返回 CodeWhale
view_log.rs
根目录 / crates / tui / tests / support / qa_harness / view_log.rs
1 //! Reader for the TUI's own `codewhale_tui::view_stack` trace records.
2 //!
3 //! Modal open/close coverage has an honesty problem: "the frame changed after
4 //! I pressed F1" is not evidence that a modal opened, and "the frame changed
5 //! back after Esc" is not evidence that it closed rather than being replaced.
6 //! `ViewStack::push` and its close paths already emit structured records with
7 //! the `ModalKind` and the resulting depth, so this reader consumes the
8 //! product's existing machine-readable signal instead of inventing a parallel
9 //! one for tests.
10 //!
11 //! Enable it by spawning the binary with
12 //! `RUST_LOG=warn,codewhale_tui::view_stack=debug` and a sealed `HOME`; the
13 //! subscriber writes to `$HOME/.codewhale/logs/tui-<date>-<pid>.log`.
14
15 use std::path::{Path, PathBuf};
16 use std::time::{Duration, Instant};
17
18 use anyhow::{Context, Result, anyhow};
19
20 pub const VIEW_STACK_RUST_LOG: &str = "warn,codewhale_tui::view_stack=debug";
21
22 #[derive(Debug, Clone, PartialEq, Eq)]
23 pub struct ViewEvent {
24 /// `push`, `push_boxed`, `pop`, `close`, or `emit_and_close`, verbatim
25 /// from the record.
26 pub action: String,
27 /// Debug spelling of the `ModalKind`, e.g. `CommandPalette`.
28 pub kind: String,
29 /// Stack depth *after* the transition, as the product reported it.
30 pub depth: usize,
31 }
32
33 impl ViewEvent {
34 pub fn is_open(&self) -> bool {
35 self.action.starts_with("push")
36 }
37
38 pub fn is_close(&self) -> bool {
39 matches!(self.action.as_str(), "pop" | "close" | "emit_and_close")
40 }
41 }
42
43 /// Locate the log file the sealed-`HOME` child is writing to. Returns the most
44 /// recently modified `tui-*.log` so a re-spawned process in the same sealed
45 /// home does not resolve to a stale file.
46 pub fn log_path(home: &Path) -> Result<PathBuf> {
47 let dir = home.join(".codewhale").join("logs");
48 let mut newest: Option<(std::time::SystemTime, PathBuf)> = None;
49 for entry in
50 std::fs::read_dir(&dir).with_context(|| format!("read sealed log dir {}", dir.display()))?
51 {
52 let entry = entry?;
53 let path = entry.path();
54 let is_tui_log = path
55 .file_name()
56 .and_then(|name| name.to_str())
57 .is_some_and(|name| name.starts_with("tui-") && name.ends_with(".log"));
58 if !is_tui_log {
59 continue;
60 }
61 let modified = entry.metadata()?.modified()?;
62 if newest.as_ref().is_none_or(|(seen, _)| modified >= *seen) {
63 newest = Some((modified, path));
64 }
65 }
66 newest
67 .map(|(_, path)| path)
68 .ok_or_else(|| anyhow!("no tui-*.log under {}", dir.display()))
69 }
70
71 /// Parse every view-stack transition currently on disk, in order.
72 pub fn read_events(home: &Path) -> Result<Vec<ViewEvent>> {
73 let path = log_path(home)?;
74 let contents =
75 std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
76 Ok(parse_events(&contents))
77 }
78
79 /// Poll the log until at least `count` transitions are visible, or fail with
80 /// the transitions that *were* observed. The subscriber writes on its own
81 /// schedule, so a modal that has already repainted may not have been flushed
82 /// yet; this is a bounded wait on a real signal, never a fixed sleep.
83 pub fn wait_for_events(home: &Path, count: usize, timeout: Duration) -> Result<Vec<ViewEvent>> {
84 let budget = super::harness::ci_scaled(timeout);
85 let deadline = Instant::now() + budget;
86 let mut last: Vec<ViewEvent> = Vec::new();
87 loop {
88 if let Ok(events) = read_events(home) {
89 last = events;
90 }
91 if last.len() >= count {
92 return Ok(last);
93 }
94 if Instant::now() >= deadline {
95 return Err(anyhow!(
96 "view-stack log never reached {count} transitions within {budget:?}; observed {:?}",
97 last
98 ));
99 }
100 std::thread::sleep(Duration::from_millis(40));
101 }
102 }
103
104 /// Poll until a newly appended transition satisfies `predicate`. Some views
105 /// are temporarily popped and restored while opening, so counting one record
106 /// per user gesture is not a stable contract; the semantic transition is.
107 pub fn wait_for_event_after<F>(
108 home: &Path,
109 after: usize,
110 timeout: Duration,
111 mut predicate: F,
112 ) -> Result<(Vec<ViewEvent>, ViewEvent)>
113 where
114 F: FnMut(&ViewEvent) -> bool,
115 {
116 let budget = super::harness::ci_scaled(timeout);
117 let deadline = Instant::now() + budget;
118 let mut last: Vec<ViewEvent> = Vec::new();
119 loop {
120 if let Ok(events) = read_events(home) {
121 last = events;
122 }
123 if let Some(event) = last.iter().skip(after).find(|event| predicate(event)) {
124 return Ok((last.clone(), event.clone()));
125 }
126 if Instant::now() >= deadline {
127 return Err(anyhow!(
128 "view-stack log produced no matching transition after index {after} within \
129 {budget:?}; observed {:?}",
130 last
131 ));
132 }
133 std::thread::sleep(Duration::from_millis(40));
134 }
135 }
136
137 pub fn parse_events(contents: &str) -> Vec<ViewEvent> {
138 contents
139 .lines()
140 .filter(|line| line.contains("codewhale_tui::view_stack"))
141 .filter_map(parse_line)
142 .collect()
143 }
144
145 fn parse_line(line: &str) -> Option<ViewEvent> {
146 let action = quoted_field(line, "action=")?;
147 let kind = bare_field(line, "kind=")?;
148 let depth = bare_field(line, "depth=")?.parse().ok()?;
149 Some(ViewEvent {
150 action,
151 kind,
152 depth,
153 })
154 }
155
156 /// `action="push"` → `push`.
157 fn quoted_field(line: &str, key: &str) -> Option<String> {
158 let rest = line.split_once(key)?.1;
159 let rest = rest.strip_prefix('"')?;
160 let end = rest.find('"')?;
161 Some(rest[..end].to_string())
162 }
163
164 /// `kind=CommandPalette depth=1` → `CommandPalette`.
165 fn bare_field(line: &str, key: &str) -> Option<String> {
166 let rest = line.split_once(key)?.1;
167 let end = rest.find(|c: char| c.is_whitespace()).unwrap_or(rest.len());
168 let value = rest[..end].trim();
169 if value.is_empty() {
170 return None;
171 }
172 Some(value.to_string())
173 }
174
175 #[cfg(test)]
176 mod tests {
177 use super::*;
178
179 const SAMPLE: &str = concat!(
180 "2026-07-26T12:00:00.100000Z WARN codewhale_tui::startup: unrelated line\n",
181 "2026-07-26T12:00:01.000000Z DEBUG codewhale_tui::view_stack: view pushed action=\"push\" kind=Help depth=1\n",
182 "2026-07-26T12:00:02.000000Z DEBUG codewhale_tui::view_stack: view pushed action=\"push_boxed\" kind=Pager depth=2\n",
183 "2026-07-26T12:00:03.000000Z DEBUG codewhale_tui::view_stack: view closed action=\"close\" kind=Pager depth=1\n",
184 );
185
186 #[test]
187 fn only_view_stack_records_are_parsed_and_order_is_preserved() {
188 let events = parse_events(SAMPLE);
189
190 assert_eq!(events.len(), 3);
191 assert_eq!(events[0].kind, "Help");
192 assert!(events[0].is_open());
193 assert_eq!(events[1].action, "push_boxed");
194 assert!(events[1].is_open());
195 assert!(events[2].is_close());
196 assert_eq!(events[2].depth, 1);
197 }
198
199 #[test]
200 fn a_record_missing_its_fields_is_skipped_rather_than_guessed() {
201 let events = parse_events(
202 "2026-07-26T12:00:01.000000Z DEBUG codewhale_tui::view_stack: view pushed depth=1\n",
203 );
204
205 assert!(events.is_empty());
206 }
207 }
208
208 lines RUST