diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b8cc40d..f97c0a26 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -173,6 +173,10 @@ coverage additions. **@reidliu41**). Git-root detection ignores invalid parent `.git` markers, env-mutating tests share the crate-wide test lock, and the streamable HTTP MCP mock server stays alive for the full test. +- **Config-mutating smoke tests now isolate `DEEPSEEK_CONFIG_PATH`.** + The command registry and web-config commit tests no longer rewrite + the developer's real `~/.deepseek/config.toml` while validating + release candidates locally. ## [0.8.28] - 2026-05-10 diff --git a/crates/tui/src/commands/mod.rs b/crates/tui/src/commands/mod.rs index 9ec4d193..ee0e6c76 100644 --- a/crates/tui/src/commands/mod.rs +++ b/crates/tui/src/commands/mod.rs @@ -846,7 +846,9 @@ mod tests { use super::*; use crate::config::Config; use crate::tui::app::{App, AppAction, TuiOptions}; - use std::path::PathBuf; + use std::ffi::OsString; + use std::path::{Path, PathBuf}; + use std::sync::MutexGuard; fn create_test_app() -> App { let options = TuiOptions { @@ -1009,16 +1011,53 @@ mod tests { assert!(deepseek_result.action.is_none()); } + struct ConfigPathGuard { + previous: Option, + _lock: MutexGuard<'static, ()>, + } + + impl ConfigPathGuard { + fn new(config_path: &Path) -> Self { + let lock = crate::test_support::lock_test_env(); + let previous = std::env::var_os("DEEPSEEK_CONFIG_PATH"); + // Safety: test-only environment mutation guarded by a global mutex. + unsafe { + std::env::set_var("DEEPSEEK_CONFIG_PATH", config_path); + } + Self { + previous, + _lock: lock, + } + } + } + + impl Drop for ConfigPathGuard { + fn drop(&mut self) { + // Safety: test-only environment mutation guarded by a global mutex. + unsafe { + if let Some(previous) = self.previous.take() { + std::env::set_var("DEEPSEEK_CONFIG_PATH", previous); + } else { + std::env::remove_var("DEEPSEEK_CONFIG_PATH"); + } + } + } + } + /// Build an App scoped to an isolated tempdir so dispatch-side-effects - /// (e.g. `/init` writing AGENTS.md, `/export` writing chat transcripts) - /// don't pollute the repo working tree when the smoke tests run. - fn create_isolated_test_app() -> (App, tempfile::TempDir) { + /// (e.g. `/init` writing AGENTS.md, `/export` writing chat transcripts, + /// `/logout` clearing credentials) don't pollute the repo working tree or + /// the developer's real config when the smoke tests run. + fn create_isolated_test_app() -> (App, tempfile::TempDir, ConfigPathGuard) { let tmpdir = tempfile::TempDir::new().expect("tempdir for smoke test"); let workspace = tmpdir.path().to_path_buf(); + let config_path = workspace.join(".deepseek").join("config.toml"); + std::fs::create_dir_all(config_path.parent().expect("config parent")).expect("config dir"); + let guard = ConfigPathGuard::new(&config_path); let options = TuiOptions { model: "deepseek-v4-pro".to_string(), workspace: workspace.clone(), - config_path: None, + config_path: Some(config_path), config_profile: None, allow_shell: false, use_alt_screen: true, @@ -1037,7 +1076,7 @@ mod tests { initial_input: None, }; let app = App::new(options, &Config::default()); - (app, tmpdir) + (app, tmpdir, guard) } /// Smoke test: every entry in `COMMANDS` must dispatch to a real handler. @@ -1083,7 +1122,7 @@ mod tests { if skip_in_dispatch_smoke(command.name) { continue; } - let (mut app, tmpdir) = create_isolated_test_app(); + let (mut app, tmpdir, _guard) = create_isolated_test_app(); let invocation = invocation_for(command.name, command.name, tmpdir.path()); let result = execute(&invocation, &mut app); if let Some(msg) = &result.message { @@ -1105,7 +1144,7 @@ mod tests { continue; } for alias in command.aliases { - let (mut app, tmpdir) = create_isolated_test_app(); + let (mut app, tmpdir, _guard) = create_isolated_test_app(); let invocation = invocation_for(command.name, alias, tmpdir.path()); let result = execute(&invocation, &mut app); if let Some(msg) = &result.message { diff --git a/crates/tui/src/tui/ui/tests.rs b/crates/tui/src/tui/ui/tests.rs index c92886f4..21ec45f5 100644 --- a/crates/tui/src/tui/ui/tests.rs +++ b/crates/tui/src/tui/ui/tests.rs @@ -11,11 +11,51 @@ use crate::tui::history::{ }; use crate::tui::views::{ModalView, ViewAction}; use crate::working_set::Workspace; +use std::ffi::OsString; use std::path::PathBuf; use std::process::Command; +use std::sync::MutexGuard; use std::time::{Duration, Instant}; use tempfile::TempDir; +struct ConfigPathEnvGuard { + _tmp: TempDir, + previous: Option, + _lock: MutexGuard<'static, ()>, +} + +impl ConfigPathEnvGuard { + fn new() -> Self { + let lock = crate::test_support::lock_test_env(); + let tmp = TempDir::new().expect("config tempdir"); + let config_path = tmp.path().join(".deepseek").join("config.toml"); + std::fs::create_dir_all(config_path.parent().expect("config parent")).expect("config dir"); + let previous = std::env::var_os("DEEPSEEK_CONFIG_PATH"); + // Safety: test-only environment mutation guarded by a global mutex. + unsafe { + std::env::set_var("DEEPSEEK_CONFIG_PATH", &config_path); + } + Self { + _tmp: tmp, + previous, + _lock: lock, + } + } +} + +impl Drop for ConfigPathEnvGuard { + fn drop(&mut self) { + // Safety: test-only environment mutation guarded by a global mutex. + unsafe { + if let Some(previous) = self.previous.take() { + std::env::set_var("DEEPSEEK_CONFIG_PATH", previous); + } else { + std::env::remove_var("DEEPSEEK_CONFIG_PATH"); + } + } + } +} + #[test] fn resume_hint_uses_canonical_resume_command() { assert_eq!( @@ -1176,6 +1216,7 @@ async fn drain_web_config_events_applies_draft_without_closing_session() { #[tokio::test] async fn drain_web_config_events_closes_session_after_commit() { + let _config_env = ConfigPathEnvGuard::new(); let mut app = create_test_app(); let mut config = Config::default(); let engine = mock_engine_handle();