| 1 | //! TUI-hosted regression coverage retained from the pre-FEAT-024 control handlers. |
| 2 | //! |
| 3 | //! These tests dispatch through the public command seam. They deliberately |
| 4 | //! stay outside `groups/session`, which FEAT-043 moves to |
| 5 | //! `codewhale-commands`, while pinning persistence, sanitization, Git-target, |
| 6 | //! localization, and first-snapshot behavior against the real TUI adapter. |
| 7 | |
| 8 | use std::path::Path; |
| 9 | use std::process::Command; |
| 10 | |
| 11 | use tempfile::TempDir; |
| 12 | |
| 13 | use crate::commands::groups::session::MAX_TITLE_LEN; |
| 14 | use crate::commands::{CommandResult, execute}; |
| 15 | use crate::config::{ApiProvider, Config}; |
| 16 | use crate::session_manager::{SessionManager, create_saved_session_with_mode}; |
| 17 | use crate::test_support::{EnvVarGuard, TestEnvLock}; |
| 18 | use crate::tui::app::{App, AppAction, TuiOptions}; |
| 19 | use codewhale_config::AppMode; |
| 20 | use codewhale_localization::{Locale, MessageId, tr}; |
| 21 | use codewhale_models::{ContentBlock, Message, Role, SystemPrompt}; |
| 22 | |
| 23 | /// Owns the global environment lock for as long as its CODEWHALE_HOME guard. |
| 24 | /// Fields are ordered so the guard restores the environment before the lock |
| 25 | /// is released and before the temporary directory is removed. |
| 26 | struct ControlHarness { |
| 27 | app: App, |
| 28 | manager: SessionManager, |
| 29 | _home: EnvVarGuard, |
| 30 | _env_lock: TestEnvLock, |
| 31 | temp: TempDir, |
| 32 | } |
| 33 | |
| 34 | impl ControlHarness { |
| 35 | fn new() -> Self { |
| 36 | let env_lock = crate::test_support::lock_test_env(); |
| 37 | let temp = TempDir::new().expect("tempdir"); |
| 38 | let home = EnvVarGuard::set("CODEWHALE_HOME", temp.path().join("home")); |
| 39 | let options = TuiOptions { |
| 40 | skills_dir: temp.path().join("skills"), |
| 41 | memory_path: temp.path().join("memory.md"), |
| 42 | notes_path: temp.path().join("notes.txt"), |
| 43 | mcp_config_path: temp.path().join("mcp.json"), |
| 44 | ..crate::test_support::test_tui_options(temp.path()) |
| 45 | }; |
| 46 | let app = App::new(options, &Config::default()); |
| 47 | let manager = SessionManager::default_location().expect("session manager"); |
| 48 | Self { |
| 49 | app, |
| 50 | manager, |
| 51 | _home: home, |
| 52 | _env_lock: env_lock, |
| 53 | temp, |
| 54 | } |
| 55 | } |
| 56 | |
| 57 | fn seed_session(&mut self) -> String { |
| 58 | let session = |
| 59 | create_saved_session_with_mode(&[], "deepseek-v4-pro", self.temp.path(), 0, None, None); |
| 60 | let session_id = session.metadata.id.clone(); |
| 61 | self.manager.save_session(&session).expect("save session"); |
| 62 | self.app.current_session_id = Some(session_id.clone()); |
| 63 | session_id |
| 64 | } |
| 65 | } |
| 66 | |
| 67 | fn dispatch(app: &mut App, name: &str, arg: Option<&str>) -> CommandResult { |
| 68 | let command = match arg { |
| 69 | Some(arg) => format!("/{name} {arg}"), |
| 70 | None => format!("/{name}"), |
| 71 | }; |
| 72 | execute(&command, app) |
| 73 | } |
| 74 | |
| 75 | fn result_text(result: &CommandResult) -> &str { |
| 76 | result.message.as_deref().unwrap_or_default() |
| 77 | } |
| 78 | |
| 79 | fn user_message(text: &str) -> Message { |
| 80 | Message { |
| 81 | role: Role::User, |
| 82 | content: vec![ContentBlock::Text { |
| 83 | text: text.to_string(), |
| 84 | cache_control: None, |
| 85 | }], |
| 86 | } |
| 87 | } |
| 88 | |
| 89 | #[test] |
| 90 | fn rename_usage_empty_active_and_oversized_boundaries_are_preserved() { |
| 91 | let mut harness = ControlHarness::new(); |
| 92 | |
| 93 | for arg in [None, Some(" ")] { |
| 94 | let result = dispatch(&mut harness.app, "rename", arg); |
| 95 | assert!(result.is_error); |
| 96 | assert_eq!(result_text(&result), "Error: Usage: /rename <new title>"); |
| 97 | } |
| 98 | |
| 99 | let no_session = dispatch(&mut harness.app, "rename", Some("task-7")); |
| 100 | assert!(no_session.is_error); |
| 101 | assert!(result_text(&no_session).contains("No active session")); |
| 102 | |
| 103 | harness.seed_session(); |
| 104 | let too_long = "a".repeat(MAX_TITLE_LEN + 1); |
| 105 | let result = dispatch(&mut harness.app, "rename", Some(&too_long)); |
| 106 | assert!(result.is_error); |
| 107 | assert!(result_text(&result).contains("Title too long (max 100 characters)")); |
| 108 | } |
| 109 | |
| 110 | #[test] |
| 111 | fn rename_persists_all_live_metadata_through_public_dispatch() { |
| 112 | let mut harness = ControlHarness::new(); |
| 113 | let stale_prompt = SystemPrompt::Text("stale prompt".to_string()); |
| 114 | let session = create_saved_session_with_mode( |
| 115 | &[], |
| 116 | "deepseek-v4-pro", |
| 117 | harness.temp.path(), |
| 118 | 0, |
| 119 | Some(&stale_prompt), |
| 120 | None, |
| 121 | ); |
| 122 | let session_id = session.metadata.id.clone(); |
| 123 | harness |
| 124 | .manager |
| 125 | .save_session(&session) |
| 126 | .expect("save session"); |
| 127 | harness.app.current_session_id = Some(session_id.clone()); |
| 128 | harness |
| 129 | .app |
| 130 | .set_model_selection("local-code-model".to_string()); |
| 131 | harness |
| 132 | .app |
| 133 | .set_provider_identity(ApiProvider::Custom, "lm-studio"); |
| 134 | harness.app.mode = AppMode::Operate; |
| 135 | harness.app.system_prompt = None; |
| 136 | harness.app.todos.try_lock().expect("todos lock").add( |
| 137 | "live rename state".to_string(), |
| 138 | crate::tools::todo::TodoStatus::InProgress, |
| 139 | ); |
| 140 | let expected_work_state = harness.app.work_state_snapshot().expect("work snapshot"); |
| 141 | |
| 142 | let result = dispatch(&mut harness.app, "rename", Some("Brand New Title")); |
| 143 | |
| 144 | assert!(!result.is_error, "{result:?}"); |
| 145 | assert_eq!( |
| 146 | result_text(&result), |
| 147 | "Session renamed to \"Brand New Title\"" |
| 148 | ); |
| 149 | let reloaded = harness.manager.load_session(&session_id).expect("reload"); |
| 150 | assert_eq!(reloaded.metadata.title, "Brand New Title"); |
| 151 | assert_eq!(reloaded.work_state, expected_work_state); |
| 152 | assert!(reloaded.system_prompt.is_none()); |
| 153 | assert_eq!(reloaded.metadata.model, "local-code-model"); |
| 154 | assert_eq!(reloaded.metadata.model_provider, "custom"); |
| 155 | assert_eq!( |
| 156 | reloaded.metadata.model_provider_id.as_deref(), |
| 157 | Some("lm-studio") |
| 158 | ); |
| 159 | assert_eq!(reloaded.metadata.workspace, harness.app.workspace); |
| 160 | assert_eq!(reloaded.metadata.mode.as_deref(), Some("operate")); |
| 161 | assert_eq!( |
| 162 | harness.app.session_title.as_deref(), |
| 163 | Some("Brand New Title") |
| 164 | ); |
| 165 | assert_eq!( |
| 166 | harness |
| 167 | .app |
| 168 | .current_session_metadata |
| 169 | .as_ref() |
| 170 | .map(|metadata| metadata.title.as_str()), |
| 171 | Some("Brand New Title") |
| 172 | ); |
| 173 | } |
| 174 | |
| 175 | #[test] |
| 176 | fn rename_sanitizes_controls_and_accepts_exact_character_limit() { |
| 177 | let mut harness = ControlHarness::new(); |
| 178 | let session_id = harness.seed_session(); |
| 179 | |
| 180 | let hostile = "Ev\u{1b}]0;PWNED\u{7}il\u{202e} Beta"; |
| 181 | let result = dispatch(&mut harness.app, "rename", Some(hostile)); |
| 182 | assert!(!result.is_error, "{result:?}"); |
| 183 | let reloaded = harness.manager.load_session(&session_id).expect("reload"); |
| 184 | assert_eq!(reloaded.metadata.title, "Ev]0;PWNEDil Beta"); |
| 185 | assert_eq!( |
| 186 | harness.app.session_title.as_deref(), |
| 187 | Some("Ev]0;PWNEDil Beta") |
| 188 | ); |
| 189 | |
| 190 | let controls_only = dispatch(&mut harness.app, "rename", Some("\u{1b}\u{7}\u{200b}")); |
| 191 | assert!(controls_only.is_error); |
| 192 | |
| 193 | let max_title = "中".repeat(MAX_TITLE_LEN); |
| 194 | let result = dispatch(&mut harness.app, "rename", Some(&max_title)); |
| 195 | assert!(!result.is_error, "{result:?}"); |
| 196 | assert_eq!( |
| 197 | harness |
| 198 | .manager |
| 199 | .load_session(&session_id) |
| 200 | .expect("reload") |
| 201 | .metadata |
| 202 | .title, |
| 203 | max_title |
| 204 | ); |
| 205 | } |
| 206 | |
| 207 | #[test] |
| 208 | fn rename_recovers_first_snapshot_from_checkpoint() { |
| 209 | let mut harness = ControlHarness::new(); |
| 210 | let checkpoint = |
| 211 | create_saved_session_with_mode(&[], "deepseek-v4-pro", harness.temp.path(), 0, None, None); |
| 212 | let session_id = checkpoint.metadata.id.clone(); |
| 213 | harness |
| 214 | .manager |
| 215 | .save_checkpoint(&checkpoint) |
| 216 | .expect("save checkpoint"); |
| 217 | harness.app.current_session_id = Some(session_id.clone()); |
| 218 | harness.app.api_messages = |
| 219 | std::sync::Arc::new(vec![user_message("first turn still streaming")]); |
| 220 | |
| 221 | let result = dispatch(&mut harness.app, "rename", Some("Midturn Rename")); |
| 222 | |
| 223 | assert!(!result.is_error, "{result:?}"); |
| 224 | assert_eq!(harness.app.session_title.as_deref(), Some("Midturn Rename")); |
| 225 | let persisted = harness |
| 226 | .manager |
| 227 | .load_session(&session_id) |
| 228 | .expect("persisted"); |
| 229 | assert_eq!(persisted.metadata.title, "Midturn Rename"); |
| 230 | assert_eq!(persisted.messages.len(), 1); |
| 231 | } |
| 232 | |
| 233 | #[test] |
| 234 | fn rename_builds_from_app_state_before_any_checkpoint_exists() { |
| 235 | let mut harness = ControlHarness::new(); |
| 236 | let session_id = "live-before-first-checkpoint"; |
| 237 | harness.app.current_session_id = Some(session_id.to_string()); |
| 238 | harness.app.api_messages = |
| 239 | std::sync::Arc::new(vec![user_message("turn one, nothing persisted yet")]); |
| 240 | |
| 241 | let result = dispatch(&mut harness.app, "rename", Some("Earliest Rename")); |
| 242 | |
| 243 | assert!(!result.is_error, "{result:?}"); |
| 244 | assert_eq!( |
| 245 | harness.app.session_title.as_deref(), |
| 246 | Some("Earliest Rename") |
| 247 | ); |
| 248 | let persisted = harness.manager.load_session(session_id).expect("persisted"); |
| 249 | assert_eq!(persisted.metadata.title, "Earliest Rename"); |
| 250 | assert_eq!(persisted.messages.len(), 1); |
| 251 | } |
| 252 | |
| 253 | #[test] |
| 254 | fn title_requires_an_active_session_and_preserves_raw_length_limit() { |
| 255 | let mut harness = ControlHarness::new(); |
| 256 | let no_session = dispatch(&mut harness.app, "title", Some("task-7")); |
| 257 | assert!(no_session.is_error); |
| 258 | assert!(result_text(&no_session).contains("No active session")); |
| 259 | |
| 260 | harness.app.current_session_id = Some("any".to_string()); |
| 261 | let too_long = "x".repeat(MAX_TITLE_LEN + 1); |
| 262 | let result = dispatch(&mut harness.app, "title", Some(&too_long)); |
| 263 | assert!(result.is_error); |
| 264 | assert!(result_text(&result).contains("Title too long")); |
| 265 | } |
| 266 | |
| 267 | #[test] |
| 268 | fn title_set_and_clear_persist_without_renaming_the_session() { |
| 269 | let mut harness = ControlHarness::new(); |
| 270 | let mut session = |
| 271 | create_saved_session_with_mode(&[], "deepseek-v4-pro", harness.temp.path(), 0, None, None); |
| 272 | session.metadata.id = "title-test".to_string(); |
| 273 | session.metadata.title = "Original Name".to_string(); |
| 274 | harness |
| 275 | .manager |
| 276 | .save_session(&session) |
| 277 | .expect("save session"); |
| 278 | harness.app.current_session_id = Some("title-test".to_string()); |
| 279 | |
| 280 | let result = dispatch(&mut harness.app, "title", Some("parallel-task")); |
| 281 | assert!(!result.is_error, "{result:?}"); |
| 282 | assert_eq!(harness.app.window_title.as_deref(), Some("parallel-task")); |
| 283 | assert!(harness.app.session_title.is_none()); |
| 284 | let reloaded = harness.manager.load_session("title-test").expect("reload"); |
| 285 | assert_eq!(reloaded.window_title.as_deref(), Some("parallel-task")); |
| 286 | assert_eq!(reloaded.metadata.title, "Original Name"); |
| 287 | |
| 288 | let result = dispatch(&mut harness.app, "title", Some("off")); |
| 289 | assert!(!result.is_error, "{result:?}"); |
| 290 | assert!(harness.app.window_title.is_none()); |
| 291 | assert!( |
| 292 | harness |
| 293 | .manager |
| 294 | .load_session("title-test") |
| 295 | .expect("reload") |
| 296 | .window_title |
| 297 | .is_none() |
| 298 | ); |
| 299 | } |
| 300 | |
| 301 | #[test] |
| 302 | fn title_bare_reports_config_and_session_sources() { |
| 303 | let mut harness = ControlHarness::new(); |
| 304 | harness.app.title_default = Some("workspace-x".to_string()); |
| 305 | let result = dispatch(&mut harness.app, "title", None); |
| 306 | assert_eq!( |
| 307 | result_text(&result), |
| 308 | "Window title: [workspace-x] (config default)" |
| 309 | ); |
| 310 | |
| 311 | harness.app.window_title = Some("session-specific".to_string()); |
| 312 | let result = dispatch(&mut harness.app, "title", None); |
| 313 | assert_eq!( |
| 314 | result_text(&result), |
| 315 | "Window title: [session-specific] (session)" |
| 316 | ); |
| 317 | } |
| 318 | |
| 319 | #[test] |
| 320 | fn title_sanitizes_terminal_controls_before_persisting() { |
| 321 | let mut harness = ControlHarness::new(); |
| 322 | let session_id = harness.seed_session(); |
| 323 | |
| 324 | let hostile = "Ev\u{1b}]0;PWNED\u{7}il\u{202e} Beta"; |
| 325 | let result = dispatch(&mut harness.app, "title", Some(hostile)); |
| 326 | assert!(!result.is_error, "{result:?}"); |
| 327 | let reloaded = harness.manager.load_session(&session_id).expect("reload"); |
| 328 | assert_eq!(reloaded.window_title.as_deref(), Some("Ev]0;PWNEDil Beta")); |
| 329 | assert_eq!( |
| 330 | harness.app.window_title.as_deref(), |
| 331 | Some("Ev]0;PWNEDil Beta") |
| 332 | ); |
| 333 | |
| 334 | let controls_only = dispatch(&mut harness.app, "title", Some("\u{1b}\u{7}\u{200b}")); |
| 335 | assert!(controls_only.is_error); |
| 336 | } |
| 337 | |
| 338 | #[test] |
| 339 | fn title_recovers_first_snapshot_from_checkpoint() { |
| 340 | let mut harness = ControlHarness::new(); |
| 341 | let checkpoint = |
| 342 | create_saved_session_with_mode(&[], "deepseek-v4-pro", harness.temp.path(), 0, None, None); |
| 343 | let session_id = checkpoint.metadata.id.clone(); |
| 344 | harness |
| 345 | .manager |
| 346 | .save_checkpoint(&checkpoint) |
| 347 | .expect("save checkpoint"); |
| 348 | harness.app.current_session_id = Some(session_id.clone()); |
| 349 | harness.app.api_messages = |
| 350 | std::sync::Arc::new(vec![user_message("first turn still streaming")]); |
| 351 | |
| 352 | let result = dispatch(&mut harness.app, "title", Some("Midturn Title")); |
| 353 | |
| 354 | assert!(!result.is_error, "{result:?}"); |
| 355 | assert_eq!(harness.app.window_title.as_deref(), Some("Midturn Title")); |
| 356 | let persisted = harness |
| 357 | .manager |
| 358 | .load_session(&session_id) |
| 359 | .expect("persisted"); |
| 360 | assert_eq!(persisted.window_title.as_deref(), Some("Midturn Title")); |
| 361 | assert_eq!(persisted.messages.len(), 1); |
| 362 | assert_ne!(persisted.metadata.title, "Midturn Title"); |
| 363 | } |
| 364 | |
| 365 | #[test] |
| 366 | fn title_builds_from_app_state_before_any_checkpoint_exists() { |
| 367 | let mut harness = ControlHarness::new(); |
| 368 | let session_id = "live-title-before-first-checkpoint"; |
| 369 | harness.app.current_session_id = Some(session_id.to_string()); |
| 370 | harness.app.api_messages = |
| 371 | std::sync::Arc::new(vec![user_message("turn one, nothing persisted yet")]); |
| 372 | |
| 373 | let result = dispatch(&mut harness.app, "title", Some("Earliest Title")); |
| 374 | |
| 375 | assert!(!result.is_error, "{result:?}"); |
| 376 | assert_eq!(harness.app.window_title.as_deref(), Some("Earliest Title")); |
| 377 | let persisted = harness.manager.load_session(session_id).expect("persisted"); |
| 378 | assert_eq!(persisted.window_title.as_deref(), Some("Earliest Title")); |
| 379 | assert_eq!(persisted.messages.len(), 1); |
| 380 | } |
| 381 | |
| 382 | fn init_repo(workspace: &Path, origin: &str, branch: &str) { |
| 383 | let init = Command::new("git") |
| 384 | .args(["init", "--quiet"]) |
| 385 | .arg(workspace) |
| 386 | .status() |
| 387 | .expect("git init"); |
| 388 | assert!(init.success()); |
| 389 | let origin_status = Command::new("git") |
| 390 | .arg("-C") |
| 391 | .arg(workspace) |
| 392 | .args(["remote", "add", "origin", origin]) |
| 393 | .status() |
| 394 | .expect("git remote add"); |
| 395 | if !origin_status.success() { |
| 396 | let update = Command::new("git") |
| 397 | .arg("-C") |
| 398 | .arg(workspace) |
| 399 | .args(["remote", "set-url", "origin", origin]) |
| 400 | .status() |
| 401 | .expect("git remote set-url"); |
| 402 | assert!(update.success()); |
| 403 | } |
| 404 | let branch_status = Command::new("git") |
| 405 | .arg("-C") |
| 406 | .arg(workspace) |
| 407 | .args(["symbolic-ref", "HEAD"]) |
| 408 | .arg(format!("refs/heads/{branch}")) |
| 409 | .status() |
| 410 | .expect("set branch"); |
| 411 | assert!(branch_status.success()); |
| 412 | } |
| 413 | |
| 414 | fn external_url(result: &CommandResult) -> &str { |
| 415 | match result.action.as_ref() { |
| 416 | Some(AppAction::OpenExternalUrl { url, .. }) => url, |
| 417 | other => panic!("expected external URL action, got {other:?}"), |
| 418 | } |
| 419 | } |
| 420 | |
| 421 | #[test] |
| 422 | fn remote_env_bare_command_preserves_every_source_boundary() { |
| 423 | let mut harness = ControlHarness::new(); |
| 424 | let result = dispatch(&mut harness.app, "remote-env", None); |
| 425 | assert!(!result.is_error); |
| 426 | assert!(result.action.is_none()); |
| 427 | for boundary in ["unpushed", "dirty", "ignored", "secrets", "session state"] { |
| 428 | assert!( |
| 429 | result_text(&result).contains(boundary), |
| 430 | "missing boundary: {boundary}" |
| 431 | ); |
| 432 | } |
| 433 | } |
| 434 | |
| 435 | #[test] |
| 436 | fn remote_env_open_encodes_branch_and_never_echoes_credentials() { |
| 437 | let mut harness = ControlHarness::new(); |
| 438 | let secret = "top-secret-token"; |
| 439 | init_repo( |
| 440 | harness.temp.path(), |
| 441 | &format!("https://hunter:{secret}@github.com/Hmbown/CodeWhale.git"), |
| 442 | "feature/mobile&cloud-{url}", |
| 443 | ); |
| 444 | |
| 445 | let result = dispatch(&mut harness.app, "remote-env", Some("open")); |
| 446 | |
| 447 | assert!(!result.is_error, "{result:?}"); |
| 448 | assert_eq!( |
| 449 | external_url(&result), |
| 450 | "https://app.codewhale.net/work?repo=Hmbown%2FCodeWhale&branch=feature%2Fmobile%26cloud-%7Burl%7D" |
| 451 | ); |
| 452 | assert!(result_text(&result).contains("feature/mobile&cloud-{url}")); |
| 453 | assert!(!external_url(&result).contains(secret)); |
| 454 | assert!(!result_text(&result).contains(secret)); |
| 455 | } |
| 456 | |
| 457 | #[test] |
| 458 | fn remote_env_supported_https_ssh_and_cnb_origins_remain_accepted() { |
| 459 | for (origin, expected) in [ |
| 460 | ( |
| 461 | "https://github.com/Hmbown/CodeWhale.git", |
| 462 | "Hmbown%2FCodeWhale", |
| 463 | ), |
| 464 | ( |
| 465 | "https://user:token@github.com/Hmbown/CodeWhale", |
| 466 | "Hmbown%2FCodeWhale", |
| 467 | ), |
| 468 | ( |
| 469 | "ssh://git@github.com/Hmbown/CodeWhale.git", |
| 470 | "Hmbown%2FCodeWhale", |
| 471 | ), |
| 472 | ("git@github.com:Hmbown/CodeWhale.git", "Hmbown%2FCodeWhale"), |
| 473 | ("https://cnb.cool/whale/codewhale.git", "whale%2Fcodewhale"), |
| 474 | ( |
| 475 | "ssh://git@cnb.cool:2222/whale/codewhale.git", |
| 476 | "whale%2Fcodewhale", |
| 477 | ), |
| 478 | ("git@cnb.cool:whale/codewhale.git", "whale%2Fcodewhale"), |
| 479 | ] { |
| 480 | let mut harness = ControlHarness::new(); |
| 481 | init_repo(harness.temp.path(), origin, "main"); |
| 482 | let result = dispatch(&mut harness.app, "remote-env", Some("open")); |
| 483 | assert!(!result.is_error, "{origin}: {result:?}"); |
| 484 | assert!( |
| 485 | external_url(&result).contains(&format!("repo={expected}")), |
| 486 | "{origin}: {:?}", |
| 487 | result.action |
| 488 | ); |
| 489 | } |
| 490 | } |
| 491 | |
| 492 | #[test] |
| 493 | fn remote_env_rejects_unsupported_origins_without_echoing_them() { |
| 494 | for origin in [ |
| 495 | "http://github.com/acme/widgets.git", |
| 496 | "git://github.com/acme/widgets.git", |
| 497 | "https://github.com/acme/widgets/extra.git", |
| 498 | "https://github.com/acme/widgets.git?token=secret", |
| 499 | "file:///tmp/widgets.git", |
| 500 | "/tmp/widgets.git", |
| 501 | ] { |
| 502 | let mut harness = ControlHarness::new(); |
| 503 | init_repo(harness.temp.path(), origin, "main"); |
| 504 | let result = dispatch(&mut harness.app, "remote-env", Some("open")); |
| 505 | assert!(result.is_error, "{origin}"); |
| 506 | assert!(result.action.is_none(), "{origin}"); |
| 507 | assert!(!result_text(&result).contains(origin), "{origin}"); |
| 508 | } |
| 509 | |
| 510 | let mut harness = ControlHarness::new(); |
| 511 | let secret = "do-not-echo-this-token"; |
| 512 | init_repo( |
| 513 | harness.temp.path(), |
| 514 | &format!("https://user:{secret}@gitlab.com/acme/widgets.git"), |
| 515 | "main", |
| 516 | ); |
| 517 | let result = dispatch(&mut harness.app, "remote-env", Some("open")); |
| 518 | assert!(result.is_error); |
| 519 | assert!(result.action.is_none()); |
| 520 | assert!(!result_text(&result).contains(secret)); |
| 521 | } |
| 522 | |
| 523 | #[test] |
| 524 | fn remote_env_invalid_operations_remain_read_only_rejections() { |
| 525 | let mut harness = ControlHarness::new(); |
| 526 | for operation in ["upload", "migrate", "sync", "surprise"] { |
| 527 | let result = dispatch(&mut harness.app, "remote-env", Some(operation)); |
| 528 | assert!(result.is_error, "{operation}"); |
| 529 | assert!(result.action.is_none(), "{operation}"); |
| 530 | assert!(result_text(&result).contains("does not upload, migrate, or sync")); |
| 531 | assert!(result_text(&result).contains("/remote-env open")); |
| 532 | } |
| 533 | } |
| 534 | |
| 535 | #[test] |
| 536 | fn remote_env_open_requires_a_symbolic_branch() { |
| 537 | let mut harness = ControlHarness::new(); |
| 538 | init_repo( |
| 539 | harness.temp.path(), |
| 540 | "git@github.com:acme/widgets.git", |
| 541 | "main", |
| 542 | ); |
| 543 | for args in [ |
| 544 | ["config", "user.name", "Codewhale Test"], |
| 545 | ["config", "user.email", "test@codewhale.invalid"], |
| 546 | ] { |
| 547 | let status = Command::new("git") |
| 548 | .arg("-C") |
| 549 | .arg(harness.temp.path()) |
| 550 | .args(args) |
| 551 | .status() |
| 552 | .expect("git config"); |
| 553 | assert!(status.success()); |
| 554 | } |
| 555 | let commit = Command::new("git") |
| 556 | .arg("-C") |
| 557 | .arg(harness.temp.path()) |
| 558 | .args(["commit", "--allow-empty", "--quiet", "-m", "fixture"]) |
| 559 | .status() |
| 560 | .expect("create fixture commit"); |
| 561 | assert!(commit.success()); |
| 562 | let detach = Command::new("git") |
| 563 | .arg("-C") |
| 564 | .arg(harness.temp.path()) |
| 565 | .args(["checkout", "--detach", "--quiet", "HEAD"]) |
| 566 | .status() |
| 567 | .expect("detach HEAD"); |
| 568 | assert!(detach.success()); |
| 569 | |
| 570 | let result = dispatch(&mut harness.app, "remote-env", Some("open")); |
| 571 | assert!(result.is_error); |
| 572 | assert!(result.action.is_none()); |
| 573 | } |
| 574 | |
| 575 | #[test] |
| 576 | fn remote_env_localized_copy_preserves_composed_placeholders() { |
| 577 | let cases: &[(MessageId, &[&str])] = &[ |
| 578 | (MessageId::CmdRemoteEnvOverview, &["{command}"]), |
| 579 | ( |
| 580 | MessageId::CmdRemoteEnvOpening, |
| 581 | &["{repo}", "{branch}", "{origin}", "{url}"], |
| 582 | ), |
| 583 | ( |
| 584 | MessageId::CmdRemoteEnvUnavailable, |
| 585 | &["{origin}", "{command}"], |
| 586 | ), |
| 587 | (MessageId::CmdRemoteEnvSourceCustodyPolicy, &["{command}"]), |
| 588 | ]; |
| 589 | for locale in Locale::shipped_complete() { |
| 590 | for (id, placeholders) in cases { |
| 591 | let message = tr(*locale, *id); |
| 592 | for placeholder in *placeholders { |
| 593 | assert!( |
| 594 | message.contains(placeholder), |
| 595 | "{} {id:?} lost {placeholder}", |
| 596 | locale.tag() |
| 597 | ); |
| 598 | } |
| 599 | } |
| 600 | } |
| 601 | } |
| 602 |