//! 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::fs; 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"); } /// 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 { 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::().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(); } /// Flow 5 — issue #39 regression: commands submitted **back-to-back**, with /// no wait between them, must still execute correctly. This is the inverse of /// flow 3 (which paces each command on purpose). Pre-fix, a Form-B insert sent /// immediately after `add column` was validated against the *stale* schema /// cache — the worker hadn't yet refreshed it — and wrongly rejected as /// "trying to write SQL?", so the row never landed. The schema-refresh gate /// (`App::awaiting_schema_refresh`) now holds each submission until the prior /// command's refresh arrives, making the outcome independent of input speed. #[test] fn back_to_back_insert_after_ddl_still_succeeds() { let mut app = PtyApp::launch(&[]); app.wait_for_no_tables(); // fresh project // Fire all three with no readiness wait in between — the faster-than-human // input that triggered issue #39 (paste / script / unpaced driver). app.submit("create table Customers with pk id(serial)"); app.submit("add column to Customers: Name (text)"); app.submit("insert into Customers values ('Alice')"); // The insert's OWN success echo (value + ✓) — proof the row reached the // database, not the "trying to write SQL?" rejection. If the gate // regressed, the insert would misparse against the stale schema and this // would time out. app.wait_for("('Alice') ✓"); app.quit(); } /// Flow 6 — ADR-0005 Amendment 2 backward-compat: opening a **legacy v1 /// project that declares a `blob` column** migrates it to `text` and opens /// cleanly. This drives the real runtime's migrate-on-open + rebuild path /// end-to-end — the post-removal binary would otherwise reject the v1/blob /// project file. (The integration test covers the migrator + rebuild via /// direct calls; this covers the runtime glue that decides to run them.) #[test] fn opens_a_legacy_v1_blob_project_by_migrating_to_text() { let dir = TempDir::new().expect("data dir"); let root = dir.path(); // Seed a v1 project (the old format) with a `blob` column, under the // data root's projects/ dir, and point `--resume` at it. No // playground.db is shipped, so the open rebuilds from the migrated text. let proj = root.join("projects").join("Legacy"); fs::create_dir_all(proj.join("data")).expect("create project dirs"); fs::write( proj.join("project.yaml"), concat!( "version: 1\n", "project:\n created_at: 2026-01-01T00:00:00Z\n mode: simple\n", "tables:\n", " - name: Files\n", " primary_key: [id]\n", " columns:\n", " - { name: id, type: serial }\n", " - { name: payload, type: blob }\n", "relationships: []\n", "indexes: []\n", ), ) .expect("write project.yaml"); fs::write( proj.join("data").join("Files.csv"), "id,payload\n1,aGVsbG8=\n", ) .expect("write csv"); fs::write(root.join("last_project"), format!("{}\n", proj.display())).expect("write resume"); // Open via --resume: the runtime migrates blob→text and rebuilds, so // the project opens and the table shows in the sidebar. let mut app = PtyApp::launch_in(root, &["--resume"]); app.wait_for_table("Files"); // The former blob column is now a normal text column — `show data` // renders it (header + the base64 value preserved as text). app.submit("show data Files"); app.wait_for("payload"); 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(); }