`rss_kib` is Linux-only (#[cfg(target_os = "linux")]) and was the sole caller of `pid()`, so on macOS/Windows `pid` was dead code and emitted a `method `pid` is never used` warning. The Linux CI clippy gate never saw it — `pid` is used on Linux — so it only surfaced on a manual macOS run. Read `child.process_id()` inline in `rss_kib` and delete the helper. Verified: macOS `cargo test` is fully green (5 e2e_pty tests there; the Linux-only RSS test is cfg'd out). clippy --all-targets + fmt clean.
420 lines
16 KiB
Rust
420 lines
16 KiB
Rust
//! 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");
|
||
}
|
||
|
||
/// Resident set size of the child in KiB (Linux only). `process_id`
|
||
/// is read inline here rather than via a helper so the method has no
|
||
/// non-Linux callers to go dead (the CI clippy gate runs on Linux,
|
||
/// where such dead code wouldn't surface).
|
||
#[cfg(target_os = "linux")]
|
||
fn rss_kib(&self) -> Option<u64> {
|
||
let pid = self.child.process_id()?;
|
||
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();
|
||
}
|