From fd63de3441d19d2d540a289e6638a2ebb96290f8 Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" Date: Mon, 22 Jun 2026 13:06:09 +0000 Subject: [PATCH] test(tt4): Tier-4 PTY end-to-end tests for the four critical flows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- Cargo.lock | 116 ++++++++++++- Cargo.toml | 2 + tests/e2e_pty.rs | 420 +++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 535 insertions(+), 3 deletions(-) create mode 100644 tests/e2e_pty.rs diff --git a/Cargo.lock b/Cargo.lock index 66411c9..d5c8761 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -64,6 +64,12 @@ dependencies = [ "x11rb", ] +[[package]] +name = "arrayvec" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f02882884d3e1bc524fb12c79f107f6ad0e1cfd498c536ffb494301740995dfe" + [[package]] name = "atomic" version = "0.6.1" @@ -164,6 +170,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e" + [[package]] name = "cfg_aliases" version = "0.2.1" @@ -481,6 +493,12 @@ dependencies = [ "litrs", ] +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + [[package]] name = "either" version = "1.15.0" @@ -976,7 +994,7 @@ version = "1.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0aeb26bf5e836cc1c341c8106051b573f1766dfa05aa87f0b98be5e51b02303" dependencies = [ - "nix", + "nix 0.29.0", "winapi", ] @@ -1038,6 +1056,18 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "nix" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4" +dependencies = [ + "bitflags 2.11.1", + "cfg-if", + "cfg_aliases 0.1.1", + "libc", +] + [[package]] name = "nix" version = "0.29.0" @@ -1046,7 +1076,7 @@ checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" dependencies = [ "bitflags 2.11.1", "cfg-if", - "cfg_aliases", + "cfg_aliases 0.2.1", "libc", "memoffset", ] @@ -1360,6 +1390,27 @@ version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" +[[package]] +name = "portable-pty" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4a596a2b3d2752d94f51fac2d4a96737b8705dddd311a32b9af47211f08671e" +dependencies = [ + "anyhow", + "bitflags 1.3.2", + "downcast-rs", + "filedescriptor", + "lazy_static", + "libc", + "log", + "nix 0.28.0", + "serial2", + "shared_library", + "shell-words", + "winapi", + "winreg", +] + [[package]] name = "powerfmt" version = "0.2.0" @@ -1548,6 +1599,7 @@ dependencies = [ "futures-util", "gethostname", "insta", + "portable-pty", "pretty_assertions", "rand 0.10.1", "ratatui", @@ -1559,6 +1611,7 @@ dependencies = [ "tokio", "tracing", "tracing-subscriber", + "vt100", "zip", ] @@ -1738,6 +1791,17 @@ dependencies = [ "unsafe-libyaml-norway", ] +[[package]] +name = "serial2" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9eb6ea5562eeaed6936b8b54e086aa0f88b9e5b1bef45beb038e2519fa1185b1" +dependencies = [ + "cfg-if", + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "sha2" version = "0.10.9" @@ -1758,6 +1822,22 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "shared_library" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a9e7e0f2bfae24d8a5b5a66c5b257a83c7412304311512a0c054cd5e619da11" +dependencies = [ + "lazy_static", + "libc", +] + +[[package]] +name = "shell-words" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" + [[package]] name = "shlex" version = "1.3.0" @@ -1968,7 +2048,7 @@ dependencies = [ "libc", "log", "memmem", - "nix", + "nix 0.29.0", "num-derive", "num-traits", "ordered-float", @@ -2240,6 +2320,27 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "vt100" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "054ff75fb8fa83e609e685106df4faeffdf3a735d3c74ebce97ec557d5d36fd9" +dependencies = [ + "itoa", + "unicode-width", + "vte", +] + +[[package]] +name = "vte" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5924018406ce0063cd67f8e008104968b74b563ee1b85dde3ed1f7cb87d3dbd" +dependencies = [ + "arrayvec", + "memchr", +] + [[package]] name = "vtparse" version = "0.6.2" @@ -2639,6 +2740,15 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" +[[package]] +name = "winreg" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d" +dependencies = [ + "winapi", +] + [[package]] name = "wit-bindgen" version = "0.51.0" diff --git a/Cargo.toml b/Cargo.toml index 822d762..5271f1d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -59,8 +59,10 @@ zip = { version = "5.1.1", default-features = false, features = ["deflate"] } [dev-dependencies] insta = { version = "1.47.2", features = ["yaml"] } +portable-pty = "0.9" pretty_assertions = "1.4.1" tempfile = "3.27.0" +vt100 = "0.16" # Dev/test build hygiene (see CLAUDE.md "Build hygiene"). `cargo test` # links ~25 separate integration-test binaries, each statically diff --git a/tests/e2e_pty.rs b/tests/e2e_pty.rs new file mode 100644 index 0000000..391b765 --- /dev/null +++ b/tests/e2e_pty.rs @@ -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, + _master: Box, + writer: Box, + parser: Arc>, + child: Box, + _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 { + self.child.process_id() + } + + /// Resident set size of the child in KiB (Linux only). + #[cfg(target_os = "linux")] + fn rss_kib(&self) -> Option { + 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::().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(); +}