test(coder-lite): drive the real TUI on a pty and assert on emulated cells

7325bb84672b · AtlantisPleb · · parent f9726959af6a

test(coder-lite): drive the real TUI on a pty and assert on emulated cells

Headless output could not prove the interactive surface worked: agent shells
are non-TTY and took the non-interactive branch every time, so every previous
check bypassed ratatui through the fallback.

Add tests/interactive_pty.rs, which starts the bare binary with no arguments
on a portable-pty pseudo-terminal, parses its output through a vt100 emulator,
and asserts on cells: a bare invocation opens a session rather than printing
help, a composer box renders with a caret, typed characters echo and the caret
column tracks them, backspace removes them, Enter clears the composer and puts
the line in the transcript, a slash line reaches the session's own dispatch
and an unknown one is refused by name, a resize redraws at the new width, the
bottom status bar reports usage, and leaving restores the terminal.

Record the practice as V2 in docs/coder/best-practices.md, stating plainly
that the completion gate does not yet run this suite (#124).

Refs #116
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K7q2vA5LJroLTR6ZFbRq6j
Co-Authored-By
Claude Fable 5 <noreply@anthropic.com>

Deploy story

What this commit did to the running system — joined from the forge receipt chain, the part a commit page elsewhere cannot show.

Not deployed through the forge lane

No push, promotion, build, or deploy receipt references this commit (receipts are scanned over a bounded recent window). Changes shipped by full node replacement carry their proof in the release gate receipt instead.

Changed files

  • modified Cargo.lock
  • modified crates/coder-lite/Cargo.toml
  • added crates/coder-lite/tests/interactive_pty.rs
  • modified docs/coder/best-practices.md

Diff

4 files changed, +831 -3

Cargo.lock modified +3

@@ -418,8 +418,10 @@ dependencies = [

418 418
 "crossterm",
419 419
 "futures",
420 420
 "html-escape",
421
 "libc",
421 422
 "linkify",
422 423
 "openagents-cli",
424
 "portable-pty",
423 425
 "pretty_assertions",
424 426
 "pulldown-cmark",
425 427
 "ratatui",

@@ -434,6 +436,7 @@ dependencies = [

434 436
 "unicode-segmentation",
435 437
 "unicode-width 0.2.0",
436 438
 "url",
439
 "vt100",
437 440
]
438 441
439 442
[[package]]
crates/coder-lite/Cargo.toml modified +6

@@ -50,3 +50,9 @@ url = "2"

50 50
51 51
[dev-dependencies]
52 52
pretty_assertions = "1"
53
# tests/interactive_pty.rs drives the real binary on a pseudo-terminal and
54
# reads the emulated screen, because a non-TTY shell takes the headless
55
# branch and never exercises ratatui. Versions match openagents-cli's pins.
56
portable-pty = "0.9"
57
vt100 = "0.15"
58
libc = "0.2"
crates/coder-lite/tests/interactive_pty.rs added +795

@@ -0,0 +1,795 @@

1
//! What the coder-lite TUI does when a person is actually typing at it.
2
//!
3
//! Every other test in this crate calls a function. This one starts the real
4
//! `coder-lite` binary as a child process on a real pseudo-terminal, sends it
5
//! bytes the way a keyboard does, parses what comes back with a terminal
6
//! emulator, and asserts on the cells.
7
//!
8
//! It exists because of a specific failure, recorded in the openagents.com
9
//! repository as `docs/2026-08-26-rust-cli-port-parity-failure-postmortem.md`:
10
//! a TUI with **no input widget at all** was reported as parity and closed
11
//! seven issues, because every verification ran in a non-TTY subshell and took
12
//! the `Non-interactive terminal detected` branch in `interactive.rs` without
13
//! ever rendering a frame. A harness that cannot see the composer cannot
14
//! notice its absence. This one looks at the composer.
15
//!
16
//! Each assertion below is chosen so that it would have gone red against that
17
//! build: a bare invocation opening a session at all, the composer box, the
18
//! echo of typed characters, the caret column, backspace, the submitted turn
19
//! appearing in the transcript, a `/` line reaching the session's own dispatch
20
//! instead of the model, the frame surviving a resize, the status bar, and the
21
//! terminal being handed back unraw on the way out.
22
//!
23
//! ## No arguments means the session
24
//!
25
//! The shipped binary answers two surfaces: a bare invocation opens the
26
//! interactive TUI, and a subcommand (`issue list` and peers) dispatches to
27
//! the CLI command set. Every session below is started with **no arguments**,
28
//! and [`the_bare_binary_opens_an_interactive_session`] asserts that contract
29
//! by name — a change that makes a bare invocation print help instead of
30
//! opening a session is exactly the regression this file exists to catch.
31
//!
32
//! ## No provider, no network
33
//!
34
//! `run_tui` opens a session only when a stored credential validates against
35
//! `GET {origin}/api/v1/models`. The harness therefore points
36
//! `OPENAGENTS_API_URL` at a stub HTTP server it starts on loopback, which
37
//! answers that one route with `200` and refuses everything else. So the
38
//! session is real, the frame is real, no provider credential is spent, and
39
//! nothing leaves the machine. A turn is never completed on purpose: what is
40
//! asserted is that Enter *starts* one, which is the part the composer owns.
41
42
#[cfg(not(unix))]
43
#[test]
44
fn skipped_without_a_unix_pseudo_terminal() {
45
    // A named skip, not a silent pass. Windows has no `openpty`, and the
46
    // crate itself only recently compiles there (3b16e0679b).
47
    eprintln!(
48
        "SKIP interactive_pty: the coder-lite PTY harness needs a unix \
49
         pseudo-terminal. The TUI is unobserved on this platform."
50
    );
51
}
52
53
#[cfg(unix)]
54
mod unix_pty {
55
    use std::io::{Read, Write};
56
    use std::net::TcpListener;
57
    use std::os::unix::io::RawFd;
58
    use std::path::PathBuf;
59
    use std::sync::atomic::{AtomicU32, Ordering};
60
    use std::sync::{Arc, Mutex};
61
    use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
62
63
    use portable_pty::{Child, CommandBuilder, MasterPty, PtySize, native_pty_system};
64
65
    /// The frame the harness starts with. Wide enough that nothing under test
66
    /// wraps by accident, tall enough that the transcript has somewhere to go.
67
    const COLS: u16 = 100;
68
    const ROWS: u16 = 30;
69
70
    /// How long the first frame may take. It covers process start, the git
71
    /// probe, the ACP scan, and the credential check against the stub.
72
    const FIRST_FRAME: Duration = Duration::from_secs(30);
73
    /// How long any later frame may take. The session's poll interval is 50ms.
74
    const REDRAW: Duration = Duration::from_secs(15);
75
76
    // ─────────────────────────────────────────────────── the stub deployment
77
78
    /// A loopback HTTP server that answers exactly one route.
79
    ///
80
    /// `GET …/api/v1/models` is what `validate_token` calls to decide whether
81
    /// a session may open, and it is the only thing this harness wants a
82
    /// deployment for. Everything else is refused with `503` and a body that
83
    /// says who refused it, so a request this harness did not intend is
84
    /// legible rather than silently satisfied.
85
    struct Stub {
86
        origin: String,
87
    }
88
89
    impl Stub {
90
        fn start() -> Self {
91
            let listener = TcpListener::bind("127.0.0.1:0").expect("bind loopback stub");
92
            let port = listener.local_addr().expect("stub address").port();
93
            std::thread::spawn(move || {
94
                for stream in listener.incoming() {
95
                    let Ok(mut stream) = stream else { continue };
96
                    let mut request = Vec::new();
97
                    let mut byte = [0u8; 1];
98
                    while !request.ends_with(b"\r\n\r\n") {
99
                        match stream.read(&mut byte) {
100
                            Ok(0) | Err(_) => break,
101
                            Ok(_) => request.push(byte[0]),
102
                        }
103
                    }
104
                    let head = String::from_utf8_lossy(&request).to_string();
105
                    let response = if head.starts_with("GET ") && head.contains("/api/v1/models") {
106
                        let body = r#"{"data":[]}"#;
107
                        format!(
108
                            "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n\
109
                             Content-Length: {}\r\nConnection: close\r\n\r\n{body}",
110
                            body.len()
111
                        )
112
                    } else {
113
                        let body = "the coder-lite PTY harness stub serves /api/v1/models only";
114
                        format!(
115
                            "HTTP/1.1 503 Service Unavailable\r\nContent-Type: text/plain\r\n\
116
                             Content-Length: {}\r\nConnection: close\r\n\r\n{body}",
117
                            body.len()
118
                        )
119
                    };
120
                    let _ = stream.write_all(response.as_bytes());
121
                    let _ = stream.flush();
122
                }
123
            });
124
            Self {
125
                origin: format!("http://127.0.0.1:{port}"),
126
            }
127
        }
128
    }
129
130
    // ──────────────────────────────────────────────────────────── the screen
131
132
    /// One rendered frame, read out of the emulator.
133
    struct Frame {
134
        rows: Vec<String>,
135
        cursor: (u16, u16),
136
        cols: u16,
137
    }
138
139
    impl Frame {
140
        /// The composer: the bordered box the session types into.
141
        ///
142
        /// Found by its borders rather than by a hard-coded row, so a layout
143
        /// change does not fake a pass — but a build with no box at all finds
144
        /// nothing, which is the case this whole file exists for.
145
        fn composer(&self) -> Option<Composer> {
146
            let width = self.cols.saturating_sub(2) as usize;
147
            let top_border = format!("┌{}┐", "─".repeat(width));
148
            let bottom_border = format!("└{}┘", "─".repeat(width));
149
            let top = self
150
                .rows
151
                .iter()
152
                .position(|row| row.trim_end() == top_border)?;
153
            let bottom = self
154
                .rows
155
                .iter()
156
                .skip(top + 1)
157
                .position(|row| row.trim_end() == bottom_border)?
158
                + top
159
                + 1;
160
            Some(Composer {
161
                top,
162
                bottom,
163
                lines: self.rows[top + 1..bottom]
164
                    .iter()
165
                    .map(|row| inside(row))
166
                    .collect(),
167
            })
168
        }
169
170
        /// The rows above the composer, joined and whitespace-collapsed, so a
171
        /// `contains` check does not care where the renderer wrapped or padded.
172
        fn transcript(&self) -> String {
173
            let end = self.composer().map(|c| c.top).unwrap_or(self.rows.len());
174
            collapse(&self.rows[..end])
175
        }
176
177
        /// The bottom line, which since 7ad52a0c8d carries the usage.
178
        fn status_bar(&self) -> String {
179
            self.rows.last().cloned().unwrap_or_default()
180
        }
181
182
        /// Every row of the frame, for a failure message. A red run in this
183
        /// file has to show what the terminal actually held, or the next
184
        /// reader has to reproduce it by hand.
185
        fn dump(&self) -> String {
186
            let mut out = String::new();
187
            for (index, row) in self.rows.iter().enumerate() {
188
                out.push_str(&format!("{index:>3} |{}|\n", row.trim_end()));
189
            }
190
            out.push_str(&format!(
191
                "cursor at row {} col {}\n",
192
                self.cursor.0, self.cursor.1
193
            ));
194
            out
195
        }
196
    }
197
198
    /// One row of the composer box without its `│` side borders, trailing
199
    /// space removed. What is left is the `" > "` gutter and what was typed.
200
    fn inside(row: &str) -> String {
201
        let cells: Vec<char> = row.chars().collect();
202
        let body: String = match cells.len() {
203
            0 | 1 => String::new(),
204
            len => cells[1..len - 1].iter().collect(),
205
        };
206
        body.trim_end().to_string()
207
    }
208
209
    fn collapse(rows: &[String]) -> String {
210
        rows.join(" ")
211
            .split_whitespace()
212
            .collect::<Vec<_>>()
213
            .join(" ")
214
    }
215
216
    struct Composer {
217
        /// Row index of the top border.
218
        top: usize,
219
        /// Row index of the bottom border.
220
        bottom: usize,
221
        /// The rows between the borders, trailing space removed.
222
        lines: Vec<String>,
223
    }
224
225
    impl Composer {
226
        /// The first line inside the box, which carries the `" > "` gutter.
227
        fn first(&self) -> &str {
228
            self.lines.first().map(String::as_str).unwrap_or("")
229
        }
230
231
        /// Whether the first line opens with the `" > "` prompt gutter.
232
        ///
233
        /// An empty composer's line is `" >"` once its trailing space is
234
        /// gone, so both shapes count — and neither is produced by a box with
235
        /// no prompt in it.
236
        fn has_gutter(&self) -> bool {
237
            self.first() == " >" || self.first().starts_with(" > ")
238
        }
239
    }
240
241
    struct Emulator {
242
        parser: vt100::Parser,
243
        raw: Vec<u8>,
244
    }
245
246
    // ─────────────────────────────────────────────────────────── the session
247
248
    /// A live `coder-lite` on a pseudo-terminal.
249
    struct Tui {
250
        master: Box<dyn MasterPty + Send>,
251
        child: Box<dyn Child + Send + Sync>,
252
        writer: Box<dyn Write + Send>,
253
        emulator: Arc<Mutex<Emulator>>,
254
        /// The throwaway `HOME` the child wrote its history into; removed on
255
        /// drop.
256
        home: PathBuf,
257
        _stub: Stub,
258
    }
259
260
    impl Tui {
261
        /// Start the binary **with no arguments**, which is the invocation
262
        /// that opens a session. A subcommand would dispatch to the CLI
263
        /// command set instead, and this harness has nothing to say about
264
        /// that surface.
265
        fn start() -> Self {
266
            Self::start_sized(ROWS, COLS)
267
        }
268
269
        fn start_sized(rows: u16, cols: u16) -> Self {
270
            let stub = Stub::start();
271
            let home = scratch_dir();
272
            let workdir = home.join("workdir");
273
            std::fs::create_dir_all(&workdir).expect("create the scratch working directory");
274
275
            let pty = native_pty_system();
276
            let pair = pty
277
                .openpty(PtySize {
278
                    rows,
279
                    cols,
280
                    pixel_width: 0,
281
                    pixel_height: 0,
282
                })
283
                .expect("open a pseudo-terminal");
284
285
            // No `.arg(…)` call anywhere in this function, deliberately.
286
            let mut command = CommandBuilder::new(env!("CARGO_BIN_EXE_coder-lite"));
287
            command.cwd(&workdir);
288
            command.env("HOME", &home);
289
            command.env("TERM", "xterm-256color");
290
            // A stub deployment, not a provider. The token is spent against
291
            // the loopback server above and nowhere else.
292
            command.env("OPENAGENTS_API_URL", &stub.origin);
293
            command.env("OPENAGENTS_BASE_URL", format!("{}/api/v1", stub.origin));
294
            command.env("OPENAGENTS_API_KEY", "pty-harness-not-a-real-credential");
295
            // A directory that does not exist, so the ACP scan returns an
296
            // empty list at once instead of probing this machine's agents.
297
            command.env("ACP_REGISTRY", home.join("no-acp-registry"));
298
            // Anything on the host that would redirect the session elsewhere.
299
            command.env_remove("OPENAGENTS_PROFILE");
300
            command.env_remove("OPENAGENTS_API_BASE");
301
            command.env_remove("NO_COLOR");
302
303
            let child = pair.slave.spawn_command(command).expect("spawn coder-lite");
304
            // The slave must close here or the reader below never sees EOF.
305
            drop(pair.slave);
306
307
            let emulator = Arc::new(Mutex::new(Emulator {
308
                parser: vt100::Parser::new(rows, cols, 0),
309
                raw: Vec::new(),
310
            }));
311
            let mut reader = pair
312
                .master
313
                .try_clone_reader()
314
                .expect("clone the pty reader");
315
            let sink = Arc::clone(&emulator);
316
            std::thread::spawn(move || {
317
                let mut buffer = [0u8; 8192];
318
                loop {
319
                    match reader.read(&mut buffer) {
320
                        Ok(0) | Err(_) => break,
321
                        Ok(read) => {
322
                            let mut emulator = sink.lock().expect("emulator lock");
323
                            emulator.parser.process(&buffer[..read]);
324
                            emulator.raw.extend_from_slice(&buffer[..read]);
325
                        }
326
                    }
327
                }
328
            });
329
330
            let writer = pair.master.take_writer().expect("take the pty writer");
331
            Self {
332
                master: pair.master,
333
                child,
334
                writer,
335
                emulator,
336
                home,
337
                _stub: stub,
338
            }
339
        }
340
341
        fn send(&mut self, bytes: &[u8]) {
342
            self.writer.write_all(bytes).expect("write to the pty");
343
            self.writer.flush().expect("flush the pty");
344
        }
345
346
        fn type_text(&mut self, text: &str) {
347
            // One byte at a time, the way a keyboard delivers them, so the
348
            // session's key handling is exercised per character rather than
349
            // as one paste.
350
            for byte in text.as_bytes() {
351
                self.send(&[*byte]);
352
                std::thread::sleep(Duration::from_millis(4));
353
            }
354
        }
355
356
        fn frame(&self) -> Frame {
357
            let emulator = self.emulator.lock().expect("emulator lock");
358
            let screen = emulator.parser.screen();
359
            let (_, cols) = screen.size();
360
            Frame {
361
                rows: screen.rows(0, cols).collect(),
362
                cursor: screen.cursor_position(),
363
                cols,
364
            }
365
        }
366
367
        fn raw_output(&self) -> Vec<u8> {
368
            self.emulator.lock().expect("emulator lock").raw.clone()
369
        }
370
371
        /// Poll until `ready` accepts a frame, or fail with the last one.
372
        fn wait_for(&self, what: &str, within: Duration, ready: impl Fn(&Frame) -> bool) -> Frame {
373
            let deadline = Instant::now() + within;
374
            let mut last = self.frame();
375
            loop {
376
                if ready(&last) {
377
                    return last;
378
                }
379
                if Instant::now() >= deadline {
380
                    panic!(
381
                        "waited {within:?} for {what} and it never happened.\n\
382
                         The terminal held:\n{}",
383
                        last.dump()
384
                    );
385
                }
386
                std::thread::sleep(Duration::from_millis(25));
387
                last = self.frame();
388
            }
389
        }
390
391
        /// The frame with a composer on it, or a failure that says the TUI
392
        /// rendered no input area — the postmortem's exact defect.
393
        fn wait_for_composer(&self) -> Frame {
394
            self.wait_for(
395
                "the composer to render an input box",
396
                FIRST_FRAME,
397
                |frame| frame.composer().is_some(),
398
            )
399
        }
400
401
        fn resize(&mut self, rows: u16, cols: u16) {
402
            self.master
403
                .resize(PtySize {
404
                    rows,
405
                    cols,
406
                    pixel_width: 0,
407
                    pixel_height: 0,
408
                })
409
                .expect("resize the pty");
410
            self.emulator
411
                .lock()
412
                .expect("emulator lock")
413
                .parser
414
                .set_size(rows, cols);
415
        }
416
417
        fn master_fd(&self) -> RawFd {
418
            self.master
419
                .as_raw_fd()
420
                .expect("the pty master exposes a file descriptor")
421
        }
422
423
        /// Ctrl+C, then wait for the process to go.
424
        fn quit(&mut self) -> portable_pty::ExitStatus {
425
            self.send(&[0x03]);
426
            let deadline = Instant::now() + REDRAW;
427
            loop {
428
                if let Ok(Some(status)) = self.child.try_wait() {
429
                    return status;
430
                }
431
                if Instant::now() >= deadline {
432
                    panic!(
433
                        "coder-lite did not exit within {REDRAW:?} of Ctrl+C.\n\
434
                         The terminal held:\n{}",
435
                        self.frame().dump()
436
                    );
437
                }
438
                std::thread::sleep(Duration::from_millis(25));
439
            }
440
        }
441
    }
442
443
    impl Drop for Tui {
444
        fn drop(&mut self) {
445
            let _ = self.child.kill();
446
            let _ = self.child.wait();
447
            let _ = std::fs::remove_dir_all(&self.home);
448
        }
449
    }
450
451
    fn scratch_dir() -> PathBuf {
452
        static COUNT: AtomicU32 = AtomicU32::new(0);
453
        let unique = format!(
454
            "coder-lite-pty-{}-{}-{}",
455
            std::process::id(),
456
            SystemTime::now()
457
                .duration_since(UNIX_EPOCH)
458
                .expect("clock")
459
                .as_nanos(),
460
            COUNT.fetch_add(1, Ordering::Relaxed)
461
        );
462
        let path = std::env::temp_dir().join(unique);
463
        std::fs::create_dir_all(&path).expect("create the scratch home");
464
        path
465
    }
466
467
    /// The column the caret sits at for `typed` characters on the first line.
468
    ///
469
    /// `CoderUi::render` puts the caret at `input_area.x + 1 + 3 + caret_col`:
470
    /// the box's left border and the `" > "` gutter. The box starts at column
471
    /// zero, so an empty composer's caret is at column four.
472
    fn caret_column(typed: usize) -> u16 {
473
        4 + typed as u16
474
    }
475
476
    // ────────────────────────────────────────────────────────────── the tests
477
478
    /// The bare binary opens a session rather than printing help.
479
    ///
480
    /// One binary now serves both the TUI and the CLI command set, so "no
481
    /// arguments means the session" is a contract that can be broken by a
482
    /// change to argument dispatch. The evidence that a session opened is a
483
    /// rendered frame with an input box on it, which is also the evidence
484
    /// that the TUI is not the postmortem's mock.
485
    #[test]
486
    fn the_bare_binary_opens_an_interactive_session() {
487
        let tui = Tui::start();
488
        let frame = tui.wait_for_composer();
489
        assert!(
490
            frame.composer().is_some(),
491
            "a bare `coder-lite` should open a session with an input box.\n{}",
492
            frame.dump()
493
        );
494
        assert!(
495
            !frame
496
                .transcript()
497
                .contains("Non-interactive terminal detected"),
498
            "the session took the headless branch on a real pty.\n{}",
499
            frame.dump()
500
        );
501
        assert!(
502
            !frame.transcript().contains("Usage: coder-lite"),
503
            "a bare invocation printed help instead of opening a session.\n{}",
504
            frame.dump()
505
        );
506
    }
507
508
    /// The one the postmortem is about: is there anywhere to type.
509
    #[test]
510
    fn the_session_renders_a_composer_with_a_caret_in_it() {
511
        let tui = Tui::start();
512
        let frame = tui.wait_for_composer();
513
        let composer = frame.composer().expect("just waited for it");
514
515
        assert!(
516
            composer.has_gutter(),
517
            "the composer's first line should carry the ` > ` gutter, and held {:?}.\n{}",
518
            composer.first(),
519
            frame.dump()
520
        );
521
        assert_eq!(
522
            frame.cursor,
523
            (composer.top as u16 + 1, caret_column(0)),
524
            "the caret should sit on the composer's first line, just after the gutter.\n{}",
525
            frame.dump()
526
        );
527
    }
528
529
    /// Typing reaches the composer, the caret follows it, and backspace
530
    /// removes what was typed.
531
    #[test]
532
    fn typed_characters_echo_into_the_composer_and_backspace_removes_them() {
533
        let mut tui = Tui::start();
534
        let frame = tui.wait_for_composer();
535
        let composer_top = frame.composer().expect("composer").top as u16;
536
537
        tui.type_text("hello");
538
        let frame = tui.wait_for("`hello` to echo into the composer", REDRAW, |frame| {
539
            frame
540
                .composer()
541
                .is_some_and(|composer| composer.first() == " > hello")
542
        });
543
        assert_eq!(
544
            frame.cursor,
545
            (composer_top + 1, caret_column(5)),
546
            "the caret should have advanced five columns with the five characters.\n{}",
547
            frame.dump()
548
        );
549
550
        tui.send(&[0x7f]);
551
        tui.send(&[0x7f]);
552
        let frame = tui.wait_for("backspace to leave `hel`", REDRAW, |frame| {
553
            frame
554
                .composer()
555
                .is_some_and(|composer| composer.first() == " > hel")
556
        });
557
        assert_eq!(
558
            frame.cursor,
559
            (composer_top + 1, caret_column(3)),
560
            "the caret should have come back two columns with the two deletions.\n{}",
561
            frame.dump()
562
        );
563
    }
564
565
    /// Enter hands the line to the session: it leaves the composer and the
566
    /// transcript shows the turn starting.
567
    #[test]
568
    fn enter_submits_the_line_and_the_transcript_shows_the_turn_beginning() {
569
        let mut tui = Tui::start();
570
        tui.wait_for_composer();
571
572
        tui.type_text("ping from the pty harness");
573
        tui.wait_for("the prompt to reach the composer", REDRAW, |frame| {
574
            frame
575
                .composer()
576
                .is_some_and(|composer| composer.first().contains("ping from the pty harness"))
577
        });
578
579
        tui.send(b"\r");
580
        let frame = tui.wait_for(
581
            "the submitted line to appear in the transcript",
582
            REDRAW,
583
            |frame| frame.transcript().contains("> ping from the pty harness"),
584
        );
585
586
        let composer = frame.composer().expect("the composer survives a submit");
587
        assert_eq!(
588
            composer.first().trim_end(),
589
            " >",
590
            "the composer should be empty after a submit, and held {:?}.\n{}",
591
            composer.first(),
592
            frame.dump()
593
        );
594
        assert_eq!(
595
            frame.cursor,
596
            (composer.top as u16 + 1, caret_column(0)),
597
            "the caret should be back at the start of an empty composer.\n{}",
598
            frame.dump()
599
        );
600
601
        // The echo alone would be satisfied by a composer that only paints.
602
        // This is the turn actually reaching the runtime: the session opened a
603
        // thread against the stub deployment, and the stub's own refusal — a
604
        // string only this harness produces — came back into the transcript.
605
        tui.wait_for(
606
            "the turn to reach the deployment and its answer to land in the transcript",
607
            REDRAW,
608
            |frame| {
609
                frame
610
                    .transcript()
611
                    .contains("the coder-lite PTY harness stub serves /api/v1/models only")
612
            },
613
        );
614
    }
615
616
    /// A `/` line reaches the session's own dispatch rather than being sent to
617
    /// a model or dropped. Both halves are asserted: a name that exists runs,
618
    /// and a name that does not is refused by name.
619
    #[test]
620
    fn a_slash_command_is_recognised_and_an_unknown_one_is_refused_by_name() {
621
        let mut tui = Tui::start();
622
        tui.wait_for_composer();
623
624
        tui.type_text("/help");
625
        tui.send(b"\r");
626
        // `/help` prints the table in `commands::COMMANDS` and the key list
627
        // beside it. Only that dispatch produces these strings.
628
        let frame = tui.wait_for("`/help` to print the command table", REDRAW, |frame| {
629
            let transcript = frame.transcript();
630
            transcript.contains("clear the transcript") && transcript.contains("Alt+Enter")
631
        });
632
        assert!(
633
            frame.transcript().contains("Commands"),
634
            "the `/help` output should be headed `Commands`.\n{}",
635
            frame.dump()
636
        );
637
638
        tui.type_text("/nosuchcommand");
639
        tui.send(b"\r");
640
        tui.wait_for("an unknown `/` name to be refused", REDRAW, |frame| {
641
            let transcript = frame.transcript();
642
            transcript.contains("There is no") && transcript.contains("nosuchcommand")
643
        });
644
    }
645
646
    /// A resize redraws the frame at the new size instead of leaving the old
647
    /// one behind it.
648
    #[test]
649
    fn a_resize_redraws_the_frame_at_the_new_width() {
650
        let mut tui = Tui::start();
651
        tui.wait_for_composer();
652
653
        tui.type_text("resize marker alpha");
654
        tui.send(b"\r");
655
        tui.wait_for("the marker to reach the transcript", REDRAW, |frame| {
656
            frame.transcript().contains("resize marker alpha")
657
        });
658
659
        for (rows, cols) in [(24u16, 70u16), (40, 120), (30, 100)] {
660
            tui.resize(rows, cols);
661
            let frame = tui.wait_for(
662
                &format!("the frame to redraw at {cols}x{rows}"),
663
                REDRAW,
664
                |frame| {
665
                    frame.cols == cols
666
                        && frame
667
                            .composer()
668
                            .is_some_and(|composer| composer.has_gutter())
669
                },
670
            );
671
672
            assert!(
673
                frame
674
                    .rows
675
                    .iter()
676
                    .all(|row| row.chars().count() <= cols as usize),
677
                "no row may run past the new width of {cols}.\n{}",
678
                frame.dump()
679
            );
680
            let borders = frame
681
                .rows
682
                .iter()
683
                .filter(|row| row.trim_end().starts_with('┌'))
684
                .count();
685
            assert_eq!(
686
                borders,
687
                1,
688
                "exactly one composer top border should survive a resize to {cols}x{rows}.\n{}",
689
                frame.dump()
690
            );
691
            assert!(
692
                frame.transcript().contains("resize marker alpha"),
693
                "the transcript should still hold the marker after a resize.\n{}",
694
                frame.dump()
695
            );
696
            assert!(
697
                frame.status_bar().contains("tokens"),
698
                "the status bar should still be on the bottom row after a resize.\n{}",
699
                frame.dump()
700
            );
701
        }
702
    }
703
704
    /// The bottom row carries the usage, where 7ad52a0c8d moved it.
705
    #[test]
706
    fn the_bottom_status_bar_reports_usage() {
707
        let tui = Tui::start();
708
        let frame = tui.wait_for("the status bar to report usage", FIRST_FRAME, |frame| {
709
            let status = frame.status_bar();
710
            status.contains("prompt") && status.contains("completion") && status.contains("tokens")
711
        });
712
713
        let status = frame.status_bar();
714
        assert!(
715
            status.trim_end().ends_with("tokens"),
716
            "the usage line is right-aligned on the bottom row, and held {:?}.\n{}",
717
            status,
718
            frame.dump()
719
        );
720
        let composer = frame.composer().expect("a composer above the status bar");
721
        assert!(
722
            composer.bottom < frame.rows.len() - 1,
723
            "the status bar should sit below the composer, not inside it.\n{}",
724
            frame.dump()
725
        );
726
    }
727
728
    /// Leaving hands the terminal back: raw mode off, alternate screen left.
729
    ///
730
    /// The termios check is read from the pty master after the child is gone,
731
    /// so it observes the state a person's shell would inherit. A build that
732
    /// exits without `disable_raw_mode` leaves `ECHO` and `ICANON` clear here,
733
    /// which is the shape of a terminal a user has to `reset`.
734
    #[test]
735
    fn leaving_restores_the_terminal() {
736
        let mut tui = Tui::start();
737
        tui.wait_for_composer();
738
739
        let while_running = termios_flags(tui.master_fd());
740
        assert_eq!(
741
            while_running & (libc::ECHO | libc::ICANON),
742
            0,
743
            "the running TUI should hold the terminal in raw mode; \
744
             if it does not, this test cannot prove the restore"
745
        );
746
747
        let status = tui.quit();
748
        assert!(
749
            status.success(),
750
            "Ctrl+C should leave cleanly, and exited with {status:?}"
751
        );
752
753
        let restored = termios_flags(tui.master_fd());
754
        assert_ne!(
755
            restored & libc::ECHO,
756
            0,
757
            "ECHO should be back on after the session exits (raw mode leaked)"
758
        );
759
        assert_ne!(
760
            restored & libc::ICANON,
761
            0,
762
            "ICANON should be back on after the session exits (raw mode leaked)"
763
        );
764
765
        let output = tui.raw_output();
766
        assert!(
767
            contains(&output, b"\x1b[?1049h"),
768
            "the session should have entered the alternate screen"
769
        );
770
        assert!(
771
            contains(&output, b"\x1b[?1049l"),
772
            "the session should have left the alternate screen on the way out"
773
        );
774
    }
775
776
    fn termios_flags(fd: RawFd) -> libc::tcflag_t {
777
        // SAFETY: `fd` is the live pty master owned by the `Tui` it was read
778
        // from, and `settings` is written only by `tcgetattr`.
779
        unsafe {
780
            let mut settings: libc::termios = std::mem::zeroed();
781
            assert_eq!(
782
                libc::tcgetattr(fd, &mut settings),
783
                0,
784
                "could not read the pty's terminal settings"
785
            );
786
            settings.c_lflag
787
        }
788
    }
789
790
    fn contains(haystack: &[u8], needle: &[u8]) -> bool {
791
        haystack
792
            .windows(needle.len())
793
            .any(|window| window == needle)
794
    }
795
}
docs/coder/best-practices.md modified +27 -3

@@ -33,9 +33,33 @@ Harbor verifier is the oracle of record.

33 33
Headless output does not prove an interactive TUI works. Agent shells are
34 34
non-TTY and will take the non-interactive branch every time.
35 35
**Provenance:** postmortem (every check bypassed ratatui via the headless
36
fallback). **Detection:** review question. An automated PTY harness is the
37
planned gate (autoimprove §7.4); until it exists, no interactive-TUI issue
38
closes on headless evidence.
36
fallback). **Detection:** a written test, not yet a gate —
37
`crates/coder-lite/tests/interactive_pty.rs`, run by
38
`cargo test -p coder-lite`, starts the real binary with no arguments on a
39
pseudo-terminal and asserts on the emulated cells: that a bare invocation
40
opens a session rather than printing help, that a composer box renders, that
41
typed characters echo and the caret column tracks them, that backspace
42
removes them, that Enter clears the composer and puts the line in the
43
transcript, that a `/` line reaches the session's own dispatch and an unknown
44
one is refused by name, that a resize redraws at the new width, that the
45
bottom status bar reports usage, and that leaving restores the terminal
46
(`ECHO` and `ICANON` read back from the pty master, alternate screen left).
47
No interactive-TUI issue closes on headless evidence; the harness is the
48
evidence. One limit, stated plainly: `pnpm run check` does not run this
49
suite. The completion gate's only cargo invocation is
50
`check:all-work-contract`, and `test:cloud-crates` is not wired into `check`,
51
so until that wiring lands (#124) this practice holds only when someone runs
52
`cargo test -p coder-lite` by hand. Treat it as enforced on the surface, not
53
in CI.
54
55
Stubbing the composer's row mapping to drop its content fails two of the
56
eight — the echo test and the Enter test — while the caret, slash-dispatch,
57
resize, status-bar, and teardown tests still pass, because they read state
58
the stub does not touch. That is the harness working: a composer-render
59
break localizes to two tests, so read a 2-of-8 failure as a located defect,
60
not a partial one. K1 is this same discipline applied to claims — read the
61
premise against the source. This entry applies it to behavior: run the check
62
on the surface the user touches.
39 63
40 64
### V3. Parity claims quantify — `adopted`
41 65

This page updates live while a promote is in flight · changelog