返回 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 }
40
41 pub struct HarnessBuilder {
42 program: PathBuf,
43 args: Vec<String>,
44 cwd: Option<PathBuf>,
45 env: HashMap<String, String>,
46 rows: u16,
47 cols: u16,
48 clear_env: bool,
49 seal_home: Option<PathBuf>,
50 }
51
52 impl HarnessBuilder {
53 pub fn new(program: impl Into<PathBuf>) -> Self {
54 Self {
55 program: program.into(),
56 args: Vec::new(),
57 cwd: None,
58 env: HashMap::new(),
59 rows: 40,
60 cols: 120,
61 clear_env: false,
62 seal_home: None,
63 }
64 }
65
66 pub fn args<I, S>(mut self, args: I) -> Self
67 where
68 I: IntoIterator<Item = S>,
69 S: Into<String>,
70 {
71 self.args.extend(args.into_iter().map(Into::into));
72 self
73 }
74
75 pub fn cwd(mut self, p: impl Into<PathBuf>) -> Self {
76 self.cwd = Some(p.into());
77 self
78 }
79
80 pub fn env(mut self, k: impl Into<String>, v: impl Into<String>) -> Self {
81 self.env.insert(k.into(), v.into());
82 self
83 }
84
85 pub fn size(mut self, rows: u16, cols: u16) -> Self {
86 self.rows = rows;
87 self.cols = cols;
88 self
89 }
90
91 pub fn clear_env(mut self) -> Self {
92 self.clear_env = true;
93 self
94 }
95
96 /// Point `$HOME` (and config/cache defaults) at a fresh dir so the spawned
97 /// binary cannot read or mutate the developer's real user config.
98 pub fn seal_home(mut self, home: impl Into<PathBuf>) -> Self {
99 self.seal_home = Some(home.into());
100 self
101 }
102
103 pub fn spawn(self) -> Result<Harness> {
104 let mut builder = PtySession::builder(&self.program)
105 .args(self.args.iter().cloned())
106 .size(self.rows, self.cols);
107 if self.clear_env {
108 builder = builder.clear_env(true);
109 }
110 if let Some(cwd) = self.cwd.as_deref() {
111 builder = builder.cwd(cwd);
112 }
113 if let Some(home) = self.seal_home.as_deref() {
114 std::fs::create_dir_all(home).context("create sealed HOME")?;
115 let codewhale_config = home.join(".codewhale").join("config.toml");
116 let deepseek_config = home.join(".deepseek").join("config.toml");
117 builder = builder
118 .env("HOME", home.to_string_lossy())
119 .env("XDG_CONFIG_HOME", home.join(".config").to_string_lossy())
120 .env("XDG_DATA_HOME", home.join(".local/share").to_string_lossy())
121 .env("XDG_CACHE_HOME", home.join(".cache").to_string_lossy())
122 .env("USERPROFILE", home.to_string_lossy())
123 .env("CODEWHALE_CONFIG_PATH", codewhale_config.to_string_lossy())
124 .env("DEEPSEEK_CONFIG_PATH", deepseek_config.to_string_lossy());
125 }
126 for (k, v) in &self.env {
127 builder = builder.env(k, v);
128 }
129
130 let pty = builder.spawn().context("spawn PtySession")?;
131 let frame = Frame::new(self.rows, self.cols);
132 Ok(Harness {
133 pty,
134 frame,
135 last_pump: Instant::now(),
136 cursor_query_tail: Vec::new(),
137 })
138 }
139 }
140
141 impl Harness {
142 pub fn builder(program: impl Into<PathBuf>) -> HarnessBuilder {
143 HarnessBuilder::new(program)
144 }
145
146 pub fn pid(&self) -> Option<u32> {
147 self.pty.pid()
148 }
149
150 pub fn send(&mut self, bytes: impl AsRef<[u8]>) -> Result<()> {
151 self.pty.write_bytes(bytes.as_ref())
152 }
153
154 pub fn resize(&mut self, rows: u16, cols: u16) -> Result<()> {
155 self.pty.resize(rows, cols)?;
156 self.frame.resize(rows, cols);
157 Ok(())
158 }
159
160 pub fn paste(&mut self, text: &str) -> Result<()> {
161 self.pty.write_bytes(&super::paste::bracketed(text))
162 }
163
164 pub fn paste_unbracketed(&mut self, text: &str) -> Result<()> {
165 self.pty.write_bytes(&super::paste::unbracketed(text))
166 }
167
168 /// Pull whatever the child has written since last call into the frame
169 /// parser. Returns `true` if any new bytes arrived.
170 pub fn pump(&mut self) -> bool {
171 let bytes = self.pty.drain();
172 let any = !bytes.is_empty();
173 if any {
174 let cursor_queries =
175 consume_cursor_position_queries(&mut self.cursor_query_tail, &bytes);
176 self.frame.feed(&bytes);
177 if cursor_queries > 0 {
178 let (row, col) = self.frame.cursor();
179 let response = format!("\x1b[{};{}R", row.saturating_add(1), col.saturating_add(1));
180 for _ in 0..cursor_queries {
181 if self.pty.write_bytes(response.as_bytes()).is_err() {
182 break;
183 }
184 }
185 }
186 self.last_pump = Instant::now();
187 }
188 any
189 }
190
191 /// Pump output and return the parsed frame. Convenience for asserts.
192 pub fn frame(&mut self) -> &Frame {
193 self.pump();
194 &self.frame
195 }
196
197 /// Block (briefly sleeping) until `predicate(frame)` is true or `timeout`
198 /// elapses. Pumps the PTY on each tick.
199 pub fn wait_for<F>(&mut self, mut predicate: F, timeout: Duration) -> Result<()>
200 where
201 F: FnMut(&Frame) -> bool,
202 {
203 let budget = ci_scaled(timeout);
204 let deadline = Instant::now() + budget;
205 loop {
206 self.pump();
207 if predicate(&self.frame) {
208 return Ok(());
209 }
210 if Instant::now() >= deadline {
211 return Err(anyhow!(
212 "wait_for timed out after {:?}.\n{}",
213 budget,
214 self.frame.debug_dump()
215 ));
216 }
217 std::thread::sleep(Duration::from_millis(40));
218 }
219 }
220
221 /// Wait for the literal substring to appear anywhere on the screen.
222 pub fn wait_for_text(&mut self, needle: &str, timeout: Duration) -> Result<()> {
223 let owned = needle.to_string();
224 self.wait_for(move |f| f.contains(&owned), timeout)
225 }
226
227 /// Wait for stable output: no new bytes for `quiet_for` consecutive
228 /// pump ticks, bounded by `max`. Useful for "let the UI settle".
229 pub fn wait_for_idle(&mut self, quiet_for: Duration, max: Duration) -> Result<()> {
230 // Only the ceiling scales: `quiet_for` is the definition of "settled",
231 // not a budget, and stretching it would change what the test asserts.
232 let budget = ci_scaled(max);
233 let max_deadline = Instant::now() + budget;
234 let mut quiet_since = Instant::now();
235 loop {
236 if self.pump() {
237 quiet_since = Instant::now();
238 }
239 if quiet_since.elapsed() >= quiet_for {
240 return Ok(());
241 }
242 if Instant::now() >= max_deadline {
243 return Err(anyhow!(
244 "wait_for_idle: never settled within {:?}\n{}",
245 budget,
246 self.frame.debug_dump()
247 ));
248 }
249 std::thread::sleep(Duration::from_millis(20));
250 }
251 }
252
253 /// Resolve a binary by Cargo bin-name (uses `CARGO_BIN_EXE_<name>`).
254 /// Tests should call this rather than hard-coding paths.
255 pub fn cargo_bin(name: &str) -> PathBuf {
256 // Newer Cargo exposes CARGO_BIN_EXE_* at runtime; older supported
257 // Cargo versions expose it to the integration test at compile time.
258 let key = format!("CARGO_BIN_EXE_{name}");
259 if let Some(path) = std::env::var_os(&key) {
260 return PathBuf::from(path);
261 }
262 if name == "codewhale-tui"
263 && let Some(path) = option_env!("CARGO_BIN_EXE_codewhale-tui")
264 {
265 return PathBuf::from(path);
266 }
267 panic!("env {key} not set; is the binary declared in this crate?")
268 }
269
270 /// Best-effort cooperative shutdown.
271 pub fn shutdown(self) -> Option<i32> {
272 self.pty.shutdown(Duration::from_secs(2))
273 }
274
275 /// Wait for the child process to exit without sending it a signal.
276 pub fn wait_for_exit(&mut self, timeout: Duration) -> Option<i32> {
277 self.pty.wait_until(Instant::now() + ci_scaled(timeout))
278 }
279
280 pub fn debug_dump(&mut self) -> String {
281 self.pump();
282 self.frame.debug_dump()
283 }
284
285 /// Every byte the child has written, from spawn to now. Survives `pump`,
286 /// so terminal-mode assertions stay valid after the frame parser has
287 /// consumed the stream.
288 pub fn transcript(&self) -> Vec<u8> {
289 self.pty.transcript()
290 }
291
292 /// Replay the transcript into a [`TerminalModeLedger`].
293 pub fn terminal_modes(&self) -> super::TerminalModeLedger {
294 super::TerminalModeLedger::from_transcript(&self.transcript())
295 }
296
297 /// Frame dump plus terminal-mode ledger. Every bounded wait in the matrix
298 /// fails with this rather than a bare `assertion failed`, so a CI timeout
299 /// carries the screen *and* the control-stream state that produced it.
300 pub fn diagnostics(&mut self) -> String {
301 let modes = self.terminal_modes().debug_dump();
302 format!("{}{modes}", self.debug_dump())
303 }
304 }
305
306 const CURSOR_POSITION_QUERIES: [&[u8]; 2] = [b"\x1b[6n", b"\x1b[?6n"];
307
308 /// Consume terminal cursor-position queries from a chunked PTY output stream.
309 ///
310 /// Crossterm asks the terminal for its cursor after Ratatui clears the screen.
311 /// A real terminal answers that DSR request; the QA PTY must do the same or the
312 /// child waits for crossterm's timeout before it can paint its first frame.
313 fn consume_cursor_position_queries(tail: &mut Vec<u8>, bytes: &[u8]) -> usize {
314 let mut stream = std::mem::take(tail);
315 stream.extend_from_slice(bytes);
316
317 let mut count = 0;
318 let mut index = 0;
319 while index < stream.len() {
320 if let Some(query) = CURSOR_POSITION_QUERIES
321 .iter()
322 .find(|query| stream[index..].starts_with(query))
323 {
324 count += 1;
325 index += query.len();
326 } else {
327 index += 1;
328 }
329 }
330
331 let max_tail = CURSOR_POSITION_QUERIES
332 .iter()
333 .map(|query| query.len().saturating_sub(1))
334 .max()
335 .unwrap_or(0)
336 .min(stream.len());
337 let keep = (1..=max_tail)
338 .rev()
339 .find(|&len| {
340 CURSOR_POSITION_QUERIES
341 .iter()
342 .any(|query| len < query.len() && query.starts_with(&stream[stream.len() - len..]))
343 })
344 .unwrap_or(0);
345 tail.extend_from_slice(&stream[stream.len() - keep..]);
346 count
347 }
348
349 /// Construct a sealed-`HOME` workspace under a `tempfile::TempDir` so the
350 /// scenario can never read or mutate the developer's real config / skills.
351 pub fn make_sealed_workspace() -> Result<SealedWorkspace> {
352 let tmp = tempfile::TempDir::new().context("tempdir")?;
353 let workspace = tmp.path().join("workspace");
354 let home = tmp.path().join("home");
355 std::fs::create_dir_all(&workspace).context("mkdir workspace")?;
356 std::fs::create_dir_all(home.join(".codewhale")).context("mkdir home/.codewhale")?;
357 std::fs::create_dir_all(home.join(".deepseek")).context("mkdir home/.deepseek")?;
358 let silent_notifications = "[notifications]\nmethod = \"off\"\ncompletion_sound = \"off\"\n";
359 std::fs::write(
360 home.join(".codewhale").join("config.toml"),
361 silent_notifications,
362 )
363 .context("write silent CodeWhale PTY config")?;
364 std::fs::write(
365 home.join(".deepseek").join("config.toml"),
366 silent_notifications,
367 )
368 .context("write silent legacy PTY config")?;
369 Ok(SealedWorkspace {
370 _tmp: tmp,
371 workspace,
372 home,
373 })
374 }
375
376 pub struct SealedWorkspace {
377 _tmp: tempfile::TempDir,
378 pub workspace: PathBuf,
379 pub home: PathBuf,
380 }
381
382 impl SealedWorkspace {
383 pub fn workspace(&self) -> &Path {
384 &self.workspace
385 }
386 pub fn home(&self) -> &Path {
387 &self.home
388 }
389 pub fn user_skills_dir(&self) -> PathBuf {
390 self.home.join(".deepseek").join("skills")
391 }
392 }
393
394 #[cfg(test)]
395 mod tests {
396 use super::consume_cursor_position_queries;
397
398 #[test]
399 fn cursor_position_queries_survive_chunk_boundaries() {
400 let mut tail = Vec::new();
401 assert_eq!(
402 consume_cursor_position_queries(&mut tail, b"before\x1b["),
403 0
404 );
405 assert_eq!(consume_cursor_position_queries(&mut tail, b"6nafter"), 1);
406 assert!(tail.is_empty());
407 }
408
409 #[test]
410 fn cursor_position_queries_accept_standard_and_dec_forms() {
411 let mut tail = Vec::new();
412 assert_eq!(
413 consume_cursor_position_queries(&mut tail, b"\x1b[6n\x1b[?6n"),
414 2
415 );
416 assert!(tail.is_empty());
417 }
418 }
419
419 lines RUST