test(tt4): Tier-4 PTY end-to-end tests for the four critical flows
ci / gate (push) Successful in 1m58s
ci / manifests (push) Successful in 3s

Wire the Tier-4 tier from ADR-0008 — the first tests that drive the actual
built binary in a real pseudo-terminal. tests/e2e_pty.rs spawns the binary
under portable-pty at a fixed 100x30, parses output with vt100, and asserts
on the rendered screen with tight (3s) fail-fast waits. Serial (one PTY at a
time) for predictable timing on the low-parallelism runner; each test gets
its own temp --data-dir so it never touches real projects.

The four flows mirror ADR-0008's Tier-4 scope:
- cold launch -> create table -> graceful quit (Ctrl-C)
- create + add column -> quit -> reopen (--resume) -> schema (incl. column)
  restored
- export -> import into a fresh project -> schema + data rebuilt
- undo after DROP TABLE, through the confirm modal (y)

Table presence is read from the Tables sidebar region (not whole-screen)
since the Output panel echoes commands; commands are paced to completion
(real-user cadence) — a command sent mid-rebuild can be misread against a
stale schema cache (issue #39).

Also two NFR perf measurements via the harness (NFR-1 startup, NFR-3 idle
RSS) against generous debug-binary bounds for gross-regression detection;
the real release figures (median 29 ms / 10 MB) are recorded in the NFR doc.

Deps (dev): portable-pty 0.9, vt100 0.16 (both current; expectrl from
ADR-0008 dropped — it bundles a conflicting PTY layer). Runs by default in
`cargo test`, so the existing Linux CI gate exercises it (advances TT5).
PTY-in-container verified (openpty+spawn works under Docker).

Full suite: 2519 passed / 0 failed / 1 ignored. clippy + fmt clean.
Relates to TT4 / ADR-0008.
This commit is contained in:
claude@clouddev1
2026-06-22 13:06:09 +00:00
parent 65eab71439
commit fd63de3441
3 changed files with 535 additions and 3 deletions
+420
View File
@@ -0,0 +1,420 @@
//! Tier-4 PTY-based end-to-end tests (ADR-0008 §Tier-4, requirements TT4).
//!
//! These drive the **actual built binary** in a real pseudo-terminal and
//! assert on what a user would see on screen — catching what the lower
//! tiers can't: TTY setup, raw-mode / alternate-screen transitions, real
//! I/O timing, and graceful quit. The four flows mirror ADR-0008's
//! initial Tier-4 scope exactly:
//!
//! 1. cold launch → first DDL command → graceful quit
//! 2. project save → reopen → identical state
//! 3. project export → import into a fresh project → rebuilt state
//! 4. `undo` immediately after `DROP TABLE` (incl. the confirm modal)
//!
//! Tooling (ADR-0008, refined): `portable-pty` to spawn the binary in a
//! PTY at a fixed window size, `vt100` to parse the output stream into an
//! inspectable screen grid. ADR-0008 also named `expectrl`; we dropped it
//! — it bundles its own PTY abstraction (conflicts with portable-pty) and
//! is line-oriented, a poor fit for a full-screen TUI. A small hand-rolled
//! `wait_for` polling the vt100 screen replaces it.
//!
//! Determinism: each test gets its own temp `--data-dir` (no contact with
//! the user's real projects / resume state), a fixed 100×30 terminal (the
//! wide three-region layout, ADR-0046), and `--theme dark`. Tests run
//! **serially** (one PTY + child process at a time) so timing stays
//! predictable on the low-parallelism self-hosted CI runner, with tight,
//! fail-fast timeouts: a slow wait means a real hang, not contention.
// `PtyApp` deliberately holds a serial `MutexGuard` for its whole lifetime
// (to run PTY tests one at a time), and the screen-reading helpers hold the
// vt100 parser lock for the duration of a read. `significant_drop_tightening`
// flags both as droppable-earlier, but tightening them is either impossible
// (the guard *is* the serialization) or pointless here.
#![allow(clippy::significant_drop_tightening)]
use std::io::{Read, Write};
use std::path::Path;
use std::sync::{Arc, Mutex, MutexGuard};
use std::thread;
use std::time::{Duration, Instant};
use portable_pty::{CommandBuilder, MasterPty, PtySize, native_pty_system};
use tempfile::TempDir;
const COLS: u16 = 100;
const ROWS: u16 = 30;
/// Tight, fail-fast wait. The self-hosted runner has little parallelism,
/// so anything slower than this is a genuine hang worth failing on.
const WAIT: Duration = Duration::from_secs(3);
const POLL: Duration = Duration::from_millis(20);
/// Tier-4 tests share one global lock so only one PTY + child runs at a
/// time (predictable timing; no resource races). Poison-tolerant so a
/// panicking test doesn't cascade-fail the rest.
static SERIAL: Mutex<()> = Mutex::new(());
fn lock_serial() -> MutexGuard<'static, ()> {
SERIAL
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
/// A running instance of the app under a pseudo-terminal.
struct PtyApp {
_serial: MutexGuard<'static, ()>,
/// Present when this instance owns its data dir (single-launch
/// flows); `None` when the test owns it (multi-launch flows that
/// reopen the same dir).
_owned_dir: Option<TempDir>,
_master: Box<dyn MasterPty + Send>,
writer: Box<dyn Write + Send>,
parser: Arc<Mutex<vt100::Parser>>,
child: Box<dyn portable_pty::Child + Send + Sync>,
_reader: thread::JoinHandle<()>,
/// Spawn → first rendered frame (the `SIMPLE` mode label). Measured
/// for NFR-1; note this is the *debug* binary, so it's a generous
/// gross-regression signal, not the release startup figure.
startup: Duration,
}
impl PtyApp {
/// Launch against a fresh, owned temp data dir.
fn launch(args: &[&str]) -> Self {
let dir = TempDir::new().expect("create temp data dir");
let mut app = Self::launch_in(dir.path(), args);
app._owned_dir = Some(dir);
app
}
/// Launch against a caller-owned data dir (so a later launch can
/// reopen the same projects — flow 2).
fn launch_in(data_dir: &Path, args: &[&str]) -> Self {
let serial = lock_serial();
let pty = native_pty_system();
let pair = pty
.openpty(PtySize {
rows: ROWS,
cols: COLS,
pixel_width: 0,
pixel_height: 0,
})
.expect("open pty");
let mut cmd = CommandBuilder::new(env!("CARGO_BIN_EXE_rdbms-playground"));
cmd.arg("--data-dir");
cmd.arg(data_dir);
cmd.arg("--theme");
cmd.arg("dark");
for a in args {
cmd.arg(a);
}
cmd.env("TERM", "xterm-256color");
cmd.env("RDBMS_PLAYGROUND_DEMO", "0");
cmd.cwd(data_dir);
let started = Instant::now();
let child = pair.slave.spawn_command(cmd).expect("spawn binary");
drop(pair.slave); // we never write to the slave directly
let parser = Arc::new(Mutex::new(vt100::Parser::new(ROWS, COLS, 0)));
let mut reader = pair.master.try_clone_reader().expect("clone pty reader");
let writer = pair.master.take_writer().expect("take pty writer");
let parser_for_reader = Arc::clone(&parser);
let reader_handle = thread::spawn(move || {
let mut buf = [0u8; 8192];
loop {
match reader.read(&mut buf) {
Ok(0) | Err(_) => break,
Ok(n) => parser_for_reader
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.process(&buf[..n]),
}
}
});
let mut app = Self {
_serial: serial,
_owned_dir: None,
_master: pair.master,
writer,
parser,
child,
_reader: reader_handle,
startup: Duration::ZERO,
};
// Every flow needs a booted, idle app; block on the first frame.
app.wait_for("SIMPLE");
app.startup = started.elapsed();
app
}
/// Current visible screen as text (one line per row, trailing blanks
/// trimmed) — what a human would read.
fn screen_text(&self) -> String {
self.parser
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.screen()
.contents()
}
/// Text of just the left **Tables sidebar** region (first
/// [`SIDEBAR_W`] columns). The Output panel echoes every command, so
/// a table name typed in a command pollutes a whole-screen match —
/// table presence/absence must be read from the sidebar, where the
/// items list actually lives.
fn sidebar(&self) -> String {
const SIDEBAR_W: u16 = 28;
let parser = self
.parser
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let screen = parser.screen();
let mut out = String::new();
for row in 0..ROWS {
for col in 0..SIDEBAR_W {
if let Some(cell) = screen.cell(row, col) {
out.push_str(cell.contents());
}
}
out.push('\n');
}
out
}
/// Poll a predicate to the tight timeout, panicking with a screen dump.
fn wait_until(&self, desc: &str, pred: impl Fn(&Self) -> bool) {
let deadline = Instant::now() + WAIT;
loop {
if pred(self) {
return;
}
assert!(
Instant::now() < deadline,
"timed out after {WAIT:?} waiting for {desc}\n\
----- screen -----\n{}\n------------------",
self.screen_text(),
);
thread::sleep(POLL);
}
}
/// Wait for `needle` anywhere on screen (Output-panel messages, modals).
fn wait_for(&self, needle: &str) {
self.wait_until(&format!("{needle:?} on screen"), |a| {
a.screen_text().contains(needle)
});
}
/// Wait for a table `name` to appear in the Tables sidebar.
fn wait_for_table(&self, name: &str) {
self.wait_until(&format!("table {name:?} in sidebar"), |a| {
a.sidebar().contains(name)
});
}
/// Wait for the Tables sidebar to be empty (the `(none yet)` placeholder).
fn wait_for_no_tables(&self) {
self.wait_until("empty Tables sidebar", |a| {
a.sidebar().contains("(none yet)")
});
}
fn send(&mut self, bytes: &[u8]) {
self.writer.write_all(bytes).expect("write to pty");
self.writer.flush().expect("flush pty");
}
/// Type a command and submit it (Enter == carriage return in raw mode).
fn submit(&mut self, line: &str) {
self.send(line.as_bytes());
self.send(b"\r");
}
fn pid(&self) -> Option<u32> {
self.child.process_id()
}
/// Resident set size of the child in KiB (Linux only).
#[cfg(target_os = "linux")]
fn rss_kib(&self) -> Option<u64> {
let pid = self.pid()?;
let status = std::fs::read_to_string(format!("/proc/{pid}/status")).ok()?;
status.lines().find_map(|l| {
let rest = l.strip_prefix("VmRSS:")?;
rest.split_whitespace().next()?.parse::<u64>().ok()
})
}
/// Send Ctrl-C and assert the process exits cleanly within the wait.
fn quit(mut self) {
self.send(b"\x03");
let deadline = Instant::now() + WAIT;
loop {
match self.child.try_wait() {
Ok(Some(status)) => {
assert!(status.success(), "app exited unsuccessfully: {status:?}");
return;
}
Ok(None) => {}
Err(e) => panic!("waiting on child failed: {e}"),
}
if Instant::now() >= deadline {
let _ = self.child.kill();
panic!(
"app did not exit within {WAIT:?} of Ctrl-C\n\
----- screen -----\n{}\n------------------",
self.screen_text(),
);
}
thread::sleep(POLL);
}
}
}
impl Drop for PtyApp {
fn drop(&mut self) {
// Never leak a child process, even if a test panicked mid-flow.
let _ = self.child.kill();
let _ = self.child.wait();
}
}
// ============================ the four flows ===========================
/// Flow 1 — cold launch → first DDL command → graceful quit.
#[test]
fn cold_launch_ddl_and_quit() {
let mut app = PtyApp::launch(&[]);
app.wait_for_no_tables(); // fresh project: empty sidebar
app.submit("create table Customers with pk id(serial)");
app.wait_for_table("Customers"); // table appears in the sidebar
app.quit();
}
/// Flow 2 — create, quit, reopen the same project (`--resume`), identical
/// state. Persistence is per-command autosave; the kept temp is recorded
/// as the resume target on quit (ADR-0015).
#[test]
fn save_quit_and_reopen_restores_state() {
let dir = TempDir::new().expect("shared data dir");
let mut app = PtyApp::launch_in(dir.path(), &[]);
app.submit("create table Customers with pk id(serial)");
app.wait_for_table("Customers");
app.submit("add column to Customers: Name (text)");
app.wait_for("Name (text) ✓");
app.quit();
// Reopen the most-recently-used project from the same data root, then
// assert the *column* survived — not just the table name — so "identical
// state" means the schema, not merely that some table exists. In this
// fresh process "Name" appears only from the show-data header.
let mut reopened = PtyApp::launch_in(dir.path(), &["--resume"]);
reopened.wait_for_table("Customers");
reopened.submit("show data Customers");
reopened.wait_for("Name"); // the added column round-tripped through restart
reopened.quit();
}
/// Flow 3 — export a project, import it into a fresh project, confirm the
/// rebuilt database carries the schema and data.
#[test]
fn export_then_import_into_fresh_project() {
let zip_dir = TempDir::new().expect("zip dir");
let zip_path = zip_dir.path().join("export.zip");
let zip = zip_path.to_str().expect("utf-8 zip path");
// Source project: a table with one data row. `Name` is a regular
// column (added separately) — putting it in the `with pk` clause would
// make a compound PK and block the serial auto-fill.
let mut source = PtyApp::launch(&[]);
source.submit("create table Customers with pk id(serial)");
source.wait_for_table("Customers");
// Pace each command to completion before the next — driving a TUI, like
// a real user (or the cast driver), means waiting for each result. A
// command sent while the previous one's worker rebuild is still in
// flight can be misread against a stale schema cache (issue #39).
source.submit("add column to Customers: Name (text)");
source.wait_for("Name (text) ✓");
source.submit("insert into Customers values ('Alice')");
// The insert's OWN success echo (value + ✓) — not a bare "✓", which the
// create already painted — so export runs only once the row is in CSV.
source.wait_for("('Alice') ✓");
source.submit(&format!("export {zip}"));
source.wait_for("[ok] export");
source.quit();
// Fresh project (new data dir): import the zip. Import switches to the
// imported project, rebuilding its db from text since the export omits
// the .db (ADR-0004/0015). "Alice" is unambiguous here — it appears in
// no command typed into this instance, only in a rebuilt data row.
let mut target = PtyApp::launch(&[]);
target.submit(&format!("import {zip}"));
target.wait_for("now editing"); // switched to the imported project
target.wait_for_table("Customers"); // schema rebuilt
target.submit("show data Customers");
target.wait_for("Alice"); // data rebuilt from the CSV
target.quit();
}
/// Flow 4 — `undo` immediately after `DROP TABLE`, including the
/// confirmation modal.
#[test]
fn undo_after_drop_table_restores_it() {
let mut app = PtyApp::launch(&[]);
app.submit("create table Customers with pk id(serial)");
app.wait_for_table("Customers");
app.submit("drop table Customers");
app.wait_for_no_tables(); // gone from the sidebar
app.submit("undo");
app.wait_for("Restore that earlier state?"); // the confirm modal
app.send(b"y"); // confirm
app.wait_for_table("Customers"); // table restored in the sidebar
app.quit();
}
// ===================== NFR perf (measured, generous) ===================
//
// These run against the DEBUG binary, so the bounds are loose
// gross-regression catches, not the NFR targets. The real NFR-1 (startup
// < 500 ms) and NFR-3 (idle RSS < 50 MB) figures are measured on a
// --release build and recorded in the NFR verification doc (ADR-0057).
/// NFR-1 — startup to first rendered frame. Generous debug-binary bound.
#[test]
fn startup_to_first_frame_is_reasonable() {
let app = PtyApp::launch(&[]);
let ms = app.startup.as_millis();
eprintln!("NFR-1 startup (debug binary, under test harness): {ms} ms");
assert!(
app.startup < Duration::from_millis(2000),
"startup {ms} ms exceeds the generous 2000 ms debug bound — likely a real regression",
);
app.quit();
}
/// NFR-3 — idle resident memory. Generous debug-binary bound (Linux only;
/// reads the child's /proc VmRSS).
#[cfg(target_os = "linux")]
#[test]
fn idle_memory_footprint_is_reasonable() {
let app = PtyApp::launch(&[]);
// Already idle after the readiness wait; let it settle a moment.
thread::sleep(Duration::from_millis(200));
let rss = app.rss_kib().expect("read VmRSS");
eprintln!(
"NFR-3 idle RSS (debug binary): {} KiB ({:.1} MB)",
rss,
rss as f64 / 1024.0,
);
assert!(
rss < 150_000,
"idle RSS {rss} KiB exceeds the generous 150 MB debug bound — likely a real regression",
);
app.quit();
}