termlens/error.rs
1//! Error types. The prime directive: when a wait fails in CI, the log must
2//! show what the terminal actually looked like — so timeout/EOF errors embed
3//! a full [`Screen`] snapshot and render it in their `Display` output.
4//!
5//! The same screens reach a directory when `TERMLENS_ARTIFACT_DIR` is set
6//! (#251), so a step after the tests can render them into the pull request
7//! rather than leaving them in the log; see [`Error`].
8
9use std::time::Duration;
10
11use crate::Screen;
12
13/// Convenience alias for `std::result::Result<T, termlens::Error>`.
14pub type Result<T, E = Error> = std::result::Result<T, E>;
15
16/// Errors returned by [`Terminal`](crate::Terminal) operations.
17///
18/// # `TERMLENS_ARTIFACT_DIR`
19///
20/// Every variant that carries a [`Screen`] prints it, so a CI log shows
21/// what the application displayed. When the environment variable
22/// `TERMLENS_ARTIFACT_DIR` names a directory, the same screen is also
23/// written there as it is embedded: `<test>-<n>.screen.json` with the
24/// `serde` feature, `<test>-<n>.screen.txt` (the `with_styles` rendering,
25/// which [`Screen::parse`] reads back) without, where `<test>` is the
26/// current thread's name — under `cargo test`, the test's path. Unset, the
27/// hook is one environment read and nothing else; insta's `.snap.new`
28/// files stay where insta puts them. This repository's `report` action
29/// (`.github/actions/report`) renders that directory into a pull
30/// request's step summary.
31#[derive(Debug, thiserror::Error)]
32#[non_exhaustive]
33pub enum Error {
34 /// A `wait_*` call ran past its deadline. The screen at the moment of
35 /// the timeout is embedded and printed, so a CI log alone is enough to
36 /// see what the application was actually showing.
37 #[error(
38 "timed out after {timeout:?} while waiting for {waiting_for}\n\
39 --- screen at timeout ---\n{screen}"
40 )]
41 Timeout {
42 /// Human description of what was awaited.
43 waiting_for: String,
44 /// The deadline that expired.
45 timeout: Duration,
46 /// The screen when the deadline expired.
47 screen: Screen,
48 },
49
50 /// The PTY reached end-of-file (the child exited or closed its
51 /// terminal) while a wait's condition was still unmet. Waiting longer
52 /// can never succeed, so this fails fast instead of burning the full
53 /// timeout.
54 #[error(
55 "terminal closed (EOF) while waiting for {waiting_for}\n\
56 --- final screen ---\n{screen}"
57 )]
58 Eof {
59 /// Human description of what was awaited.
60 waiting_for: String,
61 /// The final screen contents.
62 screen: Screen,
63 },
64
65 /// Spawning the child process failed.
66 #[error("failed to spawn `{command}`: {reason}")]
67 Spawn {
68 /// The command line that failed to spawn.
69 command: String,
70 /// The underlying PTY/OS error.
71 reason: String,
72 },
73
74 /// A PTY control operation (open, resize, reader/writer setup) failed.
75 #[error("PTY error: {0}")]
76 Pty(String),
77
78 /// An OS-level I/O error (e.g. while waiting on the child process).
79 #[error("i/o error: {0}")]
80 Io(#[from] std::io::Error),
81
82 /// A terminal size argument is invalid and was rejected before anything
83 /// was spawned or sent to the child.
84 #[error("invalid terminal size: {0}")]
85 Size(String),
86
87 /// The VT emulator panicked while processing the child's output, so the
88 /// grid stopped advancing at the screen embedded here.
89 ///
90 /// The emulation runs on the reader thread, where a panic propagates
91 /// nowhere: before this existed the drain simply died, every later
92 /// snapshot returned the same frozen screen, and each wait ran to its
93 /// deadline reporting a predicate that was never going to become true.
94 /// A wait now fails immediately and says why. The screen is the last one
95 /// taken before the failure — the emulator is not asked again, because
96 /// its state after a panic means nothing.
97 #[error(
98 "the terminal emulator failed and the screen stopped advancing: {detail}\n\
99 --- last screen before the failure ---\n{screen}"
100 )]
101 Emulator {
102 /// The panic message from the emulator.
103 detail: String,
104 /// The last screen taken before the emulator failed.
105 screen: Screen,
106 },
107
108 /// Typed input or control the child cannot receive — e.g. a mouse
109 /// click while the application never enabled mouse tracking (sending
110 /// it anyway would feed the app bytes it would misparse as garbage
111 /// keys), or a signal to a child that has already been reaped (its
112 /// pid may belong to someone else by now).
113 #[error("input not receivable: {0}")]
114 Input(String),
115
116 /// A saved screen could not be read back by [`Screen::parse`]: the text
117 /// is not the snapshot format of `docs/DESIGN.md` §3. The message names
118 /// the line.
119 #[error("could not parse a saved screen: {0}")]
120 Parse(String),
121
122 /// Typed input could not be delivered: the child is gone and the OS
123 /// tore the terminal down, or it stopped reading its input and the
124 /// write gave up at the terminal's deadline rather than blocking
125 /// forever.
126 ///
127 /// Distinct from [`Error::Input`] on purpose. `Input` means the
128 /// application cannot make sense of these bytes — a test bug. This
129 /// means the bytes could not be handed over at all, which is a fact
130 /// about the child rather than about the test, so the screen at the
131 /// moment of the failure is embedded the way a timeout's is.
132 #[error("failed to send {what}\n--- screen at the failed write ---\n{screen}")]
133 Write {
134 /// What was being sent, which command it was going to, and why the
135 /// write failed.
136 what: Box<str>,
137 /// The screen when the write failed.
138 screen: Screen,
139 },
140}
141
142impl Error {
143 /// The screen embedded in [`Error::Timeout`], [`Error::Eof`],
144 /// [`Error::Emulator`] or [`Error::Write`], if any.
145 #[must_use]
146 pub fn screen(&self) -> Option<&Screen> {
147 match self {
148 Error::Timeout { screen, .. }
149 | Error::Eof { screen, .. }
150 | Error::Emulator { screen, .. }
151 | Error::Write { screen, .. } => Some(screen),
152 _ => None,
153 }
154 }
155
156 /// The `TERMLENS_ARTIFACT_DIR` hook (#251): every error that carries a
157 /// screen passes through here on its way out of the crate, and when the
158 /// variable is set the screen is also written to that directory. The
159 /// call is a no-op when it is not — the common case, and the reason
160 /// the check is one environment read.
161 pub(crate) fn recorded(self) -> Self {
162 if let Some(screen) = self.screen() {
163 artifact::write(screen);
164 }
165 self
166 }
167}
168
169/// The `TERMLENS_ARTIFACT_DIR` hook. A CI log shows the screen a failing
170/// wait embedded; this puts the same screen somewhere a step after the
171/// tests can pick it up — the `report` action in this repository renders
172/// each into the pull request's step summary.
173pub(crate) mod artifact {
174 use std::path::PathBuf;
175 use std::sync::atomic::{AtomicUsize, Ordering};
176
177 use crate::Screen;
178
179 /// The environment variable naming the directory. Unset means off.
180 pub(crate) const VAR: &str = "TERMLENS_ARTIFACT_DIR";
181
182 /// One counter per test process, so two screens from one test are two
183 /// files rather than one overwritten.
184 static COUNTER: AtomicUsize = AtomicUsize::new(0);
185
186 /// Write `screen` to `$TERMLENS_ARTIFACT_DIR/<test>-<n>.screen.json`
187 /// (with the `serde` feature) or `.screen.txt` (the `with_styles`
188 /// rendering, which `Screen::parse` reads back). `<test>` is the
189 /// current thread's name, which under `cargo test` is the test's path.
190 /// Best effort: a directory that cannot be written is reported once on
191 /// stderr, and the error the screen came from is returned regardless.
192 pub(crate) fn write(screen: &Screen) {
193 let Some(dir) = std::env::var_os(VAR).filter(|d| !d.is_empty()) else {
194 return;
195 };
196 let dir = PathBuf::from(dir);
197 let n = COUNTER.fetch_add(1, Ordering::Relaxed) + 1;
198 let thread = std::thread::current();
199 let test: String = thread
200 .name()
201 .unwrap_or("screen")
202 .chars()
203 .map(|c| {
204 if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
205 c
206 } else {
207 '_'
208 }
209 })
210 .collect();
211 let (name, body) = render(screen, &format!("{test}-{n}"));
212 let path = dir.join(name);
213 if let Err(e) = std::fs::create_dir_all(&dir).and_then(|()| std::fs::write(&path, body)) {
214 eprintln!("termlens: could not write {} ({VAR}): {e}", path.display());
215 }
216 }
217
218 #[cfg(feature = "serde")]
219 fn render(screen: &Screen, stem: &str) -> (String, String) {
220 let json =
221 serde_json::to_string(screen).unwrap_or_else(|_| screen.with_styles().to_string());
222 (format!("{stem}.screen.json"), json)
223 }
224
225 #[cfg(not(feature = "serde"))]
226 fn render(screen: &Screen, stem: &str) -> (String, String) {
227 (
228 format!("{stem}.screen.txt"),
229 screen.with_styles().to_string(),
230 )
231 }
232}
233
234#[cfg(test)]
235mod tests {
236 use super::*;
237 use crate::screen::{Cell, Style, TermState};
238
239 fn tiny_screen() -> Screen {
240 let mut cells = Vec::new();
241 for ch in ['o', 'k'] {
242 cells.push(Cell::new(ch.to_string(), Style::default(), false, false));
243 }
244 cells.push(Cell::new(String::new(), Style::default(), false, false));
245 Screen::from_parts(3, 1, 0, 2, true, cells, TermState::default())
246 }
247
248 #[test]
249 fn timeout_display_embeds_screen_dump() {
250 let err = Error::Timeout {
251 waiting_for: "text \"ready\"".into(),
252 timeout: Duration::from_millis(250),
253 screen: tiny_screen(),
254 };
255 let msg = err.to_string();
256 assert!(msg.contains("timed out after 250ms"), "{msg}");
257 assert!(msg.contains("--- screen at timeout ---"), "{msg}");
258 assert!(msg.contains("size: 3x1 cursor: 0,2"), "{msg}");
259 assert!(msg.contains("\nok"), "{msg}");
260 assert_eq!(err.screen().unwrap().size(), (3, 1));
261 }
262}