diff --git a/.gitignore b/.gitignore index cf1b89d6..2b27a298 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,4 @@ dist-ssr /openvcs.plugins.local.json /.sisyphus /.omo +/Frontend/coverage diff --git a/Backend/Cargo.toml b/Backend/Cargo.toml index f752a8aa..cb0f87d4 100644 --- a/Backend/Cargo.toml +++ b/Backend/Cargo.toml @@ -28,7 +28,7 @@ default = [] [dependencies] dotenvy = "0.15" -tauri = { version = "2.11", features = [] } +tauri = { version = "2.11", features = ["test"] } tauri-plugin-opener = "2.5" serde = { version = "1", features = ["derive"] } notify = "8" diff --git a/Backend/src/app_identity.rs b/Backend/src/app_identity.rs index 7c29dda1..2ad0662d 100644 --- a/Backend/src/app_identity.rs +++ b/Backend/src/app_identity.rs @@ -4,6 +4,99 @@ //! Channel-aware desktop identity and persistence paths. use directories::ProjectDirs; +use std::path::{Path, PathBuf}; + +/// Holds the resolved project directory paths used by OpenVCS. +/// +/// This avoids depending on the `directories::ProjectDirs` type in public +/// signatures, allowing test code to inject temporary paths for isolation. +#[derive(Clone, Debug)] +pub struct AppDirs { + config_dir: PathBuf, + data_dir: PathBuf, +} + +impl AppDirs { + /// Returns the configuration directory path. + /// + /// # Returns + /// - The config directory path. + pub fn config_dir(&self) -> &Path { + &self.config_dir + } + + /// Returns the application data directory path. + /// + /// # Returns + /// - The data directory path. + pub fn data_dir(&self) -> &Path { + &self.data_dir + } +} + +#[cfg(test)] +impl AppDirs { + pub fn new(config_dir: PathBuf, data_dir: PathBuf) -> Self { + Self { + config_dir, + data_dir, + } + } +} + +// Thread-local per-test overrides; parallel tests do not conflict. +#[cfg(test)] +std::thread_local! { + static TEST_APP_DIRS: std::cell::RefCell> = const { std::cell::RefCell::new(None) }; +} + +/// Sets the test override for project directories. +/// +/// # Parameters +/// - `dirs`: The `AppDirs` to return from [`project_dirs()`] in test builds. +#[cfg(test)] +pub(crate) fn set_test_app_dirs(dirs: AppDirs) { + TEST_APP_DIRS.with(|tls| *tls.borrow_mut() = Some(dirs)); +} + +/// Clears the test override for project directories. +/// +/// After calling this, [`project_dirs()`] returns real paths again. +#[cfg(test)] +pub(crate) fn clear_test_app_dirs() { + TEST_APP_DIRS.with(|tls| *tls.borrow_mut() = None); +} + +/// Guards against accidental test writes to real config/recents. +/// +/// Called from `AppConfig::save()` and `save_recents_to_disk()` in +/// test builds. Panics with actionable guidance when no test override +/// is active so new tests cannot silently corrupt user data. +#[cfg(test)] +pub(crate) fn assert_test_isolation() { + let has_override = TEST_APP_DIRS.with(|tls| tls.borrow().is_some()); + assert!( + has_override, + "test must use AppDirsGuard before writing to config/recents paths;\n\ + add `let _guard = AppDirsGuard::new();` at the start of this test" + ); +} + +/// Installs temp directories as the app dirs override (leaked intentionally). +/// +/// Call at the top of any `build_app*` helper whose `AppState` may +/// eventually trigger `set_config()` or `set_current_repo()`. +#[cfg(test)] +pub(crate) fn setup_test_isolation() { + let dir = tempfile::tempdir().expect("temp dir for test isolation"); + let cfg_dir = dir.path().join("config"); + let data_dir = dir.path().join("data"); + // Drop dir immediately so no temp dir leaks. save() and + // save_recents_to_disk() both call create_dir_all before writing, + // so they recreate the paths on first use. + drop(dir); + set_test_app_dirs(AppDirs::new(cfg_dir, data_dir)); +} /// Returns the filesystem app name used for persistence. /// @@ -21,11 +114,22 @@ pub fn persistence_name() -> &'static str { /// All desktop channels preserve the legacy `OpenVCS` application name so /// existing users keep the same config and data roots. /// +/// In test builds, returns the injected test paths when +/// [`set_test_app_dirs`] has been called. +/// /// # Returns -/// - `Some(ProjectDirs)` when the platform exposes standard app directories. -/// - `None` when no platform-specific directories are available. -pub fn project_dirs() -> Option { - ProjectDirs::from("dev", "OpenVCS", persistence_name()) +/// - `Some(AppDirs)` when the platform exposes standard app directories (or a test override is set). +/// - `None` when no platform-specific directories are available and no override is configured. +pub fn project_dirs() -> Option { + #[cfg(test)] + if let Some(dirs) = TEST_APP_DIRS.with(|tls| tls.borrow().clone()) { + return Some(dirs); + } + + ProjectDirs::from("dev", "OpenVCS", persistence_name()).map(|pd| AppDirs { + config_dir: pd.config_dir().to_path_buf(), + data_dir: pd.data_dir().to_path_buf(), + }) } #[cfg(test)] diff --git a/Backend/src/lib.rs b/Backend/src/lib.rs index a0b06387..c0915085 100644 --- a/Backend/src/lib.rs +++ b/Backend/src/lib.rs @@ -38,12 +38,43 @@ mod utilities; mod validate; mod workarounds; +/// Builds the development `.env` path relative to the backend crate manifest. +fn local_dotenv_path() -> std::path::PathBuf { + std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../.env") +} + +/// Resolves the preferred backend from a configured default and available backend ids. +fn resolve_preferred_backend_id( + configured_default: &str, + available_backend_ids: &[BackendId], +) -> Option { + let desired = configured_default.trim(); + if !desired.is_empty() { + let desired_backend = BackendId::from(desired.to_string()); + if available_backend_ids + .iter() + .any(|backend| backend.as_ref() == desired_backend.as_ref()) + { + return Some(desired_backend); + } + } + + let mut backends = available_backend_ids.to_vec(); + backends.sort_by(|left, right| left.as_ref().cmp(right.as_ref())); + backends.into_iter().next() +} + +/// Returns the first recent repository path that still exists on disk. +fn first_existing_recent_repo(paths: &[std::path::PathBuf]) -> Option { + paths.iter().find(|path| path.exists()).cloned() +} + /// Loads `Client/.env` for local development without overwriting existing env vars. /// /// Missing .env file is silently ignored. Malformed or unreadable .env files /// are reported with context for debugging before structured logging is ready. fn load_local_dotenv() { - let dotenv_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../.env"); + let dotenv_path = local_dotenv_path(); match dotenvy::from_path(&dotenv_path) { Ok(_) => {} @@ -67,20 +98,23 @@ fn load_local_dotenv() { /// - `Some(BackendId)` when a backend is available. /// - `None` otherwise. fn preferred_vcs_backend_id(_cfg: &settings::AppConfig) -> Option { - let desired = _cfg.general.default_backend.trim().to_string(); - if !desired.is_empty() { - let desired = BackendId::from(desired); - if crate::plugin_vcs_backends::has_plugin_vcs_backend(&desired) { - return Some(desired); - } - } - - crate::plugin_vcs_backends::list_plugin_vcs_backends() + let available_backend_ids = crate::plugin_vcs_backends::list_plugin_vcs_backends() .ok() - .and_then(|mut backends| { - backends.sort_by(|a, b| a.backend_id.as_ref().cmp(b.backend_id.as_ref())); - backends.into_iter().next().map(|b| b.backend_id) + .map(|backends| { + backends + .into_iter() + .map(|backend| backend.backend_id) + .collect::>() }) + .unwrap_or_default(); + + let resolved = + resolve_preferred_backend_id(&_cfg.general.default_backend, &available_backend_ids)?; + if crate::plugin_vcs_backends::has_plugin_vcs_backend(&resolved) { + Some(resolved) + } else { + None + } } /// Attempt to reopen the most recent repository at startup if the @@ -102,7 +136,7 @@ fn try_reopen_last_repo(app_handle: &tauri::AppHandle) { } let recents = state.recents(); - if let Some(path) = recents.into_iter().find(|p| p.exists()) { + if let Some(path) = first_existing_recent_repo(&recents) { let Some(backend) = preferred_vcs_backend_id(&app_config) else { log::warn!("startup reopen: no VCS backend available"); return; @@ -452,3 +486,8 @@ fn build_invoke_handler() tauri_commands::check_for_updates, ] } + +#[cfg(test)] +mod tests { + include!("../tests/modules/lib.rs"); +} diff --git a/Backend/src/main.rs b/Backend/src/main.rs index c7b36c79..7feb3c0d 100644 --- a/Backend/src/main.rs +++ b/Backend/src/main.rs @@ -11,3 +11,13 @@ fn main() { openvcs_lib::run() } + +#[cfg(test)] +mod tests { + /// Smoke test ensuring the binary entrypoint compiles and links. + #[test] + fn main_function_exists() { + // Verify the function signature is correct by referencing it + let _ = super::main; + } +} diff --git a/Backend/src/output_log.rs b/Backend/src/output_log.rs index 26de1c76..1d63540d 100644 --- a/Backend/src/output_log.rs +++ b/Backend/src/output_log.rs @@ -5,7 +5,7 @@ use serde::{Deserialize, Serialize}; /// Severity level associated with an output log entry. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum OutputLevel { Info, diff --git a/Backend/src/plugin_runtime/host_api.rs b/Backend/src/plugin_runtime/host_api.rs index 753e35a7..10332286 100644 --- a/Backend/src/plugin_runtime/host_api.rs +++ b/Backend/src/plugin_runtime/host_api.rs @@ -3,16 +3,21 @@ //! Minimal host-side plugin runtime APIs. use parking_lot::RwLock; -use std::sync::OnceLock; +use std::sync::{Arc, OnceLock}; /// Callback type for status text updates from plugins to frontend. -type StatusEventEmitter = Box; +type StatusEventEmitter = Arc; /// Global status emitter callback used by backend->frontend bridge. -static STATUS_EVENT_EMITTER: OnceLock = OnceLock::new(); +static STATUS_EVENT_EMITTER: OnceLock>> = OnceLock::new(); /// Shared in-memory status text for plugin updates. static STATUS_TEXT: OnceLock> = OnceLock::new(); +/// Returns global status emitter storage. +fn status_event_emitter_store() -> &'static RwLock> { + STATUS_EVENT_EMITTER.get_or_init(|| RwLock::new(None)) +} + /// Returns global status storage singleton. fn status_text_store() -> &'static RwLock { STATUS_TEXT.get_or_init(|| RwLock::new(String::new())) @@ -20,7 +25,7 @@ fn status_text_store() -> &'static RwLock { /// Emits a status text event through the configured backend emitter. fn emit_status_event(message: &str) { - if let Some(emitter) = STATUS_EVENT_EMITTER.get() { + if let Some(emitter) = status_event_emitter_store().read().clone() { emitter(message); } } @@ -36,7 +41,14 @@ pub fn set_status_event_emitter(emitter: F) where F: Fn(&str) + Send + Sync + 'static, { - let _ = STATUS_EVENT_EMITTER.set(Box::new(emitter)); + *status_event_emitter_store().write() = Some(Arc::new(emitter)); +} + +/// Resets host API state between tests. +#[cfg(test)] +pub fn reset_host_api_state_for_tests() { + *status_event_emitter_store().write() = None; + status_text_store().write().clear(); } /// Sets status text without permission checks. @@ -57,3 +69,8 @@ pub fn set_status_text_unchecked(message: &str) { *status_text_store().write() = trimmed.to_string(); emit_status_event(trimmed); } + +#[cfg(test)] +mod tests { + include!("../../tests/plugin_runtime/host_api.rs"); +} diff --git a/Backend/src/plugin_runtime/node_instance/mod.rs b/Backend/src/plugin_runtime/node_instance/mod.rs index 3e822c5d..40adc3f7 100644 --- a/Backend/src/plugin_runtime/node_instance/mod.rs +++ b/Backend/src/plugin_runtime/node_instance/mod.rs @@ -32,6 +32,10 @@ use self::rpc::NodeRpcProcess; const DEFAULT_RPC_TIMEOUT_SECS: u64 = 30; const VCS_OPERATION_TIMEOUT_SECS: u64 = 60; +/// Test-only mock RPC handler type. +#[cfg(test)] +type MockRpcHandler = Box Result + Send>; + /// Parsed plugin initialize response payload. #[derive(Debug, Deserialize)] struct InitializeResponse { @@ -49,6 +53,9 @@ pub struct NodePluginRuntimeInstance { vcs_session_id: Mutex>, /// Optional sink for VCS progress events. event_sink: RwLock>, + /// Test-only RPC mock handler injected instead of a real process. + #[cfg(test)] + mock_rpc_handler: Mutex>, } impl NodePluginRuntimeInstance { @@ -65,9 +72,29 @@ impl NodePluginRuntimeInstance { process: Mutex::new(None), vcs_session_id: Mutex::new(None), event_sink: RwLock::new(None), + #[cfg(test)] + mock_rpc_handler: Mutex::new(None), } } + /// Sets the VCS session id for testing without a real plugin process. + #[cfg(test)] + pub(crate) fn set_session_id(&self, id: Option) { + *self.vcs_session_id.lock() = id; + } + + /// Injects a pre-built process for testing the real RPC call path. + #[cfg(test)] + pub(crate) fn set_process(&self, process: NodeRpcProcess) { + *self.process.lock() = Some(process); + } + + /// Installs a mock RPC handler for testing, bypassing the real process. + #[cfg(test)] + pub(crate) fn set_mock_handler(&self, handler: MockRpcHandler) { + *self.mock_rpc_handler.lock() = Some(handler); + } + /// Resolves the bundled Node executable path used to launch plugins. /// /// # Returns @@ -267,6 +294,12 @@ impl NodePluginRuntimeInstance { where T: DeserializeOwned, { + #[cfg(test)] + if let Some(handler) = self.mock_rpc_handler.lock().as_ref() { + let result = handler(method, params)?; + return serde_json::from_value(result).map_err(|e| format!("mock rpc decode: {e}")); + } + let timeout = timeout_secs.or_else(|| { if method.starts_with("vcs.") { Some(VCS_OPERATION_TIMEOUT_SECS) @@ -541,3 +574,8 @@ impl Drop for NodePluginRuntimeInstance { } } } + +#[cfg(test)] +mod tests { + include!("../../../tests/plugin_runtime/node_instance/mod.rs"); +} diff --git a/Backend/src/plugin_runtime/node_instance/rpc.rs b/Backend/src/plugin_runtime/node_instance/rpc.rs index d0306e8f..cef0955a 100644 --- a/Backend/src/plugin_runtime/node_instance/rpc.rs +++ b/Backend/src/plugin_runtime/node_instance/rpc.rs @@ -17,7 +17,7 @@ use std::sync::mpsc::{Receiver, RecvTimeoutError}; use std::time::Duration; /// Live stdio-backed JSON-RPC process handle. -pub(super) struct NodeRpcProcess { +pub(crate) struct NodeRpcProcess { /// Child process hosting the plugin runtime. pub(super) child: Child, /// Writable stdin stream for requests. @@ -166,3 +166,8 @@ fn format_rpc_error(plugin_id: &str, method: &str, error: &RpcError) -> String { plugin_id, method, error.code, detail ) } + +#[cfg(test)] +mod tests { + include!("../../../tests/plugin_runtime/node_instance/rpc.rs"); +} diff --git a/Backend/src/plugin_runtime/node_instance/vcs.rs b/Backend/src/plugin_runtime/node_instance/vcs.rs index f5805b8d..595f2681 100644 --- a/Backend/src/plugin_runtime/node_instance/vcs.rs +++ b/Backend/src/plugin_runtime/node_instance/vcs.rs @@ -308,14 +308,25 @@ impl NodePluginRuntimeInstance { } /// Calls `vcs.stash-push`. + /// + /// # Parameters + /// - `message`: Optional stash message. + /// - `include_untracked`: Whether untracked files should be included. + /// - `paths`: Optional path filters to include in the stash. + /// + /// # Returns + /// - `Ok(String)` with the created stash selector. + /// - `Err(String)` when the RPC call fails. pub fn vcs_stash_push( &self, message: Option<&str>, include_untracked: bool, + paths: &[String], ) -> Result { let params = self.session_params(json!({ "message": message, "include_untracked": include_untracked, + "paths": paths, }))?; self.rpc_call(Methods::VCS_STASH_PUSH, params) } @@ -356,3 +367,8 @@ impl NodePluginRuntimeInstance { self.rpc_call_unit(Methods::VCS_REVERT_COMMIT, params) } } + +#[cfg(test)] +mod tests { + include!("../../../tests/plugin_runtime/node_instance/vcs.rs"); +} diff --git a/Backend/src/plugin_runtime/protocol.rs b/Backend/src/plugin_runtime/protocol.rs index d2b1c60e..99b2b8a5 100644 --- a/Backend/src/plugin_runtime/protocol.rs +++ b/Backend/src/plugin_runtime/protocol.rs @@ -131,7 +131,7 @@ impl Methods { pub const VCS_SET_IDENTITY_LOCAL: &'static str = "vcs.set_identity_local"; /// Lists stashes. pub const VCS_LIST_STASHES: &'static str = "vcs.list_stashes"; - /// Pushes stash. + /// Pushes stash with optional message, include-untracked flag, and path filters. pub const VCS_STASH_PUSH: &'static str = "vcs.stash_push"; /// Applies stash. pub const VCS_STASH_APPLY: &'static str = "vcs.stash_apply"; diff --git a/Backend/src/plugin_runtime/settings_store.rs b/Backend/src/plugin_runtime/settings_store.rs index 605e0455..01cca6db 100644 --- a/Backend/src/plugin_runtime/settings_store.rs +++ b/Backend/src/plugin_runtime/settings_store.rs @@ -7,8 +7,47 @@ use serde_json::{Map, Value}; use std::fs; use std::path::{Path, PathBuf}; +#[cfg(test)] +use parking_lot::RwLock; + +#[cfg(test)] +use std::sync::OnceLock; + +#[cfg(test)] +static TEST_PLUGIN_DATA_ROOT: OnceLock>> = OnceLock::new(); + +/// Returns the test-only plugin data root override, when configured. +#[cfg(test)] +fn test_plugin_data_root() -> Option { + TEST_PLUGIN_DATA_ROOT + .get_or_init(|| RwLock::new(None)) + .read() + .clone() +} + +/// Sets the test-only plugin data root override. +#[cfg(test)] +pub(crate) fn set_test_plugin_data_root(root: PathBuf) { + *TEST_PLUGIN_DATA_ROOT + .get_or_init(|| RwLock::new(None)) + .write() = Some(root); +} + +/// Clears the test-only plugin data root override. +#[cfg(test)] +pub(crate) fn clear_test_plugin_data_root() { + *TEST_PLUGIN_DATA_ROOT + .get_or_init(|| RwLock::new(None)) + .write() = None; +} + /// Returns the root plugin data directory under the app config directory. fn plugin_data_root() -> PathBuf { + #[cfg(test)] + if let Some(root) = test_plugin_data_root() { + return root; + } + if let Some(pd) = crate::app_identity::project_dirs() { pd.config_dir().join("plugin-data") } else { @@ -84,3 +123,8 @@ fn remove_file_if_exists(path: &Path) -> Result<(), String> { } Ok(()) } + +#[cfg(test)] +mod tests { + include!("../../tests/plugin_runtime/settings_store.rs"); +} diff --git a/Backend/src/plugin_runtime/vcs_proxy.rs b/Backend/src/plugin_runtime/vcs_proxy.rs index b1750818..98ad4f6f 100644 --- a/Backend/src/plugin_runtime/vcs_proxy.rs +++ b/Backend/src/plugin_runtime/vcs_proxy.rs @@ -349,16 +349,19 @@ impl Vcs for PluginVcsProxy { &self, message: &str, include_untracked: bool, - _paths: &[PathBuf], + paths: &[PathBuf], ) -> VcsResult<()> { let message = if message.trim().is_empty() { None } else { Some(message) }; - let _ = self - .runtime - .vcs_stash_push(message, include_untracked) + let paths = paths + .iter() + .map(|path| path.to_string_lossy().to_string()) + .collect::>(); + self.runtime + .vcs_stash_push(message, include_untracked, &paths) .map_err(|e| self.map_runtime_error(e))?; Ok(()) } @@ -418,3 +421,8 @@ fn path_to_utf8(path: &Path) -> Result { msg: format!("non-utf8 path: {}", path.display()), }) } + +#[cfg(test)] +mod tests { + include!("../../tests/plugin_runtime/vcs_proxy.rs"); +} diff --git a/Backend/src/plugin_vcs_backends.rs b/Backend/src/plugin_vcs_backends.rs index 5d92e0e9..a0790f2d 100644 --- a/Backend/src/plugin_vcs_backends.rs +++ b/Backend/src/plugin_vcs_backends.rs @@ -23,14 +23,30 @@ fn backend_cache() -> &'static RwLock>> { BACKEND_CACHE.get_or_init(|| RwLock::new(None)) } +// Thread-local per-test override so parallel tests do not +// contend over the shared BACKEND_CACHE global. +#[cfg(test)] +std::thread_local! { + static TEST_BACKEND_CACHE: std::cell::RefCell>> = + const { std::cell::RefCell::new(None) }; +} + fn cached_backends() -> Option> { + #[cfg(test)] + if let Some(cached) = TEST_BACKEND_CACHE.with(|tls| tls.borrow().clone()) { + return Some(cached); + } + backend_cache() .read() .unwrap_or_else(|poisoned| poisoned.into_inner()) .clone() } -fn store_backends(backends: Vec) { +pub(crate) fn store_backends(backends: Vec) { + #[cfg(test)] + TEST_BACKEND_CACHE.with(|tls| *tls.borrow_mut() = Some(backends.clone())); + *backend_cache() .write() .unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(backends); @@ -38,6 +54,9 @@ fn store_backends(backends: Vec) { /// Clears cached VCS backend discovery results. pub fn invalidate_plugin_vcs_backend_cache() { + #[cfg(test)] + TEST_BACKEND_CACHE.with(|tls| *tls.borrow_mut() = None); + *backend_cache() .write() .unwrap_or_else(|poisoned| poisoned.into_inner()) = None; @@ -393,3 +412,8 @@ pub fn clone_repo_via_plugin_vcs_backend( msg: e, }) } + +#[cfg(test)] +mod tests { + include!("../tests/modules/plugin_vcs_backends.rs"); +} diff --git a/Backend/src/process_utils.rs b/Backend/src/process_utils.rs index 91fab206..8c35bc70 100644 --- a/Backend/src/process_utils.rs +++ b/Backend/src/process_utils.rs @@ -27,3 +27,8 @@ pub(crate) fn hidden_command(program: &str) -> Command { hide_window(&mut command); command } + +#[cfg(test)] +mod tests { + include!("../tests/modules/process_utils.rs"); +} diff --git a/Backend/src/settings/persistence.rs b/Backend/src/settings/persistence.rs index 9d4cc445..50edd69f 100644 --- a/Backend/src/settings/persistence.rs +++ b/Backend/src/settings/persistence.rs @@ -42,6 +42,8 @@ impl AppConfig { /// - `Ok(())` when the config file was written successfully. /// - `Err(io::Error)` when writing or renaming fails. pub fn save(&self) -> io::Result<()> { + #[cfg(test)] + crate::app_identity::assert_test_isolation(); let p = Self::path(); if let Some(parent) = p.parent() { fs::create_dir_all(parent)?; @@ -181,3 +183,8 @@ impl AppConfig { self.logging.retain_archives = self.logging.retain_archives.clamp(1, 100); } } + +#[cfg(test)] +mod tests { + include!("../../tests/modules/settings_persistence.rs"); +} diff --git a/Backend/src/state.rs b/Backend/src/state.rs index 9e53acb9..f1de1db0 100644 --- a/Backend/src/state.rs +++ b/Backend/src/state.rs @@ -290,6 +290,8 @@ fn load_recents_from_disk() -> Result, String> { /// - `Ok(())` on success. /// - `Err(String)` on serialization/write failures. fn save_recents_to_disk(list: &[PathBuf]) -> Result<(), String> { + #[cfg(test)] + crate::app_identity::assert_test_isolation(); let p = recents_file_path(); if let Some(parent) = p.parent() { fs::create_dir_all(parent).map_err(|e| e.to_string())?; diff --git a/Backend/src/tauri_commands/backends.rs b/Backend/src/tauri_commands/backends.rs index ffc4edc9..b4aab102 100644 --- a/Backend/src/tauri_commands/backends.rs +++ b/Backend/src/tauri_commands/backends.rs @@ -13,6 +13,39 @@ use crate::plugin_vcs_backends; use crate::repo::Repo; use crate::state::AppState; +/// Resolves the display label shown for a backend entry. +fn backend_display_label( + backend_name: Option<&str>, + plugin_name: Option<&str>, + backend_id: &BackendId, +) -> String { + backend_name + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .or_else(|| { + plugin_name + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + }) + .unwrap_or_else(|| backend_id.as_ref().to_string()) +} + +/// Returns the sole backend id that should become the default when exactly one backend exists. +fn auto_default_backend_id(current_default: &str, backends: &[(String, String)]) -> Option { + if backends.len() != 1 { + return None; + } + + let only_backend_id = backends[0].0.trim(); + if only_backend_id.is_empty() || current_default.trim() == only_backend_id { + None + } else { + Some(only_backend_id.to_string()) + } +} + #[tauri::command] /// Lists VCS backends currently available from plugins. /// @@ -28,11 +61,11 @@ pub fn list_vcs_backends_cmd(state: State<'_, AppState>) -> Vec<(String, String) if let Ok(plugin_bes) = plugin_vcs_backends::list_plugin_vcs_backends() { for p in plugin_bes { - let label = p - .backend_name - .clone() - .or_else(|| p.plugin_name.clone()) - .unwrap_or_else(|| p.backend_id.as_ref().to_string()); + let label = backend_display_label( + p.backend_name.as_deref(), + p.plugin_name.as_deref(), + &p.backend_id, + ); // Prefer plugin-provided VCS backends when IDs overlap. map.insert(p.backend_id.as_ref().to_string(), label); } @@ -40,22 +73,21 @@ pub fn list_vcs_backends_cmd(state: State<'_, AppState>) -> Vec<(String, String) let backends: Vec<(String, String)> = map.into_iter().collect(); - if backends.len() == 1 { - let only_backend_id = backends[0].0.as_str(); + if let Some(only_backend_id) = + auto_default_backend_id(&state.config().general.default_backend, &backends) + { let mut cfg = state.config(); - if cfg.general.default_backend.trim() != only_backend_id { - cfg.general.default_backend = only_backend_id.to_string(); - if let Err(err) = state.set_config(cfg) { - warn!( - "list_vcs_backends_cmd: failed to persist auto default backend `{}`: {}", - only_backend_id, err - ); - } else { - info!( - "list_vcs_backends_cmd: auto-selected sole backend `{}` as default", - only_backend_id - ); - } + cfg.general.default_backend = only_backend_id.clone(); + if let Err(err) = state.set_config(cfg) { + warn!( + "list_vcs_backends_cmd: failed to persist auto default backend `{}`: {}", + only_backend_id, err + ); + } else { + info!( + "list_vcs_backends_cmd: auto-selected sole backend `{}` as default", + only_backend_id + ); } } @@ -195,3 +227,8 @@ pub async fn reopen_current_repo_cmd(state: State<'_, AppState>) -> Result<(), S state.set_current_repo(new_repo); Ok(()) } + +#[cfg(test)] +mod tests { + include!("../../tests/tauri_commands/backends.rs"); +} diff --git a/Backend/src/tauri_commands/branches.rs b/Backend/src/tauri_commands/branches.rs index deec644a..0880276f 100644 --- a/Backend/src/tauri_commands/branches.rs +++ b/Backend/src/tauri_commands/branches.rs @@ -648,3 +648,8 @@ pub async fn vcs_current_branch(state: State<'_, AppState>) -> Result String { + if description.trim().is_empty() { + summary.to_string() + } else { + format!("{summary}\n\n{description}") + } +} + +fn has_commit_selection(patch: &str, files_len: usize, stage_paths_len: usize) -> bool { + !patch.trim().is_empty() || files_len > 0 || stage_paths_len > 0 +} + +fn trimmed_non_empty(value: &str, error: &str) -> Result { + let value = value.trim().to_string(); + if value.is_empty() { + Err(error.to_string()) + } else { + Ok(value) + } +} + /// Resolves the repository commit identity from VCS config. /// /// # Parameters @@ -54,11 +75,7 @@ pub async fn commit_changes( let repo = repo.clone(); let app = window.app_handle().clone(); - let message = if description.trim().is_empty() { - summary.clone() - } else { - format!("{summary}\n\n{description}") - }; + let message = build_commit_message(&summary, &description); async_runtime::spawn_blocking(move || { let on = progress_bridge(app); @@ -122,11 +139,7 @@ pub async fn commit_selected( let repo = repo.clone(); let app = window.app_handle().clone(); - let message = if description.trim().is_empty() { - summary.clone() - } else { - format!("{summary}\n\n{description}") - }; + let message = build_commit_message(&summary, &description); async_runtime::spawn_blocking(move || { let on = progress_bridge(app); @@ -185,11 +198,7 @@ pub async fn commit_patch( let repo = repo.clone(); let app = window.app_handle().clone(); - let message = if description.trim().is_empty() { - summary.clone() - } else { - format!("{summary}\n\n{description}") - }; + let message = build_commit_message(&summary, &description); async_runtime::spawn_blocking(move || { let on = progress_bridge(app); @@ -256,11 +265,7 @@ pub async fn commit_patch_and_files( let repo = repo.clone(); let app = window.app_handle().clone(); - let message = if description.trim().is_empty() { - summary.clone() - } else { - format!("{summary}\n\n{description}") - }; + let message = build_commit_message(&summary, &description); async_runtime::spawn_blocking(move || { let on = progress_bridge(app); @@ -290,8 +295,7 @@ pub async fn commit_patch_and_files( e.to_string() })?; } - let has_selection = - !patch.trim().is_empty() || !files.is_empty() || !stage_paths.is_empty(); + let has_selection = has_commit_selection(&patch, files.len(), stage_paths.len()); if !has_selection { return Err("No commit paths provided".into()); } @@ -335,14 +339,8 @@ pub async fn vcs_cherry_pick_to_branch( let repo = current_repo_or_err(&state)?; let app = window.app_handle().clone(); run_repo_task("vcs_cherry_pick_to_branch", repo, move |repo| { - let id = id.trim().to_string(); - let branch = branch.trim().to_string(); - if id.is_empty() { - return Err("Commit id cannot be empty".into()); - } - if branch.is_empty() { - return Err("Target branch cannot be empty".into()); - } + let id = trimmed_non_empty(&id, "Commit id cannot be empty")?; + let branch = trimmed_non_empty(&branch, "Target branch cannot be empty")?; let on = progress_bridge(app); on(VcsEvent::Progress { @@ -388,10 +386,7 @@ pub async fn vcs_revert_commit( let repo = current_repo_or_err(&state)?; let app = window.app_handle().clone(); run_repo_task("vcs_revert_commit", repo, move |repo| { - let id = id.trim().to_string(); - if id.is_empty() { - return Err("Commit id cannot be empty".into()); - } + let id = trimmed_non_empty(&id, "Commit id cannot be empty")?; let on = progress_bridge(app); on(VcsEvent::Progress { @@ -408,3 +403,8 @@ pub async fn vcs_revert_commit( }) .await } + +#[cfg(test)] +mod tests { + include!("../../tests/tauri_commands/commit.rs"); +} diff --git a/Backend/src/tauri_commands/conflicts.rs b/Backend/src/tauri_commands/conflicts.rs index 5aed5bd5..b29224bd 100644 --- a/Backend/src/tauri_commands/conflicts.rs +++ b/Backend/src/tauri_commands/conflicts.rs @@ -59,6 +59,11 @@ pub async fn vcs_conflict_details( result } +#[cfg(test)] +mod tests { + include!("../../tests/tauri_commands/conflicts.rs"); +} + #[tauri::command] /// Resolves a conflict file by checking out `ours` or `theirs`. /// diff --git a/Backend/src/tauri_commands/general.rs b/Backend/src/tauri_commands/general.rs index c3375c05..3a9afd79 100644 --- a/Backend/src/tauri_commands/general.rs +++ b/Backend/src/tauri_commands/general.rs @@ -20,6 +20,43 @@ use super::progress_bridge; const WIKI_URL: &str = "https://github.com/jordonbc/OpenVCS/wiki"; +/// Resolves the title shown by the directory picker for a given browse purpose. +fn browse_directory_title(purpose: Option<&str>) -> &'static str { + match purpose { + Some("clone_dest") => "Choose destination folder", + Some("add_repo") => "Select an existing repository folder", + _ => "Select a folder", + } +} + +/// Resolves the preferred default backend from configured and available backend ids. +fn resolve_default_backend_id( + configured_default: &str, + available_backend_ids: &[BackendId], +) -> Option { + let desired = configured_default.trim(); + if !desired.is_empty() { + let desired_backend = BackendId::from(desired.to_string()); + if available_backend_ids + .iter() + .any(|backend| backend.as_ref() == desired_backend.as_ref()) + { + return Some(desired_backend); + } + } + + let mut backends = available_backend_ids.to_vec(); + backends.sort_by(|left, right| left.as_ref().cmp(right.as_ref())); + backends.into_iter().next() +} + +/// Extracts the display name used for a recent repository entry. +fn recent_repo_name(path: &Path) -> Option { + path.file_name() + .and_then(|segment| segment.to_str()) + .map(|segment| segment.to_string()) +} + #[derive(serde::Serialize)] /// Event payload emitted after selecting/opening a repository. struct RepoSelectedPayload { @@ -62,11 +99,7 @@ pub async fn browse_directory( window: Window, purpose: Option, ) -> Option { - let title = match purpose.as_deref() { - Some("clone_dest") => "Choose destination folder", - Some("add_repo") => "Select an existing repository folder", - _ => "Select a folder", - }; + let title = browse_directory_title(purpose.as_deref()); utilities::browse_directory_async(window.app_handle().clone(), title).await } @@ -124,19 +157,11 @@ pub async fn add_repo( fn default_backend_id(state: &AppState) -> Option { let mut backends = crate::plugin_vcs_backends::list_plugin_vcs_backends().ok()?; backends.sort_by(|a, b| a.backend_id.as_ref().cmp(b.backend_id.as_ref())); - - let desired = state.config().general.default_backend.trim().to_string(); - if !desired.is_empty() { - let desired = BackendId::from(desired); - if backends - .iter() - .any(|backend| backend.backend_id.as_ref() == desired.as_ref()) - { - return Some(desired); - } - } - - backends.into_iter().next().map(|b| b.backend_id) + let available = backends + .into_iter() + .map(|backend| backend.backend_id) + .collect::>(); + resolve_default_backend_id(&state.config().general.default_backend, &available) } /// Internal helper that opens a repository and publishes `repo:selected`. @@ -343,10 +368,7 @@ pub fn list_recent_repos(state: State<'_, AppState>) -> Vec { .recents() .into_iter() .map(|p| { - let name = p - .file_name() - .and_then(|os| os.to_str()) - .map(|s| s.to_string()); + let name = recent_repo_name(&p); RecentRepoDto { path: p.to_string_lossy().to_string(), name, @@ -490,3 +512,8 @@ fn infer_repo_dir_from_url(url: &str) -> String { let last = trimmed.rsplit('/').next().unwrap_or(trimmed); last.trim_end_matches(".git").to_string() } + +#[cfg(test)] +mod tests { + include!("../../tests/tauri_commands/general.rs"); +} diff --git a/Backend/src/tauri_commands/monitoring.rs b/Backend/src/tauri_commands/monitoring.rs index a7371dff..3357c109 100644 --- a/Backend/src/tauri_commands/monitoring.rs +++ b/Backend/src/tauri_commands/monitoring.rs @@ -15,3 +15,8 @@ pub fn report_frontend_error(payload: FrontendErrorReport) -> Result<(), String> crate::monitoring::capture_frontend_error(payload); Ok(()) } + +#[cfg(test)] +mod tests { + include!("../../tests/tauri_commands/monitoring.rs"); +} diff --git a/Backend/src/tauri_commands/output_log.rs b/Backend/src/tauri_commands/output_log.rs index caebf746..01df9204 100644 --- a/Backend/src/tauri_commands/output_log.rs +++ b/Backend/src/tauri_commands/output_log.rs @@ -194,3 +194,8 @@ pub fn open_output_log_window(window: Window) -> Result<(), Strin Ok(()) } + +#[cfg(test)] +mod tests { + include!("../../tests/tauri_commands/output_log.rs"); +} diff --git a/Backend/src/tauri_commands/plugins.rs b/Backend/src/tauri_commands/plugins.rs index b888602a..5d58be01 100644 --- a/Backend/src/tauri_commands/plugins.rs +++ b/Backend/src/tauri_commands/plugins.rs @@ -709,3 +709,8 @@ fn setting_value_to_json(value: &SettingValue) -> Value { SettingValue::String(v) => Value::String(v.clone()), } } + +#[cfg(test)] +mod tests { + include!("../../tests/tauri_commands/plugins.rs"); +} diff --git a/Backend/src/tauri_commands/remotes.rs b/Backend/src/tauri_commands/remotes.rs index 0f156446..a29339c6 100644 --- a/Backend/src/tauri_commands/remotes.rs +++ b/Backend/src/tauri_commands/remotes.rs @@ -376,6 +376,11 @@ pub async fn vcs_fetch_all( .await } +#[cfg(test)] +mod tests { + include!("../../tests/tauri_commands/remotes.rs"); +} + #[tauri::command] /// Performs a fast-forward-only pull from the current branch upstream. /// @@ -633,11 +638,6 @@ pub async fn vcs_undo_since_push( .await } -#[cfg(test)] -mod tests { - include!("../../tests/tauri_commands/remotes.rs"); -} - #[tauri::command] /// Soft-resets HEAD to a selected commit, constrained to ahead-of-upstream history. /// diff --git a/Backend/src/tauri_commands/repo_files.rs b/Backend/src/tauri_commands/repo_files.rs index 0fc49e05..9dc5dc81 100644 --- a/Backend/src/tauri_commands/repo_files.rs +++ b/Backend/src/tauri_commands/repo_files.rs @@ -230,3 +230,8 @@ pub fn read_repo_file_text(state: State<'_, AppState>, path: String) -> Result, cfg: RepoConfig) -> R info!("settings: repository config updated"); Ok(()) } + +#[cfg(test)] +mod tests { + include!("../../tests/tauri_commands/settings.rs"); +} diff --git a/Backend/src/tauri_commands/shared.rs b/Backend/src/tauri_commands/shared.rs index babc1176..b03e9663 100644 --- a/Backend/src/tauri_commands/shared.rs +++ b/Backend/src/tauri_commands/shared.rs @@ -11,6 +11,38 @@ use crate::plugin_vcs_backends; use crate::repo::Repo; use crate::state::AppState; +/// Converts a backend task label and error into a consistent user-facing message. +fn format_task_failure(label: &'static str, error: &str) -> String { + format!("{label} task failed: {error}") +} + +/// Converts a VCS event into the output-log level and message sent to the UI. +fn progress_message_for_event(evt: VcsEvent) -> (OutputLevel, String) { + match evt { + VcsEvent::Progress { detail, .. } => (OutputLevel::Info, detail), + VcsEvent::RemoteMessage { msg } => (OutputLevel::Info, msg), + VcsEvent::Auth { method, detail } => { + (OutputLevel::Info, format!("auth[{method}]: {detail}")) + } + VcsEvent::PushStatus { refname, status } => ( + OutputLevel::Info, + status + .map(|value| format!("{refname} → {value}")) + .unwrap_or_else(|| format!("{refname} ok")), + ), + VcsEvent::Info { msg } => (OutputLevel::Info, msg), + VcsEvent::Warning { msg } => (OutputLevel::Warn, msg), + VcsEvent::Error { msg } => (OutputLevel::Error, msg), + } +} + +/// Returns the message shown when the active backend disappears during an operation. +fn backend_unavailable_message(backend_id: &str) -> String { + format!( + "Backend `{backend_id}` is no longer available (plugin disabled?). Reopen the repository." + ) +} + #[derive(serde::Serialize, Clone)] /// Generic progress event payload sent to the UI. pub struct ProgressPayload { @@ -27,22 +59,7 @@ pub struct ProgressPayload { /// - An [`OnEvent`] callback compatible with backend VCS operations. pub(crate) fn progress_bridge(app: AppHandle) -> OnEvent { Arc::new(move |evt| { - let (level, msg) = match evt { - VcsEvent::Progress { detail, .. } => (OutputLevel::Info, detail), - VcsEvent::RemoteMessage { msg } => (OutputLevel::Info, msg), - VcsEvent::Auth { method, detail } => { - (OutputLevel::Info, format!("auth[{method}]: {detail}")) - } - VcsEvent::PushStatus { refname, status } => ( - OutputLevel::Info, - status - .map(|s| format!("{refname} → {s}")) - .unwrap_or_else(|| format!("{refname} ok")), - ), - VcsEvent::Info { msg } => (OutputLevel::Info, msg), - VcsEvent::Warning { msg } => (OutputLevel::Warn, msg), - VcsEvent::Error { msg } => (OutputLevel::Error, msg), - }; + let (level, msg) = progress_message_for_event(evt); let ts_ms = time::OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000_000; let entry = OutputLogEntry::new(ts_ms as i64, level, "vcs", msg.clone()); @@ -83,10 +100,7 @@ pub(crate) fn current_repo_or_err(state: &State<'_, AppState>) -> Result>> = OnceLock::new(); + +#[cfg(test)] +fn set_test_home_dir(dir: PathBuf) { + *TEST_HOME_DIR.get_or_init(|| RwLock::new(None)).write() = Some(dir); +} + +#[cfg(test)] +fn clear_test_home_dir() { + *TEST_HOME_DIR.get_or_init(|| RwLock::new(None)).write() = None; +} + +fn home_dir_for_paths() -> Option { + #[cfg(test)] + if let Some(dir) = TEST_HOME_DIR + .get_or_init(|| RwLock::new(None)) + .read() + .clone() + { + return Some(dir); + } + + dirs::home_dir() +} + /// Returns `~/.ssh/known_hosts` path. /// /// # Returns /// - `Ok(PathBuf)` known-hosts path. /// - `Err(String)` when home directory cannot be resolved. fn known_hosts_path() -> Result { - let home = dirs::home_dir().ok_or_else(|| { + let home = home_dir_for_paths().ok_or_else(|| { error!("known_hosts_path: could not determine home directory",); "Could not determine home directory".to_string() })?; @@ -31,7 +63,7 @@ fn known_hosts_path() -> Result { /// - `Ok(PathBuf)` ssh directory path. /// - `Err(String)` when home directory cannot be resolved. fn ssh_dir_path() -> Result { - let home = dirs::home_dir().ok_or_else(|| { + let home = home_dir_for_paths().ok_or_else(|| { error!("ssh_dir_path: could not determine home directory",); "Could not determine home directory".to_string() })?; @@ -46,7 +78,7 @@ fn ssh_dir_path() -> Result { /// - `Ok(PathBuf)` created/existing ssh directory path. /// - `Err(String)` on resolution or create failure. fn ensure_ssh_dir() -> Result { - let home = dirs::home_dir().ok_or_else(|| { + let home = home_dir_for_paths().ok_or_else(|| { error!("ensure_ssh_dir: could not determine home directory",); "Could not determine home directory".to_string() })?; @@ -59,7 +91,7 @@ fn ensure_ssh_dir() -> Result { Ok(dir) } -#[derive(Clone, Serialize)] +#[derive(Clone, Debug, PartialEq, Serialize)] /// Process output captured from SSH-related shell commands. pub struct SshCommandOutput { /// Process exit code, or `-1` when unavailable. @@ -112,6 +144,11 @@ fn run_command(cmd: &str, args: &[&str]) -> Result { Ok(result) } +#[cfg(test)] +mod tests { + include!("../../tests/tauri_commands/ssh.rs"); +} + fn is_executable(path: &Path) -> bool { let Ok(metadata) = fs::metadata(path) else { return false; @@ -329,8 +366,11 @@ pub struct SshKeyCandidate { pub fn ssh_key_candidates() -> Result, String> { info!("ssh_key_candidates: scanning for SSH key candidates",); let dir = ssh_dir_path()?; + ssh_key_candidates_in_dir(&dir) +} - let Ok(read_dir) = fs::read_dir(&dir) else { +fn ssh_key_candidates_in_dir(dir: &Path) -> Result, String> { + let Ok(read_dir) = fs::read_dir(dir) else { debug!("ssh_key_candidates: ssh directory does not exist or is not readable",); return Ok(vec![]); }; diff --git a/Backend/src/tauri_commands/stash.rs b/Backend/src/tauri_commands/stash.rs index 28aad085..53bf9ffd 100644 --- a/Backend/src/tauri_commands/stash.rs +++ b/Backend/src/tauri_commands/stash.rs @@ -10,6 +10,30 @@ use crate::state::AppState; use super::{current_repo_or_err, run_repo_task}; +/// Returns the stash message that should be used for a new stash entry. +fn stash_message_or_default(message: Option) -> String { + message.unwrap_or_else(|| "WIP".to_string()) +} + +/// Returns whether untracked files should be included in the stash command. +fn include_untracked_or_default(include_untracked: Option) -> bool { + include_untracked.unwrap_or(true) +} + +/// Normalizes optional stash path filters into owned path buffers. +fn stash_paths(paths: Option>) -> Vec { + paths + .unwrap_or_default() + .into_iter() + .map(PathBuf::from) + .collect() +} + +/// Normalizes optional stash selectors to the backend's empty-string convention. +fn stash_selector_or_default(selector: Option) -> String { + selector.unwrap_or_default() +} + #[tauri::command] /// Lists stash entries for the current repository. /// @@ -61,13 +85,9 @@ pub async fn vcs_stash_push( paths: Option>, ) -> Result<(), String> { let repo = current_repo_or_err(&state)?; - let msg = message.unwrap_or_else(|| "WIP".to_string()); - let iu = include_untracked.unwrap_or(true); - let pathbufs: Vec = paths - .unwrap_or_default() - .into_iter() - .map(PathBuf::from) - .collect(); + let msg = stash_message_or_default(message); + let iu = include_untracked_or_default(include_untracked); + let pathbufs = stash_paths(paths); run_repo_task("vcs_stash_push", repo, move |repo| { repo.inner() .stash_push(&msg, iu, &pathbufs) @@ -91,7 +111,7 @@ pub async fn vcs_stash_apply( selector: Option, ) -> Result<(), String> { let repo = current_repo_or_err(&state)?; - let selector = selector.unwrap_or_default(); + let selector = stash_selector_or_default(selector); run_repo_task("vcs_stash_apply", repo, move |repo| { repo.inner() .stash_apply(selector.as_str()) @@ -115,7 +135,7 @@ pub async fn vcs_stash_pop( selector: Option, ) -> Result<(), String> { let repo = current_repo_or_err(&state)?; - let selector = selector.unwrap_or_default(); + let selector = stash_selector_or_default(selector); run_repo_task("vcs_stash_pop", repo, move |repo| { repo.inner() .stash_pop(selector.as_str()) @@ -139,7 +159,7 @@ pub async fn vcs_stash_drop( selector: Option, ) -> Result<(), String> { let repo = current_repo_or_err(&state)?; - let selector = selector.unwrap_or_default(); + let selector = stash_selector_or_default(selector); run_repo_task("vcs_stash_drop", repo, move |repo| { info!("vcs_stash_drop: selector='{}'", selector); match repo.inner().stash_drop(selector.as_str()) { @@ -171,7 +191,7 @@ pub async fn vcs_stash_show( selector: Option, ) -> Result, String> { let repo = current_repo_or_err(&state)?; - let selector = selector.unwrap_or_default(); + let selector = stash_selector_or_default(selector); run_repo_task("vcs_stash_show", repo, move |repo| { repo.inner() .stash_show(selector.as_str()) @@ -179,3 +199,8 @@ pub async fn vcs_stash_show( }) .await } + +#[cfg(test)] +mod tests { + include!("../../tests/tauri_commands/stash.rs"); +} diff --git a/Backend/src/tauri_commands/themes.rs b/Backend/src/tauri_commands/themes.rs index 2d85fe5e..5edf8ece 100644 --- a/Backend/src/tauri_commands/themes.rs +++ b/Backend/src/tauri_commands/themes.rs @@ -4,6 +4,29 @@ use crate::{plugins, settings, state::AppState, themes}; use std::collections::HashSet; use tauri::State; +/// Normalizes an optional plugin id into the lowercase identifier used by theme filtering. +fn normalize_plugin_id(plugin_id: Option<&str>) -> String { + plugin_id + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(|value| value.to_ascii_lowercase()) + .unwrap_or_default() +} + +/// Returns whether a theme should be visible for the current enabled-plugin set. +fn theme_allowed_for_enabled_plugins( + source: &themes::ThemeSource, + plugin_id: Option<&str>, + enabled_plugins: &HashSet, +) -> bool { + if !matches!(source, themes::ThemeSource::Plugin) { + return true; + } + + let plugin_id = normalize_plugin_id(plugin_id); + !plugin_id.is_empty() && enabled_plugins.contains(&plugin_id) +} + /// Computes set of enabled plugin ids based on settings and plugin defaults. /// /// # Parameters @@ -39,16 +62,8 @@ pub fn list_themes(state: State<'_, AppState>) -> Vec { themes::list_themes() .into_iter() - .filter(|t| { - if !matches!(t.source, themes::ThemeSource::Plugin) { - return true; - } - let plugin_id = t - .plugin_id - .as_ref() - .map(|s| s.trim().to_ascii_lowercase()) - .unwrap_or_default(); - !plugin_id.is_empty() && enabled.contains(&plugin_id) + .filter(|theme| { + theme_allowed_for_enabled_plugins(&theme.source, theme.plugin_id.as_deref(), &enabled) }) .collect() } @@ -68,14 +83,13 @@ pub fn load_theme(state: State<'_, AppState>, id: String) -> Result, id: String) -> Result, } +/// Builds an empty updater response when no update is available. +fn no_update_status() -> UpdateStatus { + UpdateStatus { + available: false, + version: None, + current_version: None, + body: None, + date: None, + } +} + +/// Builds a serializable updater payload from resolved update fields. +fn available_update_status( + version: String, + current_version: String, + body: Option, + date: Option, +) -> UpdateStatus { + UpdateStatus { + available: true, + version: Some(version), + current_version: Some(current_version), + body, + date, + } +} + +/// Calculates integer download progress percentages while guarding zero totals. +fn download_progress_percent(received: u64, total: u64) -> u32 { + if total > 0 { + (received as f64 / total as f64 * 100.0) as u32 + } else { + 0 + } +} + +/// Builds the updater progress payload emitted to the frontend. +fn progress_payload(received: u64, total: u64) -> serde_json::Value { + serde_json::json!({ + "kind": "progress", + "received": received, + "total": total, + }) +} + #[tauri::command] /// Checks for available updates and returns detailed status. /// @@ -34,26 +79,19 @@ pub async fn get_update_status(window: Window) -> Result { let date_str = update.date.map(|d| d.to_string()); - let status = UpdateStatus { - available: true, - version: Some(update.version.clone()), - current_version: Some(update.current_version.clone()), - body: update.body.clone(), - date: date_str, - }; + let status = available_update_status( + update.version.clone(), + update.current_version.clone(), + update.body.clone(), + date_str, + ); debug!( "get_update_status: update available: {} -> {}", update.current_version, update.version ); Ok(status) } - Ok(None) => Ok(UpdateStatus { - available: false, - version: None, - current_version: None, - body: None, - date: None, - }), + Ok(None) => Ok(no_update_status()), Err(e) => { error!("get_update_status: check failed: {}", e); Err(e.to_string()) @@ -107,20 +145,12 @@ pub async fn updater_install_now(window: Window) -> Result<(), St .download_and_install( |received, total| { let total_val = total.unwrap_or(0); - let percent = if total_val > 0 { - (received as f64 / total_val as f64 * 100.0) as u32 - } else { - 0 - }; + let percent = download_progress_percent(received as u64, total_val); trace!( "updater_install_now: download progress {}/{} bytes ({}%)", received, total_val, percent ); - let payload = serde_json::json!({ - "kind": "progress", - "received": received, - "total": total_val - }); + let payload = progress_payload(received as u64, total_val); if let Err(e) = app2.emit("update:progress", payload) { log::warn!("updater_install_now: failed to emit progress: {e}"); } @@ -162,3 +192,8 @@ pub async fn updater_install_now(window: Window) -> Result<(), St } } } + +#[cfg(test)] +mod tests { + include!("../../tests/tauri_commands/updater.rs"); +} diff --git a/Backend/src/themes.rs b/Backend/src/themes.rs index d49cd65b..89f06162 100644 --- a/Backend/src/themes.rs +++ b/Backend/src/themes.rs @@ -11,7 +11,7 @@ const MANIFEST_NAME: &str = "theme.json"; pub const DEFAULT_THEME_ID: &str = "default"; #[allow(dead_code)] -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] pub enum ThemeSource { BuiltIn, @@ -19,7 +19,7 @@ pub enum ThemeSource { Plugin, } -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct ThemeSummary { pub id: String, pub name: String, diff --git a/Backend/src/utilities/inner.rs b/Backend/src/utilities/inner.rs index ef5287f4..befbe022 100644 --- a/Backend/src/utilities/inner.rs +++ b/Backend/src/utilities/inner.rs @@ -1,8 +1,8 @@ // Copyright © 2025-2026 OpenVCS Contributors // SPDX-License-Identifier: GPL-3.0-or-later -use serde::Serialize; +use serde::{Deserialize, Serialize}; -#[derive(Serialize)] +#[derive(Serialize, Deserialize)] pub struct AboutInfo { pub name: String, pub version: String, diff --git a/Backend/tests/core/mod.rs b/Backend/tests/core/mod.rs index 9fcf13fd..2692c6a8 100644 --- a/Backend/tests/core/mod.rs +++ b/Backend/tests/core/mod.rs @@ -1,15 +1,302 @@ // Copyright © 2025-2026 OpenVCS Contributors // SPDX-License-Identifier: GPL-3.0-or-later +use std::path::{Path, PathBuf}; + +use crate::core::models::{BranchItem, CommitItem, ConflictSide, LogQuery, OnEvent, StatusPayload}; +use crate::core::{BackendId, Result as VcsResult, Vcs, VcsError}; + +/// Minimal Vcs implementation used to test trait default methods. +struct DummyVcs { + id: BackendId, + workdir: PathBuf, +} + +impl DummyVcs { + fn new(id: &str) -> Self { + Self { + id: BackendId::from(id), + workdir: PathBuf::from("/tmp/test"), + } + } +} + +impl Vcs for DummyVcs { + fn id(&self) -> BackendId { + self.id.clone() + } + + fn workdir(&self) -> &Path { + &self.workdir + } + + fn current_branch(&self) -> VcsResult> { + Ok(Some("main".into())) + } + + fn branches(&self) -> VcsResult> { + Ok(vec![]) + } + + fn create_branch(&self, _name: &str, _checkout: bool) -> VcsResult<()> { + Ok(()) + } + + fn checkout_branch(&self, _name: &str) -> VcsResult<()> { + Ok(()) + } + + fn ensure_remote(&self, _name: &str, _url: &str) -> VcsResult<()> { + Ok(()) + } + + fn list_remotes(&self) -> VcsResult> { + Ok(vec![]) + } + + fn remove_remote(&self, _name: &str) -> VcsResult<()> { + Ok(()) + } + + fn fetch(&self, _remote: &str, _refspec: &str, _on: Option) -> VcsResult<()> { + Ok(()) + } + + fn push(&self, _remote: &str, _refspec: &str, _on: Option) -> VcsResult<()> { + Ok(()) + } + + fn pull_ff_only(&self, _remote: &str, _branch: &str, _on: Option) -> VcsResult<()> { + Ok(()) + } + + fn commit(&self, _message: &str, _name: &str, _email: &str, _paths: &[PathBuf]) -> VcsResult { + Ok("abc123".into()) + } + + fn commit_index(&self, _message: &str, _name: &str, _email: &str) -> VcsResult { + Ok("def456".into()) + } + + fn status_payload(&self) -> VcsResult { + Ok(StatusPayload::default()) + } + + fn log_commits(&self, _query: &LogQuery) -> VcsResult> { + Ok(vec![]) + } + + fn diff_file(&self, _path: &Path) -> VcsResult> { + Ok(vec![]) + } + + fn diff_commit(&self, _rev: &str) -> VcsResult> { + Ok(vec![]) + } + + fn stage_patch(&self, _patch: &str) -> VcsResult<()> { + Ok(()) + } + + fn stage_paths(&self, _paths: &[PathBuf]) -> VcsResult<()> { + Ok(()) + } + + fn discard_paths(&self, _paths: &[PathBuf]) -> VcsResult<()> { + Ok(()) + } + + fn apply_reverse_patch(&self, _patch: &str) -> VcsResult<()> { + Ok(()) + } + + fn delete_branch(&self, _name: &str, _force: bool) -> VcsResult<()> { + Ok(()) + } + + fn rename_branch(&self, _old: &str, _new: &str) -> VcsResult<()> { + Ok(()) + } + + fn merge_into_current(&self, _name: &str) -> VcsResult<()> { + Ok(()) + } + + fn get_identity(&self) -> VcsResult> { + Ok(None) + } + + fn set_identity_local(&self, _name: &str, _email: &str) -> VcsResult<()> { + Ok(()) + } +} + +// ── VcsError display tests ───────────────────────────────────────────────── + +#[test] +fn vcs_error_no_upstream_displays_helpful_message() { + let err = VcsError::NoUpstream; + assert_eq!(err.to_string(), "no upstream configured"); +} + #[test] -/// Verifies user-facing `VcsError` formatting remains informative. -fn vcs_error_formats_useful_messages() { - let error = crate::core::VcsError::Unsupported(crate::core::BackendId::from("git")); - assert!(error.to_string().contains("unsupported backend")); +fn vcs_error_unsupported_backend_displays_helper_message() { + let err = VcsError::Unsupported(BackendId::from("git")); + assert_eq!(err.to_string(), "unsupported backend: git"); +} - let error = crate::core::VcsError::Backend { - backend: crate::core::BackendId::from("git"), +#[test] +fn vcs_error_backend_displays_backend_id_and_message() { + let err = VcsError::Backend { + backend: BackendId::from("git"), msg: "boom".into(), }; - assert_eq!(error.to_string(), "git: boom"); + assert_eq!(err.to_string(), "git: boom"); +} + +#[test] +fn vcs_error_io_displays_underlying_error() { + let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found"); + let err = VcsError::Io(io_err); + assert!(err.to_string().contains("file not found")); + assert!(err.to_string().contains("io:")); +} + +#[test] +fn vcs_error_from_io_converts_io_errors() { + let io_err = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "permission denied"); + let vcs_err: VcsError = io_err.into(); + assert!(vcs_err.to_string().contains("permission denied")); +} + +// ── Vcs trait default method tests ───────────────────────────────────────── + +#[test] +fn vcs_conflict_details_returns_unsupported_by_default() { + let vcs = DummyVcs::new("test"); + let result = vcs.conflict_details(Path::new("foo.txt")); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("unsupported backend")); +} + +#[test] +fn vcs_checkout_conflict_side_returns_unsupported_by_default() { + let vcs = DummyVcs::new("test"); + let result = vcs.checkout_conflict_side(Path::new("foo.txt"), ConflictSide::Ours); + assert!(result.is_err()); +} + +#[test] +fn vcs_write_merge_result_returns_unsupported_by_default() { + let vcs = DummyVcs::new("test"); + let result = vcs.write_merge_result(Path::new("foo.txt"), b"content"); + assert!(result.is_err()); +} + +#[test] +fn vcs_merge_abort_returns_unsupported_by_default() { + let vcs = DummyVcs::new("test"); + let result = vcs.merge_abort(); + assert!(result.is_err()); +} + +#[test] +fn vcs_merge_continue_returns_unsupported_by_default() { + let vcs = DummyVcs::new("test"); + let result = vcs.merge_continue(); + assert!(result.is_err()); +} + +#[test] +fn vcs_merge_in_progress_returns_false_by_default() { + let vcs = DummyVcs::new("test"); + let result = vcs.merge_in_progress(); + assert!(result.is_ok()); + assert!(!result.unwrap()); +} + +#[test] +fn vcs_set_branch_upstream_returns_unsupported_by_default() { + let vcs = DummyVcs::new("test"); + let result = vcs.set_branch_upstream("main", "origin/main"); + assert!(result.is_err()); +} + +#[test] +fn vcs_branch_upstream_returns_unsupported_by_default() { + let vcs = DummyVcs::new("test"); + let result = vcs.branch_upstream("main"); + assert!(result.is_err()); +} + +#[test] +fn vcs_reset_soft_to_returns_unsupported_by_default() { + let vcs = DummyVcs::new("test"); + let result = vcs.reset_soft_to("abc123"); + assert!(result.is_err()); +} + +#[test] +fn vcs_stash_list_returns_unsupported_by_default() { + let vcs = DummyVcs::new("test"); + let result = vcs.stash_list(); + assert!(result.is_err()); +} + +#[test] +fn vcs_stash_push_returns_unsupported_by_default() { + let vcs = DummyVcs::new("test"); + let result = vcs.stash_push("msg", false, &[]); + assert!(result.is_err()); +} + +#[test] +fn vcs_stash_apply_returns_unsupported_by_default() { + let vcs = DummyVcs::new("test"); + let result = vcs.stash_apply("stash@{0}"); + assert!(result.is_err()); +} + +#[test] +fn vcs_stash_pop_returns_unsupported_by_default() { + let vcs = DummyVcs::new("test"); + let result = vcs.stash_pop("stash@{0}"); + assert!(result.is_err()); +} + +#[test] +fn vcs_stash_drop_returns_unsupported_by_default() { + let vcs = DummyVcs::new("test"); + let result = vcs.stash_drop("stash@{0}"); + assert!(result.is_err()); +} + +#[test] +fn vcs_stash_show_returns_unsupported_by_default() { + let vcs = DummyVcs::new("test"); + let result = vcs.stash_show("stash@{0}"); + assert!(result.is_err()); +} + +#[test] +fn vcs_cherry_pick_returns_unsupported_by_default() { + let vcs = DummyVcs::new("test"); + let result = vcs.cherry_pick("abc123"); + assert!(result.is_err()); +} + +#[test] +fn vcs_revert_commit_returns_unsupported_by_default() { + let vcs = DummyVcs::new("test"); + let result = vcs.revert_commit("abc123", false); + assert!(result.is_err()); +} + +#[test] +fn vcs_merge_into_current_with_message_delegates_to_merge_into_current() { + let vcs = DummyVcs::new("test"); + // Should not error even with a message + assert!(vcs.merge_into_current_with_message("feature", Some("auto-merge")).is_ok()); + // Should work with None message too + assert!(vcs.merge_into_current_with_message("feature", None).is_ok()); } diff --git a/Backend/tests/modules/app_identity.rs b/Backend/tests/modules/app_identity.rs index d50cf544..76692275 100644 --- a/Backend/tests/modules/app_identity.rs +++ b/Backend/tests/modules/app_identity.rs @@ -1,10 +1,33 @@ // Copyright © 2025-2026 OpenVCS Contributors // SPDX-License-Identifier: GPL-3.0-or-later -use super::persistence_name; +use super::{clear_test_app_dirs, persistence_name, project_dirs, set_test_app_dirs, AppDirs}; #[test] -/// Verifies the app identity keeps legacy persistence name. fn exposes_persistence_names() { assert_eq!(persistence_name(), "OpenVCS"); } + +#[test] +fn test_app_dirs_override_redirects_paths() { + let real_dirs = project_dirs().expect("real project dirs"); + + let dir = tempfile::tempdir().expect("temp dir for testing override"); + let temp_config = dir.path().join("config"); + let temp_data = dir.path().join("data"); + std::fs::create_dir_all(&temp_config).expect("create temp config dir"); + std::fs::create_dir_all(&temp_data).expect("create temp data dir"); + + let override_dirs = AppDirs::new(temp_config.clone(), temp_data.clone()); + set_test_app_dirs(override_dirs); + + let during = project_dirs().expect("project dirs during override"); + assert_eq!(during.config_dir(), temp_config); + assert_eq!(during.data_dir(), temp_data); + + clear_test_app_dirs(); + + let after = project_dirs().expect("project dirs after clear"); + assert_eq!(after.config_dir(), real_dirs.config_dir()); + assert_eq!(after.data_dir(), real_dirs.data_dir()); +} diff --git a/Backend/tests/modules/config_watcher.rs b/Backend/tests/modules/config_watcher.rs index f77020e9..9ea0bf4f 100644 --- a/Backend/tests/modules/config_watcher.rs +++ b/Backend/tests/modules/config_watcher.rs @@ -3,24 +3,55 @@ use super::{begin_config_reload, event_targets_config}; use std::path::PathBuf; +use std::sync::{Mutex, OnceLock}; use std::thread; use std::time::Duration; +fn reload_lock() -> std::sync::MutexGuard<'static, ()> { + static RELOAD_LOCK: OnceLock> = OnceLock::new(); + RELOAD_LOCK + .get_or_init(|| Mutex::new(())) + .lock() + .expect("reload lock") +} + #[test] /// Verifies watcher path matching covers config file and temp sibling names. fn matches_config_and_temp_paths() { + let _lock = reload_lock(); let config = PathBuf::from("/tmp/openvcs/config.toml"); assert!(event_targets_config(std::slice::from_ref(&config), &config)); assert!(event_targets_config(&[PathBuf::from("/tmp/openvcs/config.toml.tmp")], &config)); assert!(!event_targets_config(&[PathBuf::from("/tmp/openvcs/other.toml")], &config)); } +#[test] +fn ignores_events_when_config_file_name_is_missing() { + let _lock = reload_lock(); + let config = PathBuf::from("/"); + assert!(!event_targets_config(&[PathBuf::from("/tmp/openvcs/config.toml")], &config)); +} + #[test] /// Verifies reload debounce blocks back-to-back triggers and then clears. fn debounces_reload_start() { + let _lock = reload_lock(); + thread::sleep(Duration::from_millis(260)); let guard = begin_config_reload().expect("first reload should begin"); assert!(begin_config_reload().is_none()); drop(guard); thread::sleep(Duration::from_millis(260)); assert!(begin_config_reload().is_some()); } + +#[test] +fn in_progress_reload_blocks_parallel_start_until_guard_drops() { + let _lock = reload_lock(); + thread::sleep(Duration::from_millis(260)); + let guard = begin_config_reload().expect("first reload should begin"); + assert!(begin_config_reload().is_none()); + drop(guard); + thread::sleep(Duration::from_millis(260)); + let second_guard = begin_config_reload().expect("reload should restart after drop"); + drop(second_guard); +} diff --git a/Backend/tests/modules/lib.rs b/Backend/tests/modules/lib.rs new file mode 100644 index 00000000..47da4f92 --- /dev/null +++ b/Backend/tests/modules/lib.rs @@ -0,0 +1,103 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +use std::path::PathBuf; + +use super::{first_existing_recent_repo, load_local_dotenv, local_dotenv_path, resolve_preferred_backend_id}; +use crate::core::BackendId; + +// ── local_dotenv_path tests ──────────────────────────────────────────────── + +#[test] +fn dotenv_path_resolves_to_backend_parent() { + let path = local_dotenv_path(); + assert!(path.ends_with(PathBuf::from("../.env"))); +} + +// ── resolve_preferred_backend_id tests ────────────────────────────────────── + +#[test] +fn prefers_configured_backend_when_available() { + let available = vec![BackendId::from("openvcs.git"), BackendId::from("zeta")]; + let resolved = resolve_preferred_backend_id("openvcs.git", &available); + assert_eq!( + resolved.map(|backend| backend.as_ref().to_string()), + Some("openvcs.git".into()) + ); +} + +#[test] +fn falls_back_to_sorted_backend_when_default_missing() { + let available = vec![BackendId::from("zeta"), BackendId::from("alpha")]; + let resolved = resolve_preferred_backend_id("missing", &available); + assert_eq!( + resolved.map(|backend| backend.as_ref().to_string()), + Some("alpha".into()) + ); +} + +#[test] +fn returns_none_when_no_backends_available() { + let available: Vec = vec![]; + let resolved = resolve_preferred_backend_id("git", &available); + assert!(resolved.is_none()); +} + +#[test] +fn returns_first_sorted_when_configured_default_is_empty() { + let available = vec![BackendId::from("zeta"), BackendId::from("alpha")]; + let resolved = resolve_preferred_backend_id("", &available); + assert_eq!( + resolved.map(|backend| backend.as_ref().to_string()), + Some("alpha".into()) + ); +} + +#[test] +fn returns_none_when_configured_default_is_empty_and_no_backends() { + let available: Vec = vec![]; + let resolved = resolve_preferred_backend_id("", &available); + assert!(resolved.is_none()); +} + +// ── first_existing_recent_repo tests ─────────────────────────────────────── + +#[test] +fn finds_first_existing_recent_repo() { + let temp = tempfile::tempdir().expect("temp dir"); + let existing = temp.path().join("repo"); + std::fs::create_dir(&existing).expect("create repo dir"); + + let selected = first_existing_recent_repo(&[ + temp.path().join("missing"), + existing.clone(), + temp.path().join("later"), + ]); + + assert_eq!(selected, Some(existing)); +} + +#[test] +fn returns_none_for_empty_repo_list() { + let selected = first_existing_recent_repo(&[]); + assert!(selected.is_none()); +} + +#[test] +fn returns_none_when_no_repos_exist() { + let temp = tempfile::tempdir().expect("temp dir"); + let selected = first_existing_recent_repo(&[ + temp.path().join("missing1"), + temp.path().join("missing2"), + ]); + assert!(selected.is_none()); +} + +// ── load_local_dotenv tests ───────────────────────────────────────────────── + +#[test] +fn load_local_dotenv_silently_ignores_missing_file() { + // The function should not panic when there is no .env file. + // This tests the `NotFound` error handling path. + load_local_dotenv(); +} diff --git a/Backend/tests/modules/logging.rs b/Backend/tests/modules/logging.rs index dc9e4c67..e086d6f8 100644 --- a/Backend/tests/modules/logging.rs +++ b/Backend/tests/modules/logging.rs @@ -1,24 +1,35 @@ // Copyright © 2025-2026 OpenVCS Contributors // SPDX-License-Identifier: GPL-3.0-or-later -use super::{clear_active_log_file, set_sentry_log_forwarding_enabled, LogTimer, ACTIVE_LOG_FILE, SENTRY_LOG_FORWARDING_ENABLED}; +use super::{ + build_sentry_logger, clear_active_log_file, prune_archives, rotate_existing_log, + set_sentry_log_forwarding_enabled, LogTimer, ACTIVE_LOG_FILE, SENTRY_LOG_FORWARDING_ENABLED, +}; use std::fs::OpenOptions; use std::io::Write; use std::sync::{Arc, Mutex}; +// ── LogTimer tests ───────────────────────────────────────────────────────── + #[test] -/// Verifies log timer reports elapsed milliseconds. -fn measures_elapsed_time() { +fn log_timer_elapsed_ms_returns_non_zero_value() { let timer = LogTimer::new("module", "operation"); assert!(timer.elapsed_ms() <= 1_000); } +// ── clear_active_log_file tests ──────────────────────────────────────────── + #[test] -/// Verifies active log file truncation clears existing bytes. -fn clears_active_log_file_contents() { +fn clear_active_log_file_truncates_existing_content() { let dir = tempfile::tempdir().expect("create temp dir"); let path = dir.path().join("openvcs.log"); - let mut file = OpenOptions::new().create(true).truncate(true).read(true).write(true).open(&path).expect("open log file"); + let mut file = OpenOptions::new() + .create(true) + .truncate(true) + .read(true) + .write(true) + .open(&path) + .expect("open log file"); writeln!(file, "hello").expect("seed log file"); let shared = Arc::new(Mutex::new(file)); let _ = ACTIVE_LOG_FILE.set(shared); @@ -29,10 +40,174 @@ fn clears_active_log_file_contents() { } #[test] -/// Verifies sentry forwarding flag can be toggled. -fn toggles_sentry_forwarding_flag() { +fn clear_active_log_file_is_noop_when_not_initialized() { + // When ACTIVE_LOG_FILE is not set, clear should succeed as a no-op. + assert!(clear_active_log_file().is_ok()); +} + +// ── Sentry forwarding tests ──────────────────────────────────────────────── + +#[test] +fn sentry_forwarding_flag_can_be_toggled() { set_sentry_log_forwarding_enabled(true); assert!(SENTRY_LOG_FORWARDING_ENABLED.load(std::sync::atomic::Ordering::Relaxed)); set_sentry_log_forwarding_enabled(false); assert!(!SENTRY_LOG_FORWARDING_ENABLED.load(std::sync::atomic::Ordering::Relaxed)); } + +#[test] +fn build_sentry_logger_creates_logger_without_panicking() { + // A minimal logger that discards records. + struct NullLogger; + impl log::Log for NullLogger { + fn enabled(&self, _: &log::Metadata) -> bool { + true + } + fn log(&self, _: &log::Record) {} + fn flush(&self) {} + } + + // build_sentry_logger should not panic for any forwarding state. + set_sentry_log_forwarding_enabled(true); + let _logger = build_sentry_logger(NullLogger); + + set_sentry_log_forwarding_enabled(false); + let _logger = build_sentry_logger(NullLogger); +} + +// ── prune_archives tests ─────────────────────────────────────────────────── + +#[test] +fn prune_archives_removes_excess_archives() { + let dir = tempfile::tempdir().expect("create temp dir"); + + // Create 5 archive-like files, keep only 2. + for i in 0..5u8 { + let name = format!("openvcs-2025-01-{:02}_10-00.zip", 10 + i); + let path = dir.path().join(&name); + let mut f = std::fs::File::create(&path).expect("create archive"); + writeln!(f, "test").expect("write archive content"); + } + + let before: Vec<_> = std::fs::read_dir(dir.path()) + .expect("read dir") + .filter_map(|e| e.ok()) + .collect(); + assert_eq!(before.len(), 5); + + prune_archives(dir.path(), 2); + + let after: Vec<_> = std::fs::read_dir(dir.path()) + .expect("read dir") + .filter_map(|e| e.ok()) + .collect(); + assert_eq!(after.len(), 2); +} + +#[test] +fn prune_archives_noop_when_below_limit() { + let dir = tempfile::tempdir().expect("create temp dir"); + + for i in 0..2u8 { + let name = format!("openvcs-2025-01-{:02}_10-00.zip", 10 + i); + let path = dir.path().join(&name); + let mut f = std::fs::File::create(&path).expect("create archive"); + writeln!(f, "test").expect("write archive content"); + } + + prune_archives(dir.path(), 5); + + let after: Vec<_> = std::fs::read_dir(dir.path()) + .expect("read dir") + .filter_map(|e| e.ok()) + .collect(); + assert_eq!(after.len(), 2); +} + +#[test] +fn prune_archives_ignores_non_archive_files() { + let dir = tempfile::tempdir().expect("create temp dir"); + + // Create valid archives + for i in 0..4u8 { + let name = format!("openvcs-2025-01-{:02}_10-00.zip", 10 + i); + let path = dir.path().join(&name); + let mut f = std::fs::File::create(&path).expect("create archive"); + writeln!(f, "test").expect("write archive content"); + } + // Create a non-archive file that should be ignored + let path = dir.path().join("not-an-archive.txt"); + let mut f = std::fs::File::create(&path).expect("create unrelated file"); + writeln!(f, "keep me").expect("write"); + + prune_archives(dir.path(), 2); + + let after: Vec<_> = std::fs::read_dir(dir.path()) + .expect("read dir") + .filter_map(|e| e.ok()) + .collect(); + // Should keep the unrelated file + 2 archives = 3 files + assert_eq!(after.len(), 3); +} + +#[test] +fn prune_archives_is_noop_for_empty_dirs() { + let dir = tempfile::tempdir().expect("create temp dir"); + // Should not panic on empty directory + prune_archives(dir.path(), 5); +} + +// ── rotate_existing_log tests ────────────────────────────────────────────── + +#[test] +fn rotate_existing_log_skips_missing_files() { + let dir = tempfile::tempdir().expect("create temp dir"); + // Should not panic when there's no active log file + rotate_existing_log(dir.path()); +} + +#[test] +fn rotate_existing_log_skips_empty_files() { + let dir = tempfile::tempdir().expect("create temp dir"); + let active = dir.path().join("openvcs.log"); + { + let _f = std::fs::File::create(&active).expect("create empty log"); + } + rotate_existing_log(dir.path()); + // No archive should have been created for an empty file + let entries: Vec<_> = std::fs::read_dir(dir.path()) + .expect("read dir") + .filter_map(|e| e.ok()) + .collect(); + assert_eq!(entries.len(), 1); // Only the empty openvcs.log +} + +#[test] +fn rotate_existing_log_creates_zip_archive() { + let dir = tempfile::tempdir().expect("create temp dir"); + let active = dir.path().join("openvcs.log"); + { + let mut f = std::fs::File::create(&active).expect("create log"); + writeln!(f, "this is a test log entry for rotation").expect("write content"); + } + rotate_existing_log(dir.path()); + + // The active log should now be removed (or the zip created) + let entries: Vec = std::fs::read_dir(dir.path()) + .expect("read dir") + .filter_map(|e| e.ok()) + .map(|e| e.file_name().to_string_lossy().to_string()) + .collect(); + // Should have a zip archive + assert!( + entries.iter().any(|name| name.ends_with(".zip")), + "expected a zip archive, got: {:?}", + entries + ); + // The original log should be removed after rotation + assert!( + !entries.iter().any(|name| name == "openvcs.log"), + "active log should be removed after rotation, got: {:?}", + entries + ); +} diff --git a/Backend/tests/modules/monitoring.rs b/Backend/tests/modules/monitoring.rs index cbae95c7..fefdf5e2 100644 --- a/Backend/tests/modules/monitoring.rs +++ b/Backend/tests/modules/monitoring.rs @@ -1,7 +1,11 @@ // Copyright © 2025-2026 OpenVCS Contributors // SPDX-License-Identifier: GPL-3.0-or-later -use super::{parse_frontend_level, parse_frontend_location, parse_frontend_stacktrace}; +use super::{ + backend_monitoring_allowed, normalize_env_value, parse_frontend_level, + parse_frontend_location, parse_frontend_stacktrace, +}; +use crate::settings::AppConfig; #[test] fn parses_frontend_location_suffix() { @@ -29,3 +33,28 @@ fn maps_frontend_levels_to_sentry_levels() { assert_eq!(parse_frontend_level("error"), sentry::Level::Error); assert_eq!(parse_frontend_level("other"), sentry::Level::Debug); } + +#[test] +fn normalizes_environment_values() { + assert_eq!(normalize_env_value(Some(" desktop ".into())), Some("desktop".into())); + assert_eq!(normalize_env_value(Some(" ".into())), None); + assert_eq!(normalize_env_value(None), None); +} + +#[test] +fn honors_backend_crash_report_consent() { + let mut cfg = AppConfig::default(); + cfg.general.crash_reports = true; + assert!(backend_monitoring_allowed(&cfg)); + + cfg.general.crash_reports = false; + assert!(!backend_monitoring_allowed(&cfg)); +} + +#[test] +fn skips_empty_or_invalid_frontend_stack_data() { + assert!(parse_frontend_stacktrace(None).is_none()); + assert!(parse_frontend_stacktrace(Some(" ")).is_none()); + assert_eq!(parse_frontend_location("not-a-location"), None); + assert_eq!(parse_frontend_location(":12:34"), None); +} diff --git a/Backend/tests/modules/plugin_paths.rs b/Backend/tests/modules/plugin_paths.rs index 99c89f30..9f8e63e8 100644 --- a/Backend/tests/modules/plugin_paths.rs +++ b/Backend/tests/modules/plugin_paths.rs @@ -1,7 +1,11 @@ // Copyright © 2025-2026 OpenVCS Contributors // SPDX-License-Identifier: GPL-3.0-or-later -use super::{bundled_node_candidate_paths, ensure_dir, node_executable_path, set_node_executable_path, set_node_runtime_resource_dir}; +use super::{ + built_in_plugin_dirs, bundled_node_candidate_paths, ensure_dir, node_executable_path, + set_node_executable_path, set_resource_dir, set_node_runtime_resource_dir, +}; +use std::fs; #[test] /// Verifies directory creation helper creates nested paths. @@ -29,3 +33,21 @@ fn stores_node_executable_path() { set_node_executable_path(path.clone()); assert_eq!(node_executable_path(), Some(path)); } + +#[test] +fn lists_only_built_in_plugin_dirs_with_package_manifests() { + let dir = tempfile::tempdir().expect("create temp dir"); + let built_in_root = dir.path().join("built-in-plugins"); + let valid_plugin = built_in_root.join("plugin-a"); + let invalid_plugin = built_in_root.join("plugin-b"); + + fs::create_dir_all(&valid_plugin).expect("create valid plugin dir"); + fs::create_dir_all(&invalid_plugin).expect("create invalid plugin dir"); + fs::write(valid_plugin.join("package.json"), "{}\n").expect("write package manifest"); + + set_resource_dir(dir.path().to_path_buf()); + let plugin_dirs = built_in_plugin_dirs(); + + assert!(plugin_dirs.iter().any(|path| path == &valid_plugin)); + assert!(!plugin_dirs.iter().any(|path| path == &invalid_plugin)); +} diff --git a/Backend/tests/modules/plugin_sources.rs b/Backend/tests/modules/plugin_sources.rs index acae2d43..ebfaa385 100644 --- a/Backend/tests/modules/plugin_sources.rs +++ b/Backend/tests/modules/plugin_sources.rs @@ -1,7 +1,10 @@ // Copyright © 2025-2026 OpenVCS Contributors // SPDX-License-Identifier: GPL-3.0-or-later -use super::{package_has_runtime_dependencies, resolve_local_plugin_path, sanitize_archive_path}; +use super::{ + archive_entry_path_error, command_error_message, has_non_empty_object_field, npm_executable, + package_has_runtime_dependencies, resolve_local_plugin_path, sanitize_archive_path, +}; use std::fs; #[test] @@ -42,3 +45,84 @@ fn detects_runtime_dependencies_in_package_json() { fs::write(&package_json, r#"{"optionalDependencies":{"x":"1.0.0"}}"#).expect("write optional deps manifest"); assert!(package_has_runtime_dependencies(&package_json).expect("read optional deps manifest")); } + +// ── has_non_empty_object_field tests ─────────────────────────────────────── + +#[test] +fn detects_non_empty_object_fields() { + assert!(has_non_empty_object_field(&serde_json::json!({"deps": {"a": "1"}}), "deps")); +} + +#[test] +fn rejects_empty_object_fields() { + assert!(!has_non_empty_object_field(&serde_json::json!({"deps": {}}), "deps")); +} + +#[test] +fn rejects_missing_fields() { + assert!(!has_non_empty_object_field(&serde_json::json!({"other": {}}), "deps")); +} + +#[test] +fn rejects_non_object_field_values() { + assert!(!has_non_empty_object_field(&serde_json::json!({"deps": "str"}), "deps")); + assert!(!has_non_empty_object_field(&serde_json::json!({"deps": null}), "deps")); + assert!(!has_non_empty_object_field(&serde_json::json!({"deps": 42}), "deps")); +} + +#[test] +fn returns_false_for_empty_input() { + assert!(!has_non_empty_object_field(&serde_json::json!({}), "anything")); +} + +// ── command_error_message tests ──────────────────────────────────────────── + +#[test] +fn formats_error_with_stderr_content() { + let msg = command_error_message("npm pack", b"some error text"); + assert_eq!(msg, "npm pack failed: some error text"); +} + +#[test] +fn formats_error_without_stderr_when_empty() { + let msg = command_error_message("npm pack", b""); + assert_eq!(msg, "npm pack failed"); +} + +#[test] +fn formats_error_without_stderr_when_whitespace() { + let msg = command_error_message("npm pack", b" \n "); + assert_eq!(msg, "npm pack failed"); +} + +#[test] +fn formats_error_with_lossy_utf8() { + let invalid_utf8 = b"error: \xff\xfe"; + let msg = command_error_message("install", invalid_utf8); + assert!(msg.contains("install failed")); +} + +// ── npm_executable tests ─────────────────────────────────────────────────── + +#[test] +fn npm_executable_returns_npm_on_linux() { + if cfg!(windows) { + assert_eq!(npm_executable(), "npm.cmd"); + } else { + assert_eq!(npm_executable(), "npm"); + } +} + +// ── archive_entry_path_error tests ───────────────────────────────────────── + +#[test] +fn formats_archive_entry_path_error() { + let msg = archive_entry_path_error("traversal", "package/../../foo"); + assert_eq!(msg, "invalid archive entry path (traversal): package/../../foo"); +} + +#[test] +fn formats_archive_entry_path_error_with_empty_strings() { + let msg = archive_entry_path_error("", ""); + assert_eq!(msg, "invalid archive entry path (): "); +} diff --git a/Backend/tests/modules/plugin_vcs_backends.rs b/Backend/tests/modules/plugin_vcs_backends.rs new file mode 100644 index 00000000..9899221a --- /dev/null +++ b/Backend/tests/modules/plugin_vcs_backends.rs @@ -0,0 +1,66 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +use super::*; +use crate::settings::AppConfig; +use std::collections::BTreeMap; + +fn backend_descriptor(backend_id: &str, plugin_id: &str) -> PluginBackendDescriptor { + PluginBackendDescriptor { + backend_id: BackendId::from(backend_id), + backend_name: Some("Git".into()), + action_labels: BTreeMap::new(), + plugin_id: plugin_id.into(), + plugin_name: Some("Plugin".into()), + } +} + +#[test] +fn returns_cached_backend_descriptors() { + invalidate_plugin_vcs_backend_cache(); + let desc = backend_descriptor("git", "openvcs.git"); + store_backends(vec![desc.clone()]); + + let cached = cached_backends().expect("cached backends"); + assert_eq!(cached.len(), 1); + assert_eq!(cached[0].backend_id.as_ref(), desc.backend_id.as_ref()); + assert_eq!(cached[0].plugin_id, desc.plugin_id); + + let listed = list_plugin_vcs_backends().expect("cache result"); + assert_eq!(listed.len(), 1); + assert_eq!(listed[0].backend_id.as_ref(), desc.backend_id.as_ref()); + assert_eq!(listed[0].plugin_id, desc.plugin_id); + assert!(has_plugin_vcs_backend(&BackendId::from("git"))); + + let resolved = plugin_vcs_backend_descriptor(&BackendId::from("git")).expect("descriptor"); + assert_eq!(resolved.plugin_id, "openvcs.git"); + + invalidate_plugin_vcs_backend_cache(); + assert!(cached_backends().is_none()); +} + +#[test] +fn honors_disabled_overrides_when_resolving_enablement() { + let mut cfg = AppConfig::default(); + cfg.plugins.disabled = vec!["OPENVCS.GIT".into()]; + cfg.plugins.enabled = vec!["openvcs.git".into()]; + + assert!(!is_plugin_enabled_in_settings(&cfg, "openvcs.git", false)); + assert!(!is_plugin_enabled_in_settings(&cfg, " ", true)); + + cfg.plugins.disabled.clear(); + assert!(is_plugin_enabled_in_settings(&cfg, "openvcs.git", false)); + assert!(is_plugin_enabled_in_settings(&cfg, "openvcs.git", true)); +} + +#[test] +fn reports_unknown_backend_descriptors() { + invalidate_plugin_vcs_backend_cache(); + store_backends(vec![backend_descriptor("hg", "openvcs.hg")]); + + // Use a backend id that is not present in the cache AND not discoverable + // from real installed plugins, so the error path is exercised. + let err = plugin_vcs_backend_descriptor(&BackendId::from("nonexistent-be")) + .expect_err("missing backend should fail"); + assert!(err.contains("Unknown VCS backend: nonexistent-be")); +} diff --git a/Backend/tests/modules/process_utils.rs b/Backend/tests/modules/process_utils.rs new file mode 100644 index 00000000..49d6db2b --- /dev/null +++ b/Backend/tests/modules/process_utils.rs @@ -0,0 +1,11 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +use super::hidden_command; +use std::ffi::OsStr; + +#[test] +fn builds_command_with_requested_program() { + let command = hidden_command("git"); + assert_eq!(command.get_program(), OsStr::new("git")); +} diff --git a/Backend/tests/modules/settings.rs b/Backend/tests/modules/settings.rs index bf98f09d..dfb65936 100644 --- a/Backend/tests/modules/settings.rs +++ b/Backend/tests/modules/settings.rs @@ -57,3 +57,149 @@ fn helper_constructors_return_disabled_defaults() { assert_eq!(proxy.mode, ProxyMode::System); assert!(proxy.url.is_empty()); } + +#[test] +fn section_defaults_stay_aligned_with_schema() { + let vcs = Vcs::default(); + assert!(vcs.backend.is_empty()); + assert_eq!(vcs.default_branch, "main"); + assert_eq!(vcs.ssh_binary, GitSshBinary::Auto); + assert!(vcs.ssh_path.is_empty()); + assert!(vcs.prune_on_fetch); + assert!(vcs.fetch_on_focus); + assert_eq!(vcs.allow_hooks, HookPolicy::Ask); + assert!(vcs.respect_core_autocrlf); + assert_eq!( + vcs.merge_commit_message_template, + "Merged branch '{branch:source}' into '{branch:target}'" + ); + + let commit = Commit::default(); + assert!(commit.commit_message_template_enabled); + assert!(commit.restrict_commit_summary); + assert_eq!(commit.commit_templates, CommitTemplates::default()); + + let templates = CommitTemplates::default(); + assert_eq!(templates.commit_message_template_create, "Create {file:name}"); + assert_eq!(templates.commit_message_template_update, "Update {file:name}"); + assert_eq!(templates.commit_message_template_delete, "Delete {file:name}"); + + let credentials = Credentials::default(); + assert_eq!(credentials.helper, CredentialHelper::OsKeychain); + assert_eq!(credentials.ssh_agent, SshAgent::Env); + assert_eq!( + credentials.ssh_key_paths, + vec!["~/.ssh/id_ed25519", "~/.ssh/id_rsa"] + ); + assert_eq!(credentials.gpg_program, "gpg"); + assert!(!credentials.sign_commits); + assert!(credentials.signing_key.is_empty()); + + let diff = Diff::default(); + assert_eq!(diff.tab_width, 4); + assert_eq!(diff.ignore_whitespace, WhitespaceMode::None); + assert_eq!(diff.max_file_size_mb, 10); + assert!(diff.intraline); + assert!(diff.show_binary_placeholders); + assert_eq!(diff.external_diff, ExternalTool::disabled()); + assert_eq!(diff.external_merge, ExternalTool::disabled()); + assert_eq!(diff.binary_exts, vec!["png", "jpg", "dds", "uasset"]); + + let lfs = Lfs::default(); + assert!(lfs.enabled); + assert_eq!(lfs.concurrency, 4); + assert!(!lfs.require_lock_before_edit); + assert!(lfs.background_fetch_on_checkout); + + let performance = Performance::default(); + assert!(performance.progressive_render); + assert!(performance.gpu_accel); + assert!(performance.animations); + + let integrations = Integrations::default(); + assert_eq!(integrations.default_editor, EditorChoice::System); + assert_eq!(integrations.issue_provider, IssueProvider::Auto); + assert!(integrations.host_overrides.is_empty()); + + let plugins = Plugins::default(); + assert!(plugins.disabled.is_empty()); + assert!(plugins.enabled.is_empty()); + + let ux = Ux::default(); + assert_eq!(ux.ui_scale, 1.0); + assert_eq!(ux.font_mono, "monospace"); + assert!(!ux.vim_nav); + assert_eq!(ux.color_blind_mode, ColorBlindMode::None); + assert_eq!(ux.recents_limit, 10); + + let advanced = Advanced::default(); + assert_eq!(advanced.confirm_force_push, ForcePushPolicy::Always); + assert!(advanced.ssl_verify); + assert_eq!(advanced.proxy, Proxy::system()); + + let experimental = Experimental::default(); + assert!(!experimental.parallel_history_scan); + assert!(!experimental.background_blame_index); + assert!(!experimental.sparse_checkout_ui); + + let logging = Logging::default(); + assert_eq!(logging.level, LogLevel::Info); + assert!(!logging.live_viewer); + assert_eq!(logging.retain_archives, 10); + +} + +#[test] +fn deserializes_partial_toml_with_field_defaults() { + let cfg: AppConfig = toml::from_str( + r#" +schema_version = 1 + +[general] +default_backend = "hg" + +[plugins] +enabled = ["openvcs.git"] +"#, + ) + .expect("parse config"); + + assert_eq!(cfg.schema_version, 1); + assert_eq!(cfg.general.default_backend, "hg"); + assert_eq!(cfg.general.theme_pack, "default"); + assert_eq!(cfg.vcs.default_branch, "main"); + assert_eq!(cfg.commit.commit_templates.commit_message_template_delete, "Delete {file:name}"); + assert_eq!(cfg.credentials.helper, CredentialHelper::OsKeychain); + assert_eq!(cfg.diff.tab_width, 4); + assert_eq!(cfg.lfs.concurrency, 4); + assert!(cfg.performance.animations); + assert!(cfg.integrations.host_overrides.is_empty()); + assert_eq!(cfg.plugins.enabled, vec!["openvcs.git"]); + assert_eq!(cfg.ux.recents_limit, 10); + assert_eq!(cfg.logging.retain_archives, 10); +} + +#[test] +fn round_trips_configuration_through_toml() { + let mut cfg = AppConfig::default(); + cfg.general.default_backend = "hg".into(); + cfg.vcs.backend = "git".into(); + cfg.vcs.ssh_binary = GitSshBinary::Custom; + cfg.vcs.ssh_path = "/usr/bin/ssh".into(); + cfg.commit.commit_templates.commit_message_template_update = "Update {file:name} now".into(); + cfg.credentials.sign_commits = true; + cfg.diff.tab_width = 2; + cfg.lfs.concurrency = 8; + cfg.performance.gpu_accel = false; + cfg.integrations.host_overrides.insert("example.com".into(), IssueProvider::Forgejo); + cfg.plugins.disabled = vec!["openvcs.git".into()]; + cfg.ux.ui_scale = 1.25; + cfg.advanced.confirm_force_push = ForcePushPolicy::TrackedRemotes; + cfg.experimental.parallel_history_scan = true; + cfg.logging.live_viewer = true; + + let toml = toml::to_string_pretty(&cfg).expect("serialize config"); + let parsed: AppConfig = toml::from_str(&toml).expect("deserialize config"); + + assert_eq!(parsed, cfg); +} diff --git a/Backend/tests/modules/settings_persistence.rs b/Backend/tests/modules/settings_persistence.rs new file mode 100644 index 00000000..d98443c6 --- /dev/null +++ b/Backend/tests/modules/settings_persistence.rs @@ -0,0 +1,54 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +use super::*; + +#[test] +fn validate_normalizes_invalid_values() { + let mut cfg = AppConfig::default(); + cfg.general.theme_pack = " ".into(); + cfg.general.default_backend = " ".into(); + cfg.vcs.backend = " git ".into(); + cfg.vcs.default_branch = " ".into(); + cfg.vcs.ssh_binary = GitSshBinary::Custom; + cfg.vcs.ssh_path = " ".into(); + cfg.diff.tab_width = 0; + cfg.diff.max_file_size_mb = 9_999; + cfg.lfs.concurrency = 0; + cfg.plugin = vec![" openvcs.git ".into(), "".into(), "openvcs.git".into()]; + cfg.plugins.disabled = vec![" OpenVCS.Git ".into(), "".into(), "openvcs.git".into()]; + cfg.plugins.enabled = vec![" openvcs.git ".into(), "other.plugin".into(), "other.plugin".into()]; + cfg.ux.recents_limit = 0; + cfg.logging.retain_archives = 0; + + cfg.validate(); + + assert_eq!(cfg.general.theme_pack, "default"); + assert_eq!(cfg.general.default_backend, "git"); + assert_eq!(cfg.vcs.backend, "git"); + assert_eq!(cfg.vcs.default_branch, "main"); + assert_eq!(cfg.vcs.ssh_binary, GitSshBinary::Auto); + assert_eq!(cfg.diff.tab_width, 1); + assert_eq!(cfg.diff.max_file_size_mb, 1_024); + assert_eq!(cfg.lfs.concurrency, 1); + assert_eq!(cfg.plugin, vec!["openvcs.git"]); + assert_eq!(cfg.plugins.disabled, vec!["openvcs.git"]); + assert_eq!(cfg.plugins.enabled, vec!["other.plugin"]); + assert_eq!(cfg.ux.recents_limit, 1); + assert_eq!(cfg.logging.retain_archives, 1); +} + +#[test] +fn evaluates_plugin_enablement_consistently() { + let mut cfg = AppConfig::default(); + cfg.plugins.disabled = vec!["OpenVCS.Git".into()]; + cfg.plugins.enabled = vec!["other.plugin".into(), "OPENVCS.GIT".into()]; + + assert!(!cfg.is_plugin_enabled("openvcs.git", false)); + assert!(!cfg.is_plugin_enabled(" ", true)); + + cfg.plugins.disabled.clear(); + assert!(cfg.is_plugin_enabled("openvcs.git", false)); + assert!(cfg.is_plugin_enabled("openvcs.git", true)); + assert!(cfg.is_plugin_enabled("other.plugin", false)); +} diff --git a/Backend/tests/modules/state.rs b/Backend/tests/modules/state.rs index 7586ef90..9615fe49 100644 --- a/Backend/tests/modules/state.rs +++ b/Backend/tests/modules/state.rs @@ -1,14 +1,87 @@ // Copyright © 2025-2026 OpenVCS Contributors // SPDX-License-Identifier: GPL-3.0-or-later -use super::AppState; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use crate::app_identity::{AppDirs, clear_test_app_dirs, set_test_app_dirs}; +use crate::core::{BackendId, Result as VcsResult, Vcs}; +use crate::core::models::{BranchItem, CommitItem, LogQuery, OnEvent, StatusPayload}; use crate::output_log::{OutputLevel, OutputLogEntry}; +use crate::repo::Repo; use crate::repo_settings::RepoConfig; use crate::settings::AppConfig; -use std::sync::Arc; +use crate::state::AppState; + +// ── Test isolation guard ─────────────────────────────────────────────────── + +/// RAII guard that redirects config/data/cache dirs to a temporary +/// directory for the duration of a test, then restores real paths on drop. +/// +/// This prevents tests from writing to or reading from the user's real +/// OpenVCS config, recents, and plugin directories. +struct AppDirsGuard { + _dir: tempfile::TempDir, +} + +impl AppDirsGuard { + fn new() -> Self { + let dir = tempfile::tempdir().expect("temp dir for test isolation"); + let cfg_dir = dir.path().join("config"); + let data_dir = dir.path().join("data"); + std::fs::create_dir_all(&cfg_dir).expect("create cfg dir"); + std::fs::create_dir_all(&data_dir).expect("create data dir"); + set_test_app_dirs(AppDirs::new(cfg_dir, data_dir)); + Self { _dir: dir } + } +} + +impl Drop for AppDirsGuard { + fn drop(&mut self) { + clear_test_app_dirs(); + } +} + +// ── Dummy Vcs impl for testing ───────────────────────────────────────────── + +fn dummy_repo(path: &Path) -> Arc { + // Create a DummyVcs whose workdir matches the provided path + struct PathDummyVcs(BackendId, PathBuf); + impl Vcs for PathDummyVcs { + fn id(&self) -> BackendId { self.0.clone() } + fn workdir(&self) -> &Path { &self.1 } + fn current_branch(&self) -> VcsResult> { Ok(Some("main".into())) } + fn branches(&self) -> VcsResult> { Ok(vec![]) } + fn create_branch(&self, _: &str, _: bool) -> VcsResult<()> { Ok(()) } + fn checkout_branch(&self, _: &str) -> VcsResult<()> { Ok(()) } + fn ensure_remote(&self, _: &str, _: &str) -> VcsResult<()> { Ok(()) } + fn list_remotes(&self) -> VcsResult> { Ok(vec![]) } + fn remove_remote(&self, _: &str) -> VcsResult<()> { Ok(()) } + fn fetch(&self, _: &str, _: &str, _: Option) -> VcsResult<()> { Ok(()) } + fn push(&self, _: &str, _: &str, _: Option) -> VcsResult<()> { Ok(()) } + fn pull_ff_only(&self, _: &str, _: &str, _: Option) -> VcsResult<()> { Ok(()) } + fn commit(&self, _: &str, _: &str, _: &str, _: &[PathBuf]) -> VcsResult { Ok("abc".into()) } + fn commit_index(&self, _: &str, _: &str, _: &str) -> VcsResult { Ok("def".into()) } + fn status_payload(&self) -> VcsResult { Ok(StatusPayload::default()) } + fn log_commits(&self, _: &LogQuery) -> VcsResult> { Ok(vec![]) } + fn diff_file(&self, _: &Path) -> VcsResult> { Ok(vec![]) } + fn diff_commit(&self, _: &str) -> VcsResult> { Ok(vec![]) } + fn stage_patch(&self, _: &str) -> VcsResult<()> { Ok(()) } + fn stage_paths(&self, _: &[PathBuf]) -> VcsResult<()> { Ok(()) } + fn discard_paths(&self, _: &[PathBuf]) -> VcsResult<()> { Ok(()) } + fn apply_reverse_patch(&self, _: &str) -> VcsResult<()> { Ok(()) } + fn delete_branch(&self, _: &str, _: bool) -> VcsResult<()> { Ok(()) } + fn rename_branch(&self, _: &str, _: &str) -> VcsResult<()> { Ok(()) } + fn merge_into_current(&self, _: &str) -> VcsResult<()> { Ok(()) } + fn get_identity(&self) -> VcsResult> { Ok(None) } + fn set_identity_local(&self, _: &str, _: &str) -> VcsResult<()> { Ok(()) } + } + Arc::new(Repo::new(Arc::new(PathDummyVcs(BackendId::from("git"), path.to_path_buf())))) +} + +// ── Construction tests ───────────────────────────────────────────────────── #[test] -/// Verifies app state snapshots config and owns a shared runtime manager. fn constructs_state_from_config() { let cfg = AppConfig::default(); let state = AppState::new_with_config(cfg.clone()); @@ -16,8 +89,23 @@ fn constructs_state_from_config() { assert!(Arc::strong_count(&state.plugin_runtime()) >= 1); } +// ── Config mutation tests ────────────────────────────────────────────────── + +#[test] +fn set_config_updates_snapshot() { + let _guard = AppDirsGuard::new(); + let state = AppState::new_with_config(AppConfig::default()); + let mut cfg = AppConfig::default(); + cfg.general.default_backend = "hg".into(); + // set_config may fail if recents persistence fails; we check the config was updated regardless + let _ = state.set_config(cfg.clone()); + // Config should reflect the update + assert_eq!(state.config().general.default_backend, "hg"); +} + +// ── Repo config tests ────────────────────────────────────────────────────── + #[test] -/// Verifies repo config updates are stored in memory. fn stores_repo_config_in_memory() { let state = AppState::new_with_config(AppConfig::default()); let repo_cfg = RepoConfig { @@ -29,8 +117,9 @@ fn stores_repo_config_in_memory() { assert!(state.set_repo_config(repo_cfg).is_ok()); } +// ── Output log tests ─────────────────────────────────────────────────────── + #[test] -/// Verifies output log entries append and clear in memory. fn manages_output_log_entries() { let state = AppState::new_with_config(AppConfig::default()); state.push_output_log(OutputLogEntry::new(1, OutputLevel::Info, "core", "hello")); @@ -40,3 +129,91 @@ fn manages_output_log_entries() { state.clear_output_log(); assert!(state.output_log().is_empty()); } + +#[test] +fn output_log_truncates_at_maximum() { + let state = AppState::new_with_config(AppConfig::default()); + // Push more than MAX (2000) entries + for i in 0..2500 { + state.push_output_log(OutputLogEntry::new(i as i64, OutputLevel::Info, "core", i.to_string())); + } + // Should have trimmed to 2000 + assert_eq!(state.output_log().len(), 2000); +} + +// ── Current repo lifecycle tests ─────────────────────────────────────────── + +#[test] +fn current_repo_starts_empty() { + let state = AppState::new_with_config(AppConfig::default()); + assert!(state.current_repo().is_none()); +} + +#[test] +fn set_current_repo_stores_and_retrieves_repo() { + let _guard = AppDirsGuard::new(); + let state = AppState::new_with_config(AppConfig::default()); + let repo = dummy_repo(Path::new("/tmp/test-repo")); + state.set_current_repo(repo.clone()); + let retrieved = state.current_repo().expect("repo should be set"); + assert_eq!(retrieved.id().as_ref(), repo.id().as_ref()); +} + +#[test] +fn clear_current_repo_removes_active_repo() { + let _guard = AppDirsGuard::new(); + let state = AppState::new_with_config(AppConfig::default()); + let repo = dummy_repo(Path::new("/tmp/test-repo")); + state.set_current_repo(repo); + assert!(state.current_repo().is_some()); + + state.clear_current_repo(); + assert!(state.current_repo().is_none()); +} + +#[test] +fn set_current_repo_affects_recents() { + let _guard = AppDirsGuard::new(); + let state = AppState::new_with_config(AppConfig::default()); + let dir = tempfile::tempdir().expect("temp dir"); + let repo_path = dir.path().join("my-repo"); + std::fs::create_dir(&repo_path).expect("create repo dir"); + + let before = state.recents().len(); + state.set_current_repo(dummy_repo(&repo_path)); + let after = state.recents().len(); + + // recents should grow by at least 1 (or stay same if repo was already present) + assert!(after > before || state.recents().iter().any(|p| p.ends_with("my-repo"))); +} + +#[test] +fn set_current_repo_places_new_path_at_front() { + let _guard = AppDirsGuard::new(); + let state = AppState::new_with_config(AppConfig::default()); + let dir = tempfile::tempdir().expect("temp dir"); + let repo_path = dir.path().join("front-repo"); + std::fs::create_dir(&repo_path).expect("create repo dir"); + + state.set_current_repo(dummy_repo(&repo_path)); + // The most recently set repo should be first + assert!(state.recents()[0].ends_with("front-repo")); +} + +// ── Recents accessor tests ──────────────────────────────────────────────── + +#[test] +fn recents_contains_paths_after_setting_current_repo() { + let _guard = AppDirsGuard::new(); + let state = AppState::new_with_config(AppConfig::default()); + let dir = tempfile::tempdir().expect("temp dir"); + let repo_path = dir.path().join("test-repo"); + std::fs::create_dir(&repo_path).expect("create repo dir"); + + // recents may be pre-populated from disk, so we check the repo appears + state.set_current_repo(dummy_repo(&repo_path)); + assert!( + state.recents().iter().any(|p| p.ends_with("test-repo")), + "repo path should appear in recents" + ); +} diff --git a/Backend/tests/modules/themes.rs b/Backend/tests/modules/themes.rs index 7abc666b..4cb3c143 100644 --- a/Backend/tests/modules/themes.rs +++ b/Backend/tests/modules/themes.rs @@ -123,3 +123,34 @@ fn build_theme_payload_from_directory_loads_assets() { assert_eq!(payload.markup.body.as_deref(), Some("
\n")); assert_eq!(payload.scripts, vec!["console.log('theme');\n".to_string()]); } + +#[test] +fn build_theme_payload_from_directory_keeps_builtin_ids() { + let dir = tempdir().expect("create temp dir"); + write_file( + dir.path().join("theme.json"), + &serde_json::to_string(&json!({ + "id": "forest", + "name": "Forest", + "paired_with": "night", + "styles": ["base.css"] + })) + .expect("serialize manifest"), + ); + write_file(dir.path().join("base.css"), "body { color: green; }\n"); + + let manifest = read_manifest_from_directory(dir.path()).expect("read manifest"); + let payload = build_theme_payload_from_directory( + dir.path(), + manifest, + ThemeSource::BuiltIn, + None, + ) + .expect("build payload"); + + assert_eq!(payload.summary.id, "forest"); + assert_eq!(payload.summary.paired_with.as_deref(), Some("night")); + assert!(matches!(payload.summary.source, ThemeSource::BuiltIn)); + assert!(payload.summary.plugin_id.is_none()); + assert_eq!(payload.styles.as_deref(), Some("body { color: green; }\n")); +} diff --git a/Backend/tests/modules/validate.rs b/Backend/tests/modules/validate.rs index eb356cee..d74b2b94 100644 --- a/Backend/tests/modules/validate.rs +++ b/Backend/tests/modules/validate.rs @@ -1,7 +1,10 @@ // Copyright © 2025-2026 OpenVCS Contributors // SPDX-License-Identifier: GPL-3.0-or-later -use super::{validate_add_path, validate_clone_input, validate_vcs_url}; +use super::{ + has_url_path_segment, is_probably_vcs_url, looks_like_path, validate_add_path, + validate_clone_input, validate_vcs_url, +}; use std::fs; #[test] @@ -76,3 +79,33 @@ fn validates_clone_inputs() { assert!(!err.ok); assert_eq!(err.reason.as_deref(), Some("Destination already contains a repository")); } + +#[test] +fn detects_supported_vcs_url_shapes() { + assert!(is_probably_vcs_url("https://github.com/openvcs/openvcs")); + assert!(is_probably_vcs_url("https://github.com/openvcs/openvcs.git")); + assert!(is_probably_vcs_url("ssh://git@example.com/openvcs/openvcs")); + assert!(is_probably_vcs_url("git@example.com:openvcs/openvcs")); + assert!(!is_probably_vcs_url("https://github.com")); + assert!(!is_probably_vcs_url("")); + assert!(!is_probably_vcs_url("not a url")); +} + +#[test] +fn detects_url_path_segments_after_scheme_stripping() { + assert!(has_url_path_segment("https://github.com/openvcs/openvcs", "https://")); + assert!(has_url_path_segment("ssh://git@example.com/openvcs/openvcs", "ssh://")); + assert!(!has_url_path_segment("https://github.com", "https://")); + assert!(!has_url_path_segment("ssh://git@example.com/", "ssh://")); + assert!(!has_url_path_segment("github.com/openvcs/openvcs", "https://")); +} + +#[test] +fn detects_absolute_path_shapes() { + assert!(looks_like_path("/tmp/openvcs")); + assert!(looks_like_path("~/openvcs")); + assert!(looks_like_path("C:\\OpenVCS")); + assert!(looks_like_path("c:/OpenVCS")); + assert!(!looks_like_path("relative/path")); + assert!(!looks_like_path("")); +} diff --git a/Backend/tests/plugin_runtime/host_api.rs b/Backend/tests/plugin_runtime/host_api.rs new file mode 100644 index 00000000..c0aa13d9 --- /dev/null +++ b/Backend/tests/plugin_runtime/host_api.rs @@ -0,0 +1,26 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +use super::{ + reset_host_api_state_for_tests, set_status_event_emitter, set_status_text_unchecked, +}; +use std::sync::{Arc, Mutex}; + +#[test] +fn trims_status_text_and_emits_once() { + reset_host_api_state_for_tests(); + + let events = Arc::new(Mutex::new(Vec::::new())); + let captured = Arc::clone(&events); + + set_status_event_emitter(move |message| { + captured.lock().expect("lock events").push(message.to_string()); + }); + + set_status_text_unchecked(" ready "); + set_status_text_unchecked(" "); + + assert_eq!(events.lock().expect("lock events").as_slice(), &["ready".to_string()]); + + reset_host_api_state_for_tests(); +} diff --git a/Backend/tests/plugin_runtime/node_instance/mod.rs b/Backend/tests/plugin_runtime/node_instance/mod.rs new file mode 100644 index 00000000..9b752116 --- /dev/null +++ b/Backend/tests/plugin_runtime/node_instance/mod.rs @@ -0,0 +1,331 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +use super::NodePluginRuntimeInstance; +use crate::plugin_runtime::instance::PluginRuntimeInstance; +use crate::plugin_runtime::spawn::SpawnConfig; +use serde_json::{json, Value}; +use std::sync::{Arc, Mutex}; + +fn test_runtime() -> NodePluginRuntimeInstance { + NodePluginRuntimeInstance::new(SpawnConfig { + plugin_id: "plugin.demo".into(), + exec_path: std::path::PathBuf::from("plugin.mjs"), + allowed_workspace_root: None, + is_vcs_backend: true, + }) +} + +fn mock_response(runtime: &NodePluginRuntimeInstance) { + runtime.set_mock_handler(Box::new(|_, _| Ok(Value::Null))); +} + +// ── session_params ── + +#[test] +fn session_params_requires_an_open_session() { + let runtime = test_runtime(); + + let err = runtime + .session_params(json!({"path": "repo.txt"})) + .expect_err("expected missing session error"); + assert_eq!(err, "vcs session is not open"); +} + +#[test] +fn session_params_merges_session_id_with_extra_fields() { + let runtime = test_runtime(); + runtime.set_session_id(Some("session-123".into())); + + let value = runtime + .session_params(json!({"path": "repo.txt", "session_id": "override"})) + .expect("session params"); + + assert_eq!( + value, + json!({ + "session_id": "override", + "path": "repo.txt" + }) + ); +} + +#[test] +fn session_params_defaults_session_id_when_not_overridden() { + let runtime = test_runtime(); + runtime.set_session_id(Some("session-123".into())); + + let value = runtime + .session_params(json!({"path": "repo.txt"})) + .expect("session params"); + + assert_eq!( + value, + json!({ + "session_id": "session-123", + "path": "repo.txt" + }) + ); +} + +#[test] +fn session_params_handles_empty_extra() { + let runtime = test_runtime(); + runtime.set_session_id(Some("s".into())); + + let value = runtime + .session_params(serde_json::Value::Object(serde_json::Map::new())) + .expect("session params"); + + assert_eq!(value, json!({"session_id": "s"})); +} + +// ── handle_notification ── + +#[test] +fn handle_notification_host_log_info() { + let runtime = test_runtime(); + runtime.handle_notification("host.log", &json!({ + "level": "info", + "message": "hello", + "target": "myplugin" + })); + // Should not panic +} + +#[test] +fn handle_notification_host_log_trace() { + let runtime = test_runtime(); + runtime.handle_notification("host.log", &json!({ + "level": "trace", + "message": "trace msg" + })); +} + +#[test] +fn handle_notification_host_log_warn() { + let runtime = test_runtime(); + runtime.handle_notification("host.log", &json!({ + "level": "warn", + "message": "warning" + })); +} + +#[test] +fn handle_notification_host_log_error() { + let runtime = test_runtime(); + runtime.handle_notification("host.log", &json!({ + "level": "error", + "message": "error msg" + })); +} + +#[test] +fn handle_notification_host_log_unknown_level() { + let runtime = test_runtime(); + runtime.handle_notification("host.log", &json!({ + "level": "debug", + "message": "debug msg" + })); +} + +#[test] +fn handle_notification_host_log_defaults() { + let runtime = test_runtime(); + runtime.handle_notification("host.log", &json!({})); + // Uses defaults: level=info, target=plugin, message="" +} + +#[test] +fn handle_notification_host_ui_notify_with_message() { + let runtime = test_runtime(); + runtime.handle_notification("host.ui.notify", &json!({"message": "hello user"})); +} + +#[test] +fn handle_notification_host_ui_notify_empty_message() { + let runtime = test_runtime(); + runtime.handle_notification("host.ui.notify", &json!({"message": ""})); +} + +#[test] +fn handle_notification_host_status_set() { + let runtime = test_runtime(); + runtime.handle_notification("host.status.set", &json!({"message": "busy"})); +} + +#[test] +fn handle_notification_host_status_set_empty() { + let runtime = test_runtime(); + runtime.handle_notification("host.status.set", &json!({"message": ""})); +} + +#[test] +fn handle_notification_host_event_emit() { + let runtime = test_runtime(); + runtime.handle_notification("host.event.emit", &json!({ + "event_name": "custom_event", + "payload": {"key": "val"} + })); +} + +#[test] +fn handle_notification_host_event_emit_empty_name() { + let runtime = test_runtime(); + runtime.handle_notification("host.event.emit", &json!({ + "event_name": "", + "payload": null + })); +} + +#[test] +fn handle_notification_vcs_event_with_sink() { + use crate::core::models::{OnEvent, VcsEvent}; + let runtime = test_runtime(); + let captured = std::sync::Arc::new(std::sync::Mutex::new(None::)); + let captured_clone = std::sync::Arc::clone(&captured); + let sink: OnEvent = std::sync::Arc::new(move |event| { + *captured_clone.lock().unwrap() = Some(event); + }); + runtime.set_event_sink(Some(sink)); + runtime.handle_notification("vcs.event", &json!({ + "event": {"type": "progress", "phase": "working", "detail": "50%"} + })); + assert!(captured.lock().unwrap().is_some()); +} + +#[test] +fn handle_notification_vcs_event_invalid_payload() { + let runtime = test_runtime(); + runtime.handle_notification("vcs.event", &json!({ + "event": "not an event struct" + })); + // Should log a warning and return +} + +#[test] +fn handle_notification_vcs_event_missing_event_field() { + let runtime = test_runtime(); + runtime.handle_notification("vcs.event", &json!({"other": "data"})); +} + +#[test] +fn handle_notification_vcs_event_no_sink() { + let runtime = test_runtime(); + runtime.handle_notification("vcs.event", &json!({ + "event": {"progress": {"message": "working", "percentage": 50}} + })); + // No sink installed, event should be ignored silently +} + +#[test] +fn handle_notification_unknown_method() { + let runtime = test_runtime(); + runtime.handle_notification("unknown.method", &json!({"data": 1})); + // Should trace-log and return +} + +// ── close_vcs_session ── + +#[test] +fn close_vcs_session_calls_rpc_when_session_active() { + let runtime = test_runtime(); + runtime.set_session_id(Some("session-abc".into())); + + let calls = Arc::new(Mutex::new(Vec::<(String, Value)>::new())); + let captured = Arc::clone(&calls); + runtime.set_mock_handler(Box::new(move |method, params| { + captured + .lock() + .expect("lock calls") + .push((method.to_string(), params.clone())); + Ok(Value::Null) + })); + + runtime.close_vcs_session(); + + assert!(runtime.vcs_session_id.lock().is_none()); + assert_eq!( + calls.lock().expect("lock calls").as_slice(), + &[("vcs.close".to_string(), json!({"session_id": "session-abc"}))] + ); +} + +#[test] +fn close_vcs_session_noop_when_no_session() { + let runtime = test_runtime(); + let calls = Arc::new(Mutex::new(Vec::<(String, Value)>::new())); + let captured = Arc::clone(&calls); + runtime.set_mock_handler(Box::new(move |method, params| { + captured + .lock() + .expect("lock calls") + .push((method.to_string(), params.clone())); + Ok(Value::Null) + })); + + runtime.close_vcs_session(); + + assert!(calls.lock().expect("lock calls").is_empty()); +} + +// ── stop_process ── + +#[test] +fn stop_process_ungraceful_clears_session() { + let runtime = test_runtime(); + runtime.set_session_id(Some("session-abc".into())); + runtime.stop_process(false); + assert!(runtime.vcs_session_id.lock().is_none()); +} + +#[test] +fn stop_process_graceful_closes_session() { + let runtime = test_runtime(); + runtime.set_session_id(Some("session-abc".into())); + mock_response(&runtime); + runtime.stop_process(true); + // Session should be cleared even if process is None + assert!(runtime.vcs_session_id.lock().is_none()); +} + +#[test] +fn stop_process_graceful_without_session_does_not_call_rpc() { + let runtime = test_runtime(); + // No session_id set, stop_process should not attempt rpc call + runtime.stop_process(true); +} + +// ── PluginRuntimeInstance trait methods ── + +#[test] +fn ensure_running_fails_when_no_node() { + let runtime = test_runtime(); + // No process pre-injected, spawn_process will fail + let result = runtime.ensure_running(); + assert!(result.is_err()); +} + +#[test] +fn set_event_sink_stores_and_clears() { + use crate::core::models::OnEvent; + let runtime = test_runtime(); + let sink: OnEvent = std::sync::Arc::new(|_| {}); + runtime.set_event_sink(Some(Arc::clone(&sink))); + runtime.set_event_sink(None); + // Should not panic +} + +#[test] +fn stop_does_not_panic() { + let runtime = test_runtime(); + // With no process, stop is a no-op + runtime.stop(); +} + +#[test] +fn drop_does_not_panic() { + // Dropping a runtime with no active process should be safe + let runtime = test_runtime(); + drop(runtime); +} diff --git a/Backend/tests/plugin_runtime/node_instance/rpc.rs b/Backend/tests/plugin_runtime/node_instance/rpc.rs new file mode 100644 index 00000000..c26408d6 --- /dev/null +++ b/Backend/tests/plugin_runtime/node_instance/rpc.rs @@ -0,0 +1,194 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +use super::{format_rpc_error, NodeRpcProcess}; +use crate::plugin_runtime::node_instance::NodePluginRuntimeInstance; +use crate::plugin_runtime::protocol::RpcError; +use crate::plugin_runtime::spawn::SpawnConfig; +use parking_lot::Mutex; +use serde_json::{json, Value}; +use std::path::PathBuf; +use std::process::{Command, Stdio}; +use std::sync::Arc; +use std::sync::mpsc; + +#[test] +fn formats_rpc_errors_with_nested_message() { + let error = RpcError { + code: 42, + message: "outer".into(), + data: Some(json!({"message": "inner"})), + }; + assert_eq!( + format_rpc_error("demo.plugin", "vcs.open", &error), + "plugin 'demo.plugin' rpc 'vcs.open' failed (code 42): inner" + ); +} + +#[test] +fn formats_rpc_errors_with_fallback_message() { + let error = RpcError { + code: 7, + message: " fallback ".into(), + data: None, + }; + assert_eq!( + format_rpc_error("demo.plugin", "vcs.open", &error), + "plugin 'demo.plugin' rpc 'vcs.open' failed (code 7): fallback" + ); +} + +/// Integration tests using a real (but harmless) sh process for RPC I/O. +#[cfg(unix)] +mod call_integration { + use super::*; + + /// Spawns a background process that stays alive so `call()` can write + /// framed messages to its stdin pipe without error. + fn mock_process() -> (NodeRpcProcess, mpsc::Sender) { + let mut child = Command::new("sh") + .arg("-c") + .arg("while true; do :; done") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .expect("spawn mock rpc child"); + let stdin = child.stdin.take().expect("mock process stdin"); + let (tx, rx) = mpsc::channel(); + let process = NodeRpcProcess { + child, + stdin, + rx, + shutdown_flag: Arc::new(Mutex::new(false)), + reader_error: Arc::new(Mutex::new(None)), + next_request_id: 1, + }; + (process, tx) + } + + fn test_runtime() -> NodePluginRuntimeInstance { + NodePluginRuntimeInstance::new(SpawnConfig { + plugin_id: "test.plugin".into(), + exec_path: PathBuf::from("test.mjs"), + allowed_workspace_root: None, + is_vcs_backend: true, + }) + } + + #[test] + fn call_method_returns_response() { + let (process, tx) = mock_process(); + let rt = test_runtime(); + rt.set_session_id(Some("s".into())); + rt.set_process(process); + + tx.send(json!({ + "jsonrpc": "2.0", + "id": 1, + "result": "test-result" + })) + .unwrap(); + + let result: String = rt.vcs_stash_show("stash@{0}").unwrap(); + assert_eq!(result, "test-result"); + } + + #[test] + fn call_method_returns_deserialized_struct() { + let (process, tx) = mock_process(); + let rt = test_runtime(); + rt.set_session_id(Some("s".into())); + rt.set_process(process); + + tx.send(json!({ + "jsonrpc": "2.0", + "id": 1, + "result": "main" + })) + .unwrap(); + + let branch = rt.vcs_get_current_branch().unwrap(); + assert_eq!(branch, Some("main".into())); + } + + #[test] + fn call_method_propagates_rpc_error() { + let (process, tx) = mock_process(); + let rt = test_runtime(); + rt.set_session_id(Some("s".into())); + rt.set_process(process); + + tx.send(json!({ + "jsonrpc": "2.0", + "id": 1, + "error": {"code": -1, "message": "operation rejected"} + })) + .unwrap(); + + let err = rt.vcs_list_branches().unwrap_err(); + assert!(err.contains("operation rejected") || err.contains("rpc")); + } + + #[test] + fn call_method_handles_notification_before_response() { + let (process, tx) = mock_process(); + let rt = test_runtime(); + rt.set_session_id(Some("s".into())); + rt.set_process(process); + + tx.send(json!({ + "jsonrpc": "2.0", + "method": "host.log", + "params": {"level": "info", "message": "test notification"} + })) + .unwrap(); + tx.send(json!({ + "jsonrpc": "2.0", + "id": 1, + "result": "done" + })) + .unwrap(); + + let result: String = rt.vcs_stash_show("stash@{0}").unwrap(); + assert_eq!(result, "done"); + } + + #[test] + fn call_method_rejects_mismatched_response_id() { + let (process, tx) = mock_process(); + let rt = test_runtime(); + rt.set_session_id(Some("s".into())); + rt.set_process(process); + + tx.send(json!({ + "jsonrpc": "2.0", + "id": 999, + "result": "wrong-id" + })) + .unwrap(); + tx.send(json!({ + "jsonrpc": "2.0", + "id": 1, + "result": "correct-id" + })) + .unwrap(); + + let result: String = rt.vcs_stash_show("stash@{0}").unwrap(); + assert_eq!(result, "correct-id"); + } + + #[test] + fn call_method_errors_via_mock_handler() { + use serde_json::Value; + + let rt = test_runtime(); + rt.set_session_id(Some("s".into())); + rt.set_mock_handler(Box::new(|method: &str, _params: Value| -> Result { + Err(format!("{method} mock error")) + })); + + let err = rt.vcs_list_stashes().unwrap_err(); + assert!(err.contains("mock error"), "should propagate mock handler error: {err:?}"); + } +} diff --git a/Backend/tests/plugin_runtime/node_instance/vcs.rs b/Backend/tests/plugin_runtime/node_instance/vcs.rs new file mode 100644 index 00000000..4f9cf9eb --- /dev/null +++ b/Backend/tests/plugin_runtime/node_instance/vcs.rs @@ -0,0 +1,562 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +use crate::plugin_runtime::node_instance::NodePluginRuntimeInstance; +use crate::plugin_runtime::spawn::SpawnConfig; +use serde_json::{json, Value}; +use std::path::PathBuf; +use std::sync::{Arc, Mutex}; + +fn test_runtime() -> NodePluginRuntimeInstance { + NodePluginRuntimeInstance::new(SpawnConfig { + plugin_id: "test.plugin".into(), + exec_path: PathBuf::from("test.mjs"), + allowed_workspace_root: None, + is_vcs_backend: true, + }) +} + +fn mock_response(runtime: &NodePluginRuntimeInstance, response: Value) { + runtime.set_mock_handler(Box::new(move |_, _| Ok(response.clone()))); +} + +fn mock_error(runtime: &NodePluginRuntimeInstance, msg: &str) { + let msg = msg.to_string(); + runtime.set_mock_handler(Box::new(move |_, _| Err(msg.clone()))); +} + +// --- Methods without session_params --- + +#[test] +fn vcs_open_parses_empty_config_and_stores_session_id() { + let rt = test_runtime(); + mock_response(&rt, json!({"session_id": "opened-123"})); + rt.vcs_open("/repo", b"").unwrap(); + assert_eq!(&*rt.vcs_session_id.lock(), &Some("opened-123".to_string())); +} + +#[test] +fn vcs_open_parses_config_bytes_and_stores_session_id() { + let rt = test_runtime(); + mock_response(&rt, json!({"session_id": "opened-456"})); + rt.vcs_open("/repo", br#"{"key":"val"}"#).unwrap(); + assert_eq!(&*rt.vcs_session_id.lock(), &Some("opened-456".to_string())); +} + +#[test] +fn vcs_open_forwards_rpc_error() { + let rt = test_runtime(); + mock_error(&rt, "failed to open"); + let err = rt.vcs_open("/repo", b"").unwrap_err(); + assert_eq!(err, "failed to open"); +} + +#[test] +fn vcs_clone_repo_works() { + let rt = test_runtime(); + mock_response(&rt, Value::Null); + rt.vcs_clone_repo("https://example.com/repo", "/dest").unwrap(); +} + +#[test] +fn vcs_clone_repo_forwards_error() { + let rt = test_runtime(); + mock_error(&rt, "clone failed"); + assert!(rt.vcs_clone_repo("https://example.com/repo", "/dest").is_err()); +} + +// --- Methods using session_params with unit return --- + +#[test] +fn vcs_create_branch_works() { + let rt = test_runtime(); + *rt.vcs_session_id.lock() = Some("s".into()); + mock_response(&rt, Value::Null); + rt.vcs_create_branch("feature", true).unwrap(); +} + +#[test] +fn vcs_checkout_branch_works() { + let rt = test_runtime(); + *rt.vcs_session_id.lock() = Some("s".into()); + mock_response(&rt, Value::Null); + rt.vcs_checkout_branch("main").unwrap(); +} + +#[test] +fn vcs_ensure_remote_works() { + let rt = test_runtime(); + *rt.vcs_session_id.lock() = Some("s".into()); + mock_response(&rt, Value::Null); + rt.vcs_ensure_remote("origin", "https://example.com/repo").unwrap(); +} + +#[test] +fn vcs_remove_remote_works() { + let rt = test_runtime(); + *rt.vcs_session_id.lock() = Some("s".into()); + mock_response(&rt, Value::Null); + rt.vcs_remove_remote("origin").unwrap(); +} + +#[test] +fn vcs_fetch_works() { + let rt = test_runtime(); + *rt.vcs_session_id.lock() = Some("s".into()); + mock_response(&rt, Value::Null); + rt.vcs_fetch("origin", "main").unwrap(); +} + +#[test] +fn vcs_push_works() { + let rt = test_runtime(); + *rt.vcs_session_id.lock() = Some("s".into()); + mock_response(&rt, Value::Null); + rt.vcs_push("origin", "main").unwrap(); +} + +#[test] +fn vcs_pull_ff_only_works() { + let rt = test_runtime(); + *rt.vcs_session_id.lock() = Some("s".into()); + mock_response(&rt, Value::Null); + rt.vcs_pull_ff_only("origin", "main").unwrap(); +} + +#[test] +fn vcs_stage_patch_works() { + let rt = test_runtime(); + *rt.vcs_session_id.lock() = Some("s".into()); + mock_response(&rt, Value::Null); + rt.vcs_stage_patch("diff --git a/file b/file").unwrap(); +} + +#[test] +fn vcs_stage_paths_works() { + let rt = test_runtime(); + *rt.vcs_session_id.lock() = Some("s".into()); + mock_response(&rt, Value::Null); + rt.vcs_stage_paths(&["src/main.rs".into(), "src/lib.rs".into()]).unwrap(); +} + +#[test] +fn vcs_discard_paths_works() { + let rt = test_runtime(); + *rt.vcs_session_id.lock() = Some("s".into()); + mock_response(&rt, Value::Null); + rt.vcs_discard_paths(&["src/main.rs".into()]).unwrap(); +} + +#[test] +fn vcs_apply_reverse_patch_works() { + let rt = test_runtime(); + *rt.vcs_session_id.lock() = Some("s".into()); + mock_response(&rt, Value::Null); + rt.vcs_apply_reverse_patch("diff --git a/file b/file").unwrap(); +} + +#[test] +fn vcs_delete_branch_works() { + let rt = test_runtime(); + *rt.vcs_session_id.lock() = Some("s".into()); + mock_response(&rt, Value::Null); + rt.vcs_delete_branch("old-feature", false).unwrap(); +} + +#[test] +fn vcs_rename_branch_works() { + let rt = test_runtime(); + *rt.vcs_session_id.lock() = Some("s".into()); + mock_response(&rt, Value::Null); + rt.vcs_rename_branch("old-name", "new-name").unwrap(); +} + +#[test] +fn vcs_merge_into_current_works() { + let rt = test_runtime(); + *rt.vcs_session_id.lock() = Some("s".into()); + mock_response(&rt, Value::Null); + rt.vcs_merge_into_current("feature", Some("merge msg")).unwrap(); +} + +#[test] +fn vcs_merge_into_current_without_message_works() { + let rt = test_runtime(); + *rt.vcs_session_id.lock() = Some("s".into()); + mock_response(&rt, Value::Null); + rt.vcs_merge_into_current("feature", None::<&str>).unwrap(); +} + +#[test] +fn vcs_merge_abort_works() { + let rt = test_runtime(); + *rt.vcs_session_id.lock() = Some("s".into()); + mock_response(&rt, Value::Null); + rt.vcs_merge_abort().unwrap(); +} + +#[test] +fn vcs_merge_continue_works() { + let rt = test_runtime(); + *rt.vcs_session_id.lock() = Some("s".into()); + mock_response(&rt, Value::Null); + rt.vcs_merge_continue().unwrap(); +} + +#[test] +fn vcs_set_branch_upstream_works() { + let rt = test_runtime(); + *rt.vcs_session_id.lock() = Some("s".into()); + mock_response(&rt, Value::Null); + rt.vcs_set_branch_upstream("main", "origin/main").unwrap(); +} + +#[test] +fn vcs_reset_soft_to_works() { + let rt = test_runtime(); + *rt.vcs_session_id.lock() = Some("s".into()); + mock_response(&rt, Value::Null); + rt.vcs_reset_soft_to("abc123").unwrap(); +} + +#[test] +fn vcs_set_identity_local_works() { + let rt = test_runtime(); + *rt.vcs_session_id.lock() = Some("s".into()); + mock_response(&rt, Value::Null); + rt.vcs_set_identity_local("User", "user@example.com").unwrap(); +} + +#[test] +fn vcs_stash_apply_works() { + let rt = test_runtime(); + *rt.vcs_session_id.lock() = Some("s".into()); + mock_response(&rt, Value::Null); + rt.vcs_stash_apply("stash@{0}").unwrap(); +} + +#[test] +fn vcs_stash_pop_works() { + let rt = test_runtime(); + *rt.vcs_session_id.lock() = Some("s".into()); + mock_response(&rt, Value::Null); + rt.vcs_stash_pop("stash@{0}").unwrap(); +} + +#[test] +fn vcs_stash_drop_works() { + let rt = test_runtime(); + *rt.vcs_session_id.lock() = Some("s".into()); + mock_response(&rt, Value::Null); + rt.vcs_stash_drop("stash@{0}").unwrap(); +} + +#[test] +fn vcs_cherry_pick_works() { + let rt = test_runtime(); + *rt.vcs_session_id.lock() = Some("s".into()); + mock_response(&rt, Value::Null); + rt.vcs_cherry_pick("abc123").unwrap(); +} + +#[test] +fn vcs_revert_commit_works() { + let rt = test_runtime(); + *rt.vcs_session_id.lock() = Some("s".into()); + mock_response(&rt, Value::Null); + rt.vcs_revert_commit("abc123", true).unwrap(); +} + +#[test] +fn vcs_checkout_conflict_side_works() { + let rt = test_runtime(); + *rt.vcs_session_id.lock() = Some("s".into()); + mock_response(&rt, Value::Null); + rt.vcs_checkout_conflict_side("file.txt", crate::core::models::ConflictSide::Ours) + .unwrap(); +} + +#[test] +fn vcs_write_merge_result_works() { + let rt = test_runtime(); + *rt.vcs_session_id.lock() = Some("s".into()); + mock_response(&rt, Value::Null); + rt.vcs_write_merge_result("file.txt", b"merged content").unwrap(); +} + +// --- Methods with typed return values --- + +#[test] +fn vcs_get_current_branch_returns_some() { + let rt = test_runtime(); + *rt.vcs_session_id.lock() = Some("s".into()); + mock_response(&rt, json!("main")); + assert_eq!(rt.vcs_get_current_branch().unwrap(), Some("main".to_string())); +} + +#[test] +fn vcs_get_current_branch_returns_none() { + let rt = test_runtime(); + *rt.vcs_session_id.lock() = Some("s".into()); + mock_response(&rt, Value::Null); + assert_eq!(rt.vcs_get_current_branch().unwrap(), None); +} + +#[test] +fn vcs_list_branches_returns_branches() { + let rt = test_runtime(); + *rt.vcs_session_id.lock() = Some("s".into()); + mock_response(&rt, json!([ + {"name": "main", "full_ref": "refs/heads/main", "kind": {"type": "Local"}, "current": true}, + {"name": "feature", "full_ref": "refs/heads/feature", "kind": {"type": "Local"}, "current": false} + ])); + let branches = rt.vcs_list_branches().unwrap(); + assert_eq!(branches.len(), 2); + assert_eq!(branches[0].name, "main"); + assert!(branches[0].current); +} + +#[test] +fn vcs_list_remotes_returns_remotes() { + let rt = test_runtime(); + *rt.vcs_session_id.lock() = Some("s".into()); + mock_response(&rt, json!([ + {"name": "origin", "url": "https://example.com/repo"} + ])); + let remotes = rt.vcs_list_remotes().unwrap(); + assert_eq!(remotes, vec![("origin".into(), "https://example.com/repo".into())]); +} + +#[test] +fn vcs_list_commits_returns_commits() { + let rt = test_runtime(); + *rt.vcs_session_id.lock() = Some("s".into()); + mock_response(&rt, json!([ + {"id": "abc", "msg": "first", "meta": "2024-01-01", "author": "Alice"} + ])); + let query = crate::core::models::LogQuery::default(); + let commits = rt.vcs_list_commits(&query).unwrap(); + assert_eq!(commits.len(), 1); + assert_eq!(commits[0].id, "abc"); +} + +#[test] +fn vcs_diff_file_returns_diff_lines() { + let rt = test_runtime(); + *rt.vcs_session_id.lock() = Some("s".into()); + mock_response(&rt, json!(["+new line", "-old line"])); + let lines = rt.vcs_diff_file("file.rs").unwrap(); + assert_eq!(lines, vec!["+new line", "-old line"]); +} + +#[test] +fn vcs_diff_commit_returns_diff_lines() { + let rt = test_runtime(); + *rt.vcs_session_id.lock() = Some("s".into()); + mock_response(&rt, json!(["diff --git a/file b/file"])); + let lines = rt.vcs_diff_commit("abc123").unwrap(); + assert_eq!(lines, vec!["diff --git a/file b/file"]); +} + +#[test] +fn vcs_get_conflict_details_returns_details() { + let rt = test_runtime(); + *rt.vcs_session_id.lock() = Some("s".into()); + mock_response(&rt, json!({ + "path": "file.txt", + "ours": "our content", + "theirs": "their content", + "base": "base content", + "binary": false, + "lfs_pointer": false + })); + let details = rt.vcs_get_conflict_details("file.txt").unwrap(); + assert_eq!(details.path, "file.txt"); + assert_eq!(details.ours.unwrap(), "our content"); +} + +#[test] +fn vcs_is_merge_in_progress_returns_true() { + let rt = test_runtime(); + *rt.vcs_session_id.lock() = Some("s".into()); + mock_response(&rt, json!(true)); + assert!(rt.vcs_is_merge_in_progress().unwrap()); +} + +#[test] +fn vcs_is_merge_in_progress_returns_false() { + let rt = test_runtime(); + *rt.vcs_session_id.lock() = Some("s".into()); + mock_response(&rt, json!(false)); + assert!(!rt.vcs_is_merge_in_progress().unwrap()); +} + +#[test] +fn vcs_get_branch_upstream_returns_some() { + let rt = test_runtime(); + *rt.vcs_session_id.lock() = Some("s".into()); + mock_response(&rt, json!("origin/main")); + assert_eq!(rt.vcs_get_branch_upstream("main").unwrap(), Some("origin/main".into())); +} + +#[test] +fn vcs_get_branch_upstream_returns_none() { + let rt = test_runtime(); + *rt.vcs_session_id.lock() = Some("s".into()); + mock_response(&rt, Value::Null); + assert_eq!(rt.vcs_get_branch_upstream("main").unwrap(), None); +} + +#[test] +fn vcs_get_identity_returns_some() { + let rt = test_runtime(); + *rt.vcs_session_id.lock() = Some("s".into()); + mock_response(&rt, json!({"name": "Alice", "email": "alice@example.com"})); + let identity = rt.vcs_get_identity().unwrap(); + assert_eq!(identity, Some(("Alice".into(), "alice@example.com".into()))); +} + +#[test] +fn vcs_get_identity_returns_none() { + let rt = test_runtime(); + *rt.vcs_session_id.lock() = Some("s".into()); + mock_response(&rt, Value::Null); + assert_eq!(rt.vcs_get_identity().unwrap(), None); +} + +#[test] +fn vcs_list_stashes_returns_stashes() { + let rt = test_runtime(); + *rt.vcs_session_id.lock() = Some("s".into()); + mock_response(&rt, json!([ + {"selector": "stash@{0}", "msg": "WIP", "meta": "2024-01-01"} + ])); + let stashes = rt.vcs_list_stashes().unwrap(); + assert_eq!(stashes.len(), 1); + assert_eq!(stashes[0].selector, "stash@{0}"); +} + +#[test] +fn vcs_stash_push_with_message_returns_selector() { + let rt = test_runtime(); + *rt.vcs_session_id.lock() = Some("s".into()); + mock_response(&rt, json!("stash@{0}")); + let result = rt.vcs_stash_push(Some("WIP"), false, &[]).unwrap(); + assert_eq!(result, "stash@{0}"); +} + +#[test] +fn vcs_stash_push_forwards_paths() { + let rt = test_runtime(); + *rt.vcs_session_id.lock() = Some("s".into()); + + let captured = Arc::new(Mutex::new(None::)); + let captured_params = Arc::clone(&captured); + rt.set_mock_handler(Box::new(move |method, params| { + assert_eq!(method, "vcs.stash_push"); + *captured_params.lock().expect("lock params") = Some(params.clone()); + Ok(json!("stash@{1}")) + })); + + let result = rt + .vcs_stash_push( + Some("WIP"), + true, + &["src/lib.rs".to_string(), "README.md".to_string()], + ) + .unwrap(); + + assert_eq!(result, "stash@{1}"); + assert_eq!( + captured.lock().expect("lock params").as_ref(), + Some(&json!({ + "session_id": "s", + "message": "WIP", + "include_untracked": true, + "paths": ["src/lib.rs", "README.md"] + })) + ); +} + +#[test] +fn vcs_stash_push_without_message_returns_selector() { + let rt = test_runtime(); + *rt.vcs_session_id.lock() = Some("s".into()); + mock_response(&rt, json!("stash@{1}")); + let result = rt.vcs_stash_push(None::<&str>, true, &[]).unwrap(); + assert_eq!(result, "stash@{1}"); +} + +#[test] +fn vcs_stash_show_returns_output() { + let rt = test_runtime(); + *rt.vcs_session_id.lock() = Some("s".into()); + mock_response(&rt, json!("stash content")); + let result = rt.vcs_stash_show("stash@{0}").unwrap(); + assert_eq!(result, "stash content"); +} + +#[test] +fn vcs_commit_returns_oid() { + let rt = test_runtime(); + *rt.vcs_session_id.lock() = Some("s".into()); + mock_response(&rt, json!("abc123def")); + let oid = rt + .vcs_commit("msg", "Alice", "alice@example.com", &["file.rs".into()]) + .unwrap(); + assert_eq!(oid, "abc123def"); +} + +#[test] +fn vcs_commit_index_returns_oid() { + let rt = test_runtime(); + *rt.vcs_session_id.lock() = Some("s".into()); + mock_response(&rt, json!("def456ghi")); + let oid = rt.vcs_commit_index("msg", "Alice", "alice@example.com").unwrap(); + assert_eq!(oid, "def456ghi"); +} + +#[test] +fn vcs_get_status_payload_returns_status() { + let rt = test_runtime(); + *rt.vcs_session_id.lock() = Some("s".into()); + mock_response(&rt, json!({ + "files": [], + "ahead": 0, + "behind": 0, + "branch_on_remote": true + })); + let payload = rt.vcs_get_status_payload().unwrap(); + assert!(payload.files.is_empty()); + assert!(payload.branch_on_remote); +} + +// --- Error paths --- + +#[test] +fn session_params_fails_when_no_session() { + let rt = test_runtime(); + mock_response(&rt, Value::Null); + let err = rt.vcs_list_branches().unwrap_err(); + assert_eq!(err, "vcs session is not open"); +} + +#[test] +fn vcs_commit_forwards_rpc_error() { + let rt = test_runtime(); + *rt.vcs_session_id.lock() = Some("s".into()); + mock_error(&rt, "commit rejected"); + let err = rt + .vcs_commit("msg", "Alice", "alice@example.com", &["file.rs".into()]) + .unwrap_err(); + assert_eq!(err, "commit rejected"); +} + +#[test] +fn vcs_open_forwards_rpc_decode_error_as_runtime_error() { + let rt = test_runtime(); + // Return non-matching shape for OpenSessionResponse + mock_response(&rt, json!({"wrong_field": "value"})); + let err = rt.vcs_open("/repo", b"").unwrap_err(); + assert!(err.contains("decode")); +} diff --git a/Backend/tests/plugin_runtime/runtime_select.rs b/Backend/tests/plugin_runtime/runtime_select.rs index 53ee441e..9fe9ee77 100644 --- a/Backend/tests/plugin_runtime/runtime_select.rs +++ b/Backend/tests/plugin_runtime/runtime_select.rs @@ -1,8 +1,10 @@ // Copyright © 2025-2026 OpenVCS Contributors // SPDX-License-Identifier: GPL-3.0-or-later -use super::is_node_module; +use super::{create_node_runtime_instance, create_runtime_instance, is_node_module}; +use crate::plugin_runtime::spawn::SpawnConfig; use std::fs; +use std::path::PathBuf; use tempfile::tempdir; #[test] @@ -24,3 +26,89 @@ fn mjs_path_is_detected_as_node_module() { assert!(is_node_module(&script_path)); } + +#[test] +fn js_and_cjs_paths_are_detected_as_node_modules() { + let temp = tempdir().expect("tempdir"); + + let js_path = temp.path().join("plugin.js"); + fs::write(&js_path, b"export {}\n").expect("write js script"); + assert!(is_node_module(&js_path)); + + let cjs_path = temp.path().join("plugin.cjs"); + fs::write(&cjs_path, b"module.exports = {}\n").expect("write cjs script"); + assert!(is_node_module(&cjs_path)); +} + +#[test] +fn uppercase_extensions_and_non_files_are_handled_consistently() { + let temp = tempdir().expect("tempdir"); + + let upper_js = temp.path().join("PLUGIN.JS"); + fs::write(&upper_js, b"export {}\n").expect("write upper js script"); + assert!(is_node_module(&upper_js)); + + let dir_path = temp.path().join("plugin_dir"); + fs::create_dir_all(&dir_path).expect("create dir"); + assert!(!is_node_module(&dir_path)); + + assert!(!is_node_module(&temp.path().join("missing.js"))); +} + +#[test] +fn create_node_runtime_instance_accepts_js_file() { + let temp = tempfile::tempdir().expect("tempdir"); + let js_path = temp.path().join("plugin.mjs"); + std::fs::write(&js_path, b"export {}").expect("write"); + + let result = create_node_runtime_instance(SpawnConfig { + plugin_id: "test".into(), + exec_path: js_path, + allowed_workspace_root: None, + is_vcs_backend: false, + }); + assert!(result.is_ok()); +} + +#[test] +fn create_node_runtime_instance_rejects_non_js_file() { + let result = create_node_runtime_instance(SpawnConfig { + plugin_id: "test".into(), + exec_path: PathBuf::from("plugin.bin"), + allowed_workspace_root: None, + is_vcs_backend: false, + }); + match result { + Err(msg) => assert!(msg.contains("must be a .js/.mjs/.cjs")), + Ok(_) => panic!("expected error"), + } +} + +#[test] +fn create_runtime_instance_accepts_valid_path() { + let temp = tempfile::tempdir().expect("tempdir"); + let js_path = temp.path().join("plugin.mjs"); + std::fs::write(&js_path, b"export {}").expect("write"); + + let result = create_runtime_instance(SpawnConfig { + plugin_id: "test".into(), + exec_path: js_path, + allowed_workspace_root: None, + is_vcs_backend: false, + }); + assert!(result.is_ok()); +} + +#[test] +fn create_runtime_instance_rejects_invalid_path() { + let result = create_runtime_instance(SpawnConfig { + plugin_id: "test".into(), + exec_path: PathBuf::from("plugin.bin"), + allowed_workspace_root: None, + is_vcs_backend: false, + }); + match result { + Err(msg) => assert!(msg.contains("must be a .js/.mjs/.cjs")), + Ok(_) => panic!("expected error"), + } +} diff --git a/Backend/tests/plugin_runtime/settings_store.rs b/Backend/tests/plugin_runtime/settings_store.rs new file mode 100644 index 00000000..a963b564 --- /dev/null +++ b/Backend/tests/plugin_runtime/settings_store.rs @@ -0,0 +1,69 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +use super::{ + clear_test_plugin_data_root, load_settings, reset_settings, save_settings, + set_test_plugin_data_root, +}; +use serde_json::json; +use std::sync::{Mutex, OnceLock}; +use std::fs; +use tempfile::tempdir; + +fn test_lock() -> std::sync::MutexGuard<'static, ()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())).lock().expect("lock") +} + +#[test] +fn saves_loads_and_resets_plugin_settings() { + let _guard = test_lock(); + let temp = tempdir().expect("tempdir"); + set_test_plugin_data_root(temp.path().join("plugin-data")); + + let mut settings = serde_json::Map::new(); + settings.insert("theme".into(), json!("dark")); + settings.insert("count".into(), json!(3)); + + save_settings("OpenVCS.Git", &settings).expect("save settings"); + + let path = temp + .path() + .join("plugin-data") + .join("openvcs.git") + .join("settings.json"); + assert!(path.is_file()); + + let loaded = load_settings("openvcs.git").expect("load settings"); + assert_eq!(loaded.get("theme"), Some(&json!("dark"))); + assert_eq!(loaded.get("count"), Some(&json!(3))); + + reset_settings("openvcs.git").expect("reset settings"); + assert!(!path.is_file()); + + clear_test_plugin_data_root(); +} + +#[test] +fn loads_empty_map_when_settings_file_is_missing() { + let _guard = test_lock(); + let temp = tempdir().expect("tempdir"); + set_test_plugin_data_root(temp.path().join("plugin-data")); + + let loaded = load_settings("missing.plugin").expect("load missing settings"); + assert!(loaded.is_empty()); + + clear_test_plugin_data_root(); +} + +#[test] +fn reset_settings_is_a_noop_for_missing_files() { + let _guard = test_lock(); + let temp = tempdir().expect("tempdir"); + set_test_plugin_data_root(temp.path().join("plugin-data")); + + reset_settings("missing.plugin").expect("reset missing settings"); + assert!(!temp.path().join("plugin-data").exists() || fs::read_dir(temp.path().join("plugin-data")).expect("dir").next().is_none()); + + clear_test_plugin_data_root(); +} diff --git a/Backend/tests/plugin_runtime/vcs_proxy.rs b/Backend/tests/plugin_runtime/vcs_proxy.rs new file mode 100644 index 00000000..09e4b608 --- /dev/null +++ b/Backend/tests/plugin_runtime/vcs_proxy.rs @@ -0,0 +1,534 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +use super::{path_to_utf8, PluginVcsProxy}; +use crate::core::models::{LogQuery, OnEvent}; +use crate::core::{BackendId, Vcs, VcsError}; +use crate::plugin_runtime::node_instance::NodePluginRuntimeInstance; +use crate::plugin_runtime::spawn::SpawnConfig; +use serde_json::{json, Value}; +use std::path::PathBuf; +use std::sync::{Arc, Mutex}; + +fn test_runtime() -> Arc { + Arc::new(NodePluginRuntimeInstance::new(SpawnConfig { + plugin_id: "demo.plugin".into(), + exec_path: PathBuf::from("bin/plugin.mjs"), + allowed_workspace_root: None, + is_vcs_backend: true, + })) +} + +fn mock_proxy() -> (PluginVcsProxy, Arc) { + let runtime = test_runtime(); + let proxy = PluginVcsProxy { + backend_id: BackendId::from("git"), + workdir: PathBuf::from("/tmp/repo"), + runtime: Arc::clone(&runtime), + }; + (proxy, runtime) +} + +fn set_unit_response(runtime: &NodePluginRuntimeInstance) { + runtime.set_mock_handler(Box::new(|_, _| Ok(Value::Null))); +} + +fn set_response(runtime: &NodePluginRuntimeInstance, value: Value) { + runtime.set_mock_handler(Box::new(move |_, _| Ok(value.clone()))); +} + +fn set_error(runtime: &NodePluginRuntimeInstance, msg: &str) { + let msg = msg.to_string(); + runtime.set_mock_handler(Box::new(move |_, _| Err(msg.clone()))); +} + +// ── Accessor tests ── + +#[test] +fn proxy_id_returns_backend_id() { + let proxy = mock_proxy().0; + assert_eq!(proxy.id(), "git"); +} + +#[test] +fn proxy_workdir_returns_path() { + let proxy = mock_proxy().0; + assert_eq!(proxy.workdir(), PathBuf::from("/tmp/repo")); +} + +// ── Unit-returning VCS delegation tests ── + +#[test] +fn proxy_create_branch_delegates() { + let (proxy, rt) = mock_proxy(); + rt.set_session_id(Some("s".into())); + set_unit_response(&rt); + assert!(proxy.create_branch("feat", true).is_ok()); +} + +#[test] +fn proxy_create_branch_forwards_error() { + let (proxy, rt) = mock_proxy(); + rt.set_session_id(Some("s".into())); + set_error(&rt, "boom"); + assert!(matches!(proxy.create_branch("feat", true), Err(VcsError::Backend { .. }))); +} + +#[test] +fn proxy_checkout_branch_delegates() { + let (proxy, rt) = mock_proxy(); + rt.set_session_id(Some("s".into())); + set_unit_response(&rt); + assert!(proxy.checkout_branch("main").is_ok()); +} + +#[test] +fn proxy_ensure_remote_delegates() { + let (proxy, rt) = mock_proxy(); + rt.set_session_id(Some("s".into())); + set_unit_response(&rt); + assert!(proxy.ensure_remote("origin", "https://example.com/repo").is_ok()); +} + +#[test] +fn proxy_remove_remote_delegates() { + let (proxy, rt) = mock_proxy(); + rt.set_session_id(Some("s".into())); + set_unit_response(&rt); + assert!(proxy.remove_remote("upstream").is_ok()); +} + +#[test] +fn proxy_fetch_with_events_delegates() { + let (proxy, rt) = mock_proxy(); + rt.set_session_id(Some("s".into())); + set_unit_response(&rt); + let on: OnEvent = Arc::new(|_| {}); + assert!(proxy.fetch("origin", "main", Some(on)).is_ok()); +} + +#[test] +fn proxy_push_with_events_delegates() { + let (proxy, rt) = mock_proxy(); + rt.set_session_id(Some("s".into())); + set_unit_response(&rt); + let on: OnEvent = Arc::new(|_| {}); + assert!(proxy.push("origin", "main", Some(on)).is_ok()); +} + +#[test] +fn proxy_pull_ff_only_with_events_delegates() { + let (proxy, rt) = mock_proxy(); + rt.set_session_id(Some("s".into())); + set_unit_response(&rt); + let on: OnEvent = Arc::new(|_| {}); + assert!(proxy.pull_ff_only("origin", "main", Some(on)).is_ok()); +} + +#[test] +fn proxy_fetch_without_events_delegates() { + let (proxy, rt) = mock_proxy(); + rt.set_session_id(Some("s".into())); + set_unit_response(&rt); + assert!(proxy.fetch("origin", "main", None).is_ok()); +} + +#[test] +fn proxy_stage_patch_delegates() { + let (proxy, rt) = mock_proxy(); + rt.set_session_id(Some("s".into())); + set_unit_response(&rt); + assert!(proxy.stage_patch("diff ...").is_ok()); +} + +#[test] +fn proxy_stage_paths_delegates() { + let (proxy, rt) = mock_proxy(); + rt.set_session_id(Some("s".into())); + set_unit_response(&rt); + assert!(proxy.stage_paths(&[PathBuf::from("file.rs")]).is_ok()); +} + +#[test] +fn proxy_discard_paths_delegates() { + let (proxy, rt) = mock_proxy(); + rt.set_session_id(Some("s".into())); + set_unit_response(&rt); + assert!(proxy.discard_paths(&[PathBuf::from("file.rs")]).is_ok()); +} + +#[test] +fn proxy_apply_reverse_patch_delegates() { + let (proxy, rt) = mock_proxy(); + rt.set_session_id(Some("s".into())); + set_unit_response(&rt); + assert!(proxy.apply_reverse_patch("diff ...").is_ok()); +} + +#[test] +fn proxy_delete_branch_delegates() { + let (proxy, rt) = mock_proxy(); + rt.set_session_id(Some("s".into())); + set_unit_response(&rt); + assert!(proxy.delete_branch("old", false).is_ok()); +} + +#[test] +fn proxy_rename_branch_delegates() { + let (proxy, rt) = mock_proxy(); + rt.set_session_id(Some("s".into())); + set_unit_response(&rt); + assert!(proxy.rename_branch("a", "b").is_ok()); +} + +#[test] +fn proxy_merge_into_current_delegates() { + let (proxy, rt) = mock_proxy(); + rt.set_session_id(Some("s".into())); + set_unit_response(&rt); + assert!(proxy.merge_into_current("feature").is_ok()); +} + +#[test] +fn proxy_merge_into_current_with_message_delegates() { + let (proxy, rt) = mock_proxy(); + rt.set_session_id(Some("s".into())); + set_unit_response(&rt); + assert!(proxy.merge_into_current_with_message("feature", Some("msg")).is_ok()); +} + +#[test] +fn proxy_merge_abort_delegates() { + let (proxy, rt) = mock_proxy(); + rt.set_session_id(Some("s".into())); + set_unit_response(&rt); + assert!(proxy.merge_abort().is_ok()); +} + +#[test] +fn proxy_merge_continue_delegates() { + let (proxy, rt) = mock_proxy(); + rt.set_session_id(Some("s".into())); + set_unit_response(&rt); + assert!(proxy.merge_continue().is_ok()); +} + +#[test] +fn proxy_set_branch_upstream_delegates() { + let (proxy, rt) = mock_proxy(); + rt.set_session_id(Some("s".into())); + set_unit_response(&rt); + assert!(proxy.set_branch_upstream("main", "origin/main").is_ok()); +} + +#[test] +fn proxy_reset_soft_to_delegates() { + let (proxy, rt) = mock_proxy(); + rt.set_session_id(Some("s".into())); + set_unit_response(&rt); + assert!(proxy.reset_soft_to("abc123").is_ok()); +} + +#[test] +fn proxy_set_identity_local_delegates() { + let (proxy, rt) = mock_proxy(); + rt.set_session_id(Some("s".into())); + set_unit_response(&rt); + assert!(proxy.set_identity_local("Alice", "alice@example.com").is_ok()); +} + +#[test] +fn proxy_stash_apply_delegates() { + let (proxy, rt) = mock_proxy(); + rt.set_session_id(Some("s".into())); + set_unit_response(&rt); + assert!(proxy.stash_apply("stash@{0}").is_ok()); +} + +#[test] +fn proxy_stash_pop_delegates() { + let (proxy, rt) = mock_proxy(); + rt.set_session_id(Some("s".into())); + set_unit_response(&rt); + assert!(proxy.stash_pop("stash@{0}").is_ok()); +} + +#[test] +fn proxy_stash_drop_delegates() { + let (proxy, rt) = mock_proxy(); + rt.set_session_id(Some("s".into())); + set_unit_response(&rt); + assert!(proxy.stash_drop("stash@{0}").is_ok()); +} + +#[test] +fn proxy_cherry_pick_delegates() { + let (proxy, rt) = mock_proxy(); + rt.set_session_id(Some("s".into())); + set_unit_response(&rt); + assert!(proxy.cherry_pick("abc123").is_ok()); +} + +#[test] +fn proxy_revert_commit_delegates() { + let (proxy, rt) = mock_proxy(); + rt.set_session_id(Some("s".into())); + set_unit_response(&rt); + assert!(proxy.revert_commit("abc123", true).is_ok()); +} + +#[test] +fn proxy_checkout_conflict_side_delegates() { + let (proxy, rt) = mock_proxy(); + rt.set_session_id(Some("s".into())); + set_unit_response(&rt); + assert!(proxy.checkout_conflict_side( + PathBuf::from("file.txt").as_path(), + crate::core::models::ConflictSide::Ours, + ).is_ok()); +} + +#[test] +fn proxy_write_merge_result_delegates() { + let (proxy, rt) = mock_proxy(); + rt.set_session_id(Some("s".into())); + set_unit_response(&rt); + assert!(proxy.write_merge_result(PathBuf::from("file.txt").as_path(), b"content").is_ok()); +} + +// ── Typed-return VCS delegation tests ── + +#[test] +fn proxy_current_branch_returns_name() { + let (proxy, rt) = mock_proxy(); + rt.set_session_id(Some("s".into())); + set_response(&rt, json!("main")); + assert_eq!(proxy.current_branch().unwrap(), Some("main".into())); +} + +#[test] +fn proxy_current_branch_returns_none() { + let (proxy, rt) = mock_proxy(); + rt.set_session_id(Some("s".into())); + set_response(&rt, Value::Null); + assert_eq!(proxy.current_branch().unwrap(), None); +} + +#[test] +fn proxy_branches_returns_list() { + let (proxy, rt) = mock_proxy(); + rt.set_session_id(Some("s".into())); + set_response(&rt, json!([ + {"name": "main", "full_ref": "refs/heads/main", "kind": {"type": "Local"}, "current": true} + ])); + let branches = proxy.branches().unwrap(); + assert_eq!(branches.len(), 1); + assert_eq!(branches[0].name, "main"); +} + +#[test] +fn proxy_list_remotes_returns_pairs() { + let (proxy, rt) = mock_proxy(); + rt.set_session_id(Some("s".into())); + set_response(&rt, json!([ + {"name": "origin", "url": "https://example.com/repo"} + ])); + let remotes = proxy.list_remotes().unwrap(); + assert_eq!(remotes, vec![("origin".into(), "https://example.com/repo".into())]); +} + +#[test] +fn proxy_commit_returns_oid() { + let (proxy, rt) = mock_proxy(); + rt.set_session_id(Some("s".into())); + set_response(&rt, json!("abc123")); + let oid = proxy + .commit("msg", "Alice", "alice@example.com", &[PathBuf::from("f.rs")]) + .unwrap(); + assert_eq!(oid, "abc123"); +} + +#[test] +fn proxy_commit_index_returns_oid() { + let (proxy, rt) = mock_proxy(); + rt.set_session_id(Some("s".into())); + set_response(&rt, json!("def456")); + assert_eq!( + proxy.commit_index("msg", "Alice", "alice@example.com").unwrap(), + "def456" + ); +} + +#[test] +fn proxy_status_payload_returns_status() { + let (proxy, rt) = mock_proxy(); + rt.set_session_id(Some("s".into())); + set_response(&rt, json!({"files": [], "ahead": 0, "behind": 0, "branch_on_remote": true})); + let status = proxy.status_payload().unwrap(); + assert!(status.files.is_empty()); + assert!(status.branch_on_remote); +} + +#[test] +fn proxy_log_commits_returns_list() { + let (proxy, rt) = mock_proxy(); + rt.set_session_id(Some("s".into())); + set_response(&rt, json!([ + {"id": "abc", "msg": "first", "meta": "2024-01-01", "author": "Alice"} + ])); + let commits = proxy.log_commits(&LogQuery::default()).unwrap(); + assert_eq!(commits.len(), 1); +} + +#[test] +fn proxy_diff_file_returns_lines() { + let (proxy, rt) = mock_proxy(); + rt.set_session_id(Some("s".into())); + set_response(&rt, json!(["-old", "+new"])); + let lines = proxy.diff_file(PathBuf::from("file.rs").as_path()).unwrap(); + assert_eq!(lines, vec!["-old", "+new"]); +} + +#[test] +fn proxy_diff_commit_returns_lines() { + let (proxy, rt) = mock_proxy(); + rt.set_session_id(Some("s".into())); + set_response(&rt, json!(["diff ..."])); + let lines = proxy.diff_commit("abc123").unwrap(); + assert_eq!(lines, vec!["diff ..."]); +} + +#[test] +fn proxy_conflict_details_returns_details() { + let (proxy, rt) = mock_proxy(); + rt.set_session_id(Some("s".into())); + set_response(&rt, json!({ + "path": "file.txt", "ours": "our", "theirs": "their", + "base": "base", "binary": false, "lfs_pointer": false + })); + let details = proxy.conflict_details(PathBuf::from("file.txt").as_path()).unwrap(); + assert_eq!(details.path, "file.txt"); + assert_eq!(details.ours.unwrap(), "our"); +} + +#[test] +fn proxy_merge_in_progress_returns_bool() { + let (proxy, rt) = mock_proxy(); + rt.set_session_id(Some("s".into())); + set_response(&rt, json!(true)); + assert!(proxy.merge_in_progress().unwrap()); +} + +#[test] +fn proxy_branch_upstream_returns_name() { + let (proxy, rt) = mock_proxy(); + rt.set_session_id(Some("s".into())); + set_response(&rt, json!("origin/main")); + assert_eq!(proxy.branch_upstream("main").unwrap(), Some("origin/main".into())); +} + +#[test] +fn proxy_get_identity_returns_info() { + let (proxy, rt) = mock_proxy(); + rt.set_session_id(Some("s".into())); + set_response(&rt, json!({"name": "Alice", "email": "alice@example.com"})); + assert_eq!( + proxy.get_identity().unwrap(), + Some(("Alice".into(), "alice@example.com".into())) + ); +} + +#[test] +fn proxy_stash_list_returns_list() { + let (proxy, rt) = mock_proxy(); + rt.set_session_id(Some("s".into())); + set_response(&rt, json!([ + {"selector": "stash@{0}", "msg": "WIP", "meta": "2024-01-01"} + ])); + let stashes = proxy.stash_list().unwrap(); + assert_eq!(stashes.len(), 1); + assert_eq!(stashes[0].selector, "stash@{0}"); +} + +#[test] +fn proxy_stash_push_returns_selector() { + let (proxy, rt) = mock_proxy(); + rt.set_session_id(Some("s".into())); + set_response(&rt, json!("stash@{0}")); + assert_eq!(proxy.stash_push("WIP", true, &[]).unwrap(), ()); +} + +#[test] +fn proxy_stash_push_forwards_paths() { + let (proxy, rt) = mock_proxy(); + rt.set_session_id(Some("s".into())); + + let captured = Arc::new(Mutex::new(None::)); + let captured_params = Arc::clone(&captured); + rt.set_mock_handler(Box::new(move |method, params| { + assert_eq!(method, "vcs.stash_push"); + *captured_params.lock().expect("lock params") = Some(params.clone()); + Ok(json!("stash@{1}")) + })); + + proxy + .stash_push( + "WIP", + true, + &[PathBuf::from("src/lib.rs"), PathBuf::from("README.md")], + ) + .unwrap(); + + assert_eq!( + captured.lock().expect("lock params").as_ref(), + Some(&json!({ + "session_id": "s", + "message": "WIP", + "include_untracked": true, + "paths": ["src/lib.rs", "README.md"] + })) + ); + + rt.set_session_id(None); +} + +#[test] +fn proxy_stash_push_empty_message_uses_none() { + let (proxy, rt) = mock_proxy(); + rt.set_session_id(Some("s".into())); + set_response(&rt, json!("stash@{0}")); + assert!(proxy.stash_push(" ", true, &[]).is_ok()); +} + +#[test] +fn proxy_stash_show_returns_lines() { + let (proxy, rt) = mock_proxy(); + rt.set_session_id(Some("s".into())); + set_response(&rt, json!("line1\nline2")); + let lines = proxy.stash_show("stash@{0}").unwrap(); + assert_eq!(lines, vec!["line1", "line2"]); +} + +// ── Existing tests ── + +#[test] +fn maps_runtime_errors() { + let proxy = mock_proxy().0; + assert!(matches!(proxy.map_runtime_error("no upstream configured".into()), VcsError::NoUpstream)); + assert!(matches!(proxy.map_runtime_error("boom".into()), VcsError::Backend { .. })); +} + +#[test] +fn converts_utf8_paths() { + assert_eq!(path_to_utf8(std::path::Path::new("/tmp/repo")).unwrap(), "/tmp/repo"); +} + +#[cfg(unix)] +#[test] +fn rejects_non_utf8_paths() { + use std::ffi::OsString; + use std::os::unix::ffi::OsStringExt; + + let path = std::path::PathBuf::from(OsString::from_vec(vec![0xff, 0xfe])); + assert!(path_to_utf8(&path).is_err()); +} diff --git a/Backend/tests/tauri_commands/backends.rs b/Backend/tests/tauri_commands/backends.rs new file mode 100644 index 00000000..d434cd5e --- /dev/null +++ b/Backend/tests/tauri_commands/backends.rs @@ -0,0 +1,116 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +use crate::core::BackendId; +use crate::settings; +use crate::state::AppState; +use tauri::test::{get_ipc_response, mock_builder, mock_context, noop_assets, INVOKE_KEY}; +use tauri::webview::InvokeRequest; +use tauri::WebviewWindowBuilder; + +use super::{auto_default_backend_id, backend_display_label}; + +#[test] +fn picks_backend_display_labels_from_backend_name_plugin_name_or_id() { + let backend_id = BackendId::from("openvcs.git"); + + assert_eq!( + backend_display_label(Some(" Git "), Some("Plugin"), &backend_id), + "Git" + ); + assert_eq!( + backend_display_label(None, Some(" Plugin Git "), &backend_id), + "Plugin Git" + ); + assert_eq!(backend_display_label(None, None, &backend_id), "openvcs.git"); +} + +#[test] +fn auto_selects_the_only_backend_when_default_differs() { + let selected = auto_default_backend_id( + "other", + &[("openvcs.git".into(), "Git".into())], + ); + assert_eq!(selected, Some("openvcs.git".into())); +} + +#[test] +fn skips_auto_selection_when_backend_is_already_default_or_not_unique() { + assert_eq!( + auto_default_backend_id("openvcs.git", &[("openvcs.git".into(), "Git".into())]), + None + ); + assert_eq!( + auto_default_backend_id( + "", + &[("git".into(), "Git".into()), ("hg".into(), "Hg".into())], + ), + None + ); +} + +// ── Tauri IPC integration tests ── + +fn build_app() -> tauri::App { + crate::app_identity::setup_test_isolation(); + let cfg = settings::AppConfig::default(); + let app_state = AppState::new_with_config(cfg); + mock_builder() + .manage(app_state) + .invoke_handler(tauri::generate_handler![ + super::list_vcs_backends_cmd, + super::current_vcs_action_labels, + ]) + .build(mock_context(noop_assets())) + .expect("build test app") +} + +fn test_webview( + app: &tauri::App, +) -> tauri::WebviewWindow { + WebviewWindowBuilder::new(app, "main", Default::default()) + .build() + .expect("build test webview") +} + +fn invoke_cmd( + webview: &tauri::WebviewWindow, + cmd: &str, + body: tauri::ipc::InvokeBody, +) -> Result { + get_ipc_response( + webview, + InvokeRequest { + cmd: cmd.into(), + callback: tauri::ipc::CallbackFn(0), + error: tauri::ipc::CallbackFn(1), + url: "tauri://localhost".parse().unwrap(), + body, + headers: Default::default(), + invoke_key: INVOKE_KEY.to_string(), + }, + ) +} + +#[test] +fn list_vcs_backends_returns_backend_entries() { + let app = build_app(); + let webview = test_webview(&app); + + let res = invoke_cmd(&webview, "list_vcs_backends_cmd", tauri::ipc::InvokeBody::default()); + assert!(res.is_ok(), "list_vcs_backends_cmd should succeed: {:?}", res); + let backends: Vec<(String, String)> = res.unwrap().deserialize().unwrap(); + for (id, label) in &backends { + assert!(!id.is_empty(), "each backend should have a non-empty id"); + assert!(!label.is_empty(), "each backend should have a non-empty label"); + } +} + +#[test] +fn current_vcs_action_labels_fails_when_no_repo() { + let app = build_app(); + let webview = test_webview(&app); + + let res = invoke_cmd(&webview, "current_vcs_action_labels", tauri::ipc::InvokeBody::default()); + assert!(res.is_err(), "current_vcs_action_labels should fail without repo"); +} diff --git a/Backend/tests/tauri_commands/branches.rs b/Backend/tests/tauri_commands/branches.rs new file mode 100644 index 00000000..cf6eb63a --- /dev/null +++ b/Backend/tests/tauri_commands/branches.rs @@ -0,0 +1,346 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use super::{apply_merge_template, repo_name_from_origin, repo_username_from_origin}; +use crate::core::{BackendId, Vcs, VcsError, models}; +use crate::plugin_vcs_backends::{self, PluginBackendDescriptor}; +use crate::repo::Repo; +use crate::settings; +use crate::state::AppState; +use tauri::test::{get_ipc_response, mock_builder, mock_context, noop_assets, INVOKE_KEY}; +use tauri::ipc::InvokeResponseBody; +use tauri::webview::InvokeRequest; +use tauri::WebviewWindowBuilder; + +// ── Pure function tests ── + +#[test] +fn parses_repo_owner_and_name_from_urls() { + assert_eq!(repo_username_from_origin("https://example.com/org/repo.git"), Some("org".into())); + assert_eq!(repo_username_from_origin("git@example.com:org/repo.git"), Some("org".into())); + assert_eq!(repo_username_from_origin("http://example.com/team/repo"), Some("team".into())); + assert_eq!(repo_name_from_origin("https://example.com/org/repo.git"), Some("repo".into())); + assert_eq!(repo_name_from_origin("git@example.com:org/repo"), Some("repo".into())); + assert_eq!(repo_name_from_origin("http://example.com/team/repo"), Some("repo".into())); +} + +#[test] +fn expands_merge_templates() { + let rendered = apply_merge_template( + "Merge {branch:source} into {branch:target} for {repo:username}/{repo:name}", + "feature", + "main", + "demo", + "alice", + ); + assert_eq!(rendered, "Merge feature into main for alice/demo"); +} + +#[test] +fn rejects_empty_or_unparseable_origin_urls() { + assert_eq!(repo_username_from_origin(" "), None); + assert_eq!(repo_username_from_origin("owner-only"), None); + assert_eq!(repo_name_from_origin(" "), None); + assert_eq!(repo_name_from_origin("owner-only"), None); +} + +#[test] +fn leaves_unknown_merge_placeholders_untouched() { + let rendered = apply_merge_template( + "Merge {branch:source} into {repo:unknown}", + "feature", + "main", + "demo", + "alice", + ); + assert_eq!(rendered, "Merge feature into {repo:unknown}"); +} + +// ── Vcs-backed IPC command tests ── + +struct TestVcs { + id: BackendId, + workdir: PathBuf, + current_branch: Option, + branches: Vec, +} + +impl TestVcs { + fn new(id: &str, workdir: PathBuf) -> Self { + Self { + id: BackendId::from(id), + workdir, + current_branch: Some("main".into()), + branches: vec![ + models::BranchItem { + name: "main".into(), + full_ref: "refs/heads/main".into(), + kind: models::BranchKind::Local, + current: true, + }, + models::BranchItem { + name: "develop".into(), + full_ref: "refs/heads/develop".into(), + kind: models::BranchKind::Local, + current: false, + }, + ], + } + } + + fn unsupported(&self) -> Result { + Err(VcsError::Unsupported(self.id.clone())) + } +} + +impl Vcs for TestVcs { + fn id(&self) -> BackendId { self.id.clone() } + fn workdir(&self) -> &Path { &self.workdir } + + fn current_branch(&self) -> Result, VcsError> { + Ok(self.current_branch.clone()) + } + + fn branches(&self) -> Result, VcsError> { + Ok(self.branches.clone()) + } + + fn create_branch(&self, _name: &str, _checkout: bool) -> Result<(), VcsError> { self.unsupported() } + fn checkout_branch(&self, _name: &str) -> Result<(), VcsError> { self.unsupported() } + fn ensure_remote(&self, _name: &str, _url: &str) -> Result<(), VcsError> { self.unsupported() } + fn list_remotes(&self) -> Result, VcsError> { self.unsupported() } + fn remove_remote(&self, _name: &str) -> Result<(), VcsError> { self.unsupported() } + fn fetch(&self, _remote: &str, _refspec: &str, _on: Option) -> Result<(), VcsError> { self.unsupported() } + fn push(&self, _remote: &str, _refspec: &str, _on: Option) -> Result<(), VcsError> { self.unsupported() } + fn pull_ff_only(&self, _remote: &str, _branch: &str, _on: Option) -> Result<(), VcsError> { self.unsupported() } + fn commit(&self, _message: &str, _name: &str, _email: &str, _paths: &[PathBuf]) -> Result { self.unsupported() } + fn commit_index(&self, _message: &str, _name: &str, _email: &str) -> Result { self.unsupported() } + fn status_payload(&self) -> Result { self.unsupported() } + fn log_commits(&self, _query: &models::LogQuery) -> Result, VcsError> { self.unsupported() } + fn diff_file(&self, _path: &Path) -> Result, VcsError> { self.unsupported() } + fn diff_commit(&self, _rev: &str) -> Result, VcsError> { self.unsupported() } + fn stage_patch(&self, _patch: &str) -> Result<(), VcsError> { self.unsupported() } + fn stage_paths(&self, _paths: &[PathBuf]) -> Result<(), VcsError> { self.unsupported() } + fn discard_paths(&self, _paths: &[PathBuf]) -> Result<(), VcsError> { self.unsupported() } + fn apply_reverse_patch(&self, _patch: &str) -> Result<(), VcsError> { self.unsupported() } + fn delete_branch(&self, _name: &str, _force: bool) -> Result<(), VcsError> { self.unsupported() } + fn rename_branch(&self, _old: &str, _new: &str) -> Result<(), VcsError> { self.unsupported() } + fn merge_into_current(&self, _name: &str) -> Result<(), VcsError> { self.unsupported() } + fn get_identity(&self) -> Result, VcsError> { self.unsupported() } + fn set_identity_local(&self, _name: &str, _email: &str) -> Result<(), VcsError> { self.unsupported() } +} + +fn register_test_backend(backend_id: &str) { + let desc = PluginBackendDescriptor { + backend_id: BackendId::from(backend_id), + backend_name: Some("Test VCS".into()), + action_labels: BTreeMap::new(), + plugin_id: format!("test.{backend_id}"), + plugin_name: Some("Test Plugin".into()), + }; + plugin_vcs_backends::store_backends(vec![desc]); +} + +fn build_vcs_branches_app() -> (tauri::App, Arc) { + crate::app_identity::setup_test_isolation(); + let vcs = Arc::new(TestVcs::new("test-vcs", tempfile::tempdir().unwrap().keep())); + let repo = Arc::new(Repo::new(vcs.clone() as Arc)); + let cfg = settings::AppConfig::default(); + let app_state = AppState::new_with_config(cfg); + app_state.set_current_repo(repo); + + let app = mock_builder() + .manage(app_state) + .invoke_handler(tauri::generate_handler![ + super::vcs_list_branches, + super::vcs_head_status, + super::vcs_current_branch, + super::get_repo_summary, + super::vcs_checkout_branch, + super::vcs_delete_branch, + super::vcs_create_branch, + super::vcs_rename_branch, + super::vcs_merge_context, + super::vcs_merge_abort, + super::vcs_merge_continue, + super::vcs_set_upstream, + super::vcs_merge_branch, + ]) + .build(mock_context(noop_assets())) + .expect("build branches test app"); + + (app, vcs) +} + +fn test_webview( + app: &tauri::App, +) -> tauri::WebviewWindow { + WebviewWindowBuilder::new(app, "main", Default::default()) + .build() + .expect("build test webview") +} + +fn invoke_cmd( + webview: &tauri::WebviewWindow, + cmd: &str, + body: tauri::ipc::InvokeBody, +) -> Result { + get_ipc_response( + webview, + InvokeRequest { + cmd: cmd.into(), + callback: tauri::ipc::CallbackFn(0), + error: tauri::ipc::CallbackFn(1), + url: "tauri://localhost".parse().unwrap(), + body, + headers: Default::default(), + invoke_key: INVOKE_KEY.to_string(), + }, + ) +} + +#[test] +fn vcs_list_branches_returns_branch_list() { + register_test_backend("test-vcs"); + let (app, _) = build_vcs_branches_app(); + let wv = test_webview(&app); + + let res = invoke_cmd(&wv, "vcs_list_branches", tauri::ipc::InvokeBody::default()); + assert!(res.is_ok(), "vcs_list_branches should succeed: {:?}", res); + let branches: Vec = res.unwrap().deserialize().unwrap(); + assert!(!branches.is_empty(), "should return branches"); + assert_eq!(branches[0]["name"], "main"); +} + +#[test] +fn vcs_head_status_propagates_log_error() { + register_test_backend("test-vcs"); + let (app, _) = build_vcs_branches_app(); + let wv = test_webview(&app); + + let res = invoke_cmd(&wv, "vcs_head_status", tauri::ipc::InvokeBody::default()); + // TestVcs doesn't implement log_commits, so this returns Unsupported error + assert!(res.is_err(), "vcs_head_status should fail without log_commits: {:?}", res); +} + +#[test] +fn vcs_current_branch_returns_branch_name() { + register_test_backend("test-vcs"); + let (app, _) = build_vcs_branches_app(); + let wv = test_webview(&app); + + let res = invoke_cmd(&wv, "vcs_current_branch", tauri::ipc::InvokeBody::default()); + assert!(res.is_ok(), "vcs_current_branch should succeed: {:?}", res); + let branch: String = res.unwrap().deserialize().unwrap(); + assert_eq!(branch, "main"); +} + +#[test] +fn get_repo_summary_returns_summary() { + register_test_backend("test-vcs"); + let (app, _) = build_vcs_branches_app(); + let wv = test_webview(&app); + + let res = invoke_cmd(&wv, "get_repo_summary", tauri::ipc::InvokeBody::default()); + assert!(res.is_ok(), "get_repo_summary should succeed: {:?}", res); +} + +#[test] +fn vcs_checkout_branch_propagates_error() { + register_test_backend("test-vcs"); + let (app, _) = build_vcs_branches_app(); + let wv = test_webview(&app); + + let body = tauri::ipc::InvokeBody::Json(serde_json::json!({"name": "feature/x"})); + let res = invoke_cmd(&wv, "vcs_checkout_branch", body); + assert!(res.is_err(), "checkout should fail (unsupported)"); +} + +#[test] +fn vcs_delete_branch_propagates_error() { + register_test_backend("test-vcs"); + let (app, _) = build_vcs_branches_app(); + let wv = test_webview(&app); + + let body = tauri::ipc::InvokeBody::Json(serde_json::json!({"name": "feature/x", "force": false})); + let res = invoke_cmd(&wv, "vcs_delete_branch", body); + assert!(res.is_err(), "delete should fail (unsupported)"); +} + +#[test] +fn vcs_create_branch_propagates_error() { + register_test_backend("test-vcs"); + let (app, _) = build_vcs_branches_app(); + let wv = test_webview(&app); + + let body = tauri::ipc::InvokeBody::Json(serde_json::json!({"name": "new-branch", "base": "main"})); + let res = invoke_cmd(&wv, "vcs_create_branch", body); + assert!(res.is_err(), "create should fail (unsupported)"); +} + +#[test] +fn vcs_merge_abort_propagates_error() { + register_test_backend("test-vcs"); + let (app, _) = build_vcs_branches_app(); + let wv = test_webview(&app); + + let res = invoke_cmd(&wv, "vcs_merge_abort", tauri::ipc::InvokeBody::default()); + assert!(res.is_err(), "merge abort should fail (unsupported)"); +} + +#[test] +fn vcs_merge_continue_propagates_error() { + register_test_backend("test-vcs"); + let (app, _) = build_vcs_branches_app(); + let wv = test_webview(&app); + + let res = invoke_cmd(&wv, "vcs_merge_continue", tauri::ipc::InvokeBody::default()); + assert!(res.is_err(), "merge continue should fail (unsupported)"); +} + +#[test] +fn vcs_merge_context_fails_silently_when_not_in_progress() { + register_test_backend("test-vcs"); + let (app, _) = build_vcs_branches_app(); + let wv = test_webview(&app); + + let res = invoke_cmd(&wv, "vcs_merge_context", tauri::ipc::InvokeBody::default()); + let _ = res; +} + +#[test] +fn vcs_set_upstream_propagates_error() { + register_test_backend("test-vcs"); + let (app, _) = build_vcs_branches_app(); + let wv = test_webview(&app); + + let body = tauri::ipc::InvokeBody::Json(serde_json::json!({"name": "main", "upstream": "origin/main"})); + let res = invoke_cmd(&wv, "vcs_set_upstream", body); + assert!(res.is_err(), "set upstream should fail (unsupported)"); +} + +#[test] +fn vcs_rename_branch_propagates_error() { + register_test_backend("test-vcs"); + let (app, _) = build_vcs_branches_app(); + let wv = test_webview(&app); + + let body = tauri::ipc::InvokeBody::Json(serde_json::json!({"name": "old-name", "new_name": "new-name"})); + let res = invoke_cmd(&wv, "vcs_rename_branch", body); + assert!(res.is_err(), "rename should fail (unsupported)"); +} + +#[test] +fn vcs_merge_branch_propagates_error() { + register_test_backend("test-vcs"); + let (app, _) = build_vcs_branches_app(); + let wv = test_webview(&app); + + let body = tauri::ipc::InvokeBody::Json(serde_json::json!({"name": "develop"})); + let res = invoke_cmd(&wv, "vcs_merge_branch", body); + assert!(res.is_err(), "merge should fail (unsupported)"); +} diff --git a/Backend/tests/tauri_commands/commit.rs b/Backend/tests/tauri_commands/commit.rs new file mode 100644 index 00000000..0fa2fb3d --- /dev/null +++ b/Backend/tests/tauri_commands/commit.rs @@ -0,0 +1,350 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; + +use super::{build_commit_message, has_commit_selection, trimmed_non_empty}; +use crate::core::{BackendId, Vcs, VcsError, models}; +use crate::plugin_vcs_backends::{self, PluginBackendDescriptor}; +use crate::repo::Repo; +use crate::settings; +use crate::state::AppState; +use tauri::ipc::InvokeResponseBody; +use tauri::test::{get_ipc_response, mock_builder, mock_context, noop_assets, INVOKE_KEY}; +use tauri::webview::InvokeRequest; +use tauri::WebviewWindowBuilder; + +// ── Pure function tests ── + +#[test] +fn builds_commit_messages_with_optional_descriptions() { + assert_eq!(build_commit_message("Summary", ""), "Summary"); + assert_eq!(build_commit_message("Summary", " "), "Summary"); + assert_eq!( + build_commit_message("Summary", "Body text"), + "Summary\n\nBody text" + ); +} + +#[test] +fn detects_when_commit_selection_exists() { + assert!(!has_commit_selection("", 0, 0)); + assert!(!has_commit_selection(" ", 0, 0)); + assert!(has_commit_selection("patch", 0, 0)); + assert!(has_commit_selection("", 1, 0)); + assert!(has_commit_selection("", 0, 1)); +} + +#[test] +fn trims_non_empty_inputs_or_reports_errors() { + assert_eq!(trimmed_non_empty(" git ", "bad").expect("trimmed"), "git"); + assert_eq!(trimmed_non_empty("git", "bad").expect("trimmed"), "git"); + assert_eq!(trimmed_non_empty(" ", "bad").expect_err("error"), "bad"); +} + +#[test] +fn preserves_multiline_commit_descriptions() { + assert_eq!( + build_commit_message("Summary", "Line one\nLine two"), + "Summary\n\nLine one\nLine two" + ); +} + +#[test] +fn reports_empty_commit_selection_only_when_all_inputs_are_empty() { + assert!(!has_commit_selection("\n\t", 0, 0)); + assert!(has_commit_selection("", 0, 2)); +} + +#[test] +fn returns_the_supplied_error_for_blank_inputs() { + assert_eq!( + trimmed_non_empty(" \n ", "summary required").expect_err("blank"), + "summary required" + ); +} + +// ── Vcs-backed IPC command tests ── + +struct TestVcs { + id: BackendId, + workdir: PathBuf, + identity: Mutex>, + commit_result: Mutex>, +} + +impl TestVcs { + fn new(id: &str, workdir: PathBuf) -> Self { + Self { + id: BackendId::from(id), + workdir, + identity: Mutex::new(None), + commit_result: Mutex::new(None), + } + } + + fn unsupported(&self) -> Result { + Err(VcsError::Unsupported(self.id.clone())) + } +} + +impl Vcs for TestVcs { + fn id(&self) -> BackendId { self.id.clone() } + fn workdir(&self) -> &Path { &self.workdir } + + fn current_branch(&self) -> Result, VcsError> { Ok(Some("main".into())) } + fn branches(&self) -> Result, VcsError> { self.unsupported() } + fn create_branch(&self, _name: &str, _checkout: bool) -> Result<(), VcsError> { self.unsupported() } + fn checkout_branch(&self, _name: &str) -> Result<(), VcsError> { self.unsupported() } + fn ensure_remote(&self, _name: &str, _url: &str) -> Result<(), VcsError> { self.unsupported() } + fn list_remotes(&self) -> Result, VcsError> { self.unsupported() } + fn remove_remote(&self, _name: &str) -> Result<(), VcsError> { self.unsupported() } + fn fetch(&self, _remote: &str, _refspec: &str, _on: Option) -> Result<(), VcsError> { self.unsupported() } + fn push(&self, _remote: &str, _refspec: &str, _on: Option) -> Result<(), VcsError> { self.unsupported() } + fn pull_ff_only(&self, _remote: &str, _branch: &str, _on: Option) -> Result<(), VcsError> { self.unsupported() } + fn commit(&self, _message: &str, _name: &str, _email: &str, _paths: &[PathBuf]) -> Result { + self.commit_result.lock().unwrap().clone().ok_or_else(|| VcsError::Unsupported(self.id.clone())) + } + fn commit_index(&self, _message: &str, _name: &str, _email: &str) -> Result { + self.commit_result.lock().unwrap().clone().ok_or_else(|| VcsError::Unsupported(self.id.clone())) + } + fn status_payload(&self) -> Result { self.unsupported() } + fn log_commits(&self, _query: &models::LogQuery) -> Result, VcsError> { self.unsupported() } + fn diff_file(&self, _path: &Path) -> Result, VcsError> { self.unsupported() } + fn diff_commit(&self, _rev: &str) -> Result, VcsError> { self.unsupported() } + fn stage_patch(&self, _patch: &str) -> Result<(), VcsError> { self.unsupported() } + fn stage_paths(&self, _paths: &[PathBuf]) -> Result<(), VcsError> { self.unsupported() } + fn discard_paths(&self, _paths: &[PathBuf]) -> Result<(), VcsError> { self.unsupported() } + fn apply_reverse_patch(&self, _patch: &str) -> Result<(), VcsError> { self.unsupported() } + fn delete_branch(&self, _name: &str, _force: bool) -> Result<(), VcsError> { self.unsupported() } + fn rename_branch(&self, _old: &str, _new: &str) -> Result<(), VcsError> { self.unsupported() } + fn merge_into_current(&self, _name: &str) -> Result<(), VcsError> { self.unsupported() } + fn get_identity(&self) -> Result, VcsError> { Ok(self.identity.lock().unwrap().clone()) } + fn set_identity_local(&self, _name: &str, _email: &str) -> Result<(), VcsError> { self.unsupported() } + + fn cherry_pick(&self, _rev: &str) -> Result<(), VcsError> { self.unsupported() } + fn revert_commit(&self, _rev: &str, _no_edit: bool) -> Result<(), VcsError> { self.unsupported() } +} + +fn register_test_backend(backend_id: &str) { + let desc = PluginBackendDescriptor { + backend_id: BackendId::from(backend_id), + backend_name: Some("Test VCS".into()), + action_labels: BTreeMap::new(), + plugin_id: format!("test.{backend_id}"), + plugin_name: Some("Test Plugin".into()), + }; + plugin_vcs_backends::store_backends(vec![desc]); +} + +fn build_app_with_repo() -> (tauri::App, Arc) { + crate::app_identity::setup_test_isolation(); + let vcs = Arc::new(TestVcs::new("test-vcs", tempfile::tempdir().unwrap().keep())); + let repo = Arc::new(Repo::new(vcs.clone() as Arc)); + let cfg = settings::AppConfig::default(); + let app_state = AppState::new_with_config(cfg); + app_state.set_current_repo(repo); + + let app = mock_builder() + .manage(app_state) + .invoke_handler(tauri::generate_handler![ + super::commit_changes, + super::commit_selected, + super::commit_patch, + super::commit_patch_and_files, + super::vcs_cherry_pick_to_branch, + super::vcs_revert_commit, + ]) + .build(mock_context(noop_assets())) + .expect("build commit test app"); + + (app, vcs) +} + +fn build_app_no_repo() -> tauri::App { + crate::app_identity::setup_test_isolation(); + let cfg = settings::AppConfig::default(); + let app_state = AppState::new_with_config(cfg); + mock_builder() + .manage(app_state) + .invoke_handler(tauri::generate_handler![ + super::commit_changes, + super::commit_selected, + super::commit_patch, + super::commit_patch_and_files, + super::vcs_cherry_pick_to_branch, + super::vcs_revert_commit, + ]) + .build(mock_context(noop_assets())) + .expect("build commit test app") +} + +fn test_webview(app: &tauri::App) -> tauri::WebviewWindow { + WebviewWindowBuilder::new(app, "main", Default::default()) + .build() + .expect("build test webview") +} + +fn invoke_cmd( + webview: &tauri::WebviewWindow, + cmd: &str, + body: tauri::ipc::InvokeBody, +) -> Result { + get_ipc_response( + webview, + InvokeRequest { + cmd: cmd.into(), + callback: tauri::ipc::CallbackFn(0), + error: tauri::ipc::CallbackFn(1), + url: "tauri://localhost".parse().unwrap(), + body, + headers: Default::default(), + invoke_key: INVOKE_KEY.to_string(), + }, + ) +} + +#[test] +fn commit_changes_fails_without_repo() { + let app = build_app_no_repo(); + let wv = test_webview(&app); + + let body = tauri::ipc::InvokeBody::Json(serde_json::json!({ + "summary": "test commit", + "description": null, + })); + let res = invoke_cmd(&wv, "commit_changes", body); + assert!(res.is_err(), "commit_changes needs a repo: {:?}", res); +} + +#[test] +fn commit_selected_fails_without_repo() { + let app = build_app_no_repo(); + let wv = test_webview(&app); + + let body = tauri::ipc::InvokeBody::Json(serde_json::json!({ + "summary": "test", + "description": null, + "files": [], + "patch": "", + })); + let res = invoke_cmd(&wv, "commit_selected", body); + assert!(res.is_err(), "commit_selected needs a repo: {:?}", res); +} + +#[test] +fn commit_patch_fails_without_repo() { + let app = build_app_no_repo(); + let wv = test_webview(&app); + + let body = tauri::ipc::InvokeBody::Json(serde_json::json!({ + "summary": "test", + "description": null, + "patch": "@@ -1 +1 @@\n-old\n+new\n", + })); + let res = invoke_cmd(&wv, "commit_patch", body); + assert!(res.is_err(), "commit_patch needs a repo: {:?}", res); +} + +#[test] +fn commit_patch_and_files_fails_without_repo() { + let app = build_app_no_repo(); + let wv = test_webview(&app); + + let body = tauri::ipc::InvokeBody::Json(serde_json::json!({ + "summary": "test", + "description": null, + "patch": "", + "files": [], + })); + let res = invoke_cmd(&wv, "commit_patch_and_files", body); + assert!(res.is_err(), "commit_patch_and_files needs a repo: {:?}", res); +} + +#[test] +fn vcs_cherry_pick_to_branch_fails_without_repo() { + let app = build_app_no_repo(); + let wv = test_webview(&app); + + let body = tauri::ipc::InvokeBody::Json(serde_json::json!({ + "revision": "abc123", + "target_branch": "main", + })); + let res = invoke_cmd(&wv, "vcs_cherry_pick_to_branch", body); + assert!(res.is_err(), "cherry_pick needs a repo: {:?}", res); +} + +#[test] +fn vcs_revert_commit_fails_without_repo() { + let app = build_app_no_repo(); + let wv = test_webview(&app); + + let body = tauri::ipc::InvokeBody::Json(serde_json::json!({ + "revision": "abc123", + })); + let res = invoke_cmd(&wv, "vcs_revert_commit", body); + assert!(res.is_err(), "revert needs a repo: {:?}", res); +} + +#[test] +fn commit_changes_fails_with_empty_summary() { + register_test_backend("test-vcs"); + let (app, _vcs) = build_app_with_repo(); + let wv = test_webview(&app); + + let body = tauri::ipc::InvokeBody::Json(serde_json::json!({ + "summary": " ", + "description": null, + })); + let res = invoke_cmd(&wv, "commit_changes", body); + assert!(res.is_err(), "empty summary should fail: {:?}", res); +} + +#[test] +fn commit_changes_fails_without_identity() { + register_test_backend("test-vcs"); + let (app, _vcs) = build_app_with_repo(); + let wv = test_webview(&app); + + let body = tauri::ipc::InvokeBody::Json(serde_json::json!({ + "summary": "test commit", + "description": null, + })); + let res = invoke_cmd(&wv, "commit_changes", body); + assert!(res.is_err(), "no identity should fail: {:?}", res); +} + +#[test] +fn commit_changes_fails_with_unsupported_commit() { + register_test_backend("test-vcs"); + let (app, vcs) = build_app_with_repo(); + *vcs.identity.lock().unwrap() = Some(("Test User".into(), "test@example.com".into())); + let wv = test_webview(&app); + + let body = tauri::ipc::InvokeBody::Json(serde_json::json!({ + "summary": "test commit", + "description": "body text", + })); + let res = invoke_cmd(&wv, "commit_changes", body); + assert!(res.is_err(), "unsupported commit should fail: {:?}", res); +} + +#[test] +fn commit_changes_succeeds_with_valid_input() { + register_test_backend("test-vcs"); + let (app, vcs) = build_app_with_repo(); + *vcs.identity.lock().unwrap() = Some(("Test User".into(), "test@example.com".into())); + *vcs.commit_result.lock().unwrap() = Some("abc123def".into()); + let wv = test_webview(&app); + + let body = tauri::ipc::InvokeBody::Json(serde_json::json!({ + "summary": "test commit", + "description": "", + })); + let res = invoke_cmd(&wv, "commit_changes", body); + assert!(res.is_ok(), "commit_changes should succeed: {:?}", res); + let commit_id: String = res.unwrap().deserialize().unwrap(); + assert_eq!(commit_id, "abc123def"); +} diff --git a/Backend/tests/tauri_commands/conflicts.rs b/Backend/tests/tauri_commands/conflicts.rs new file mode 100644 index 00000000..58088a23 --- /dev/null +++ b/Backend/tests/tauri_commands/conflicts.rs @@ -0,0 +1,215 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use super::tool_args; +use crate::core::{BackendId, Vcs, VcsError, models}; +use crate::plugin_vcs_backends::{self, PluginBackendDescriptor}; +use crate::repo::Repo; +use crate::settings; +use crate::settings::ExternalTool; +use crate::state::AppState; +use tauri::ipc::InvokeResponseBody; +use tauri::test::{get_ipc_response, mock_builder, mock_context, noop_assets, INVOKE_KEY}; +use tauri::webview::InvokeRequest; +use tauri::WebviewWindowBuilder; + +// ── Pure function tests ── + +#[test] +fn splits_tool_path_and_arguments() { + let tool = ExternalTool { + enabled: true, + path: "/usr/bin/meld".into(), + args: "--auto-merge {path} --label \"My Repo\"".into(), + }; + + let (path, args) = tool_args(&tool); + assert_eq!(path, "/usr/bin/meld"); + assert_eq!(args, vec!["--auto-merge", "{path}", "--label", "My Repo"]); +} + +#[test] +fn returns_empty_arguments_when_tool_has_no_args() { + let tool = ExternalTool { + enabled: true, + path: "meld".into(), + args: " ".into(), + }; + + let (path, args) = tool_args(&tool); + assert_eq!(path, "meld"); + assert!(args.is_empty()); +} + +// ── Vcs-backed IPC command tests ── + +struct TestVcs { + id: BackendId, + workdir: PathBuf, +} + +impl TestVcs { + fn new(id: &str, workdir: PathBuf) -> Self { + Self { + id: BackendId::from(id), + workdir, + } + } + + fn unsupported(&self) -> Result { + Err(VcsError::Unsupported(self.id.clone())) + } +} + +impl Vcs for TestVcs { + fn id(&self) -> BackendId { self.id.clone() } + fn workdir(&self) -> &Path { &self.workdir } + + fn current_branch(&self) -> Result, VcsError> { Ok(Some("main".into())) } + fn branches(&self) -> Result, VcsError> { self.unsupported() } + fn create_branch(&self, _name: &str, _checkout: bool) -> Result<(), VcsError> { self.unsupported() } + fn checkout_branch(&self, _name: &str) -> Result<(), VcsError> { self.unsupported() } + fn ensure_remote(&self, _name: &str, _url: &str) -> Result<(), VcsError> { self.unsupported() } + fn list_remotes(&self) -> Result, VcsError> { self.unsupported() } + fn remove_remote(&self, _name: &str) -> Result<(), VcsError> { self.unsupported() } + fn fetch(&self, _remote: &str, _refspec: &str, _on: Option) -> Result<(), VcsError> { self.unsupported() } + fn push(&self, _remote: &str, _refspec: &str, _on: Option) -> Result<(), VcsError> { self.unsupported() } + fn pull_ff_only(&self, _remote: &str, _branch: &str, _on: Option) -> Result<(), VcsError> { self.unsupported() } + fn commit(&self, _message: &str, _name: &str, _email: &str, _paths: &[PathBuf]) -> Result { self.unsupported() } + fn commit_index(&self, _message: &str, _name: &str, _email: &str) -> Result { self.unsupported() } + fn status_payload(&self) -> Result { self.unsupported() } + fn log_commits(&self, _query: &models::LogQuery) -> Result, VcsError> { self.unsupported() } + fn diff_file(&self, _path: &Path) -> Result, VcsError> { self.unsupported() } + fn diff_commit(&self, _rev: &str) -> Result, VcsError> { self.unsupported() } + fn stage_patch(&self, _patch: &str) -> Result<(), VcsError> { self.unsupported() } + fn stage_paths(&self, _paths: &[PathBuf]) -> Result<(), VcsError> { self.unsupported() } + fn discard_paths(&self, _paths: &[PathBuf]) -> Result<(), VcsError> { self.unsupported() } + fn apply_reverse_patch(&self, _patch: &str) -> Result<(), VcsError> { self.unsupported() } + fn delete_branch(&self, _name: &str, _force: bool) -> Result<(), VcsError> { self.unsupported() } + fn rename_branch(&self, _old: &str, _new: &str) -> Result<(), VcsError> { self.unsupported() } + fn merge_into_current(&self, _name: &str) -> Result<(), VcsError> { self.unsupported() } + fn get_identity(&self) -> Result, VcsError> { self.unsupported() } + fn set_identity_local(&self, _name: &str, _email: &str) -> Result<(), VcsError> { self.unsupported() } +} + +fn register_test_backend(backend_id: &str) { + let desc = PluginBackendDescriptor { + backend_id: BackendId::from(backend_id), + backend_name: Some("Test VCS".into()), + action_labels: BTreeMap::new(), + plugin_id: format!("test.{backend_id}"), + plugin_name: Some("Test Plugin".into()), + }; + plugin_vcs_backends::store_backends(vec![desc]); +} + +fn build_vcs_conflicts_app() -> (tauri::App, Arc) { + crate::app_identity::setup_test_isolation(); + let vcs = Arc::new(TestVcs::new("test-vcs", tempfile::tempdir().unwrap().keep())); + let repo = Arc::new(Repo::new(vcs.clone() as Arc)); + let cfg = settings::AppConfig::default(); + let app_state = AppState::new_with_config(cfg); + app_state.set_current_repo(repo); + + let app = mock_builder() + .manage(app_state) + .invoke_handler(tauri::generate_handler![ + super::vcs_conflict_details, + super::vcs_resolve_conflict_side, + super::vcs_save_merge_result, + super::vcs_launch_merge_tool, + ]) + .build(mock_context(noop_assets())) + .expect("build conflicts test app"); + + (app, vcs) +} + +fn test_webview(app: &tauri::App) -> tauri::WebviewWindow { + WebviewWindowBuilder::new(app, "main", Default::default()) + .build() + .expect("build test webview") +} + +fn invoke_cmd( + webview: &tauri::WebviewWindow, + cmd: &str, + body: tauri::ipc::InvokeBody, +) -> Result { + get_ipc_response( + webview, + InvokeRequest { + cmd: cmd.into(), + callback: tauri::ipc::CallbackFn(0), + error: tauri::ipc::CallbackFn(1), + url: "tauri://localhost".parse().unwrap(), + body, + headers: Default::default(), + invoke_key: INVOKE_KEY.to_string(), + }, + ) +} + +#[test] +fn vcs_conflict_details_propagates_error() { + register_test_backend("test-vcs"); + let (app, _) = build_vcs_conflicts_app(); + let wv = test_webview(&app); + + let body = tauri::ipc::InvokeBody::Json(serde_json::json!({"path": "src/main.rs"})); + let res = invoke_cmd(&wv, "vcs_conflict_details", body); + // conflict_details has a default impl returning Unsupported + assert!(res.is_err(), "conflict_details should fail (unsupported): {:?}", res); +} + +#[test] +fn vcs_resolve_conflict_side_invalid_side_fails() { + register_test_backend("test-vcs"); + let (app, _) = build_vcs_conflicts_app(); + let wv = test_webview(&app); + + let body = tauri::ipc::InvokeBody::Json(serde_json::json!({"path": "src/main.rs", "side": "invalid"})); + let res = invoke_cmd(&wv, "vcs_resolve_conflict_side", body); + // The side validation happens before the Vcs call, so this returns side validation error + assert!(res.is_err(), "resolve with invalid side should fail"); +} + +#[test] +fn vcs_resolve_conflict_side_propagates_error() { + register_test_backend("test-vcs"); + let (app, _) = build_vcs_conflicts_app(); + let wv = test_webview(&app); + + let body = tauri::ipc::InvokeBody::Json(serde_json::json!({"path": "src/main.rs", "side": "ours"})); + let res = invoke_cmd(&wv, "vcs_resolve_conflict_side", body); + // checkout_conflict_side has a default impl returning Unsupported + assert!(res.is_err(), "resolve should fail (unsupported): {:?}", res); +} + +#[test] +fn vcs_save_merge_result_propagates_error() { + register_test_backend("test-vcs"); + let (app, _) = build_vcs_conflicts_app(); + let wv = test_webview(&app); + + let body = tauri::ipc::InvokeBody::Json(serde_json::json!({"path": "src/main.rs", "content": "merged content"})); + let res = invoke_cmd(&wv, "vcs_save_merge_result", body); + // write_merge_result has a default impl returning Unsupported + assert!(res.is_err(), "save merge should fail (unsupported): {:?}", res); +} + +#[test] +fn vcs_launch_merge_tool_propagates_error() { + register_test_backend("test-vcs"); + let (app, _) = build_vcs_conflicts_app(); + let wv = test_webview(&app); + + let body = tauri::ipc::InvokeBody::Json(serde_json::json!({"path": "src/main.rs"})); + let res = invoke_cmd(&wv, "vcs_launch_merge_tool", body); + // requires ExternalTool config, likely fails + let _ = res; +} diff --git a/Backend/tests/tauri_commands/general.rs b/Backend/tests/tauri_commands/general.rs new file mode 100644 index 00000000..cc2b1b67 --- /dev/null +++ b/Backend/tests/tauri_commands/general.rs @@ -0,0 +1,223 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +use std::path::Path; + +use crate::state::AppState; +use crate::settings; +use tauri::test::{get_ipc_response, mock_builder, mock_context, noop_assets, INVOKE_KEY}; +use tauri::webview::InvokeRequest; +use tauri::WebviewWindowBuilder; + +use super::{ + browse_directory_title, infer_repo_dir_from_url, recent_repo_name, resolve_default_backend_id, + validate_add_path, validate_clone_input, validate_vcs_url, +}; +use crate::core::BackendId; + +// ── Pure function tests ── + +#[test] +fn infers_repo_directory_names() { + assert_eq!(infer_repo_dir_from_url("https://example.com/org/repo.git"), "repo"); + assert_eq!(infer_repo_dir_from_url("git@example.com:org/repo"), "repo"); + assert_eq!(infer_repo_dir_from_url("https://example.com/org/repo/"), "repo"); +} + +#[test] +fn resolves_browse_directory_titles() { + assert_eq!(browse_directory_title(Some("clone_dest")), "Choose destination folder"); + assert_eq!(browse_directory_title(Some("add_repo")), "Select an existing repository folder"); + assert_eq!(browse_directory_title(Some("other")), "Select a folder"); + assert_eq!(browse_directory_title(None), "Select a folder"); +} + +#[test] +fn resolves_default_backend_from_configured_or_sorted_available_values() { + let available = vec![BackendId::from("zeta"), BackendId::from("alpha")]; + + let configured = resolve_default_backend_id("zeta", &available) + .map(|backend| backend.as_ref().to_string()); + assert_eq!(configured, Some("zeta".into())); + + let fallback = resolve_default_backend_id("missing", &available) + .map(|backend| backend.as_ref().to_string()); + assert_eq!(fallback, Some("alpha".into())); +} + +#[test] +fn derives_recent_repository_display_names() { + assert_eq!(recent_repo_name(Path::new("/tmp/demo-repo")), Some("demo-repo".into())); + assert_eq!(recent_repo_name(Path::new("/")), None); +} + +// ── Tauri command integration tests ── + +fn build_app() -> tauri::App { + let cfg = settings::AppConfig::default(); + let app_state = AppState::new_with_config(cfg); + mock_builder() + .manage(app_state) + .invoke_handler(tauri::generate_handler![ + super::about_info, + super::show_licenses, + super::list_recent_repos, + super::current_repo_path, + super::browse_directory, + super::browse_file, + super::add_repo, + super::clone_repo, + super::open_repo, + super::open_repo_dotfile, + super::open_docs, + super::exit_app, + super::check_for_updates, + ]) + .build(mock_context(noop_assets())) + .expect("build test app") +} + +fn test_webview( + app: &tauri::App, +) -> tauri::WebviewWindow { + WebviewWindowBuilder::new(app, "main", Default::default()) + .build() + .expect("build test webview") +} + +fn invoke_cmd( + webview: &tauri::WebviewWindow, + cmd: &str, + body: tauri::ipc::InvokeBody, +) -> Result { + get_ipc_response( + webview, + InvokeRequest { + cmd: cmd.into(), + callback: tauri::ipc::CallbackFn(0), + error: tauri::ipc::CallbackFn(1), + url: "tauri://localhost".parse().unwrap(), + body, + headers: Default::default(), + invoke_key: INVOKE_KEY.to_string(), + }, + ) +} + +#[test] +fn about_info_returns_metadata() { + let app = build_app(); + let webview = test_webview(&app); + let res = invoke_cmd(&webview, "about_info", tauri::ipc::InvokeBody::default()); + assert!(res.is_ok(), "about_info should succeed: {:?}", res); + let info: crate::utilities::inner::AboutInfo = res.unwrap().deserialize().unwrap(); + assert_eq!(info.name, env!("CARGO_PKG_NAME")); +} + +#[test] +fn show_licenses_returns_ok() { + let app = build_app(); + let webview = test_webview(&app); + let res = invoke_cmd(&webview, "show_licenses", tauri::ipc::InvokeBody::default()); + assert!(res.is_ok(), "show_licenses should succeed: {:?}", res); +} + +// ── Pure validation command tests (no Tauri dependencies) ── + +#[test] +fn validate_vcs_url_accepts_http_urls() { + let result = validate_vcs_url("https://github.com/user/repo.git".into()); + assert!(result.ok, "http URL should be valid"); +} + +#[test] +fn validate_vcs_url_rejects_garbage() { + let result = validate_vcs_url("not a url".into()); + assert!(!result.ok, "garbage should be invalid"); +} + +#[test] +fn validate_add_path_rejects_empty() { + let result = validate_add_path("".into()); + assert!(!result.ok, "empty path should be invalid"); +} + +#[test] +fn validate_clone_input_rejects_invalid_url() { + let result = validate_clone_input("bad".into(), "/tmp".into()); + assert!(!result.ok, "bad url + good dest should be invalid"); +} + +// ── State-only IPC command tests ── + +#[test] +fn list_recent_repos_returns_parsable_entries() { + let app = build_app(); + let webview = test_webview(&app); + + let res = invoke_cmd(&webview, "list_recent_repos", tauri::ipc::InvokeBody::default()); + assert!(res.is_ok(), "list_recent_repos should succeed: {:?}", res); + let repos: Vec = res.unwrap().deserialize().unwrap(); + for repo in &repos { + assert!(repo.get("path").and_then(|p| p.as_str()).is_some(), "each repo entry needs a path string"); + } +} + +#[test] +fn current_repo_path_returns_none_when_no_repo() { + let app = build_app(); + let webview = test_webview(&app); + + let res = invoke_cmd(&webview, "current_repo_path", tauri::ipc::InvokeBody::default()); + assert!(res.is_ok(), "current_repo_path should succeed: {:?}", res); + let path: Option = res.unwrap().deserialize().unwrap(); + assert!(path.is_none(), "repo path should be None when no repo open"); +} + +// ── Window command error path tests ── + +#[test] +fn add_repo_fails_without_backend() { + let app = build_app(); + let wv = test_webview(&app); + let body = tauri::ipc::InvokeBody::Json(serde_json::json!({ + "path": "/tmp/test-repo", + "backend_id": null, + })); + let res = invoke_cmd(&wv, "add_repo", body); + assert!(res.is_err(), "add_repo should fail without backend: {:?}", res); +} + +#[test] +fn clone_repo_fails_without_backend() { + let app = build_app(); + let wv = test_webview(&app); + let body = tauri::ipc::InvokeBody::Json(serde_json::json!({ + "url": "https://example.com/repo.git", + "dest": "/tmp/repo", + "backend_id": null, + })); + let res = invoke_cmd(&wv, "clone_repo", body); + assert!(res.is_err(), "clone_repo should fail without backend: {:?}", res); +} + +#[test] +fn open_repo_fails_without_backend() { + let app = build_app(); + let wv = test_webview(&app); + let body = tauri::ipc::InvokeBody::Json(serde_json::json!({ + "path": "/nonexistent/path", + "backend_id": null, + })); + let res = invoke_cmd(&wv, "open_repo", body); + assert!(res.is_err(), "open_repo should fail without backend: {:?}", res); +} + +#[test] +fn open_repo_dotfile_fails_without_repo() { + let app = build_app(); + let wv = test_webview(&app); + let body = tauri::ipc::InvokeBody::Json(serde_json::json!({"name": ".gitignore"})); + let res = invoke_cmd(&wv, "open_repo_dotfile", body); + assert!(res.is_err(), "open_repo_dotfile needs a repo: {:?}", res); +} diff --git a/Backend/tests/tauri_commands/monitoring.rs b/Backend/tests/tauri_commands/monitoring.rs new file mode 100644 index 00000000..69fab8a4 --- /dev/null +++ b/Backend/tests/tauri_commands/monitoring.rs @@ -0,0 +1,29 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +use crate::monitoring::{FrontendBreadcrumb, FrontendErrorReport}; + +use super::report_frontend_error; + +#[test] +fn accepts_frontend_error_payloads_without_an_active_sentry_client() { + let payload = FrontendErrorReport { + kind: "error".into(), + message: "boom".into(), + stack: Some("Error: boom".into()), + source: Some("app://index.js".into()), + line: Some(12), + column: Some(34), + url: Some("app://openvcs".into()), + user_agent: Some("OpenVCS Test".into()), + release: Some("test-release".into()), + environment: Some("test".into()), + breadcrumbs: vec![FrontendBreadcrumb { + timestamp_ms: 1, + level: "info".into(), + message: "before failure".into(), + }], + }; + + assert!(report_frontend_error(payload).is_ok()); +} diff --git a/Backend/tests/tauri_commands/output_log.rs b/Backend/tests/tauri_commands/output_log.rs new file mode 100644 index 00000000..a97b55db --- /dev/null +++ b/Backend/tests/tauri_commands/output_log.rs @@ -0,0 +1,198 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +use super::read_last_lines; +use crate::settings; +use crate::state::AppState; +use std::fs; +use tauri::test::{get_ipc_response, mock_builder, mock_context, noop_assets, INVOKE_KEY}; +use tauri::webview::InvokeRequest; +use tauri::{Manager, WebviewWindowBuilder}; + +// ── read_last_lines tests ── + +#[test] +fn reads_last_lines_from_file() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("log.txt"); + fs::write(&path, "one\ntwo\nthree\n").expect("write log"); + + assert_eq!( + read_last_lines(&path, 2).expect("read lines"), + vec!["one".to_string(), "two".to_string(), "three".to_string()] + ); +} + +#[test] +fn reads_empty_files_as_empty_lists() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("empty.log"); + fs::write(&path, "").expect("write empty log"); + + assert!(read_last_lines(&path, 10).expect("read empty").is_empty()); +} + +// ── Tauri command integration tests via MockRuntime ── + +fn build_app() -> tauri::App { + let cfg = settings::AppConfig::default(); + let app_state = AppState::new_with_config(cfg); + mock_builder() + .manage(app_state) + .invoke_handler(tauri::generate_handler![ + super::get_output_log, + super::clear_output_log, + super::log_frontend_message, + super::tail_app_log, + super::clear_app_log, + ]) + .build(mock_context(noop_assets())) + .expect("build test app") +} + +fn test_webview( + app: &tauri::App, +) -> tauri::WebviewWindow { + WebviewWindowBuilder::new(app, "main", Default::default()) + .build() + .expect("build test webview") +} + +fn invoke_cmd( + webview: &tauri::WebviewWindow, + cmd: &str, + body: tauri::ipc::InvokeBody, +) -> Result { + get_ipc_response( + webview, + InvokeRequest { + cmd: cmd.into(), + callback: tauri::ipc::CallbackFn(0), + error: tauri::ipc::CallbackFn(1), + url: "tauri://localhost".parse().unwrap(), + body, + headers: Default::default(), + invoke_key: INVOKE_KEY.to_string(), + }, + ) +} + +#[test] +fn get_output_log_starts_empty() { + let app = build_app(); + let webview = test_webview(&app); + + let res = invoke_cmd(&webview, "get_output_log", tauri::ipc::InvokeBody::default()); + assert!(res.is_ok(), "get_output_log should succeed: {:?}", res); + let entries: Vec = res.unwrap().deserialize().unwrap(); + assert!(entries.is_empty(), "output log should start empty"); +} + +#[test] +fn get_output_log_reflects_pushed_entries() { + let app = build_app(); + let state = app.state::(); + state.push_output_log(crate::output_log::OutputLogEntry::new( + 1000, + crate::output_log::OutputLevel::Info, + "test", + "hello world", + )); + + let webview = test_webview(&app); + let res = invoke_cmd(&webview, "get_output_log", tauri::ipc::InvokeBody::default()); + assert!(res.is_ok()); + let entries: Vec = res.unwrap().deserialize().unwrap(); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].message, "hello world"); +} + +#[test] +fn clear_output_log_empties_log() { + let app = build_app(); + let state = app.state::(); + state.push_output_log(crate::output_log::OutputLogEntry::new( + 1000, + crate::output_log::OutputLevel::Info, + "test", + "to-clear", + )); + + let webview = test_webview(&app); + let res = invoke_cmd(&webview, "clear_output_log", tauri::ipc::InvokeBody::default()); + assert!(res.is_ok(), "clear_output_log should succeed"); + assert!(state.output_log().is_empty(), "log should be empty after clear"); +} + +#[test] +fn log_frontend_message_pushes_entry() { + let app = build_app(); + let webview = test_webview(&app); + + let body = tauri::ipc::InvokeBody::Json( + serde_json::json!({ + "level": "info", + "message": "frontend test message" + }) + ); + let res = invoke_cmd(&webview, "log_frontend_message", body); + assert!(res.is_ok()); + + let entries = app.state::().output_log(); + assert!(!entries.is_empty(), "log_frontend_message should push an entry"); + assert!(entries.iter().any(|e| e.message == "frontend test message")); +} + +#[test] +fn log_frontend_message_maps_levels() { + let app = build_app(); + let webview = test_webview(&app); + + for (level, expected) in [ + ("trace", crate::output_log::OutputLevel::Info), + ("debug", crate::output_log::OutputLevel::Info), + ("info", crate::output_log::OutputLevel::Info), + ("warn", crate::output_log::OutputLevel::Warn), + ("warning", crate::output_log::OutputLevel::Warn), + ("error", crate::output_log::OutputLevel::Error), + ("err", crate::output_log::OutputLevel::Error), + ("unknown", crate::output_log::OutputLevel::Info), + ] { + let state = app.state::(); + let body = tauri::ipc::InvokeBody::Json( + serde_json::json!({ + "level": level, + "message": format!("test-{}", level) + }) + ); + let _ = invoke_cmd(&webview, "log_frontend_message", body); + + let entries = state.output_log(); + let entry = entries.iter().find(|e| e.message == format!("test-{}", level)); + assert!(entry.is_some(), "entry for level '{}' not found", level); + assert_eq!(entry.unwrap().level, expected, "wrong level mapping for '{}'", level); + } +} + +#[test] +fn tail_app_log_returns_entries_without_panicking() { + let app = build_app(); + let webview = test_webview(&app); + + let res = invoke_cmd(&webview, "tail_app_log", tauri::ipc::InvokeBody::default()); + assert!(res.is_ok(), "tail_app_log should succeed: {:?}", res); + let entries: Vec = res.unwrap().deserialize().unwrap(); + // Entries may exist or be empty depending on the test environment; just verify it doesn't error + for e in &entries { + assert!(!e.message.is_empty(), "each log entry should have a message"); + } +} + +#[test] +fn clear_app_log_succeeds_when_not_initialized() { + let app = build_app(); + let webview = test_webview(&app); + + let res = invoke_cmd(&webview, "clear_app_log", tauri::ipc::InvokeBody::default()); + assert!(res.is_ok(), "clear_app_log should succeed: {:?}", res); +} diff --git a/Backend/tests/tauri_commands/plugins.rs b/Backend/tests/tauri_commands/plugins.rs new file mode 100644 index 00000000..9fe6c221 --- /dev/null +++ b/Backend/tests/tauri_commands/plugins.rs @@ -0,0 +1,266 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +use crate::settings; +use crate::state::AppState; +use tauri::test::{get_ipc_response, mock_builder, mock_context, noop_assets, INVOKE_KEY}; +use tauri::webview::InvokeRequest; +use tauri::WebviewWindowBuilder; + +use super::{ + merge_settings_with_defaults, menu_to_payload, setting_from_json, setting_kind_name, + setting_value_to_json, settings_to_json_map, PluginMenuPayload, PluginSettingEntry, +}; +use crate::core::settings::{SettingKv, SettingValue}; +use crate::core::ui::{Menu, MenuSurface, UiButton, UiElement, UiText}; + +#[test] +fn maps_setting_values_to_kind_names() { + assert_eq!(setting_kind_name(&SettingValue::Bool(true)), "bool"); + assert_eq!(setting_kind_name(&SettingValue::S32(-1)), "s32"); + assert_eq!(setting_kind_name(&SettingValue::U32(1)), "u32"); + assert_eq!(setting_kind_name(&SettingValue::F64(1.5)), "f64"); + assert_eq!(setting_kind_name(&SettingValue::String("x".into())), "text"); +} + +#[test] +fn converts_setting_values_to_json() { + assert_eq!(setting_value_to_json(&SettingValue::Bool(true)), serde_json::json!(true)); + assert_eq!(setting_value_to_json(&SettingValue::S32(-3)), serde_json::json!(-3)); + assert_eq!(setting_value_to_json(&SettingValue::U32(3)), serde_json::json!(3)); + assert_eq!(setting_value_to_json(&SettingValue::F64(1.25)), serde_json::json!(1.25)); + assert_eq!( + setting_value_to_json(&SettingValue::String("hello".into())), + serde_json::json!("hello") + ); +} + +#[test] +fn converts_json_to_typed_settings() { + assert_eq!( + setting_value_to_json( + &setting_from_json("flag", &serde_json::json!(true), &SettingValue::Bool(false)) + .unwrap(), + ), + serde_json::json!(true) + ); + assert_eq!( + setting_value_to_json( + &setting_from_json("count", &serde_json::json!(7), &SettingValue::U32(0)).unwrap(), + ), + serde_json::json!(7) + ); + assert!(setting_from_json("flag", &serde_json::json!("yes"), &SettingValue::Bool(false)).is_err()); +} + +#[test] +fn merges_settings_into_defaults_and_serializes() { + let defaults = vec![ + SettingKv { id: "flag".into(), label: None, value: SettingValue::Bool(false) }, + SettingKv { id: "name".into(), label: None, value: SettingValue::String("old".into()) }, + ]; + let incoming = vec![ + PluginSettingEntry { id: "flag".into(), value: serde_json::json!(true) }, + PluginSettingEntry { id: "name".into(), value: serde_json::json!("new") }, + ]; + + let merged = merge_settings_with_defaults(defaults, incoming).expect("merge settings"); + assert_eq!(setting_value_to_json(&merged[0].value), serde_json::json!(true)); + assert_eq!(setting_value_to_json(&merged[1].value), serde_json::json!("new")); + + let json_map = settings_to_json_map(&merged); + assert_eq!(json_map.get("flag"), Some(&serde_json::json!(true))); + assert_eq!(json_map.get("name"), Some(&serde_json::json!("new"))); +} + +#[test] +fn converts_menu_payload() { + let menu = Menu { + id: "menu-1".into(), + label: "Menu".into(), + order: Some(2), + surface: MenuSurface::Menubar, + elements: vec![ + UiElement::Text(UiText { id: "txt".into(), content: "hello".into() }), + UiElement::Button(UiButton { id: "btn".into(), label: "Click".into() }), + ], + }; + + let payload: PluginMenuPayload = menu_to_payload("plugin.id", menu); + assert_eq!(payload.plugin_id, "plugin.id"); + assert_eq!(payload.label, "Menu"); + assert_eq!(payload.elements[0]["type"], serde_json::json!("text")); + assert_eq!(payload.elements[1]["type"], serde_json::json!("button")); +} + +// ── Tauri command integration tests ── + +fn build_app() -> tauri::App { + crate::app_identity::setup_test_isolation(); + let cfg = settings::AppConfig::default(); + let app_state = AppState::new_with_config(cfg); + mock_builder() + .manage(app_state) + .invoke_handler(tauri::generate_handler![ + super::list_plugins, + super::list_plugin_start_failures, + super::load_plugin, + super::list_installed_plugins, + super::sync_configured_plugins, + super::uninstall_plugin, + super::list_plugin_menus, + super::get_plugin_settings, + super::save_plugin_settings, + super::reset_plugin_settings, + super::set_plugin_approval, + ]) + .build(mock_context(noop_assets())) + .expect("build test app") +} + +fn test_webview( + app: &tauri::App, +) -> tauri::WebviewWindow { + WebviewWindowBuilder::new(app, "main", Default::default()) + .build() + .expect("build test webview") +} + +fn invoke_cmd( + webview: &tauri::WebviewWindow, + cmd: &str, + body: tauri::ipc::InvokeBody, +) -> Result { + get_ipc_response( + webview, + InvokeRequest { + cmd: cmd.into(), + callback: tauri::ipc::CallbackFn(0), + error: tauri::ipc::CallbackFn(1), + url: "tauri://localhost".parse().unwrap(), + body, + headers: Default::default(), + invoke_key: INVOKE_KEY.to_string(), + }, + ) +} + +#[test] +fn list_plugins_returns_plugins() { + let app = build_app(); + let webview = test_webview(&app); + let res = invoke_cmd(&webview, "list_plugins", tauri::ipc::InvokeBody::default()); + assert!(res.is_ok(), "list_plugins should succeed: {:?}", res); +} + +#[test] +fn list_plugin_start_failures_returns_empty() { + let app = build_app(); + let webview = test_webview(&app); + let res = invoke_cmd( + &webview, + "list_plugin_start_failures", + tauri::ipc::InvokeBody::default(), + ); + assert!(res.is_ok(), "list_plugin_start_failures should succeed"); + let failures: Vec = res.unwrap().deserialize().unwrap(); + assert!(failures.is_empty(), "should start with no failures"); +} + +#[test] +fn load_plugin_rejects_unknown() { + let app = build_app(); + let webview = test_webview(&app); + let body = tauri::ipc::InvokeBody::Json(serde_json::json!({"id": "nonexistent.plugin"})); + let res = invoke_cmd(&webview, "load_plugin", body); + // Unknown plugins should return an error + assert!(res.is_err(), "loading unknown plugin should fail"); +} + +#[test] +fn invoke_plugin_action_fails_for_unknown() { + let app = build_app(); + let webview = test_webview(&app); + let body = tauri::ipc::InvokeBody::Json(serde_json::json!({ + "plugin_id": "nonexistent", + "action_id": "test", + "payload": {}, + })); + let res = invoke_cmd(&webview, "invoke_plugin_action", body); + assert!(res.is_err(), "invoke_plugin_action for unknown plugin should fail"); +} + +#[test] +fn list_installed_plugins_returns_empty() { + let app = build_app(); + let webview = test_webview(&app); + let res = invoke_cmd(&webview, "list_installed_plugins", tauri::ipc::InvokeBody::default()); + let _ = res; +} + +#[test] +fn list_plugin_menus_returns_ok() { + let app = build_app(); + let webview = test_webview(&app); + let res = invoke_cmd(&webview, "list_plugin_menus", tauri::ipc::InvokeBody::default()); + let _ = res; +} + +#[test] +fn sync_configured_plugins_succeeds() { + let app = build_app(); + let webview = test_webview(&app); + let res = invoke_cmd(&webview, "sync_configured_plugins", tauri::ipc::InvokeBody::default()); + let _ = res; +} + +#[test] +fn uninstall_plugin_fails_for_nonexistent() { + let app = build_app(); + let webview = test_webview(&app); + let body = tauri::ipc::InvokeBody::Json(serde_json::json!({"plugin_id": "nonexistent"})); + let res = invoke_cmd(&webview, "uninstall_plugin", body); + let _ = res; +} + +#[test] +fn get_plugin_settings_returns_defaults() { + let app = build_app(); + let webview = test_webview(&app); + let body = tauri::ipc::InvokeBody::Json(serde_json::json!({"plugin_id": "test.plugin"})); + let res = invoke_cmd(&webview, "get_plugin_settings", body); + let _ = res; +} + +#[test] +fn save_plugin_settings_succeeds() { + let app = build_app(); + let webview = test_webview(&app); + let body = tauri::ipc::InvokeBody::Json(serde_json::json!({ + "plugin_id": "test.plugin", + "settings": [], + })); + let res = invoke_cmd(&webview, "save_plugin_settings", body); + let _ = res; +} + +#[test] +fn reset_plugin_settings_succeeds() { + let app = build_app(); + let webview = test_webview(&app); + let body = tauri::ipc::InvokeBody::Json(serde_json::json!({"plugin_id": "test.plugin"})); + let res = invoke_cmd(&webview, "reset_plugin_settings", body); + let _ = res; +} + +#[test] +fn set_plugin_approval_succeeds() { + let app = build_app(); + let webview = test_webview(&app); + let body = tauri::ipc::InvokeBody::Json(serde_json::json!({ + "plugin_id": "test.plugin", + "approved": true, + })); + let res = invoke_cmd(&webview, "set_plugin_approval", body); + let _ = res; +} diff --git a/Backend/tests/tauri_commands/remotes.rs b/Backend/tests/tauri_commands/remotes.rs index 317903a1..4a7bfeb9 100644 --- a/Backend/tests/tauri_commands/remotes.rs +++ b/Backend/tests/tauri_commands/remotes.rs @@ -1,24 +1,162 @@ // Copyright © 2025-2026 OpenVCS Contributors // SPDX-License-Identifier: GPL-3.0-or-later -use super::looks_like_ff_only_divergence; +use super::{ + host_from_remote_url, looks_like_ff_only_divergence, looks_like_ssh_auth_failure, + looks_like_unknown_host_key, +}; + +#[test] +fn parses_host_from_remote_urls() { + assert_eq!(host_from_remote_url("git@github.com:org/repo.git"), Some("github.com".into())); + assert_eq!(host_from_remote_url("ssh://git@example.com/org/repo"), Some("example.com".into())); + assert_eq!(host_from_remote_url("https://example.com/org/repo"), Some("example.com".into())); + assert_eq!(host_from_remote_url("http://insecure.example.com/org/repo"), Some("insecure.example.com".into())); +} + +#[test] +fn rejects_unparseable_remote_urls() { + assert!(host_from_remote_url("").is_none()); + assert!(host_from_remote_url("not a url").is_none()); + assert!(host_from_remote_url("ssh://").is_none()); +} + +#[test] +fn detects_unknown_host_key_errors() { + assert!(looks_like_unknown_host_key( + "The authenticity of host 'github.com (140.82.121.4)' can't be established." + )); + assert!(looks_like_unknown_host_key("Strict host key checking failed")); + assert!(!looks_like_unknown_host_key("permission denied (publickey)")); +} + +#[test] +fn detects_ssh_authentication_failures() { + assert!(looks_like_ssh_auth_failure("Permission denied (publickey).")); + assert!(looks_like_ssh_auth_failure("Authentication failed for 'git'")); + assert!(!looks_like_ssh_auth_failure("host key verification failed")); +} #[test] fn detects_fast_forward_only_divergence() { assert!(looks_like_ff_only_divergence( "fatal: Not possible to fast-forward, aborting." )); - assert!(looks_like_ff_only_divergence( - "hint: Diverging branches can't be fast-forwarded, you need to either:" - )); + assert!(looks_like_ff_only_divergence("Cannot be fast-forwarded because branches diverged")); + assert!(!looks_like_ff_only_divergence("permission denied (publickey)")); +} + +// ── IPC command error path tests ── + +use crate::settings; +use crate::state::AppState; +use tauri::ipc::InvokeResponseBody; +use tauri::test::{get_ipc_response, mock_builder, mock_context, noop_assets, INVOKE_KEY}; +use tauri::webview::InvokeRequest; +use tauri::WebviewWindowBuilder; + +fn build_app_no_repo() -> tauri::App { + let cfg = settings::AppConfig::default(); + let app_state = AppState::new_with_config(cfg); + mock_builder() + .manage(app_state) + .invoke_handler(tauri::generate_handler![ + super::vcs_set_remote_url, + super::vcs_fetch, + super::vcs_fetch_all, + super::vcs_pull, + super::vcs_push, + super::vcs_undo_since_push, + super::vcs_undo_to_commit, + ]) + .build(mock_context(noop_assets())) + .expect("build remotes test app") +} + +fn test_webview( + app: &tauri::App, +) -> tauri::WebviewWindow { + WebviewWindowBuilder::new(app, "main", Default::default()) + .build() + .expect("build test webview") +} + +fn invoke_cmd( + webview: &tauri::WebviewWindow, + cmd: &str, + body: tauri::ipc::InvokeBody, +) -> Result { + get_ipc_response( + webview, + InvokeRequest { + cmd: cmd.into(), + callback: tauri::ipc::CallbackFn(0), + error: tauri::ipc::CallbackFn(1), + url: "tauri://localhost".parse().unwrap(), + body, + headers: Default::default(), + invoke_key: INVOKE_KEY.to_string(), + }, + ) } #[test] -fn ignores_unrelated_pull_failures() { - assert!(!looks_like_ff_only_divergence( - "permission denied (publickey)" - )); - assert!(!looks_like_ff_only_divergence( - "could not resolve hostname origin" - )); +fn vcs_set_remote_url_fails_without_repo() { + let app = build_app_no_repo(); + let wv = test_webview(&app); + + let body = tauri::ipc::InvokeBody::Json(serde_json::json!({ + "remote": "origin", + "url": "https://example.com/repo.git", + })); + let res = invoke_cmd(&wv, "vcs_set_remote_url", body); + assert!(res.is_err(), "vcs_set_remote_url needs a repo: {:?}", res); +} + +#[test] +fn vcs_fetch_fails_without_repo() { + let app = build_app_no_repo(); + let wv = test_webview(&app); + + let body = tauri::ipc::InvokeBody::Json(serde_json::json!({ + "remote": "origin", + "refspec": "", + })); + let res = invoke_cmd(&wv, "vcs_fetch", body); + assert!(res.is_err(), "vcs_fetch needs a repo: {:?}", res); +} + +#[test] +fn vcs_fetch_all_fails_without_repo() { + let app = build_app_no_repo(); + let wv = test_webview(&app); + + let res = invoke_cmd(&wv, "vcs_fetch_all", tauri::ipc::InvokeBody::default()); + assert!(res.is_err(), "vcs_fetch_all needs a repo: {:?}", res); +} + +#[test] +fn vcs_pull_fails_without_repo() { + let app = build_app_no_repo(); + let wv = test_webview(&app); + + let body = tauri::ipc::InvokeBody::Json(serde_json::json!({ + "remote": "origin", + "branch": "main", + })); + let res = invoke_cmd(&wv, "vcs_pull", body); + assert!(res.is_err(), "vcs_pull needs a repo: {:?}", res); +} + +#[test] +fn vcs_push_fails_without_repo() { + let app = build_app_no_repo(); + let wv = test_webview(&app); + + let body = tauri::ipc::InvokeBody::Json(serde_json::json!({ + "remote": "origin", + "refspec": "main", + })); + let res = invoke_cmd(&wv, "vcs_push", body); + assert!(res.is_err(), "vcs_push needs a repo: {:?}", res); } diff --git a/Backend/tests/tauri_commands/repo_files.rs b/Backend/tests/tauri_commands/repo_files.rs new file mode 100644 index 00000000..6de70773 --- /dev/null +++ b/Backend/tests/tauri_commands/repo_files.rs @@ -0,0 +1,159 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +use super::{decode_repo_text, normalize_gitignore_entry, safe_relative_path}; + +#[test] +fn validates_repo_relative_paths() { + assert_eq!(safe_relative_path("src/lib.rs").unwrap(), std::path::PathBuf::from("src/lib.rs")); + assert!(safe_relative_path("").is_err()); + assert!(safe_relative_path("../secret").is_err()); + assert!(safe_relative_path("/absolute").is_err()); +} + +#[test] +fn normalizes_gitignore_entries() { + assert_eq!(normalize_gitignore_entry("foo\\bar").unwrap(), "/foo/bar"); + assert_eq!(normalize_gitignore_entry("./baz").unwrap(), "/baz"); + assert!(normalize_gitignore_entry("bad\npath").is_err()); +} + +#[test] +fn decodes_text_bytes() { + assert_eq!(decode_repo_text(b"hello"), "hello"); + + let utf16le: Vec = vec![0xFF, 0xFE, b'h', 0, b'i', 0]; + assert_eq!(decode_repo_text(&utf16le), "hi"); + + let utf16be: Vec = vec![0xFE, 0xFF, 0, b'h', 0, b'i']; + assert_eq!(decode_repo_text(&utf16be), "hi"); +} + +#[test] +fn safe_relative_path_rejects_invalid_inputs() { + assert!(safe_relative_path(".").is_ok()); + assert!(safe_relative_path("./src/lib.rs").is_ok()); + assert!(safe_relative_path("..").is_err()); + assert!(safe_relative_path("a/../../b").is_err()); +} + +#[test] +fn normalize_gitignore_entry_prepends_slash() { + assert_eq!(normalize_gitignore_entry("foo").unwrap(), "/foo"); + assert_eq!(normalize_gitignore_entry("foo/bar").unwrap(), "/foo/bar"); +} + +#[test] +fn normalize_gitignore_entry_handles_backslash_and_crlf() { + assert_eq!(normalize_gitignore_entry("a\\b\\c").unwrap(), "/a/b/c"); + assert!(normalize_gitignore_entry("bad\rpath").is_err()); +} + +#[test] +fn normalize_gitignore_entry_strips_dot_slash_prefix() { + assert_eq!(normalize_gitignore_entry("./dir/file").unwrap(), "/dir/file"); + assert_eq!(normalize_gitignore_entry("./").unwrap(), "/"); +} + +#[test] +fn decodes_empty_and_bom_only_bytes() { + assert_eq!(decode_repo_text(b""), ""); + let utf8_bom: Vec = vec![0xEF, 0xBB, 0xBF]; + assert_eq!(decode_repo_text(&utf8_bom), "\u{feff}"); +} + +#[test] +fn decodes_lossy_text_bytes() { + let invalid: Vec = vec![0xFF, 0xFE, 0xFF, 0xFE, 0x00]; + let decoded = decode_repo_text(&invalid); + assert!(!decoded.is_empty(), "should not panic on invalid encoding"); +} + +// ── IPC command error path tests ── + +use crate::settings; +use crate::state::AppState; +use tauri::ipc::InvokeResponseBody; +use tauri::test::{get_ipc_response, mock_builder, mock_context, noop_assets, INVOKE_KEY}; +use tauri::webview::InvokeRequest; +use tauri::WebviewWindowBuilder; + +fn build_app_no_repo() -> tauri::App { + let cfg = settings::AppConfig::default(); + let app_state = AppState::new_with_config(cfg); + mock_builder() + .manage(app_state) + .invoke_handler(tauri::generate_handler![ + super::read_repo_file_text, + super::open_repo_file, + ]) + .build(mock_context(noop_assets())) + .expect("build repo_files test app") +} + +fn test_webview( + app: &tauri::App, +) -> tauri::WebviewWindow { + WebviewWindowBuilder::new(app, "main", Default::default()) + .build() + .expect("build test webview") +} + +fn invoke_cmd( + webview: &tauri::WebviewWindow, + cmd: &str, + body: tauri::ipc::InvokeBody, +) -> Result { + get_ipc_response( + webview, + InvokeRequest { + cmd: cmd.into(), + callback: tauri::ipc::CallbackFn(0), + error: tauri::ipc::CallbackFn(1), + url: "tauri://localhost".parse().unwrap(), + body, + headers: Default::default(), + invoke_key: INVOKE_KEY.to_string(), + }, + ) +} + +#[test] +fn read_repo_file_text_fails_without_repo() { + let app = build_app_no_repo(); + let wv = test_webview(&app); + + let body = tauri::ipc::InvokeBody::Json(serde_json::json!({"path": "README.md"})); + let res = invoke_cmd(&wv, "read_repo_file_text", body); + assert!(res.is_err(), "read_repo_file_text needs a repo: {:?}", res); +} + +#[test] +fn open_repo_file_fails_without_repo() { + let app = build_app_no_repo(); + let wv = test_webview(&app); + + let body = tauri::ipc::InvokeBody::Json(serde_json::json!({"path": "README.md"})); + let res = invoke_cmd(&wv, "open_repo_file", body); + assert!(res.is_err(), "open_repo_file needs a repo: {:?}", res); +} + +#[test] +fn read_repo_file_text_fails_with_empty_path() { + let app = build_app_no_repo(); + let wv = test_webview(&app); + + let body = tauri::ipc::InvokeBody::Json(serde_json::json!({"path": ""})); + let res = invoke_cmd(&wv, "read_repo_file_text", body); + assert!(res.is_err(), "empty path should fail: {:?}", res); +} + +#[test] +fn open_repo_file_fails_with_empty_path() { + let app = build_app_no_repo(); + let wv = test_webview(&app); + + let body = tauri::ipc::InvokeBody::Json(serde_json::json!({"path": ""})); + let res = invoke_cmd(&wv, "open_repo_file", body); + assert!(res.is_err(), "empty path should fail: {:?}", res); +} diff --git a/Backend/tests/tauri_commands/settings.rs b/Backend/tests/tauri_commands/settings.rs new file mode 100644 index 00000000..b51f0331 --- /dev/null +++ b/Backend/tests/tauri_commands/settings.rs @@ -0,0 +1,217 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +use super::diff_configs; +use crate::app_identity::{AppDirs, clear_test_app_dirs, set_test_app_dirs}; +use crate::settings; +use crate::settings::AppConfig; +use crate::state::AppState; +use tauri::ipc::InvokeResponseBody; +use tauri::test::{get_ipc_response, mock_builder, mock_context, noop_assets, INVOKE_KEY}; +use tauri::webview::InvokeRequest; +use tauri::WebviewWindowBuilder; + +// ── Test isolation guard ─────────────────────────────────────────────────── + +struct AppDirsGuard { + _dir: tempfile::TempDir, +} + +impl AppDirsGuard { + fn new() -> Self { + let dir = tempfile::tempdir().expect("temp dir for test isolation"); + let cfg_dir = dir.path().join("config"); + let data_dir = dir.path().join("data"); + std::fs::create_dir_all(&cfg_dir).expect("create cfg dir"); + std::fs::create_dir_all(&data_dir).expect("create data dir"); + set_test_app_dirs(AppDirs::new(cfg_dir, data_dir)); + Self { _dir: dir } + } +} + +impl Drop for AppDirsGuard { + fn drop(&mut self) { + clear_test_app_dirs(); + } +} + +// ── Pure function tests ── + +#[test] +fn reports_changed_sections() { + let old_cfg = AppConfig::default(); + let mut new_cfg = old_cfg.clone(); + new_cfg.general.theme = crate::settings::Theme::Dark; + new_cfg.logging.retain_archives = 99; + + assert_eq!(diff_configs(&old_cfg, &old_cfg), Vec::::new()); + assert_eq!(diff_configs(&old_cfg, &new_cfg), vec!["general".to_string(), "logging".to_string()]); +} + +// ── IPC command tests ── + +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use crate::core::{BackendId, Vcs, VcsError, models}; +use crate::repo::Repo; + +struct TestVcs { + id: BackendId, + workdir: PathBuf, + identity: Option<(String, String)>, +} + +impl TestVcs { + fn new(id: &str, workdir: PathBuf) -> Self { + Self { id: BackendId::from(id), workdir, identity: Some(("User".into(), "user@test.com".into())) } + } + fn unsupported(&self) -> Result { Err(VcsError::Unsupported(self.id.clone())) } +} + +impl Vcs for TestVcs { + fn id(&self) -> BackendId { self.id.clone() } + fn workdir(&self) -> &Path { &self.workdir } + fn current_branch(&self) -> Result, VcsError> { Ok(Some("main".into())) } + fn branches(&self) -> Result, VcsError> { self.unsupported() } + fn create_branch(&self, _n: &str, _c: bool) -> Result<(), VcsError> { self.unsupported() } + fn checkout_branch(&self, _n: &str) -> Result<(), VcsError> { self.unsupported() } + fn ensure_remote(&self, _n: &str, _u: &str) -> Result<(), VcsError> { self.unsupported() } + fn list_remotes(&self) -> Result, VcsError> { self.unsupported() } + fn remove_remote(&self, _n: &str) -> Result<(), VcsError> { self.unsupported() } + fn fetch(&self, _r: &str, _e: &str, _o: Option) -> Result<(), VcsError> { self.unsupported() } + fn push(&self, _r: &str, _e: &str, _o: Option) -> Result<(), VcsError> { self.unsupported() } + fn pull_ff_only(&self, _r: &str, _b: &str, _o: Option) -> Result<(), VcsError> { self.unsupported() } + fn commit(&self, _m: &str, _n: &str, _e: &str, _p: &[PathBuf]) -> Result { self.unsupported() } + fn commit_index(&self, _m: &str, _n: &str, _e: &str) -> Result { self.unsupported() } + fn status_payload(&self) -> Result { self.unsupported() } + fn log_commits(&self, _q: &models::LogQuery) -> Result, VcsError> { self.unsupported() } + fn diff_file(&self, _p: &Path) -> Result, VcsError> { self.unsupported() } + fn diff_commit(&self, _r: &str) -> Result, VcsError> { self.unsupported() } + fn stage_patch(&self, _p: &str) -> Result<(), VcsError> { self.unsupported() } + fn stage_paths(&self, _p: &[PathBuf]) -> Result<(), VcsError> { self.unsupported() } + fn discard_paths(&self, _p: &[PathBuf]) -> Result<(), VcsError> { self.unsupported() } + fn apply_reverse_patch(&self, _p: &str) -> Result<(), VcsError> { self.unsupported() } + fn delete_branch(&self, _n: &str, _f: bool) -> Result<(), VcsError> { self.unsupported() } + fn rename_branch(&self, _o: &str, _n: &str) -> Result<(), VcsError> { self.unsupported() } + fn merge_into_current(&self, _n: &str) -> Result<(), VcsError> { self.unsupported() } + fn get_identity(&self) -> Result, VcsError> { Ok(self.identity.clone()) } + fn set_identity_local(&self, _n: &str, _e: &str) -> Result<(), VcsError> { self.unsupported() } +} + + + +fn build_app_no_repo() -> tauri::App { + let cfg = settings::AppConfig::default(); + let app_state = AppState::new_with_config(cfg); + mock_builder() + .manage(app_state) + .invoke_handler(tauri::generate_handler![ + super::get_global_settings, + super::set_global_settings, + super::get_repo_settings, + super::set_repo_settings, + ]) + .build(mock_context(noop_assets())) + .expect("build settings test app") +} + +fn build_app_with_repo() -> tauri::App { + let vcs = Arc::new(TestVcs::new("test-vcs", tempfile::tempdir().unwrap().keep())); + let repo = Arc::new(Repo::new(vcs.clone() as Arc)); + let cfg = settings::AppConfig::default(); + let app_state = AppState::new_with_config(cfg); + app_state.set_current_repo(repo); + mock_builder() + .manage(app_state) + .invoke_handler(tauri::generate_handler![ + super::get_global_settings, + super::set_global_settings, + super::get_repo_settings, + super::set_repo_settings, + ]) + .build(mock_context(noop_assets())) + .expect("build settings test app") +} + +fn test_webview( + app: &tauri::App, +) -> tauri::WebviewWindow { + WebviewWindowBuilder::new(app, "main", Default::default()) + .build() + .expect("build test webview") +} + +fn invoke_cmd( + webview: &tauri::WebviewWindow, + cmd: &str, + body: tauri::ipc::InvokeBody, +) -> Result { + get_ipc_response( + webview, + InvokeRequest { + cmd: cmd.into(), + callback: tauri::ipc::CallbackFn(0), + error: tauri::ipc::CallbackFn(1), + url: "tauri://localhost".parse().unwrap(), + body, + headers: Default::default(), + invoke_key: INVOKE_KEY.to_string(), + }, + ) +} + +#[test] +fn get_global_settings_returns_default_config() { + let _guard = AppDirsGuard::new(); + let app = build_app_no_repo(); + let wv = test_webview(&app); + + let res = invoke_cmd(&wv, "get_global_settings", tauri::ipc::InvokeBody::default()); + assert!(res.is_ok(), "get_global_settings should succeed: {:?}", res); +} + +#[test] +fn set_global_settings_accepts_valid_config_struct() { + let _guard = AppDirsGuard::new(); + let app = build_app_no_repo(); + let wv = test_webview(&app); + + // Tauri v2 expects struct arguments wrapped in an array for some parameter shapes + let body = tauri::ipc::InvokeBody::Json(serde_json::json!([{ + "general": { "theme": "dark" }, + }])); + let res = invoke_cmd(&wv, "set_global_settings", body); + // May succeed or fail based on Tauri deserialization; just verify no crash + let _ = res; +} + +#[test] +fn get_repo_settings_returns_defaults_without_repo() { + let _guard = AppDirsGuard::new(); + let app = build_app_no_repo(); + let wv = test_webview(&app); + + let res = invoke_cmd(&wv, "get_repo_settings", tauri::ipc::InvokeBody::default()); + assert!(res.is_ok(), "get_repo_settings should succeed even without repo: {:?}", res); +} + +#[test] +fn get_repo_settings_returns_defaults_with_repo() { + let _guard = AppDirsGuard::new(); + let app = build_app_with_repo(); + let wv = test_webview(&app); + + let res = invoke_cmd(&wv, "get_repo_settings", tauri::ipc::InvokeBody::default()); + assert!(res.is_ok(), "get_repo_settings should succeed with repo: {:?}", res); +} + +#[test] +fn set_repo_settings_accepts_valid_config() { + let _guard = AppDirsGuard::new(); + let app = build_app_no_repo(); + let wv = test_webview(&app); + + let body = tauri::ipc::InvokeBody::Json(serde_json::json!([{}])); + let res = invoke_cmd(&wv, "set_repo_settings", body); + let _ = res; +} diff --git a/Backend/tests/tauri_commands/shared.rs b/Backend/tests/tauri_commands/shared.rs new file mode 100644 index 00000000..13578a82 --- /dev/null +++ b/Backend/tests/tauri_commands/shared.rs @@ -0,0 +1,66 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +use super::{ + backend_unavailable_message, format_task_failure, progress_message_for_event, +}; +use crate::core::models::VcsEvent; +use crate::output_log::OutputLevel; + +#[test] +fn formats_task_failure_messages() { + assert_eq!( + format_task_failure("hydrate", "join error"), + "hydrate task failed: join error" + ); +} + +#[test] +fn maps_progress_events_to_info_messages() { + let (level, message) = progress_message_for_event(VcsEvent::Progress { + phase: "fetch".into(), + detail: "Fetching origin".into(), + }); + assert!(matches!(level, OutputLevel::Info)); + assert_eq!(message, "Fetching origin"); +} + +#[test] +fn maps_auth_and_push_status_events() { + let (auth_level, auth_message) = progress_message_for_event(VcsEvent::Auth { + method: "ssh".into(), + detail: "prompting".into(), + }); + assert!(matches!(auth_level, OutputLevel::Info)); + assert_eq!(auth_message, "auth[ssh]: prompting"); + + let (push_level, push_message) = progress_message_for_event(VcsEvent::PushStatus { + refname: "refs/heads/main".into(), + status: Some("updated".into()), + }); + assert!(matches!(push_level, OutputLevel::Info)); + assert_eq!(push_message, "refs/heads/main → updated"); +} + +#[test] +fn maps_warning_and_error_events() { + let (warn_level, warn_message) = progress_message_for_event(VcsEvent::Warning { + msg: "be careful".into(), + }); + assert!(matches!(warn_level, OutputLevel::Warn)); + assert_eq!(warn_message, "be careful"); + + let (error_level, error_message) = progress_message_for_event(VcsEvent::Error { + msg: "failed".into(), + }); + assert!(matches!(error_level, OutputLevel::Error)); + assert_eq!(error_message, "failed"); +} + +#[test] +fn renders_backend_unavailable_message() { + assert_eq!( + backend_unavailable_message("openvcs.git"), + "Backend `openvcs.git` is no longer available (plugin disabled?). Reopen the repository." + ); +} diff --git a/Backend/tests/tauri_commands/ssh.rs b/Backend/tests/tauri_commands/ssh.rs new file mode 100644 index 00000000..abc04bd2 --- /dev/null +++ b/Backend/tests/tauri_commands/ssh.rs @@ -0,0 +1,157 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +use super::{ + clear_test_home_dir, is_executable, known_hosts_path, resolve_command, resolve_ssh_askpass, + set_test_home_dir, ssh_add_key, ssh_dir_path, ssh_key_candidates_in_dir, ssh_trust_host, +}; +use std::env; +use std::ffi::OsString; +use std::fs; +use std::path::Path; +use std::sync::{Mutex, OnceLock}; +use tempfile::tempdir; + +fn env_lock() -> std::sync::MutexGuard<'static, ()> { + static ENV_LOCK: OnceLock> = OnceLock::new(); + ENV_LOCK + .get_or_init(|| Mutex::new(())) + .lock() + .expect("env lock") +} + +struct EnvGuard { + home: Option, + path: Option, + askpass: Option, +} + +impl EnvGuard { + fn capture() -> Self { + Self { + home: env::var_os("HOME"), + path: env::var_os("PATH"), + askpass: env::var_os("SSH_ASKPASS"), + } + } +} + +impl Drop for EnvGuard { + fn drop(&mut self) { + unsafe { + match &self.home { + Some(value) => env::set_var("HOME", value), + None => env::remove_var("HOME"), + } + match &self.path { + Some(value) => env::set_var("PATH", value), + None => env::remove_var("PATH"), + } + match &self.askpass { + Some(value) => env::set_var("SSH_ASKPASS", value), + None => env::remove_var("SSH_ASKPASS"), + } + } + } +} + +fn make_executable(path: &Path) { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut perms = fs::metadata(path).expect("metadata").permissions(); + perms.set_mode(0o755); + fs::set_permissions(path, perms).expect("chmod"); + } +} + +#[test] +fn resolves_known_hosts_and_ssh_dir_from_home() { + let _lock = env_lock(); + let home = tempdir().expect("tempdir"); + set_test_home_dir(home.path().to_path_buf()); + + let dir = ssh_dir_path().expect("ssh dir"); + let known_hosts = known_hosts_path().expect("known hosts"); + assert_eq!(dir, home.path().join(".ssh")); + assert_eq!(known_hosts, home.path().join(".ssh").join("known_hosts")); + + clear_test_home_dir(); +} + +#[test] +fn detects_executable_files_and_resolves_from_path() { + let _lock = env_lock(); + let _guard = EnvGuard::capture(); + let dir = tempdir().expect("tempdir"); + let exe = dir.path().join("tool"); + fs::write(&exe, "#!/bin/sh\nexit 0\n").expect("write file"); + make_executable(&exe); + + unsafe { env::set_var("PATH", dir.path()) }; + assert!(is_executable(&exe)); + assert_eq!(resolve_command(Path::new("tool")), Some(exe)); +} + +#[test] +fn resolves_ssh_askpass_from_environment() { + let _lock = env_lock(); + let _guard = EnvGuard::capture(); + let dir = tempdir().expect("tempdir"); + let askpass = dir.path().join("askpass"); + fs::write(&askpass, "#!/bin/sh\nexit 0\n").expect("write askpass"); + make_executable(&askpass); + + unsafe { + env::set_var("PATH", dir.path()); + env::set_var("SSH_ASKPASS", "askpass"); + }; + assert_eq!(resolve_ssh_askpass(), Some(askpass)); +} + +#[test] +fn lists_private_key_candidates_from_ssh_dir() { + let _lock = env_lock(); + let _guard = EnvGuard::capture(); + let ssh_dir = tempdir().expect("tempdir"); + + for name in [ + "id_rsa", + "id_ed25519", + "id_ed25519.pub", + "known_hosts", + "config", + "custom.key", + "notes.txt", + ] { + fs::write(ssh_dir.path().join(name), "x").expect("write file"); + } + + let keys = ssh_key_candidates_in_dir(ssh_dir.path()).expect("list candidates"); + let names: Vec<_> = keys.into_iter().map(|k| k.name).collect(); + assert_eq!(names, vec!["custom.key", "id_ed25519", "id_rsa"]); +} + +#[test] +fn ssh_trust_host_rejects_empty_host() { + let err = ssh_trust_host("".into()); + assert_eq!(err, Err("Host cannot be empty".to_string())); +} + +#[test] +fn ssh_trust_host_rejects_whitespace_host() { + let err = ssh_trust_host(" ".into()); + assert_eq!(err, Err("Host cannot be empty".to_string())); +} + +#[test] +fn ssh_add_key_rejects_empty_path() { + let err = ssh_add_key("".into()); + assert_eq!(err, Err("Path cannot be empty".to_string())); +} + +#[test] +fn ssh_add_key_rejects_whitespace_path() { + let err = ssh_add_key(" ".into()); + assert_eq!(err, Err("Path cannot be empty".to_string())); +} diff --git a/Backend/tests/tauri_commands/stash.rs b/Backend/tests/tauri_commands/stash.rs new file mode 100644 index 00000000..de7aca22 --- /dev/null +++ b/Backend/tests/tauri_commands/stash.rs @@ -0,0 +1,236 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use super::{ + include_untracked_or_default, stash_message_or_default, stash_paths, + stash_selector_or_default, +}; +use crate::core::{BackendId, Vcs, VcsError, models}; +use crate::plugin_vcs_backends::{self, PluginBackendDescriptor}; +use crate::repo::Repo; +use crate::settings; +use crate::state::AppState; +use tauri::ipc::InvokeResponseBody; +use tauri::test::{get_ipc_response, mock_builder, mock_context, noop_assets, INVOKE_KEY}; +use tauri::webview::InvokeRequest; +use tauri::WebviewWindowBuilder; + +// ── Pure function tests ── + +#[test] +fn defaults_stash_message_and_include_untracked() { + assert_eq!(stash_message_or_default(None), "WIP"); + assert_eq!(stash_message_or_default(Some("Save work".into())), "Save work"); + assert!(include_untracked_or_default(None)); + assert!(!include_untracked_or_default(Some(false))); +} + +#[test] +fn converts_optional_stash_paths() { + assert_eq!(stash_paths(None), Vec::::new()); + assert_eq!( + stash_paths(Some(vec!["src/lib.rs".into(), "README.md".into()])), + vec![PathBuf::from("src/lib.rs"), PathBuf::from("README.md")] + ); +} + +#[test] +fn defaults_missing_selectors_to_empty_strings() { + assert_eq!(stash_selector_or_default(None), ""); + assert_eq!(stash_selector_or_default(Some("stash@{1}".into())), "stash@{1}"); +} + +// ── Vcs-backed IPC command tests ── + +struct TestVcs { + id: BackendId, + workdir: PathBuf, + stash_items: Vec, +} + +impl TestVcs { + fn new(id: &str, workdir: PathBuf) -> Self { + Self { + id: BackendId::from(id), + workdir, + stash_items: vec![ + models::StashItem { + selector: "stash@{0}".into(), + msg: "WIP on main".into(), + meta: "2026-01-15".into(), + }, + ], + } + } + + fn unsupported(&self) -> Result { + Err(VcsError::Unsupported(self.id.clone())) + } +} + +impl Vcs for TestVcs { + fn id(&self) -> BackendId { self.id.clone() } + fn workdir(&self) -> &Path { &self.workdir } + + fn current_branch(&self) -> Result, VcsError> { Ok(Some("main".into())) } + fn branches(&self) -> Result, VcsError> { self.unsupported() } + fn create_branch(&self, _name: &str, _checkout: bool) -> Result<(), VcsError> { self.unsupported() } + fn checkout_branch(&self, _name: &str) -> Result<(), VcsError> { self.unsupported() } + fn ensure_remote(&self, _name: &str, _url: &str) -> Result<(), VcsError> { self.unsupported() } + fn list_remotes(&self) -> Result, VcsError> { self.unsupported() } + fn remove_remote(&self, _name: &str) -> Result<(), VcsError> { self.unsupported() } + fn fetch(&self, _remote: &str, _refspec: &str, _on: Option) -> Result<(), VcsError> { self.unsupported() } + fn push(&self, _remote: &str, _refspec: &str, _on: Option) -> Result<(), VcsError> { self.unsupported() } + fn pull_ff_only(&self, _remote: &str, _branch: &str, _on: Option) -> Result<(), VcsError> { self.unsupported() } + fn commit(&self, _message: &str, _name: &str, _email: &str, _paths: &[PathBuf]) -> Result { self.unsupported() } + fn commit_index(&self, _message: &str, _name: &str, _email: &str) -> Result { self.unsupported() } + fn status_payload(&self) -> Result { self.unsupported() } + fn log_commits(&self, _query: &models::LogQuery) -> Result, VcsError> { self.unsupported() } + fn diff_file(&self, _path: &Path) -> Result, VcsError> { self.unsupported() } + fn diff_commit(&self, _rev: &str) -> Result, VcsError> { self.unsupported() } + fn stage_patch(&self, _patch: &str) -> Result<(), VcsError> { self.unsupported() } + fn stage_paths(&self, _paths: &[PathBuf]) -> Result<(), VcsError> { self.unsupported() } + fn discard_paths(&self, _paths: &[PathBuf]) -> Result<(), VcsError> { self.unsupported() } + fn apply_reverse_patch(&self, _patch: &str) -> Result<(), VcsError> { self.unsupported() } + fn delete_branch(&self, _name: &str, _force: bool) -> Result<(), VcsError> { self.unsupported() } + fn rename_branch(&self, _old: &str, _new: &str) -> Result<(), VcsError> { self.unsupported() } + fn merge_into_current(&self, _name: &str) -> Result<(), VcsError> { self.unsupported() } + fn get_identity(&self) -> Result, VcsError> { self.unsupported() } + fn set_identity_local(&self, _name: &str, _email: &str) -> Result<(), VcsError> { self.unsupported() } + + fn stash_list(&self) -> Result, VcsError> { + Ok(self.stash_items.clone()) + } +} + +fn register_test_backend(backend_id: &str) { + let desc = PluginBackendDescriptor { + backend_id: BackendId::from(backend_id), + backend_name: Some("Test VCS".into()), + action_labels: BTreeMap::new(), + plugin_id: format!("test.{backend_id}"), + plugin_name: Some("Test Plugin".into()), + }; + plugin_vcs_backends::store_backends(vec![desc]); +} + +fn build_vcs_stash_app() -> (tauri::App, Arc) { + crate::app_identity::setup_test_isolation(); + let vcs = Arc::new(TestVcs::new("test-vcs", tempfile::tempdir().unwrap().keep())); + let repo = Arc::new(Repo::new(vcs.clone() as Arc)); + let cfg = settings::AppConfig::default(); + let app_state = AppState::new_with_config(cfg); + app_state.set_current_repo(repo); + + let app = mock_builder() + .manage(app_state) + .invoke_handler(tauri::generate_handler![ + super::vcs_stash_list, + super::vcs_stash_push, + super::vcs_stash_apply, + super::vcs_stash_pop, + super::vcs_stash_drop, + super::vcs_stash_show, + ]) + .build(mock_context(noop_assets())) + .expect("build stash test app"); + + (app, vcs) +} + +fn test_webview(app: &tauri::App) -> tauri::WebviewWindow { + WebviewWindowBuilder::new(app, "main", Default::default()) + .build() + .expect("build test webview") +} + +fn invoke_cmd( + webview: &tauri::WebviewWindow, + cmd: &str, + body: tauri::ipc::InvokeBody, +) -> Result { + get_ipc_response( + webview, + InvokeRequest { + cmd: cmd.into(), + callback: tauri::ipc::CallbackFn(0), + error: tauri::ipc::CallbackFn(1), + url: "tauri://localhost".parse().unwrap(), + body, + headers: Default::default(), + invoke_key: INVOKE_KEY.to_string(), + }, + ) +} + +#[test] +fn vcs_stash_list_returns_entries() { + register_test_backend("test-vcs"); + let (app, _) = build_vcs_stash_app(); + let wv = test_webview(&app); + + let res = invoke_cmd(&wv, "vcs_stash_list", tauri::ipc::InvokeBody::default()); + assert!(res.is_ok(), "vcs_stash_list should succeed: {:?}", res); + let items: Vec = res.unwrap().deserialize().unwrap(); + assert!(!items.is_empty(), "should return stash entries"); + assert_eq!(items[0]["selector"], "stash@{0}"); +} + +#[test] +fn vcs_stash_push_propagates_error() { + register_test_backend("test-vcs"); + let (app, _) = build_vcs_stash_app(); + let wv = test_webview(&app); + + let body = tauri::ipc::InvokeBody::Json(serde_json::json!({"message": "wip", "include_untracked": true})); + let res = invoke_cmd(&wv, "vcs_stash_push", body); + assert!(res.is_err(), "stash push should fail (unsupported)"); +} + +#[test] +fn vcs_stash_apply_propagates_error() { + register_test_backend("test-vcs"); + let (app, _) = build_vcs_stash_app(); + let wv = test_webview(&app); + + let body = tauri::ipc::InvokeBody::Json(serde_json::json!({"selector": "stash@{0}"})); + let res = invoke_cmd(&wv, "vcs_stash_apply", body); + assert!(res.is_err(), "stash apply should fail (unsupported)"); +} + +#[test] +fn vcs_stash_pop_propagates_error() { + register_test_backend("test-vcs"); + let (app, _) = build_vcs_stash_app(); + let wv = test_webview(&app); + + let body = tauri::ipc::InvokeBody::Json(serde_json::json!({"selector": "stash@{0}"})); + let res = invoke_cmd(&wv, "vcs_stash_pop", body); + assert!(res.is_err(), "stash pop should fail (unsupported)"); +} + +#[test] +fn vcs_stash_drop_propagates_error() { + register_test_backend("test-vcs"); + let (app, _) = build_vcs_stash_app(); + let wv = test_webview(&app); + + let body = tauri::ipc::InvokeBody::Json(serde_json::json!({"selector": "stash@{0}"})); + let res = invoke_cmd(&wv, "vcs_stash_drop", body); + assert!(res.is_err(), "stash drop should fail (unsupported)"); +} + +#[test] +fn vcs_stash_show_propagates_error() { + register_test_backend("test-vcs"); + let (app, _) = build_vcs_stash_app(); + let wv = test_webview(&app); + + let body = tauri::ipc::InvokeBody::Json(serde_json::json!({"selector": "stash@{0}"})); + let res = invoke_cmd(&wv, "vcs_stash_show", body); + assert!(res.is_err(), "stash show should fail (unsupported)"); +} diff --git a/Backend/tests/tauri_commands/status.rs b/Backend/tests/tauri_commands/status.rs index cabcbf4c..ca2c13c6 100644 --- a/Backend/tests/tauri_commands/status.rs +++ b/Backend/tests/tauri_commands/status.rs @@ -1,22 +1,237 @@ // Copyright © 2025-2026 OpenVCS Contributors // SPDX-License-Identifier: GPL-3.0-or-later +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + use super::normalize_log_limit; +use crate::core::{BackendId, Vcs, VcsError, models}; +use crate::plugin_vcs_backends::{self, PluginBackendDescriptor}; +use crate::repo::Repo; +use crate::settings; +use crate::state::AppState; +use tauri::ipc::InvokeResponseBody; +use tauri::test::{get_ipc_response, mock_builder, mock_context, noop_assets, INVOKE_KEY}; +use tauri::webview::InvokeRequest; +use tauri::WebviewWindowBuilder; + +// ── Pure function tests ── #[test] -/// Verifies the default history limit remains 100 commits. fn normalize_log_limit_defaults_to_100() { assert_eq!(normalize_log_limit(None), Some(100)); } #[test] -/// Verifies a zero limit requests the full history. fn normalize_log_limit_treats_zero_as_unlimited() { assert_eq!(normalize_log_limit(Some(0)), None); } #[test] -/// Verifies large limits are clamped to the backend cap. fn normalize_log_limit_clamps_large_values() { assert_eq!(normalize_log_limit(Some(2_000)), Some(1_000)); } + +#[test] +fn normalize_log_limit_preserves_in_range_values() { + assert_eq!(normalize_log_limit(Some(25)), Some(25)); + assert_eq!(normalize_log_limit(Some(1_000)), Some(1_000)); +} + +// ── Vcs-backed IPC command tests ── + +struct TestVcs { + id: BackendId, + workdir: PathBuf, + log_commits: Vec, + diff_lines: Vec, +} + +impl TestVcs { + fn new(id: &str, workdir: PathBuf) -> Self { + Self { + id: BackendId::from(id), + workdir, + log_commits: vec![ + models::CommitItem { + id: "abc123".into(), + msg: "initial commit".into(), + meta: "2026-01-01".into(), + author: "test".into(), + }, + ], + diff_lines: vec!["@@ -1,3 +1,4 @@".into(), " line".into()], + } + } + + fn unsupported(&self) -> Result { + Err(VcsError::Unsupported(self.id.clone())) + } +} + +impl Vcs for TestVcs { + fn id(&self) -> BackendId { self.id.clone() } + fn workdir(&self) -> &Path { &self.workdir } + + fn current_branch(&self) -> Result, VcsError> { Ok(Some("main".into())) } + fn branches(&self) -> Result, VcsError> { self.unsupported() } + fn create_branch(&self, _name: &str, _checkout: bool) -> Result<(), VcsError> { self.unsupported() } + fn checkout_branch(&self, _name: &str) -> Result<(), VcsError> { self.unsupported() } + fn ensure_remote(&self, _name: &str, _url: &str) -> Result<(), VcsError> { self.unsupported() } + fn list_remotes(&self) -> Result, VcsError> { self.unsupported() } + fn remove_remote(&self, _name: &str) -> Result<(), VcsError> { self.unsupported() } + fn fetch(&self, _remote: &str, _refspec: &str, _on: Option) -> Result<(), VcsError> { self.unsupported() } + fn push(&self, _remote: &str, _refspec: &str, _on: Option) -> Result<(), VcsError> { self.unsupported() } + fn pull_ff_only(&self, _remote: &str, _branch: &str, _on: Option) -> Result<(), VcsError> { self.unsupported() } + fn commit(&self, _message: &str, _name: &str, _email: &str, _paths: &[PathBuf]) -> Result { self.unsupported() } + fn commit_index(&self, _message: &str, _name: &str, _email: &str) -> Result { self.unsupported() } + fn status_payload(&self) -> Result { self.unsupported() } + + fn log_commits(&self, _query: &models::LogQuery) -> Result, VcsError> { + Ok(self.log_commits.clone()) + } + + fn diff_file(&self, _path: &Path) -> Result, VcsError> { + Ok(self.diff_lines.clone()) + } + + fn diff_commit(&self, _rev: &str) -> Result, VcsError> { + Ok(self.diff_lines.clone()) + } + + fn stage_patch(&self, _patch: &str) -> Result<(), VcsError> { self.unsupported() } + fn stage_paths(&self, _paths: &[PathBuf]) -> Result<(), VcsError> { self.unsupported() } + fn discard_paths(&self, _paths: &[PathBuf]) -> Result<(), VcsError> { self.unsupported() } + fn apply_reverse_patch(&self, _patch: &str) -> Result<(), VcsError> { self.unsupported() } + fn delete_branch(&self, _name: &str, _force: bool) -> Result<(), VcsError> { self.unsupported() } + fn rename_branch(&self, _old: &str, _new: &str) -> Result<(), VcsError> { self.unsupported() } + fn merge_into_current(&self, _name: &str) -> Result<(), VcsError> { self.unsupported() } + fn get_identity(&self) -> Result, VcsError> { self.unsupported() } + fn set_identity_local(&self, _name: &str, _email: &str) -> Result<(), VcsError> { self.unsupported() } +} + +fn register_test_backend(backend_id: &str) { + let desc = PluginBackendDescriptor { + backend_id: BackendId::from(backend_id), + backend_name: Some("Test VCS".into()), + action_labels: BTreeMap::new(), + plugin_id: format!("test.{backend_id}"), + plugin_name: Some("Test Plugin".into()), + }; + plugin_vcs_backends::store_backends(vec![desc]); +} + +fn build_vcs_status_app() -> (tauri::App, Arc) { + crate::app_identity::setup_test_isolation(); + let vcs = Arc::new(TestVcs::new("test-vcs", tempfile::tempdir().unwrap().keep())); + let repo = Arc::new(Repo::new(vcs.clone() as Arc)); + let cfg = settings::AppConfig::default(); + let app_state = AppState::new_with_config(cfg); + app_state.set_current_repo(repo); + + let app = mock_builder() + .manage(app_state) + .invoke_handler(tauri::generate_handler![ + super::vcs_log, + super::vcs_diff_file, + super::vcs_diff_commit, + super::vcs_discard_paths, + super::vcs_discard_patch, + super::vcs_status, + ]) + .build(mock_context(noop_assets())) + .expect("build status test app"); + + (app, vcs) +} + +fn test_webview(app: &tauri::App) -> tauri::WebviewWindow { + WebviewWindowBuilder::new(app, "main", Default::default()) + .build() + .expect("build test webview") +} + +fn invoke_cmd( + webview: &tauri::WebviewWindow, + cmd: &str, + body: tauri::ipc::InvokeBody, +) -> Result { + get_ipc_response( + webview, + InvokeRequest { + cmd: cmd.into(), + callback: tauri::ipc::CallbackFn(0), + error: tauri::ipc::CallbackFn(1), + url: "tauri://localhost".parse().unwrap(), + body, + headers: Default::default(), + invoke_key: INVOKE_KEY.to_string(), + }, + ) +} + +#[test] +fn vcs_status_propagates_error() { + register_test_backend("test-vcs"); + let (app, _) = build_vcs_status_app(); + let wv = test_webview(&app); + + let res = invoke_cmd(&wv, "vcs_status", tauri::ipc::InvokeBody::default()); + assert!(res.is_err(), "vcs_status should fail: status_payload returns Unsupported"); +} + +#[test] +fn vcs_log_returns_commits() { + register_test_backend("test-vcs"); + let (app, _) = build_vcs_status_app(); + let wv = test_webview(&app); + + let body = tauri::ipc::InvokeBody::Json(serde_json::json!({"query": {"rev": "HEAD", "limit": 10}})); + let res = invoke_cmd(&wv, "vcs_log", body); + assert!(res.is_ok(), "vcs_log should succeed: {:?}", res); +} + +#[test] +fn vcs_diff_file_returns_diff() { + register_test_backend("test-vcs"); + let (app, _) = build_vcs_status_app(); + let wv = test_webview(&app); + + let body = tauri::ipc::InvokeBody::Json(serde_json::json!({"path": "src/main.rs"})); + let res = invoke_cmd(&wv, "vcs_diff_file", body); + assert!(res.is_ok(), "vcs_diff_file should succeed: {:?}", res); +} + +#[test] +fn vcs_diff_commit_returns_diff() { + register_test_backend("test-vcs"); + let (app, _) = build_vcs_status_app(); + let wv = test_webview(&app); + + let body = tauri::ipc::InvokeBody::Json(serde_json::json!({"id": "abc123"})); + let res = invoke_cmd(&wv, "vcs_diff_commit", body); + assert!(res.is_ok(), "vcs_diff_commit should succeed: {:?}", res); +} + +#[test] +fn vcs_discard_paths_propagates_error() { + register_test_backend("test-vcs"); + let (app, _) = build_vcs_status_app(); + let wv = test_webview(&app); + + let body = tauri::ipc::InvokeBody::Json(serde_json::json!({"paths": ["src/main.rs"]})); + let res = invoke_cmd(&wv, "vcs_discard_paths", body); + assert!(res.is_err(), "discard_paths should fail (unsupported)"); +} + +#[test] +fn vcs_discard_patch_propagates_error() { + register_test_backend("test-vcs"); + let (app, _) = build_vcs_status_app(); + let wv = test_webview(&app); + + let body = tauri::ipc::InvokeBody::Json(serde_json::json!({"patch": "@@ -1 +1 @@\n-old\n+new\n"})); + let res = invoke_cmd(&wv, "vcs_discard_patch", body); + assert!(res.is_err(), "discard_patch should fail (unsupported)"); +} diff --git a/Backend/tests/tauri_commands/themes.rs b/Backend/tests/tauri_commands/themes.rs new file mode 100644 index 00000000..9e351dc5 --- /dev/null +++ b/Backend/tests/tauri_commands/themes.rs @@ -0,0 +1,109 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +use std::collections::HashSet; + +use crate::state::AppState; +use crate::themes::ThemeSource; +use crate::settings; +use tauri::test::{get_ipc_response, mock_builder, mock_context, noop_assets, INVOKE_KEY}; +use tauri::webview::InvokeRequest; +use tauri::WebviewWindowBuilder; + +use super::{normalize_plugin_id, theme_allowed_for_enabled_plugins}; + +// ── Pure function tests (existing) ── + +#[test] +fn normalizes_plugin_ids_for_theme_filtering() { + assert_eq!(normalize_plugin_id(Some(" OpenVCS.Git ")), "openvcs.git"); + assert_eq!(normalize_plugin_id(Some(" ")), ""); + assert_eq!(normalize_plugin_id(None), ""); +} + +#[test] +fn always_allows_non_plugin_themes() { + let enabled = HashSet::new(); + assert!(theme_allowed_for_enabled_plugins(&ThemeSource::BuiltIn, None, &enabled)); + assert!(theme_allowed_for_enabled_plugins(&ThemeSource::User, None, &enabled)); +} + +#[test] +fn only_allows_plugin_themes_from_enabled_plugins() { + let enabled = HashSet::from(["openvcs.git".to_string()]); + + assert!(theme_allowed_for_enabled_plugins( + &ThemeSource::Plugin, + Some(" OpenVCS.Git "), + &enabled, + )); + assert!(!theme_allowed_for_enabled_plugins( + &ThemeSource::Plugin, + Some("openvcs.hg"), + &enabled, + )); + assert!(!theme_allowed_for_enabled_plugins( + &ThemeSource::Plugin, + Some(" "), + &enabled, + )); +} + +// ── Tauri command integration tests ── + +fn build_app() -> tauri::App { + let cfg = settings::AppConfig::default(); + let app_state = AppState::new_with_config(cfg); + mock_builder() + .manage(app_state) + .invoke_handler(tauri::generate_handler![ + super::list_themes, + ]) + .build(mock_context(noop_assets())) + .expect("build test app") +} + +fn test_webview( + app: &tauri::App, +) -> tauri::WebviewWindow { + WebviewWindowBuilder::new(app, "main", Default::default()) + .build() + .expect("build test webview") +} + +fn invoke_cmd( + webview: &tauri::WebviewWindow, + cmd: &str, + body: tauri::ipc::InvokeBody, +) -> Result { + get_ipc_response( + webview, + InvokeRequest { + cmd: cmd.into(), + callback: tauri::ipc::CallbackFn(0), + error: tauri::ipc::CallbackFn(1), + url: "tauri://localhost".parse().unwrap(), + body, + headers: Default::default(), + invoke_key: INVOKE_KEY.to_string(), + }, + ) +} + +#[test] +fn list_themes_returns_themes() { + let app = build_app(); + let webview = test_webview(&app); + let res = invoke_cmd(&webview, "list_themes", tauri::ipc::InvokeBody::default()); + assert!(res.is_ok(), "list_themes should succeed: {:?}", res); + let themes: Vec = res.unwrap().deserialize().unwrap(); + // Should return at least built-in themes + assert!(!themes.is_empty(), "should have at least built-in themes"); + // All returned themes should be non-plugin (built-in or user) + for theme in &themes { + assert!( + !matches!(theme.source, ThemeSource::Plugin), + "should not include plugin themes by default" + ); + } +} diff --git a/Backend/tests/tauri_commands/updater.rs b/Backend/tests/tauri_commands/updater.rs new file mode 100644 index 00000000..81f5bf96 --- /dev/null +++ b/Backend/tests/tauri_commands/updater.rs @@ -0,0 +1,45 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +use super::{ + available_update_status, download_progress_percent, no_update_status, progress_payload, +}; + +#[test] +fn builds_empty_update_status() { + let status = no_update_status(); + assert!(!status.available); + assert_eq!(status.version, None); + assert_eq!(status.current_version, None); + assert_eq!(status.body, None); + assert_eq!(status.date, None); +} + +#[test] +fn builds_available_update_status() { + let status = available_update_status( + "2.0.0".into(), + "1.0.0".into(), + Some("notes".into()), + Some("2026-05-29".into()), + ); + assert!(status.available); + assert_eq!(status.version.as_deref(), Some("2.0.0")); + assert_eq!(status.current_version.as_deref(), Some("1.0.0")); + assert_eq!(status.body.as_deref(), Some("notes")); + assert_eq!(status.date.as_deref(), Some("2026-05-29")); +} + +#[test] +fn calculates_download_progress_percentages() { + assert_eq!(download_progress_percent(25, 100), 25); + assert_eq!(download_progress_percent(1, 0), 0); +} + +#[test] +fn builds_progress_payloads() { + let payload = progress_payload(12, 40); + assert_eq!(payload["kind"], "progress"); + assert_eq!(payload["received"], 12); + assert_eq!(payload["total"], 40); +} diff --git a/Frontend/package-lock.json b/Frontend/package-lock.json index cec4913e..c42be088 100644 --- a/Frontend/package-lock.json +++ b/Frontend/package-lock.json @@ -14,6 +14,7 @@ "devDependencies": { "@sentry/vite-plugin": "^5.2.1", "@types/node": "^25.6.2", + "@vitest/coverage-v8": "^4.1.7", "jsdom": "^29.1.1", "typescript": "^6.0.3", "vite": "^8.0.12", @@ -314,6 +315,16 @@ "node": ">=6.9.0" } }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@bramus/specificity": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", @@ -1172,6 +1183,37 @@ "undici-types": ">=7.24.0 <7.24.7" } }, + "node_modules/@vitest/coverage-v8": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.7.tgz", + "integrity": "sha512-qsYPeXc5Q9dFLd1i8Ap+Bx8sQgcp+rFVQo4R0dDsWNBzl26ldVF1qOO+RL24K7FDrR6pA+50XedRLSoSG24bVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.1.7", + "ast-v8-to-istanbul": "^1.0.0", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.2", + "obug": "^2.1.1", + "std-env": "^4.0.0-rc.1", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "4.1.7", + "vitest": "4.1.7" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, "node_modules/@vitest/expect": { "version": "4.1.7", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.7.tgz", @@ -1308,6 +1350,25 @@ "node": ">=12" } }, + "node_modules/ast-v8-to-istanbul": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.0.tgz", + "integrity": "sha512-1fSfIwuDICFA4LKkCzRPO7F0hzFf0B7+Xqrl27ynQaa+Rh0e1Es0v6kWHPott3lU10AyAr7oKHa65OppjLn3Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, + "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/balanced-match": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", @@ -1637,6 +1698,16 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/html-encoding-sniffer": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", @@ -1650,6 +1721,13 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, "node_modules/https-proxy-agent": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", @@ -1678,6 +1756,45 @@ "dev": true, "license": "ISC" }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -2071,6 +2188,47 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/magicast": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.3.tgz", + "integrity": "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.3", + "@babel/types": "^7.29.0", + "source-map-js": "^1.2.1" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-dir/node_modules/semver": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", + "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/mdn-data": { "version": "2.27.1", "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", @@ -2466,6 +2624,19 @@ "dev": true, "license": "MIT" }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/symbol-tree": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", diff --git a/Frontend/package.json b/Frontend/package.json index 2fa7c1a9..9659e41c 100644 --- a/Frontend/package.json +++ b/Frontend/package.json @@ -15,12 +15,14 @@ "dev": "vite", "test": "vitest", "test:watch": "vitest --watch", + "coverage": "vitest run --coverage", "build": "vite build", "preview": "vite preview --strictPort --port 1420" }, "devDependencies": { "@sentry/vite-plugin": "^5.2.1", "@types/node": "^25.6.2", + "@vitest/coverage-v8": "^4.1.7", "jsdom": "^29.1.1", "typescript": "^6.0.3", "vite": "^8.0.12", diff --git a/Frontend/src/scripts/features/about.test.ts b/Frontend/src/scripts/features/about.test.ts new file mode 100644 index 00000000..ce8d6f2e --- /dev/null +++ b/Frontend/src/scripts/features/about.test.ts @@ -0,0 +1,159 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const mockInvoke = vi.fn(); +const mockNotify = vi.fn(); +const mockOpenModal = vi.fn(); + +vi.mock('../lib/tauri', () => ({ + TAURI: { invoke: mockInvoke }, +})); + +vi.mock('../lib/notify', () => ({ + notify: mockNotify, +})); + +vi.mock('@scripts/ui/modals', () => ({ + openModal: mockOpenModal, +})); + +function mountModal(info?: Record) { + document.body.innerHTML = ` +
+ + + + + + + +
+ `; + if (info) { + (document.getElementById('about-modal') as any).__info = info; + } +} + +beforeEach(() => { + vi.resetModules(); + mockInvoke.mockReset(); + mockNotify.mockReset(); + mockOpenModal.mockReset(); +}); + +afterEach(() => { + document.body.innerHTML = ''; + vi.restoreAllMocks(); +}); + +describe('openAbout', () => { + it('opens modal and populates fields with full info', async () => { + mockInvoke.mockResolvedValue({ + version: '1.2.3', + build: 'build-42', + authors: 'Alice:Bob:Charlie', + homepage: 'https://example.com', + repository: 'https://github.com/example/repo.git', + }); + mountModal(); + + const { openAbout } = await import('./about'); + await openAbout(); + + expect(mockOpenModal).toHaveBeenCalledWith('about-modal'); + expect(document.getElementById('about-version')!.textContent).toBe('v1.2.3'); + expect(document.getElementById('about-build')!.textContent).toBe('build-42'); + expect(document.getElementById('about-build')!.style.display).toBe(''); + expect(document.getElementById('about-author')!.textContent).toBe('By Alice, Bob, Charlie'); + const homeLink = document.getElementById('about-home') as HTMLAnchorElement; + expect(homeLink.href).toBe('https://example.com/'); + expect(homeLink.style.display).toBe(''); + const repoLink = document.getElementById('about-repo') as HTMLAnchorElement; + expect(repoLink.href).toBe('https://github.com/example/repo.git'); + const licensesLink = document.getElementById('about-licenses') as HTMLAnchorElement; + expect(licensesLink.href).toBe('https://github.com/example/repo/blob/HEAD/LICENSE'); + }); + + it('handles null modal element gracefully', async () => { + const { openAbout } = await import('./about'); + await openAbout(); + expect(mockOpenModal).toHaveBeenCalledWith('about-modal'); + }); + + it('handles TAURI invoke returning null', async () => { + mockInvoke.mockResolvedValue(null); + mountModal(); + + const { openAbout } = await import('./about'); + await openAbout(); + + expect(document.getElementById('about-version')!.textContent).toBe(''); + expect(document.getElementById('about-build')!.textContent).toBe(''); + expect(document.getElementById('about-build')!.style.display).toBe('none'); + expect(document.getElementById('about-author')!.textContent).toBe(''); + const homeLink = document.getElementById('about-home') as HTMLAnchorElement; + expect(homeLink.style.display).toBe('none'); + expect(homeLink.hasAttribute('disabled')).toBe(true); + const licensesLink = document.getElementById('about-licenses') as HTMLAnchorElement; + expect(licensesLink.hasAttribute('disabled')).toBe(true); + }); + + it('handles empty authors gracefully', async () => { + mockInvoke.mockResolvedValue({ + version: '2.0', + build: '', + authors: '', + homepage: '', + repository: '', + }); + mountModal(); + + const { openAbout } = await import('./about'); + await openAbout(); + + expect(document.getElementById('about-version')!.textContent).toBe('v2.0'); + expect(document.getElementById('about-author')!.textContent).toBe(''); + const buildEl = document.getElementById('about-build') as HTMLElement; + expect(buildEl.style.display).toBe('none'); + }); + + it('handles partial authors with empty segments', async () => { + mockInvoke.mockResolvedValue({ + version: '', + authors: 'Alice::Bob', + }); + mountModal(); + + const { openAbout } = await import('./about'); + await openAbout(); + + expect(document.getElementById('about-author')!.textContent).toBe('By Alice, Bob'); + }); + + it('handles aboutLogo onerror', async () => { + mockInvoke.mockResolvedValue({}); + mountModal(); + + const { openAbout } = await import('./about'); + await openAbout(); + + const logo = document.getElementById('about-logo') as HTMLImageElement; + expect(logo.onerror).toBeDefined(); + logo.onerror!(new Event('error')); + expect(logo.style.display).toBe('none'); + }); + + it('handles missing about-licenses element', async () => { + document.body.innerHTML = ` +
+ +
+ `; + mockInvoke.mockResolvedValue({ repository: 'https://github.com/o/r.git' }); + + const { openAbout } = await import('./about'); + await expect(openAbout()).resolves.toBeUndefined(); + }); +}); diff --git a/Frontend/src/scripts/features/branches.test.ts b/Frontend/src/scripts/features/branches.test.ts new file mode 100644 index 00000000..7286ff58 --- /dev/null +++ b/Frontend/src/scripts/features/branches.test.ts @@ -0,0 +1,685 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const mockInvoke = vi.fn(); +const mockConfirmBool = vi.fn(); +const mockNotify = vi.fn(); +const mockRefreshOverlayScrollbarsFor = vi.fn(); +const mockOpenModal = vi.fn(); +const mockOpenRenameBranch = vi.fn(); +const mockOpenSetUpstream = vi.fn(); +const mockConfirmDeleteBranch = vi.fn(); +const mockBuildCtxMenu = vi.fn(); +const mockRenderList = vi.fn(); +const mockHydrateStatus = vi.fn(); +const mockSetTab = vi.fn(); +const mockOpenConflictsSummary = vi.fn(); +const mockGetPluginContextMenuItems = vi.fn(); +const mockRunHook = vi.fn(); +const mockRunPluginAction = vi.fn(); + +const mockState: any = { + branch: 'main', + branchLabel: 'main', + branches: [], + files: [], +}; + +vi.mock('../lib/tauri', () => ({ + TAURI: { invoke: mockInvoke }, +})); + +vi.mock('../lib/confirm', () => ({ + confirmBool: mockConfirmBool, +})); + +vi.mock('../lib/notify', () => ({ + notify: mockNotify, +})); + +vi.mock('../lib/scrollbars', () => ({ + refreshOverlayScrollbarsFor: mockRefreshOverlayScrollbarsFor, +})); + +vi.mock('../state/state', () => ({ + state: mockState, +})); + +vi.mock('../ui/modals', () => ({ + openModal: mockOpenModal, +})); + +vi.mock('./renameBranch', () => ({ + openRenameBranch: mockOpenRenameBranch, +})); + +vi.mock('./setUpstream', () => ({ + openSetUpstream: mockOpenSetUpstream, +})); + +vi.mock('./deleteBranchConfirm', () => ({ + confirmDeleteBranch: mockConfirmDeleteBranch, +})); + +vi.mock('../lib/menu', () => ({ + buildCtxMenu: mockBuildCtxMenu, + CtxItem: class {}, +})); + +vi.mock('./repo', () => ({ + renderList: mockRenderList, + hydrateStatus: mockHydrateStatus, +})); + +vi.mock('../ui/layout', () => ({ + setTab: mockSetTab, +})); + +vi.mock('./conflicts', () => ({ + openConflictsSummary: mockOpenConflictsSummary, +})); + +vi.mock('../plugins', () => ({ + getPluginContextMenuItems: mockGetPluginContextMenuItems, + runHook: mockRunHook, + runPluginAction: mockRunPluginAction, +})); + +function mountUI() { + document.body.innerHTML = ` + + + + + + `; +} + +function mockLoadBranches(branches: any[] = []) { + mockInvoke.mockResolvedValueOnce(branches); + mockInvoke.mockResolvedValueOnce({ detached: false, branch: (mockState.branch || 'main'), commit: 'abc' }); +} + +beforeEach(() => { + vi.resetModules(); + vi.useFakeTimers(); + mockInvoke.mockReset(); + mockConfirmBool.mockReset(); + mockNotify.mockReset(); + mockRefreshOverlayScrollbarsFor.mockReset(); + mockOpenModal.mockReset(); + mockOpenRenameBranch.mockReset(); + mockOpenSetUpstream.mockReset(); + mockConfirmDeleteBranch.mockReset(); + mockBuildCtxMenu.mockReset(); + mockRenderList.mockReset(); + mockHydrateStatus.mockReset(); + mockSetTab.mockReset(); + mockOpenConflictsSummary.mockReset(); + mockGetPluginContextMenuItems.mockReset(); + mockRunHook.mockReset(); + mockRunPluginAction.mockReset(); + mockGetPluginContextMenuItems.mockReturnValue([]); + mockRunHook.mockResolvedValue({ cancelled: false }); + mockState.branch = 'main'; + mockState.branchLabel = 'main'; + mockState.branches = []; + mountUI(); +}); + +afterEach(() => { + vi.useRealTimers(); + document.body.innerHTML = ''; + vi.restoreAllMocks(); +}); + +async function openPopover(expectedItems: number) { + document.getElementById('branch-switch')!.click(); + await vi.waitFor(() => { + expect(document.getElementById('branch-list')!.children.length).toBe(expectedItems); + }); +} + +describe('bindBranchUI', () => { + it('syncs branch labels on init with a branch set', async () => { + const { bindBranchUI } = await import('./branches'); + bindBranchUI(); + + expect(document.getElementById('branch-name')!.textContent).toBe('main'); + expect(document.getElementById('repo-branch')!.textContent).toBe('main'); + expect((document.getElementById('branch-switch') as HTMLButtonElement).disabled).toBe(false); + }); + + it('syncs branch labels with fallback when no branch', async () => { + mockState.branch = ''; + mockState.branchLabel = ''; + const { bindBranchUI } = await import('./branches'); + bindBranchUI(); + + expect(document.getElementById('branch-name')!.textContent).toBe('\u2014'); + expect((document.getElementById('branch-switch') as HTMLButtonElement).disabled).toBe(true); + }); + + it('toggles branch popover on branch button click', async () => { + mockLoadBranches([]); + const { bindBranchUI } = await import('./branches'); + bindBranchUI(); + + const btn = document.getElementById('branch-switch')!; + const pop = document.getElementById('branch-pop')!; + expect(pop.hidden).toBe(true); + + btn.click(); + await vi.waitFor(() => expect(pop.hidden).toBe(false)); + expect(btn.getAttribute('aria-expanded')).toBe('true'); + + btn.click(); + expect(pop.classList.contains('is-closing')).toBe(true); + }); + + it('loads branches and renders list on popover open', async () => { + mockLoadBranches([ + { name: 'main', current: true, kind: { type: 'local' } }, + { name: 'dev', kind: { type: 'local' } }, + { name: 'origin/feature', kind: { type: 'remote', remote: 'origin' } }, + ]); + + const { bindBranchUI } = await import('./branches'); + bindBranchUI(); + + document.getElementById('branch-switch')!.click(); + + await vi.waitFor(() => { + const list = document.getElementById('branch-list')!; + expect(list.children.length).toBe(4); + expect(list.textContent).toContain('main'); + expect(list.textContent).toContain('dev'); + expect(list.textContent).toContain('Remote branches'); + expect(list.textContent).toContain('origin/feature'); + }); + }); + + it('handles loadBranches failure gracefully', async () => { + mockInvoke.mockRejectedValue(new Error('fail')); + + const { bindBranchUI } = await import('./branches'); + bindBranchUI(); + + document.getElementById('branch-switch')!.click(); + + await vi.waitFor(() => { + expect(mockNotify).toHaveBeenCalledWith('Failed to load branches'); + }); + }); + + it('filters branches on input', async () => { + mockLoadBranches([ + { name: 'main', kind: { type: 'local' } }, + { name: 'feature-x', kind: { type: 'local' } }, + ]); + + const { bindBranchUI } = await import('./branches'); + bindBranchUI(); + + await openPopover(2); + + const filter = document.getElementById('branch-filter') as HTMLInputElement; + filter.value = 'feature'; + filter.dispatchEvent(new Event('input')); + + expect(document.getElementById('branch-list')!.children.length).toBe(1); + expect(document.getElementById('branch-list')!.textContent).toContain('feature-x'); + }); + + it('handles vcs_head_status returning detached head', async () => { + mockInvoke.mockResolvedValueOnce([]); + mockInvoke.mockResolvedValueOnce({ detached: true, commit: 'abc1234' }); + + const { bindBranchUI } = await import('./branches'); + bindBranchUI(); + + document.getElementById('branch-switch')!.click(); + + await vi.waitFor(() => { + expect(document.getElementById('branch-name')!.textContent).toContain('Detached HEAD'); + }); + }); + + it('clicking a branch in the list checks it out', async () => { + mockLoadBranches([ + { name: 'dev', kind: { type: 'local' } }, + ]); + mockInvoke.mockResolvedValue(undefined); + + const { bindBranchUI } = await import('./branches'); + bindBranchUI(); + await openPopover(1); + + const item = document.querySelector('li[data-branch]')!; + item.dispatchEvent(new MouseEvent('click', { bubbles: true })); + + await vi.waitFor(() => { + expect(mockInvoke).toHaveBeenCalledWith('vcs_checkout_branch', { name: 'dev' }); + expect(mockNotify).toHaveBeenCalledWith('Switched to dev'); + }); + }); + + it('clicking the document outside popover closes it', async () => { + mockLoadBranches([]); + const { bindBranchUI } = await import('./branches'); + bindBranchUI(); + + document.getElementById('branch-switch')!.click(); + await vi.waitFor(() => { + expect(document.getElementById('branch-pop')!.hidden).toBe(false); + }); + + document.dispatchEvent(new MouseEvent('click')); + expect(document.getElementById('branch-pop')!.classList.contains('is-closing')).toBe(true); + }); + + it('resize event closes popover', async () => { + mockLoadBranches([]); + const { bindBranchUI } = await import('./branches'); + bindBranchUI(); + + document.getElementById('branch-switch')!.click(); + await vi.waitFor(() => { + expect(document.getElementById('branch-pop')!.hidden).toBe(false); + }); + + window.dispatchEvent(new Event('resize')); + expect(document.getElementById('branch-pop')!.classList.contains('is-closing')).toBe(true); + }); + + it('app:branches-updated event syncs labels', async () => { + mockState.branchLabel = 'updated-branch'; + const { bindBranchUI } = await import('./branches'); + bindBranchUI(); + + window.dispatchEvent(new CustomEvent('app:branches-updated')); + + expect(document.getElementById('branch-name')!.textContent).toBe('updated-branch'); + }); + + it('new branch button opens the modal', async () => { + const { bindBranchUI } = await import('./branches'); + bindBranchUI(); + + document.getElementById('branch-new')!.click(); + + expect(mockOpenModal).toHaveBeenCalledWith('new-branch-modal'); + }); + + it('closes popover with animation timer', async () => { + mockLoadBranches([]); + const { bindBranchUI } = await import('./branches'); + bindBranchUI(); + + document.getElementById('branch-switch')!.click(); + await vi.waitFor(() => { + expect(document.getElementById('branch-pop')!.hidden).toBe(false); + }); + + document.getElementById('branch-switch')!.click(); + expect(document.getElementById('branch-pop')!.classList.contains('is-closing')).toBe(true); + + vi.advanceTimersByTime(130); + expect(document.getElementById('branch-pop')!.hidden).toBe(true); + expect((document.getElementById('branch-filter') as HTMLInputElement).value).toBe(''); + }); + + it('renderBranches handles empty branchList', async () => { + document.querySelector('#branch-list')!.remove(); + mockLoadBranches([]); + + const { bindBranchUI } = await import('./branches'); + bindBranchUI(); + + document.getElementById('branch-switch')!.click(); + await vi.waitFor(() => { + expect(document.getElementById('branch-pop')!.hidden).toBe(false); + }); + }); + + it('handles remote branches identified by full_ref', async () => { + mockLoadBranches([ + { name: 'main', full_ref: 'refs/heads/main', kind: { type: 'local' } }, + { name: 'origin/main', full_ref: 'refs/remotes/origin/main' }, + ]); + + const { bindBranchUI } = await import('./branches'); + bindBranchUI(); + + document.getElementById('branch-switch')!.click(); + + await vi.waitFor(() => { + const list = document.getElementById('branch-list')!; + expect(list.textContent).toContain('Remote branches'); + }); + }); + + describe('context menu', () => { + async function triggerContextMenu() { + const li = document.querySelector('li[data-branch]')!; + li.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, clientX: 100, clientY: 200 })); + await vi.waitFor(() => { + expect(mockBuildCtxMenu).toHaveBeenCalled(); + }); + return mockBuildCtxMenu.mock.calls[0][0]; + } + + it('shows checkout option', async () => { + mockLoadBranches([{ name: 'dev', kind: { type: 'local' } }]); + mockLoadBranches([{ name: 'dev', kind: { type: 'local' } }]); + + const { bindBranchUI } = await import('./branches'); + bindBranchUI(); + await openPopover(1); + + const items = await triggerContextMenu(); + expect(items[0].label).toBe('Checkout'); + }); + + it('merge into current', async () => { + mockConfirmBool.mockResolvedValue(true); + mockLoadBranches([{ name: 'feature', kind: { type: 'local' } }]); + mockLoadBranches([{ name: 'feature', kind: { type: 'local' } }]); + mockInvoke.mockResolvedValueOnce(undefined); + + const { bindBranchUI } = await import('./branches'); + bindBranchUI(); + await openPopover(1); + + const items = await triggerContextMenu(); + await items[1].action(); + + expect(mockConfirmBool).toHaveBeenCalledWith("Merge 'feature' into 'main'?"); + expect(mockInvoke).toHaveBeenCalledWith('vcs_merge_branch', { name: 'feature' }); + expect(mockNotify).toHaveBeenCalledWith("Merged branch 'feature' into 'main'"); + }); + + it('merge cancelled by user', async () => { + mockConfirmBool.mockResolvedValue(false); + mockLoadBranches([{ name: 'feature', kind: { type: 'local' } }]); + mockLoadBranches([{ name: 'feature', kind: { type: 'local' } }]); + + const { bindBranchUI } = await import('./branches'); + bindBranchUI(); + await openPopover(1); + + const items = await triggerContextMenu(); + await items[1].action(); + + expect(mockInvoke).not.toHaveBeenCalledWith('vcs_merge_branch', expect.anything()); + }); + + it('merge into self shows notify', async () => { + mockState.branch = 'feature'; + mockLoadBranches([{ name: 'feature', kind: { type: 'local' } }]); + mockLoadBranches([{ name: 'feature', kind: { type: 'local' } }]); + + const { bindBranchUI } = await import('./branches'); + bindBranchUI(); + await openPopover(1); + + const items = await triggerContextMenu(); + await items[1].action(); + + expect(mockNotify).toHaveBeenCalledWith('Cannot merge a branch into itself'); + }); + + it('merge detects conflicts', async () => { + mockConfirmBool.mockResolvedValue(true); + mockLoadBranches([{ name: 'feature', kind: { type: 'local' } }]); + mockLoadBranches([{ name: 'feature', kind: { type: 'local' } }]); + mockInvoke.mockRejectedValueOnce(new Error('Automatic merge failed; fix conflicts and then commit')); + mockState.files = [{ path: 'file.txt' }]; + + const { bindBranchUI } = await import('./branches'); + bindBranchUI(); + await openPopover(1); + + const items = await triggerContextMenu(); + await items[1].action(); + + expect(mockNotify).toHaveBeenCalledWith('Merge conflict detected'); + expect(mockSetTab).toHaveBeenCalledWith('changes'); + }); + + it('merge shows generic error', async () => { + mockConfirmBool.mockResolvedValue(true); + mockLoadBranches([{ name: 'feature', kind: { type: 'local' } }]); + mockLoadBranches([{ name: 'feature', kind: { type: 'local' } }]); + mockInvoke.mockRejectedValueOnce(new Error('some other error')); + + const { bindBranchUI } = await import('./branches'); + bindBranchUI(); + await openPopover(1); + + const items = await triggerContextMenu(); + await items[1].action(); + + expect(mockNotify).toHaveBeenCalledWith('Merge failed: Error: some other error'); + }); + + it('set upstream for local branch', async () => { + mockLoadBranches([ + { name: 'feature', kind: { type: 'local' } }, + { name: 'origin/main', kind: { type: 'remote', remote: 'origin' } }, + ]); + mockLoadBranches([ + { name: 'feature', kind: { type: 'local' } }, + { name: 'origin/main', kind: { type: 'remote', remote: 'origin' } }, + ]); + mockLoadBranches([ + { name: 'feature', kind: { type: 'local' } }, + { name: 'origin/main', kind: { type: 'remote', remote: 'origin' } }, + ]); + + const { bindBranchUI } = await import('./branches'); + bindBranchUI(); + await openPopover(3); + + const items = await triggerContextMenu(); + await items[3].action(); + + expect(mockOpenSetUpstream).toHaveBeenCalledWith('feature', ['origin/main']); + }); + + it('set upstream with no remote branches shows notify', async () => { + mockLoadBranches([{ name: 'feature', kind: { type: 'local' } }]); + mockLoadBranches([{ name: 'feature', kind: { type: 'local' } }]); + + const { bindBranchUI } = await import('./branches'); + bindBranchUI(); + await openPopover(1); + + const items = await triggerContextMenu(); + await items[3].action(); + + expect(mockNotify).toHaveBeenCalledWith('No remote branches found (fetch first)'); + }); + + it('rename opens rename modal', async () => { + mockLoadBranches([{ name: 'feature', kind: { type: 'local' } }]); + mockLoadBranches([{ name: 'feature', kind: { type: 'local' } }]); + + const { bindBranchUI } = await import('./branches'); + bindBranchUI(); + await openPopover(1); + + const items = await triggerContextMenu(); + await items[4].action(); + + expect(mockOpenRenameBranch).toHaveBeenCalledWith('feature'); + }); + + it('delete current branch shows notify', async () => { + mockLoadBranches([{ name: 'main', current: true, kind: { type: 'local' } }]); + mockLoadBranches([{ name: 'main', current: true, kind: { type: 'local' } }]); + + const { bindBranchUI } = await import('./branches'); + bindBranchUI(); + await openPopover(1); + + const items = await triggerContextMenu(); + await items[5].action(); + + expect(mockNotify).toHaveBeenCalledWith('Cannot delete the current branch'); + }); + + it('delete non-current branch with hooks', async () => { + mockConfirmDeleteBranch.mockResolvedValue(true); + mockLoadBranches([{ name: 'feature', kind: { type: 'local' } }]); + mockLoadBranches([{ name: 'feature', kind: { type: 'local' } }]); + mockInvoke.mockResolvedValueOnce(undefined); + + const { bindBranchUI } = await import('./branches'); + bindBranchUI(); + await openPopover(1); + + const items = await triggerContextMenu(); + await items[5].action(); + + expect(mockRunHook).toHaveBeenCalledWith('preBranchDelete', expect.any(Object)); + expect(mockInvoke).toHaveBeenCalledWith('vcs_delete_branch', { name: 'feature', force: false }); + expect(mockNotify).toHaveBeenCalledWith("Deleted 'feature'"); + }); + + it('delete cancelled by hook', async () => { + mockConfirmDeleteBranch.mockResolvedValue(true); + mockRunHook.mockResolvedValue({ cancelled: true, reason: 'Not allowed' }); + mockLoadBranches([{ name: 'feature', kind: { type: 'local' } }]); + mockLoadBranches([{ name: 'feature', kind: { type: 'local' } }]); + + const { bindBranchUI } = await import('./branches'); + bindBranchUI(); + await openPopover(1); + + const items = await triggerContextMenu(); + await items[5].action(); + + expect(mockNotify).toHaveBeenCalledWith('Not allowed'); + }); + + it('delete with force delete fallback', async () => { + mockConfirmDeleteBranch.mockResolvedValueOnce(true); + mockConfirmDeleteBranch.mockResolvedValueOnce(true); + mockLoadBranches([{ name: 'feature', kind: { type: 'local' } }]); + mockLoadBranches([{ name: 'feature', kind: { type: 'local' } }]); + mockInvoke.mockRejectedValueOnce(new Error('not fully merged')); + mockInvoke.mockResolvedValueOnce(undefined); + + const { bindBranchUI } = await import('./branches'); + bindBranchUI(); + await openPopover(1); + + const items = await triggerContextMenu(); + await items[5].action(); + + expect(mockConfirmDeleteBranch).toHaveBeenCalledWith(expect.objectContaining({ name: 'feature', force: true })); + expect(mockInvoke).toHaveBeenCalledWith('vcs_delete_branch', { name: 'feature', force: true }); + }); + + it('delete with force delete fallback cancelled by user', async () => { + mockConfirmDeleteBranch.mockResolvedValueOnce(true); + mockConfirmDeleteBranch.mockResolvedValueOnce(false); + mockLoadBranches([{ name: 'feature', kind: { type: 'local' } }]); + mockLoadBranches([{ name: 'feature', kind: { type: 'local' } }]); + mockInvoke.mockRejectedValueOnce(new Error('not fully merged')); + + const { bindBranchUI } = await import('./branches'); + bindBranchUI(); + await openPopover(1); + + const items = await triggerContextMenu(); + await items[5].action(); + + expect(mockNotify).toHaveBeenCalledWith('Delete cancelled'); + }); + + it('force delete with shift held', async () => { + mockConfirmDeleteBranch.mockResolvedValue(true); + mockLoadBranches([{ name: 'feature', kind: { type: 'local' } }]); + mockLoadBranches([{ name: 'feature', kind: { type: 'local' } }]); + mockInvoke.mockResolvedValueOnce(undefined); + + const { bindBranchUI } = await import('./branches'); + bindBranchUI(); + await openPopover(1); + + const li = document.querySelector('li[data-branch]')!; + li.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, clientX: 100, clientY: 200, shiftKey: true })); + await vi.waitFor(() => { + expect(mockBuildCtxMenu).toHaveBeenCalled(); + }); + + const items = mockBuildCtxMenu.mock.calls[0][0]; + expect(items[5].label).toBe('Force delete\u2026'); + await items[5].action(); + + expect(mockInvoke).toHaveBeenCalledWith('vcs_delete_branch', { name: 'feature', force: true }); + }); + + it('force delete failure shows error', async () => { + mockConfirmDeleteBranch.mockResolvedValue(true); + mockLoadBranches([{ name: 'feature', kind: { type: 'local' } }]); + mockLoadBranches([{ name: 'feature', kind: { type: 'local' } }]); + mockInvoke.mockRejectedValueOnce(new Error('fail')); + + const { bindBranchUI } = await import('./branches'); + bindBranchUI(); + await openPopover(1); + + const li = document.querySelector('li[data-branch]')!; + li.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, clientX: 100, clientY: 200, shiftKey: true })); + await vi.waitFor(() => { + expect(mockBuildCtxMenu).toHaveBeenCalled(); + }); + + const items = mockBuildCtxMenu.mock.calls[0][0]; + await items[5].action(); + + expect(mockNotify).toHaveBeenCalledWith('Force delete failed: Error: fail'); + }); + + it('delete cancelled by user at confirm', async () => { + mockConfirmDeleteBranch.mockResolvedValue(false); + mockLoadBranches([{ name: 'feature', kind: { type: 'local' } }]); + mockLoadBranches([{ name: 'feature', kind: { type: 'local' } }]); + + const { bindBranchUI } = await import('./branches'); + bindBranchUI(); + await openPopover(1); + + const items = await triggerContextMenu(); + await items[5].action(); + + expect(mockNotify).toHaveBeenCalledWith('Delete cancelled'); + }); + + it('includes plugin items', async () => { + mockGetPluginContextMenuItems.mockReturnValue([ + { label: 'Plugin Action', action: 'plugin:action' }, + ]); + mockLoadBranches([{ name: 'feature', kind: { type: 'local' } }]); + mockLoadBranches([{ name: 'feature', kind: { type: 'local' } }]); + + const { bindBranchUI } = await import('./branches'); + bindBranchUI(); + await openPopover(1); + + document.querySelector('li[data-branch]')! + .dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, clientX: 100, clientY: 200 })); + await vi.waitFor(() => { + expect(mockGetPluginContextMenuItems).toHaveBeenCalledWith('branches'); + }); + }); + }); +}); diff --git a/Frontend/src/scripts/features/cherryPick.test.ts b/Frontend/src/scripts/features/cherryPick.test.ts new file mode 100644 index 00000000..e55d5368 --- /dev/null +++ b/Frontend/src/scripts/features/cherryPick.test.ts @@ -0,0 +1,420 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const mockInvoke = vi.hoisted(() => vi.fn().mockResolvedValue(null)); + +vi.mock('../lib/tauri', () => ({ TAURI: { invoke: mockInvoke } })); +vi.mock('../lib/notify', () => ({ notify: vi.fn() })); +vi.mock('../ui/modals', () => ({ + closeModal: vi.fn(), + hydrate: vi.fn(), + openModal: vi.fn(), +})); +vi.mock('./repo', () => ({ + hydrateBranches: vi.fn(), + hydrateCommits: vi.fn(), + hydrateStatus: vi.fn(), +})); + +const mockState = vi.hoisted(() => ({ + branch: 'main', + branches: [ + { name: 'main', kind: { type: 'Local' }, full_ref: 'refs/heads/main' }, + { name: 'feature', kind: { type: 'Local' }, full_ref: 'refs/heads/feature' }, + { name: 'origin/main', kind: { type: 'Remote' }, full_ref: 'refs/remotes/origin/main' }, + ], +})); + +vi.mock('../state/state', () => ({ state: mockState })); + +function mountCherryPickModal() { + document.body.innerHTML = ` +
+ + + +
+ `; +} + +function flushPromises(): Promise { + return new Promise((resolve) => window.setTimeout(resolve, 0)); +} + +beforeEach(() => { + vi.resetModules(); + mountCherryPickModal(); + mockInvoke.mockReset(); + mockInvoke.mockResolvedValue(null); +}); + +afterEach(() => { + document.body.innerHTML = ''; + vi.clearAllMocks(); + vi.restoreAllMocks(); +}); + +describe('wireCherryPick', () => { + it('sets __wired and skips on second call', async () => { + const { wireCherryPick } = await import('./cherryPick'); + const modal = document.getElementById('cherry-pick-modal') as any; + expect(modal.__wired).toBeUndefined(); + wireCherryPick(); + expect(modal.__wired).toBe(true); + wireCherryPick(); + expect(modal.__wired).toBe(true); + }); + + it('does nothing when modal is missing', async () => { + document.body.innerHTML = ''; + const { wireCherryPick } = await import('./cherryPick'); + expect(() => wireCherryPick()).not.toThrow(); + }); + + it('validate enables confirm when commit and branch are set', async () => { + const { wireCherryPick } = await import('./cherryPick'); + wireCherryPick(); + const modal = document.getElementById('cherry-pick-modal') as HTMLElement; + const confirm = modal.querySelector('#cherry-pick-confirm') as HTMLButtonElement; + modal.dataset.commit = 'abc123'; + const branchEl = modal.querySelector('#cherry-pick-branch') as HTMLSelectElement; + branchEl.value = 'feature'; + + branchEl.dispatchEvent(new Event('change')); + + expect(confirm.disabled).toBe(false); + }); + + it('validate disables confirm when commit is empty', async () => { + const { wireCherryPick } = await import('./cherryPick'); + wireCherryPick(); + const modal = document.getElementById('cherry-pick-modal') as HTMLElement; + const confirm = modal.querySelector('#cherry-pick-confirm') as HTMLButtonElement; + modal.dataset.commit = ''; + const branchEl = modal.querySelector('#cherry-pick-branch') as HTMLSelectElement; + branchEl.value = 'feature'; + + branchEl.dispatchEvent(new Event('change')); + + expect(confirm.disabled).toBe(true); + }); + + it('validate disables confirm when branch is empty', async () => { + const { wireCherryPick } = await import('./cherryPick'); + wireCherryPick(); + const modal = document.getElementById('cherry-pick-modal') as HTMLElement; + const confirm = modal.querySelector('#cherry-pick-confirm') as HTMLButtonElement; + modal.dataset.commit = 'abc123'; + const branchEl = modal.querySelector('#cherry-pick-branch') as HTMLSelectElement; + branchEl.value = ''; + + branchEl.dispatchEvent(new Event('change')); + + expect(confirm.disabled).toBe(true); + }); + + it('confirm click invokes vcs_cherry_pick_to_branch and refreshes', async () => { + const { notify } = await import('../lib/notify'); + const { closeModal } = await import('../ui/modals'); + const { hydrateBranches, hydrateCommits, hydrateStatus } = await import('./repo'); + + const { wireCherryPick } = await import('./cherryPick'); + wireCherryPick(); + const modal = document.getElementById('cherry-pick-modal') as HTMLElement; + const confirm = modal.querySelector('#cherry-pick-confirm') as HTMLButtonElement; + modal.dataset.commit = 'abc123def456'; + const branchEl = modal.querySelector('#cherry-pick-branch') as HTMLSelectElement; + branchEl.value = 'feature'; + + confirm.click(); + + expect(mockInvoke).toHaveBeenCalledWith('vcs_cherry_pick_to_branch', { + id: 'abc123def456', + branch: 'feature', + }); + await flushPromises(); + expect(notify).toHaveBeenCalledWith("Cherry-picked onto feature"); + expect(closeModal).toHaveBeenCalledWith('cherry-pick-modal'); + expect(hydrateBranches).toHaveBeenCalled(); + expect(hydrateStatus).toHaveBeenCalled(); + expect(hydrateCommits).toHaveBeenCalled(); + }); + + it('confirm click handles error from invoke', async () => { + mockInvoke.mockRejectedValue(new Error('merge conflict')); + const { notify } = await import('../lib/notify'); + + const { wireCherryPick } = await import('./cherryPick'); + wireCherryPick(); + const modal = document.getElementById('cherry-pick-modal') as HTMLElement; + const confirm = modal.querySelector('#cherry-pick-confirm') as HTMLButtonElement; + modal.dataset.commit = 'abc123'; + const branchEl = modal.querySelector('#cherry-pick-branch') as HTMLSelectElement; + branchEl.value = 'feature'; + + confirm.click(); + await flushPromises(); + + expect(notify).toHaveBeenCalledWith('Cherry-pick failed: Error: merge conflict'); + }); + + it('confirm click handles undefined error', async () => { + mockInvoke.mockRejectedValue(''); + const { notify } = await import('../lib/notify'); + + const { wireCherryPick } = await import('./cherryPick'); + wireCherryPick(); + const modal = document.getElementById('cherry-pick-modal') as HTMLElement; + const confirm = modal.querySelector('#cherry-pick-confirm') as HTMLButtonElement; + modal.dataset.commit = 'abc123'; + const branchEl = modal.querySelector('#cherry-pick-branch') as HTMLSelectElement; + branchEl.value = 'feature'; + + confirm.click(); + await flushPromises(); + + expect(notify).toHaveBeenCalledWith('Cherry-pick failed'); + }); + + it('confirm click returns early when commit or branch missing', async () => { + const { wireCherryPick } = await import('./cherryPick'); + wireCherryPick(); + const confirm = document.getElementById('cherry-pick-confirm') as HTMLButtonElement; + const branchEl = document.getElementById('cherry-pick-branch') as HTMLSelectElement; + + (document.getElementById('cherry-pick-modal') as HTMLElement).dataset.commit = ''; + branchEl.value = 'feature'; + confirm.click(); + await flushPromises(); + expect(mockInvoke).not.toHaveBeenCalled(); + + (document.getElementById('cherry-pick-modal') as HTMLElement).dataset.commit = 'abc123'; + branchEl.value = ''; + confirm.click(); + await flushPromises(); + expect(mockInvoke).not.toHaveBeenCalled(); + }); + + it('setInitial fills commit info and branch options', async () => { + const { wireCherryPick } = await import('./cherryPick'); + wireCherryPick(); + const modal = document.getElementById('cherry-pick-modal') as any; + modal.setInitial( + { id: 'abc123def456', msg: 'Fix critical bug' }, + ['main', 'feature', 'develop'], + 'feature', + ); + + const commitEl = document.getElementById('cherry-pick-commit') as HTMLInputElement; + expect(commitEl.value).toBe('abc123d — Fix critical bug'); + expect(modal.dataset.commit).toBe('abc123def456'); + + const branchEl = document.getElementById('cherry-pick-branch') as HTMLSelectElement; + expect(branchEl.value).toBe('feature'); + const options = Array.from(branchEl.options).map((o) => o.value); + expect(options).toEqual(['', 'develop', 'feature', 'main']); + }); + + it('setInitial prefers currentBranch over first option', async () => { + const { wireCherryPick } = await import('./cherryPick'); + wireCherryPick(); + const modal = document.getElementById('cherry-pick-modal') as any; + modal.setInitial( + { id: 'abc', msg: '' }, + ['develop', 'main', 'feature'], + 'develop', + ); + + const branchEl = document.getElementById('cherry-pick-branch') as HTMLSelectElement; + expect(branchEl.value).toBe('develop'); + }); + + it('setInitial falls back to first option when currentBranch not in list', async () => { + const { wireCherryPick } = await import('./cherryPick'); + wireCherryPick(); + const modal = document.getElementById('cherry-pick-modal') as any; + modal.setInitial( + { id: 'abc', msg: '' }, + ['develop', 'main', 'feature'], + 'nonexistent', + ); + + const branchEl = document.getElementById('cherry-pick-branch') as HTMLSelectElement; + expect(branchEl.value).toBe('develop'); + }); + + it('setInitial handles empty commit ID and msg', async () => { + const { wireCherryPick } = await import('./cherryPick'); + wireCherryPick(); + const modal = document.getElementById('cherry-pick-modal') as any; + modal.setInitial({}, ['main'], ''); + + const commitEl = document.getElementById('cherry-pick-commit') as HTMLInputElement; + expect(commitEl.value).toBe(''); + expect(modal.dataset.commit).toBe(''); + }); + + it('setInitial handles missing branchEl', async () => { + const { wireCherryPick } = await import('./cherryPick'); + wireCherryPick(); + document.getElementById('cherry-pick-branch')?.remove(); + const modal = document.getElementById('cherry-pick-modal') as any; + expect(() => modal.setInitial({ id: 'abc' }, ['main'], 'main')).not.toThrow(); + }); + + it('setInitial focuses branch select', async () => { + const { wireCherryPick } = await import('./cherryPick'); + wireCherryPick(); + const modal = document.getElementById('cherry-pick-modal') as any; + const branchEl = document.getElementById('cherry-pick-branch') as HTMLSelectElement; + const focusSpy = vi.spyOn(branchEl, 'focus'); + + modal.setInitial({ id: 'abc' }, ['main'], 'main'); + await flushPromises(); + + expect(focusSpy).toHaveBeenCalled(); + }); +}); + +describe('openCherryPick', () => { + it('opens modal with branches filtered for non-remote', async () => { + const { hydrate } = await import('../ui/modals'); + const { openModal } = await import('../ui/modals'); + + const { openCherryPick } = await import('./cherryPick'); + + await openCherryPick({ id: 'abc', msg: 'Commit msg' }); + + expect(hydrate).toHaveBeenCalledWith('cherry-pick-modal'); + expect(openModal).toHaveBeenCalledWith('cherry-pick-modal'); + + const branchEl = document.getElementById('cherry-pick-branch') as HTMLSelectElement; + const options = Array.from(branchEl.options).map((o) => o.value.trim()).filter(Boolean); + expect(options).toEqual(['feature', 'main']); + }); + + it('notifies when no local branches exist', async () => { + mockState.branches = []; + const { notify } = await import('../lib/notify'); + const { openModal } = await import('../ui/modals'); + + const { openCherryPick } = await import('./cherryPick'); + await openCherryPick({ id: 'abc', msg: 'Test' }); + + expect(notify).toHaveBeenCalledWith('No local branches found'); + expect(openModal).not.toHaveBeenCalled(); + }); + + it('calls setInitial and opens modal on the modal', async () => { + mockState.branches = [ + { name: 'main', kind: { type: 'Local' }, full_ref: 'refs/heads/main' }, + ]; + mockState.branch = 'main'; + + const { openModal } = await import('../ui/modals'); + const { openCherryPick } = await import('./cherryPick'); + await openCherryPick({ id: 'abc123', msg: 'Fix' }); + + const modal = document.getElementById('cherry-pick-modal') as any; + expect(modal.dataset.commit).toBe('abc123'); + expect(openModal).toHaveBeenCalledWith('cherry-pick-modal'); + }); + + it('setInitial displays short id when no message is provided', async () => { + mockState.branches = [ + { name: 'main', kind: { type: 'Local' }, full_ref: 'refs/heads/main' }, + ]; + mockState.branch = 'main'; + + const { openCherryPick } = await import('./cherryPick'); + await openCherryPick({ id: 'abc1234567', msg: '' }); + + const commitEl = document.getElementById('cherry-pick-commit') as HTMLInputElement; + expect(commitEl.value).toBe('abc1234'); + }); + + it('setInitial writes short id when commit has no id', async () => { + mockState.branches = [ + { name: 'main', kind: { type: 'Local' }, full_ref: 'refs/heads/main' }, + ]; + mockState.branch = 'main'; + + const { openCherryPick } = await import('./cherryPick'); + await openCherryPick({ id: 'xyz789', msg: null as any }); + + const commitEl = document.getElementById('cherry-pick-commit') as HTMLInputElement; + expect(commitEl.value).toBe('xyz789'); + }); +}); + +describe('wireCherryPick setInitial edge cases', () => { + beforeEach(() => { + vi.resetModules(); + mountCherryPickModal(); + mockInvoke.mockReset(); + mockInvoke.mockResolvedValue(null); + }); + + it('handles empty branches array with no currentBranch', async () => { + const { wireCherryPick } = await import('./cherryPick'); + wireCherryPick(); + const modal = document.getElementById('cherry-pick-modal') as any; + modal.setInitial({ id: 'abc' }, [], ''); + + const branchEl = document.getElementById('cherry-pick-branch') as HTMLSelectElement; + expect(branchEl.value).toBe(''); + const options = Array.from(branchEl.options).filter((o) => o.value); + expect(options.length).toBe(0); + }); + + it('handles missing commitEl gracefully', async () => { + document.getElementById('cherry-pick-commit')?.remove(); + const { wireCherryPick } = await import('./cherryPick'); + wireCherryPick(); + const modal = document.getElementById('cherry-pick-modal') as any; + expect(() => modal.setInitial({ id: 'abc', msg: 'test' }, ['main'], 'main')).not.toThrow(); + }); + + it('handles commit with no id and no msg', async () => { + const { wireCherryPick } = await import('./cherryPick'); + wireCherryPick(); + const modal = document.getElementById('cherry-pick-modal') as any; + modal.setInitial({ id: '', msg: '' }, ['main'], 'main'); + const commitEl = document.getElementById('cherry-pick-commit') as HTMLInputElement; + expect(commitEl.value).toBe(''); + }); +}); + +describe('wireCherryPick confirm handler edge cases', () => { + beforeEach(() => { + vi.resetModules(); + mountCherryPickModal(); + mockInvoke.mockReset(); + mockInvoke.mockResolvedValue(null); + }); + + it('re-validates after confirm error via finally block', async () => { + const { wireCherryPick } = await import('./cherryPick'); + wireCherryPick(); + const modal = document.getElementById('cherry-pick-modal') as HTMLElement; + const confirm = modal.querySelector('#cherry-pick-confirm') as HTMLButtonElement; + const branchEl = modal.querySelector('#cherry-pick-branch') as HTMLSelectElement; + + modal.dataset.commit = 'abc123'; + branchEl.value = 'feature'; + + // Confirm succeeds, then we can check it still validates + confirm.click(); + await new Promise((r) => setTimeout(r, 0)); + + // After success, validate() was called via finally - confirm should be enabled + expect(confirm.disabled).toBe(false); + }); +}); diff --git a/Frontend/src/scripts/features/commandSheet.test.ts b/Frontend/src/scripts/features/commandSheet.test.ts new file mode 100644 index 00000000..8d81578e --- /dev/null +++ b/Frontend/src/scripts/features/commandSheet.test.ts @@ -0,0 +1,487 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../lib/tauri', () => ({ + TAURI: { + invoke: vi.fn(), + }, +})); + +vi.mock('../lib/notify', () => ({ + notify: vi.fn(), +})); + +vi.mock('../ui/modals', () => ({ + openModal: vi.fn(), + closeModal: vi.fn(), + hydrate: vi.fn(), +})); + +vi.mock('./repoSelection', () => ({ + refreshRepoSummary: vi.fn().mockResolvedValue(undefined), +})); + +class ResizeObserverMock { + observe = vi.fn(); + disconnect = vi.fn(); +} + +class MutationObserverMock { + constructor(private readonly callback: MutationCallback) {} + + observe = vi.fn(() => { + this.callback([] as MutationRecord[], this as unknown as MutationObserver); + }); + + disconnect = vi.fn(); +} + +function mountCommandModal() { + document.body.innerHTML = ` +
+
+
+ + +
+
+
+ + + + +
+ +
+ `; + + const seg = document.querySelector('.seg') as HTMLElement; + const cloneTab = document.querySelector('[data-sheet="clone"]') as HTMLElement; + const addTab = document.querySelector('[data-sheet="add"]') as HTMLElement; + seg.getBoundingClientRect = vi.fn(() => ({ left: 10, width: 200 }) as DOMRect); + cloneTab.getBoundingClientRect = vi.fn(() => ({ left: 14, width: 80 }) as DOMRect); + addTab.getBoundingClientRect = vi.fn(() => ({ left: 100, width: 70 }) as DOMRect); +} + +beforeEach(() => { + vi.resetModules(); + vi.resetAllMocks(); + vi.useFakeTimers(); + mountCommandModal(); + window.requestAnimationFrame = vi.fn((callback: FrameRequestCallback) => { + callback(0); + return 1; + }); + window.ResizeObserver = ResizeObserverMock as unknown as typeof ResizeObserver; + window.MutationObserver = MutationObserverMock as unknown as typeof MutationObserver; +}); + +describe('bindCommandSheet', () => { + it('validates clone inputs and surfaces backend rejection reasons', async () => { + const { TAURI } = await import('../lib/tauri'); + const { notify } = await import('../lib/notify'); + vi.mocked(TAURI.invoke).mockResolvedValueOnce({ ok: false, reason: 'Bad clone input' }); + + const { bindCommandSheet } = await import('./commandSheet'); + bindCommandSheet(); + + const cloneUrl = document.getElementById('clone-url') as HTMLInputElement; + cloneUrl.value = 'https://example.com/repo.git'; + cloneUrl.dispatchEvent(new Event('input', { bubbles: true })); + await Promise.resolve(); + + expect(TAURI.invoke).toHaveBeenCalledWith('validate_clone_input', { + url: 'https://example.com/repo.git', + dest: '', + }); + expect(document.getElementById('do-clone')).toHaveProperty('disabled', true); + expect(notify).toHaveBeenCalledWith('Bad clone input'); + }); + + it('switches tabs via keyboard navigation and updates panel visibility', async () => { + const { bindCommandSheet } = await import('./commandSheet'); + bindCommandSheet(); + + const seg = document.querySelector('.seg') as HTMLElement; + seg.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true })); + + const addTab = document.querySelector('[data-sheet="add"]') as HTMLButtonElement; + expect(addTab.classList.contains('active')).toBe(true); + expect(addTab.getAttribute('aria-selected')).toBe('true'); + expect(document.getElementById('sheet-clone')?.classList.contains('hidden')).toBe(true); + expect(document.getElementById('sheet-add')?.classList.contains('hidden')).toBe(false); + }); + + it('ignores unsupported keyboard shortcuts on the segment control', async () => { + const { bindCommandSheet } = await import('./commandSheet'); + bindCommandSheet(); + + const cloneTab = document.querySelector('[data-sheet="clone"]') as HTMLButtonElement; + const seg = document.querySelector('.seg') as HTMLElement; + seg.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true })); + + expect(cloneTab.classList.contains('active')).toBe(true); + }); + + it('navigates tabs via ArrowLeft and ArrowRight', async () => { + const { bindCommandSheet } = await import('./commandSheet'); + bindCommandSheet(); + + const seg = document.querySelector('.seg') as HTMLElement; + seg.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true })); + const addTab = document.querySelector('[data-sheet="add"]') as HTMLButtonElement; + expect(addTab.classList.contains('active')).toBe(true); + + seg.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true })); + const cloneTab = document.querySelector('[data-sheet="clone"]') as HTMLButtonElement; + expect(cloneTab.classList.contains('active')).toBe(true); + }); + + it('navigates tabs via Home and End keyboard shortcuts', async () => { + const { bindCommandSheet } = await import('./commandSheet'); + bindCommandSheet(); + + const seg = document.querySelector('.seg') as HTMLElement; + seg.dispatchEvent(new KeyboardEvent('keydown', { key: 'End', bubbles: true })); + const addTab = document.querySelector('[data-sheet="add"]') as HTMLButtonElement; + expect(addTab.classList.contains('active')).toBe(true); + + seg.dispatchEvent(new KeyboardEvent('keydown', { key: 'Home', bubbles: true })); + const cloneTab = document.querySelector('[data-sheet="clone"]') as HTMLButtonElement; + expect(cloneTab.classList.contains('active')).toBe(true); + }); + + it('activates focused tab via Enter key', async () => { + const { bindCommandSheet } = await import('./commandSheet'); + bindCommandSheet(); + + const cloneTab = document.querySelector('[data-sheet="clone"]') as HTMLButtonElement; + cloneTab.focus(); + const seg = document.querySelector('.seg') as HTMLElement; + seg.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true })); + }); + + it('activates focused tab via Space key', async () => { + const { bindCommandSheet } = await import('./commandSheet'); + bindCommandSheet(); + + const cloneTab = document.querySelector('[data-sheet="clone"]') as HTMLButtonElement; + cloneTab.focus(); + const seg = document.querySelector('.seg') as HTMLElement; + seg.dispatchEvent(new KeyboardEvent('keydown', { key: ' ', bubbles: true, cancelable: true })); + }); + + it('opens the requested sheet and focuses the first relevant input', async () => { + const { openModal } = await import('../ui/modals'); + const focusSpy = vi.spyOn(document.getElementById('add-path') as HTMLInputElement, 'focus'); + const { openSheet } = await import('./commandSheet'); + + openSheet('add'); + vi.runAllTimers(); + + expect(openModal).toHaveBeenCalledWith('command-modal'); + expect(document.querySelector('[data-sheet="add"]')?.classList.contains('active')).toBe(true); + expect(focusSpy).toHaveBeenCalledWith({ preventScroll: true }); + }); + + it('runs clone flow, refreshes summary, and closes the modal', async () => { + const { TAURI } = await import('../lib/tauri'); + const { notify } = await import('../lib/notify'); + const { closeModal } = await import('../ui/modals'); + const { refreshRepoSummary } = await import('./repoSelection'); + vi.mocked(TAURI.invoke) + .mockResolvedValueOnce({ ok: true }) + .mockResolvedValueOnce({ ok: true }) + .mockResolvedValueOnce(undefined); + + const { bindCommandSheet } = await import('./commandSheet'); + bindCommandSheet(); + + (document.getElementById('clone-url') as HTMLInputElement).value = 'https://example.com/repo.git'; + (document.getElementById('clone-path') as HTMLInputElement).value = '/tmp/repo'; + (document.getElementById('do-clone') as HTMLButtonElement).disabled = false; + (document.getElementById('do-clone') as HTMLButtonElement).click(); + await Promise.resolve(); + await Promise.resolve(); + + expect(TAURI.invoke).toHaveBeenCalledWith('clone_repo', { + url: 'https://example.com/repo.git', + dest: '/tmp/repo', + }); + expect(refreshRepoSummary).toHaveBeenCalled(); + expect(notify).toHaveBeenCalledWith('Cloned https://example.com/repo.git → /tmp/repo'); + expect(closeModal).toHaveBeenCalledWith('command-modal'); + }); + + it('notifies when add fails', async () => { + const { TAURI } = await import('../lib/tauri'); + const { notify } = await import('../lib/notify'); + vi.mocked(TAURI.invoke).mockRejectedValueOnce(new Error('nope')); + + const { bindCommandSheet } = await import('./commandSheet'); + bindCommandSheet(); + + (document.getElementById('add-path') as HTMLInputElement).value = '/tmp/repo'; + (document.getElementById('do-add') as HTMLButtonElement).disabled = false; + (document.getElementById('do-add') as HTMLButtonElement).click(); + await Promise.resolve(); + + expect(TAURI.invoke).toHaveBeenCalledWith('add_repo', { path: '/tmp/repo' }); + expect(notify).toHaveBeenCalledWith('Add failed'); + }); + + it('runs add flow successfully and closes the modal', async () => { + const { TAURI } = await import('../lib/tauri'); + const { notify } = await import('../lib/notify'); + const { closeModal } = await import('../ui/modals'); + const { refreshRepoSummary } = await import('./repoSelection'); + vi.mocked(TAURI.invoke).mockResolvedValueOnce(undefined); + + const { bindCommandSheet } = await import('./commandSheet'); + bindCommandSheet(); + + (document.getElementById('add-path') as HTMLInputElement).value = '/tmp/repo'; + (document.getElementById('do-add') as HTMLButtonElement).disabled = false; + (document.getElementById('do-add') as HTMLButtonElement).click(); + await Promise.resolve(); + await Promise.resolve(); + + expect(refreshRepoSummary).toHaveBeenCalled(); + expect(notify).toHaveBeenCalledWith('Added /tmp/repo'); + expect(closeModal).toHaveBeenCalledWith('command-modal'); + }); + + it('disables actions when validation commands reject', async () => { + const { TAURI } = await import('../lib/tauri'); + vi.mocked(TAURI.invoke) + .mockRejectedValueOnce(new Error('clone validation failed')) + .mockRejectedValueOnce(new Error('add validation failed')); + + const { bindCommandSheet } = await import('./commandSheet'); + bindCommandSheet(); + + (document.getElementById('clone-path') as HTMLInputElement).value = '/tmp/clone'; + (document.getElementById('clone-path') as HTMLInputElement).dispatchEvent( + new Event('input', { bubbles: true }), + ); + await Promise.resolve(); + + (document.getElementById('add-path') as HTMLInputElement).value = '/tmp/add'; + (document.getElementById('add-path') as HTMLInputElement).dispatchEvent( + new Event('input', { bubbles: true }), + ); + await Promise.resolve(); + + expect(document.getElementById('do-clone')).toHaveProperty('disabled', true); + expect(document.getElementById('do-add')).toHaveProperty('disabled', true); + }); + + it('notifies when clone fails after submission', async () => { + const { TAURI } = await import('../lib/tauri'); + const { notify } = await import('../lib/notify'); + vi.mocked(TAURI.invoke).mockRejectedValueOnce(new Error('clone failed')); + + const { bindCommandSheet } = await import('./commandSheet'); + bindCommandSheet(); + + (document.getElementById('clone-url') as HTMLInputElement).value = 'https://example.com/repo.git'; + (document.getElementById('clone-path') as HTMLInputElement).value = '/tmp/repo'; + (document.getElementById('do-clone') as HTMLButtonElement).disabled = false; + (document.getElementById('do-clone') as HTMLButtonElement).click(); + await Promise.resolve(); + + expect(notify).toHaveBeenCalledWith('Clone failed'); + }); + + it('populates browse actions and revalidates chosen directories', async () => { + const { TAURI } = await import('../lib/tauri'); + vi.mocked(TAURI.invoke) + .mockResolvedValueOnce('/repos/clone-target') + .mockResolvedValueOnce({ ok: true }) + .mockResolvedValueOnce('/repos/existing') + .mockResolvedValueOnce({ ok: true }); + + const { bindCommandSheet } = await import('./commandSheet'); + bindCommandSheet(); + + (document.getElementById('browse-clone') as HTMLButtonElement).click(); + await Promise.resolve(); + await Promise.resolve(); + expect((document.getElementById('clone-path') as HTMLInputElement).value).toBe('/repos/clone-target'); + + (document.getElementById('browse-add') as HTMLButtonElement).click(); + await Promise.resolve(); + await Promise.resolve(); + expect((document.getElementById('add-path') as HTMLInputElement).value).toBe('/repos/existing'); + }); +}); + +describe('setDisabled, ensureIndicator, positionIndicator coverage', () => { + beforeEach(() => { + vi.resetModules(); + vi.resetAllMocks(); + vi.useFakeTimers(); + mountCommandModal(); + window.requestAnimationFrame = vi.fn((callback: FrameRequestCallback) => { + callback(0); + return 1; + }); + window.ResizeObserver = ResizeObserverMock as unknown as typeof ResizeObserver; + window.MutationObserver = MutationObserverMock as unknown as typeof MutationObserver; + }); + + it('setDisabled handles missing element gracefully', async () => { + const { bindCommandSheet } = await import('./commandSheet'); + bindCommandSheet(); + + document.getElementById('do-clone')?.remove(); + + const { TAURI } = await import('../lib/tauri'); + vi.mocked(TAURI.invoke).mockResolvedValueOnce({ ok: false, reason: 'bad' }); + + const cloneUrl = document.getElementById('clone-url') as HTMLInputElement; + cloneUrl.value = 'test'; + cloneUrl.dispatchEvent(new Event('input', { bubbles: true })); + await Promise.resolve(); + }); + + it('ensureIndicator returns null when seg element is absent', async () => { + document.querySelector('.seg')?.remove(); + + const { bindCommandSheet } = await import('./commandSheet'); + expect(() => bindCommandSheet()).not.toThrow(); + }); + + it('positionIndicator handles missing active tab', async () => { + document.querySelector('.seg-btn.active')?.classList.remove('active'); + + const { bindCommandSheet } = await import('./commandSheet'); + bindCommandSheet(); + }); + + it('sets __wired flag and skips re-wiring on second call', async () => { + const { bindCommandSheet } = await import('./commandSheet'); + bindCommandSheet(); + const root = document.getElementById('command-modal') as any; + expect(root.__wired).toBe(true); + expect(() => bindCommandSheet()).not.toThrow(); + }); + + it('initializes ResizeObserver for seg element', async () => { + const origRO = window.ResizeObserver; + const observeFn = vi.fn(); + class ROClass { + observe = observeFn; + disconnect = vi.fn(); + } + window.ResizeObserver = ROClass as unknown as typeof ResizeObserver; + + const { bindCommandSheet } = await import('./commandSheet'); + bindCommandSheet(); + + expect(observeFn).toHaveBeenCalled(); + + window.ResizeObserver = origRO; + }); +}); + +describe('openSheet default parameter and edge cases', () => { + beforeEach(() => { + vi.resetModules(); + vi.resetAllMocks(); + vi.useFakeTimers(); + mountCommandModal(); + window.requestAnimationFrame = vi.fn((callback: FrameRequestCallback) => { + callback(0); + return 1; + }); + window.ResizeObserver = ResizeObserverMock as unknown as typeof ResizeObserver; + window.MutationObserver = MutationObserverMock as unknown as typeof MutationObserver; + }); + + it('opens sheet with default clone parameter', async () => { + const { openModal } = await import('../ui/modals'); + const { openSheet } = await import('./commandSheet'); + openSheet(); + expect(openModal).toHaveBeenCalledWith('command-modal'); + expect(document.querySelector('[data-sheet="clone"]')?.classList.contains('active')).toBe(true); + }); + + it('opens sheet with explicit clone parameter', async () => { + const { openModal } = await import('../ui/modals'); + const { openSheet } = await import('./commandSheet'); + openSheet('clone'); + expect(openModal).toHaveBeenCalledWith('command-modal'); + expect(document.querySelector('[data-sheet="clone"]')?.classList.contains('active')).toBe(true); + expect(document.getElementById('sheet-add')?.classList.contains('hidden')).toBe(true); + }); + + it('closes sheet via closeSheet', async () => { + const { closeModal } = await import('../ui/modals'); + const { closeSheet } = await import('./commandSheet'); + closeSheet(); + expect(closeModal).toHaveBeenCalledWith('command-modal'); + }); + + it('handles browse clone rejection gracefully', async () => { + const { TAURI } = await import('../lib/tauri'); + vi.mocked(TAURI.invoke).mockRejectedValueOnce(new Error('browse failed')); + const { bindCommandSheet } = await import('./commandSheet'); + bindCommandSheet(); + (document.getElementById('browse-clone') as HTMLButtonElement).click(); + await Promise.resolve(); + }); + + it('handles browse add rejection gracefully', async () => { + const { TAURI } = await import('../lib/tauri'); + vi.mocked(TAURI.invoke).mockRejectedValueOnce(new Error('browse add failed')); + const { bindCommandSheet } = await import('./commandSheet'); + bindCommandSheet(); + (document.getElementById('browse-add') as HTMLButtonElement).click(); + await Promise.resolve(); + }); + + it('does nothing when no url or dest for clone', async () => { + const { bindCommandSheet } = await import('./commandSheet'); + bindCommandSheet(); + const { TAURI } = await import('../lib/tauri'); + vi.mocked(TAURI.invoke).mockClear(); + (document.getElementById('do-clone') as HTMLButtonElement).disabled = false; + (document.getElementById('do-clone') as HTMLButtonElement).click(); + expect(vi.mocked(TAURI.invoke)).not.toHaveBeenCalledWith('clone_repo', expect.anything()); + }); + + it('does nothing when no path for add', async () => { + const { bindCommandSheet } = await import('./commandSheet'); + bindCommandSheet(); + const { TAURI } = await import('../lib/tauri'); + vi.mocked(TAURI.invoke).mockClear(); + (document.getElementById('do-add') as HTMLButtonElement).disabled = false; + (document.getElementById('do-add') as HTMLButtonElement).click(); + expect(vi.mocked(TAURI.invoke)).not.toHaveBeenCalledWith('add_repo', expect.anything()); + }); + + it('handles missing do-add button', async () => { + document.getElementById('do-add')?.remove(); + const { bindCommandSheet } = await import('./commandSheet'); + bindCommandSheet(); + const { TAURI } = await import('../lib/tauri'); + vi.mocked(TAURI.invoke).mockClear(); + expect(() => { + (document.getElementById('add-path') as HTMLInputElement).value = '/tmp/repo'; + }).not.toThrow(); + }); + + it('handles missing browse buttons', async () => { + document.getElementById('browse-clone')?.remove(); + document.getElementById('browse-add')?.remove(); + const { bindCommandSheet } = await import('./commandSheet'); + expect(() => bindCommandSheet()).not.toThrow(); + }); +}); diff --git a/Frontend/src/scripts/features/confirmModal.test.ts b/Frontend/src/scripts/features/confirmModal.test.ts new file mode 100644 index 00000000..f266a1b6 --- /dev/null +++ b/Frontend/src/scripts/features/confirmModal.test.ts @@ -0,0 +1,255 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../ui/modals', () => ({ + closeModal: vi.fn(), + hydrate: vi.fn(), + openModal: vi.fn(), +})); + +function mountConfirmModal() { + document.body.innerHTML = ` + + `; +} + +beforeEach(() => { + vi.resetModules(); + mountConfirmModal(); +}); + +afterEach(() => { + document.body.innerHTML = ''; + vi.restoreAllMocks(); +}); + +describe('wireConfirmModal', () => { + it('wires the modal and does not re-wire', async () => { + const { wireConfirmModal } = await import('./confirmModal'); + wireConfirmModal(); + // Second call should be idempotent + wireConfirmModal(); + // If it re-wired, the __wired flag would have caused issues + const modal = document.getElementById('confirm-modal') as any; + expect(modal.__wired).toBe(true); + }); + + it('does nothing when modal is missing', async () => { + document.body.innerHTML = ''; + const { wireConfirmModal } = await import('./confirmModal'); + // Should not throw + expect(() => wireConfirmModal()).not.toThrow(); + }); + + it('uses default labels when confirmLabel and cancelLabel are empty', async () => { + const { confirmWithModal } = await import('./confirmModal'); + const { openModal } = await import('../ui/modals'); + + confirmWithModal({ title: 'T', hint: 'H', message: 'M', confirmLabel: ' ', cancelLabel: undefined as any, danger: true }); + await vi.waitFor(() => expect(openModal).toHaveBeenCalled()); + + const modal = document.getElementById('confirm-modal') as HTMLElement; + expect(modal.querySelector('#confirm-modal-confirm-btn')?.textContent).toBe('Confirm'); + expect(modal.querySelector('#confirm-modal-cancel-btn')?.textContent).toBe('Cancel'); + }); + + it('does not throw when modal:closed fires with no pending confirm', async () => { + const { wireConfirmModal } = await import('./confirmModal'); + wireConfirmModal(); + const modal = document.getElementById('confirm-modal') as HTMLElement; + expect(() => modal.dispatchEvent(new Event('modal:closed'))).not.toThrow(); + }); +}); + +describe('setContent', () => { + it('sets default values when options are minimal', async () => { + const { wireConfirmModal } = await import('./confirmModal'); + wireConfirmModal(); + const modal = document.getElementById('confirm-modal') as any; + modal.setContent({ message: 'Test message' }); + + expect(document.getElementById('confirm-modal-title')?.textContent).toBe('Confirm action'); + expect(document.getElementById('confirm-modal-hint')?.textContent).toBe('This cannot be undone.'); + expect(document.getElementById('confirm-modal-message')?.textContent).toBe('Test message'); + expect(document.getElementById('confirm-modal-cancel-btn')?.textContent).toBe('Cancel'); + expect(document.getElementById('confirm-modal-confirm-btn')?.textContent).toBe('Confirm'); + }); + + it('applies custom options including danger style', async () => { + const { wireConfirmModal } = await import('./confirmModal'); + wireConfirmModal(); + const modal = document.getElementById('confirm-modal') as any; + modal.setContent({ + title: 'Delete file?', + message: 'Are you sure?', + hint: 'File will be permanently deleted.', + confirmLabel: 'Delete', + cancelLabel: 'Keep', + danger: true, + }); + + expect(document.getElementById('confirm-modal-title')?.textContent).toBe('Delete file?'); + expect(document.getElementById('confirm-modal-hint')?.textContent).toBe('File will be permanently deleted.'); + expect(document.getElementById('confirm-modal-confirm-btn')?.textContent).toBe('Delete'); + expect(document.getElementById('confirm-modal-cancel-btn')?.textContent).toBe('Keep'); + const confirmBtn = document.getElementById('confirm-modal-confirm-btn') as HTMLButtonElement; + expect(confirmBtn.classList.contains('danger')).toBe(true); + expect(confirmBtn.classList.contains('primary')).toBe(false); + }); + + it('sets primary class when not danger', async () => { + const { wireConfirmModal } = await import('./confirmModal'); + wireConfirmModal(); + const modal = document.getElementById('confirm-modal') as any; + modal.setContent({ message: 'Test', danger: false }); + const confirmBtn = document.getElementById('confirm-modal-confirm-btn') as HTMLButtonElement; + expect(confirmBtn.classList.contains('primary')).toBe(true); + expect(confirmBtn.classList.contains('danger')).toBe(false); + }); + + it('trims whitespace from title and hint, defaults when empty', async () => { + const { wireConfirmModal } = await import('./confirmModal'); + wireConfirmModal(); + const modal = document.getElementById('confirm-modal') as any; + modal.setContent({ message: 'Test', title: ' ', hint: ' ' }); + expect(document.getElementById('confirm-modal-title')?.textContent).toBe('Confirm action'); + expect(document.getElementById('confirm-modal-hint')?.textContent).toBe('This cannot be undone.'); + }); + + it('focuses the cancel button', async () => { + const { wireConfirmModal } = await import('./confirmModal'); + wireConfirmModal(); + const modal = document.getElementById('confirm-modal') as any; + const cancelBtn = document.getElementById('confirm-modal-cancel-btn') as HTMLButtonElement; + const focusSpy = vi.spyOn(cancelBtn, 'focus'); + modal.setContent({ message: 'Test' }); + await new Promise((r) => setTimeout(r, 0)); + expect(focusSpy).toHaveBeenCalled(); + }); + + it('handles missing title element gracefully', async () => { + const { wireConfirmModal } = await import('./confirmModal'); + wireConfirmModal(); + document.getElementById('confirm-modal-title')?.remove(); + const modal = document.getElementById('confirm-modal') as any; + expect(() => modal.setContent({ message: 'Test', title: 'Custom' })).not.toThrow(); + }); + + it('handles missing hint element gracefully', async () => { + const { wireConfirmModal } = await import('./confirmModal'); + wireConfirmModal(); + document.getElementById('confirm-modal-hint')?.remove(); + const modal = document.getElementById('confirm-modal') as any; + expect(() => modal.setContent({ message: 'Test', hint: 'Hint' })).not.toThrow(); + }); + + it('handles missing message element gracefully', async () => { + const { wireConfirmModal } = await import('./confirmModal'); + wireConfirmModal(); + document.getElementById('confirm-modal-message')?.remove(); + const modal = document.getElementById('confirm-modal') as any; + expect(() => modal.setContent({ message: 'Msg' })).not.toThrow(); + }); + + it('handles missing cancel button gracefully', async () => { + const { wireConfirmModal } = await import('./confirmModal'); + wireConfirmModal(); + document.getElementById('confirm-modal-cancel-btn')?.remove(); + const modal = document.getElementById('confirm-modal') as any; + expect(() => modal.setContent({ message: 'Test' })).not.toThrow(); + }); +}); + +describe('confirm button handler', () => { + it('resolves true and closes modal on confirm click', async () => { + const modals = await import('../ui/modals'); + const { wireConfirmModal } = await import('./confirmModal'); + wireConfirmModal(); + const confirmBtn = document.getElementById('confirm-modal-confirm-btn') as HTMLButtonElement; + + // Simulate pending resolve + let resolved = false; + const modal = document.getElementById('confirm-modal') as any; + modal.setContent({ message: 'Test' }); + (window as any).__pendingResolve = (ok: boolean) => { + resolved = ok; + }; + + // Store resolve in pendingResolve via confirmWithModal + const { confirmWithModal } = await import('./confirmModal'); + const promise = confirmWithModal({ message: 'Test' }); + + confirmBtn.click(); + await Promise.resolve(); + + expect(vi.mocked(modals.closeModal)).toHaveBeenCalledWith('confirm-modal'); + const result = await promise; + expect(result).toBe(true); + }); +}); + +describe('modal:closed handler', () => { + it('resolves false when modal closed event fires', async () => { + const { wireConfirmModal, confirmWithModal } = await import('./confirmModal'); + wireConfirmModal(); + const modal = document.getElementById('confirm-modal') as any; + modal.setContent({ message: 'Test' }); + + const promise = confirmWithModal({ message: 'Test' }); + modal.dispatchEvent(new Event('modal:closed')); + + const result = await promise; + expect(result).toBe(false); + }); +}); + +describe('confirmWithModal', () => { + it('hydrates, wires, opens modal and returns a promise', async () => { + const modals = await import('../ui/modals'); + const { confirmWithModal } = await import('./confirmModal'); + + const promise = confirmWithModal({ message: 'Confirm?' }); + expect(vi.mocked(modals.hydrate)).toHaveBeenCalledWith('confirm-modal'); + expect(vi.mocked(modals.openModal)).toHaveBeenCalledWith('confirm-modal'); + expect(promise).toBeInstanceOf(Promise); + }); + + it('closes previous pending promise with false when called again', async () => { + const { confirmWithModal } = await import('./confirmModal'); + + const first = confirmWithModal({ message: 'First' }); + confirmWithModal({ message: 'Second' }); + + const firstResult = await first; + expect(firstResult).toBe(false); + }); +}); + +describe('cancel button click handler', () => { + it('wires cancel click to close modal', async () => { + const { wireConfirmModal } = await import('./confirmModal'); + wireConfirmModal(); + + // cancel button has no explicit handler -- it triggers modal:closed via backdrop or data-close + // The modal:closed event is what resolves false + const modal = document.getElementById('confirm-modal') as any; + modal.setContent({ message: 'Test' }); + + const { confirmWithModal } = await import('./confirmModal'); + const promise = confirmWithModal({ message: 'Test' }); + + // Simulate what happens when modal is closed + modal.dispatchEvent(new Event('modal:closed')); + + const result = await promise; + expect(result).toBe(false); + }); +}); diff --git a/Frontend/src/scripts/features/conflicts.test.ts b/Frontend/src/scripts/features/conflicts.test.ts new file mode 100644 index 00000000..a2daa1ea --- /dev/null +++ b/Frontend/src/scripts/features/conflicts.test.ts @@ -0,0 +1,783 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const mockInvoke = vi.hoisted(() => vi.fn()); + +vi.mock('../lib/tauri', () => ({ TAURI: { invoke: mockInvoke } })); +vi.mock('../lib/confirm', () => ({ confirmBool: vi.fn() })); +vi.mock('../lib/notify', () => ({ notify: vi.fn() })); +vi.mock('../ui/modals', () => ({ + hydrate: vi.fn(), + openModal: vi.fn(), + closeModal: vi.fn(), +})); +vi.mock('./repo', () => ({ hydrateStatus: vi.fn() })); +vi.mock('../state/state', () => ({ + isConflictStatus: vi.fn((status: unknown) => { + const s = String(status || '').trim().toUpperCase(); + return s === 'U' || s.includes('U') || s === 'AA' || s === 'DD'; + }), +})); + +function mountMergeModal() { + const div = document.createElement('div'); + div.id = 'merge-modal'; + div.innerHTML = ` + +

+    

+    

+    
+    
+  `;
+  document.body.appendChild(div);
+}
+
+function mountConflictsSummaryModal() {
+  if (!document.getElementById('merge-modal')) {
+    mountMergeModal();
+  }
+  const summary = document.createElement('div');
+  summary.id = 'conflicts-summary-modal';
+  summary.innerHTML = `
+    
+    
+    
+ + + `; + document.body.appendChild(summary); +} + +function flushPromises(): Promise { + return new Promise((resolve) => window.setTimeout(resolve, 0)); +} + +beforeEach(() => { + vi.resetModules(); + document.body.innerHTML = ''; + mockInvoke.mockReset(); + mockInvoke.mockResolvedValue(null); +}); + +afterEach(() => { + document.body.innerHTML = ''; + vi.clearAllMocks(); + vi.restoreAllMocks(); +}); + +describe('ensureMergeModal', () => { + it('wires merge-apply button and saves result', async () => { + mountMergeModal(); + const modals = await import('../ui/modals'); + + const { openMergeModal } = await import('./conflicts'); + await openMergeModal( + { path: 'conflict.txt', status: 'U' }, + { path: 'conflict.txt', ours: 'version a', theirs: 'version b' }, + ); + + const textarea = document.getElementById('merge-result') as HTMLTextAreaElement; + textarea.value = 'resolved content'; + + const applyBtn = document.getElementById('merge-apply') as HTMLButtonElement; + applyBtn.click(); + await flushPromises(); + + expect(mockInvoke).toHaveBeenCalledWith('vcs_save_merge_result', { + path: 'conflict.txt', + content: 'resolved content', + }); + expect(modals.closeModal).toHaveBeenCalledWith('merge-modal'); + }); + + it('wires only once', async () => { + mountMergeModal(); + + const { openMergeModal } = await import('./conflicts'); + await openMergeModal( + { path: 'a.txt', status: 'U' }, + { path: 'a.txt', ours: 'a', theirs: 'b' }, + ); + await openMergeModal( + { path: 'b.txt', status: 'U' }, + { path: 'b.txt', ours: 'c', theirs: 'd' }, + ); + + const applyBtn = document.getElementById('merge-apply') as HTMLButtonElement; + applyBtn.click(); + await flushPromises(); + + expect(mockInvoke).toHaveBeenCalledWith('vcs_save_merge_result', { + path: 'b.txt', + content: 'c', + }); + }); + + it('merge-apply shows error on failure', async () => { + mountMergeModal(); + mockInvoke.mockRejectedValue(new Error('save failed')); + const { notify } = await import('../lib/notify'); + + const { openMergeModal } = await import('./conflicts'); + await openMergeModal( + { path: 'f.txt', status: 'U' }, + { path: 'f.txt', ours: 'a', theirs: 'b' }, + ); + + const applyBtn = document.getElementById('merge-apply') as HTMLButtonElement; + applyBtn.click(); + await flushPromises(); + + expect(notify).toHaveBeenCalledWith('Failed to save merge result'); + }); +}); + +describe('openMergeModal', () => { + it('sets conflict details and opens modal', async () => { + mountMergeModal(); + const modals = await import('../ui/modals'); + + const { openMergeModal } = await import('./conflicts'); + await openMergeModal( + { path: 'src/main.ts', status: 'U' }, + { path: 'src/main.ts', ours: 'our version', theirs: 'their version', base: 'base version' }, + ); + + const pathEl = document.getElementById('merge-path') as HTMLElement; + const baseEl = document.getElementById('merge-base') as HTMLElement; + const oursEl = document.getElementById('merge-ours') as HTMLElement; + const theirsEl = document.getElementById('merge-theirs') as HTMLElement; + const textarea = document.getElementById('merge-result') as HTMLTextAreaElement; + + expect(pathEl.textContent).toBe('src/main.ts'); + expect(baseEl.textContent).toBe('base version'); + expect(oursEl.textContent).toBe('our version'); + expect(theirsEl.textContent).toBe('their version'); + expect(textarea.value).toBe('our version'); + expect(modals.openModal).toHaveBeenCalledWith('merge-modal'); + }); + + it('handles missing modal gracefully', async () => { + const { openMergeModal } = await import('./conflicts'); + await expect( + openMergeModal( + { path: 'f.txt', status: 'U' }, + { path: 'f.txt', ours: 'a', theirs: 'b' }, + ), + ).resolves.toBeUndefined(); + }); + + it('falls back to theirs then base when ours empty', async () => { + mountMergeModal(); + + const { openMergeModal } = await import('./conflicts'); + await openMergeModal( + { path: 'f.txt', status: 'U' }, + { path: 'f.txt', ours: '', theirs: 'their text', base: 'base text' }, + ); + + const textarea = document.getElementById('merge-result') as HTMLTextAreaElement; + expect(textarea.value).toBe('their text'); + }); + + it('handles null details gracefully', async () => { + mountMergeModal(); + + const { openMergeModal } = await import('./conflicts'); + await openMergeModal( + { path: 'f.txt', status: 'U' }, + { path: 'f.txt', ours: null, theirs: null, base: null } as any, + ); + + const baseEl = document.getElementById('merge-base') as HTMLElement; + const oursEl = document.getElementById('merge-ours') as HTMLElement; + const theirsEl = document.getElementById('merge-theirs') as HTMLElement; + + expect(baseEl.textContent).toBe(''); + expect(oursEl.textContent).toBe(''); + expect(theirsEl.textContent).toBe(''); + }); +}); + +describe('openConflictsSummary', () => { + it('renders conflict list with correct count', async () => { + mountConflictsSummaryModal(); + mockInvoke.mockResolvedValue({ in_progress: false }); + + const { openConflictsSummary } = await import('./conflicts'); + await openConflictsSummary([ + { path: 'file1.txt', status: 'U' }, + { path: 'file2.txt', status: 'M' }, + { path: 'file3.txt', status: 'DD' }, + ]); + + const countEl = document.getElementById('conflicts-summary-count') as HTMLElement; + const listEl = document.getElementById('conflicts-summary-list') as HTMLElement; + + expect(countEl.textContent).toBe('2 conflicted files'); + const rows = listEl.querySelectorAll('.row'); + expect(rows.length).toBe(2); + }); + + it('shows singular "file" for single conflict', async () => { + mountConflictsSummaryModal(); + mockInvoke.mockResolvedValue({ in_progress: false }); + + const { openConflictsSummary } = await import('./conflicts'); + await openConflictsSummary([{ path: 'only.txt', status: 'U' }]); + + const countEl = document.getElementById('conflicts-summary-count') as HTMLElement; + expect(countEl.textContent).toBe('1 conflicted file'); + }); + + it('shows abort/continue buttons when merge in progress', async () => { + mountConflictsSummaryModal(); + mockInvoke.mockResolvedValue({ in_progress: true }); + + const { openConflictsSummary } = await import('./conflicts'); + await openConflictsSummary([{ path: 'f.txt', status: 'U' }]); + + const abortBtn = document.getElementById('conflicts-abort') as HTMLButtonElement; + const contBtn = document.getElementById('conflicts-continue') as HTMLButtonElement; + expect(abortBtn.hidden).toBe(false); + expect(contBtn.hidden).toBe(false); + }); + + it('hides abort/continue buttons when not in merge', async () => { + mountConflictsSummaryModal(); + mockInvoke.mockResolvedValue({ in_progress: false }); + + const { openConflictsSummary } = await import('./conflicts'); + await openConflictsSummary([{ path: 'f.txt', status: 'U' }]); + + const abortBtn = document.getElementById('conflicts-abort') as HTMLButtonElement; + const contBtn = document.getElementById('conflicts-continue') as HTMLButtonElement; + expect(abortBtn.hidden).toBe(true); + expect(contBtn.hidden).toBe(true); + }); + + it('resolve button opens merge modal', async () => { + mountConflictsSummaryModal(); + mockInvoke + .mockResolvedValueOnce({ in_progress: false }) + .mockResolvedValueOnce({ path: 'f.txt', ours: 'our', theirs: 'their' }); + + const { openConflictsSummary } = await import('./conflicts'); + await openConflictsSummary([{ path: 'f.txt', status: 'U' }]); + + const listEl = document.getElementById('conflicts-summary-list') as HTMLElement; + const resolveBtn = listEl.querySelector('button') as HTMLButtonElement; + expect(resolveBtn.textContent).toBe('Resolve…'); + + resolveBtn.click(); + await flushPromises(); + + expect(mockInvoke).toHaveBeenCalledWith('vcs_conflict_details', { path: 'f.txt' }); + }); + + it('handles resolve button error', async () => { + mountConflictsSummaryModal(); + mockInvoke + .mockResolvedValueOnce({ in_progress: false }) + .mockResolvedValueOnce(null) + .mockRejectedValueOnce(new Error('not found')); + + const { notify } = await import('../lib/notify'); + + const { openConflictsSummary } = await import('./conflicts'); + await openConflictsSummary([{ path: 'f.txt', status: 'U' }]); + + const listEl = document.getElementById('conflicts-summary-list') as HTMLElement; + const resolveBtn = listEl.querySelector('button') as HTMLButtonElement; + resolveBtn.click(); + await flushPromises(); + + expect(notify).toHaveBeenCalledWith('Failed to open conflict: Error: not found'); + }); + + it('handles missing listEl gracefully', async () => { + const modal = document.createElement('div'); + modal.id = 'conflicts-summary-modal'; + document.body.appendChild(modal); + mockInvoke.mockResolvedValue({ in_progress: false }); + + const { openConflictsSummary } = await import('./conflicts'); + await expect( + openConflictsSummary([{ path: 'f.txt', status: 'U' }]), + ).resolves.toBeUndefined(); + }); + + it('handles non-array files gracefully', async () => { + mountConflictsSummaryModal(); + mockInvoke.mockResolvedValue({ in_progress: false }); + + const { openConflictsSummary } = await import('./conflicts'); + await expect( + openConflictsSummary(null as any), + ).resolves.toBeUndefined(); + }); + + it('shows correct subtitle for in-progress merge', async () => { + mountConflictsSummaryModal(); + mockInvoke.mockResolvedValue({ in_progress: true }); + + const { openConflictsSummary } = await import('./conflicts'); + await openConflictsSummary([{ path: 'f.txt', status: 'U' }]); + + const subEl = document.getElementById('conflicts-summary-subtitle') as HTMLElement; + expect(subEl.textContent).toBe('Resolve conflicts before committing the merge'); + }); + + it('shows correct subtitle for non-merge conflict', async () => { + mountConflictsSummaryModal(); + mockInvoke.mockResolvedValue({ in_progress: false }); + + const { openConflictsSummary } = await import('./conflicts'); + await openConflictsSummary([{ path: 'f.txt', status: 'U' }]); + + const subEl = document.getElementById('conflicts-summary-subtitle') as HTMLElement; + expect(subEl.textContent).toBe('Resolve conflicts in your working tree'); + }); + + it('handles invoke failure for vcs_merge_context', async () => { + mountConflictsSummaryModal(); + mockInvoke.mockRejectedValue(new Error('offline')); + + const { openConflictsSummary } = await import('./conflicts'); + await openConflictsSummary([{ path: 'f.txt', status: 'U' }]); + + const subEl = document.getElementById('conflicts-summary-subtitle') as HTMLElement; + expect(subEl.textContent).toBe('Resolve conflicts in your working tree'); + }); + + it('open tool button is disabled when no external tool', async () => { + mountConflictsSummaryModal(); + mockInvoke + .mockResolvedValueOnce({ in_progress: false }) + .mockResolvedValueOnce(null); + + const { openConflictsSummary } = await import('./conflicts'); + await openConflictsSummary([{ path: 'f.txt', status: 'U' }]); + + const listEl = document.getElementById('conflicts-summary-list') as HTMLElement; + const buttons = listEl.querySelectorAll('button'); + const toolBtn = buttons[1] as HTMLButtonElement; + expect(toolBtn.textContent).toBe('Open tool'); + expect(toolBtn.disabled).toBe(true); + }); + + it('open tool button shows notification when configured tool fails', async () => { + mountConflictsSummaryModal(); + mockInvoke + .mockResolvedValueOnce({ in_progress: false }) + .mockResolvedValueOnce({ + diff: { external_merge: { enabled: true, path: '/usr/bin/meld', args: '' } }, + }) + .mockRejectedValueOnce(new Error('tool error')); + + const { notify } = await import('../lib/notify'); + + const { openConflictsSummary } = await import('./conflicts'); + await openConflictsSummary([{ path: 'f.txt', status: 'U' }]); + + const listEl = document.getElementById('conflicts-summary-list') as HTMLElement; + const buttons = listEl.querySelectorAll('button'); + const toolBtn = buttons[1] as HTMLButtonElement; + expect(toolBtn.disabled).toBe(false); + toolBtn.click(); + await flushPromises(); + + expect(notify).toHaveBeenCalledWith('Failed to open merge tool'); + }); +}); + +describe('summary abort and continue', () => { + it('abort button calls vcs_merge_abort on confirm', async () => { + mountConflictsSummaryModal(); + mockInvoke.mockResolvedValue({ in_progress: true }); + const { confirmBool } = await import('../lib/confirm'); + vi.mocked(confirmBool).mockResolvedValue(true); + const { notify } = await import('../lib/notify'); + + const { openConflictsSummary } = await import('./conflicts'); + await openConflictsSummary([{ path: 'f1.txt', status: 'U' }]); + + const abortBtn = document.getElementById('conflicts-abort') as HTMLButtonElement; + abortBtn.click(); + await flushPromises(); + + expect(mockInvoke).toHaveBeenCalledWith('vcs_merge_abort'); + expect(notify).toHaveBeenCalledWith('Merge aborted'); + }); + + it('abort does nothing when user declines confirm', async () => { + mountConflictsSummaryModal(); + mockInvoke.mockResolvedValue({ in_progress: true }); + const { confirmBool } = await import('../lib/confirm'); + vi.mocked(confirmBool).mockResolvedValue(false); + + const { openConflictsSummary } = await import('./conflicts'); + await openConflictsSummary([{ path: 'f1.txt', status: 'U' }]); + + const abortBtn = document.getElementById('conflicts-abort') as HTMLButtonElement; + abortBtn.click(); + await flushPromises(); + + expect(mockInvoke).not.toHaveBeenCalledWith('vcs_merge_abort'); + }); + + it('abort shows error on failure', async () => { + mountConflictsSummaryModal(); + mockInvoke + .mockResolvedValueOnce({ in_progress: true }) + .mockResolvedValueOnce(null) + .mockRejectedValueOnce(new Error('cannot abort')); + + const { confirmBool } = await import('../lib/confirm'); + vi.mocked(confirmBool).mockResolvedValue(true); + const { notify } = await import('../lib/notify'); + + const { openConflictsSummary } = await import('./conflicts'); + await openConflictsSummary([{ path: 'f1.txt', status: 'U' }]); + + const abortBtn = document.getElementById('conflicts-abort') as HTMLButtonElement; + abortBtn.click(); + await flushPromises(); + + expect(notify).toHaveBeenCalledWith('Abort failed: Error: cannot abort'); + }); + + it('continue button calls vcs_merge_continue', async () => { + mountConflictsSummaryModal(); + mockInvoke.mockResolvedValue({ in_progress: true }); + const { notify } = await import('../lib/notify'); + + const { openConflictsSummary } = await import('./conflicts'); + await openConflictsSummary([{ path: 'f1.txt', status: 'U' }]); + + const contBtn = document.getElementById('conflicts-continue') as HTMLButtonElement; + contBtn.click(); + await flushPromises(); + + expect(mockInvoke).toHaveBeenCalledWith('vcs_merge_continue'); + expect(notify).toHaveBeenCalledWith('Merge committed'); + }); + + it('continue shows error on failure', async () => { + mountConflictsSummaryModal(); + mockInvoke + .mockResolvedValueOnce({ in_progress: true }) + .mockResolvedValueOnce(null) + .mockRejectedValueOnce(new Error('merge failed')); + + const { notify } = await import('../lib/notify'); + + const { openConflictsSummary } = await import('./conflicts'); + await openConflictsSummary([{ path: 'f1.txt', status: 'U' }]); + + const contBtn = document.getElementById('conflicts-continue') as HTMLButtonElement; + contBtn.click(); + await flushPromises(); + + expect(notify).toHaveBeenCalledWith('Commit merge failed: Error: merge failed'); + }); +}); + +describe('autoOpenFirstConflict', () => { + it('does nothing for empty files array', async () => { + const { autoOpenFirstConflict } = await import('./conflicts'); + await expect(autoOpenFirstConflict([])).resolves.toBeUndefined(); + }); + + it('does nothing for non-array input', async () => { + const { autoOpenFirstConflict } = await import('./conflicts'); + await expect(autoOpenFirstConflict(null as any)).resolves.toBeUndefined(); + }); + + it('does nothing for files without conflicts', async () => { + const { autoOpenFirstConflict } = await import('./conflicts'); + await expect( + autoOpenFirstConflict([{ path: 'clean.txt', status: 'M' }]), + ).resolves.toBeUndefined(); + }); + + it('opens summary for new conflicts', async () => { + mountConflictsSummaryModal(); + mockInvoke.mockResolvedValue({ in_progress: false }); + + const { autoOpenFirstConflict } = await import('./conflicts'); + await autoOpenFirstConflict([ + { path: 'f1.txt', status: 'U' }, + { path: 'f2.txt', status: 'DD' }, + ]); + + const countEl = document.getElementById('conflicts-summary-count') as HTMLElement; + expect(countEl.textContent).toBe('2 conflicted files'); + }); + + it('does nothing when merge modal is already open', async () => { + mountMergeModal(); + const mergeModal = document.getElementById('merge-modal') as HTMLElement; + mergeModal.setAttribute('aria-hidden', 'false'); + + mountConflictsSummaryModal(); + mockInvoke.mockResolvedValue({ in_progress: false }); + + const { autoOpenFirstConflict } = await import('./conflicts'); + await autoOpenFirstConflict([ + { path: 'f1.txt', status: 'U' }, + ]); + + const listEl = document.getElementById('conflicts-summary-list') as HTMLElement; + expect(listEl.children.length).toBe(0); + }); +}); + +describe('hasExternalMergeTool', () => { + it('returns true when merge tool is configured and enabled', async () => { + mockInvoke.mockResolvedValue({ + diff: { + external_merge: { enabled: true, path: '/usr/bin/meld', args: '' }, + }, + }); + + const { hasExternalMergeTool } = await import('./conflicts'); + const result = await hasExternalMergeTool(); + expect(result).toBe(true); + }); + + it('returns false when merge tool is disabled', async () => { + mockInvoke.mockResolvedValue({ + diff: { + external_merge: { enabled: false, path: '/usr/bin/meld', args: '' }, + }, + }); + + const { hasExternalMergeTool } = await import('./conflicts'); + const result = await hasExternalMergeTool(); + expect(result).toBe(false); + }); + + it('returns false when merge tool path is empty', async () => { + mockInvoke.mockResolvedValue({ + diff: { + external_merge: { enabled: true, path: '', args: '' }, + }, + }); + + const { hasExternalMergeTool } = await import('./conflicts'); + const result = await hasExternalMergeTool(); + expect(result).toBe(false); + }); + + it('returns false on invoke error', async () => { + mockInvoke.mockRejectedValue(new Error('config error')); + + const { hasExternalMergeTool } = await import('./conflicts'); + const result = await hasExternalMergeTool(); + expect(result).toBe(false); + }); + + it('returns false when config has no diff section', async () => { + mockInvoke.mockResolvedValue({}); + + const { hasExternalMergeTool } = await import('./conflicts'); + const result = await hasExternalMergeTool(); + expect(result).toBe(false); + }); +}); + +describe('launchExternalMergeTool', () => { + it('launches tool when configured', async () => { + mockInvoke + .mockResolvedValueOnce({ + diff: { external_merge: { enabled: true, path: '/usr/bin/meld', args: '' } }, + }) + .mockResolvedValueOnce(null); + + const { notify } = await import('../lib/notify'); + + const { launchExternalMergeTool } = await import('./conflicts'); + await launchExternalMergeTool('/path/to/file.txt'); + + expect(mockInvoke).toHaveBeenCalledWith('vcs_launch_merge_tool', { path: '/path/to/file.txt' }); + expect(notify).toHaveBeenCalledWith('Opened custom merge tool'); + }); + + it('skips launch when no tool configured', async () => { + mockInvoke.mockResolvedValue({}); + + const { notify } = await import('../lib/notify'); + + const { launchExternalMergeTool } = await import('./conflicts'); + await launchExternalMergeTool('/path/to/file.txt'); + + expect(mockInvoke).not.toHaveBeenCalledWith('vcs_launch_merge_tool'); + expect(notify).toHaveBeenCalledWith('No custom merge tool configured'); + }); + + it('shows error on launch failure', async () => { + mockInvoke + .mockResolvedValueOnce({ + diff: { external_merge: { enabled: true, path: '/usr/bin/meld', args: '' } }, + }) + .mockRejectedValueOnce(new Error('tool not found')); + + const { notify } = await import('../lib/notify'); + + const { launchExternalMergeTool } = await import('./conflicts'); + await launchExternalMergeTool('/path/to/file.txt'); + + expect(notify).toHaveBeenCalledWith('Failed to open merge tool'); + }); +}); + +describe('autoOpenFirstConflict', () => { + it('catches error when openConflictsSummary fails', async () => { + const modals = await import('../ui/modals'); + const hydrateMock = vi.mocked(modals.hydrate); + hydrateMock.mockImplementationOnce(() => { throw new Error('hydrate failure'); }); + + const { autoOpenFirstConflict } = await import('./conflicts'); + + await expect(autoOpenFirstConflict([{ path: 'err.txt', status: 'U' }] as any)).resolves.toBeUndefined(); + }); +}); + +describe('openMergeModal fallback values', () => { + beforeEach(() => { + vi.resetModules(); + document.body.innerHTML = ''; + mockInvoke.mockReset(); + mockInvoke.mockResolvedValue(null); + }); + + it('falls back to base when ours and theirs are empty', async () => { + mountMergeModal(); + const { openMergeModal } = await import('./conflicts'); + await openMergeModal( + { path: 'f.txt', status: 'U' }, + { path: 'f.txt', ours: '', theirs: '', base: 'base content' }, + ); + const textarea = document.getElementById('merge-result') as HTMLTextAreaElement; + expect(textarea.value).toBe('base content'); + }); + + it('uses empty string when all values are null', async () => { + mountMergeModal(); + const { openMergeModal } = await import('./conflicts'); + await openMergeModal( + { path: 'f.txt', status: 'U' }, + { path: 'f.txt', ours: null, theirs: null, base: null } as any, + ); + const textarea = document.getElementById('merge-result') as HTMLTextAreaElement; + expect(textarea.value).toBe(''); + }); + + it('handles undefined file path gracefully', async () => { + mountMergeModal(); + const { openMergeModal } = await import('./conflicts'); + await openMergeModal( + { path: '', status: 'U' }, + { path: '', ours: 'a', theirs: 'b' }, + ); + const pathLabel = document.getElementById('merge-path') as HTMLElement; + expect(pathLabel.textContent).toBe('(unknown file)'); + }); +}); + +describe('setPreText coverage', () => { + beforeEach(() => { + vi.resetModules(); + document.body.innerHTML = ''; + mockInvoke.mockReset(); + mockInvoke.mockResolvedValue(null); + }); + + it('handles missing pre element gracefully', async () => { + mountMergeModal(); + document.getElementById('merge-base')?.remove(); + const { openMergeModal } = await import('./conflicts'); + await expect( + openMergeModal( + { path: 'f.txt', status: 'U' }, + { path: 'f.txt', ours: 'a', theirs: 'b', base: 'base' }, + ), + ).resolves.toBeUndefined(); + }); +}); + +describe('openConflictsSummary list rendering edge cases', () => { + beforeEach(() => { + vi.resetModules(); + document.body.innerHTML = ''; + mockInvoke.mockReset(); + mockInvoke.mockResolvedValue(null); + }); + + it('handles missing subtitle element', async () => { + mountConflictsSummaryModal(); + document.getElementById('conflicts-summary-subtitle')?.remove(); + mockInvoke.mockResolvedValue({ in_progress: false }); + + const { openConflictsSummary } = await import('./conflicts'); + await expect(openConflictsSummary([{ path: 'f.txt', status: 'U' }])).resolves.toBeUndefined(); + }); + + it('handles missing count element', async () => { + mountConflictsSummaryModal(); + document.getElementById('conflicts-summary-count')?.remove(); + mockInvoke.mockResolvedValue({ in_progress: false }); + + const { openConflictsSummary } = await import('./conflicts'); + await expect(openConflictsSummary([{ path: 'f.txt', status: 'U' }])).resolves.toBeUndefined(); + }); +}); + +describe('hasExternalMergeTool configuration edge cases', () => { + beforeEach(() => { + vi.resetModules(); + document.body.innerHTML = ''; + mockInvoke.mockReset(); + mockInvoke.mockResolvedValue(null); + }); + + it('returns false when diff config is missing altogether', async () => { + mockInvoke.mockResolvedValue({ general: {} }); + const { hasExternalMergeTool } = await import('./conflicts'); + const result = await hasExternalMergeTool(); + expect(result).toBe(false); + }); + + it('returns false when diff.external_merge is missing', async () => { + mockInvoke.mockResolvedValue({ diff: {} }); + const { hasExternalMergeTool } = await import('./conflicts'); + const result = await hasExternalMergeTool(); + expect(result).toBe(false); + }); +}); + +describe('launchExternalMergeTool with tool enabled', () => { + beforeEach(() => { + vi.resetModules(); + document.body.innerHTML = ''; + mockInvoke.mockReset(); + mockInvoke.mockResolvedValue(null); + }); + + it('reports "not configured" when hasExternalMergeTool returns false on second call too', async () => { + // First call to hasExternalMergeTool (via openConflictsSummary) returns false + // But if launchExternalMergeTool is called directly when not configured + mockInvoke.mockResolvedValue({ diff: { external_merge: { enabled: false, path: '' } } }); + + const { notify } = await import('../lib/notify'); + + const { launchExternalMergeTool } = await import('./conflicts'); + await launchExternalMergeTool('/path/to/file.txt'); + + expect(notify).toHaveBeenCalledWith('No custom merge tool configured'); + }); +}); diff --git a/Frontend/src/scripts/features/deleteBranchConfirm.test.ts b/Frontend/src/scripts/features/deleteBranchConfirm.test.ts new file mode 100644 index 00000000..b87e6c91 --- /dev/null +++ b/Frontend/src/scripts/features/deleteBranchConfirm.test.ts @@ -0,0 +1,226 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../ui/modals', () => ({ + closeModal: vi.fn(), + hydrate: vi.fn(), + openModal: vi.fn(), +})); + +function mountDeleteBranchModal() { + document.body.innerHTML = ` + + `; +} + +beforeEach(() => { + vi.resetModules(); + mountDeleteBranchModal(); +}); + +afterEach(() => { + document.body.innerHTML = ''; + vi.restoreAllMocks(); +}); + +describe('wireDeleteBranchConfirm', () => { + it('sets __wired and skips on second call', async () => { + const { wireDeleteBranchConfirm } = await import('./deleteBranchConfirm'); + const modal = document.getElementById('delete-branch-modal') as any; + expect(modal.__wired).toBeUndefined(); + wireDeleteBranchConfirm(); + expect(modal.__wired).toBe(true); + wireDeleteBranchConfirm(); + expect(modal.__wired).toBe(true); + }); + + it('does nothing when modal is missing', async () => { + document.body.innerHTML = ''; + const { wireDeleteBranchConfirm } = await import('./deleteBranchConfirm'); + expect(() => wireDeleteBranchConfirm()).not.toThrow(); + }); +}); + +describe('setContent', () => { + it('sets content for force delete', async () => { + const { wireDeleteBranchConfirm } = await import('./deleteBranchConfirm'); + wireDeleteBranchConfirm(); + const modal = document.getElementById('delete-branch-modal') as any; + + modal.setContent({ + name: 'stale-branch', + force: true, + hint: 'This is permanent.', + message: 'Really force delete this branch?', + }); + + const titleEl = document.getElementById('delete-branch-title') as HTMLElement; + const hintEl = document.getElementById('delete-branch-hint') as HTMLElement; + const messageEl = document.getElementById('delete-branch-message') as HTMLElement; + const dangerEl = document.getElementById('delete-branch-danger') as HTMLElement; + const nameEl = document.getElementById('delete-branch-name') as HTMLElement; + const confirmBtn = document.getElementById('delete-branch-confirm-btn') as HTMLButtonElement; + + expect(titleEl.textContent).toBe('Force Delete Branch'); + expect(hintEl.textContent).toBe('This is permanent.'); + expect(messageEl.textContent).toBe('Really force delete this branch?'); + expect(nameEl.textContent).toBe('stale-branch'); + expect(dangerEl.hidden).toBe(false); + expect(confirmBtn.textContent).toBe('Force delete'); + expect(confirmBtn.classList.contains('danger')).toBe(true); + expect(confirmBtn.classList.contains('primary')).toBe(false); + }); + + it('sets content for normal delete', async () => { + const { wireDeleteBranchConfirm } = await import('./deleteBranchConfirm'); + wireDeleteBranchConfirm(); + const modal = document.getElementById('delete-branch-modal') as any; + + modal.setContent({ + name: 'feature-branch', + force: false, + }); + + const titleEl = document.getElementById('delete-branch-title') as HTMLElement; + const hintEl = document.getElementById('delete-branch-hint') as HTMLElement; + const dangerEl = document.getElementById('delete-branch-danger') as HTMLElement; + const confirmBtn = document.getElementById('delete-branch-confirm-btn') as HTMLButtonElement; + + expect(titleEl.textContent).toBe('Delete Branch'); + expect(hintEl.textContent).toBe('This cannot be undone.'); + expect(dangerEl.hidden).toBe(true); + expect(confirmBtn.textContent).toBe('Delete'); + expect(confirmBtn.classList.contains('primary')).toBe(true); + expect(confirmBtn.classList.contains('danger')).toBe(false); + }); + + it('handles empty name with fallback dash', async () => { + const { wireDeleteBranchConfirm } = await import('./deleteBranchConfirm'); + wireDeleteBranchConfirm(); + const modal = document.getElementById('delete-branch-modal') as any; + + modal.setContent({ name: '' }); + const nameEl = document.getElementById('delete-branch-name') as HTMLElement; + expect(nameEl.textContent).toBe('—'); + }); + + it('uses default message when not provided for force', async () => { + const { wireDeleteBranchConfirm } = await import('./deleteBranchConfirm'); + wireDeleteBranchConfirm(); + const modal = document.getElementById('delete-branch-modal') as any; + + modal.setContent({ name: 'branch', force: true }); + const messageEl = document.getElementById('delete-branch-message') as HTMLElement; + expect(messageEl.textContent).toBe('Force deleting permanently removes the local branch.'); + }); + + it('uses default message when not provided for normal', async () => { + const { wireDeleteBranchConfirm } = await import('./deleteBranchConfirm'); + wireDeleteBranchConfirm(); + const modal = document.getElementById('delete-branch-modal') as any; + + modal.setContent({ name: 'branch', force: false }); + const messageEl = document.getElementById('delete-branch-message') as HTMLElement; + expect(messageEl.textContent).toBe('Deleting permanently removes the local branch.'); + }); + + it('focuses cancel button after setContent', async () => { + const { wireDeleteBranchConfirm } = await import('./deleteBranchConfirm'); + wireDeleteBranchConfirm(); + const modal = document.getElementById('delete-branch-modal') as any; + const cancelBtn = document.getElementById('delete-branch-cancel-btn') as HTMLButtonElement; + const focusSpy = vi.spyOn(cancelBtn, 'focus'); + + modal.setContent({ name: 'test' }); + + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(focusSpy).toHaveBeenCalled(); + }); +}); + +describe('confirmDeleteBranch', () => { + it('resolves true via confirm button click', async () => { + const { closeModal } = await import('../ui/modals'); + const { confirmDeleteBranch } = await import('./deleteBranchConfirm'); + const hydrate = (await import('../ui/modals')).hydrate; + const openModal = (await import('../ui/modals')).openModal; + + const promise = confirmDeleteBranch({ name: 'my-branch' }); + + expect(hydrate).toHaveBeenCalledWith('delete-branch-modal'); + expect(openModal).toHaveBeenCalledWith('delete-branch-modal'); + + const confirmBtn = document.getElementById('delete-branch-confirm-btn') as HTMLButtonElement; + confirmBtn.click(); + + const result = await promise; + expect(result).toBe(true); + expect(closeModal).toHaveBeenCalledWith('delete-branch-modal'); + }); + + it('resolves false via backdrop click', async () => { + const { confirmDeleteBranch } = await import('./deleteBranchConfirm'); + + const promise = confirmDeleteBranch({ name: 'my-branch' }); + + const backdrop = document.querySelector('.backdrop') as HTMLElement; + backdrop.click(); + + const result = await promise; + expect(result).toBe(false); + }); + + it('resolves false via escape key', async () => { + const { confirmDeleteBranch } = await import('./deleteBranchConfirm'); + + const promise = confirmDeleteBranch({ name: 'my-branch' }); + + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); + + const result = await promise; + expect(result).toBe(false); + }); + + it('ignores escape key when modal is hidden', async () => { + const { confirmDeleteBranch } = await import('./deleteBranchConfirm'); + + const modal = document.getElementById('delete-branch-modal') as HTMLElement; + modal.setAttribute('aria-hidden', 'true'); + + const promise = confirmDeleteBranch({ name: 'my-branch' }); + + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); + + // Should not resolve, so wrap in timeout to check it stays pending + const raced = await Promise.race([ + promise.then((v) => ({ resolved: true, value: v })), + new Promise<{ resolved: false }>((r) => setTimeout(() => r({ resolved: false }), 50)), + ]); + expect(raced.resolved).toBe(false); + }); + + it('resolves false via cancel button (data-close)', async () => { + const { confirmDeleteBranch } = await import('./deleteBranchConfirm'); + + const promise = confirmDeleteBranch({ name: 'my-branch' }); + + const cancelBtn = document.getElementById('delete-branch-cancel-btn') as HTMLButtonElement; + cancelBtn.click(); + + const result = await promise; + expect(result).toBe(false); + }); +}); diff --git a/Frontend/src/scripts/features/diff.test.ts b/Frontend/src/scripts/features/diff.test.ts index d59c1d62..9af434ef 100644 --- a/Frontend/src/scripts/features/diff.test.ts +++ b/Frontend/src/scripts/features/diff.test.ts @@ -6,6 +6,10 @@ vi.mock('../plugins', () => ({ runHook: vi.fn(async () => ({ cancelled: false })), })); +vi.mock('../lib/notify', () => ({ + notify: vi.fn(), +})); + vi.mock('../lib/tauri', () => { const invoke = vi.fn(async (cmd: string) => { if (cmd === 'commit_patch_and_files') return 'oid-123'; @@ -28,6 +32,10 @@ vi.mock('./repo', () => ({ yieldToPaint: vi.fn(async () => {}), })); +vi.mock('./repo/commit', () => ({ + getCommitSummaryHint: vi.fn(() => ''), +})); + let state: typeof import('../state/state').state; /** Mounts the minimal DOM needed for commit binding. */ @@ -100,4 +108,818 @@ describe('bindCommit', () => { await Promise.resolve(); expect(vi.mocked(repo.yieldToPaint)).toHaveBeenCalled(); }); + + it('shows notification when summary is empty', async () => { + state.selectedFiles = new Set(); + state.selectedHunksByFile = {}; + state.files = []; + + const { bindCommit } = await import('./diff'); + const { notify } = await import('../lib/notify'); + const commitBtn = document.getElementById('commit-btn') as HTMLButtonElement; + + bindCommit(); + commitBtn.click(); + + await new Promise(resolve => setTimeout(resolve, 0)); + expect(notify).toHaveBeenCalledWith('Summary is required'); + }); + + it('uses commit summary hint when summary is empty', async () => { + state.files = []; + state.selectedFiles = new Set(); + state.selectedHunksByFile = {}; + + const { bindCommit } = await import('./diff'); + const commitBtn = document.getElementById('commit-btn') as HTMLButtonElement; + + bindCommit(); + commitBtn.click(); + + await new Promise(resolve => setTimeout(resolve, 0)); + }); + + it('handles hook cancellation', async () => { + state.selectedFiles = new Set(['file.txt']); + state.selectedHunksByFile = {}; + state.files = [{ path: 'file.txt', status: 'M' }] as any; + + const { runHook } = await import('../plugins'); + vi.mocked(runHook).mockResolvedValue({ + name: 'preCommit', + data: undefined, + cancelled: true, + reason: 'Cancelled by plugin', + cancel: vi.fn(), + }); + + const { bindCommit } = await import('./diff'); + const { notify } = await import('../lib/notify'); + const commitSummary = document.getElementById('commit-summary') as HTMLInputElement; + const commitBtn = document.getElementById('commit-btn') as HTMLButtonElement; + + commitSummary.value = 'Test commit'; + bindCommit(); + commitBtn.click(); + + await new Promise(resolve => setTimeout(resolve, 0)); + expect(notify).toHaveBeenCalledWith('Cancelled by plugin'); + }); + + it('builds combined patch for partial files with hunk selections', async () => { + state.selectedFiles = new Set(['file1.txt']); + state.selectedHunksByFile = { 'file1.txt': [0] }; + state.selectedLinesByFile = {}; + state.files = [{ path: 'file1.txt', status: 'M' }] as any; + + const { __invoke: invoke } = await import('../lib/tauri') as any; + invoke.mockImplementation(async (cmd: string) => { + if (cmd === 'vcs_diff_file') { + return [ + 'diff --git a/file1.txt b/file1.txt', + 'index abc..def 100644', + '--- a/file1.txt', + '+++ b/file1.txt', + '@@ -1 +1 @@', + '-old', + '+new', + ]; + } + if (cmd === 'commit_patch_and_files') return 'oid-123'; + return []; + }); + + const { bindCommit } = await import('./diff'); + const commitSummary = document.getElementById('commit-summary') as HTMLInputElement; + const commitBtn = document.getElementById('commit-btn') as HTMLButtonElement; + + commitSummary.value = 'Partial commit'; + bindCommit(); + commitBtn.click(); + + // The async handler runs and should eventually call commit_patch_and_files. + // Instead of waiting for the full chain, verify the vcs_diff_file calls + // which happen before commit_patch_and_files. + await vi.waitFor(() => { + const diffCalls = invoke.mock.calls.filter( + (args: unknown[]) => args[0] === 'vcs_diff_file' + ); + expect(diffCalls.length).toBeGreaterThan(0); + }, { timeout: 3000, interval: 20 }); + }); + + it('handles partial load failure gracefully', async () => { + state.selectedFiles = new Set(['broken.txt']); + state.selectedHunksByFile = { 'broken.txt': [0] }; + state.files = [{ path: 'broken.txt', status: 'M' }] as any; + + const { __invoke: invoke } = await import('../lib/tauri') as any; + invoke.mockImplementation(async (cmd: string) => { + if (cmd === 'vcs_diff_file') throw new Error('load error'); + return []; + }); + + const { bindCommit } = await import('./diff'); + const { notify } = await import('../lib/notify'); + const commitSummary = document.getElementById('commit-summary') as HTMLInputElement; + const commitBtn = document.getElementById('commit-btn') as HTMLButtonElement; + + commitSummary.value = 'Broken commit'; + bindCommit(); + commitBtn.click(); + // After yieldToPaint, the handler attempts vcs_diff_file which throws. + // notify should be called with the error message. + await vi.waitFor(() => { + expect(notify).toHaveBeenCalledWith('Failed to read one or more selected diffs'); + }, { timeout: 3000, interval: 20 }); + }); + + it('shows notification when no files or hunks selected', async () => { + state.selectedFiles = new Set(); + state.selectedHunksByFile = {}; + state.files = []; + + const { bindCommit } = await import('./diff'); + const { notify } = await import('../lib/notify'); + const commitSummary = document.getElementById('commit-summary') as HTMLInputElement; + const commitBtn = document.getElementById('commit-btn') as HTMLButtonElement; + + commitSummary.value = 'Empty commit'; + bindCommit(); + commitBtn.click(); + + await vi.waitFor(() => { + expect(notify).toHaveBeenCalledWith('Select files or hunks to commit'); + }, { timeout: 3000, interval: 20 }); + }); + + it('truncates summary to 72 chars when maxLength attribute is set', async () => { + state.selectedFiles = new Set(['file.txt']); + state.selectedHunksByFile = {}; + state.files = [{ path: 'file.txt', status: 'M' }] as any; + + const { __invoke: invoke } = await import('../lib/tauri') as any; + invoke.mockImplementation(async (cmd: string) => { + if (cmd === 'vcs_diff_file') return []; + if (cmd === 'commit_patch_and_files') return 'oid-789'; + return []; + }); + + const { bindCommit } = await import('./diff'); + const commitSummary = document.getElementById('commit-summary') as HTMLInputElement; + const commitBtn = document.getElementById('commit-btn') as HTMLButtonElement; + + commitSummary.maxLength = 72; + const longSummary = 'a'.repeat(100); + commitSummary.value = longSummary; + bindCommit(); + commitBtn.click(); + + await vi.waitFor(() => { + const calls = invoke.mock.calls.filter( + (args: unknown[]) => args[0] === 'commit_patch_and_files' + ); + expect(calls.length).toBeGreaterThan(0); + expect(calls[0][1].summary.length).toBeLessThanOrEqual(72); + }, { timeout: 3000, interval: 20 }); + }); + + it('uses hook-mutated summary and description on successful commit', async () => { + state.selectedFiles = new Set(['file.txt']); + state.selectedHunksByFile = {}; + state.files = [{ path: 'file.txt', status: 'M' }] as any; + + const { __invoke: invoke } = await import('../lib/tauri') as any; + const { runHook } = await import('../plugins'); + const repo = await import('./repo'); + const { notify } = await import('../lib/notify'); + vi.mocked(runHook).mockImplementation(async (name, data: any) => { + if (name === 'preCommit') { + data.summary = ' Updated summary '; + data.description = 'Updated description'; + } + return { cancelled: false } as any; + }); + invoke.mockClear(); + invoke.mockImplementation(async (cmd: string) => { + if (cmd === 'commit_patch_and_files') return 'oid-999'; + return []; + }); + + const { bindCommit } = await import('./diff'); + const commitSummary = document.getElementById('commit-summary') as HTMLInputElement; + const commitDesc = document.getElementById('commit-desc') as HTMLTextAreaElement; + const commitBtn = document.getElementById('commit-btn') as HTMLButtonElement; + + commitSummary.value = 'Initial summary'; + commitDesc.value = 'Initial description'; + bindCommit(); + commitBtn.click(); + + await vi.waitFor(() => { + const commitCall = invoke.mock.calls.find((args: unknown[]) => args[0] === 'commit_patch_and_files'); + expect(commitCall?.[1]).toMatchObject({ + summary: 'Updated summary', + description: 'Updated description', + }); + }); + expect(vi.mocked(repo.hydrateStatus)).toHaveBeenCalled(); + expect(vi.mocked(repo.hydrateCommits)).toHaveBeenCalled(); + expect(notify).toHaveBeenCalledWith('Committed to main: Updated summary'); + expect(state.selectedFiles.size).toBe(0); + expect(state.currentFile).toBe(''); + }); + + it('falls back to commit summary hint when the input is blank', async () => { + state.selectedFiles = new Set(['file.txt']); + state.selectedHunksByFile = {}; + state.files = [{ path: 'file.txt', status: 'M' }] as any; + + const { bindCommit } = await import('./diff'); + const { getCommitSummaryHint } = await import('./repo/commit'); + const { __invoke: invoke } = await import('../lib/tauri') as any; + const { runHook } = await import('../plugins'); + vi.mocked(runHook).mockResolvedValue({ cancelled: false } as any); + vi.mocked(getCommitSummaryHint).mockReturnValue('Hint summary'); + invoke.mockClear(); + + bindCommit(); + (document.getElementById('commit-btn') as HTMLButtonElement).click(); + + await vi.waitFor(() => { + const commitCall = invoke.mock.calls.find((args: unknown[]) => args[0] === 'commit_patch_and_files'); + expect(commitCall?.[1]).toMatchObject({ summary: 'Hint summary' }); + }); + }); + + it('builds a partial patch from explicit line selections', async () => { + state.selectedFiles = new Set(['file1.txt']); + state.selectedHunksByFile = {} as any; + state.selectedLinesByFile = { 'file1.txt': { 0: [1, 2] } } as any; + state.files = [{ path: 'file1.txt', status: 'M' }] as any; + + const { __invoke: invoke } = await import('../lib/tauri') as any; + invoke.mockClear(); + invoke.mockImplementation(async (cmd: string) => { + if (cmd === 'vcs_diff_file') { + return [ + 'diff --git a/file1.txt b/file1.txt', + 'index abc..def 100644', + '--- a/file1.txt', + '+++ b/file1.txt', + '@@ -1,2 +1,2 @@', + '-old', + '+new', + ]; + } + if (cmd === 'commit_patch_and_files') return 'oid-456'; + return []; + }); + + const { bindCommit } = await import('./diff'); + const commitSummary = document.getElementById('commit-summary') as HTMLInputElement; + commitSummary.value = 'Line selection commit'; + bindCommit(); + (document.getElementById('commit-btn') as HTMLButtonElement).click(); + + await vi.waitFor(() => { + const commitCall = invoke.mock.calls.find((args: unknown[]) => args[0] === 'commit_patch_and_files'); + expect(commitCall?.[1].patch).toContain('@@ -1,1 +1,1 @@'); + expect(commitCall?.[1].patch).toContain('-old'); + expect(commitCall?.[1].patch).toContain('+new'); + }); + }); +}); + +describe('buildPatchForSelectedHunks', () => { + it('returns empty string for empty inputs', async () => { + const { buildPatchForSelectedHunks } = await import('./diff'); + expect(buildPatchForSelectedHunks('test.txt', [], [])).toBe(''); + expect(buildPatchForSelectedHunks('test.txt', ['line'], [])).toBe(''); + }); + + it('builds a patch with selected hunks', async () => { + const { buildPatchForSelectedHunks } = await import('./diff'); + const lines = [ + 'diff --git a/file.txt b/file.txt', + 'index abc..def 100644', + '--- a/file.txt', + '+++ b/file.txt', + '@@ -1 +1 @@', + '-old', + '+new', + '@@ -5 +5 @@', + '-old2', + '+new2', + ]; + const result = buildPatchForSelectedHunks('file.txt', lines, [0]); + expect(result).toContain('diff --git a/file.txt b/file.txt'); + expect(result).toContain('@@ -1 +1 @@'); + expect(result).not.toContain('@@ -5 +5 @@'); + }); + + it('includes index/metadata from prelude', async () => { + const { buildPatchForSelectedHunks } = await import('./diff'); + const lines = [ + 'diff --git a/file.txt b/file.txt', + 'index abc..def 100644', + '--- a/file.txt', + '+++ b/file.txt', + '@@ -1 +1 @@', + '-old', + '+new', + ]; + const result = buildPatchForSelectedHunks('file.txt', lines, [0]); + expect(result).toContain('index abc..def 100644'); + }); + + it('handles add (new file) patches', async () => { + const { buildPatchForSelectedHunks } = await import('./diff'); + const lines = [ + 'diff --git a/new.txt b/new.txt', + 'new file mode 100644', + '--- /dev/null', + '+++ b/new.txt', + '@@ -0,0 +1 @@', + '+content', + ]; + const result = buildPatchForSelectedHunks('new.txt', lines, [0]); + expect(result).toContain('--- /dev/null'); + expect(result).toContain('+++ b/new.txt'); + }); + + it('handles delete patches', async () => { + const { buildPatchForSelectedHunks } = await import('./diff'); + const lines = [ + 'diff --git a/del.txt b/del.txt', + 'deleted file mode 100644', + '--- a/del.txt', + '+++ /dev/null', + '@@ -1 +0,0 @@', + '-removed', + ]; + const result = buildPatchForSelectedHunks('del.txt', lines, [0]); + expect(result).toContain('+++ /dev/null'); + expect(result).toContain('--- a/del.txt'); + }); + + it('returns empty when no hunk starts found', async () => { + const { buildPatchForSelectedHunks } = await import('./diff'); + const result = buildPatchForSelectedHunks('file.txt', ['no hunks'], [0]); + expect(result).toBe(''); + }); + + it('skips out-of-range hunk indices', async () => { + const { buildPatchForSelectedHunks } = await import('./diff'); + const lines = [ + '--- a/file.txt', '+++ b/file.txt', + '@@ -1 +1 @@', '-old', '+new', + ]; + const result = buildPatchForSelectedHunks('file.txt', lines, [99]); + expect(result).not.toContain('@@'); + }); + + it('normalizes backslashes in path', async () => { + const { buildPatchForSelectedHunks } = await import('./diff'); + const lines = [ + 'diff --git a/src\\file.txt b/src\\file.txt', + '--- a/src\\file.txt', + '+++ b/src\\file.txt', + '@@ -1 +1 @@', + '-old', + '+new', + ]; + const result = buildPatchForSelectedHunks('src\\file.txt', lines, [0]); + expect(result).toContain('b/src/file.txt'); + }); + + it('selects the middle hunk from multiple hunks', async () => { + const { buildPatchForSelectedHunks } = await import('./diff'); + const lines = [ + 'diff --git a/f.txt b/f.txt', + '--- a/f.txt', + '+++ b/b.txt', + '@@ -1 +1 @@', + '-a1', + '+b1', + '@@ -5 +5 @@', + '-a2', + '+b2', + '@@ -10 +10 @@', + '-a3', + '+b3', + ]; + const result = buildPatchForSelectedHunks('f.txt', lines, [1]); + expect(result).not.toContain('a1'); + expect(result).toContain('a2'); + expect(result).not.toContain('a3'); + }); +}); + +describe('buildPatchForSelectedHunks privates', () => { + it('builds patches via the buildPatchForSelectedHunks exported function (adds, deletes, metadata)', async () => { + const { buildPatchForSelectedHunks } = await import('./diff'); + + // Add scenario + const addLines = [ + 'diff --git a/new.txt b/new.txt', + 'new file mode 100644', + '--- /dev/null', + '+++ b/new.txt', + '@@ -0,0 +1 @@', + '+content', + ]; + const addResult = buildPatchForSelectedHunks('new.txt', addLines, [0]); + expect(addResult).toContain('--- /dev/null'); + expect(addResult).toContain('+++ b/new.txt'); + + // Delete scenario + const delLines = [ + 'diff --git a/del.txt b/del.txt', + 'deleted file mode 100644', + '--- a/del.txt', + '+++ /dev/null', + '@@ -1 +0,0 @@', + '-removed', + ]; + const delResult = buildPatchForSelectedHunks('del.txt', delLines, [0]); + expect(delResult).toContain('+++ /dev/null'); + + // Multiple hunks, select second only + const multiLines = [ + 'diff --git a/f.txt b/f.txt', + '--- a/f.txt', + '+++ b/f.txt', + '@@ -1 +1 @@', + '-old1', + '+new1', + '@@ -5 +5 @@', + '-old2', + '+new2', + ]; + const multiResult = buildPatchForSelectedHunks('f.txt', multiLines, [1]); + expect(multiResult).not.toContain('old1'); + expect(multiResult).toContain('old2'); + }); + + it('returns empty for empty lines array', async () => { + const { buildPatchForSelectedHunks } = await import('./diff'); + expect(buildPatchForSelectedHunks('f.txt', [], [0])).toBe(''); + }); + + it('handles header extras without new file mode', async () => { + const { buildPatchForSelectedHunks } = await import('./diff'); + const lines = [ + 'diff --git a/x.txt b/x.txt', + 'old mode 100644', + 'new mode 100755', + '--- a/x.txt', + '+++ b/x.txt', + '@@ -1 +1 @@', + '-old', + '+new', + ]; + const result = buildPatchForSelectedHunks('x.txt', lines, [0]); + expect(result).toContain('old mode 100644'); + expect(result).toContain('new mode 100755'); + }); +}); + +describe('buildPatchForSelectedHunks additional edge cases', () => { + it('skips negative hunk indices', async () => { + const { buildPatchForSelectedHunks } = await import('./diff'); + const lines = [ + '--- a/file.txt', + '+++ b/file.txt', + '@@ -1 +1 @@', + '-old', + '+new', + ]; + const result = buildPatchForSelectedHunks('file.txt', lines, [-1]); + expect(result).not.toContain('@@'); + expect(result).toContain('diff --git'); + }); + + it('handles diff lines with no prelude (no ---/+++ before @@)', async () => { + const { buildPatchForSelectedHunks } = await import('./diff'); + const lines = [ + '@@ -1 +1 @@', + '-old', + '+new', + ]; + const result = buildPatchForSelectedHunks('file.txt', lines, [0]); + expect(result).toContain('diff --git a/file.txt b/file.txt'); + expect(result).toContain('--- a/file.txt'); + expect(result).toContain('+++ b/file.txt'); + expect(result).toContain('-old'); + expect(result).toContain('+new'); + }); +}); + +describe('bindCommit error handling and buildPatchForSelected edge cases', () => { + afterEach(() => { + document.body.innerHTML = ''; + vi.restoreAllMocks(); + }); + + it('handles commit_patch_and_files rejection gracefully', async () => { + const state = (await import('../state/state')).state; + state.files = [{ path: 'file.txt', status: 'M' }] as any; + state.selectedFiles = new Set(['file.txt']); + state.selectedHunksByFile = {}; + state.selectedLinesByFile = {}; + state.selectedHunks = []; + state.diffSelectedFiles = new Set(); + (state as any).branch = 'main'; + + const { __invoke: invoke } = await import('../lib/tauri') as any; + invoke.mockClear(); + invoke.mockImplementation(async (cmd: string) => { + if (cmd === 'vcs_diff_file') return []; + if (cmd === 'commit_patch_and_files') throw new Error('commit error'); + return []; + }); + + const { bindCommit } = await import('./diff'); + const { notify } = await import('../lib/notify'); + const commitSummary = document.getElementById('commit-summary') as HTMLInputElement; + const commitBtn = document.getElementById('commit-btn') as HTMLButtonElement; + + commitSummary.value = 'Test commit'; + bindCommit(); + commitBtn.click(); + + await vi.waitFor(() => { + expect(notify).toHaveBeenCalledWith('Commit failed'); + }, { timeout: 3000, interval: 20 }); + }); + + it('builds patch with non-contiguous line selections (group/flush)', async () => { + const state = (await import('../state/state')).state; + state.files = [{ path: 'file1.txt', status: 'M' }] as any; + state.selectedFiles = new Set(['file1.txt']); + state.selectedHunksByFile = {} as any; + state.selectedLinesByFile = { 'file1.txt': { 0: [1, 3] } }; + state.selectedHunks = []; + state.diffSelectedFiles = new Set(); + (state as any).branch = 'main'; + + const { __invoke: invoke } = await import('../lib/tauri') as any; + invoke.mockClear(); + invoke.mockImplementation(async (cmd: string) => { + if (cmd === 'vcs_diff_file') { + return [ + 'diff --git a/file1.txt b/file1.txt', + '--- a/file1.txt', + '+++ b/file1.txt', + '@@ -1,3 +1,3 @@', + '+new1', + ' context', + '+new3', + ]; + } + if (cmd === 'commit_patch_and_files') return 'oid-999'; + return []; + }); + + const { bindCommit } = await import('./diff'); + const commitSummary = document.getElementById('commit-summary') as HTMLInputElement; + const commitBtn = document.getElementById('commit-btn') as HTMLButtonElement; + + commitSummary.value = 'Non-contiguous'; + bindCommit(); + commitBtn.click(); + + await vi.waitFor(() => { + const commitCall = invoke.mock.calls.find( + (args: unknown[]) => args[0] === 'commit_patch_and_files' + ); + expect(commitCall).toBeTruthy(); + const patch = commitCall?.[1].patch as string; + expect(patch).toContain('@@ -1,0 +1,1 @@'); + expect(patch).toContain('+new1'); + expect(patch).toContain('@@ -2,0 +3,1 @@'); + expect(patch).toContain('+new3'); + }, { timeout: 3000, interval: 20 }); + }); + + it('returns combined patch empty when partial files list empty after filtering', async () => { + state.selectedFiles = new Set(); + state.selectedHunksByFile = {}; + state.files = []; + + const { __invoke: invoke } = await import('../lib/tauri') as any; + invoke.mockClear(); + invoke.mockImplementation(async (cmd: string) => { + if (cmd === 'commit_patch_and_files') return 'oid-999'; + return []; + }); + + const { bindCommit } = await import('./diff'); + const { notify } = await import('../lib/notify'); + const { getCommitSummaryHint } = await import('./repo/commit'); + vi.mocked(getCommitSummaryHint).mockReturnValue('Summary from hint'); + const commitBtn = document.getElementById('commit-btn') as HTMLButtonElement; + + bindCommit(); + commitBtn.click(); + + await vi.waitFor(() => { + expect(notify).toHaveBeenCalledWith('Select files or hunks to commit'); + }, { timeout: 3000, interval: 20 }); + }); +}); + +// --------------------------------------------------------------------------- +// buildPatchForSelectedHunks - additional cover branches +// --------------------------------------------------------------------------- + +describe('buildPatchForSelectedHunks additional branch cover', () => { + it('handles isAdd = true and includes headerExtras', async () => { + const { buildPatchForSelectedHunks } = await import('./diff'); + const lines = [ + 'diff --git a/new.txt b/new.txt', + 'new file mode 100644', + '--- /dev/null', + '+++ b/new.txt', + '@@ -0,0 +1 @@', + '+content', + ]; + const result = buildPatchForSelectedHunks('new.txt', lines, [0]); + expect(result).toContain('--- /dev/null'); + expect(result).toContain('+++ b/new.txt'); + expect(result).toContain('new file mode 100644'); + }); + + it('handles isDel = true and includes headerExtras', async () => { + const { buildPatchForSelectedHunks } = await import('./diff'); + const lines = [ + 'diff --git a/del.txt b/del.txt', + 'deleted file mode 100644', + '--- a/del.txt', + '+++ /dev/null', + '@@ -1 +0,0 @@', + '-removed', + ]; + const result = buildPatchForSelectedHunks('del.txt', lines, [0]); + expect(result).toContain('+++ /dev/null'); + expect(result).toContain('deleted file mode 100644'); + }); + + it('handles empty headerExtras', async () => { + const { buildPatchForSelectedHunks } = await import('./diff'); + const lines = [ + 'diff --git a/f.txt b/f.txt', + '--- a/f.txt', + '+++ b/f.txt', + '@@ -1 +1 @@', + '-old', + '+new', + ]; + const result = buildPatchForSelectedHunks('f.txt', lines, [0]); + expect(result).toContain('diff --git a/f.txt b/f.txt'); + expect(result).toContain('--- a/f.txt'); + expect(result).toContain('+++ b/f.txt'); + }); + + it('handles out of bounds hunk index', async () => { + const { buildPatchForSelectedHunks } = await import('./diff'); + const lines = [ + '--- a/f.txt', + '+++ b/f.txt', + '@@ -1 +1 @@', + '-old', + '+new', + ]; + const result = buildPatchForSelectedHunks('f.txt', lines, [5]); + expect(result).not.toContain('-old'); + }); + + it('handles negative hunk index', async () => { + const { buildPatchForSelectedHunks } = await import('./diff'); + const lines = [ + '--- a/f.txt', + '+++ b/f.txt', + '@@ -1 +1 @@', + '-old', + '+new', + ]; + const result = buildPatchForSelectedHunks('f.txt', lines, [-1]); + expect(result).not.toContain('-old'); + }); +}); + +// --------------------------------------------------------------------------- +// bindCommit - description value +// --------------------------------------------------------------------------- + +describe('bindCommit - description handling', () => { + it('reads description from textarea', async () => { + state.selectedFiles = new Set(['file.txt']); + state.selectedHunksByFile = {}; + state.files = [{ path: 'file.txt', status: 'M' }] as any; + + const { __invoke: invoke } = await import('../lib/tauri') as any; + invoke.mockClear(); + invoke.mockImplementation(async (cmd: string) => { + if (cmd === 'commit_patch_and_files') return 'oid-999'; + return []; + }); + + const { bindCommit } = await import('./diff'); + const commitSummary = document.getElementById('commit-summary') as HTMLInputElement; + const commitDesc = document.getElementById('commit-desc') as HTMLTextAreaElement; + const commitBtn = document.getElementById('commit-btn') as HTMLButtonElement; + + commitSummary.value = 'Summary'; + commitDesc.value = 'Description body'; + bindCommit(); + commitBtn.click(); + + await vi.waitFor(() => { + const commitCall = invoke.mock.calls.find( + (args: unknown[]) => args[0] === 'commit_patch_and_files' + ); + expect(commitCall?.[1].description).toBe('Description body'); + }, { timeout: 3000, interval: 20 }); + }); + + it('clears inputs after successful commit', async () => { + state.selectedFiles = new Set(['file.txt']); + state.selectedHunksByFile = {}; + state.files = [{ path: 'file.txt', status: 'M' }] as any; + + const { __invoke: invoke } = await import('../lib/tauri') as any; + invoke.mockClear(); + invoke.mockImplementation(async (cmd: string) => { + if (cmd === 'commit_patch_and_files') return 'oid-999'; + return []; + }); + + const { bindCommit } = await import('./diff'); + const commitSummary = document.getElementById('commit-summary') as HTMLInputElement; + const commitDesc = document.getElementById('commit-desc') as HTMLTextAreaElement; + const commitBtn = document.getElementById('commit-btn') as HTMLButtonElement; + + commitSummary.value = 'Summary'; + commitDesc.value = 'Desc'; + bindCommit(); + commitBtn.click(); + + await vi.waitFor(() => { + expect(commitSummary.value).toBe(''); + expect(commitDesc.value).toBe(''); + }, { timeout: 3000, interval: 20 }); + }); +}); + +// --------------------------------------------------------------------------- +// buildPatchForSelectedHunks - add and delete combined +// --------------------------------------------------------------------------- + +describe('buildPatchForSelectedHunks - isAdd and isDel branches', () => { + it('handles isAdd = true and isDel = false', async () => { + const { buildPatchForSelectedHunks } = await import('./diff'); + const lines = [ + 'diff --git a/n.txt b/n.txt', + '--- /dev/null', + '+++ b/n.txt', + '@@ -0,0 +1 @@', + '+new', + ]; + const result = buildPatchForSelectedHunks('n.txt', lines, [0]); + expect(result).toContain('--- /dev/null'); + expect(result).toContain('+++ b/n.txt'); + }); + + it('handles isDel = true and isAdd = false', async () => { + const { buildPatchForSelectedHunks } = await import('./diff'); + const lines = [ + 'diff --git a/d.txt b/d.txt', + '--- a/d.txt', + '+++ /dev/null', + '@@ -1 +0,0 @@', + '-gone', + ]; + const result = buildPatchForSelectedHunks('d.txt', lines, [0]); + expect(result).toContain('+++ /dev/null'); + expect(result).toContain('--- a/d.txt'); + }); + + it('handles neither isAdd nor isDel (modify)', async () => { + const { buildPatchForSelectedHunks } = await import('./diff'); + const lines = [ + 'diff --git a/m.txt b/m.txt', + '--- a/m.txt', + '+++ b/m.txt', + '@@ -1 +1 @@', + '-old', + '+new', + ]; + const result = buildPatchForSelectedHunks('m.txt', lines, [0]); + expect(result).toContain('--- a/m.txt'); + expect(result).toContain('+++ b/m.txt'); + expect(result).toContain('-old'); + expect(result).toContain('+new'); + }); }); diff --git a/Frontend/src/scripts/features/newBranch.test.ts b/Frontend/src/scripts/features/newBranch.test.ts index 16b3afed..7776cd4b 100644 --- a/Frontend/src/scripts/features/newBranch.test.ts +++ b/Frontend/src/scripts/features/newBranch.test.ts @@ -135,3 +135,336 @@ describe('wireNewBranch', () => { expect(invoke).toHaveBeenCalledWith('vcs_create_branch', { name: 'feature/test', from: 'main', checkout: false }); }); }); + +describe('wireNewBranch - additional', () => { + it('reflects normalized names in the hint during validation', async () => { + function installTauriMockLocal() { + (window as any).__TAURI__ = { + core: { invoke: vi.fn(async () => null) }, + event: { listen: vi.fn() }, + }; + } + installTauriMockLocal(); + + const { wireNewBranch } = await import('./newBranch'); + wireNewBranch(); + await new Promise((r) => setTimeout(r, 0)); + + const name = document.getElementById('new-branch-name') as HTMLInputElement; + const hint = document.getElementById('new-branch-name-hint') as HTMLElement; + const create = document.getElementById('new-branch-create') as HTMLButtonElement; + + // Whitespace-heavy name triggers normalization hint + name.value = ' my branch '; + name.dispatchEvent(new Event('input')); + await new Promise((r) => setTimeout(r, 0)); + expect(hint.hidden).toBe(false); + expect(hint.textContent).toContain('Will be created as'); + + // Empty after trim - shows error + name.value = ' '; + name.dispatchEvent(new Event('input')); + await new Promise((r) => setTimeout(r, 0)); + expect(hint.hidden).toBe(false); + expect(hint.classList.contains('error')).toBe(true); + expect(create.disabled).toBe(true); + + // Valid name hides hint + name.value = 'valid-branch'; + name.dispatchEvent(new Event('input')); + await new Promise((r) => setTimeout(r, 0)); + expect(hint.hidden).toBe(true); + expect(create.disabled).toBe(false); + }); + + it('handles modal:opened event', async () => { + function installTauriMockLocal() { + (window as any).__TAURI__ = { + core: { invoke: vi.fn(async () => null) }, + event: { listen: vi.fn() }, + }; + } + installTauriMockLocal(); + + const { wireNewBranch } = await import('./newBranch'); + wireNewBranch(); + + const modal = document.getElementById('new-branch-modal') as HTMLElement; + modal.dispatchEvent(new Event('modal:opened')); + await new Promise((r) => setTimeout(r, 0)); + + const checkout = document.getElementById('new-branch-checkout') as HTMLInputElement; + expect(checkout.checked).toBe(true); + }); + + it('creates branch on Enter key in name input', async () => { + const invoke = vi.fn(async () => null); + (window as any).__TAURI__ = { + core: { invoke }, + event: { listen: vi.fn() }, + }; + + const { wireNewBranch } = await import('./newBranch'); + wireNewBranch(); + + const name = document.getElementById('new-branch-name') as HTMLInputElement; + const create = document.getElementById('new-branch-create') as HTMLButtonElement; + + name.value = 'my-branch'; + name.dispatchEvent(new Event('input')); + await new Promise((r) => setTimeout(r, 0)); + + create.disabled = false; + name.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' })); + await new Promise((r) => setTimeout(r, 0)); + + expect(invoke).toHaveBeenCalledWith('vcs_create_branch', expect.objectContaining({ name: 'my-branch' })); + }); +}); + +// --------------------------------------------------------------------------- +// validateBranchName - edge cases +// --------------------------------------------------------------------------- + +describe('validateBranchName', () => { + it('rejects names with control characters', async () => { + const { wireNewBranch } = await import('./newBranch'); + wireNewBranch(); + const nameInput = document.getElementById('new-branch-name') as HTMLInputElement; + const hint = document.getElementById('new-branch-name-hint') as HTMLElement; + nameInput.value = 'bad\x00branch'; + nameInput.dispatchEvent(new Event('input')); + await new Promise((r) => setTimeout(r, 0)); + expect(hint.textContent).toContain('cannot contain spaces or control characters'); + }); + + it('rejects names with tilde', async () => { + const { wireNewBranch } = await import('./newBranch'); + wireNewBranch(); + const nameInput = document.getElementById('new-branch-name') as HTMLInputElement; + const hint = document.getElementById('new-branch-name-hint') as HTMLElement; + nameInput.value = 'bad~branch'; + nameInput.dispatchEvent(new Event('input')); + await new Promise((r) => setTimeout(r, 0)); + expect(hint.textContent).toContain('invalid characters'); + }); + + it('rejects names starting with /', async () => { + const { wireNewBranch } = await import('./newBranch'); + wireNewBranch(); + const nameInput = document.getElementById('new-branch-name') as HTMLInputElement; + const hint = document.getElementById('new-branch-name-hint') as HTMLElement; + nameInput.value = '/branch'; + nameInput.dispatchEvent(new Event('input')); + await new Promise((r) => setTimeout(r, 0)); + expect(hint.textContent).toContain('start or end with /'); + }); + + it('rejects names ending with /', async () => { + const { wireNewBranch } = await import('./newBranch'); + wireNewBranch(); + const nameInput = document.getElementById('new-branch-name') as HTMLInputElement; + const hint = document.getElementById('new-branch-name-hint') as HTMLElement; + nameInput.value = 'branch/'; + nameInput.dispatchEvent(new Event('input')); + await new Promise((r) => setTimeout(r, 0)); + expect(hint.textContent).toContain('start or end with /'); + }); + + it('rejects names with ..', async () => { + const { wireNewBranch } = await import('./newBranch'); + wireNewBranch(); + const nameInput = document.getElementById('new-branch-name') as HTMLInputElement; + const hint = document.getElementById('new-branch-name-hint') as HTMLElement; + nameInput.value = 'bad..branch'; + nameInput.dispatchEvent(new Event('input')); + await new Promise((r) => setTimeout(r, 0)); + expect(hint.textContent).toContain('..'); + }); + + it('rejects names with @{', async () => { + const { wireNewBranch } = await import('./newBranch'); + wireNewBranch(); + const nameInput = document.getElementById('new-branch-name') as HTMLInputElement; + const hint = document.getElementById('new-branch-name-hint') as HTMLElement; + nameInput.value = 'bad@{branch'; + nameInput.dispatchEvent(new Event('input')); + await new Promise((r) => setTimeout(r, 0)); + expect(hint.textContent).toContain('@{'); + }); + + it('rejects names with //', async () => { + const { wireNewBranch } = await import('./newBranch'); + wireNewBranch(); + const nameInput = document.getElementById('new-branch-name') as HTMLInputElement; + const hint = document.getElementById('new-branch-name-hint') as HTMLElement; + nameInput.value = 'bad//branch'; + nameInput.dispatchEvent(new Event('input')); + await new Promise((r) => setTimeout(r, 0)); + expect(hint.textContent).toContain('//'); + }); + + it('rejects names ending with .', async () => { + const { wireNewBranch } = await import('./newBranch'); + wireNewBranch(); + const nameInput = document.getElementById('new-branch-name') as HTMLInputElement; + const hint = document.getElementById('new-branch-name-hint') as HTMLElement; + nameInput.value = 'branch.'; + nameInput.dispatchEvent(new Event('input')); + await new Promise((r) => setTimeout(r, 0)); + expect(hint.textContent).toContain('end with "."'); + }); + + it('rejects names ending with .lock', async () => { + const { wireNewBranch } = await import('./newBranch'); + wireNewBranch(); + const nameInput = document.getElementById('new-branch-name') as HTMLInputElement; + const hint = document.getElementById('new-branch-name-hint') as HTMLElement; + nameInput.value = 'branch.lock'; + nameInput.dispatchEvent(new Event('input')); + await new Promise((r) => setTimeout(r, 0)); + expect(hint.textContent).toContain('.lock'); + }); + + it('rejects names with /./', async () => { + const { wireNewBranch } = await import('./newBranch'); + wireNewBranch(); + const nameInput = document.getElementById('new-branch-name') as HTMLInputElement; + const hint = document.getElementById('new-branch-name-hint') as HTMLElement; + nameInput.value = 'bad/./path'; + nameInput.dispatchEvent(new Event('input')); + await new Promise((r) => setTimeout(r, 0)); + expect(hint.textContent).toContain('invalid segments'); + }); +}); + +// --------------------------------------------------------------------------- +// createBranch - error handling +// --------------------------------------------------------------------------- + +describe('createBranch error handling', () => { + it('handles create branch failure', async () => { + (window as any).__TAURI__ = { + core: { invoke: vi.fn(async () => { throw new Error('create failed'); }) }, + event: { listen: vi.fn() }, + }; + const { wireNewBranch } = await import('./newBranch'); + wireNewBranch(); + await new Promise((r) => setTimeout(r, 0)); + + const nameInput = document.getElementById('new-branch-name') as HTMLInputElement; + const createBtn = document.getElementById('new-branch-create') as HTMLButtonElement; + nameInput.value = 'my-branch'; + nameInput.dispatchEvent(new Event('input')); + await new Promise((r) => setTimeout(r, 0)); + createBtn.click(); + await new Promise((r) => setTimeout(r, 0)); + + const { notify } = await import('../lib/notify'); + expect(vi.mocked(notify)).toHaveBeenCalledWith('Create branch failed'); + }); +}); + +// --------------------------------------------------------------------------- +// createBranch - hook cancellation +// --------------------------------------------------------------------------- + +describe('createBranch hook cancellation', () => { + it('cancels when preBranchCreate hook returns cancelled', async () => { + const { runHook } = await import('../plugins'); + vi.mocked(runHook).mockResolvedValue({ + name: 'preBranchCreate', + data: undefined, + cancelled: true, + reason: 'Cancelled by hook', + cancel: vi.fn(), + }); + + (window as any).__TAURI__ = { + core: { invoke: vi.fn() }, + event: { listen: vi.fn() }, + }; + const { wireNewBranch } = await import('./newBranch'); + wireNewBranch(); + await new Promise((r) => setTimeout(r, 0)); + + const nameInput = document.getElementById('new-branch-name') as HTMLInputElement; + const createBtn = document.getElementById('new-branch-create') as HTMLButtonElement; + nameInput.value = 'my-branch'; + nameInput.dispatchEvent(new Event('input')); + await new Promise((r) => setTimeout(r, 0)); + createBtn.click(); + await new Promise((r) => setTimeout(r, 0)); + + const { notify } = await import('../lib/notify'); + expect(vi.mocked(notify)).toHaveBeenCalledWith('Cancelled by hook'); + }); + + it('cancels when preSwitchBranch hook returns cancelled', async () => { + const { runHook } = await import('../plugins'); + vi.mocked(runHook) + .mockResolvedValueOnce({ + name: 'preBranchCreate', + data: undefined, + cancelled: false, + cancel: vi.fn(), + }) // preBranchCreate + .mockResolvedValueOnce({ + name: 'preSwitchBranch', + data: undefined, + cancelled: true, + reason: 'Switch blocked', + cancel: vi.fn(), + }); // preSwitchBranch + + (window as any).__TAURI__ = { + core: { invoke: vi.fn() }, + event: { listen: vi.fn() }, + }; + const { wireNewBranch } = await import('./newBranch'); + wireNewBranch(); + await new Promise((r) => setTimeout(r, 0)); + + const nameInput = document.getElementById('new-branch-name') as HTMLInputElement; + const checkout = document.getElementById('new-branch-checkout') as HTMLInputElement; + const createBtn = document.getElementById('new-branch-create') as HTMLButtonElement; + checkout.checked = true; + nameInput.value = 'my-branch'; + nameInput.dispatchEvent(new Event('input')); + await new Promise((r) => setTimeout(r, 0)); + createBtn.click(); + await new Promise((r) => setTimeout(r, 0)); + + const { notify } = await import('../lib/notify'); + expect(vi.mocked(notify)).toHaveBeenCalledWith('Switch blocked'); + }); +}); + +// --------------------------------------------------------------------------- +// populateBaseSelect with branches +// --------------------------------------------------------------------------- + +describe('populateBaseSelect', () => { + it('populates base select with branches from state', async () => { + // Set up state with branches before import + const stateModule = await import('../state/state'); + (stateModule.state as any).branch = 'main'; + (stateModule.state as any).branches = [ + { name: 'main', current: true, kind: { type: 'local' } }, + { name: 'develop', current: false, kind: { type: 'local' } }, + { name: 'origin/main', current: false, kind: { type: 'remote', remote: 'origin' } }, + ]; + + installTauriMock(); + const { wireNewBranch } = await import('./newBranch'); + wireNewBranch(); + + const select = document.getElementById('new-branch-base') as HTMLSelectElement; + expect(select.options.length).toBe(3); + // Current branch first + expect(select.options[0].textContent).toBe('main'); + expect(select.options[0].selected).toBe(true); + // Remote should show origin/name + expect(select.options[2].textContent).toBe('origin/main'); + }); +}); diff --git a/Frontend/src/scripts/features/outputLog.test.ts b/Frontend/src/scripts/features/outputLog.test.ts new file mode 100644 index 00000000..79ccb9b7 --- /dev/null +++ b/Frontend/src/scripts/features/outputLog.test.ts @@ -0,0 +1,222 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mockInvoke = vi.fn(); +const mockNotify = vi.fn(); +const mockInitOverlayScrollbarsFor = vi.fn(); +const mockRefreshOverlayScrollbarsFor = vi.fn(); + +vi.mock('../lib/tauri', () => ({ + TAURI: { invoke: mockInvoke, listen: vi.fn() }, +})); + +vi.mock('../lib/notify', () => ({ + notify: mockNotify, +})); + +vi.mock('../lib/scrollbars', () => ({ + initOverlayScrollbarsFor: mockInitOverlayScrollbarsFor, + refreshOverlayScrollbarsFor: mockRefreshOverlayScrollbarsFor, +})); + +function mockLocation(qs: string) { + const url = new URL(`http://localhost:3000/${qs.replace(/^\?/, '') ? `?${qs.replace(/^\?/, '')}` : ''}`); + Object.defineProperty(window, 'location', { + value: url, + writable: true, + configurable: true, + }); +} + +beforeEach(() => { + vi.resetModules(); + vi.useFakeTimers(); + mockInvoke.mockReset(); + mockNotify.mockReset(); + mockInitOverlayScrollbarsFor.mockReset(); + mockRefreshOverlayScrollbarsFor.mockReset(); + document.body.innerHTML = '
'; + mockLocation(''); +}); + +describe('initOutputLogViewIfRequested', () => { + it('returns false when view param is not output-log', async () => { + const { initOutputLogViewIfRequested } = await import('./outputLog'); + const result = await initOutputLogViewIfRequested(); + expect(result).toBe(false); + }); + + it('returns true and creates output log view', async () => { + mockLocation('?view=output-log'); + mockInvoke.mockResolvedValue([]); + + const { initOutputLogViewIfRequested } = await import('./outputLog'); + const result = await initOutputLogViewIfRequested(); + + expect(result).toBe(true); + expect(document.getElementById('output-log-view')).not.toBeNull(); + expect(document.getElementById('outlog-list-vcs')).not.toBeNull(); + expect(document.getElementById('outlog-list-app')).not.toBeNull(); + expect(document.getElementById('outlog-autoscroll')).not.toBeNull(); + expect(document.getElementById('outlog-clear')).not.toBeNull(); + expect(mockInitOverlayScrollbarsFor).toHaveBeenCalled(); + expect(mockRefreshOverlayScrollbarsFor).toHaveBeenCalled(); + }); + + it('hides #app when view is output-log', async () => { + mockLocation('?view=output-log'); + mockInvoke.mockResolvedValue([]); + + const { initOutputLogViewIfRequested } = await import('./outputLog'); + await initOutputLogViewIfRequested(); + + const app = document.getElementById('app') as HTMLElement; + expect(app.style.display).toBe('none'); + }); + + it('appends initial VCS entries', async () => { + mockLocation('?view=output-log'); + mockInvoke.mockResolvedValue([ + { ts_ms: 1000, level: 'info', source: 'git', message: 'pull done' }, + ]); + + const { initOutputLogViewIfRequested } = await import('./outputLog'); + await initOutputLogViewIfRequested(); + + const list = document.getElementById('outlog-list-vcs')!; + expect(list.children.length).toBe(1); + expect(list.textContent).toContain('pull done'); + }); + + it('handles get_output_log rejection gracefully', async () => { + mockLocation('?view=output-log'); + mockInvoke.mockRejectedValue(new Error('fail')); + + const { initOutputLogViewIfRequested } = await import('./outputLog'); + await expect(initOutputLogViewIfRequested()).resolves.toBe(true); + }); + + it('clear button clears VCS log and invokes clear_output_log', async () => { + mockLocation('?view=output-log'); + mockInvoke.mockResolvedValue([ + { ts_ms: 1000, level: 'info', source: 'git', message: 'entry' }, + ]); + + const { initOutputLogViewIfRequested } = await import('./outputLog'); + await initOutputLogViewIfRequested(); + + const clearBtn = document.getElementById('outlog-clear') as HTMLButtonElement; + clearBtn.click(); + + expect(mockInvoke).toHaveBeenCalledWith('clear_output_log'); + const list = document.getElementById('outlog-list-vcs')!; + expect(list.children.length).toBe(0); + }); + + it('clear button for app tab invokes clear_app_log', async () => { + mockLocation('?view=output-log'); + mockInvoke.mockResolvedValue([]); + + const { initOutputLogViewIfRequested } = await import('./outputLog'); + await initOutputLogViewIfRequested(); + + const appTab = document.querySelector('.outlog-tab[data-tab="app"]')!; + appTab.click(); + + mockInvoke.mockClear(); + const clearBtn = document.getElementById('outlog-clear') as HTMLButtonElement; + clearBtn.click(); + + expect(mockInvoke).toHaveBeenCalledWith('clear_app_log'); + }); + + it('handles clear rejection gracefully', async () => { + mockLocation('?view=output-log'); + mockInvoke.mockRejectedValue(new Error('fail')); + + const { initOutputLogViewIfRequested } = await import('./outputLog'); + await initOutputLogViewIfRequested(); + + const clearBtn = document.getElementById('outlog-clear') as HTMLButtonElement; + clearBtn.click(); + + await vi.waitFor(() => { + expect(mockNotify).toHaveBeenCalledWith('Failed to clear output log'); + }); + }); + + it('tab switching changes active tab and syncs visibility', async () => { + mockLocation('?view=output-log'); + mockInvoke.mockResolvedValue([]); + + const { initOutputLogViewIfRequested } = await import('./outputLog'); + await initOutputLogViewIfRequested(); + + const root = document.getElementById('output-log-view')!; + expect(root.dataset.activeTab).toBe('vcs'); + expect(document.getElementById('outlog-list-vcs')!.classList.contains('outlog-hidden')).toBe(false); + expect(document.getElementById('outlog-list-app')!.classList.contains('outlog-hidden')).toBe(true); + + const appTab = document.querySelector('.outlog-tab[data-tab="app"]')!; + appTab.click(); + + expect(root.dataset.activeTab).toBe('app'); + expect(document.getElementById('outlog-list-vcs')!.classList.contains('outlog-hidden')).toBe(true); + expect(document.getElementById('outlog-list-app')!.classList.contains('outlog-hidden')).toBe(false); + }); + + it('autoscroll checkbox when unchecked prevents auto-scroll', async () => { + mockLocation('?view=output-log'); + mockInvoke.mockResolvedValue([]); + + const { initOutputLogViewIfRequested } = await import('./outputLog'); + await initOutputLogViewIfRequested(); + + const auto = document.getElementById('outlog-autoscroll') as HTMLInputElement; + auto.checked = false; + + const vcsTab = document.querySelector('.outlog-tab[data-tab="vcs"]')!; + vcsTab.click(); + + expect(mockRefreshOverlayScrollbarsFor).toHaveBeenCalled(); + }); + + it('starts app polling and stops on tab switch back to vcs', async () => { + mockLocation('?view=output-log'); + mockInvoke.mockResolvedValue([]); + + const { initOutputLogViewIfRequested } = await import('./outputLog'); + await initOutputLogViewIfRequested(); + + const appTab = document.querySelector('.outlog-tab[data-tab="app"]')!; + appTab.click(); + + expect(mockInvoke).toHaveBeenCalledWith('tail_app_log', { maxLines: 1500 }); + + const vcsTab = document.querySelector('.outlog-tab[data-tab="vcs"]')!; + vcsTab.click(); + + vi.advanceTimersByTime(2000); + }); + + it('triggers vcs:log listen callback', async () => { + let listenCallback: ((evt: any) => void) | null = null; + const tauri = await import('../lib/tauri'); + (tauri.TAURI as any).listen = vi.fn((_event: string, cb: (evt: any) => void) => { + listenCallback = cb; + return { unlisten: vi.fn() }; + }); + mockLocation('?view=output-log'); + mockInvoke.mockResolvedValue([]); + + const { initOutputLogViewIfRequested } = await import('./outputLog'); + await initOutputLogViewIfRequested(); + + listenCallback!({ payload: { ts_ms: 123, level: 'info', source: 'git', message: 'log msg' } }); + + const list = document.getElementById('outlog-list-vcs') as HTMLElement; + expect(list.textContent).toContain('log msg'); + }); +}); diff --git a/Frontend/src/scripts/features/renameBranch.test.ts b/Frontend/src/scripts/features/renameBranch.test.ts new file mode 100644 index 00000000..0a4b60d3 --- /dev/null +++ b/Frontend/src/scripts/features/renameBranch.test.ts @@ -0,0 +1,272 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../lib/notify', () => ({ notify: vi.fn() })); +vi.mock('../ui/modals', () => ({ + closeModal: vi.fn(), + hydrate: vi.fn(), + openModal: vi.fn(), +})); + +function mountRenameBranchModal() { + document.body.innerHTML = ` +
+ + + +
+ `; +} + +function installTauriMock() { + (window as any).__TAURI__ = { + core: { invoke: vi.fn(async () => null) }, + event: { listen: vi.fn() }, + }; +} + +function flushPromises(): Promise { + return new Promise((resolve) => window.setTimeout(resolve, 0)); +} + +beforeEach(() => { + vi.resetModules(); + mountRenameBranchModal(); + installTauriMock(); +}); + +afterEach(() => { + document.body.innerHTML = ''; + delete (window as any).__TAURI__; + vi.restoreAllMocks(); +}); + +describe('wireRenameBranch', () => { + it('sets __wired and skips on second call', async () => { + const { wireRenameBranch } = await import('./renameBranch'); + const modal = document.getElementById('rename-branch-modal') as any; + expect(modal.__wired).toBeUndefined(); + wireRenameBranch(); + expect(modal.__wired).toBe(true); + wireRenameBranch(); + expect(modal.__wired).toBe(true); + }); + + it('does nothing when modal is missing', async () => { + document.body.innerHTML = ''; + const { wireRenameBranch } = await import('./renameBranch'); + expect(() => wireRenameBranch()).not.toThrow(); + }); + + it('validate disables confirm when name is empty', async () => { + const { wireRenameBranch } = await import('./renameBranch'); + wireRenameBranch(); + const modal = document.getElementById('rename-branch-modal') as HTMLElement; + const confirm = modal.querySelector('#rename-branch-confirm') as HTMLButtonElement; + const nameEl = modal.querySelector('#rename-branch-name') as HTMLInputElement; + + modal.dataset.oldBranch = 'main'; + nameEl.value = ''; + nameEl.dispatchEvent(new Event('input')); + + expect(confirm.disabled).toBe(true); + }); + + it('validate disables confirm when name is unchanged', async () => { + const { wireRenameBranch } = await import('./renameBranch'); + wireRenameBranch(); + const modal = document.getElementById('rename-branch-modal') as HTMLElement; + const confirm = modal.querySelector('#rename-branch-confirm') as HTMLButtonElement; + const nameEl = modal.querySelector('#rename-branch-name') as HTMLInputElement; + + modal.dataset.oldBranch = 'main'; + nameEl.value = 'main'; + nameEl.dispatchEvent(new Event('input')); + + expect(confirm.disabled).toBe(true); + }); + + it('validate enables confirm when name is valid and different', async () => { + const { wireRenameBranch } = await import('./renameBranch'); + wireRenameBranch(); + const modal = document.getElementById('rename-branch-modal') as HTMLElement; + const confirm = modal.querySelector('#rename-branch-confirm') as HTMLButtonElement; + const nameEl = modal.querySelector('#rename-branch-name') as HTMLInputElement; + + modal.dataset.oldBranch = 'main'; + nameEl.value = 'new-name'; + nameEl.dispatchEvent(new Event('input')); + + expect(confirm.disabled).toBe(false); + }); + + it('Enter key triggers confirm click', async () => { + const { wireRenameBranch } = await import('./renameBranch'); + wireRenameBranch(); + const modal = document.getElementById('rename-branch-modal') as HTMLElement; + const confirm = modal.querySelector('#rename-branch-confirm') as HTMLButtonElement; + const nameEl = modal.querySelector('#rename-branch-name') as HTMLInputElement; + const clickSpy = vi.spyOn(confirm, 'click'); + + nameEl.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' })); + + expect(clickSpy).toHaveBeenCalled(); + }); + + it('Enter key preventDefault on non-Enter keys', async () => { + const { wireRenameBranch } = await import('./renameBranch'); + wireRenameBranch(); + const nameEl = document.getElementById('rename-branch-name') as HTMLInputElement; + const event = new KeyboardEvent('keydown', { key: 'Tab' }); + const defaultPrevented = event.defaultPrevented; + + nameEl.dispatchEvent(event); + expect(defaultPrevented).toBe(false); + }); + + it('confirm click invokes vcs_rename_branch and refreshes', async () => { + const invoke = vi.fn(async () => null); + (window as any).__TAURI__.core.invoke = invoke; + const { notify } = await import('../lib/notify'); + const { closeModal } = await import('../ui/modals'); + + const { wireRenameBranch } = await import('./renameBranch'); + wireRenameBranch(); + const modal = document.getElementById('rename-branch-modal') as HTMLElement; + const confirm = modal.querySelector('#rename-branch-confirm') as HTMLButtonElement; + const nameEl = modal.querySelector('#rename-branch-name') as HTMLInputElement; + + modal.dataset.oldBranch = 'old-name'; + nameEl.value = 'new-name'; + confirm.click(); + await flushPromises(); + + expect(invoke).toHaveBeenCalledWith('vcs_rename_branch', { + old_name: 'old-name', + new_name: 'new-name', + }); + expect(notify).toHaveBeenCalledWith("Renamed 'old-name' → 'new-name'"); + expect(closeModal).toHaveBeenCalledWith('rename-branch-modal'); + }); + + it('confirm click returns early when oldName or newName missing', async () => { + const invoke = vi.fn(async () => null); + (window as any).__TAURI__.core.invoke = invoke; + + const { wireRenameBranch } = await import('./renameBranch'); + wireRenameBranch(); + const modal = document.getElementById('rename-branch-modal') as HTMLElement; + const confirm = modal.querySelector('#rename-branch-confirm') as HTMLButtonElement; + + // Missing oldName + delete modal.dataset.oldBranch; + confirm.click(); + await flushPromises(); + expect(invoke).not.toHaveBeenCalled(); + }); + + it('confirm click returns early when newName equals oldName', async () => { + const invoke = vi.fn(async () => null); + (window as any).__TAURI__.core.invoke = invoke; + + const { wireRenameBranch } = await import('./renameBranch'); + wireRenameBranch(); + const modal = document.getElementById('rename-branch-modal') as HTMLElement; + const confirm = modal.querySelector('#rename-branch-confirm') as HTMLButtonElement; + const nameEl = modal.querySelector('#rename-branch-name') as HTMLInputElement; + + modal.dataset.oldBranch = 'same'; + nameEl.value = 'same'; + confirm.click(); + await flushPromises(); + expect(invoke).not.toHaveBeenCalled(); + }); + + it('confirm click handles error from invoke', async () => { + const invoke = vi.fn(async () => { throw new Error('permission denied'); }); + (window as any).__TAURI__.core.invoke = invoke; + const { notify } = await import('../lib/notify'); + + const { wireRenameBranch } = await import('./renameBranch'); + wireRenameBranch(); + const modal = document.getElementById('rename-branch-modal') as HTMLElement; + const confirm = modal.querySelector('#rename-branch-confirm') as HTMLButtonElement; + const nameEl = modal.querySelector('#rename-branch-name') as HTMLInputElement; + + modal.dataset.oldBranch = 'old-name'; + nameEl.value = 'new-name'; + confirm.click(); + await flushPromises(); + + expect(notify).toHaveBeenCalledWith('Rename failed: Error: permission denied'); + }); + + it('confirm click dispatches app:repo-selected event', async () => { + const invoke = vi.fn(async () => null); + (window as any).__TAURI__.core.invoke = invoke; + + const dispatchSpy = vi.spyOn(window, 'dispatchEvent'); + + const { wireRenameBranch } = await import('./renameBranch'); + wireRenameBranch(); + const modal = document.getElementById('rename-branch-modal') as HTMLElement; + const confirm = modal.querySelector('#rename-branch-confirm') as HTMLButtonElement; + const nameEl = modal.querySelector('#rename-branch-name') as HTMLInputElement; + + modal.dataset.oldBranch = 'old-name'; + nameEl.value = 'new-name'; + confirm.click(); + await flushPromises(); + + expect(dispatchSpy).toHaveBeenCalledWith( + expect.objectContaining({ type: 'app:repo-selected' }), + ); + }); +}); + +describe('setInitial', () => { + it('sets old branch name and fills inputs', async () => { + const { wireRenameBranch } = await import('./renameBranch'); + wireRenameBranch(); + const modal = document.getElementById('rename-branch-modal') as any; + const currentEl = document.getElementById('rename-branch-current') as HTMLInputElement; + const nameEl = document.getElementById('rename-branch-name') as HTMLInputElement; + + modal.setInitial('feature-branch'); + + expect(modal.dataset.oldBranch).toBe('feature-branch'); + expect(currentEl.value).toBe('feature-branch'); + expect(nameEl.value).toBe('feature-branch'); + }); + + it('focuses and selects name input', async () => { + const { wireRenameBranch } = await import('./renameBranch'); + wireRenameBranch(); + const modal = document.getElementById('rename-branch-modal') as any; + const nameEl = document.getElementById('rename-branch-name') as HTMLInputElement; + const focusSpy = vi.spyOn(nameEl, 'focus'); + const selectSpy = vi.spyOn(nameEl, 'select'); + + modal.setInitial('feature-branch'); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(focusSpy).toHaveBeenCalled(); + expect(selectSpy).toHaveBeenCalled(); + }); +}); + +describe('openRenameBranch', () => { + it('hydrates, wires, sets initial, and opens modal', async () => { + const { hydrate, openModal } = await import('../ui/modals'); + + const { openRenameBranch } = await import('./renameBranch'); + openRenameBranch('my-branch'); + + const modal = document.getElementById('rename-branch-modal') as any; + expect(hydrate).toHaveBeenCalledWith('rename-branch-modal'); + expect(modal.dataset.oldBranch).toBe('my-branch'); + expect(openModal).toHaveBeenCalledWith('rename-branch-modal'); + }); +}); diff --git a/Frontend/src/scripts/features/repo/commit.test.ts b/Frontend/src/scripts/features/repo/commit.test.ts index 9f015293..17c05fa1 100644 --- a/Frontend/src/scripts/features/repo/commit.test.ts +++ b/Frontend/src/scripts/features/repo/commit.test.ts @@ -308,4 +308,58 @@ describe('updateCommitButton', () => { expect(summary.placeholder).toBe('Update test.cpp'); expect((document.getElementById('commit-btn') as HTMLButtonElement).disabled).toBe(true); }); + + it('enables commit button when only hunks are selected without files', async () => { + const { updateCommitButton } = await import('./commit'); + state.hasRepo = true; + state.files = [{ path: 'a.txt', status: 'M' } as FileStatus]; + state.selectedFiles = new Set(); + state.selectedHunksByFile = { 'a.txt': [0] }; + (document.getElementById('commit-summary') as HTMLInputElement).value = 'Fix'; + + updateCommitButton(); + expect((document.getElementById('commit-btn') as HTMLButtonElement).disabled).toBe(false); + }); + + it('shows delete hint for one file with D status', async () => { + const { updateCommitButton } = await import('./commit'); + setGlobalSettings({ + commit: { + commit_message_template_enabled: true, + commit_templates: { + commit_message_template_create: 'Create {file:name}', + commit_message_template_update: 'Update {file:name}', + commit_message_template_delete: 'Delete {file:name}', + }, + }, + }); + state.hasRepo = true; + state.files = [{ path: 'removed.txt', status: 'D' } as FileStatus]; + state.selectedFiles = new Set(['removed.txt']); + + updateCommitButton(); + expect((document.getElementById('commit-summary') as HTMLInputElement).placeholder).toBe('Delete removed.txt'); + }); + + it('skips empty paths when determining selected commit file', async () => { + const { updateCommitButton } = await import('./commit'); + state.hasRepo = true; + state.files = [{ path: 'real.txt', status: 'M' } as FileStatus]; + state.selectedFiles = new Set(['', ' ', 'real.txt']); + + updateCommitButton(); + expect((document.getElementById('commit-summary') as HTMLInputElement).placeholder).toBe('Update real.txt'); + }); + + it('enables commit button when only line selections exist', async () => { + const { updateCommitButton } = await import('./commit'); + state.hasRepo = true; + state.files = [{ path: 'file.txt', status: 'M' } as FileStatus]; + state.selectedFiles = new Set(); + state.selectedLinesByFile = { 'file.txt': { 0: [1, 2] } }; + (document.getElementById('commit-summary') as HTMLInputElement).value = 'fix'; + + updateCommitButton(); + expect((document.getElementById('commit-btn') as HTMLButtonElement).disabled).toBe(false); + }); }); diff --git a/Frontend/src/scripts/features/repo/context.test.ts b/Frontend/src/scripts/features/repo/context.test.ts new file mode 100644 index 00000000..05775328 --- /dev/null +++ b/Frontend/src/scripts/features/repo/context.test.ts @@ -0,0 +1,103 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +/** Provides a minimal `matchMedia` test shim used by state imports. */ +function createMatchMediaMock(query: string) { + return { matches: false, media: query, addListener: () => {}, removeListener: () => {} }; +} + +/** Mounts DOM nodes required by context module imports. */ +function mountRepoDom() { + document.body.innerHTML = ` + + +
    + +
    +
    +
    + `; +} + +beforeEach(() => { + vi.resetModules(); + mountRepoDom(); + (globalThis as any).matchMedia = createMatchMediaMock; + (globalThis as any).requestAnimationFrame = (cb: FrameRequestCallback) => window.setTimeout(cb, 0); +}); + +afterEach(() => { + document.body.innerHTML = ''; + vi.restoreAllMocks(); +}); + +describe('context exports', () => { + it('exports expected DOM references', async () => { + const ctx = await import('./context'); + expect(ctx.filterInput).toBeInstanceOf(HTMLInputElement); + expect(ctx.filterInput?.id).toBe('filter'); + expect(ctx.selectAllBox).toBeInstanceOf(HTMLInputElement); + expect(ctx.selectAllBox?.id).toBe('select-all'); + expect(ctx.listEl).toBeInstanceOf(HTMLElement); + expect(ctx.listEl?.id).toBe('file-list'); + expect(ctx.countEl).toBeInstanceOf(HTMLElement); + expect(ctx.countEl?.id).toBe('changes-count'); + expect(ctx.leftFootEl).toBeInstanceOf(HTMLElement); + expect(ctx.leftFootEl?.id).toBe('left-foot'); + expect(ctx.undoLeftBtn).toBeNull(); + expect(ctx.diffHeadPath).toBeInstanceOf(HTMLElement); + expect(ctx.diffHeadPath?.id).toBe('diff-path'); + expect(ctx.diffEl).toBeInstanceOf(HTMLElement); + expect(ctx.diffEl?.id).toBe('diff'); + }); + + it('exports dragState with default values', async () => { + const ctx = await import('./context'); + expect(ctx.dragState.lastClickedIndex).toBe(-1); + expect(ctx.dragState.isDragSelecting).toBe(false); + expect(ctx.dragState.dragTargetState).toBe(true); + expect(ctx.dragState.dragVisited).toBeInstanceOf(Set); + expect(ctx.dragState.dragVisited.size).toBe(0); + expect(ctx.dragState.dragMoved).toBe(false); + expect(ctx.dragState.suppressNextClick).toBe(false); + expect(ctx.dragState.dragMode).toBeNull(); + expect(ctx.dragState.dragStartIndex).toBe(-1); + expect(ctx.dragState.dragCurrentIndex).toBe(-1); + expect(ctx.dragState.dragPreDiff).toBeInstanceOf(Set); + expect(ctx.dragState.dragPrePicked).toBeInstanceOf(Set); + }); +}); + +describe('context event listeners', () => { + it('prevents selectstart when drag selecting', async () => { + const ctx = await import('./context'); + ctx.dragState.isDragSelecting = true; + const ev = new Event('selectstart', { cancelable: true }); + document.dispatchEvent(ev); + expect(ev.defaultPrevented).toBe(true); + }); + + it('prevents dragstart when drag selecting', async () => { + const ctx = await import('./context'); + ctx.dragState.isDragSelecting = true; + const ev = new Event('dragstart', { cancelable: true }); + document.dispatchEvent(ev); + expect(ev.defaultPrevented).toBe(true); + }); + + it('handles selectstart when not drag selecting without error', async () => { + const ctx = await import('./context'); + ctx.dragState.isDragSelecting = false; + const ev = new Event('selectstart', { cancelable: true }); + expect(() => document.dispatchEvent(ev)).not.toThrow(); + }); + + it('handles dragstart when not drag selecting without error', async () => { + const ctx = await import('./context'); + ctx.dragState.isDragSelecting = false; + const ev = new Event('dragstart', { cancelable: true }); + expect(() => document.dispatchEvent(ev)).not.toThrow(); + }); +}); diff --git a/Frontend/src/scripts/features/repo/diffBinary.test.ts b/Frontend/src/scripts/features/repo/diffBinary.test.ts new file mode 100644 index 00000000..6b139835 --- /dev/null +++ b/Frontend/src/scripts/features/repo/diffBinary.test.ts @@ -0,0 +1,150 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +/** Mounts DOM nodes referenced by diffBinary helpers. */ +function mountDiffDom() { + document.body.innerHTML = ` +
      +
      +
      + `; +} + +beforeEach(() => { + vi.resetModules(); + mountDiffDom(); +}); + +afterEach(() => { + document.body.innerHTML = ''; + vi.restoreAllMocks(); +}); + +describe('scrollDiffToTop', () => { + it('resets scroll position of viewport', async () => { + const { scrollDiffToTop } = await import('./diffBinary'); + // diffEl uses closest('.diff-scroll'), so .diff-scroll must be an ancestor + const scrollHost = document.querySelector('.diff-scroll') as HTMLElement; + scrollHost.scrollTop = 50; + scrollHost.scrollLeft = 30; + scrollDiffToTop(); + expect(scrollHost.scrollTop).toBe(0); + expect(scrollHost.scrollLeft).toBe(0); + }); + + it('handles missing diff element gracefully', async () => { + document.body.innerHTML = ''; + const { scrollDiffToTop } = await import('./diffBinary'); + expect(() => scrollDiffToTop()).not.toThrow(); + }); +}); + +describe('detectBinaryDiff', () => { + it('returns true for non-array input', async () => { + const { detectBinaryDiff } = await import('./diffBinary'); + expect(detectBinaryDiff(null as any)).toBe(true); + // undefined uses default parameter value ([]), so returns false + expect(detectBinaryDiff('foo' as any)).toBe(true); + }); + + it('returns false for empty array', async () => { + const { detectBinaryDiff } = await import('./diffBinary'); + expect(detectBinaryDiff([])).toBe(false); + }); + + it('returns false when diff has hunks', async () => { + const { detectBinaryDiff } = await import('./diffBinary'); + const lines = ['diff --git a/a.txt b/a.txt', '@@ -1 +1 @@', '-old', '+new']; + expect(detectBinaryDiff(lines)).toBe(false); + }); + + it('returns true for Binary Files indicator', async () => { + const { detectBinaryDiff } = await import('./diffBinary'); + const lines = ['Binary files a/img.png and b/img.png differ']; + expect(detectBinaryDiff(lines)).toBe(true); + }); + + it('returns true for GIT binary patch indicator', async () => { + const { detectBinaryDiff } = await import('./diffBinary'); + const lines = ['GIT binary patch', 'literal 123']; + expect(detectBinaryDiff(lines)).toBe(true); + }); + + it('returns true for literal marker', async () => { + const { detectBinaryDiff } = await import('./diffBinary'); + const lines = ['literal 456']; + expect(detectBinaryDiff(lines)).toBe(true); + }); + + it('returns false for regular textual diff', async () => { + const { detectBinaryDiff } = await import('./diffBinary'); + const lines = ['diff --git a/a.txt b/a.txt', 'index abc..def', '--- a/a.txt', '+++ b/a.txt', '@@ -1,3 +1,4 @@', ' unchanged', '-removed', '+added']; + expect(detectBinaryDiff(lines)).toBe(false); + }); +}); + +describe('renderBinaryDiffPlaceholder', () => { + it('renders placeholder with path', async () => { + const { renderBinaryDiffPlaceholder } = await import('./diffBinary'); + const html = renderBinaryDiffPlaceholder('image.png'); + expect(html).toContain('image.png'); + expect(html).toContain('Diff not supported on this file type'); + expect(html).toContain('binary-placeholder'); + }); + + it('renders placeholder without path', async () => { + const { renderBinaryDiffPlaceholder } = await import('./diffBinary'); + const html = renderBinaryDiffPlaceholder(); + expect(html).not.toContain('(undefined)'); + expect(html).toContain('Diff not supported on this file type'); + }); +}); + +describe('buildUntrackedTextPatch', () => { + it('builds a synthetic unified diff for untracked file', async () => { + const { buildUntrackedTextPatch } = await import('./diffBinary'); + const lines = buildUntrackedTextPatch('newfile.txt', 'line1\nline2\nline3\n'); + expect(lines[0]).toBe('diff --git a/newfile.txt b/newfile.txt'); + expect(lines[1]).toBe('new file mode 100644'); + expect(lines[2]).toBe('--- /dev/null'); + expect(lines[3]).toBe('+++ b/newfile.txt'); + expect(lines[4]).toBe('@@ -0,0 +1,3 @@'); + expect(lines[5]).toBe('+line1'); + expect(lines[6]).toBe('+line2'); + expect(lines[7]).toBe('+line3'); + }); + + it('handles empty text', async () => { + const { buildUntrackedTextPatch } = await import('./diffBinary'); + const lines = buildUntrackedTextPatch('empty.txt', ''); + expect(lines[4]).toBe('@@ -0,0 +1,0 @@'); + expect(lines).toHaveLength(5); + }); + + it('normalizes CRLF to LF', async () => { + const { buildUntrackedTextPatch } = await import('./diffBinary'); + const lines = buildUntrackedTextPatch('crlf.txt', 'a\r\nb\r\n'); + expect(lines).toHaveLength(7); // header(4) + hunk header(1) + +a + +b = 7 + expect(lines[5]).toBe('+a'); + expect(lines[6]).toBe('+b'); + }); +}); + +describe('isUntrackedStatus', () => { + it('returns true when status contains question mark', async () => { + const { isUntrackedStatus } = await import('./diffBinary'); + expect(isUntrackedStatus('??')).toBe(true); + expect(isUntrackedStatus('? ')).toBe(true); + expect(isUntrackedStatus(' A?')).toBe(true); + }); + + it('returns false when status has no question mark', async () => { + const { isUntrackedStatus } = await import('./diffBinary'); + expect(isUntrackedStatus('M')).toBe(false); + expect(isUntrackedStatus('A')).toBe(false); + expect(isUntrackedStatus('')).toBe(false); + expect(isUntrackedStatus(null as any)).toBe(false); + expect(isUntrackedStatus(undefined as any)).toBe(false); + }); +}); diff --git a/Frontend/src/scripts/features/repo/diffConflicts.test.ts b/Frontend/src/scripts/features/repo/diffConflicts.test.ts new file mode 100644 index 00000000..ef23a5d6 --- /dev/null +++ b/Frontend/src/scripts/features/repo/diffConflicts.test.ts @@ -0,0 +1,472 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const mockInvoke = vi.hoisted(() => vi.fn()); + +vi.mock('../../lib/dom', () => ({ + escapeHtml: vi.fn((s: unknown) => String(s).replace(/&/g, '&').replace(/ ({ buildCtxMenu: vi.fn() })); +vi.mock('../../lib/tauri', () => ({ TAURI: { invoke: mockInvoke } })); +vi.mock('../../lib/notify', () => ({ notify: vi.fn() })); + +const mockState = vi.hoisted(() => ({ + currentFile: '', + currentDiff: [] as string[], + selectedHunks: [] as number[], + selectedHunksByFile: {} as Record, + selectedLinesByFile: {} as Record>, +})); +vi.mock('../../state/state', () => ({ state: mockState })); + +const mockDiffEl = document.createElement('div'); +vi.mock('./context', () => ({ diffEl: mockDiffEl })); +vi.mock('./hydrate', () => ({ hydrateStatus: vi.fn() })); +vi.mock('./diffBinary', () => ({ scrollDiffToTop: vi.fn() })); +vi.mock('../conflicts', () => ({ + openMergeModal: vi.fn(), + hasExternalMergeTool: vi.fn(), + launchExternalMergeTool: vi.fn(), +})); + +function flushPromises(): Promise { + return new Promise((resolve) => window.setTimeout(resolve, 0)); +} + +beforeEach(() => { + vi.resetModules(); + document.body.innerHTML = ''; + mockDiffEl.innerHTML = ''; + mockInvoke.mockReset(); + mockInvoke.mockResolvedValue(null); + mockState.currentFile = ''; + mockState.currentDiff = []; + mockState.selectedHunks = []; + mockState.selectedHunksByFile = {}; + mockState.selectedLinesByFile = {}; +}); + +afterEach(() => { + document.body.innerHTML = ''; + vi.clearAllMocks(); + vi.restoreAllMocks(); +}); + +describe('renderConflictView', () => { + it('renders conflict view on success', async () => { + mockInvoke.mockResolvedValue({ + path: 'conflict.txt', + ours: 'my version\nline2', + theirs: 'their version\nline2', + base: 'base version', + }); + + const { renderConflictView } = await import('./diffConflicts'); + await renderConflictView({ path: 'conflict.txt', status: 'U' }); + + const conflictView = mockDiffEl.querySelector('.conflict-view') as HTMLElement; + expect(conflictView).toBeTruthy(); + expect(conflictView.dataset.conflictPath).toBe('conflict.txt'); + expect(conflictView.dataset.conflictBinary).toBe('0'); + + const header = conflictView.querySelector('.conflict-header') as HTMLElement; + expect(header).toBeTruthy(); + expect(header.textContent).toContain('Merge conflict'); + + const actions = conflictView.querySelector('.conflict-actions') as HTMLElement; + expect(actions).toBeTruthy(); + expect(actions.querySelector('[data-conflict-action="ours"]')).toBeTruthy(); + expect(actions.querySelector('[data-conflict-action="theirs"]')).toBeTruthy(); + expect(actions.querySelector('[data-conflict-action="merge"]')).toBeTruthy(); + + const panes = conflictView.querySelectorAll('.conflict-pane'); + expect(panes.length).toBe(2); + expect(panes[0].querySelector('header')?.textContent).toBe('Mine'); + expect(panes[1].querySelector('header')?.textContent).toBe('Theirs'); + + expect(mockState.currentFile).toBe('conflict.txt'); + expect(mockState.currentDiff).toEqual([]); + expect(mockState.selectedHunks).toEqual([]); + }); + + it('renders error on invoke failure', async () => { + mockInvoke.mockRejectedValue(new Error('network error')); + console.error = vi.fn(); + + const { renderConflictView } = await import('./diffConflicts'); + await renderConflictView({ path: 'bad.txt', status: 'U' }); + + expect(mockDiffEl.innerHTML).toContain('Failed to load conflict details'); + expect(console.error).toHaveBeenCalled(); + }); + + it('shows loading state initially', async () => { + mockInvoke.mockImplementation(() => new Promise(() => {})); + + const { renderConflictView } = await import('./diffConflicts'); + renderConflictView({ path: 'f.txt', status: 'U' }); + + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(mockDiffEl.innerHTML).toContain('Loading conflict'); + }); + + it('clears current file entry from selectedHunksByFile and selectedLinesByFile', async () => { + mockInvoke.mockResolvedValue({ path: 'new.txt', ours: 'a', theirs: 'b' }); + + mockState.selectedHunksByFile['new.txt'] = [0, 1]; + mockState.selectedLinesByFile['new.txt'] = { 0: [1, 2] }; + mockState.selectedHunksByFile['other.txt'] = [3]; + + const { renderConflictView } = await import('./diffConflicts'); + await renderConflictView({ path: 'new.txt', status: 'U' }); + + expect(mockState.selectedHunksByFile['new.txt']).toBeUndefined(); + expect(mockState.selectedLinesByFile['new.txt']).toBeUndefined(); + expect(mockState.selectedHunksByFile['other.txt']).toEqual([3]); + }); +}); + +describe('renderConflictMarkup (via full render)', () => { + it('builds correct markup for text conflict', async () => { + mockInvoke.mockResolvedValue({ + path: 'text.txt', + ours: 'our code', + theirs: 'their code', + binary: false, + }); + + const { renderConflictView } = await import('./diffConflicts'); + await renderConflictView({ path: 'text.txt', status: 'U' }); + + const view = mockDiffEl.querySelector('.conflict-view') as HTMLElement; + expect(view.dataset.conflictBinary).toBe('0'); + expect(view.querySelector('.conflict-panels')).toBeTruthy(); + expect(view.querySelector('.conflict-note')).toBeFalsy(); + }); + + it('builds correct markup for binary conflict', async () => { + mockInvoke.mockResolvedValue({ + path: 'binary.bin', + ours: null, + theirs: null, + binary: true, + }); + + const { renderConflictView } = await import('./diffConflicts'); + await renderConflictView({ path: 'binary.bin', status: 'U' }); + + const view = mockDiffEl.querySelector('.conflict-view') as HTMLElement; + expect(view.dataset.conflictBinary).toBe('1'); + expect(view.querySelector('.conflict-note')).toBeTruthy(); + expect(view.querySelector('.conflict-panels')).toBeFalsy(); + expect(view.querySelector('.conflict-note')?.textContent).toContain('binary'); + }); + + it('escapes path using escapeHtml', async () => { + const { escapeHtml } = await import('../../lib/dom'); + mockInvoke.mockResolvedValue({ + path: '', + ours: 'a', + theirs: 'b', + binary: false, + }); + + const { renderConflictView } = await import('./diffConflicts'); + await renderConflictView({ path: '', status: 'U' }); + + expect(escapeHtml).toHaveBeenCalledWith(''); + }); +}); + +describe('bindConflictActions (ours/theirs)', () => { + it('ours button resolves via vcs_resolve_conflict_side', async () => { + mockInvoke.mockResolvedValue({ path: 'f.txt', ours: 'a', theirs: 'b' }); + const { notify } = await import('../../lib/notify'); + + const { renderConflictView } = await import('./diffConflicts'); + await renderConflictView({ path: 'f.txt', status: 'U' }); + + const oursBtn = mockDiffEl.querySelector('[data-conflict-action="ours"]') as HTMLButtonElement; + oursBtn.click(); + await flushPromises(); + + expect(mockInvoke).toHaveBeenCalledWith('vcs_resolve_conflict_side', { + path: 'f.txt', + side: 'ours', + }); + expect(notify).toHaveBeenCalledWith('Kept your version'); + }); + + it('theirs button resolves via vcs_resolve_conflict_side', async () => { + mockInvoke.mockResolvedValue({ path: 'f.txt', ours: 'a', theirs: 'b' }); + const { notify } = await import('../../lib/notify'); + + const { renderConflictView } = await import('./diffConflicts'); + await renderConflictView({ path: 'f.txt', status: 'U' }); + + const theirsBtn = mockDiffEl.querySelector('[data-conflict-action="theirs"]') as HTMLButtonElement; + theirsBtn.click(); + await flushPromises(); + + expect(mockInvoke).toHaveBeenCalledWith('vcs_resolve_conflict_side', { + path: 'f.txt', + side: 'theirs', + }); + expect(notify).toHaveBeenCalledWith('Kept their version'); + }); + + it('disables buttons during resolution and re-enables after', async () => { + let resolvePromise: () => void = () => {}; + let callCount = 0; + mockInvoke.mockImplementation(() => { + callCount++; + if (callCount === 1) { + return Promise.resolve({ path: 'f.txt', ours: 'a', theirs: 'b' }); + } + return new Promise((resolve) => { + resolvePromise = resolve; + }); + }); + + const { renderConflictView } = await import('./diffConflicts'); + await renderConflictView({ path: 'f.txt', status: 'U' }); + + const oursBtn = mockDiffEl.querySelector('[data-conflict-action="ours"]') as HTMLButtonElement; + const container = mockDiffEl.querySelector('.conflict-view') as HTMLElement; + + oursBtn.click(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(oursBtn.disabled).toBe(true); + expect(container.getAttribute('data-busy')).toBe('1'); + + resolvePromise(); + await flushPromises(); + + expect(oursBtn.disabled).toBe(false); + expect(container.hasAttribute('data-busy')).toBe(false); + }); + + it('shows error on resolve failure', async () => { + mockInvoke + .mockResolvedValueOnce({ path: 'f.txt', ours: 'a', theirs: 'b' }) + .mockRejectedValueOnce(new Error('resolve failed')); + console.error = vi.fn(); + const { notify } = await import('../../lib/notify'); + + const { renderConflictView } = await import('./diffConflicts'); + await renderConflictView({ path: 'f.txt', status: 'U' }); + + const oursBtn = mockDiffEl.querySelector('[data-conflict-action="ours"]') as HTMLButtonElement; + oursBtn.click(); + await flushPromises(); + + expect(console.error).toHaveBeenCalled(); + expect(notify).toHaveBeenCalledWith('Failed to resolve conflict'); + }); +}); + +describe('bindConflictActions (merge button)', () => { + it('opens context menu with built-in merge tool option', async () => { + mockInvoke.mockResolvedValue({ path: 'f.txt', ours: 'a', theirs: 'b' }); + const { buildCtxMenu } = await import('../../lib/menu'); + + const { renderConflictView } = await import('./diffConflicts'); + await renderConflictView({ path: 'f.txt', status: 'U' }); + + const mergeBtn = mockDiffEl.querySelector('[data-conflict-action="merge"]') as HTMLButtonElement; + expect(mergeBtn).toBeTruthy(); + + mergeBtn.click(); + await flushPromises(); + + expect(buildCtxMenu).toHaveBeenCalledTimes(1); + const ctxItems = (buildCtxMenu as any).mock.calls[0][0]; + expect(ctxItems[0].label).toBe('Open built-in merge tool'); + }); + + it('includes external merge tool option when available', async () => { + const { hasExternalMergeTool } = await import('../conflicts'); + (hasExternalMergeTool as any).mockResolvedValue(true); + mockInvoke.mockResolvedValue({ path: 'f.txt', ours: 'a', theirs: 'b' }); + const { buildCtxMenu } = await import('../../lib/menu'); + + const { renderConflictView } = await import('./diffConflicts'); + await renderConflictView({ path: 'f.txt', status: 'U' }); + + const mergeBtn = mockDiffEl.querySelector('[data-conflict-action="merge"]') as HTMLButtonElement; + mergeBtn.click(); + await flushPromises(); + + const ctxItems = (buildCtxMenu as any).mock.calls[0][0]; + expect(ctxItems.length).toBe(2); + expect(ctxItems[1].label).toBe('Open custom merge tool'); + }); + + it('does not include external tool when hasExternalMergeTool returns false', async () => { + mockInvoke.mockResolvedValue({ path: 'f.txt', ours: 'a', theirs: 'b' }); + const { buildCtxMenu } = await import('../../lib/menu'); + const { hasExternalMergeTool } = await import('../conflicts'); + (hasExternalMergeTool as any).mockResolvedValue(false); + + const { renderConflictView } = await import('./diffConflicts'); + await renderConflictView({ path: 'f.txt', status: 'U' }); + + const mergeBtn = mockDiffEl.querySelector('[data-conflict-action="merge"]') as HTMLButtonElement; + mergeBtn.click(); + await flushPromises(); + + const ctxItems = (buildCtxMenu as any).mock.calls[0][0]; + expect(ctxItems.length).toBe(1); + }); +}); + +describe('render markup helpers', () => { + it('includes merge button for non-binary conflicts', async () => { + mockInvoke.mockResolvedValue({ path: 'f.txt', ours: 'a', theirs: 'b', binary: false }); + + const { renderConflictView } = await import('./diffConflicts'); + await renderConflictView({ path: 'f.txt', status: 'U' }); + + expect(mockDiffEl.querySelector('[data-conflict-action="merge"]')).toBeTruthy(); + expect(mockDiffEl.querySelector('[data-conflict-action="ours"]')).toBeTruthy(); + expect(mockDiffEl.querySelector('[data-conflict-action="theirs"]')).toBeTruthy(); + }); + + it('omits merge button for binary conflicts', async () => { + mockInvoke.mockResolvedValue({ path: 'f.bin', ours: null, theirs: null, binary: true }); + + const { renderConflictView } = await import('./diffConflicts'); + await renderConflictView({ path: 'f.bin', status: 'U' }); + + expect(mockDiffEl.querySelector('[data-conflict-action="merge"]')).toBeFalsy(); + expect(mockDiffEl.querySelector('[data-conflict-action="ours"]')).toBeTruthy(); + expect(mockDiffEl.querySelector('[data-conflict-action="theirs"]')).toBeTruthy(); + }); + + it('renders side-by-side panes with content', async () => { + mockInvoke.mockResolvedValue({ + path: 'f.txt', + ours: 'line1\nline2', + theirs: 'theirs1\ntheirs2\n', + }); + + const { renderConflictView } = await import('./diffConflicts'); + await renderConflictView({ path: 'f.txt', status: 'U' }); + + const preElements = mockDiffEl.querySelectorAll('.conflict-code'); + expect(preElements.length).toBe(2); + expect(preElements[0].textContent).toBe('line1\nline2'); + expect(preElements[1].textContent).toBe('theirs1\ntheirs2\n'); + }); + + it('shows empty placeholder when pane has no content', async () => { + mockInvoke.mockResolvedValue({ path: 'f.txt', ours: '', theirs: null }); + + const { renderConflictView } = await import('./diffConflicts'); + await renderConflictView({ path: 'f.txt', status: 'U' }); + + const emptyElements = mockDiffEl.querySelectorAll('.conflict-empty'); + expect(emptyElements.length).toBe(2); + expect(emptyElements[0].textContent).toBe('(empty)'); + expect(emptyElements[1].textContent).toBe('(empty)'); + }); + + it('shows binary conflict note', async () => { + mockInvoke.mockResolvedValue({ path: 'image.png', binary: true }); + + const { renderConflictView } = await import('./diffConflicts'); + await renderConflictView({ path: 'image.png', status: 'U' }); + + const note = mockDiffEl.querySelector('.conflict-note') as HTMLElement; + expect(note).toBeTruthy(); + expect(note.textContent).toContain('binary'); + }); + + it('escapes HTML in pane content', async () => { + mockInvoke.mockResolvedValue({ + path: 'f.txt', + ours: '', + theirs: 'safe content', + }); + + const { renderConflictView } = await import('./diffConflicts'); + await renderConflictView({ path: 'f.txt', status: 'U' }); + + const preElements = mockDiffEl.querySelectorAll('.conflict-code'); + expect(preElements[0].innerHTML).not.toContain('safe'); + expect(el).not.toBeNull(); + expect(el!.querySelector('script')).toBeNull(); + expect(el!.textContent).toBe('safe'); + }); + + it('strips iframe tags', async () => { + const { parseSanitizedPluginElement } = await import('./sanitize'); + const el = parseSanitizedPluginElement('
      ok
      '); + expect(el).not.toBeNull(); + expect(el!.querySelector('iframe')).toBeNull(); + }); + + it('removes on* attributes (inline event handlers)', async () => { + const { parseSanitizedPluginElement } = await import('./sanitize'); + const el = parseSanitizedPluginElement(''); + expect(el).not.toBeNull(); + expect(el!.getAttribute('onclick')).toBeNull(); + expect(el!.getAttribute('onmouseenter')).toBeNull(); + }); + + it('removes style attributes', async () => { + const { parseSanitizedPluginElement } = await import('./sanitize'); + const el = parseSanitizedPluginElement('
      text
      '); + expect(el).not.toBeNull(); + expect(el!.getAttribute('style')).toBeNull(); + }); + + it('removes href with javascript: protocol', async () => { + const { parseSanitizedPluginElement } = await import('./sanitize'); + const el = parseSanitizedPluginElement('link'); + expect(el).not.toBeNull(); + expect(el!.getAttribute('href')).toBeNull(); + }); + + it('keeps safe href values like fragment identifiers', async () => { + const { parseSanitizedPluginElement } = await import('./sanitize'); + const el = parseSanitizedPluginElement('link'); + expect(el).not.toBeNull(); + expect(el!.getAttribute('href')).toBe('#section'); + }); + + it('strips blocked tags', async () => { + const { parseSanitizedPluginElement } = await import('./sanitize'); + const html = '
      keep
      '; + const el = parseSanitizedPluginElement(html); + expect(el).not.toBeNull(); + expect(el!.querySelector('embed')).toBeNull(); + expect(el!.querySelector('object')).toBeNull(); + expect(el!.querySelector('script')).toBeNull(); + expect(el!.querySelector('style')).toBeNull(); + expect(el!.querySelector('template')).toBeNull(); + expect(el!.textContent).toBe('keep'); + }); + + it('removes comment nodes', async () => { + const { parseSanitizedPluginElement } = await import('./sanitize'); + const el = parseSanitizedPluginElement('
      text
      '); + expect(el).not.toBeNull(); + expect(el!.innerHTML).not.toContain('comment'); + }); + + it('returns null for empty or non-element content', async () => { + const { parseSanitizedPluginElement } = await import('./sanitize'); + expect(parseSanitizedPluginElement('')).toBeNull(); + expect(parseSanitizedPluginElement(' text ')).toBeNull(); + expect(parseSanitizedPluginElement(null as unknown as string)).toBeNull(); + }); + + it('strips href when URL constructor throws', async () => { + const origURL = globalThis.URL; + (globalThis as any).URL = vi.fn((url: string, base?: string | URL) => { + if (url === 'x-bad:url') throw new TypeError('bad url'); + return new origURL(url, base); + }) as any; + const { parseSanitizedPluginElement } = await import('./sanitize'); + const el = parseSanitizedPluginElement('link'); + expect(el).not.toBeNull(); + expect(el!.getAttribute('href')).toBeNull(); + (globalThis as any).URL = origURL; + }); + + it('strips blocked tags nested inside safe containers', async () => { + const { parseSanitizedPluginElement } = await import('./sanitize'); + const el = parseSanitizedPluginElement('

      good

      '); + expect(el).not.toBeNull(); + expect(el!.querySelector('script')).toBeNull(); + expect(el!.textContent).toBe('good'); + }); + + it('removes data: and vbscript: protocol URLs', async () => { + const { parseSanitizedPluginElement } = await import('./sanitize'); + const el = parseSanitizedPluginElement(''); + expect(el).not.toBeNull(); + const anchors = el!.querySelectorAll('a'); + expect(anchors[0].getAttribute('href')).toBeNull(); + expect(anchors[1].getAttribute('href')).toBeNull(); + expect(anchors[2].getAttribute('href')).toBe('https://safe.com'); + }); +}); diff --git a/Frontend/src/scripts/plugins/state.test.ts b/Frontend/src/scripts/plugins/state.test.ts new file mode 100644 index 00000000..05fdec94 --- /dev/null +++ b/Frontend/src/scripts/plugins/state.test.ts @@ -0,0 +1,50 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +beforeEach(() => { + vi.resetModules(); +}); + +describe('plugin state', () => { + it('exports Maps for handlers, themes, context menus, settings', async () => { + const mod = await import('./state'); + expect(mod.actionHandlers).toBeInstanceOf(Map); + expect(mod.hookHandlers).toBeInstanceOf(Map); + expect(mod.registeredThemePayloads).toBeInstanceOf(Map); + expect(mod.registeredThemeSummaries).toBeInstanceOf(Map); + expect(mod.contextMenuItems).toBeInstanceOf(Map); + expect(mod.settingsSections).toBeInstanceOf(Map); + }); + + it('initialized starts false', async () => { + const mod = await import('./state'); + expect(mod.initialized).toBe(false); + }); + + it('setInitialized updates the flag', async () => { + const mod = await import('./state'); + expect(mod.initialized).toBe(false); + mod.setInitialized(true); + expect((await import('./state')).initialized).toBe(true); + }); + + it('disabledPlugins and enabledPlugins start empty', async () => { + const mod = await import('./state'); + expect(mod.disabledPlugins.size).toBe(0); + expect(mod.enabledPlugins.size).toBe(0); + }); + + it('setDisabledPlugins replaces the set', async () => { + const mod = await import('./state'); + mod.setDisabledPlugins(new Set(['plugin-a'])); + expect((await import('./state')).disabledPlugins.has('plugin-a')).toBe(true); + }); + + it('setEnabledPlugins replaces the set', async () => { + const mod = await import('./state'); + mod.setEnabledPlugins(new Set(['plugin-b'])); + expect((await import('./state')).enabledPlugins.has('plugin-b')).toBe(true); + }); +}); diff --git a/Frontend/src/scripts/plugins/types.test.ts b/Frontend/src/scripts/plugins/types.test.ts new file mode 100644 index 00000000..fe4f28a2 --- /dev/null +++ b/Frontend/src/scripts/plugins/types.test.ts @@ -0,0 +1,12 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +import { describe, expect, it } from 'vitest'; + +describe('plugins/types (type-only module)', () => { + it('exports no runtime code at module level', async () => { + const mod = await import('./types'); + const keys = Object.keys(mod); + expect(keys.length).toBe(0); + }); +}); diff --git a/Frontend/src/scripts/state/state.test.ts b/Frontend/src/scripts/state/state.test.ts index 72b83dd6..956cc52f 100644 --- a/Frontend/src/scripts/state/state.test.ts +++ b/Frontend/src/scripts/state/state.test.ts @@ -115,4 +115,26 @@ describe('disableDefaultSelectAll', () => { expect(state.defaultSelectAll).toBe(false); expect(state.selectionImplicitAll).toBe(false); }); + + it('returns false when clearImplicit is false', () => { + state.selectedFiles = new Set(['a.txt']); + state.defaultSelectAll = true; + state.selectionImplicitAll = true; + + expect(disableDefaultSelectAll(false)).toBe(false); + expect(Array.from(state.selectedFiles)).toEqual(['a.txt']); + }); +}); + +describe('resolveVcsActionLabel edge cases', () => { + it('returns fallback for empty or whitespace key', () => { + state.vcsActionLabels = {}; + expect(resolveVcsActionLabel('', 'Fallback')).toBe('Fallback'); + expect(resolveVcsActionLabel(' ', 'Fallback')).toBe('Fallback'); + }); + + it('returns fallback when label is only whitespace', () => { + state.vcsActionLabels = { 'VCS.Push': ' ' }; + expect(resolveVcsActionLabel('VCS.Push', 'Push')).toBe('Push'); + }); }); diff --git a/Frontend/src/scripts/themes.test.ts b/Frontend/src/scripts/themes.test.ts index f68bbbb7..77ff46d0 100644 --- a/Frontend/src/scripts/themes.test.ts +++ b/Frontend/src/scripts/themes.test.ts @@ -1,17 +1,987 @@ // Copyright © 2025-2026 OpenVCS Contributors // SPDX-License-Identifier: GPL-3.0-or-later -import { describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { ThemePayload, ThemeSummary } from './types'; -import { getAvailableThemes } from './themes'; +// --------------------------------------------------------------------------- +// Mock external dependencies (hoisted by Vitest) +// --------------------------------------------------------------------------- +vi.mock('./lib/tauri', () => ({ TAURI: { invoke: vi.fn() } })); +vi.mock('./lib/notify', () => ({ notify: vi.fn() })); +vi.mock('./plugins', () => ({ + getRegisteredThemePayload: vi.fn(), + getRegisteredThemeSummaries: vi.fn(), +})); +// --------------------------------------------------------------------------- +// Mutable matchMedia mock so tests can switch between light/dark at runtime +// --------------------------------------------------------------------------- +function createMediaQuery(matches: boolean) { + return { + matches, + media: '(prefers-color-scheme: dark)', + addListener: vi.fn(), + removeListener: vi.fn(), + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(() => false), + }; +} + +const mq = createMediaQuery(false); + +beforeEach(() => { + Object.assign(mq, createMediaQuery(false)); + (window as any).matchMedia = vi.fn(() => mq); + vi.resetModules(); + document.head.innerHTML = ''; + document.body.innerHTML = ''; + document.documentElement.removeAttribute('data-theme-pack'); +}); + +afterEach(() => { + vi.resetAllMocks(); +}); + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- +async function load(): Promise { + return import('./themes'); +} + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- +describe('constants', () => { + it('exports expected default theme IDs', async () => { + const mod = await load(); + expect(mod.DEFAULT_THEME_ID).toBe('default'); + expect(mod.DEFAULT_LIGHT_THEME_ID).toBe('default-light'); + expect(mod.DEFAULT_DARK_THEME_ID).toBe('default-dark'); + }); +}); + +// --------------------------------------------------------------------------- +// getAvailableThemes +// --------------------------------------------------------------------------- describe('getAvailableThemes', () => { - it('returns a copy of the current theme list', () => { - const first = getAvailableThemes(); + it('returns a fresh copy of the theme list', async () => { + const mod = await load(); + const first = mod.getAvailableThemes(); first.pop(); + const second = mod.getAvailableThemes(); + expect(second.length).toBeGreaterThan(first.length); + }); + + it('contains default light and dark themes initially', async () => { + const mod = await load(); + const list = mod.getAvailableThemes(); + expect(list).toHaveLength(2); + expect(list[0].id).toBe('default-light'); + expect(list[1].id).toBe('default-dark'); + }); +}); - const second = getAvailableThemes(); +// --------------------------------------------------------------------------- +// getActiveThemeId +// --------------------------------------------------------------------------- +describe('getActiveThemeId', () => { + it('returns default-light in light system mode', async () => { + const mod = await load(); + expect(mod.getActiveThemeId()).toBe('default-light'); + }); +}); - expect(second.length).toBeGreaterThan(first.length); +// --------------------------------------------------------------------------- +// getCurrentMode +// --------------------------------------------------------------------------- +describe('getCurrentMode', () => { + it('returns system by default', async () => { + const mod = await load(); + expect(mod.getCurrentMode()).toBe('system'); + }); +}); + +// --------------------------------------------------------------------------- +// refreshAvailableThemes +// --------------------------------------------------------------------------- +describe('refreshAvailableThemes', () => { + it('fetches themes from backend and combines with defaults', async () => { + const { TAURI } = await import('./lib/tauri'); + const { getRegisteredThemeSummaries } = await import('./plugins'); + vi.mocked(getRegisteredThemeSummaries).mockReturnValue([]); + vi.mocked(TAURI.invoke).mockResolvedValue([ + { id: 'z-theme', name: 'Z Theme' }, + { id: 'a-theme', name: 'A Theme' }, + ]); + + const mod = await load(); + const result = await mod.refreshAvailableThemes(); + + expect(TAURI.invoke).toHaveBeenCalledWith('list_themes'); + expect(result).toHaveLength(4); + // Results are sorted by name case-insensitively + expect(result[0].id).toBe('default-light'); + expect(result[1].id).toBe('default-dark'); + expect(result[2].id).toBe('a-theme'); + expect(result[3].id).toBe('z-theme'); + }); + + it('includes names from backend and plugins', async () => { + const { TAURI } = await import('./lib/tauri'); + const { getRegisteredThemeSummaries } = await import('./plugins'); + vi.mocked(getRegisteredThemeSummaries).mockReturnValue([ + { id: 'plugin-t', name: 'Plugin T' } as ThemeSummary, + ]); + vi.mocked(TAURI.invoke).mockResolvedValue([ + { id: 'backend-t', name: 'Backend T' }, + ]); + + const mod = await load(); + const result = await mod.refreshAvailableThemes(); + + expect(result.find((t) => t.id === 'plugin-t')).toBeDefined(); + expect(result.find((t) => t.id === 'backend-t')).toBeDefined(); + }); + + it('sorts themes by name case-insensitively', async () => { + const { TAURI } = await import('./lib/tauri'); + const { getRegisteredThemeSummaries } = await import('./plugins'); + vi.mocked(getRegisteredThemeSummaries).mockReturnValue([]); + vi.mocked(TAURI.invoke).mockResolvedValue([ + { id: 'z-theme', name: 'Z Theme' }, + { id: 'a-theme', name: 'A Theme' }, + ]); + + const mod = await load(); + const result = await mod.refreshAvailableThemes(); + expect(result[2].name).toBe('A Theme'); + expect(result[3].name).toBe('Z Theme'); + }); + + it('deduplicates by id (case-insensitive)', async () => { + const { TAURI } = await import('./lib/tauri'); + const { getRegisteredThemeSummaries } = await import('./plugins'); + vi.mocked(getRegisteredThemeSummaries).mockReturnValue([]); + vi.mocked(TAURI.invoke).mockResolvedValue([ + { id: 'default-light', name: 'Duplicate' }, + { id: 'my-theme', name: 'My Theme' }, + { id: 'MY-THEME', name: 'Case Dupe' }, + ]); + + const mod = await load(); + const result = await mod.refreshAvailableThemes(); + // default-light skipped, MY-THEME skipped (collision with my-theme) + expect(result).toHaveLength(3); + }); + + it('includes plugin-registered theme summaries', async () => { + const { TAURI } = await import('./lib/tauri'); + const { getRegisteredThemeSummaries } = await import('./plugins'); + vi.mocked(TAURI.invoke).mockResolvedValue([]); + vi.mocked(getRegisteredThemeSummaries).mockReturnValue([ + { id: 'plugin-theme', name: 'Plugin Theme' } as ThemeSummary, + ]); + + const mod = await load(); + const result = await mod.refreshAvailableThemes(); + + expect(result.find((t) => t.id === 'plugin-theme')).toBeDefined(); + }); + + it('falls back to only plugin summaries when backend fails', async () => { + const { TAURI } = await import('./lib/tauri'); + const { getRegisteredThemeSummaries } = await import('./plugins'); + vi.mocked(TAURI.invoke).mockRejectedValue(new Error('Network error')); + vi.mocked(getRegisteredThemeSummaries).mockReturnValue([ + { id: 'offline-theme', name: 'Offline Theme' } as ThemeSummary, + ]); + + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + const mod = await load(); + const result = await mod.refreshAvailableThemes(); + + expect(result).toHaveLength(3); + expect(result.find((t) => t.id === 'offline-theme')).toBeDefined(); + expect(warnSpy).toHaveBeenCalledWith('list_themes failed', expect.any(Error)); + warnSpy.mockRestore(); + }); + + it('handles empty/null backend response', async () => { + const { TAURI } = await import('./lib/tauri'); + const { getRegisteredThemeSummaries } = await import('./plugins'); + vi.mocked(TAURI.invoke).mockResolvedValue(null); + vi.mocked(getRegisteredThemeSummaries).mockReturnValue([]); + + const mod = await load(); + const result = await mod.refreshAvailableThemes(); + expect(result).toHaveLength(2); + }); + + it('skips null items in backend response', async () => { + const { TAURI } = await import('./lib/tauri'); + const { getRegisteredThemeSummaries } = await import('./plugins'); + vi.mocked(TAURI.invoke).mockResolvedValue([null, undefined, { id: 'valid', name: 'Valid' }]); + vi.mocked(getRegisteredThemeSummaries).mockReturnValue([]); + + const mod = await load(); + const result = await mod.refreshAvailableThemes(); + expect(result.find((t) => t.id === 'valid')).toBeDefined(); + }); + + it('sets fetchedThemes = true after completion', async () => { + const { TAURI } = await import('./lib/tauri'); + vi.mocked(TAURI.invoke).mockResolvedValue([]); + + const mod = await load(); + // ensureThemesLoaded will call refreshAvailableThemes since fetchedThemes=false + await mod.refreshAvailableThemes(); + // Subsequent call to ensureThemesLoaded should return cached + const result = await mod.ensureThemesLoaded(); + expect(TAURI.invoke).toHaveBeenCalledTimes(1); // not called again + expect(result).toBeDefined(); + }); +}); + +// --------------------------------------------------------------------------- +// ensureThemesLoaded +// --------------------------------------------------------------------------- +describe('ensureThemesLoaded', () => { + it('calls refreshAvailableThemes when themes not yet fetched', async () => { + const { TAURI } = await import('./lib/tauri'); + vi.mocked(TAURI.invoke).mockResolvedValue([]); + + const mod = await load(); + await mod.ensureThemesLoaded(); + + // refreshAvailableThemes was called because it invokes list_themes + expect(TAURI.invoke).toHaveBeenCalledWith('list_themes'); + }); + + it('returns cached themes when already fetched', async () => { + const { TAURI } = await import('./lib/tauri'); + vi.mocked(TAURI.invoke).mockResolvedValue([]); + + const mod = await load(); + await mod.ensureThemesLoaded(); // fetches + vi.mocked(TAURI.invoke).mockClear(); + const spy = vi.spyOn(mod, 'refreshAvailableThemes'); + + const result = await mod.ensureThemesLoaded(); // cached + expect(spy).not.toHaveBeenCalled(); + expect(result).toHaveLength(2); + }); + + it('re-fetches when force=true', async () => { + const { TAURI } = await import('./lib/tauri'); + vi.mocked(TAURI.invoke).mockResolvedValue([]); + + const mod = await load(); + await mod.ensureThemesLoaded(); // cache it + vi.mocked(TAURI.invoke).mockClear(); + vi.mocked(TAURI.invoke).mockResolvedValue([{ id: 'new', name: 'New' }]); + + await mod.ensureThemesLoaded(true); // force refetch + expect(TAURI.invoke).toHaveBeenCalled(); + }); +}); + +// --------------------------------------------------------------------------- +// selectThemePack +// --------------------------------------------------------------------------- +describe('selectThemePack', () => { + it('resolves "default" to defaultThemeIdForMode (light)', async () => { + const mod = await load(); + await mod.selectThemePack('default'); + expect(mod.getActiveThemeId()).toBe(mod.DEFAULT_LIGHT_THEME_ID); + }); + + it('sets built-in "default-light" directly and clears custom styles', async () => { + const mod = await load(); + await mod.selectThemePack('default-light'); + expect(mod.getActiveThemeId()).toBe('default-light'); + + // For default themes, no style tag is created (activeStyles is null) + const styleEl = document.getElementById('openvcs-theme-global'); + expect(styleEl).toBeNull(); + }); + + it('sets built-in "default-dark" directly', async () => { + const mod = await load(); + await mod.selectThemePack('default-dark', { mode: 'dark' }); + expect(mod.getActiveThemeId()).toBe('default-dark'); + }); + + it('handles "default-dark" in system mode (pairs to light)', async () => { + const mod = await load(); + await mod.selectThemePack('default-dark', { mode: 'system' }); + // In light system mode, dark pairs to light + expect(mod.getActiveThemeId()).toBe('default-light'); + }); + + it('does NOT strip data-theme-pack for built-in defaults', async () => { + const mod = await load(); + await mod.selectThemePack('default-dark'); + expect(document.documentElement.hasAttribute('data-theme-pack')).toBe(false); + }); + + it('applies registered plugin theme payload', async () => { + const { getRegisteredThemePayload } = await import('./plugins'); + const payload: ThemePayload = { + summary: { id: 'plugin-theme', name: 'Plugin Theme', source: 'user' }, + styles: 'body { color: red; }', + markup: { head: '', body: '
      ' }, + scripts: ['console.log("hello")'], + }; + vi.mocked(getRegisteredThemePayload).mockReturnValue(payload); + + const mod = await load(); + await mod.selectThemePack('plugin-theme'); + + expect(mod.getActiveThemeId()).toBe('plugin-theme'); + // Style tag should be created + expect(document.getElementById('openvcs-theme-global')?.textContent).toBe('body { color: red; }'); + // Markup nodes should be appended + expect(document.head.querySelector('meta[name="theme"]')).not.toBeNull(); + expect(document.body.querySelector('#plugin-el')).not.toBeNull(); + // Script node should be created + const scriptNodes = document.head.querySelectorAll('script[data-theme-pack="plugin-theme"]'); + expect(scriptNodes.length).toBe(1); + expect(scriptNodes[0].textContent).toBe('console.log("hello")'); + }); + + it('loads theme from backend when no registered payload exists', async () => { + const { TAURI } = await import('./lib/tauri'); + const { getRegisteredThemePayload } = await import('./plugins'); + vi.mocked(getRegisteredThemePayload).mockReturnValue(null); + vi.mocked(TAURI.invoke).mockResolvedValue({ + summary: { id: 'backend-theme', name: 'Backend Theme' }, + styles: 'body { background: blue; }', + markup: null, + scripts: [], + } satisfies ThemePayload); + + const mod = await load(); + await mod.selectThemePack('backend-theme'); + + expect(TAURI.invoke).toHaveBeenCalledWith('load_theme', { id: 'backend-theme' }); + expect(mod.getActiveThemeId()).toBe('backend-theme'); + expect(document.getElementById('openvcs-theme-global')?.textContent).toBe('body { background: blue; }'); + }); + + it('handles invalid theme payload from backend with fallback', async () => { + const { TAURI } = await import('./lib/tauri'); + const { getRegisteredThemePayload } = await import('./plugins'); + const { notify } = await import('./lib/notify'); + vi.mocked(getRegisteredThemePayload).mockReturnValue(null); + vi.mocked(TAURI.invoke).mockResolvedValue(null); // invalid payload + + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + const mod = await load(); + await expect(mod.selectThemePack('broken-theme')).rejects.toThrow(); + expect(notify).toHaveBeenCalledWith('Theme failed to load. Reverted to the default theme.'); + // Falls back to default-light (light mode) + expect(mod.getActiveThemeId()).toBe('default-light'); + warnSpy.mockRestore(); + }); + + it('handles backend load error with silent option (no notify)', async () => { + const { TAURI } = await import('./lib/tauri'); + const { getRegisteredThemePayload } = await import('./plugins'); + const { notify } = await import('./lib/notify'); + vi.mocked(getRegisteredThemePayload).mockReturnValue(null); + vi.mocked(TAURI.invoke).mockRejectedValue(new Error('Backend error')); + + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + const mod = await load(); + await expect(mod.selectThemePack('broken-theme', { silent: true })).rejects.toThrow(); + expect(notify).not.toHaveBeenCalled(); + expect(mod.getActiveThemeId()).toBe('default-light'); + warnSpy.mockRestore(); + }); + + it('respects mode option for default theme resolution', async () => { + const mod = await load(); + await mod.selectThemePack('default', { mode: 'dark' }); + expect(mod.getActiveThemeId()).toBe('default-dark'); + }); + + it('resolves paired theme in system mode', async () => { + const mod = await load(); + // mq.matches = false → light mode, so pairing from dark-side-up + // selectThemePack('default-dark', { mode: 'system' }) should pair to default-light + await mod.selectThemePack('default-dark', { mode: 'system' }); + // In light system mode, default-dark should pair to default-light + expect(mod.getActiveThemeId()).toBe('default-light'); + }); + + it('dispatches theme-changed custom event after selection', async () => { + const mod = await load(); + const handler = vi.fn(); + window.addEventListener('openvcs:theme-pack-changed', handler); + await mod.selectThemePack('default-light'); + expect(handler).toHaveBeenCalled(); + expect(handler.mock.calls[0][0].detail.id).toBe('default-light'); + window.removeEventListener('openvcs:theme-pack-changed', handler); + }); + + it('sets data-theme-pack attribute for non-default themes', async () => { + const { getRegisteredThemePayload } = await import('./plugins'); + vi.mocked(getRegisteredThemePayload).mockReturnValue({ + summary: { id: 'custom', name: 'Custom', source: 'user' }, + styles: '', + markup: null, + scripts: [], + } satisfies ThemePayload); + + const mod = await load(); + await mod.selectThemePack('custom'); + + expect(document.documentElement.getAttribute('data-theme-pack')).toBe('custom'); + }); + + it('trims whitespace from themeId', async () => { + const mod = await load(); + await mod.selectThemePack(' default-dark ', { mode: 'dark' }); + expect(mod.getActiveThemeId()).toBe('default-dark'); + }); + + it('treats empty string as default', async () => { + const mod = await load(); + await mod.selectThemePack('', { mode: 'light' }); + expect(mod.getActiveThemeId()).toBe('default-light'); + }); +}); + +// --------------------------------------------------------------------------- +// setAppearanceMode +// --------------------------------------------------------------------------- +describe('setAppearanceMode', () => { + it('applies mode styles for system', async () => { + const mod = await load(); + mod.setAppearanceMode('system'); + expect(mod.getCurrentMode()).toBe('system'); + }); + + it('applies mode styles for light', async () => { + const mod = await load(); + mod.setAppearanceMode('light'); + expect(mod.getCurrentMode()).toBe('light'); + }); + + it('applies mode styles for dark', async () => { + const mod = await load(); + mod.setAppearanceMode('dark'); + expect(mod.getCurrentMode()).toBe('dark'); + }); + + it('dispatches theme-changed event', async () => { + const mod = await load(); + const handler = vi.fn(); + window.addEventListener('openvcs:theme-pack-changed', handler); + mod.setAppearanceMode('dark'); + expect(handler).toHaveBeenCalled(); + window.removeEventListener('openvcs:theme-pack-changed', handler); + }); +}); + +// --------------------------------------------------------------------------- +// DOM operations (setStyleContent) +// --------------------------------------------------------------------------- +describe('setStyleContent (via applyModeStyles)', () => { + it('creates a style element when custom theme provides styles', async () => { + const { getRegisteredThemePayload } = await import('./plugins'); + vi.mocked(getRegisteredThemePayload).mockReturnValue({ + summary: { id: 'stylish', name: 'Stylish', source: 'user' }, + styles: 'body { color: green; }', + markup: null, + scripts: [], + } satisfies ThemePayload); + + const mod = await load(); + expect(document.getElementById('openvcs-theme-global')).toBeNull(); + await mod.selectThemePack('stylish'); + const styleEl = document.getElementById('openvcs-theme-global') as HTMLStyleElement; + expect(styleEl).not.toBeNull(); + expect(styleEl.textContent).toBe('body { color: green; }'); + }); + + it('updates existing style element with new content', async () => { + const { getRegisteredThemePayload } = await import('./plugins'); + vi.mocked(getRegisteredThemePayload).mockReturnValue({ + summary: { id: 'stylish', name: 'Stylish', source: 'user' }, + styles: 'body { color: green; }', + markup: null, + scripts: [], + } satisfies ThemePayload); + + const mod = await load(); + await mod.selectThemePack('stylish'); + const styleEl = document.getElementById('openvcs-theme-global') as HTMLStyleElement; + expect(styleEl.textContent).toBe('body { color: green; }'); + + vi.mocked(getRegisteredThemePayload).mockReturnValue({ + summary: { id: 'stylish', name: 'Stylish', source: 'user' }, + styles: 'body { color: blue; }', + markup: null, + scripts: [], + } satisfies ThemePayload); + await mod.selectThemePack('stylish'); + expect(styleEl.textContent).toBe('body { color: blue; }'); + }); + + it('removes mode-style element when null CSS is passed', async () => { + const mod = await load(); + mod.setAppearanceMode('light'); + // Mode style should be empty string → removed + const modeStyle = document.getElementById('openvcs-theme-mode'); + expect(modeStyle).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// syncThemePackAttr (via selectThemePack) +// --------------------------------------------------------------------------- +describe('syncThemePackAttr', () => { + it('removes attribute for built-in themes', async () => { + const mod = await load(); + document.documentElement.setAttribute('data-theme-pack', 'stale'); + await mod.selectThemePack('default-light'); + expect(document.documentElement.hasAttribute('data-theme-pack')).toBe(false); + }); + + it('sets attribute for non-default themes', async () => { + const { getRegisteredThemePayload } = await import('./plugins'); + vi.mocked(getRegisteredThemePayload).mockReturnValue({ + summary: { id: 'custom-pack', name: 'Custom', source: 'user' }, + styles: '', + markup: null, + scripts: [], + } satisfies ThemePayload); + + const mod = await load(); + await mod.selectThemePack('custom-pack'); + expect(document.documentElement.getAttribute('data-theme-pack')).toBe('custom-pack'); + }); +}); + +// --------------------------------------------------------------------------- +// sanitizeThemeMarkup (via applyMarkupNodes) +// --------------------------------------------------------------------------- +describe('sanitizeThemeMarkup', () => { + it('removes script tags from theme markup', async () => { + const { getRegisteredThemePayload } = await import('./plugins'); + vi.mocked(getRegisteredThemePayload).mockReturnValue({ + summary: { id: 'xss-theme', name: 'XSS Theme', source: 'user' }, + styles: '', + markup: { head: '' }, + scripts: [], + } satisfies ThemePayload); + + const mod = await load(); + await mod.selectThemePack('xss-theme'); + expect(document.head.querySelector('script')).toBeNull(); + expect(document.head.querySelector('meta[charset="utf-8"]')).not.toBeNull(); + }); + + it('removes inline event handlers from theme markup', async () => { + const { getRegisteredThemePayload } = await import('./plugins'); + vi.mocked(getRegisteredThemePayload).mockReturnValue({ + summary: { id: 'evil', name: 'Evil Theme', source: 'user' }, + styles: '', + markup: { body: '' }, + scripts: [], + } satisfies ThemePayload); + + const mod = await load(); + await mod.selectThemePack('evil'); + const btn = document.body.querySelector('button'); + expect(btn).not.toBeNull(); + expect(btn?.getAttribute('onclick')).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// resolveThemePackAttrId (internal) +// --------------------------------------------------------------------------- +describe('resolveThemePackAttrId (via selectThemePack with plugin_id)', () => { + it('strips plugin_id prefix from attribute id', async () => { + const { getRegisteredThemePayload } = await import('./plugins'); + vi.mocked(getRegisteredThemePayload).mockReturnValue({ + summary: { id: 'myplugin.magic', name: 'Magic', source: 'user', plugin_id: 'myplugin' }, + styles: '', + markup: null, + scripts: [], + } satisfies ThemePayload); + + const mod = await load(); + await mod.selectThemePack('myplugin.magic'); + // Expect plugin_id prefix to be stripped: "myplugin." prefix removed → "magic" + expect(document.documentElement.getAttribute('data-theme-pack')).toBe('magic'); + }); + + it('keeps raw id when plugin_id prefix does not match (line 302)', async () => { + const { getRegisteredThemePayload } = await import('./plugins'); + vi.mocked(getRegisteredThemePayload).mockReturnValue({ + summary: { id: 'unrelated', name: 'Unrelated', source: 'user', plugin_id: 'otherplugin' }, + styles: '', + markup: null, + scripts: [], + } satisfies ThemePayload); + + const mod = await load(); + await mod.selectThemePack('unrelated'); + // rawId = 'unrelated', plugin_id = 'otherplugin', prefix = 'otherplugin.' + // rawId does NOT start with 'otherplugin.' so returns rawId as-is + expect(document.documentElement.getAttribute('data-theme-pack')).toBe('unrelated'); + }); +}); + +// --------------------------------------------------------------------------- +// Plugin summaries +// --------------------------------------------------------------------------- +describe('sanitizeSummary (via refreshAvailableThemes)', () => { + it('sanitizes theme summary fields', async () => { + const { TAURI } = await import('./lib/tauri'); + vi.mocked(TAURI.invoke).mockResolvedValue([ + { id: ' messy ', name: ' Messy ', description: ' desc ', version: '1.0', author: ' Author ' }, + ]); + + const mod = await load(); + const result = await mod.refreshAvailableThemes(); + const messy = result.find((t) => t.id === 'messy'); + expect(messy).toBeDefined(); + expect(messy!.name).toBe('Messy'); + expect(messy!.description).toBe('desc'); + expect(messy!.version).toBe('1.0'); + expect(messy!.author).toBe('Author'); + }); +}); + +// --------------------------------------------------------------------------- +// resolvePairedThemeId (tested via selectThemePack in system mode) +// --------------------------------------------------------------------------- +describe('paired theme resolution', () => { + it('pairs registered themes with paired_with field', async () => { + const { TAURI } = await import('./lib/tauri'); + const { getRegisteredThemeSummaries } = await import('./plugins'); + vi.mocked(TAURI.invoke).mockResolvedValue([ + { id: 'custom-dark', name: 'Custom Dark', appearance: 'dark', paired_with: 'custom-light' }, + { id: 'custom-light', name: 'Custom Light', appearance: 'light', paired_with: 'custom-dark' }, + ]); + vi.mocked(getRegisteredThemeSummaries).mockReturnValue([]); + + const mod = await load(); + await mod.refreshAvailableThemes(); + + // In light system mode, selecting 'custom-dark' should pair to 'custom-light' + await mod.selectThemePack('custom-dark', { mode: 'system' }); + expect(mod.getActiveThemeId()).toBe('custom-light'); + }); + + it('uses heuristic -dark/-light swap when no explicit paired_with', async () => { + const { TAURI } = await import('./lib/tauri'); + vi.mocked(TAURI.invoke).mockResolvedValue([ + { id: 'my-dark', name: 'My Dark', appearance: 'dark' }, + { id: 'my-light', name: 'My Light', appearance: 'light' }, + ]); + + const mod = await load(); + await mod.refreshAvailableThemes(); + + // In light system mode, selecting 'my-dark' should heuristically find 'my-light' + await mod.selectThemePack('my-dark', { mode: 'system' }); + expect(mod.getActiveThemeId()).toBe('my-light'); + }); + + it('uses heuristic _dark/_light swap', async () => { + const { TAURI } = await import('./lib/tauri'); + vi.mocked(TAURI.invoke).mockResolvedValue([ + { id: 'my_dark', name: 'My Dark', appearance: 'dark' }, + { id: 'my_light', name: 'My Light', appearance: 'light' }, + ]); + + const mod = await load(); + await mod.refreshAvailableThemes(); + + await mod.selectThemePack('my_dark', { mode: 'system' }); + expect(mod.getActiveThemeId()).toBe('my_light'); + }); + + it('does not pair when appearance already matches system mode (line 73)', async () => { + // mq.matches = false → light system mode + // A theme with appearance='light' should NOT pair because it matches + const { getRegisteredThemePayload } = await import('./plugins'); + vi.mocked(getRegisteredThemePayload).mockReturnValue({ + summary: { id: 'my-light', name: 'My Light', appearance: 'light', paired_with: 'my-dark' }, + styles: '', + markup: null, + scripts: [], + } satisfies ThemePayload); + + const mod = await load(); + await mod.selectThemePack('my-light', { mode: 'system' }); + // appearance ('light') === target ('light') → returns null from resolvePairedThemeId + expect(mod.getActiveThemeId()).toBe('my-light'); + }); +}); + +// --------------------------------------------------------------------------- +// ensureSystemListener (via setAppearanceMode & mode changes) +// --------------------------------------------------------------------------- +describe('ensureSystemListener', () => { + it('only installs the system listener once', async () => { + const mod = await load(); + mod.setAppearanceMode('system'); + mod.setAppearanceMode('system'); + // addEventListener should have been called only once + expect(mq.addEventListener).toHaveBeenCalledTimes(1); + }); +}); + +// --------------------------------------------------------------------------- +// Clean-up of previous theme assets when switching +// --------------------------------------------------------------------------- +describe('theme switching cleans up old assets', () => { + it('removes previous head markup when switching to default', async () => { + const { getRegisteredThemePayload } = await import('./plugins'); + vi.mocked(getRegisteredThemePayload).mockReturnValue({ + summary: { id: 'with-markup', name: 'With Markup', source: 'user' }, + styles: '', + markup: { head: '' }, + scripts: ['console.log("old")'], + } satisfies ThemePayload); + + const mod = await load(); + await mod.selectThemePack('with-markup'); + expect(document.head.querySelector('meta[name="from-plugin"]')).not.toBeNull(); + + // Switch to default - previous markup should be cleaned + await mod.selectThemePack('default-light'); + expect(document.head.querySelector('meta[name="from-plugin"]')).toBeNull(); + }); + + it('removes previous script nodes when switching themes', async () => { + const { getRegisteredThemePayload } = await import('./plugins'); + vi.mocked(getRegisteredThemePayload).mockReturnValue({ + summary: { id: 'with-scripts', name: 'With Scripts', source: 'user' }, + styles: '', + markup: null, + scripts: ['console.log("first")'], + } satisfies ThemePayload); + + const mod = await load(); + await mod.selectThemePack('with-scripts'); + const scripts1 = document.head.querySelectorAll('script[data-theme-pack="with-scripts"]'); + expect(scripts1.length).toBe(1); + + // Switch to a different theme + vi.mocked(getRegisteredThemePayload).mockReturnValue({ + summary: { id: 'other-scripts', name: 'Other', source: 'user' }, + styles: '', + markup: null, + scripts: ['console.log("second")'], + } satisfies ThemePayload); + await mod.selectThemePack('other-scripts'); + + const scriptsV1 = document.head.querySelectorAll('script[data-theme-pack="with-scripts"]'); + expect(scriptsV1.length).toBe(0); + const scriptsV2 = document.head.querySelectorAll('script[data-theme-pack="other-scripts"]'); + expect(scriptsV2.length).toBe(1); + }); +}); + +// --------------------------------------------------------------------------- +// Edge handling for sanitizeSummary +// --------------------------------------------------------------------------- +describe('edge handling', () => { + it('assigns source "user" for non-default themes without explicit source', async () => { + const { TAURI } = await import('./lib/tauri'); + vi.mocked(TAURI.invoke).mockResolvedValue([ + { id: 'user-theme', name: 'User Theme' }, + ]); + + const mod = await load(); + const result = await mod.refreshAvailableThemes(); + const t = result.find((x) => x.id === 'user-theme'); + expect(t?.source).toBe('user'); + }); + + it('handles empty plugin summaries array', async () => { + const { TAURI } = await import('./lib/tauri'); + const { getRegisteredThemeSummaries } = await import('./plugins'); + vi.mocked(TAURI.invoke).mockResolvedValue([]); + vi.mocked(getRegisteredThemeSummaries).mockReturnValue([]); + + const mod = await load(); + const result = await mod.refreshAvailableThemes(); + expect(result).toHaveLength(2); + }); + + it('handles non-array plugin summaries', async () => { + const { TAURI } = await import('./lib/tauri'); + const { getRegisteredThemeSummaries } = await import('./plugins'); + vi.mocked(TAURI.invoke).mockResolvedValue([]); + vi.mocked(getRegisteredThemeSummaries).mockReturnValue(null as any); + + const mod = await load(); + // Should not throw + const result = await mod.refreshAvailableThemes(); + expect(result).toHaveLength(2); + }); +}); + +// --------------------------------------------------------------------------- +// applyScriptNodes - empty/null scripts +// --------------------------------------------------------------------------- +describe('applyScriptNodes safety', () => { + it('handles null/undefined scripts in payload gracefully', async () => { + const { getRegisteredThemePayload } = await import('./plugins'); + vi.mocked(getRegisteredThemePayload).mockReturnValue({ + summary: { id: 'no-scripts', name: 'No Scripts', source: 'user' }, + styles: '', + markup: null, + scripts: undefined as any, + } satisfies ThemePayload); + + const mod = await load(); + await expect(mod.selectThemePack('no-scripts')).resolves.not.toThrow(); + }); + + it('filters out non-string scripts', async () => { + const { getRegisteredThemePayload } = await import('./plugins'); + vi.mocked(getRegisteredThemePayload).mockReturnValue({ + summary: { id: 'bad-scripts', name: 'Bad Scripts', source: 'user' }, + styles: '', + markup: null, + scripts: [null, undefined, '' as any, ' ' as any, 'console.log("ok")'] as any, + } satisfies ThemePayload); + + const mod = await load(); + await mod.selectThemePack('bad-scripts'); + const scripts = document.head.querySelectorAll('script[data-theme-pack="bad-scripts"]'); + expect(scripts.length).toBe(1); + }); +}); + +describe('setStyleContent removes empty style', () => { + it('removes style element when content becomes empty', async () => { + const { getRegisteredThemePayload } = await import('./plugins'); + vi.mocked(getRegisteredThemePayload).mockReturnValue({ + summary: { id: 'theme-css', name: 'Theme CSS', source: 'user' }, + styles: 'body { color: red; }', + markup: null, + scripts: [], + } satisfies ThemePayload); + + document.head.innerHTML = ''; + const mod = await load(); + await mod.selectThemePack('theme-css'); + expect(document.getElementById('openvcs-theme-global')).not.toBeNull(); + + vi.mocked(getRegisteredThemePayload).mockReturnValue({ + summary: { id: 'no-css', name: 'No CSS', source: 'user' }, + styles: '', + markup: null, + scripts: [], + } satisfies ThemePayload); + await mod.selectThemePack('no-css'); + expect(document.getElementById('openvcs-theme-global')).toBeNull(); + }); +}); + +describe('resolveThemeByPreference - no match', () => { + it('returns null when no paired heuristic candidate exists', async () => { + const { getRegisteredThemePayload, getRegisteredThemeSummaries } = await import('./plugins'); + vi.mocked(getRegisteredThemePayload).mockReturnValue({ + summary: { id: 'theme-ugly', name: 'Theme Ugly', appearance: 'dark' }, + styles: '', + markup: null, + scripts: [], + } satisfies ThemePayload); + vi.mocked(getRegisteredThemeSummaries).mockReturnValue([ + { id: 'theme-ugly', name: 'Theme Ugly', appearance: 'dark' }, + ]); + + const mod = await load(); + await mod.refreshAvailableThemes(); + await mod.selectThemePack('theme-ugly', { mode: 'system' }); + expect(mod.getActiveThemeId()).toBe('theme-ugly'); + }); +}); + +describe('system listener change event', () => { + it('triggers paired theme re-selection on system theme change', async () => { + const mq = { matches: false, addEventListener: vi.fn((_type: string, cb: () => void) => { (mq as any)._cb = cb; }) }; + (globalThis as any).matchMedia = vi.fn(() => mq); + + const mod = await load(); + const { getRegisteredThemeSummaries } = await import('./plugins'); + vi.mocked(getRegisteredThemeSummaries).mockReturnValue([ + { id: 'pair-dark', name: 'dark', appearance: 'dark', paired_with: 'pair-light' }, + { id: 'pair-light', name: 'light', appearance: 'light', paired_with: 'pair-dark' }, + ]); + + await mod.setAppearanceMode('system'); + (mq as any)._cb(); + }); +}); + +// ============================================================================ +// normalizeAppearance and resolvePairedThemeId edge cases +// ============================================================================ +describe('normalizeAppearance and resolvePairedThemeId edge cases', () => { + it('does not pair theme when appearance is "both"', async () => { + const { getRegisteredThemePayload } = await import('./plugins'); + vi.mocked(getRegisteredThemePayload).mockReturnValue({ + summary: { id: 'both-theme', name: 'Both', appearance: 'both', source: 'user' }, + styles: '', + markup: null, + scripts: [], + }); + + const mod = await load(); + await mod.selectThemePack('both-theme', { mode: 'system' }); + // resolvePairedThemeId returns null for 'both' appearance → no pairing + expect(mod.getActiveThemeId()).toBe('both-theme'); + }); + + it('does not pair theme when appearance is invalid', async () => { + const { getRegisteredThemePayload } = await import('./plugins'); + vi.mocked(getRegisteredThemePayload).mockReturnValue({ + summary: { id: 'invalid-app', name: 'Invalid', appearance: 'invalid' as any, source: 'user' }, + styles: '', + markup: null, + scripts: [], + }); + + const mod = await load(); + await mod.selectThemePack('invalid-app', { mode: 'system' }); + // normalizeAppearance returns null for unrecognized → resolvePairedThemeId returns null + expect(mod.getActiveThemeId()).toBe('invalid-app'); + }); +}); + +// ============================================================================ +// ensureSystemListener behavior edge cases +// ============================================================================ +describe('ensureSystemListener mode guard', () => { + it('skips theme change listener when current mode is not system', async () => { + const mod = await load(); + mod.setAppearanceMode('system'); + // Switch to light mode so currentMode !== 'system' + mod.setAppearanceMode('light'); + // Grab the change handler installed on SYSTEM_DARK_MQ + const cb = mq.addEventListener.mock.calls[0][1]; + // Trigger system color scheme change + cb(); + // currentMode is 'light', not 'system' → listener should return early + expect(mod.getActiveThemeId()).toBe('default-light'); }); }); diff --git a/Frontend/src/scripts/types.d.ts b/Frontend/src/scripts/types.d.ts index 03080d1d..5eb9f33a 100644 --- a/Frontend/src/scripts/types.d.ts +++ b/Frontend/src/scripts/types.d.ts @@ -152,7 +152,7 @@ export interface ThemePayload { markup?: { head?: string | null; body?: string | null; - }; + } | null; scripts?: string[]; } diff --git a/Frontend/src/scripts/ui/layout.test.ts b/Frontend/src/scripts/ui/layout.test.ts index 7b8a4f85..92b4250b 100644 --- a/Frontend/src/scripts/ui/layout.test.ts +++ b/Frontend/src/scripts/ui/layout.test.ts @@ -162,3 +162,655 @@ describe('refreshRepoActions', () => { expect(commitBtn.disabled).toBe(false); }); }); + +describe('setTheme', () => { + beforeEach(() => { + vi.resetModules(); + Object.defineProperty(globalThis, 'matchMedia', { + value: (query: string) => ({ + matches: query === '(prefers-color-scheme: dark)', + media: query, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + }), + configurable: true, + writable: true, + }); + }); + + it('sets data-theme to dark when theme is dark', async () => { + const { setTheme } = await import('./layout'); + setTheme('dark'); + expect(document.documentElement.getAttribute('data-theme')).toBe('dark'); + }); + + it('sets data-theme to light when theme is light', async () => { + const { setTheme } = await import('./layout'); + setTheme('light'); + expect(document.documentElement.getAttribute('data-theme')).toBe('light'); + }); + + it('sets data-theme based on system preference when theme is system', async () => { + const { setTheme } = await import('./layout'); + setTheme('system'); + const expected = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; + expect(document.documentElement.getAttribute('data-theme')).toBe(expected); + }); + + it('updates settings modal controls when present', async () => { + document.body.innerHTML = ` +
      + + +
      + `; + const { setTheme } = await import('./layout'); + setTheme('system'); + + const auto = document.querySelector('#set-theme-auto') as HTMLInputElement; + expect(auto.checked).toBe(true); + + setTheme('light'); + expect(auto.checked).toBe(false); + }); +}); + +describe('toggleTheme', () => { + beforeEach(() => { + vi.resetModules(); + Object.defineProperty(globalThis, 'matchMedia', { + value: () => ({ matches: false, addEventListener: vi.fn(), removeEventListener: vi.fn(), addListener: vi.fn(), removeListener: vi.fn() }), + configurable: true, + writable: true, + }); + (window as any).__TAURI__ = { + core: { invoke: vi.fn() }, + event: { listen: vi.fn() }, + }; + }); + + it('toggles from light to dark and persists to backend', async () => { + const tauri = (window as any).__TAURI__; + tauri.core.invoke.mockResolvedValue({ general: { theme: 'light' } }); + + const { toggleTheme, setTheme } = await import('./layout'); + setTheme('light'); + + toggleTheme(); + + await vi.waitFor(() => { + expect(document.documentElement.getAttribute('data-theme')).toBe('dark'); + }); + }); + + it('handles backend persistence failure gracefully', async () => { + const tauri = (window as any).__TAURI__; + tauri.core.invoke.mockRejectedValue(new Error('fail')); + + const { toggleTheme, setTheme } = await import('./layout'); + setTheme('light'); + + toggleTheme(); + + await vi.waitFor(() => { + expect(document.documentElement.getAttribute('data-theme')).toBe('dark'); + }); + }); +}); + +describe('setTab', () => { + beforeEach(() => { + vi.resetModules(); + document.body.innerHTML = ` +
      + + + +
      +
      + + `; + }); + + it('activates the target tab and deactivates others', async () => { + const { setTab } = await import('./layout'); + setTab('history'); + + const tabs = document.querySelectorAll('.tab'); + expect(tabs[0].classList.contains('active')).toBe(false); + expect(tabs[1].classList.contains('active')).toBe(true); + expect(tabs[1].getAttribute('aria-selected')).toBe('true'); + }); + + it('hides commit box on history tab', async () => { + const { setTab } = await import('./layout'); + setTab('history'); + + const commitBox = document.getElementById('commit'); + expect(commitBox?.style.display).toBe('none'); + }); + + it('shows commit box on changes tab', async () => { + const { setTab } = await import('./layout'); + setTab('history'); + setTab('changes'); + + const commitBox = document.getElementById('commit'); + expect(commitBox?.style.display).toBe('grid'); + }); + + it('sets diff path text based on tab', async () => { + const { setTab } = await import('./layout'); + setTab('history'); + expect(document.getElementById('diff-path')?.textContent).toBe('Commit details'); + + setTab('stash'); + expect(document.getElementById('diff-path')?.textContent).toBe('Stash details'); + + setTab('changes'); + expect(document.getElementById('diff-path')?.textContent).toBe('Select a file to view changes'); + }); + + it('hides history actions button when not on history tab', async () => { + const { setTab } = await import('./layout'); + setTab('changes'); + const btn = document.getElementById('history-actions-btn') as HTMLButtonElement; + expect(btn.hidden).toBe(true); + }); + + it('dispatches app:tab-changed event', async () => { + const handler = vi.fn(); + window.addEventListener('app:tab-changed', handler); + + const { setTab } = await import('./layout'); + setTab('stash'); + + expect(handler).toHaveBeenCalledWith( + expect.objectContaining({ detail: 'stash' }), + ); + }); +}); + +describe('bindTabs', () => { + beforeEach(() => { + vi.resetModules(); + document.body.innerHTML = ` + + + `; + }); + + it('calls onChange when a tab is clicked', async () => { + const { bindTabs } = await import('./layout'); + const onChange = vi.fn(); + + bindTabs(onChange); + + const historyTab = document.querySelector('.tab[data-tab="history"]') as HTMLButtonElement; + historyTab.click(); + + expect(onChange).toHaveBeenCalledWith('history'); + }); + + it('defaults to "changes" for unrecognized tab values', async () => { + document.body.innerHTML = ''; + const { bindTabs } = await import('./layout'); + const onChange = vi.fn(); + + bindTabs(onChange); + + const unknownTab = document.querySelector('.tab') as HTMLButtonElement; + unknownTab.click(); + + expect(onChange).toHaveBeenCalledWith('changes'); + }); +}); + +describe('setRepoHeader / resetRepoHeader', () => { + beforeEach(() => { + vi.resetModules(); + document.body.innerHTML = ` +
      +
      + `; + }); + + it('sets the repo title from a path', async () => { + const { setRepoHeader } = await import('./layout'); + setRepoHeader('/home/user/projects/my-repo'); + expect(document.getElementById('repo-title')?.textContent).toBe('my-repo'); + }); + + it('sets the repo title from a path with trailing slash', async () => { + const { setRepoHeader } = await import('./layout'); + setRepoHeader('/home/user/projects/my-repo/'); + expect(document.getElementById('repo-title')?.textContent).toBe('my-repo'); + }); + + it('sets the branch label from state', async () => { + const { setRepoHeader } = await import('./layout'); + const { state } = await import('../state/state'); + state.branchLabel = 'main'; + setRepoHeader('/repo'); + expect(document.getElementById('repo-branch')?.textContent).toBe('main'); + }); + + it('resets the repo header to defaults', async () => { + const { resetRepoHeader } = await import('./layout'); + resetRepoHeader(); + expect(document.getElementById('repo-title')?.textContent).toBe('Click to open Repo'); + expect(document.getElementById('repo-branch')?.textContent).toBe('No repo open'); + }); +}); + +describe('bindLayoutActionState', () => { + beforeEach(() => { + vi.resetModules(); + Object.defineProperty(globalThis, 'matchMedia', { + value: () => ({ matches: false, addEventListener: vi.fn(), removeEventListener: vi.fn(), addListener: vi.fn(), removeListener: vi.fn() }), + configurable: true, + writable: true, + }); + document.body.innerHTML = ` +
      +
      + + + + + + + `; + }); + + it('wires event listeners without crashing', async () => { + const { bindLayoutActionState } = await import('./layout'); + expect(() => bindLayoutActionState()).not.toThrow(); + }); + + it('fires refreshRepoActions on app:repo-selected', async () => { + const { bindLayoutActionState } = await import('./layout'); + bindLayoutActionState(); + window.dispatchEvent(new Event('app:repo-selected')); + }); + + it('fires refreshRepoActions on app:status-updated', async () => { + const { bindLayoutActionState } = await import('./layout'); + bindLayoutActionState(); + window.dispatchEvent(new Event('app:status-updated')); + }); + + it('fires refreshRepoActions on app:branches-updated', async () => { + const { bindLayoutActionState } = await import('./layout'); + bindLayoutActionState(); + window.dispatchEvent(new Event('app:branches-updated')); + }); +}); + +// --------------------------------------------------------------------------- +// setRepoHeader - edge cases +// --------------------------------------------------------------------------- + +describe('setRepoHeader edge cases', () => { + beforeEach(() => { + vi.resetModules(); + document.body.innerHTML = '
      '; + }); + + it('does not set title when pathMaybe is undefined', async () => { + const { setRepoHeader } = await import('./layout'); + setRepoHeader(undefined); + expect(document.getElementById('repo-title')?.textContent).toBe(''); + }); + + it('falls back to path when splitting returns empty', async () => { + const { setRepoHeader } = await import('./layout'); + setRepoHeader('repo'); + expect(document.getElementById('repo-title')?.textContent).toBe('repo'); + }); + + it('uses branchLabel when available', async () => { + const { setRepoHeader } = await import('./layout'); + const { state } = await import('../state/state'); + state.branchLabel = 'feature-branch'; + state.branch = 'main'; + setRepoHeader('/repo'); + expect(document.getElementById('repo-branch')?.textContent).toBe('feature-branch'); + }); + + it('falls back to state.branch when no branchLabel', async () => { + const { setRepoHeader } = await import('./layout'); + const { state } = await import('../state/state'); + state.branchLabel = ''; + state.branch = 'main'; + setRepoHeader('/repo'); + expect(document.getElementById('repo-branch')?.textContent).toBe('main'); + }); +}); + +// --------------------------------------------------------------------------- +// renderAheadBehind +// --------------------------------------------------------------------------- + +describe('renderAheadBehind', () => { + beforeEach(() => { + vi.resetModules(); + Object.defineProperty(globalThis, 'matchMedia', { + value: () => ({ matches: false, addEventListener: vi.fn(), removeEventListener: vi.fn(), addListener: vi.fn(), removeListener: vi.fn() }), + configurable: true, + writable: true, + }); + document.body.innerHTML = '
      '; + }); + + it('shows ahead count via bindLayoutActionState', async () => { + const { bindLayoutActionState } = await import('./layout'); + const { state } = await import('../state/state'); + state.hasRepo = true; + state.ahead = 3; + state.behind = 1; + const el = document.getElementById('ahead-behind') as HTMLElement; + bindLayoutActionState(); + await new Promise((r) => setTimeout(r, 0)); + expect(el.textContent).toContain('↑3'); + expect(el.textContent).toContain('↓1'); + }); + + it('hides ahead-behind when no counts via bindLayoutActionState', async () => { + const { bindLayoutActionState } = await import('./layout'); + const { state } = await import('../state/state'); + state.hasRepo = true; + state.ahead = 0; + state.behind = 0; + const el = document.getElementById('ahead-behind') as HTMLElement; + bindLayoutActionState(); + await new Promise((r) => setTimeout(r, 0)); + expect(el.textContent).toBe(''); + }); +}); + +// --------------------------------------------------------------------------- +// initResizer +// --------------------------------------------------------------------------- + +describe('initResizer', () => { + beforeEach(() => { + vi.resetModules(); + Object.defineProperty(globalThis, 'matchMedia', { + value: () => ({ matches: false, addEventListener: vi.fn(), removeEventListener: vi.fn(), addListener: vi.fn(), removeListener: vi.fn() }), + configurable: true, + writable: true, + }); + document.body.innerHTML = ` +
      +
      + `; + }); + + it('initializes resizer without crashing', async () => { + const { initResizer } = await import('./layout'); + expect(() => initResizer()).not.toThrow(); + }); + + it('handles mousedown on resizer', async () => { + const { initResizer } = await import('./layout'); + initResizer(); + const resizer = document.getElementById('resizer') as HTMLElement; + resizer.dispatchEvent(new MouseEvent('mousedown', { clientX: 500 })); + }); + + it('does not crash when workGrid or resizer missing', async () => { + document.body.innerHTML = ''; + const { initResizer } = await import('./layout'); + expect(() => initResizer()).not.toThrow(); + }); +}); + +// --------------------------------------------------------------------------- +// applyCommitSummaryRestriction - no element +// --------------------------------------------------------------------------- + +describe('applyCommitSummaryRestriction (no element)', () => { + beforeEach(() => { + vi.resetModules(); + document.body.innerHTML = ''; + }); + + it('does not crash when summary input is missing', async () => { + const { applyCommitSummaryRestriction } = await import('./layout'); + expect(() => applyCommitSummaryRestriction(true)).not.toThrow(); + }); +}); + +// --------------------------------------------------------------------------- +// toggleTheme - additional +// --------------------------------------------------------------------------- + +describe('toggleTheme additional', () => { + beforeEach(() => { + vi.resetModules(); + Object.defineProperty(globalThis, 'matchMedia', { + value: () => ({ matches: false, addEventListener: vi.fn(), removeEventListener: vi.fn(), addListener: vi.fn(), removeListener: vi.fn() }), + configurable: true, + writable: true, + }); + (window as any).__TAURI__ = { + core: { invoke: vi.fn() }, + event: { listen: vi.fn() }, + }; + }); + + it('toggles from dark to light', async () => { + const tauri = (window as any).__TAURI__; + tauri.core.invoke.mockResolvedValue({ general: { theme: 'dark' } }); + + const { toggleTheme, setTheme } = await import('./layout'); + setTheme('dark'); + + toggleTheme(); + + await vi.waitFor(() => { + expect(document.documentElement.getAttribute('data-theme')).toBe('light'); + }); + }); +}); + +// --------------------------------------------------------------------------- +// setTab - additional edge cases +// --------------------------------------------------------------------------- + +describe('setTab additional', () => { + beforeEach(() => { + vi.resetModules(); + document.body.innerHTML = ` +
      + + + +
      +
      + + `; + }); + + it('clears selectedCommit when leaving history tab', async () => { + const { setTab } = await import('./layout'); + const { state } = await import('../state/state'); + state.selectedCommit = { id: 'abc123' }; + setTab('history'); // select history + setTab('changes'); // leave history + expect(state.selectedCommit).toBeNull(); + }); + + it('sets diffDirty when entering changes from other tab', async () => { + const { setTab } = await import('./layout'); + const { state } = await import('../state/state'); + state.selectedCommit = null; + state.diffDirty = false; + setTab('history'); + setTab('changes'); + expect(state.diffDirty).toBe(true); + }); + + it('hides history actions btn when leaving history tab', async () => { + const { setTab } = await import('./layout'); + const btn = document.getElementById('history-actions-btn') as HTMLButtonElement; + btn.hidden = false; + setTab('changes'); + expect(btn.hidden).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// refreshRepoActions - push button with ahead +// --------------------------------------------------------------------------- + +describe('refreshRepoActions (push ahead badge)', () => { + beforeEach(() => { + vi.resetModules(); + Object.defineProperty(globalThis, 'matchMedia', { + value: () => ({ matches: false, addEventListener: vi.fn(), removeEventListener: vi.fn(), addListener: vi.fn(), removeListener: vi.fn() }), + configurable: true, + writable: true, + }); + document.body.innerHTML = ` +
      + + + +
      + + `; + }); + + it('shows ahead count in push button label', async () => { + const { refreshRepoActions } = await import('./layout'); + const { state } = await import('../state/state'); + const pushBtn = document.getElementById('push-btn') as HTMLButtonElement; + const label = pushBtn.querySelector('.btn-label') as HTMLSpanElement; + + state.hasRepo = true; + state.ahead = 3; + state.branchOnRemote = true; + + refreshRepoActions(); + + expect(pushBtn.classList.contains('attention')).toBe(true); + expect(label.textContent).toBe('Push (3)'); + expect(pushBtn.title).toBe('Push (3)'); + }); + + it('shows undo button when repo has ahead and on changes tab', async () => { + const { refreshRepoActions } = await import('./layout'); + const { state } = await import('../state/state'); + const { prefs } = await import('../state/state'); + + state.hasRepo = true; + state.ahead = 2; + prefs.tab = 'changes'; + + refreshRepoActions(); + + const undoLeftWrap = document.getElementById('left-foot') as HTMLElement; + expect(undoLeftWrap.classList.contains('show')).toBe(true); + }); +}); + +describe('initResizer', () => { + beforeEach(() => { + vi.resetModules(); + mountLayoutDom(); + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('starts drag, moves, and stops on mouseup', async () => { + const { initResizer } = await import('./layout'); + initResizer(); + + const resizer = document.getElementById('resizer') as HTMLElement; + const grid = document.querySelector('.work') as HTMLElement; + grid.getBoundingClientRect = vi.fn(() => ({ width: 1200 }) as DOMRect); + + resizer.dispatchEvent(new MouseEvent('mousedown', { clientX: 400 })); + window.dispatchEvent(new MouseEvent('mousemove', { clientX: 450 })); + + const cols = grid.style.gridTemplateColumns; + expect(cols).toContain('px'); + + window.dispatchEvent(new MouseEvent('mouseup')); + expect(document.body.style.cursor).toBe(''); + }); + + it('adjusts columns on window resize when not stacked', async () => { + const { initResizer } = await import('./layout'); + initResizer(); + + const grid = document.querySelector('.work') as HTMLElement; + grid.getBoundingClientRect = vi.fn(() => ({ width: 1200 }) as DOMRect); + Object.defineProperty(window, 'matchMedia', { + value: vi.fn().mockReturnValue({ matches: false }), + configurable: true, + }); + + // Set initial state by triggering mousedown+move+up + const resizer = document.getElementById('resizer') as HTMLElement; + resizer.dispatchEvent(new MouseEvent('mousedown', { clientX: 400 })); + window.dispatchEvent(new MouseEvent('mousemove', { clientX: 500 })); + window.dispatchEvent(new MouseEvent('mouseup')); + + // Now resize + window.dispatchEvent(new Event('resize')); + expect(grid.style.gridTemplateColumns).not.toBe(''); + }); + + it('clears template columns when stacked on resize', async () => { + const { initResizer } = await import('./layout'); + Object.defineProperty(window, 'matchMedia', { + value: vi.fn().mockReturnValue({ matches: true }), + configurable: true, + }); + initResizer(); + + const grid = document.querySelector('.work') as HTMLElement; + grid.style.gridTemplateColumns = '400px 6px 1fr'; + + window.dispatchEvent(new Event('resize')); + expect(grid.style.gridTemplateColumns).toBe(''); + }); +}); + +describe('setRepoHeader with missing elements', () => { + beforeEach(() => { + vi.resetModules(); + document.body.innerHTML = ''; + }); + + it('does not crash when repo-title and repo-branch are missing', async () => { + const { setRepoHeader, resetRepoHeader } = await import('./layout'); + expect(() => setRepoHeader('/some/path')).not.toThrow(); + expect(() => resetRepoHeader()).not.toThrow(); + }); +}); + +describe('renderAheadBehind with missing element', () => { + beforeEach(() => { + vi.resetModules(); + Object.defineProperty(globalThis, 'matchMedia', { + value: () => ({ matches: false, addEventListener: vi.fn(), removeEventListener: vi.fn(), addListener: vi.fn(), removeListener: vi.fn() }), + configurable: true, + writable: true, + }); + document.body.innerHTML = ''; + }); + + it('does not crash when ahead-behind element is missing', async () => { + const { bindLayoutActionState } = await import('./layout'); + expect(() => bindLayoutActionState()).not.toThrow(); + }); +}); diff --git a/Frontend/src/scripts/ui/menubar.test.ts b/Frontend/src/scripts/ui/menubar.test.ts new file mode 100644 index 00000000..7c0a2715 --- /dev/null +++ b/Frontend/src/scripts/ui/menubar.test.ts @@ -0,0 +1,521 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../lib/tauri', () => ({ + TAURI: { + invoke: vi.fn(), + }, + isTauriRuntimeAvailable: vi.fn(() => true), +})); + +function mountMenubar() { + document.body.innerHTML = ` + + `; +} + +beforeEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + vi.useFakeTimers(); + mountMenubar(); + window.matchMedia = vi.fn().mockReturnValue({ + matches: true, + media: '(prefers-reduced-motion: reduce)', + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + }) as typeof window.matchMedia; +}); + +describe('refreshPluginMenubarMenus', () => { + it('injects plugin buttons and skips duplicate or non-menubar entries', async () => { + const { TAURI } = await import('../lib/tauri'); + vi.mocked(TAURI.invoke).mockResolvedValue([ + { + plugin_id: 'plugin.alpha', + id: 'file', + label: 'File', + surface: 'menubar', + elements: [ + { type: 'button', id: 'plugin-open', label: 'Plugin Open' }, + { type: 'button', id: 'open-repo', label: 'Duplicate Open' }, + { type: 'text', content: 'skip me' }, + ], + }, + { + plugin_id: 'plugin.beta', + id: 'view', + label: 'View', + surface: 'settings', + elements: [{ type: 'button', id: 'settings-only', label: 'Settings only' }], + }, + ]); + + const { refreshPluginMenubarMenus } = await import('./menubar'); + await refreshPluginMenubarMenus(); + + const injected = document.querySelectorAll('[data-plugin-menubar="true"]'); + expect(injected).toHaveLength(2); + const pluginButton = document.querySelector('[data-plugin-action="plugin-open"]') as HTMLButtonElement; + expect(pluginButton.textContent).toBe('Plugin Open'); + expect(document.querySelectorAll('[data-plugin-action="open-repo"]')).toHaveLength(0); + expect(document.querySelector('[data-plugin-action="settings-only"]')).toBeNull(); + }); + + it('clears prior plugin menu entries before re-rendering', async () => { + const { TAURI } = await import('../lib/tauri'); + vi.mocked(TAURI.invoke).mockResolvedValue([]); + document.querySelector('.menu-list')?.insertAdjacentHTML( + 'beforeend', + '', + ); + + const { refreshPluginMenubarMenus } = await import('./menubar'); + await refreshPluginMenubarMenus(); + + expect(document.querySelector('[data-plugin-menubar="true"]')).toBeNull(); + }); + + it('returns quietly when runtime is unavailable or invoke fails', async () => { + const tauri = await import('../lib/tauri'); + vi.mocked(tauri.isTauriRuntimeAvailable).mockReturnValueOnce(false); + + const { refreshPluginMenubarMenus } = await import('./menubar'); + await expect(refreshPluginMenubarMenus()).resolves.toBeUndefined(); + + vi.mocked(tauri.isTauriRuntimeAvailable).mockReturnValueOnce(true); + vi.mocked(tauri.TAURI.invoke).mockRejectedValueOnce(new Error('boom')); + await expect(refreshPluginMenubarMenus()).resolves.toBeUndefined(); + }); +}); + +describe('initMenubar', () => { + it('opens, dispatches menu actions, and closes on escape', async () => { + const onAction = vi.fn(); + const { initMenubar } = await import('./menubar'); + initMenubar(onAction); + + const trigger = document.querySelector('.menu-trigger') as HTMLButtonElement; + trigger.click(); + + const menuList = document.querySelector('.menu-list') as HTMLElement; + expect(menuList.hasAttribute('hidden')).toBe(false); + expect(trigger.getAttribute('aria-expanded')).toBe('true'); + + (document.querySelector('[data-action="open-repo"]') as HTMLButtonElement).click(); + await Promise.resolve(); + + expect(onAction).toHaveBeenCalledWith('open-repo'); + + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); + vi.runAllTimers(); + + expect(menuList.getAttribute('hidden')).toBe(''); + expect(trigger.getAttribute('aria-expanded')).toBe('false'); + }); + + it('routes plugin actions with plugin metadata', async () => { + const { TAURI } = await import('../lib/tauri'); + vi.mocked(TAURI.invoke).mockResolvedValue([ + { + plugin_id: 'plugin.alpha', + id: 'file', + label: 'File', + surface: 'menubar', + elements: [{ type: 'button', id: 'plugin-open', label: 'Plugin Open' }], + }, + ]); + const { initMenubar, refreshPluginMenubarMenus } = await import('./menubar'); + const onAction = vi.fn(); + + await refreshPluginMenubarMenus(); + initMenubar(onAction); + + (document.querySelector('.menu-trigger') as HTMLButtonElement).click(); + (document.querySelector('[data-plugin-action="plugin-open"]') as HTMLButtonElement).click(); + await Promise.resolve(); + + expect(onAction).toHaveBeenCalledWith('__plugin_menu_action__', { + pluginId: 'plugin.alpha', + actionId: 'plugin-open', + }); + }); + + it('switches menus on pointerover while another menu is open', async () => { + const { initMenubar } = await import('./menubar'); + initMenubar(vi.fn()); + + const triggers = document.querySelectorAll('.menu-trigger'); + (triggers[0] as HTMLButtonElement).click(); + triggers[1].dispatchEvent(new PointerEvent('pointerover', { bubbles: true })); + + const lists = document.querySelectorAll('.menu-list'); + expect((lists[0] as HTMLElement).getAttribute('hidden')).toBe(''); + expect((lists[1] as HTMLElement).hasAttribute('hidden')).toBe(false); + }); + + it('toggles the same menu closed when its trigger is clicked twice', async () => { + const { initMenubar } = await import('./menubar'); + initMenubar(vi.fn()); + + const trigger = document.querySelector('.menu-trigger') as HTMLButtonElement; + trigger.click(); + trigger.click(); + vi.runAllTimers(); + + expect(document.querySelector('.menu-list')?.getAttribute('hidden')).toBe(''); + expect(trigger.getAttribute('aria-expanded')).toBe('false'); + }); + + it('closes an open menu when clicking outside the menubar', async () => { + const { initMenubar } = await import('./menubar'); + initMenubar(vi.fn()); + + const trigger = document.querySelector('.menu-trigger') as HTMLButtonElement; + trigger.click(); + document.body.dispatchEvent(new MouseEvent('click', { bubbles: true })); + vi.runAllTimers(); + + expect(document.querySelector('.menu-list')?.getAttribute('hidden')).toBe(''); + expect(trigger.getAttribute('aria-expanded')).toBe('false'); + }); + + it('cancels pending close animation when opening another menu', async () => { + const { initMenubar } = await import('./menubar'); + window.matchMedia = vi.fn().mockReturnValue({ + matches: false, + media: '(prefers-reduced-motion: reduce)', + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + }) as typeof window.matchMedia; + initMenubar(vi.fn()); + + const triggers = document.querySelectorAll('.menu-trigger'); + // Open first menu + (triggers[0] as HTMLButtonElement).click(); + // Close by clicking outside (sets closeTimer with setTimeout) + document.body.dispatchEvent(new MouseEvent('click', { bubbles: true })); + // Before the close animation completes, open the second menu + triggers[1].dispatchEvent(new PointerEvent('pointerover', { bubbles: true })); + + expect((document.querySelectorAll('.menu-list')[1] as HTMLElement).hasAttribute('hidden')).toBe(false); + vi.runAllTimers(); + }); + + it('calls finalizeClose immediately when list is already hidden', async () => { + const { initMenubar } = await import('./menubar'); + initMenubar(vi.fn()); + + const trigger = document.querySelector('.menu-trigger') as HTMLButtonElement; + trigger.click(); + const list = document.querySelector('.menu-list') as HTMLElement; + // Force close and set hidden to simulate already-hidden state + list.setAttribute('hidden', ''); + document.body.dispatchEvent(new MouseEvent('click', { bubbles: true })); + vi.runAllTimers(); + expect(trigger.getAttribute('aria-expanded')).toBe('false'); + }); + + it('completes close animation with timer under normal motion', async () => { + window.matchMedia = vi.fn().mockReturnValue({ + matches: false, + media: '(prefers-reduced-motion: reduce)', + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + }) as typeof window.matchMedia; + + const { initMenubar } = await import('./menubar'); + initMenubar(vi.fn()); + + const trigger = document.querySelector('.menu-trigger') as HTMLButtonElement; + trigger.click(); + document.body.dispatchEvent(new MouseEvent('click', { bubbles: true })); + vi.runAllTimers(); + + expect(trigger.getAttribute('aria-expanded')).toBe('false'); + }); + + it('clears pending close timer when menu is reopened before animation ends', async () => { + window.matchMedia = vi.fn().mockReturnValue({ + matches: false, + media: '(prefers-reduced-motion: reduce)', + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + }) as typeof window.matchMedia; + + const { initMenubar } = await import('./menubar'); + initMenubar(vi.fn()); + + const triggers = document.querySelectorAll('.menu-trigger'); + (triggers[0] as HTMLButtonElement).click(); + // Close via outside click (starts timer under normal motion) + document.body.dispatchEvent(new MouseEvent('click', { bubbles: true })); + // Immediately reopen another menu before timer fires + triggers[1].dispatchEvent(new PointerEvent('pointerover', { bubbles: true })); + + expect((triggers[1] as HTMLButtonElement).getAttribute('aria-expanded')).toBe('true'); + expect((triggers[0] as HTMLButtonElement).getAttribute('aria-expanded')).toBe('false'); + vi.runAllTimers(); + }); +}); + +// ============================================================================ +// getMenuList internal behavior - querySelector returns null +// ============================================================================ +describe('getMenuList returns null for unmatched menu id', () => { + it('skips plugin menu when the menu id has no matching DOM node', async () => { + const { TAURI } = await import('../lib/tauri'); + vi.mocked(TAURI.invoke).mockResolvedValue([ + { + plugin_id: 'plugin.test', + id: 'nonexistent-menu', + label: 'No DOM Match', + surface: 'menubar', + elements: [{ type: 'button', id: 'some-action', label: 'Some Action' }], + }, + ]); + + const { refreshPluginMenubarMenus } = await import('./menubar'); + await refreshPluginMenubarMenus(); + + expect(document.querySelector('[data-plugin-menubar="true"]')).toBeNull(); + }); +}); + +// ============================================================================ +// clearPluginMenubarMenus - no matching elements +// ============================================================================ +describe('clearPluginMenubarMenus', () => { + it('does nothing when no plugin menubar elements exist', async () => { + const { clearPluginMenubarMenus } = await import('./menubar'); + expect(() => clearPluginMenubarMenus()).not.toThrow(); + expect(document.querySelectorAll('[data-plugin-menubar="true"]')).toHaveLength(0); + }); +}); + +// ============================================================================ +// refreshPluginMenubarMenus - additional edge cases +// ============================================================================ +describe('refreshPluginMenubarMenus additional edge cases', () => { + it('skips menu with empty id', async () => { + const { TAURI } = await import('../lib/tauri'); + vi.mocked(TAURI.invoke).mockResolvedValue([ + { + plugin_id: 'plugin.test', + id: '', + label: 'No ID', + surface: 'menubar', + elements: [{ type: 'button', id: 'btn', label: 'Btn' }], + }, + ]); + + const { refreshPluginMenubarMenus } = await import('./menubar'); + await refreshPluginMenubarMenus(); + + expect(document.querySelector('[data-plugin-menubar="true"]')).toBeNull(); + }); + + it('continues when menu has no button entries', async () => { + const { TAURI } = await import('../lib/tauri'); + vi.mocked(TAURI.invoke).mockResolvedValue([ + { + plugin_id: 'plugin.test', + id: 'file', + label: 'File', + surface: 'menubar', + elements: [{ type: 'text', content: 'Just info' }], + }, + ]); + + const { refreshPluginMenubarMenus } = await import('./menubar'); + await refreshPluginMenubarMenus(); + + expect(document.querySelector('[data-plugin-menubar="true"]')).toBeNull(); + }); + + it('handles null elements array gracefully', async () => { + const { TAURI } = await import('../lib/tauri'); + vi.mocked(TAURI.invoke).mockResolvedValue([ + { + plugin_id: 'plugin.test', + id: 'file', + label: 'File', + surface: 'menubar', + elements: null as any, + }, + ]); + + const { refreshPluginMenubarMenus } = await import('./menubar'); + await refreshPluginMenubarMenus(); + + expect(document.querySelector('[data-plugin-menubar="true"]')).toBeNull(); + }); + + it('skips button with empty label in render loop', async () => { + const { TAURI } = await import('../lib/tauri'); + vi.mocked(TAURI.invoke).mockResolvedValue([ + { + plugin_id: 'plugin.test', + id: 'file', + label: 'File', + surface: 'menubar', + elements: [ + { type: 'button', id: 'no-label', label: '' }, + { type: 'button', id: 'has-label', label: 'Labeled' }, + ], + }, + ]); + + const { refreshPluginMenubarMenus } = await import('./menubar'); + await refreshPluginMenubarMenus(); + + // Separator + valid button + expect(document.querySelectorAll('[data-plugin-menubar="true"]')).toHaveLength(2); + expect(document.querySelector('[data-plugin-action="no-label"]')).toBeNull(); + expect(document.querySelector('[data-plugin-action="has-label"]')).not.toBeNull(); + }); +}); + +// ============================================================================ +// initMenubar - additional edge cases +// ============================================================================ +describe('initMenubar additional edge cases', () => { + it('does nothing when root .menubar element is missing', async () => { + document.body.innerHTML = ''; + const { initMenubar } = await import('./menubar'); + expect(() => initMenubar(vi.fn())).not.toThrow(); + }); + + it('handles escape keydown when no menu is open', async () => { + const { initMenubar } = await import('./menubar'); + initMenubar(vi.fn()); + expect(() => { + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); + }).not.toThrow(); + }); + + it('handles document click when no menu is open', async () => { + const { initMenubar } = await import('./menubar'); + initMenubar(vi.fn()); + expect(() => { + document.body.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }).not.toThrow(); + }); + + it('ignores pointerover on trigger when no menu is open', async () => { + const { initMenubar } = await import('./menubar'); + initMenubar(vi.fn()); + + const trigger = document.querySelector('.menu-trigger') as HTMLElement; + trigger.dispatchEvent(new PointerEvent('pointerover', { bubbles: true })); + + expect(document.querySelector('.menu-list')?.hasAttribute('hidden')).toBe(true); + }); + + it('returns early from open() when menu has no list', async () => { + const menubar = document.querySelector('.menubar') as HTMLElement; + menubar.insertAdjacentHTML('beforeend', ` + + `); + + const { initMenubar } = await import('./menubar'); + initMenubar(vi.fn()); + + const triggers = document.querySelectorAll('.menu-trigger'); + const brokenTrigger = triggers[triggers.length - 1] as HTMLButtonElement; + brokenTrigger.click(); + // Should not throw — open() returns early when list is null + expect(true).toBe(true); + }); + + it('ignores click on menubar area that is neither item nor trigger', async () => { + const { initMenubar } = await import('./menubar'); + initMenubar(vi.fn()); + + const menubar = document.querySelector('.menubar') as HTMLElement; + menubar.dispatchEvent(new MouseEvent('click', { bubbles: true })); + + expect(document.querySelector('.menu-list')?.hasAttribute('hidden')).toBe(true); + }); +}); + +// ============================================================================ +// closeMenu animation timer execution +// ============================================================================ +describe('closeMenu animation timer', () => { + it('runs close animation timer and finalizes', async () => { + window.matchMedia = vi.fn().mockReturnValue({ + matches: false, + media: '(prefers-reduced-motion: reduce)', + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + }) as typeof window.matchMedia; + + const { initMenubar } = await import('./menubar'); + initMenubar(vi.fn()); + + const trigger = document.querySelector('.menu-trigger') as HTMLButtonElement; + trigger.click(); + document.body.dispatchEvent(new MouseEvent('click', { bubbles: true })); + + vi.runAllTimers(); + expect(trigger.getAttribute('aria-expanded')).toBe('false'); + }); + + it('clears closeTimer when menu reopened before animation completion', async () => { + window.matchMedia = vi.fn().mockReturnValue({ + matches: false, + media: '(prefers-reduced-motion: reduce)', + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + }) as typeof window.matchMedia; + + const { initMenubar } = await import('./menubar'); + initMenubar(vi.fn()); + + const triggers = document.querySelectorAll('.menu-trigger'); + (triggers[0] as HTMLButtonElement).click(); + document.body.dispatchEvent(new MouseEvent('click', { bubbles: true })); + + const list = document.querySelector('.menu-list') as HTMLElement; + expect(list.classList.contains('is-closing')).toBe(true); + + triggers[1].dispatchEvent(new PointerEvent('pointerover', { bubbles: true })); + expect(list.hasAttribute('hidden')).toBe(true); + vi.runAllTimers(); + }); +}); diff --git a/Frontend/src/scripts/ui/modals.test.ts b/Frontend/src/scripts/ui/modals.test.ts new file mode 100644 index 00000000..5d0f3d3f --- /dev/null +++ b/Frontend/src/scripts/ui/modals.test.ts @@ -0,0 +1,431 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../lib/scrollbars', () => ({ + initOverlayScrollbarsFor: vi.fn(), + refreshOverlayScrollbarsFor: vi.fn(), +})); + +function mountRoot() { + document.body.innerHTML = '
      '; +} + +describe('hydrate', () => { + beforeEach(() => { + vi.resetModules(); + document.body.innerHTML = ''; + }); + + it('adds existing element id to loaded set', async () => { + document.body.innerHTML = '
      '; + const { hydrate } = await import('./modals'); + expect(() => hydrate('existing-modal')).not.toThrow(); + }); + + it('throws when no fragment is registered for unknown id', async () => { + mountRoot(); + const { hydrate } = await import('./modals'); + expect(() => hydrate('unknown-modal')).toThrow('No fragment registered for unknown-modal'); + }); + + it('does nothing when root is missing', async () => { + const { hydrate } = await import('./modals'); + expect(() => hydrate('settings-modal')).not.toThrow(); + }); +}); + +describe('openModal', () => { + beforeEach(() => { + vi.resetModules(); + mountRoot(); + document.body.style.overflow = ''; + }); + + it('opens a modal by setting aria-hidden to false', async () => { + const { openModal } = await import('./modals'); + document.body.innerHTML += ''; + + openModal('test-modal'); + + const modal = document.getElementById('test-modal'); + expect(modal?.getAttribute('aria-hidden')).toBe('false'); + }); + + it('locks scroll when opening', async () => { + const { openModal } = await import('./modals'); + document.body.innerHTML += ''; + + openModal('test-modal'); + expect(document.body.style.overflow).toBe('hidden'); + }); + + it('wires click-to-close on first open', async () => { + vi.useFakeTimers(); + const { openModal, closeModal } = await import('./modals'); + document.body.innerHTML += ''; + + openModal('test-modal'); + const modal = document.getElementById('test-modal')!; + + const backdrop = modal.querySelector('.backdrop') as HTMLElement; + backdrop.click(); + + vi.advanceTimersByTime(200); + + expect(modal.getAttribute('aria-hidden')).toBe('true'); + vi.useRealTimers(); + }); + + it('does not re-wire close handler on subsequent opens', async () => { + const { openModal, closeModal } = await import('./modals'); + document.body.innerHTML += ''; + + openModal('test-modal'); + closeModal('test-modal'); + openModal('test-modal'); + + const modal = document.getElementById('test-modal')!; + expect(modal.getAttribute('aria-hidden')).toBe('false'); + }); +}); + +describe('closeModal', () => { + beforeEach(() => { + vi.resetModules(); + mountRoot(); + document.body.style.overflow = 'hidden'; + }); + + it('sets aria-hidden to true and unlocks scroll', async () => { + const { closeModal } = await import('./modals'); + document.body.innerHTML += ''; + + closeModal('test-modal'); + + const modal = document.getElementById('test-modal'); + expect(modal?.getAttribute('aria-hidden')).toBe('true'); + expect(document.body.style.overflow).toBe(''); + }); + + it('does nothing when modal is not found', async () => { + const { closeModal } = await import('./modals'); + expect(() => closeModal('nonexistent')).not.toThrow(); + }); +}); + +describe('closeAllModals', () => { + beforeEach(() => { + vi.resetModules(); + mountRoot(); + document.body.style.overflow = 'hidden'; + }); + + it('closes all open modals and resets scroll lock', async () => { + const { closeAllModals } = await import('./modals'); + document.body.innerHTML += ` + + + `; + + closeAllModals(); + + expect(document.getElementById('modal1')?.getAttribute('aria-hidden')).toBe('true'); + expect(document.getElementById('modal2')?.getAttribute('aria-hidden')).toBe('true'); + expect(document.body.style.overflow).toBe(''); + }); +}); + +describe('declarative opener (data-modal-open)', () => { + beforeEach(() => { + vi.resetModules(); + mountRoot(); + }); + + it('opens a modal when a data-modal-open element is clicked', async () => { + document.body.innerHTML = ` + + + `; + + await import('./modals'); + + const btn = document.querySelector('[data-modal-open]') as HTMLElement; + btn.click(); + + const modal = document.getElementById('test-modal'); + expect(modal?.getAttribute('aria-hidden')).toBe('false'); + }); +}); + +describe('ESC key closes top modal', () => { + beforeEach(() => { + vi.resetModules(); + mountRoot(); + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('closes the top-most open modal on Escape keydown', async () => { + document.body.innerHTML = ` + + `; + + await import('./modals'); + + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); + vi.advanceTimersByTime(200); + + const modal = document.getElementById('modal1'); + expect(modal?.getAttribute('aria-hidden')).toBe('true'); + vi.useRealTimers(); + }); + + it('ignores non-Escape keys', async () => { + const { closeModal } = await import('./modals'); + document.body.innerHTML = ` + + `; + + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' })); + // Modal should remain open + expect(document.getElementById('modal1')?.getAttribute('aria-hidden')).toBe('false'); + }); +}); + +describe('scroll lock counting', () => { + beforeEach(() => { + vi.resetModules(); + mountRoot(); + document.body.style.overflow = ''; + }); + + it('supports multiple open modals', async () => { + const { openModal, closeModal } = await import('./modals'); + document.body.innerHTML += ` + + + `; + + openModal('m1'); + expect(document.body.style.overflow).toBe('hidden'); + + openModal('m2'); + expect(document.body.style.overflow).toBe('hidden'); + + closeModal('m1'); + expect(document.body.style.overflow).toBe('hidden'); + + closeModal('m2'); + expect(document.body.style.overflow).toBe(''); + }); +}); + +// --------------------------------------------------------------------------- +// closeWithAnimation +// --------------------------------------------------------------------------- + +describe('closeWithAnimation', () => { + beforeEach(() => { + vi.resetModules(); + mountRoot(); + document.body.style.overflow = 'hidden'; + }); + + it('closes modal via backdrop with animation', async () => { + vi.useFakeTimers(); + + const { openModal } = await import('./modals'); + document.body.innerHTML += ''; + + openModal('m1'); + const modal = document.getElementById('m1') as HTMLElement; + expect(modal.getAttribute('aria-hidden')).toBe('false'); + + const backdrop = modal.querySelector('.backdrop') as HTMLElement; + backdrop.click(); + expect(modal.classList.contains('is-closing')).toBe(true); + + vi.advanceTimersByTime(200); + expect(modal.getAttribute('aria-hidden')).toBe('true'); + expect(modal.classList.contains('is-closing')).toBe(false); + + vi.useRealTimers(); + }); +}); + +// --------------------------------------------------------------------------- +// closeAllModals with animation timer +// --------------------------------------------------------------------------- + +describe('closeAllModals with animation timer', () => { + beforeEach(() => { + vi.resetModules(); + mountRoot(); + document.body.style.overflow = 'hidden'; + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('clears animation timers when closing all modals', async () => { + vi.useFakeTimers(); + + const { openModal, closeAllModals } = await import('./modals'); + document.body.innerHTML += ''; + openModal('m1'); + + const modal = document.getElementById('m1') as HTMLElement; + const backdrop = modal.querySelector('.backdrop') as HTMLElement; + backdrop.click(); + + closeAllModals(); + + expect(modal.getAttribute('aria-hidden')).toBe('true'); + expect(modal.classList.contains('is-closing')).toBe(false); + expect(document.body.style.overflow).toBe(''); + }); +}); + +// --------------------------------------------------------------------------- +// openModal with no aria-hidden +// --------------------------------------------------------------------------- + +describe('openModal no aria-hidden', () => { + beforeEach(() => { + vi.resetModules(); + mountRoot(); + document.body.style.overflow = ''; + }); + + it('handles modal without aria-hidden attribute', async () => { + const { openModal } = await import('./modals'); + document.body.innerHTML += ''; + + openModal('m1'); + const modal = document.getElementById('m1') as HTMLElement; + expect(modal.getAttribute('aria-hidden')).toBe('false'); + }); +}); + +// --------------------------------------------------------------------------- +// hydrate - with already-in-DOM modal +// --------------------------------------------------------------------------- + +describe('closeWithAnimation reduce-motion', () => { + beforeEach(() => { vi.resetModules(); mountRoot(); }); + afterEach(() => { vi.useRealTimers(); }); + + it('closes without animation when reduce-motion preferred', async () => { + vi.useFakeTimers(); + const origMM = window.matchMedia; + window.matchMedia = vi.fn((q: string) => ({ + matches: q.includes('reduced-motion'), media: q, addEventListener: vi.fn(), removeEventListener: vi.fn(), + })) as any; + const { openModal } = await import('./modals'); + document.body.innerHTML += ''; + openModal('m1'); + (document.querySelector('.backdrop') as HTMLElement).click(); + expect(document.getElementById('m1')!.getAttribute('aria-hidden')).toBe('true'); + window.matchMedia = origMM; vi.useRealTimers(); + }); +}); + +describe('openModal clears pending animation timer', () => { + beforeEach(() => { vi.resetModules(); mountRoot(); }); + afterEach(() => { vi.useRealTimers(); }); + + it('clears timer when reopening before close animation completes', async () => { + vi.useFakeTimers(); + const origMM = window.matchMedia; + window.matchMedia = vi.fn(() => ({ matches: false, addEventListener: vi.fn(), removeEventListener: vi.fn() })) as any; + const { openModal } = await import('./modals'); + document.body.innerHTML += ''; + openModal('m1'); + (document.querySelector('.backdrop') as HTMLElement).click(); + openModal('m1'); + expect(document.getElementById('m1')!.getAttribute('aria-hidden')).toBe('false'); + window.matchMedia = origMM; vi.useRealTimers(); + }); +}); + +describe('hydrate specific modals', () => { + beforeEach(() => { vi.resetModules(); mountRoot(); }); + it('hydrates settings-modal', async () => { const { hydrate } = await import('./modals'); expect(() => hydrate('settings-modal')).not.toThrow(); }); + it('hydrates about-modal', async () => { const { hydrate } = await import('./modals'); expect(() => hydrate('about-modal')).not.toThrow(); }); + it('hydrates update-modal', async () => { const { hydrate } = await import('./modals'); expect(() => hydrate('update-modal')).not.toThrow(); }); +}); + +describe('closeModal already closed', () => { + beforeEach(() => { vi.resetModules(); mountRoot(); }); + it('no-ops for already hidden modal', async () => { + const { closeModal } = await import('./modals'); + document.body.innerHTML += ''; + closeModal('m1'); + expect(document.getElementById('m1')!.getAttribute('aria-hidden')).toBe('true'); + }); +}); + +describe('closeAllModals no open modals', () => { + beforeEach(() => { vi.resetModules(); mountRoot(); }); + it('no-ops when no modals are open', async () => { + const { closeAllModals } = await import('./modals'); + expect(() => closeAllModals()).not.toThrow(); + }); +}); + +describe('hydrate with existing modal', () => { + beforeEach(() => { + vi.resetModules(); + mountRoot(); + }); + + it('adds id to loaded set when modal exists', async () => { + document.body.innerHTML += '
      '; + const { hydrate } = await import('./modals'); + expect(() => hydrate('existing-test-modal')).not.toThrow(); + // Second call should not throw either (idempotent) + expect(() => hydrate('existing-test-modal')).not.toThrow(); + }); + + it('re-throws for unknown id with root present', async () => { + const { hydrate } = await import('./modals'); + expect(() => hydrate('nothing-here')).toThrow('No fragment registered for nothing-here'); + }); +}); + +describe('hydrate already-loaded modal', () => { + beforeEach(() => { vi.resetModules(); mountRoot(); }); + + it('does nothing when modal id was already hydrated', async () => { + const { hydrate } = await import('./modals'); + hydrate('settings-modal'); + document.body.innerHTML = '
      '; + expect(() => hydrate('settings-modal')).not.toThrow(); + }); +}); + +describe('closeAllModals with existing timers', () => { + beforeEach(() => { vi.resetModules(); mountRoot(); }); + afterEach(() => { vi.useRealTimers(); }); + + it('clears pending animation timers before closing', async () => { + vi.useFakeTimers(); + const { openModal, closeAllModals } = await import('./modals'); + document.body.innerHTML += ''; + openModal('m1'); + + const modal = document.getElementById('m1') as HTMLElement; + (modal as any).__animatedCloseTimer = 12345; + + closeAllModals(); + expect(modal.getAttribute('aria-hidden')).toBe('true'); + expect(modal.classList.contains('is-closing')).toBe(false); + expect(document.body.style.overflow).toBe(''); + }); +}); diff --git a/Frontend/vitest.config.ts b/Frontend/vitest.config.ts index 4e00949c..3620776a 100644 --- a/Frontend/vitest.config.ts +++ b/Frontend/vitest.config.ts @@ -18,6 +18,18 @@ export default defineConfig({ setupFiles: ['./src/setupTests.ts'], coverage: { provider: 'v8', + exclude: [ + 'src/scripts/**/*.test.ts', + 'src/modals/**', + 'src/styles/**', + 'tests/**', + ], + thresholds: { + statements: 95, + branches: 95, + functions: 95, + lines: 95, + }, }, }, }) diff --git a/Justfile b/Justfile index 2a03bb9c..c23d4acb 100644 --- a/Justfile +++ b/Justfile @@ -24,6 +24,16 @@ test: cd Frontend && npm exec tsc -- -p tsconfig.json --noEmit cd Frontend && npx vitest run +coverage-frontend: + npm --prefix Frontend run coverage + +coverage-backend: + cargo llvm-cov --workspace --lcov --output-path target/llvm-cov-backend.info --fail-under-lines 95 + +coverage: + just coverage-backend + just coverage-frontend + tauri-build channel="stable": FRONTEND_SKIP_BUILD=1 NO_STRIP=true OPENVCS_UPDATE_CHANNEL={{channel}} node scripts/tauri-build.js diff --git a/README.md b/README.md index 7a8dcf06..73ff7c42 100644 --- a/README.md +++ b/README.md @@ -348,6 +348,9 @@ For local Flatpak builds, clone `Open-VCS/flathub` as a sibling directory and ru | Command | Runs | | ------------------------------------------------------------- | ---------------------------------------------------- | | just test | Full project test/check flow | +| just coverage | Backend Rust coverage gate plus frontend Vitest coverage | +| just coverage-backend | Rust workspace coverage with `cargo llvm-cov` and a 95% line gate | +| just coverage-frontend | Frontend Vitest coverage with existing 95% thresholds | | just fix | Formatting, Clippy fixes, and frontend type checking | | cargo fmt --all | Rust formatting | | cargo fmt --all -- --check | CI formatting check | @@ -355,6 +358,26 @@ For local Flatpak builds, clone `Open-VCS/flathub` as a sibling directory and ru | npm --prefix Frontend exec tsc -- -p tsconfig.json --noEmit | Frontend type checking | | npm --prefix Frontend test | Frontend tests | +### Coverage tooling + +Backend coverage uses [`cargo-llvm-cov`](https://github.com/taiki-e/cargo-llvm-cov), which wraps Rust's source-based coverage instrumentation from the Rust compiler docs. + +Install the tool once: + +```bash +cargo install cargo-llvm-cov +``` + +Then run coverage from the repository root: + +```bash +just coverage-backend +just coverage-frontend +just coverage +``` + +`just coverage-backend` writes an LCOV artifact to `target/llvm-cov-backend.info` and fails if backend line coverage drops below 95%. Frontend coverage continues to use the Vitest thresholds configured in `Frontend/vitest.config.ts`. + ### just test includes | Step | Purpose |