返回 CodeWhale
test_support.rs
根目录 / crates / tui / src / test_support.rs
1 //! Shared test-only helpers.
2
3 use std::ffi::{OsStr, OsString};
4 use std::path::{Path, PathBuf};
5 use std::sync::{Mutex, OnceLock};
6 use std::time::{SystemTime, UNIX_EPOCH};
7
8 pub(crate) use crate::shell_dispatcher::test_env_lock::{
9 EnvScopeMembership, EnvScopeTicket, TestEnvLock, current_env_scope_generation,
10 current_thread_holds_test_env_lock, env_scope_ticket, join_env_scope, lock_test_env,
11 with_test_env_lock,
12 };
13
14 /// Process-wide state root for unit tests that do not intentionally provide an
15 /// explicit config/settings path.
16 ///
17 /// The production fallback is the user's real home. That is useful at runtime
18 /// and unsafe in a parallel test binary: an unguarded save can otherwise read
19 /// or overwrite the developer's config. Tests that exercise path precedence
20 /// still hold [`lock_test_env`] and provide explicit temporary environment
21 /// values; every other test is confined here.
22 pub(crate) fn isolated_test_state_root() -> &'static Path {
23 static ROOT: OnceLock<PathBuf> = OnceLock::new();
24 ROOT.get_or_init(|| {
25 let nonce = SystemTime::now()
26 .duration_since(UNIX_EPOCH)
27 .unwrap_or_default()
28 .as_nanos();
29 let root = std::env::temp_dir().join(format!(
30 "codewhale-tui-test-state-{}-{nonce}",
31 std::process::id()
32 ));
33 std::fs::create_dir_all(&root).unwrap_or_else(|error| {
34 panic!(
35 "failed to create isolated unit-test state root {}: {error}",
36 root.display()
37 )
38 });
39 root
40 })
41 }
42
43 /// Build a syntactically valid, non-secret JWT fixture without embedding a
44 /// high-entropy token-shaped literal in Git history.
45 pub(crate) fn future_test_jwt(label: &str) -> String {
46 use base64::Engine as _;
47
48 let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(br#"{"exp":9999999999}"#);
49 format!("test.{payload}.{label}")
50 }
51
52 fn state_io_lock() -> &'static Mutex<()> {
53 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
54 LOCK.get_or_init(|| Mutex::new(()))
55 }
56
57 /// Serialize read/merge/write operations against the process-wide isolated
58 /// test state root.
59 ///
60 /// Path isolation protects the developer's files, but parallel tests still
61 /// share the same temporary files. Settings persistence is a multi-step
62 /// operation, so it needs this second barrier around the complete I/O
63 /// transaction rather than only around path resolution.
64 pub(crate) fn with_test_state_io_lock<T>(operation: impl FnOnce() -> T) -> T {
65 let _guard = match state_io_lock().lock() {
66 Ok(guard) => guard,
67 Err(poisoned) => poisoned.into_inner(),
68 };
69 operation()
70 }
71
72 /// Restore one environment variable when dropped.
73 ///
74 /// Callers that mutate process-global environment variables must hold
75 /// [`lock_test_env`] until after this guard is dropped.
76 pub(crate) struct EnvVarGuard {
77 key: &'static str,
78 previous: Option<OsString>,
79 }
80
81 impl EnvVarGuard {
82 pub(crate) fn set(key: &'static str, value: impl AsRef<OsStr>) -> Self {
83 debug_assert!(
84 current_thread_holds_test_env_lock(),
85 "EnvVarGuard::set({key}) requires lock_test_env()"
86 );
87 let previous = std::env::var_os(key);
88 // SAFETY: callers hold the process-wide test env mutex.
89 unsafe { std::env::set_var(key, value) };
90 Self { key, previous }
91 }
92
93 pub(crate) fn remove(key: &'static str) -> Self {
94 debug_assert!(
95 current_thread_holds_test_env_lock(),
96 "EnvVarGuard::remove({key}) requires lock_test_env()"
97 );
98 let previous = std::env::var_os(key);
99 // SAFETY: callers hold the process-wide test env mutex.
100 unsafe { std::env::remove_var(key) };
101 Self { key, previous }
102 }
103
104 pub(crate) fn previous(&self) -> Option<OsString> {
105 self.previous.clone()
106 }
107 }
108
109 impl Drop for EnvVarGuard {
110 fn drop(&mut self) {
111 // SAFETY: callers hold the process-wide test env mutex until after this
112 // guard is dropped.
113 unsafe {
114 if let Some(value) = self.previous.take() {
115 std::env::set_var(self.key, value);
116 } else {
117 std::env::remove_var(self.key);
118 }
119 }
120 }
121 }
122
123 /// Find the byte position of the first divergence between two strings,
124 /// returning a windowed view (`±32 bytes` around the divergence) so failures
125 /// in cache-prefix-stability tests show *which* bytes drifted, not just that
126 /// they did. Returns `None` when the strings are byte-identical.
127 pub(crate) fn first_divergence(a: &str, b: &str) -> Option<(usize, String, String)> {
128 let a_bytes = a.as_bytes();
129 let b_bytes = b.as_bytes();
130 let max = a_bytes.len().min(b_bytes.len());
131 for i in 0..max {
132 if a_bytes[i] != b_bytes[i] {
133 let lo = i.saturating_sub(32);
134 let a_hi = (i + 32).min(a_bytes.len());
135 let b_hi = (i + 32).min(b_bytes.len());
136 let a_ctx = String::from_utf8_lossy(&a_bytes[lo..a_hi]).into_owned();
137 let b_ctx = String::from_utf8_lossy(&b_bytes[lo..b_hi]).into_owned();
138 return Some((i, a_ctx, b_ctx));
139 }
140 }
141 if a_bytes.len() != b_bytes.len() {
142 return Some((
143 max,
144 format!("(len={})", a_bytes.len()),
145 format!("(len={})", b_bytes.len()),
146 ));
147 }
148 None
149 }
150
151 /// Assert two strings are byte-identical, panicking with a windowed diff
152 /// around the first divergence when they aren't. Used by the prefix-cache
153 /// stability harness (#263, #280) to pin construction surfaces that land in
154 /// DeepSeek's KV cache prefix.
155 #[track_caller]
156 pub(crate) fn assert_byte_identical(label: &str, a: &str, b: &str) {
157 if let Some((pos, a_ctx, b_ctx)) = first_divergence(a, b) {
158 panic!(
159 "{label}: prompt construction is non-deterministic — first diff at byte {pos}\n\
160 ── side A (±32B) ──\n{a_ctx:?}\n── side B (±32B) ──\n{b_ctx:?}",
161 );
162 }
163 }
164
165 // ── Shared App/TuiOptions fixtures (#3923) ──────────────────────────────
166 //
167 // Before this module owned them, `create_test_app` was copy-pasted across 28
168 // test modules, each spelling out the full `TuiOptions` literal — 87 literals
169 // in all. The copies had drifted: different modules pinned different locales,
170 // currencies, and onboarding flags without anyone having chosen that, which is
171 // the non-hermeticity behind the intermittent `config_command_allow_shell_*`
172 // failures. Adding a `TuiOptions` field meant editing up to 87 sites.
173 //
174 // Express intentional differences by mutating the returned value at the call
175 // site, so the difference is visible as a deliberate line of test code rather
176 // than hidden inside another near-identical literal.
177
178 /// Default `TuiOptions` for tests, pinned to the deepseek-v4-pro fixture route.
179 pub(crate) fn test_tui_options(workspace: impl AsRef<Path>) -> crate::tui::app::TuiOptions {
180 let workspace = workspace.as_ref().to_path_buf();
181 crate::tui::app::TuiOptions {
182 model: "deepseek-v4-pro".to_string(),
183 workspace,
184 config_path: None,
185 config_profile: None,
186 allow_shell: false,
187 use_alt_screen: true,
188 use_mouse_capture: false,
189 use_bracketed_paste: true,
190 max_subagents: 1,
191 skills_dir: PathBuf::from("."),
192 memory_path: PathBuf::from("memory.md"),
193 notes_path: PathBuf::from("notes.txt"),
194 mcp_config_path: PathBuf::from("mcp.json"),
195 use_memory: false,
196 // Majority-of-fixtures defaults, measured across the 89 literals this
197 // helper replaced. Modules that need the other value say so explicitly.
198 start_in_agent_mode: false,
199 skip_onboarding: true,
200 yolo: false,
201 resume_session_id: None,
202 initial_input: None,
203 startup_notice: None,
204 }
205 }
206
207 /// Build an `App` whose observable state does not depend on the developer's
208 /// machine.
209 ///
210 /// `App::new` consults real persisted settings (provider/model maps,
211 /// auto-model, route limits, locale, currency), so an un-pinned fixture
212 /// computes against whatever the developer last configured. Every pin below
213 /// exists because some test was observed to depend on it.
214 pub(crate) fn test_app_with_options(options: crate::tui::app::TuiOptions) -> crate::tui::app::App {
215 let config = crate::config::Config::default();
216 let mut app = crate::tui::app::App::new(options, &config);
217
218 // Deterministic presentation regardless of host locale.
219 app.cost_currency = crate::pricing::CostCurrency::Usd;
220 app.ui_locale = crate::localization::Locale::En;
221 // Transcript tests must not depend on a concurrently swapped settings
222 // home. Tests for hidden reasoning opt out explicitly.
223 app.show_thinking = true;
224 // Pin the route identity: without this, a machine with customized
225 // settings computes context-window assertions against a different model
226 // than the requested deepseek-v4-pro.
227 app.set_provider_identity(crate::config::ApiProvider::Deepseek, "deepseek");
228 app.billing_presentation = crate::route_billing::BillingPresentation::Metered;
229 app.model = "deepseek-v4-pro".to_string();
230 app.auto_model = false;
231 app.last_effective_model = None;
232 app.active_route_limits = None;
233 app.active_context_window_override = None;
234 // Fixtures replace `app.workspace` freely. Do not retain `App::new`'s real
235 // process cwd as a second discovery root: parallel tests and a large
236 // developer checkout can otherwise consume the bounded mention index
237 // before the fixture workspace is scanned.
238 app.composer.mention_cwd = None;
239 app
240 }
241
242 #[cfg(test)]
243 mod tests {
244 use super::*;
245 use std::sync::mpsc;
246 use std::time::Duration;
247
248 #[test]
249 fn unguarded_state_writes_use_isolated_test_root() {
250 const PROBE_ENV: &str = "CODEWHALE_TEST_STATE_ISOLATION_PROBE";
251 const RECEIPT_ENV: &str = "CODEWHALE_TEST_STATE_ISOLATION_RECEIPT";
252
253 if std::env::var_os(PROBE_ENV).is_some() {
254 let config_path =
255 crate::config_persistence::persist_root_bool_key(None, "allow_shell", true)
256 .expect("write isolated config");
257 let direct_config_path =
258 crate::config::save_workspace_trust(Path::new("/tmp/codewhale-test-workspace"))
259 .expect("write through direct default config path");
260 crate::settings::Settings::default()
261 .save()
262 .expect("write isolated settings");
263 let settings_path =
264 crate::settings::Settings::path().expect("resolve isolated settings");
265 let root = isolated_test_state_root();
266 assert!(config_path.starts_with(root), "{}", config_path.display());
267 assert!(
268 settings_path.starts_with(root),
269 "{}",
270 settings_path.display()
271 );
272 assert!(
273 direct_config_path.starts_with(root),
274 "{}",
275 direct_config_path.display()
276 );
277 let receipt = std::env::var_os(RECEIPT_ENV).expect("receipt path");
278 std::fs::write(
279 receipt,
280 format!(
281 "{}\n{}\n{}\n{}\n",
282 root.display(),
283 config_path.display(),
284 settings_path.display(),
285 direct_config_path.display()
286 ),
287 )
288 .expect("write isolation receipt");
289 return;
290 }
291
292 let sentinel = tempfile::tempdir().expect("sentinel home");
293 let user_state = sentinel.path().join(".codewhale");
294 std::fs::create_dir_all(&user_state).expect("create sentinel state");
295 let config_path = user_state.join("config.toml");
296 let settings_path = user_state.join("settings.toml");
297 let config_sentinel = b"# developer config sentinel\n";
298 let settings_sentinel = b"# developer settings sentinel\n";
299 std::fs::write(&config_path, config_sentinel).expect("seed config");
300 std::fs::write(&settings_path, settings_sentinel).expect("seed settings");
301 let receipt_path = sentinel.path().join("receipt.txt");
302
303 let output = std::process::Command::new(std::env::current_exe().expect("test binary"))
304 .arg("--exact")
305 .arg("test_support::tests::unguarded_state_writes_use_isolated_test_root")
306 .arg("--test-threads=1")
307 .env(PROBE_ENV, "1")
308 .env(RECEIPT_ENV, &receipt_path)
309 .env("HOME", sentinel.path())
310 .env("USERPROFILE", sentinel.path())
311 .env_remove("CODEWHALE_HOME")
312 .env_remove("CODEWHALE_CONFIG_PATH")
313 .env_remove("DEEPSEEK_CONFIG_PATH")
314 .output()
315 .expect("run isolated-state probe");
316 assert!(
317 output.status.success(),
318 "probe failed\nstdout:\n{}\nstderr:\n{}",
319 String::from_utf8_lossy(&output.stdout),
320 String::from_utf8_lossy(&output.stderr)
321 );
322
323 assert_eq!(
324 std::fs::read(&config_path).expect("read config sentinel"),
325 config_sentinel
326 );
327 assert_eq!(
328 std::fs::read(&settings_path).expect("read settings sentinel"),
329 settings_sentinel
330 );
331
332 let receipt = std::fs::read_to_string(&receipt_path).expect("read isolation receipt");
333 let mut paths = receipt.lines().map(PathBuf::from);
334 let isolated_root = paths.next().expect("root receipt");
335 let written_config = paths.next().expect("config receipt");
336 let written_settings = paths.next().expect("settings receipt");
337 let direct_config = paths.next().expect("direct config receipt");
338 assert!(!isolated_root.starts_with(sentinel.path()));
339 assert!(written_config.starts_with(&isolated_root));
340 assert!(written_settings.starts_with(&isolated_root));
341 assert!(direct_config.starts_with(&isolated_root));
342 assert!(written_config.exists());
343 assert!(written_settings.exists());
344 }
345
346 #[test]
347 fn config_path_read_waits_for_foreign_env_redirect_to_restore() {
348 let (started_tx, started_rx) = mpsc::channel();
349 let (tx, rx) = mpsc::channel();
350 let redirected = std::env::temp_dir().join(format!(
351 "codewhale-config-path-read-barrier-{}",
352 std::process::id()
353 ));
354
355 let reader = {
356 let lock = lock_test_env();
357 let redirect = EnvVarGuard::set("DEEPSEEK_CONFIG_PATH", &redirected);
358 let reader = std::thread::spawn(move || {
359 started_tx.send(()).expect("signal config path read start");
360 tx.send(crate::config_persistence::config_toml_path(None))
361 .expect("send resolved config path");
362 });
363
364 started_rx
365 .recv_timeout(Duration::from_secs(2))
366 .expect("reader reached config path resolution");
367 assert!(
368 rx.recv_timeout(Duration::from_millis(50)).is_err(),
369 "a foreign reader observed the temporary config redirect"
370 );
371 drop(redirect);
372 drop(lock);
373 reader
374 };
375
376 let resolved = rx
377 .recv_timeout(Duration::from_secs(2))
378 .expect("reader resumed after the redirect was restored")
379 .expect("resolve config path");
380 reader.join().expect("reader thread");
381 assert_ne!(resolved, redirected);
382 }
383
384 #[test]
385 fn settings_save_waits_for_foreign_state_io_transaction() {
386 let (holder_ready_tx, holder_ready_rx) = mpsc::channel();
387 let (release_tx, release_rx) = mpsc::channel();
388 let holder = std::thread::spawn(move || {
389 with_test_state_io_lock(|| {
390 holder_ready_tx.send(()).expect("signal state lock held");
391 release_rx.recv().expect("release state lock");
392 });
393 });
394 holder_ready_rx
395 .recv_timeout(Duration::from_secs(2))
396 .expect("holder acquired state I/O lock");
397
398 let (started_tx, started_rx) = mpsc::channel();
399 let (saved_tx, saved_rx) = mpsc::channel();
400 let writer = std::thread::spawn(move || {
401 started_tx.send(()).expect("signal settings save start");
402 saved_tx
403 .send(crate::settings::Settings::default().save())
404 .expect("send settings save result");
405 });
406 started_rx
407 .recv_timeout(Duration::from_secs(2))
408 .expect("writer reached settings save");
409 assert!(
410 saved_rx.recv_timeout(Duration::from_millis(50)).is_err(),
411 "settings save did not wait for an in-flight state transaction"
412 );
413
414 release_tx.send(()).expect("release holder");
415 holder.join().expect("holder thread");
416 saved_rx
417 .recv_timeout(Duration::from_secs(2))
418 .expect("settings save resumed")
419 .expect("settings save succeeded");
420 writer.join().expect("writer thread");
421 }
422 }
423
423 lines RUST