diff --git a/Backend/src/logging.rs b/Backend/src/logging.rs index 0171ed33..ffb2b948 100644 --- a/Backend/src/logging.rs +++ b/Backend/src/logging.rs @@ -337,7 +337,12 @@ pub fn init() { let dir = crate::app_identity::project_dirs() .map(|pd| pd.data_dir().join("logs")) .unwrap_or_else(|| std::path::PathBuf::from("logs")); - let _ = fs::create_dir_all(&dir); // best effort + if let Err(e) = fs::create_dir_all(&dir) { + log::warn!( + "logging: failed to create log directory '{}': {e}", + dir.display() + ); + } rotate_existing_log(&dir); prune_archives(&dir, cfg.logging.retain_archives as usize); diff --git a/Backend/src/plugin_bundles.rs b/Backend/src/plugin_bundles.rs index 951ed40e..147e63ac 100644 --- a/Backend/src/plugin_bundles.rs +++ b/Backend/src/plugin_bundles.rs @@ -270,10 +270,15 @@ fn copy_directory_recursive(source: &Path, dest: &Path) -> Result<(), String> { #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; - let _ = fs::set_permissions( + if let Err(e) = fs::set_permissions( &dest_path, fs::Permissions::from_mode(metadata.permissions().mode()), - ); + ) { + warn!( + "copy_recursive: failed to set permissions on '{}': {e}", + dest_path.display() + ); + } } } } @@ -452,7 +457,12 @@ impl PluginBundleStore { plugin_dir.display() ) })?; - let _ = fs::remove_dir_all(&staging); + if let Err(e) = fs::remove_dir_all(&staging) { + warn!( + "install_plugin_dir: failed to remove staging directory '{}': {e}", + staging.display() + ); + } write_plugin_source_metadata(&plugin_dir, source_metadata)?; let approval = if auto_approve { diff --git a/Backend/src/plugin_vcs_backends.rs b/Backend/src/plugin_vcs_backends.rs index dad3f459..20a98f33 100644 --- a/Backend/src/plugin_vcs_backends.rs +++ b/Backend/src/plugin_vcs_backends.rs @@ -12,10 +12,37 @@ use crate::plugin_runtime::{PluginRuntimeManager, vcs_proxy::PluginVcsProxy}; use crate::settings::AppConfig; use log::{debug, error, info, trace, warn}; use std::collections::BTreeMap; -use std::{path::Path, sync::Arc}; +use std::path::Path; +use std::sync::{Arc, OnceLock, RwLock}; const MODULE: &str = "plugin_vcs_backends"; +static BACKEND_CACHE: OnceLock>>> = OnceLock::new(); + +fn backend_cache() -> &'static RwLock>> { + BACKEND_CACHE.get_or_init(|| RwLock::new(None)) +} + +fn cached_backends() -> Option> { + backend_cache() + .read() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .clone() +} + +fn store_backends(backends: Vec) { + *backend_cache() + .write() + .unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(backends); +} + +/// Clears cached VCS backend discovery results. +pub fn invalidate_plugin_vcs_backend_cache() { + *backend_cache() + .write() + .unwrap_or_else(|poisoned| poisoned.into_inner()) = None; +} + /// Returns plugin-scoped open config for a VCS backend plugin. fn plugin_open_config(plugin_id: &str) -> serde_json::Value { serde_json::Value::Object(settings_store::load_settings(plugin_id).unwrap_or_default()) @@ -72,6 +99,20 @@ pub fn list_plugin_vcs_backends() -> Result, String let _timer = LogTimer::new(MODULE, "list_plugin_vcs_backends"); info!("list_plugin_vcs_backends: discovering VCS backends",); + if let Some(cached) = cached_backends() { + trace!( + "list_plugin_vcs_backends: returning {} cached backend(s)", + cached.len() + ); + return Ok(cached); + } + + let discovered = discover_plugin_vcs_backends()?; + store_backends(discovered.clone()); + Ok(discovered) +} + +fn discover_plugin_vcs_backends() -> Result, String> { let store = PluginBundleStore::new_default(); if let Err(err) = store.sync_built_in_plugins() { warn!( diff --git a/Backend/src/state.rs b/Backend/src/state.rs index 3cbefc39..192c5cb8 100644 --- a/Backend/src/state.rs +++ b/Backend/src/state.rs @@ -86,6 +86,7 @@ impl AppState { next.validate(); next.save().map_err(|e| e.to_string())?; crate::monitoring::sync_backend_monitoring(&next); + crate::plugin_vcs_backends::invalidate_plugin_vcs_backend_cache(); *self.config.write() = next; self.enforce_recents_limit_and_persist(); Ok(()) diff --git a/Backend/src/tauri_commands/plugins.rs b/Backend/src/tauri_commands/plugins.rs index 9f8c4676..b888602a 100644 --- a/Backend/src/tauri_commands/plugins.rs +++ b/Backend/src/tauri_commands/plugins.rs @@ -130,6 +130,7 @@ pub fn sync_configured_plugins(state: State<'_, AppState>) -> Result<(), String> state.set_config(cfg.clone())?; PluginBundleStore::new_default().sync_built_in_plugins()?; crate::plugin_sources::sync_configured_plugins(&cfg)?; + crate::plugin_vcs_backends::invalidate_plugin_vcs_backend_cache(); state.plugin_runtime().sync_plugin_runtime_with_config(&cfg) } @@ -146,6 +147,7 @@ pub fn uninstall_plugin(state: State<'_, AppState>, plugin_id: String) -> Result let plugin_id = plugin_id.trim().to_string(); state.plugin_runtime().stop_plugin(&plugin_id)?; PluginBundleStore::new_default().uninstall_plugin(&plugin_id)?; + crate::plugin_vcs_backends::invalidate_plugin_vcs_backend_cache(); info!("plugin: uninstalled '{}'", plugin_id); Ok(()) } @@ -278,6 +280,7 @@ pub fn set_plugin_approval( let store = PluginBundleStore::new_default(); store.approve_capabilities(&plugin_id, &version, approved)?; + crate::plugin_vcs_backends::invalidate_plugin_vcs_backend_cache(); if approved { if let Err(err) = state.plugin_runtime().sync_plugin_runtime() { diff --git a/Backend/src/tauri_commands/ssh.rs b/Backend/src/tauri_commands/ssh.rs index 51a54559..dfe9eccf 100644 --- a/Backend/src/tauri_commands/ssh.rs +++ b/Backend/src/tauri_commands/ssh.rs @@ -1,6 +1,10 @@ // Copyright © 2025-2026 OpenVCS Contributors // SPDX-License-Identifier: GPL-3.0-or-later -use std::{fs, path::PathBuf, process::Command}; +use std::{ + env, fs, + path::{Path, PathBuf}, + process::Command, +}; use log::{debug, error, info, trace, warn}; use serde::Serialize; @@ -107,6 +111,60 @@ fn run_command(cmd: &str, args: &[&str]) -> Result { Ok(result) } +fn is_executable(path: &Path) -> bool { + let Ok(metadata) = fs::metadata(path) else { + return false; + }; + + if !metadata.is_file() { + return false; + } + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + metadata.permissions().mode() & 0o111 != 0 + } + + #[cfg(not(unix))] + { + true + } +} + +fn resolve_command(candidate: &Path) -> Option { + if candidate.is_absolute() || candidate.components().count() > 1 { + return is_executable(candidate).then(|| candidate.to_path_buf()); + } + + let path = env::var_os("PATH")?; + env::split_paths(&path) + .map(|dir| dir.join(candidate)) + .find(|path| is_executable(path)) +} + +fn resolve_ssh_askpass() -> Option { + let mut candidates = Vec::new(); + + if let Some(path) = env::var_os("SSH_ASKPASS") { + candidates.push(PathBuf::from(path)); + } + + candidates.extend([ + PathBuf::from("/usr/bin/ksshaskpass"), + PathBuf::from("/usr/bin/ssh-askpass"), + PathBuf::from("/usr/bin/x11-ssh-askpass"), + PathBuf::from("/usr/bin/gnome-ssh-askpass"), + PathBuf::from("/usr/bin/lxqt-openssh-askpass"), + PathBuf::from("/usr/libexec/openssh/ssh-askpass"), + PathBuf::from("/usr/lib/ssh/ssh-askpass"), + ]); + + candidates + .into_iter() + .find_map(|candidate| resolve_command(candidate.as_path())) +} + #[cfg(not(target_os = "windows"))] /// Scans SSH host keys using `ssh-keyscan`. /// @@ -338,13 +396,44 @@ pub fn ssh_add_key(path: String) -> Result { return Err("Path cannot be empty".to_string()); } - let result = run_command("ssh-add", &[p])?; + let result = if let Some(askpass) = resolve_ssh_askpass() { + debug!("ssh_add_key: using SSH_ASKPASS='{}'", askpass.display()); + let start = std::time::Instant::now(); + let out = Command::new("ssh-add") + .env("SSH_ASKPASS", &askpass) + .arg(p) + .output() + .map_err(|e| { + error!("ssh_add_key: failed to spawn ssh-add: {}", e); + format!("Failed to run ssh-add: {e}") + })?; + + let elapsed = start.elapsed(); + let result = SshCommandOutput { + code: out.status.code().unwrap_or(-1), + stdout: String::from_utf8_lossy(&out.stdout).trim().to_string(), + stderr: String::from_utf8_lossy(&out.stderr).trim().to_string(), + }; - if result.code == 0 { - debug!("ssh_add_key: key added successfully"); + if out.status.success() { + debug!( + "ssh_add_key: key added successfully in {:?} (code={})", + elapsed, result.code + ); + } else { + warn!( + "ssh_add_key: failed to add key in {:?} (code={}): {}", + elapsed, result.code, result.stderr + ); + } + + result } else { - warn!("ssh_add_key: failed to add key: {}", result.stderr); - } + warn!( + "ssh_add_key: no usable SSH_ASKPASS found; encrypted keys may require terminal prompt" + ); + run_command("ssh-add", &[p])? + }; Ok(result) } diff --git a/Backend/src/tauri_commands/updater.rs b/Backend/src/tauri_commands/updater.rs index 72e90f45..deedd513 100644 --- a/Backend/src/tauri_commands/updater.rs +++ b/Backend/src/tauri_commands/updater.rs @@ -121,7 +121,9 @@ pub async fn updater_install_now(window: Window) -> Result<(), St "received": received, "total": total_val }); - let _ = app2.emit("update:progress", payload); + if let Err(e) = app2.emit("update:progress", payload) { + log::warn!("updater_install_now: failed to emit progress: {e}"); + } }, || { let download_elapsed = download_start.elapsed(); @@ -129,10 +131,12 @@ pub async fn updater_install_now(window: Window) -> Result<(), St "updater_install_now: download completed in {:?}", download_elapsed ); - let _ = app2.emit( + if let Err(e) = app2.emit( "update:progress", serde_json::json!({ "kind": "downloaded" }), - ); + ) { + log::warn!("updater_install_now: failed to emit downloaded event: {e}"); + } }, ) .await diff --git a/Backend/src/validate.rs b/Backend/src/validate.rs index 8f79522d..fb379f01 100644 --- a/Backend/src/validate.rs +++ b/Backend/src/validate.rs @@ -4,12 +4,15 @@ use std::path::Path; use std::sync::LazyLock; /// Regex pattern for scp-like VCS URLs. -static SCP_LIKE_RE: LazyLock = - LazyLock::new(|| regex::Regex::new(r"^[\w.-]+@[\w.-]+:[\w./-]+(?:\.git)?$").unwrap()); +static SCP_LIKE_RE: LazyLock = LazyLock::new(|| { + regex::Regex::new(r"^[\w.-]+@[\w.-]+:[\w./-]+(?:\.git)?$") + .expect("hardcoded SCP-like regex is valid") +}); /// Regex pattern for Windows absolute paths. -static WIN_ABS_RE: LazyLock = - LazyLock::new(|| regex::Regex::new(r"^[A-Za-z]:[\\/]").unwrap()); +static WIN_ABS_RE: LazyLock = LazyLock::new(|| { + regex::Regex::new(r"^[A-Za-z]:[\\/]").expect("hardcoded Windows path regex is valid") +}); #[derive(serde::Serialize)] pub struct Validation { diff --git a/Frontend/src/scripts/features/diff.ts b/Frontend/src/scripts/features/diff.ts index caa6cf28..f8b62e6a 100644 --- a/Frontend/src/scripts/features/diff.ts +++ b/Frontend/src/scripts/features/diff.ts @@ -83,8 +83,8 @@ export function bindCommit() { clearBusy('Ready'); return; } - if (commitSummary?.maxLength === 72 && summary.length > 72) { - summary = summary.slice(0, 72); + if (commitSummary?.maxLength === 72 && String(hookData.summary || '').length > 72) { + summary = String(hookData.summary || '').trim().slice(0, 72); commitSummary.value = summary; } else { summary = String(hookData.summary || '').trim() || summary; diff --git a/Frontend/src/scripts/features/newBranch.test.ts b/Frontend/src/scripts/features/newBranch.test.ts index bd2e5f45..16b3afed 100644 --- a/Frontend/src/scripts/features/newBranch.test.ts +++ b/Frontend/src/scripts/features/newBranch.test.ts @@ -66,6 +66,49 @@ describe('wireNewBranch', () => { expect(checkout.checked).toBe(true); }); + it('shows normalized branch name when spaces collapse to dashes', async () => { + installTauriMock(); + + const { wireNewBranch } = await import('./newBranch'); + wireNewBranch(); + await flushPromises(); + + 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; + + name.value = ' feature branch '; + name.dispatchEvent(new Event('input')); + await flushPromises(); + + expect(hint.hidden).toBe(false); + expect(hint.classList.contains('error')).toBe(false); + expect(hint.textContent).toContain('Will be created as'); + expect(hint.querySelector('code')?.textContent).toBe('feature-branch'); + expect(create.disabled).toBe(false); + }); + + it('rejects branch names with invalid characters', async () => { + installTauriMock(); + + const { wireNewBranch } = await import('./newBranch'); + wireNewBranch(); + await flushPromises(); + + 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; + + name.value = 'bad~branch'; + name.dispatchEvent(new Event('input')); + await flushPromises(); + + expect(hint.hidden).toBe(false); + expect(hint.classList.contains('error')).toBe(true); + expect(hint.textContent).toBe('Branch name contains invalid characters'); + expect(create.disabled).toBe(true); + }); + it('passes the checkout choice to branch creation', async () => { const invoke = vi.fn(async () => null); (window as any).__TAURI__ = { diff --git a/Frontend/src/scripts/features/settings.ts b/Frontend/src/scripts/features/settings.ts index 8110326e..14bc5c23 100644 --- a/Frontend/src/scripts/features/settings.ts +++ b/Frontend/src/scripts/features/settings.ts @@ -448,7 +448,7 @@ export function openSettings(section?: string){ } loadSettingsIntoForm(modal) - .catch(console.error) + .catch((err) => { console.error('Failed to load settings into form:', err); }) .finally(() => { modal.removeAttribute('aria-busy'); const setThemeAuto = modal.querySelector('#set-theme-auto'); diff --git a/Frontend/src/scripts/features/settingsGeneral.test.ts b/Frontend/src/scripts/features/settingsGeneral.test.ts index 000fd555..57d85a0c 100644 --- a/Frontend/src/scripts/features/settingsGeneral.test.ts +++ b/Frontend/src/scripts/features/settingsGeneral.test.ts @@ -4,6 +4,7 @@ import { describe, expect, it, vi } from 'vitest'; import { collectGeneralSettings, loadGeneralSettingsIntoForm } from './settingsGeneral'; +import { DEFAULT_DARK_THEME_ID, DEFAULT_LIGHT_THEME_ID, DEFAULT_THEME_ID } from '../themes'; describe('collectGeneralSettings', () => { it('captures the crash report toggle from the general settings panel', () => { @@ -65,4 +66,137 @@ describe('loadGeneralSettingsIntoForm', () => { expect((root.querySelector('#set-crash-reports') as HTMLInputElement).checked).toBe(true); expect((root.querySelector('#set-restrict-commit-summary') as HTMLInputElement).checked).toBe(true); }); + + it('expands the default theme id to the light built-in theme in light mode', async () => { + document.body.innerHTML = ` +
+ + + + + + + + +
+ `; + + const root = document.body.firstElementChild as HTMLElement; + const refreshDefaultBackendOptions = vi.fn().mockResolvedValue(undefined); + const rebuildThemePackOptions = vi.fn().mockResolvedValue(undefined); + + await loadGeneralSettingsIntoForm( + root, + { + general: { + theme: 'light', + theme_pack: DEFAULT_THEME_ID, + language: 'system', + update_channel: 'stable', + reopen_last_repos: false, + checks_on_launch: false, + crash_reports: false, + restrict_commit_summary: true, + }, + }, + (value) => String(value ?? ''), + refreshDefaultBackendOptions, + rebuildThemePackOptions, + ); + + expect(rebuildThemePackOptions).toHaveBeenCalledWith(expect.any(HTMLSelectElement), { + desiredId: DEFAULT_LIGHT_THEME_ID, + forceReload: true, + }); + expect((root.querySelector('#set-theme') as HTMLSelectElement).disabled).toBe(false); + }); + + it('expands the default theme id to the dark built-in theme in dark mode', async () => { + document.body.innerHTML = ` +
+ + + + + + + + +
+ `; + + const root = document.body.firstElementChild as HTMLElement; + const refreshDefaultBackendOptions = vi.fn().mockResolvedValue(undefined); + const rebuildThemePackOptions = vi.fn().mockResolvedValue(undefined); + + await loadGeneralSettingsIntoForm( + root, + { + general: { + theme: 'dark', + theme_pack: DEFAULT_THEME_ID, + language: 'system', + update_channel: 'stable', + reopen_last_repos: false, + checks_on_launch: false, + crash_reports: false, + restrict_commit_summary: true, + }, + }, + (value) => String(value ?? ''), + refreshDefaultBackendOptions, + rebuildThemePackOptions, + ); + + expect(rebuildThemePackOptions).toHaveBeenCalledWith(expect.any(HTMLSelectElement), { + desiredId: DEFAULT_DARK_THEME_ID, + forceReload: true, + }); + expect((root.querySelector('#set-theme') as HTMLSelectElement).disabled).toBe(false); + }); + + it('disables theme selection when system theme is active', async () => { + document.body.innerHTML = ` +
+ + + + + + + + +
+ `; + + const root = document.body.firstElementChild as HTMLElement; + const refreshDefaultBackendOptions = vi.fn().mockResolvedValue(undefined); + const rebuildThemePackOptions = vi.fn().mockResolvedValue(undefined); + + await loadGeneralSettingsIntoForm( + root, + { + general: { + theme: 'system', + theme_pack: 'acme-theme', + language: 'system', + update_channel: 'stable', + reopen_last_repos: false, + checks_on_launch: false, + crash_reports: false, + restrict_commit_summary: true, + }, + }, + (value) => String(value ?? ''), + refreshDefaultBackendOptions, + rebuildThemePackOptions, + ); + + expect(rebuildThemePackOptions).toHaveBeenCalledWith(expect.any(HTMLSelectElement), { + desiredId: 'acme-theme', + forceReload: true, + }); + expect((root.querySelector('#set-theme') as HTMLSelectElement).disabled).toBe(true); + expect((root.querySelector('#set-theme-auto') as HTMLInputElement).checked).toBe(true); + }); }); diff --git a/Frontend/src/scripts/features/sshAuth.test.ts b/Frontend/src/scripts/features/sshAuth.test.ts new file mode 100644 index 00000000..b08505d6 --- /dev/null +++ b/Frontend/src/scripts/features/sshAuth.test.ts @@ -0,0 +1,81 @@ +// 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', () => ({ openModal: vi.fn(), closeModal: vi.fn() })); +vi.mock('./repoSettings', () => ({ openRepoSettings: vi.fn() })); +vi.mock('./sshKeys', () => ({ openSshKeysModal: vi.fn() })); +vi.mock('../lib/notify', () => ({ notify: vi.fn() })); + +let listenHandler: ((evt: { payload: unknown }) => void) | null = null; + +/** Mounts the SSH auth modal used by the prompt wiring. */ +function mountSshAuthModal() { + document.body.innerHTML = ` +
+ + + + + + + + +
+ `; +} + +/** Installs a mocked Tauri runtime before module import. */ +function installTauriMock() { + listenHandler = null; + (window as any).__TAURI__ = { + core: { + invoke: vi.fn(async () => null), + }, + event: { + listen: vi.fn(async (_event: string, cb: (evt: { payload: unknown }) => void) => { + listenHandler = cb; + return { unlisten: vi.fn() }; + }), + }, + }; +} + +beforeEach(() => { + vi.resetModules(); + mountSshAuthModal(); + installTauriMock(); +}); + +afterEach(() => { + document.body.innerHTML = ''; + delete (window as any).__TAURI__; + vi.restoreAllMocks(); +}); + +describe('initSshAuthPrompt', () => { + it('enables HTTPS conversion for SCP-style SSH URLs', async () => { + const { initSshAuthPrompt } = await import('./sshAuth'); + initSshAuthPrompt(); + + expect(listenHandler).not.toBeNull(); + listenHandler?.({ payload: { host: 'github.com', remote: 'origin', url: 'git@github.com:user/repo.git' } }); + + const httpsBtn = document.getElementById('ssh-auth-switch-https') as HTMLButtonElement; + expect(httpsBtn.disabled).toBe(false); + expect(document.getElementById('ssh-auth-url')?.textContent).toBe('git@github.com:user/repo.git'); + }); + + it('disables HTTPS conversion for unsupported URLs', async () => { + const { initSshAuthPrompt } = await import('./sshAuth'); + initSshAuthPrompt(); + + expect(listenHandler).not.toBeNull(); + listenHandler?.({ payload: { host: 'example.com', remote: 'origin', url: 'ftp://example.com/repo' } }); + + const httpsBtn = document.getElementById('ssh-auth-switch-https') as HTMLButtonElement; + expect(httpsBtn.disabled).toBe(true); + expect(document.getElementById('ssh-auth-host')?.textContent).toBe('example.com'); + }); +}); diff --git a/Frontend/src/scripts/features/sshKeys.ts b/Frontend/src/scripts/features/sshKeys.ts index 4e764202..462ac288 100644 --- a/Frontend/src/scripts/features/sshKeys.ts +++ b/Frontend/src/scripts/features/sshKeys.ts @@ -102,8 +102,8 @@ export function wireSshKeys() { if (out.code === 0) { notify('Key added to ssh-agent'); await refresh(); - } else if (/enter passphrase|bad passphrase|passphrase/i.test(msg)) { - notify('Key is encrypted; run ssh-add in a terminal to enter the passphrase'); + } else if (/ssh_askpass_exec|askpass.*no such file or directory|enter passphrase|bad passphrase|passphrase/i.test(msg)) { + notify('Passphrase prompt app missing; install ssh-askpass or ksshaskpass, or run ssh-add in a terminal'); } else { notify(msg || `ssh-add failed (code ${out.code})`); } @@ -126,4 +126,3 @@ export function openSshKeysModal(preselectPath?: string) { const modal = document.getElementById('ssh-keys-modal') as any; modal?.__open?.(preselectPath); } - diff --git a/Frontend/src/scripts/features/stashConfirm.test.ts b/Frontend/src/scripts/features/stashConfirm.test.ts new file mode 100644 index 00000000..c6436603 --- /dev/null +++ b/Frontend/src/scripts/features/stashConfirm.test.ts @@ -0,0 +1,111 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const state = vi.hoisted(() => ({ + files: [] as Array<{ path: string; status: string }>, +})); + +vi.mock('../lib/notify', () => ({ notify: vi.fn() })); +vi.mock('../ui/modals', () => ({ + closeModal: vi.fn(), + hydrate: vi.fn(), + openModal: vi.fn(), +})); +vi.mock('../state/state', () => ({ + state, + statusClass: vi.fn((code: string) => `status-${code || 'none'}`), + statusLabel: vi.fn((code: string) => `label-${code}`), +})); + +/** Mounts the stash confirmation modal used by the feature wiring. */ +function mountStashConfirmModal() { + document.body.innerHTML = ` +
+ + +
    + + +
    + `; +} + +/** Installs a mocked Tauri runtime before module import. */ +function installTauriMock() { + (window as any).__TAURI__ = { + core: { + invoke: vi.fn(async () => null), + }, + event: { listen: vi.fn() }, + }; +} + +/** Waits for queued promise work from feature initialization. */ +function flushPromises(): Promise { + return new Promise((resolve) => window.setTimeout(resolve, 0)); +} + +beforeEach(() => { + vi.resetModules(); + mountStashConfirmModal(); + installTauriMock(); + state.files = [ + { path: 'a.txt', status: '??' }, + { path: 'b.txt', status: 'M' }, + ]; +}); + +afterEach(() => { + document.body.innerHTML = ''; + delete (window as any).__TAURI__; + vi.restoreAllMocks(); +}); + +describe('openStashConfirm', () => { + it('passes override paths through to stash payload', async () => { + const invoke = vi.fn(async () => null); + (window as any).__TAURI__.core.invoke = invoke; + + const { openStashConfirm } = await import('./stashConfirm'); + openStashConfirm({ defaultMessage: 'Keep work', paths: ['a.txt'], includeUntracked: false }); + + const countEl = document.getElementById('stash-file-count') as HTMLElement; + const confirmBtn = document.getElementById('stash-confirm-btn') as HTMLButtonElement; + + expect(countEl.textContent).toBe('1 file'); + expect(confirmBtn.disabled).toBe(false); + + confirmBtn.click(); + await flushPromises(); + + expect(invoke).toHaveBeenCalledWith('vcs_stash_push', { + includeUntracked: false, + message: 'Keep work', + paths: ['a.txt'], + }); + }); + + it('sends default stash payload when no paths override exists', async () => { + const invoke = vi.fn(async () => null); + (window as any).__TAURI__.core.invoke = invoke; + + const { openStashConfirm } = await import('./stashConfirm'); + openStashConfirm(); + + const countEl = document.getElementById('stash-file-count') as HTMLElement; + const confirmBtn = document.getElementById('stash-confirm-btn') as HTMLButtonElement; + + expect(countEl.textContent).toBe('2 files'); + expect(confirmBtn.disabled).toBe(false); + + confirmBtn.click(); + await flushPromises(); + + expect(invoke).toHaveBeenCalledWith('vcs_stash_push', { + includeUntracked: true, + message: 'WIP', + }); + }); +}); diff --git a/Frontend/src/scripts/lib/confirm.test.ts b/Frontend/src/scripts/lib/confirm.test.ts index 4de7b243..04f44c3e 100644 --- a/Frontend/src/scripts/lib/confirm.test.ts +++ b/Frontend/src/scripts/lib/confirm.test.ts @@ -77,4 +77,26 @@ describe('confirmBool', () => { await expect(confirmBool('Discard changes?')).resolves.toBe(false); }); + + it('coerces numeric confirm results', async () => { + document.body.innerHTML = ''; + Object.defineProperty(window, 'confirm', { + configurable: true, + writable: true, + value: vi.fn().mockReturnValue(0), + }); + + await expect(confirmBool('Discard changes?')).resolves.toBe(false); + }); + + it('coerces object confirm results via result field', async () => { + document.body.innerHTML = ''; + Object.defineProperty(window, 'confirm', { + configurable: true, + writable: true, + value: vi.fn().mockReturnValue({ result: 'ok' }), + }); + + await expect(confirmBool('Discard changes?')).resolves.toBe(true); + }); }); diff --git a/Frontend/src/scripts/lib/notify.test.ts b/Frontend/src/scripts/lib/notify.test.ts new file mode 100644 index 00000000..8f68a1cb --- /dev/null +++ b/Frontend/src/scripts/lib/notify.test.ts @@ -0,0 +1,34 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +/** Mounts the status bar before importing notify. */ +function mountStatusBar() { + document.body.innerHTML = '
    Ready
    '; +} + +beforeEach(() => { + vi.resetModules(); + vi.useFakeTimers(); + mountStatusBar(); +}); + +afterEach(() => { + vi.useRealTimers(); + document.body.innerHTML = ''; + vi.restoreAllMocks(); +}); + +describe('notify', () => { + it('updates status text and restores Ready later', async () => { + const { notify } = await import('./notify'); + const status = document.getElementById('status') as HTMLElement; + + notify('Saving changes'); + expect(status.textContent).toBe('Saving changes'); + + vi.advanceTimersByTime(2200); + expect(status.textContent).toBe('Ready'); + }); +}); diff --git a/Frontend/src/scripts/lib/tauri.test.ts b/Frontend/src/scripts/lib/tauri.test.ts new file mode 100644 index 00000000..71fd035c --- /dev/null +++ b/Frontend/src/scripts/lib/tauri.test.ts @@ -0,0 +1,53 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +/** Clears any mocked Tauri runtime before each import. */ +beforeEach(() => { + vi.resetModules(); + delete (window as any).__TAURI__; +}); + +/** Restores the DOM and mocks after each test. */ +afterEach(() => { + delete (window as any).__TAURI__; + vi.restoreAllMocks(); +}); + +/** Imports tauri module after runtime stubbing is configured. */ +async function loadTauriModule() { + return import('./tauri'); +} + +describe('isTauriRuntimeAvailable', () => { + it('returns false without runtime', async () => { + const { isTauriRuntimeAvailable } = await loadTauriModule(); + + expect(isTauriRuntimeAvailable()).toBe(false); + }); +}); + +describe('TAURI.invoke', () => { + it('rejects without runtime', async () => { + const { TAURI } = await loadTauriModule(); + + await expect(TAURI.invoke('test-command')).rejects.toThrow('Failed to initialize Tauri runtime.'); + }); +}); + +describe('TAURI.listen', () => { + it('rejects without runtime', async () => { + const { TAURI } = await loadTauriModule(); + + await expect(TAURI.listen('test-event', vi.fn())).rejects.toThrow('Failed to initialize Tauri runtime.'); + }); +}); + +describe('assertDesktopRuntime', () => { + it('throws without runtime', async () => { + const { assertDesktopRuntime } = await loadTauriModule(); + + expect(() => assertDesktopRuntime()).toThrow('Failed to initialize Tauri runtime.'); + }); +}); diff --git a/Frontend/src/scripts/main.ts b/Frontend/src/scripts/main.ts index c5d5df93..fbc3d0c3 100644 --- a/Frontend/src/scripts/main.ts +++ b/Frontend/src/scripts/main.ts @@ -56,7 +56,7 @@ const commitBtn = qs('#commit-btn'); } pluginMenuRefreshTimer = window.setTimeout(() => { pluginMenuRefreshTimer = null; - refreshPluginMenubarMenus().catch(() => {}); + refreshPluginMenubarMenus().catch((err) => console.warn('Plugin menu refresh failed:', err)); }, delayMs); } @@ -414,7 +414,7 @@ async function boot() { } break; } - case 'exit': TAURI.invoke('exit_app', {}).catch(() => {}); break; + case 'exit': TAURI.invoke('exit_app', {}).catch((err) => console.error('Failed to exit app:', err)); break; default: { if (!id) break; const handled = await runPluginAction(id); @@ -424,7 +424,7 @@ async function boot() { } // title actions - fetchBtn?.addEventListener('click', () => { defaultFetchAction().catch(() => {}); }); + fetchBtn?.addEventListener('click', () => { defaultFetchAction().catch((err) => console.warn('Fetch action failed:', err)); }); fetchCaret?.addEventListener('click', (e) => { if (!fetchPop) return; if (fetchPop.hidden) openFetchPopover(); else closeFetchPopover(); @@ -464,13 +464,13 @@ async function boot() { updateFetchUI(); // initial data - hydrateBranches().then(() => setRepoHeader()); + hydrateBranches().then(() => setRepoHeader()).catch((err) => console.warn('Failed to hydrate branches on startup:', err)); hydrateStatus(); hydrateCommits(); hydrateStash(); initMenubar(runMenuAction); - refreshPluginMenubarMenus().catch(() => {}); + refreshPluginMenubarMenus().catch((err) => console.warn('Plugin menu refresh failed:', err)); schedulePluginMenuRefresh(PLUGIN_MENU_REFRESH_SETTLE_MS); TAURI.listen?.('menu', async ({ payload: id }) => { @@ -530,7 +530,7 @@ async function boot() { // Broadcast app-level event so branch UI and actions can sync window.dispatchEvent(new CustomEvent('app:repo-selected', { detail: { path } })); refreshRepoActions(); - await refreshPluginMenubarMenus().catch(() => {}); + await refreshPluginMenubarMenus().catch((err) => console.warn('Plugin menu refresh failed:', err)); schedulePluginMenuRefresh(); }); @@ -547,10 +547,10 @@ async function boot() { window.dispatchEvent(new CustomEvent('app:repo-selected', { detail: { path } })); refreshRepoActions(); updateFetchUI(); - await refreshPluginMenubarMenus().catch(() => {}); + await refreshPluginMenubarMenus().catch((err) => console.warn('Plugin menu refresh failed:', err)); schedulePluginMenuRefresh(); }) - .catch(() => {}); + .catch((err) => console.warn('Failed to restore initial repository state:', err)); // backend status updates (footer) TAURI.listen?.('status:set', ({ payload }) => { @@ -658,9 +658,9 @@ async function boot() { if (li.getAttribute('aria-disabled') === 'true') return; const action = li.dataset.action || ''; closeFetchPopover(); - if (action === 'fetch-only') fetchOnly().catch(() => {}); - else if (action === 'fetch-all') fetchAllRemotesOnly().catch(() => {}); - else if (action === 'pull') fetchAndPull().catch(() => {}); + if (action === 'fetch-only') fetchOnly().catch((err) => console.warn('Fetch only failed:', err)); + else if (action === 'fetch-all') fetchAllRemotesOnly().catch((err) => console.warn('Fetch all failed:', err)); + else if (action === 'pull') fetchAndPull().catch((err) => console.warn('Pull failed:', err)); }); document.addEventListener('click', (e) => { diff --git a/Frontend/src/scripts/state/state.test.ts b/Frontend/src/scripts/state/state.test.ts index a53bb965..72b83dd6 100644 --- a/Frontend/src/scripts/state/state.test.ts +++ b/Frontend/src/scripts/state/state.test.ts @@ -1,6 +1,6 @@ // Copyright © 2025-2026 OpenVCS Contributors // SPDX-License-Identifier: GPL-3.0-or-later -import { describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it } from 'vitest'; /** Provides a minimal `matchMedia` test shim used by state imports. */ function createMatchMediaMock(query: string) { @@ -10,7 +10,17 @@ function createMatchMediaMock(query: string) { // Set matchMedia before importing modules that touch browser media APIs. (globalThis as any).matchMedia = createMatchMediaMock; -import { isConflictStatus, resolveVcsActionLabel, state } from './state'; +import { disableDefaultSelectAll, hasChanges, hasRepo, isConflictStatus, resolveVcsActionLabel, state, statusClass, statusLabel } from './state'; + +/** Resets mutable state between assertions. */ +afterEach(() => { + state.hasRepo = false; + state.files = []; + state.vcsActionLabels = {}; + state.defaultSelectAll = true; + state.selectionImplicitAll = true; + state.selectedFiles = new Set(); +}); describe('resolveVcsActionLabel', () => { it('falls back to generic VCS text when a label is missing', () => { @@ -33,3 +43,76 @@ describe('isConflictStatus', () => { expect(isConflictStatus('M')).toBe(false); }); }); + +describe('statusLabel', () => { + it.each([ + ['A', 'Added'], + ['?', 'Untracked'], + ['R', 'Renamed'], + ['C', 'Copied'], + ['T', 'Type change'], + ['S', 'Submodule'], + ['U', 'Conflicted'], + ['M', 'Modified'], + ['D', 'Deleted'], + ['AA', 'Conflicted'], + ['X', 'Changed'], + ])('maps %s to %s', (code, label) => { + expect(statusLabel(code)).toBe(label); + }); +}); + +describe('statusClass', () => { + it.each([ + ['A', 'add'], + ['?', 'untracked'], + ['R', 'ren'], + ['C', 'cpy'], + ['T', 'type'], + ['S', 'submodule'], + ['U', 'conflict'], + ['M', 'mod'], + ['D', 'del'], + ['AA', 'conflict'], + ['X', 'mod'], + ])('maps %s to %s', (code, klass) => { + expect(statusClass(code)).toBe(klass); + }); +}); + +describe('state flags', () => { + it('reports repo and change presence', () => { + expect(hasRepo()).toBe(false); + expect(hasChanges()).toBe(false); + + state.hasRepo = true; + state.files = [{ path: 'file.txt', status: 'M' }]; + + expect(hasRepo()).toBe(true); + expect(hasChanges()).toBe(true); + }); +}); + +describe('disableDefaultSelectAll', () => { + it('clears implicit selections when asked', () => { + state.selectedFiles = new Set(['a.txt']); + state.defaultSelectAll = true; + state.selectionImplicitAll = true; + + expect(disableDefaultSelectAll(true)).toBe(true); + expect(Array.from(state.selectedFiles)).toEqual([]); + expect(state.defaultSelectAll).toBe(false); + expect(state.selectionImplicitAll).toBe(false); + }); + + it('returns false when no implicit selection exists', () => { + state.selectedFiles = new Set(['a.txt']); + state.defaultSelectAll = false; + state.selectionImplicitAll = false; + + expect(disableDefaultSelectAll(true)).toBe(false); + expect(Array.from(state.selectedFiles)).toEqual(['a.txt']); + expect(state.defaultSelectAll).toBe(false); + expect(state.selectionImplicitAll).toBe(false); + }); +}); diff --git a/Frontend/src/scripts/themes.test.ts b/Frontend/src/scripts/themes.test.ts new file mode 100644 index 00000000..f68bbbb7 --- /dev/null +++ b/Frontend/src/scripts/themes.test.ts @@ -0,0 +1,17 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +import { describe, expect, it } from 'vitest'; + +import { getAvailableThemes } from './themes'; + +describe('getAvailableThemes', () => { + it('returns a copy of the current theme list', () => { + const first = getAvailableThemes(); + first.pop(); + + const second = getAvailableThemes(); + + expect(second.length).toBeGreaterThan(first.length); + }); +}); diff --git a/Frontend/src/scripts/themes.ts b/Frontend/src/scripts/themes.ts index fa11d623..0318992b 100644 --- a/Frontend/src/scripts/themes.ts +++ b/Frontend/src/scripts/themes.ts @@ -210,6 +210,24 @@ function applyMarkupNodes() { setMarkupForTarget(document.body, BODY_MARKUP_NODES, bodyHtml); } +/** Strips dangerous script content from theme markup while preserving style/link/meta. */ +function sanitizeThemeMarkup(root: ParentNode): void { + for (const node of Array.from(root.childNodes)) { + if (node.nodeType !== Node.ELEMENT_NODE) continue; + const element = node as Element; + if (element.tagName.toLowerCase() === 'script') { + element.remove(); + continue; + } + for (const attr of Array.from(element.attributes)) { + if (attr.name.toLowerCase().startsWith('on')) { + element.removeAttribute(attr.name); + } + } + sanitizeThemeMarkup(element); + } +} + /** Replaces tracked markup nodes for a target container. */ function setMarkupForTarget(target: ParentNode | null, store: ChildNode[], html: string | null | undefined) { const parent = target ?? null; @@ -219,6 +237,7 @@ function setMarkupForTarget(target: ParentNode | null, store: ChildNode[], html: if (!text) return; const template = document.createElement('template'); template.innerHTML = text; + sanitizeThemeMarkup(template.content); const nodes = Array.from(template.content.childNodes); for (const node of nodes) { parent.appendChild(node);