返回 CodeWhale
session_control_regression_tests.rs
根目录 / crates / tui / src / commands / session_control_regression_tests.rs
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
602 lines RUST