test(tt4): Tier-4 PTY end-to-end tests for the four critical flows
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:
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user