返回 CodeWhale
harness.rs
根目录 / crates / tui / tests / support / qa_harness / harness.rs
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
745 lines RUST