| 1 | //! End-to-end harness composing [`PtySession`] + [`Frame`]. |
| 2 | //! |
| 3 | //! Tests build a [`Harness`] via [`Harness::builder`], drive the TUI with |
| 4 | //! [`Harness::send`] / [`Harness::paste`], poll the parsed terminal state |
| 5 | //! with [`Harness::wait_for`], and assert on [`Harness::frame`] / |
| 6 | //! filesystem state. |
| 7 | |
| 8 | use std::collections::HashMap; |
| 9 | use std::path::{Path, PathBuf}; |
| 10 | use std::time::{Duration, Instant}; |
| 11 | |
| 12 | use anyhow::{Context, Result, anyhow}; |
| 13 | |
| 14 | use super::{Frame, PtySession}; |
| 15 | |
| 16 | /// Scale a wait budget for shared CI runners. |
| 17 | /// |
| 18 | /// PTY scenarios boot a real binary and wait on real terminal output, and the |
| 19 | /// budgets in the scenarios are tuned for a developer laptop running one test |
| 20 | /// at a time. CI runs the whole workspace suite on a shared runner, where the |
| 21 | /// same output can legitimately arrive several times later. Every budget this |
| 22 | /// scales is a deadline on a poll that returns as soon as the condition holds, |
| 23 | /// so a larger budget never slows a passing run — it only changes how long a |
| 24 | /// genuinely stuck scenario waits before failing. Local runs keep the tight |
| 25 | /// value so a real hang still surfaces quickly while developing. |
| 26 | pub fn ci_scaled(base: Duration) -> Duration { |
| 27 | if std::env::var_os("CI").is_some() { |
| 28 | base * 4 |
| 29 | } else { |
| 30 | base |
| 31 | } |
| 32 | } |
| 33 | |
| 34 | pub struct Harness { |
| 35 | pty: PtySession, |
| 36 | frame: Frame, |
| 37 | last_pump: Instant, |
| 38 | cursor_query_tail: Vec<u8>, |
| 39 | program: PathBuf, |
| 40 | sealed_home: Option<PathBuf>, |
| 41 | diagnostic_root: PathBuf, |
| 42 | terminal_environment: String, |
| 43 | } |
| 44 | |
| 45 | pub struct HarnessBuilder { |
| 46 | program: PathBuf, |
| 47 | args: Vec<String>, |
| 48 | cwd: Option<PathBuf>, |
| 49 | env: HashMap<String, String>, |
| 50 | rows: u16, |
| 51 | cols: u16, |
| 52 | clear_env: bool, |
| 53 | seal_home: Option<PathBuf>, |
| 54 | } |
| 55 | |
| 56 | impl HarnessBuilder { |
| 57 | pub fn new(program: impl Into<PathBuf>) -> Self { |
| 58 | // PTY scenarios must never emit product telemetry merely because they |
| 59 | // launch a real binary in a fresh HOME. Tests that explicitly exercise |
| 60 | // the first-run disclosure can override this value on their builder. |
| 61 | let env = HashMap::from([("CODEWHALE_TELEMETRY".to_string(), "0".to_string())]); |
| 62 | Self { |
| 63 | program: program.into(), |
| 64 | args: Vec::new(), |
| 65 | cwd: None, |
| 66 | env, |
| 67 | rows: 40, |
| 68 | cols: 120, |
| 69 | clear_env: false, |
| 70 | seal_home: None, |
| 71 | } |
| 72 | } |
| 73 | |
| 74 | pub fn args<I, S>(mut self, args: I) -> Self |
| 75 | where |
| 76 | I: IntoIterator<Item = S>, |
| 77 | S: Into<String>, |
| 78 | { |
| 79 | self.args.extend(args.into_iter().map(Into::into)); |
| 80 | self |
| 81 | } |
| 82 | |
| 83 | pub fn cwd(mut self, p: impl Into<PathBuf>) -> Self { |
| 84 | self.cwd = Some(p.into()); |
| 85 | self |
| 86 | } |
| 87 | |
| 88 | pub fn env(mut self, k: impl Into<String>, v: impl Into<String>) -> Self { |
| 89 | self.env.insert(k.into(), v.into()); |
| 90 | self |
| 91 | } |
| 92 | |
| 93 | pub fn size(mut self, rows: u16, cols: u16) -> Self { |
| 94 | self.rows = rows; |
| 95 | self.cols = cols; |
| 96 | self |
| 97 | } |
| 98 | |
| 99 | pub fn clear_env(mut self) -> Self { |
| 100 | self.clear_env = true; |
| 101 | self |
| 102 | } |
| 103 | |
| 104 | /// Point `$HOME` (and config/cache defaults) at a fresh dir so the spawned |
| 105 | /// binary cannot read or mutate the developer's real user config. |
| 106 | pub fn seal_home(mut self, home: impl Into<PathBuf>) -> Self { |
| 107 | self.seal_home = Some(home.into()); |
| 108 | self |
| 109 | } |
| 110 | |
| 111 | pub fn spawn(self) -> Result<Harness> { |
| 112 | let mut builder = PtySession::builder(&self.program) |
| 113 | .args(self.args.iter().cloned()) |
| 114 | .size(self.rows, self.cols); |
| 115 | if self.clear_env { |
| 116 | builder = builder.clear_env(true); |
| 117 | } |
| 118 | if let Some(cwd) = self.cwd.as_deref() { |
| 119 | builder = builder.cwd(cwd); |
| 120 | } |
| 121 | if let Some(home) = self.seal_home.as_deref() { |
| 122 | std::fs::create_dir_all(home).context("create sealed HOME")?; |
| 123 | let codewhale_config = home.join(".codewhale").join("config.toml"); |
| 124 | let deepseek_config = home.join(".deepseek").join("config.toml"); |
| 125 | builder = builder |
| 126 | .env("HOME", home.to_string_lossy()) |
| 127 | .env("XDG_CONFIG_HOME", home.join(".config").to_string_lossy()) |
| 128 | .env("XDG_DATA_HOME", home.join(".local/share").to_string_lossy()) |
| 129 | .env("XDG_CACHE_HOME", home.join(".cache").to_string_lossy()) |
| 130 | .env("USERPROFILE", home.to_string_lossy()) |
| 131 | .env("CODEWHALE_CONFIG_PATH", codewhale_config.to_string_lossy()) |
| 132 | .env("DEEPSEEK_CONFIG_PATH", deepseek_config.to_string_lossy()) |
| 133 | // Sealing the filesystem is not enough on its own: the startup |
| 134 | // Ollama probe reaches the developer's machine over loopback, |
| 135 | // and adopting a live :11434 catalog rewrites the very launch |
| 136 | // screen these suites wait for. |
| 137 | .env("CODEWHALE_DISABLE_LOCAL_OLLAMA_PROBE", "1"); |
| 138 | } |
| 139 | for (k, v) in &self.env { |
| 140 | builder = builder.env(k, v); |
| 141 | } |
| 142 | |
| 143 | let diagnostic_root = self |
| 144 | .env |
| 145 | .get("QA_PTY_DIAGNOSTICS_DIR") |
| 146 | .map(PathBuf::from) |
| 147 | .or_else(|| std::env::var_os("QA_PTY_DIAGNOSTICS_DIR").map(PathBuf::from)) |
| 148 | .unwrap_or_else(|| std::env::temp_dir().join("codewhale-pty-failures")); |
| 149 | // Report only terminal capabilities, never the inherited environment |
| 150 | // (which may contain developer credentials on unsealed scenarios). |
| 151 | let terminal_environment = ["TERM", "COLORTERM", "NO_COLOR"] |
| 152 | .into_iter() |
| 153 | .map(|key| { |
| 154 | let default = match key { |
| 155 | "TERM" => "xterm-256color", |
| 156 | "COLORTERM" => "truecolor", |
| 157 | _ => "<unset>", |
| 158 | }; |
| 159 | let value = self |
| 160 | .env |
| 161 | .get(key) |
| 162 | .cloned() |
| 163 | .or_else(|| { |
| 164 | (!self.clear_env && key == "NO_COLOR") |
| 165 | .then(|| std::env::var(key).ok()) |
| 166 | .flatten() |
| 167 | }) |
| 168 | .unwrap_or_else(|| default.to_string()); |
| 169 | format!("{key}={value:?}") |
| 170 | }) |
| 171 | .collect::<Vec<_>>() |
| 172 | .join(" "); |
| 173 | |
| 174 | // Arm the stall watchdog before the child exists, so a spawn that wedges |
| 175 | // is covered too. Idempotent per process. |
| 176 | super::watchdog::arm(); |
| 177 | let pty = builder.spawn().context("spawn PtySession")?; |
| 178 | let frame = Frame::new(self.rows, self.cols); |
| 179 | Ok(Harness { |
| 180 | pty, |
| 181 | frame, |
| 182 | last_pump: Instant::now(), |
| 183 | cursor_query_tail: Vec::new(), |
| 184 | program: self.program, |
| 185 | sealed_home: self.seal_home, |
| 186 | diagnostic_root, |
| 187 | terminal_environment, |
| 188 | }) |
| 189 | } |
| 190 | } |
| 191 | |
| 192 | impl Harness { |
| 193 | pub fn builder(program: impl Into<PathBuf>) -> HarnessBuilder { |
| 194 | HarnessBuilder::new(program) |
| 195 | } |
| 196 | |
| 197 | pub fn pid(&self) -> Option<u32> { |
| 198 | self.pty.pid() |
| 199 | } |
| 200 | |
| 201 | pub fn send(&mut self, bytes: impl AsRef<[u8]>) -> Result<()> { |
| 202 | self.pty.write_bytes(bytes.as_ref()) |
| 203 | } |
| 204 | |
| 205 | pub fn resize(&mut self, rows: u16, cols: u16) -> Result<()> { |
| 206 | self.pty.resize(rows, cols)?; |
| 207 | self.frame.resize(rows, cols); |
| 208 | Ok(()) |
| 209 | } |
| 210 | |
| 211 | pub fn paste(&mut self, text: &str) -> Result<()> { |
| 212 | self.pty.write_bytes(&super::paste::bracketed(text)) |
| 213 | } |
| 214 | |
| 215 | pub fn paste_unbracketed(&mut self, text: &str) -> Result<()> { |
| 216 | self.pty.write_bytes(&super::paste::unbracketed(text)) |
| 217 | } |
| 218 | |
| 219 | /// Type a line of plain text and submit it: the text goes in as a |
| 220 | /// bracketed paste — this harness's terminal advertises bracketed |
| 221 | /// paste, and bulk text delivered as one zero-gap keystroke write is |
| 222 | /// (correctly) paste-classified by the burst heuristic, whose Enter |
| 223 | /// suppression window then swallows the submit — then a beat of idle, |
| 224 | /// then Enter. The real `Event::Paste` also disarms the heuristic for |
| 225 | /// the rest of the session, so later scripted typing behaves like a |
| 226 | /// terminal with verified bracketed paste. Slash commands don't need |
| 227 | /// this helper (Enter flushes buffered command text); plain prompts do. |
| 228 | pub fn type_line(&mut self, text: &str) -> Result<()> { |
| 229 | self.paste(text)?; |
| 230 | self.wait_for_text(text, Duration::from_secs(10))?; |
| 231 | std::thread::sleep(Duration::from_millis(150)); |
| 232 | self.pty.write_bytes(&super::keys::key::enter()) |
| 233 | } |
| 234 | |
| 235 | /// Pull whatever the child has written since last call into the frame |
| 236 | /// parser. Returns `true` if any new bytes arrived. |
| 237 | pub fn pump(&mut self) -> bool { |
| 238 | // Every bounded wait loops through here, so this is the harness's |
| 239 | // liveness signal for the stall watchdog. |
| 240 | super::watchdog::progress("pump"); |
| 241 | let bytes = self.pty.drain(); |
| 242 | let any = !bytes.is_empty(); |
| 243 | if any { |
| 244 | let cursor_queries = |
| 245 | consume_cursor_position_queries(&mut self.cursor_query_tail, &bytes); |
| 246 | self.frame.feed(&bytes); |
| 247 | if cursor_queries > 0 { |
| 248 | let (row, col) = self.frame.cursor(); |
| 249 | let response = format!("\x1b[{};{}R", row.saturating_add(1), col.saturating_add(1)); |
| 250 | for _ in 0..cursor_queries { |
| 251 | if self.pty.write_bytes(response.as_bytes()).is_err() { |
| 252 | break; |
| 253 | } |
| 254 | } |
| 255 | } |
| 256 | self.last_pump = Instant::now(); |
| 257 | } |
| 258 | any |
| 259 | } |
| 260 | |
| 261 | /// Pump output and return the parsed frame. Convenience for asserts. |
| 262 | pub fn frame(&mut self) -> &Frame { |
| 263 | self.pump(); |
| 264 | &self.frame |
| 265 | } |
| 266 | |
| 267 | /// Block (briefly sleeping) until `predicate(frame)` is true or `timeout` |
| 268 | /// elapses. Pumps the PTY on each tick. |
| 269 | pub fn wait_for<F>(&mut self, mut predicate: F, timeout: Duration) -> Result<()> |
| 270 | where |
| 271 | F: FnMut(&Frame) -> bool, |
| 272 | { |
| 273 | let budget = ci_scaled(timeout); |
| 274 | let deadline = Instant::now() + budget; |
| 275 | loop { |
| 276 | self.pump(); |
| 277 | if predicate(&self.frame) { |
| 278 | return Ok(()); |
| 279 | } |
| 280 | if Instant::now() >= deadline { |
| 281 | return Err(anyhow!( |
| 282 | "wait_for timed out after {:?}.\n{}", |
| 283 | budget, |
| 284 | self.failure_diagnostics(budget) |
| 285 | )); |
| 286 | } |
| 287 | std::thread::sleep(Duration::from_millis(40)); |
| 288 | } |
| 289 | } |
| 290 | |
| 291 | /// Wait for the literal substring to appear anywhere on the screen. |
| 292 | pub fn wait_for_text(&mut self, needle: &str, timeout: Duration) -> Result<()> { |
| 293 | let owned = needle.to_string(); |
| 294 | self.wait_for(move |f| f.contains(&owned), timeout) |
| 295 | } |
| 296 | |
| 297 | /// Wait for stable output: no new bytes for `quiet_for` consecutive |
| 298 | /// pump ticks, bounded by `max`. Useful for "let the UI settle". |
| 299 | pub fn wait_for_idle(&mut self, quiet_for: Duration, max: Duration) -> Result<()> { |
| 300 | // Only the ceiling scales: `quiet_for` is the definition of "settled", |
| 301 | // not a budget, and stretching it would change what the test asserts. |
| 302 | let budget = ci_scaled(max); |
| 303 | let max_deadline = Instant::now() + budget; |
| 304 | let mut quiet_since = Instant::now(); |
| 305 | loop { |
| 306 | if self.pump() { |
| 307 | quiet_since = Instant::now(); |
| 308 | } |
| 309 | if quiet_since.elapsed() >= quiet_for { |
| 310 | return Ok(()); |
| 311 | } |
| 312 | if Instant::now() >= max_deadline { |
| 313 | return Err(anyhow!( |
| 314 | "wait_for_idle: never settled within {:?}\n{}", |
| 315 | budget, |
| 316 | self.failure_diagnostics(budget) |
| 317 | )); |
| 318 | } |
| 319 | std::thread::sleep(Duration::from_millis(20)); |
| 320 | } |
| 321 | } |
| 322 | |
| 323 | /// Capture evidence while the child and sealed HOME still exist. A blank |
| 324 | /// parsed frame cannot distinguish no first draw from a later clear, and |
| 325 | /// the TUI redirects stderr into its runtime log before drawing. This is |
| 326 | /// diagnostics only: predicates, deadlines and teardown are unchanged. |
| 327 | fn failure_diagnostics(&mut self, budget: Duration) -> String { |
| 328 | self.pump(); |
| 329 | let transcript = self.pty.transcript(); |
| 330 | let pid = self.pty.pid(); |
| 331 | let exit = self.pty.wait_until(Instant::now()); |
| 332 | let program = self.program.clone(); |
| 333 | let diagnostic_root = self.diagnostic_root.clone(); |
| 334 | let sealed_home = self.sealed_home.clone(); |
| 335 | let terminal_environment = self.terminal_environment.clone(); |
| 336 | let frame_dump = self.frame.debug_dump(); |
| 337 | let modes_dump = self.terminal_modes().debug_dump(); |
| 338 | // Failure evidence can hash a large binary and read redirected logs. |
| 339 | // Keep that I/O on a dedicated worker, then join before fixture teardown |
| 340 | // can remove the sealed HOME. Readiness itself has already timed out. |
| 341 | let worker = std::thread::Builder::new() |
| 342 | .name("qa-pty-diagnostics".into()) |
| 343 | .spawn(move || { |
| 344 | let nonce = std::time::SystemTime::now() |
| 345 | .duration_since(std::time::UNIX_EPOCH) |
| 346 | .unwrap_or_default() |
| 347 | .as_nanos(); |
| 348 | let destination = diagnostic_root |
| 349 | .join(format!("{}-{nonce}", pid.unwrap_or_default())); |
| 350 | let mut report = format!( |
| 351 | "program={:?} host={}/{} pid={pid:?} observed_exit={exit:?} wait_budget={budget:?} parent_CI={} {}\nPTY bytes={}\n", |
| 352 | program, |
| 353 | std::env::consts::OS, |
| 354 | std::env::consts::ARCH, |
| 355 | std::env::var_os("CI").is_some(), |
| 356 | terminal_environment, |
| 357 | transcript.len(), |
| 358 | ); |
| 359 | report.push_str(&format!("diagnostic_worker={:?}\n", std::thread::current().name())); |
| 360 | // Hash the running Linux executable when available; the launch path |
| 361 | // may have been replaced by a concurrent build. Other hosts retain the |
| 362 | // launch-path digest, explicitly labelled as such. |
| 363 | let executable = pid |
| 364 | .map(|pid| PathBuf::from(format!("/proc/{pid}/exe"))) |
| 365 | .filter(|path| path.exists()) |
| 366 | .unwrap_or_else(|| program.clone()); |
| 367 | let digest = (|| -> std::io::Result<String> { |
| 368 | use sha2::{Digest, Sha256}; |
| 369 | let mut file = std::fs::File::open(&executable)?; |
| 370 | let mut hasher = Sha256::new(); |
| 371 | use std::io::Read; |
| 372 | let mut buffer = [0_u8; 64 * 1024]; |
| 373 | loop { |
| 374 | let count = file.read(&mut buffer)?; |
| 375 | if count == 0 { |
| 376 | break; |
| 377 | } |
| 378 | hasher.update(&buffer[..count]); |
| 379 | } |
| 380 | Ok(hasher |
| 381 | .finalize() |
| 382 | .iter() |
| 383 | .map(|byte| format!("{byte:02x}")) |
| 384 | .collect()) |
| 385 | })(); |
| 386 | report.push_str(&format!("executable={executable:?} sha256={digest:?}\n")); |
| 387 | // /proc is local, read-only and cheap. No debugger dependency or |
| 388 | // process environment is needed to locate a blocked Linux thread. |
| 389 | if let Some(pid) = pid { |
| 390 | let process = PathBuf::from(format!("/proc/{pid}")); |
| 391 | for name in ["status", "wchan"] { |
| 392 | if let Ok(text) = std::fs::read_to_string(process.join(name)) { |
| 393 | report.push_str(&format!("process {name}:\n{text}\n")); |
| 394 | } |
| 395 | } |
| 396 | if let Ok(entries) = std::fs::read_dir(process.join("task")) { |
| 397 | for entry in entries.flatten().take(128) { |
| 398 | let thread = entry.path(); |
| 399 | for name in ["comm", "wchan", "stack"] { |
| 400 | let text = std::fs::read_to_string(thread.join(name)) |
| 401 | .unwrap_or_else(|error| format!("unavailable: {error}")); |
| 402 | report |
| 403 | .push_str(&format!("thread {:?} {name}: {text}\n", entry.file_name())); |
| 404 | } |
| 405 | } |
| 406 | } |
| 407 | } |
| 408 | let saved = (|| -> std::io::Result<()> { |
| 409 | let mut directories = std::fs::DirBuilder::new(); |
| 410 | directories.recursive(true); |
| 411 | #[cfg(unix)] |
| 412 | { |
| 413 | use std::os::unix::fs::DirBuilderExt; |
| 414 | directories.mode(0o700); |
| 415 | } |
| 416 | directories.create(&destination)?; |
| 417 | let write_private = |path: &Path, contents: &[u8]| -> std::io::Result<()> { |
| 418 | use std::io::Write; |
| 419 | let mut options = std::fs::OpenOptions::new(); |
| 420 | options.write(true).create_new(true); |
| 421 | #[cfg(unix)] |
| 422 | { |
| 423 | use std::os::unix::fs::OpenOptionsExt; |
| 424 | options.mode(0o600); |
| 425 | } |
| 426 | options.open(path)?.write_all(contents) |
| 427 | }; |
| 428 | write_private(&destination.join("pty.raw"), &transcript)?; |
| 429 | write_private( |
| 430 | &destination.join("frame.txt"), |
| 431 | frame_dump.as_bytes(), |
| 432 | )?; |
| 433 | // Copy only runtime logs under the explicitly sealed fixture; |
| 434 | // never walk a developer's HOME or copy configuration/credentials. |
| 435 | if let Some(home) = &sealed_home { |
| 436 | for relative in [".codewhale/logs", ".deepseek/logs"] { |
| 437 | let directory = home.join(relative); |
| 438 | if let Ok(entries) = std::fs::read_dir(&directory) { |
| 439 | for entry in entries.flatten() { |
| 440 | let name = entry.file_name(); |
| 441 | let name_text = name.to_string_lossy(); |
| 442 | if !name_text.starts_with("tui-") |
| 443 | || !name_text.ends_with(".log") |
| 444 | || !entry.file_type()?.is_file() |
| 445 | { |
| 446 | continue; |
| 447 | } |
| 448 | let target = destination.join(relative).join(&name); |
| 449 | directories.create(target.parent().unwrap())?; |
| 450 | write_private(&target, &std::fs::read(entry.path())?)?; |
| 451 | report.push_str(&format!("runtime stderr: {}\n", target.display())); |
| 452 | } |
| 453 | } |
| 454 | } |
| 455 | } |
| 456 | write_private(&destination.join("process.txt"), report.as_bytes())?; |
| 457 | Ok(()) |
| 458 | })(); |
| 459 | match saved { |
| 460 | Ok(()) => report.push_str(&format!("failure artifacts: {}\n", destination.display())), |
| 461 | Err(error) => report.push_str(&format!("failure artifact capture failed: {error}\n")), |
| 462 | } |
| 463 | let tail = &transcript[transcript.len().saturating_sub(4096)..]; |
| 464 | format!( |
| 465 | "{}{}\nPTY tail: {:?}\n{}", |
| 466 | frame_dump, |
| 467 | modes_dump, |
| 468 | String::from_utf8_lossy(tail), |
| 469 | report |
| 470 | ) |
| 471 | }); |
| 472 | match worker { |
| 473 | Ok(worker) => worker.join().unwrap_or_else(|_| { |
| 474 | format!( |
| 475 | "{}\nfailure diagnostic worker panicked", |
| 476 | self.frame.debug_dump() |
| 477 | ) |
| 478 | }), |
| 479 | Err(error) => format!( |
| 480 | "{}\nfailure diagnostic worker could not start: {error}", |
| 481 | self.frame.debug_dump() |
| 482 | ), |
| 483 | } |
| 484 | } |
| 485 | |
| 486 | /// Resolve a binary by Cargo bin-name (uses `CARGO_BIN_EXE_<name>`). |
| 487 | /// Tests should call this rather than hard-coding paths. |
| 488 | /// |
| 489 | /// `QA_TUI_BIN` overrides the `codewhale-tui` resolution entirely — cargo |
| 490 | /// itself always sets `CARGO_BIN_EXE_*` for integration tests, so an |
| 491 | /// inherited value cannot win. Release QA uses this to A/B an older |
| 492 | /// released binary against the in-tree build (e.g. the #5424 sweep). |
| 493 | pub fn cargo_bin(name: &str) -> PathBuf { |
| 494 | if name == "codewhale-tui" |
| 495 | && let Some(path) = std::env::var_os("QA_TUI_BIN") |
| 496 | && !path.is_empty() |
| 497 | { |
| 498 | return PathBuf::from(path); |
| 499 | } |
| 500 | // Newer Cargo exposes CARGO_BIN_EXE_* at runtime; older supported |
| 501 | // Cargo versions expose it to the integration test at compile time. |
| 502 | let key = format!("CARGO_BIN_EXE_{name}"); |
| 503 | if let Some(path) = std::env::var_os(&key) { |
| 504 | return PathBuf::from(path); |
| 505 | } |
| 506 | if name == "codewhale-tui" |
| 507 | && let Some(path) = option_env!("CARGO_BIN_EXE_codewhale-tui") |
| 508 | { |
| 509 | return PathBuf::from(path); |
| 510 | } |
| 511 | panic!("env {key} not set; is the binary declared in this crate?") |
| 512 | } |
| 513 | |
| 514 | /// Best-effort cooperative shutdown. |
| 515 | pub fn shutdown(self) -> Option<i32> { |
| 516 | self.pty.shutdown(Duration::from_secs(2)) |
| 517 | } |
| 518 | |
| 519 | /// Wait for the child process to exit without sending it a signal. |
| 520 | pub fn wait_for_exit(&mut self, timeout: Duration) -> Option<i32> { |
| 521 | self.pty.wait_until(Instant::now() + ci_scaled(timeout)) |
| 522 | } |
| 523 | |
| 524 | pub fn debug_dump(&mut self) -> String { |
| 525 | self.pump(); |
| 526 | self.frame.debug_dump() |
| 527 | } |
| 528 | |
| 529 | /// Every byte the child has written, from spawn to now. Survives `pump`, |
| 530 | /// so terminal-mode assertions stay valid after the frame parser has |
| 531 | /// consumed the stream. |
| 532 | pub fn transcript(&self) -> Vec<u8> { |
| 533 | self.pty.transcript() |
| 534 | } |
| 535 | |
| 536 | /// Replay the transcript into a [`TerminalModeLedger`]. |
| 537 | pub fn terminal_modes(&self) -> super::TerminalModeLedger { |
| 538 | super::TerminalModeLedger::from_transcript(&self.transcript()) |
| 539 | } |
| 540 | |
| 541 | /// Frame dump plus terminal-mode ledger. Every bounded wait in the matrix |
| 542 | /// fails with this rather than a bare `assertion failed`, so a CI timeout |
| 543 | /// carries the screen *and* the control-stream state that produced it. |
| 544 | pub fn diagnostics(&mut self) -> String { |
| 545 | let modes = self.terminal_modes().debug_dump(); |
| 546 | format!("{}{modes}", self.debug_dump()) |
| 547 | } |
| 548 | } |
| 549 | |
| 550 | const CURSOR_POSITION_QUERIES: [&[u8]; 2] = [b"\x1b[6n", b"\x1b[?6n"]; |
| 551 | |
| 552 | /// Consume terminal cursor-position queries from a chunked PTY output stream. |
| 553 | /// |
| 554 | /// Crossterm asks the terminal for its cursor after Ratatui clears the screen. |
| 555 | /// A real terminal answers that DSR request; the QA PTY must do the same or the |
| 556 | /// child waits for crossterm's timeout before it can paint its first frame. |
| 557 | fn consume_cursor_position_queries(tail: &mut Vec<u8>, bytes: &[u8]) -> usize { |
| 558 | let mut stream = std::mem::take(tail); |
| 559 | stream.extend_from_slice(bytes); |
| 560 | |
| 561 | let mut count = 0; |
| 562 | let mut index = 0; |
| 563 | while index < stream.len() { |
| 564 | if let Some(query) = CURSOR_POSITION_QUERIES |
| 565 | .iter() |
| 566 | .find(|query| stream[index..].starts_with(query)) |
| 567 | { |
| 568 | count += 1; |
| 569 | index += query.len(); |
| 570 | } else { |
| 571 | index += 1; |
| 572 | } |
| 573 | } |
| 574 | |
| 575 | let max_tail = CURSOR_POSITION_QUERIES |
| 576 | .iter() |
| 577 | .map(|query| query.len().saturating_sub(1)) |
| 578 | .max() |
| 579 | .unwrap_or(0) |
| 580 | .min(stream.len()); |
| 581 | let keep = (1..=max_tail) |
| 582 | .rev() |
| 583 | .find(|&len| { |
| 584 | CURSOR_POSITION_QUERIES |
| 585 | .iter() |
| 586 | .any(|query| len < query.len() && query.starts_with(&stream[stream.len() - len..])) |
| 587 | }) |
| 588 | .unwrap_or(0); |
| 589 | tail.extend_from_slice(&stream[stream.len() - keep..]); |
| 590 | count |
| 591 | } |
| 592 | |
| 593 | /// Construct a sealed-`HOME` workspace under a `tempfile::TempDir` so the |
| 594 | /// scenario can never read or mutate the developer's real config / skills. |
| 595 | pub fn make_sealed_workspace() -> Result<SealedWorkspace> { |
| 596 | let tmp = tempfile::TempDir::new().context("tempdir")?; |
| 597 | let workspace = tmp.path().join("workspace"); |
| 598 | let home = tmp.path().join("home"); |
| 599 | std::fs::create_dir_all(&workspace).context("mkdir workspace")?; |
| 600 | std::fs::create_dir_all(home.join(".codewhale")).context("mkdir home/.codewhale")?; |
| 601 | std::fs::create_dir_all(home.join(".deepseek")).context("mkdir home/.deepseek")?; |
| 602 | let silent_notifications = "[notifications]\nmethod = \"off\"\ncompletion_sound = \"off\"\n"; |
| 603 | std::fs::write( |
| 604 | home.join(".codewhale").join("config.toml"), |
| 605 | silent_notifications, |
| 606 | ) |
| 607 | .context("write silent CodeWhale PTY config")?; |
| 608 | std::fs::write( |
| 609 | home.join(".deepseek").join("config.toml"), |
| 610 | silent_notifications, |
| 611 | ) |
| 612 | .context("write silent legacy PTY config")?; |
| 613 | Ok(SealedWorkspace { |
| 614 | _tmp: tmp, |
| 615 | workspace, |
| 616 | home, |
| 617 | }) |
| 618 | } |
| 619 | |
| 620 | pub struct SealedWorkspace { |
| 621 | _tmp: tempfile::TempDir, |
| 622 | pub workspace: PathBuf, |
| 623 | pub home: PathBuf, |
| 624 | } |
| 625 | |
| 626 | impl SealedWorkspace { |
| 627 | pub fn workspace(&self) -> &Path { |
| 628 | &self.workspace |
| 629 | } |
| 630 | pub fn home(&self) -> &Path { |
| 631 | &self.home |
| 632 | } |
| 633 | pub fn user_skills_dir(&self) -> PathBuf { |
| 634 | self.home.join(".deepseek").join("skills") |
| 635 | } |
| 636 | } |
| 637 | |
| 638 | #[cfg(test)] |
| 639 | mod tests { |
| 640 | use super::consume_cursor_position_queries; |
| 641 | |
| 642 | #[cfg(unix)] |
| 643 | #[test] |
| 644 | fn failed_wait_retains_raw_output_and_sealed_stderr_before_teardown() { |
| 645 | let directory = tempfile::tempdir().unwrap(); |
| 646 | let home = directory.path().join("home"); |
| 647 | let logs = home.join(".codewhale/logs"); |
| 648 | std::fs::create_dir_all(&logs).unwrap(); |
| 649 | std::fs::write( |
| 650 | logs.join("tui-fixture.log"), |
| 651 | "startup stopped before first draw", |
| 652 | ) |
| 653 | .unwrap(); |
| 654 | std::fs::write(home.join("secret.txt"), "must never be copied").unwrap(); |
| 655 | let artifacts = directory.path().join("evidence"); |
| 656 | let mut harness = super::Harness::builder("/bin/sh") |
| 657 | .clear_env() |
| 658 | .seal_home(&home) |
| 659 | .env("QA_PTY_DIAGNOSTICS_DIR", artifacts.to_string_lossy()) |
| 660 | .args([ |
| 661 | "-c", |
| 662 | r"printf '\033[Hdrawn then erased\033[2J\033[H'; sleep 3", |
| 663 | ]) |
| 664 | .spawn() |
| 665 | .unwrap(); |
| 666 | // Wait for the raw clear itself, without relying on a scheduling sleep. |
| 667 | let deadline = |
| 668 | std::time::Instant::now() + super::ci_scaled(std::time::Duration::from_secs(2)); |
| 669 | while !harness |
| 670 | .transcript() |
| 671 | .windows(4) |
| 672 | .any(|part| part == b"\x1b[2J") |
| 673 | { |
| 674 | harness.pump(); |
| 675 | assert!( |
| 676 | std::time::Instant::now() < deadline, |
| 677 | "fixture did not emit clear" |
| 678 | ); |
| 679 | std::thread::sleep(std::time::Duration::from_millis(5)); |
| 680 | } |
| 681 | let error = harness |
| 682 | .wait_for_text("never emitted", std::time::Duration::from_millis(20)) |
| 683 | .unwrap_err(); |
| 684 | assert!(error.to_string().contains("failure artifacts:")); |
| 685 | assert!(!harness.frame().contains("drawn then erased")); |
| 686 | let evidence = std::fs::read_dir(&artifacts) |
| 687 | .unwrap() |
| 688 | .next() |
| 689 | .unwrap() |
| 690 | .unwrap() |
| 691 | .path(); |
| 692 | harness.shutdown(); |
| 693 | let raw = std::fs::read(evidence.join("pty.raw")).unwrap(); |
| 694 | assert!(raw.windows(17).any(|part| part == b"drawn then erased")); |
| 695 | assert_eq!( |
| 696 | std::fs::read_to_string(evidence.join(".codewhale/logs/tui-fixture.log")).unwrap(), |
| 697 | "startup stopped before first draw" |
| 698 | ); |
| 699 | assert!(!evidence.join("secret.txt").exists()); |
| 700 | assert!( |
| 701 | !error |
| 702 | .to_string() |
| 703 | .contains("startup stopped before first draw") |
| 704 | ); |
| 705 | use std::os::unix::fs::PermissionsExt; |
| 706 | for path in [ |
| 707 | evidence.clone(), |
| 708 | evidence.join("pty.raw"), |
| 709 | evidence.join(".codewhale/logs/tui-fixture.log"), |
| 710 | ] { |
| 711 | assert_eq!( |
| 712 | std::fs::metadata(path).unwrap().permissions().mode() & 0o077, |
| 713 | 0 |
| 714 | ); |
| 715 | } |
| 716 | let process = std::fs::read_to_string(evidence.join("process.txt")).unwrap(); |
| 717 | assert!(process.contains("pid=Some(")); |
| 718 | assert!(process.contains("diagnostic_worker=Some(\"qa-pty-diagnostics\")")); |
| 719 | assert!(process.contains("wait_budget=")); |
| 720 | assert!(process.contains("sha256=Ok(")); |
| 721 | assert!(process.contains("TERM=\"xterm-256color\"")); |
| 722 | } |
| 723 | |
| 724 | #[test] |
| 725 | fn cursor_position_queries_survive_chunk_boundaries() { |
| 726 | let mut tail = Vec::new(); |
| 727 | assert_eq!( |
| 728 | consume_cursor_position_queries(&mut tail, b"before\x1b["), |
| 729 | 0 |
| 730 | ); |
| 731 | assert_eq!(consume_cursor_position_queries(&mut tail, b"6nafter"), 1); |
| 732 | assert!(tail.is_empty()); |
| 733 | } |
| 734 | |
| 735 | #[test] |
| 736 | fn cursor_position_queries_accept_standard_and_dec_forms() { |
| 737 | let mut tail = Vec::new(); |
| 738 | assert_eq!( |
| 739 | consume_cursor_position_queries(&mut tail, b"\x1b[6n\x1b[?6n"), |
| 740 | 2 |
| 741 | ); |
| 742 | assert!(tail.is_empty()); |
| 743 | } |
| 744 | } |
| 745 |