From 5cce8ea80a5a085d8d469d5ec2c3b63b7942ca39 Mon Sep 17 00:00:00 2001 From: Alexey Zhokhov Date: Sun, 25 Oct 2026 17:24:33 +0700 Subject: [PATCH 01/16] Add public owned TerminalSnapshot with atomic live capture Live capture reads grid, cursor, dimensions, title, and resolved palette under one terminal-state lock. Default capture records raw visual data; highlighted capture needs an explicit option. Signed-off-by: Alexey Zhokhov --- crates/tui-test/src/engine.rs | 53 ++ crates/tui-test/src/lib.rs | 7 + crates/tui-test/src/runtime.rs | 22 + crates/tui-test/src/snapshot.rs | 1368 +++++++++++++++++++++++++++++++ 4 files changed, 1450 insertions(+) create mode 100644 crates/tui-test/src/snapshot.rs diff --git a/crates/tui-test/src/engine.rs b/crates/tui-test/src/engine.rs index 07a80fb5..673db059 100644 --- a/crates/tui-test/src/engine.rs +++ b/crates/tui-test/src/engine.rs @@ -35,6 +35,10 @@ use crate::session::{ capture_visual_state, scroll_emulator_and_record, try_capture_visual_state, Session as TerminalSession, TermState, TextHighlight, }; +use crate::snapshot::{ + capture_live, capture_time_ms, LiveHighlight, SnapshotMetadata, SnapshotOptions, SnapshotScope, + SnapshotSource, TerminalSnapshot, +}; use crate::terminal::cell::{rows_to_strings, Attrs, Color, EmuCell}; use crate::terminal::emu::{ ClipboardType, CursorShape, Emulator, KeyboardMode, MouseMode, TerminalMode, @@ -1472,6 +1476,55 @@ impl Engine { } } + /// Capture an owned snapshot of the selected scope. + /// + /// Reads grid, cursor, dimensions, title, and resolved colors while it + /// holds one terminal-state lock, so the snapshot is one atomic terminal + /// moment. Records raw visual data; locator highlighting never leaks in. + /// The snapshot is fully owned and stays usable after the session closes. + pub fn capture_snapshot(&self, scope: SnapshotScope) -> Result { + self.capture_snapshot_with(&SnapshotOptions::new(scope)) + } + + /// Capture an owned snapshot with explicit options. + /// + /// Same atomic capture as [`Engine::capture_snapshot`]; pass + /// [`SnapshotOptions::with_highlight`] to fold locator highlighting into + /// the captured cells instead of recording raw visual data. + pub fn capture_snapshot_with( + &self, + options: &SnapshotOptions, + ) -> Result { + let guard = self.lock_session(); + let session = guard.as_ref().ok_or_else(TuiTestError::no_session)?; + if let Some(fault) = session.fault() { + return Err( + TuiTestError::internal(fault.clone()).with_report(FailureReport::new( + "terminal.operation", + None, + FailureReason::EmulatorFault, + fault, + )), + ); + } + let state = session + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let metadata = SnapshotMetadata { + captured_at_ms: capture_time_ms(), + revision: state.visual_revision, + backend: session.backend.as_str().to_string(), + session: self.name.clone(), + source: SnapshotSource::Live, + }; + let highlight = state.highlight.as_ref().map(|selected| LiveHighlight { + cells: &selected.cells, + }); + capture_live(state.emu.as_ref(), highlight, options, metadata) + .map_err(crate::snapshot::SnapshotError::into) + } + pub fn frame(&self) -> Option { let live = self .live diff --git a/crates/tui-test/src/lib.rs b/crates/tui-test/src/lib.rs index 230b7c20..35cb3bbb 100644 --- a/crates/tui-test/src/lib.rs +++ b/crates/tui-test/src/lib.rs @@ -11,6 +11,7 @@ pub mod record; pub mod render; pub mod runtime; pub mod shell; +pub mod snapshot; pub mod terminal; pub mod trace; @@ -24,4 +25,10 @@ pub use runtime::{ global_registry, Locator, LocatorClickOptions, LocatorExpectOptions, LocatorFilterOptions, Session, SessionHandle, SessionRegistry, }; +pub use snapshot::{ + SnapshotCell, SnapshotColor, SnapshotCursor, SnapshotCursorShape, SnapshotError, + SnapshotMetadata, SnapshotOptions, SnapshotParts, SnapshotScope, SnapshotSource, + SnapshotUnderline, TerminalSnapshot, MAX_CELL_TEXT_BYTES, MAX_LINK_BYTES, MAX_SNAPSHOT_CELLS, + MAX_SNAPSHOT_DIM, MAX_TITLE_BYTES, SNAPSHOT_SCHEMA_VERSION, +}; pub use terminal::backend::Backend; diff --git a/crates/tui-test/src/runtime.rs b/crates/tui-test/src/runtime.rs index daaa83bd..97c6e903 100644 --- a/crates/tui-test/src/runtime.rs +++ b/crates/tui-test/src/runtime.rs @@ -13,6 +13,7 @@ use crate::api::{ use crate::diagnostics::ExecutionContext; use crate::engine::Engine; use crate::logger::Logger; +use crate::snapshot::{SnapshotOptions, SnapshotScope, TerminalSnapshot}; const MAX_COMPLETED_RECORDINGS: usize = 1024; @@ -446,6 +447,27 @@ impl Session { self.execute(Operation::Close).map(|_| ()) } + /// Capture an owned snapshot of the selected scope through the session's + /// engine. + /// + /// Reads grid, cursor, dimensions, title, and resolved colors under one + /// terminal-state lock. Records raw visual data; the snapshot is fully + /// owned and stays usable after the session closes. + pub fn capture_snapshot(&self, scope: SnapshotScope) -> Result { + self.engine.capture_snapshot(scope) + } + + /// Capture an owned snapshot with explicit options. + /// + /// Pass [`SnapshotOptions::with_highlight`] to fold locator highlighting + /// into the captured cells instead of recording raw visual data. + pub fn capture_snapshot_with( + &self, + options: &SnapshotOptions, + ) -> Result { + self.engine.capture_snapshot_with(options) + } + /// Cancel pending operations, including startup readiness, and terminate the /// child without waiting for the operation queue or PTY input writer. pub fn interrupt(&self) { diff --git a/crates/tui-test/src/snapshot.rs b/crates/tui-test/src/snapshot.rs new file mode 100644 index 00000000..a1c9cd43 --- /dev/null +++ b/crates/tui-test/src/snapshot.rs @@ -0,0 +1,1368 @@ +//! A public owned snapshot of terminal state. +//! +//! [`TerminalSnapshot`] is the single owned model for live capture and offline +//! construction. Every field is private; read-only accessors expose the data. +//! Capture reads grid, cursor, dimensions, title, and resolved colors while it +//! holds one terminal-state lock, so a snapshot never mixes data from two +//! terminal moments. Observation metadata (capture time, revision, backend and +//! session identity, source kind) travels with the snapshot but stays out of +//! canonical equality and hashing: two snapshots are canonically equal when +//! their terminal state matches, however they were observed. +//! +//! The default capture records raw visual data. Locator highlighting never +//! leaks into it; a highlighted capture needs an explicit opt-in through +//! [`SnapshotOptions::with_highlight`]. + +use std::collections::HashSet; +use std::fmt; +use std::hash::{Hash, Hasher}; + +use serde::{Deserialize, Deserializer, Serialize, Serializer}; + +use crate::profile::{ColorSlot, Rgb}; +use crate::terminal::cell::{Attrs, Color, UnderlineStyle, CONTINUATION}; +use crate::terminal::emu::{CursorShape, Emulator}; + +/// Wire schema understood by [`TerminalSnapshot`] deserialization. +/// +/// Bumped only when the serialized form changes incompatibly; older payloads +/// keep failing closed with [`SnapshotError::UnsupportedSchema`] instead of +/// being misread as the current layout. +pub const SNAPSHOT_SCHEMA_VERSION: u32 = 1; + +/// Largest accepted snapshot dimension in columns or rows. +/// +/// Matches the downstream image-dimension bound so a snapshot can always be +/// rendered without a second size check. +pub const MAX_SNAPSHOT_DIM: u32 = 32_768; + +/// Largest accepted cell count (`cols * rows`) in one snapshot. +/// +/// Matches the downstream per-sample cell bound. +pub const MAX_SNAPSHOT_CELLS: u64 = 1_000_000; + +/// Largest accepted UTF-8 byte length of one cell grapheme. +/// +/// Matches the downstream per-cell string bound. +pub const MAX_CELL_TEXT_BYTES: usize = 4_096; + +/// Largest accepted UTF-8 byte length of one hyperlink URI or link identity. +pub const MAX_LINK_BYTES: usize = 4_096; + +/// Largest accepted UTF-8 byte length of the window title. +pub const MAX_TITLE_BYTES: usize = 65_536; + +/// Which terminal region a snapshot covers. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SnapshotScope { + /// The rows currently visible in the viewport. + Viewport, + /// The active screen, independent of viewport scrolling. + ActiveScreen, + /// Scrollback history followed by the active screen. + FullHistory, +} + +impl SnapshotScope { + /// The name this scope goes by on the wire. + pub const fn name(self) -> &'static str { + match self { + SnapshotScope::Viewport => "viewport", + SnapshotScope::ActiveScreen => "active_screen", + SnapshotScope::FullHistory => "full_history", + } + } +} + +/// How a snapshot cell refers to a color. +/// +/// `Default` means the terminal default and stays distinct from an explicit +/// [`SnapshotColor::Rgb`] even when both resolve to the same painted color. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum SnapshotColor { + /// The terminal default foreground or background. + Default, + /// A palette entry, with indices 0-15 using the recorded SGR + /// normalization (both `SGR 31` and `SGR 38;5;1` land on slot 1). + Indexed(u8), + /// An explicit RGB color (`SGR 38;2;r;g;b`). + Rgb(u8, u8, u8), +} + +impl SnapshotColor { + /// The name or spelling this reference goes by on the wire. + pub fn wire_name(&self) -> String { + match self { + SnapshotColor::Default => "default".to_string(), + SnapshotColor::Indexed(index) => index.to_string(), + SnapshotColor::Rgb(r, g, b) => Rgb::new(*r, *g, *b).to_hex(), + } + } +} + +impl From for SnapshotColor { + fn from(color: Color) -> Self { + match color { + Color::Named(named) => SnapshotColor::Indexed(named.index()), + Color::Idx(index) => SnapshotColor::Indexed(index), + Color::Rgb(r, g, b) => SnapshotColor::Rgb(r, g, b), + } + } +} + +impl From> for SnapshotColor { + fn from(color: Option) -> Self { + color + .map(SnapshotColor::from) + .unwrap_or(SnapshotColor::Default) + } +} + +impl Serialize for SnapshotColor { + fn serialize(&self, serializer: S) -> Result { + match self { + SnapshotColor::Default => serializer.serialize_str("default"), + SnapshotColor::Indexed(index) => serializer.serialize_u8(*index), + SnapshotColor::Rgb(r, g, b) => serializer.serialize_str(&Rgb::new(*r, *g, *b).to_hex()), + } + } +} + +impl<'de> Deserialize<'de> for SnapshotColor { + fn deserialize>(deserializer: D) -> Result { + struct ColorVisitor; + + impl serde::de::Visitor<'_> for ColorVisitor { + type Value = SnapshotColor; + + fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("\"default\", a palette index 0-255, or a #rrggbb color") + } + + fn visit_str(self, value: &str) -> Result { + if value == "default" { + return Ok(SnapshotColor::Default); + } + Rgb::parse(value) + .map(|rgb| SnapshotColor::Rgb(rgb.r, rgb.g, rgb.b)) + .map_err(|_| E::custom(format!("invalid snapshot color (got {value:?})"))) + } + + fn visit_u64(self, value: u64) -> Result { + u8::try_from(value) + .map(SnapshotColor::Indexed) + .map_err(|_| E::custom(format!("palette index out of range (got {value})"))) + } + + fn visit_i64(self, value: i64) -> Result { + u8::try_from(value) + .map(SnapshotColor::Indexed) + .map_err(|_| E::custom(format!("palette index out of range (got {value})"))) + } + } + + deserializer.deserialize_any(ColorVisitor) + } +} + +/// The shape of a cell underline, mirroring [`UnderlineStyle`] wire names. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum SnapshotUnderline { + None, + Single, + Double, + Curly, + Dotted, + Dashed, +} + +impl SnapshotUnderline { + /// The name this shape goes by on the wire. + pub const fn name(self) -> &'static str { + match self { + SnapshotUnderline::None => "none", + SnapshotUnderline::Single => "single", + SnapshotUnderline::Double => "double", + SnapshotUnderline::Curly => "curly", + SnapshotUnderline::Dotted => "dotted", + SnapshotUnderline::Dashed => "dashed", + } + } +} + +impl From for SnapshotUnderline { + fn from(style: UnderlineStyle) -> Self { + match style { + UnderlineStyle::None => SnapshotUnderline::None, + UnderlineStyle::Single => SnapshotUnderline::Single, + UnderlineStyle::Double => SnapshotUnderline::Double, + UnderlineStyle::Curly => SnapshotUnderline::Curly, + UnderlineStyle::Dotted => SnapshotUnderline::Dotted, + UnderlineStyle::Dashed => SnapshotUnderline::Dashed, + } + } +} + +/// One captured cell: grapheme, width, colors, attributes, and hyperlink. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case", deny_unknown_fields)] +pub struct SnapshotCell { + text: String, + width: u16, + fg: SnapshotColor, + bg: SnapshotColor, + bold: bool, + dim: bool, + italic: bool, + inverse: bool, + invisible: bool, + strike: bool, + blink: bool, + underline: SnapshotUnderline, + underline_color: SnapshotColor, + link_uri: Option, + link_id: Option, +} + +impl SnapshotCell { + /// Build a cell, rejecting oversize text and links. + /// + /// Continuation placement (empty text away from column zero) and grid + /// bounds are checked by [`TerminalSnapshot::try_new`], which knows each + /// cell's coordinates. + #[allow(clippy::too_many_arguments)] + pub fn new( + text: String, + width: u16, + fg: SnapshotColor, + bg: SnapshotColor, + attrs: Attrs, + underline: SnapshotUnderline, + underline_color: SnapshotColor, + link_uri: Option, + link_id: Option, + ) -> Result { + if text.len() > MAX_CELL_TEXT_BYTES { + return Err(SnapshotError::OversizeText { + bytes: text.len(), + limit: MAX_CELL_TEXT_BYTES, + }); + } + if let Some(uri) = link_uri.as_deref() { + if uri.len() > MAX_LINK_BYTES { + return Err(SnapshotError::OversizeLink { + bytes: uri.len(), + limit: MAX_LINK_BYTES, + }); + } + } + if let Some(id) = link_id.as_deref() { + if id.len() > MAX_LINK_BYTES { + return Err(SnapshotError::OversizeLink { + bytes: id.len(), + limit: MAX_LINK_BYTES, + }); + } + } + Ok(SnapshotCell { + text, + width, + fg, + bg, + bold: attrs.contains(Attrs::BOLD), + dim: attrs.contains(Attrs::DIM), + italic: attrs.contains(Attrs::ITALIC), + inverse: attrs.contains(Attrs::INVERSE), + invisible: attrs.contains(Attrs::INVISIBLE), + strike: attrs.contains(Attrs::STRIKE), + blink: attrs.contains(Attrs::BLINK), + underline, + underline_color, + link_uri, + link_id, + }) + } + + /// The cell grapheme; empty for a continuation cell trailing a wide char. + pub fn text(&self) -> &str { + &self.text + } + + /// Terminal columns this cell occupies; zero for continuation cells. + pub fn width(&self) -> u16 { + self.width + } + + /// Whether this cell continues the wide character in the previous column. + pub fn is_continuation(&self) -> bool { + self.text.is_empty() + } + + /// Foreground color reference (`Default` is the terminal default). + pub fn fg(&self) -> SnapshotColor { + self.fg + } + + /// Background color reference (`Default` is the terminal default). + pub fn bg(&self) -> SnapshotColor { + self.bg + } + + /// Whether the cell carries the named boolean attribute. + pub fn has(&self, attr: Attrs) -> bool { + let mut probe = Attrs::empty(); + if self.bold { + probe |= Attrs::BOLD; + } + if self.dim { + probe |= Attrs::DIM; + } + if self.italic { + probe |= Attrs::ITALIC; + } + if self.inverse { + probe |= Attrs::INVERSE; + } + if self.invisible { + probe |= Attrs::INVISIBLE; + } + if self.strike { + probe |= Attrs::STRIKE; + } + if self.blink { + probe |= Attrs::BLINK; + } + probe.contains(attr) + } + + /// The underline shape; [`SnapshotUnderline::None`] when not underlined. + pub fn underline(&self) -> SnapshotUnderline { + self.underline + } + + /// Underline color reference (`Default` follows the cell foreground). + pub fn underline_color(&self) -> SnapshotColor { + self.underline_color + } + + /// The OSC 8 hyperlink URI, if the cell sits inside a link. + pub fn link_uri(&self) -> Option<&str> { + self.link_uri.as_deref() + } + + /// The link `id=` parameter, if the sequence carried one. + pub fn link_id(&self) -> Option<&str> { + self.link_id.as_deref() + } + + fn check_at(&self, col: u32, row: u32) -> Result<(), SnapshotError> { + if self.text.len() > MAX_CELL_TEXT_BYTES { + return Err(SnapshotError::CellTextTooLarge { + col, + row, + bytes: self.text.len(), + limit: MAX_CELL_TEXT_BYTES, + }); + } + if self.text.is_empty() && (col == 0 || self.width != 0) { + return Err(SnapshotError::BadContinuation { col, row }); + } + for uri in self.link_uri.iter().chain(self.link_id.iter()) { + if uri.len() > MAX_LINK_BYTES { + return Err(SnapshotError::LinkTooLarge { + col, + row, + bytes: uri.len(), + limit: MAX_LINK_BYTES, + }); + } + } + Ok(()) + } +} + +/// The shape the snapshot cursor is drawn as. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum SnapshotCursorShape { + Block, + Underline, + Bar, +} + +impl SnapshotCursorShape { + /// The name this shape goes by on the wire. + pub const fn name(self) -> &'static str { + match self { + SnapshotCursorShape::Block => "block", + SnapshotCursorShape::Underline => "underline", + SnapshotCursorShape::Bar => "bar", + } + } +} + +impl From for SnapshotCursorShape { + fn from(shape: CursorShape) -> Self { + match shape { + CursorShape::Block => SnapshotCursorShape::Block, + CursorShape::Underline => SnapshotCursorShape::Underline, + CursorShape::Bar => SnapshotCursorShape::Bar, + } + } +} + +/// The captured cursor: position, visibility, shape, color, and blink intent. +/// +/// Position is in full-grid coordinates (history rows first, active screen +/// last), never clamped: a cursor outside the captured region keeps its real +/// position and reports [`SnapshotCursor::in_capture`] as `false`. Terminal +/// cursor visibility stays separate from capture clipping: a hidden cursor +/// still has a position, and a visible cursor can sit outside the capture. +/// +/// Blink is `None` when the backend does not report blink intent. An unknown +/// blink stays unknown; it is never normalized to `false`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case", deny_unknown_fields)] +pub struct SnapshotCursor { + col: u32, + row: u32, + in_capture: bool, + visible: bool, + shape: SnapshotCursorShape, + color: Rgb, + blink: Option, +} + +impl SnapshotCursor { + /// Build a cursor value. Bounds against the capture grid are checked by + /// [`TerminalSnapshot::try_new`], which knows the grid dimensions. + #[allow(clippy::too_many_arguments)] + pub fn new( + col: u32, + row: u32, + in_capture: bool, + visible: bool, + shape: SnapshotCursorShape, + color: Rgb, + blink: Option, + ) -> Self { + SnapshotCursor { + col, + row, + in_capture, + visible, + shape, + color, + blink, + } + } + + /// Full-grid column (history rows share the same column frame). + pub fn col(&self) -> u32 { + self.col + } + + /// Full-grid row, counting from the oldest history row as zero. + pub fn row(&self) -> u32 { + self.row + } + + /// Whether the cursor position falls inside the captured grid. + pub fn in_capture(&self) -> bool { + self.in_capture + } + + /// Whether the terminal draws the cursor (`DECTCEM`). + pub fn visible(&self) -> bool { + self.visible + } + + /// The shape the terminal draws the cursor as. + pub fn shape(&self) -> SnapshotCursorShape { + self.shape + } + + /// The cursor color as currently resolved (`OSC 12` applied). + pub fn color(&self) -> Rgb { + self.color + } + + /// Blink intent: `Some(true)` blinking, `Some(false)` steady, `None` + /// when the backend does not report it. + pub fn blink(&self) -> Option { + self.blink + } + + fn hash_fields(&self, state: &mut H) { + self.col.hash(state); + self.row.hash(state); + self.in_capture.hash(state); + self.visible.hash(state); + self.shape.hash(state); + hash_rgb(&self.color, state); + self.blink.hash(state); + } + + fn check_in(&self, cols: u32, rows: u32, origin: u64) -> Result<(), SnapshotError> { + if !self.in_capture { + return Ok(()); + } + let out_of_bounds = || SnapshotError::CursorOutOfBounds { + col: self.col, + row: self.row, + cols, + rows, + }; + let local_row = u64::from(self.row) + .checked_sub(origin) + .ok_or_else(out_of_bounds)?; + if self.col >= cols || local_row >= u64::from(rows) { + return Err(SnapshotError::CursorOutOfBounds { + col: self.col, + row: self.row, + cols, + rows, + }); + } + Ok(()) + } +} + +/// Where a snapshot came from. Kept as observation metadata, outside +/// canonical equality and hashing. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)] +#[serde(rename_all = "lowercase")] +pub enum SnapshotSource { + /// Captured from a live terminal under one state lock. + Live, + /// Built offline through [`TerminalSnapshot::try_new`] or deserialization. + #[default] + Offline, +} + +/// Observation metadata: when and where a snapshot was taken. +/// +/// Every field is observation-only: changing metadata never changes canonical +/// equality or hashing. There are no invariants, so the fields are public. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, Default)] +#[serde(rename_all = "snake_case", deny_unknown_fields, default)] +pub struct SnapshotMetadata { + /// Milliseconds since the Unix epoch when the snapshot was taken. + pub captured_at_ms: u64, + /// Terminal visual revision at capture time (live captures only). + pub revision: u64, + /// Backend that produced the grid (`alacritty`, `ghostty`, ...). + pub backend: String, + /// Session the grid was captured from (live captures only). + pub session: String, + /// Whether the snapshot came from a live terminal or offline input. + pub source: SnapshotSource, +} + +/// The validated input bundle for [`TerminalSnapshot::try_new`]. +/// +/// Plain data with public fields; every invariant is checked by `try_new`, +/// which reports the offending field and coordinates. +#[derive(Debug, Clone)] +pub struct SnapshotParts { + /// Logical terminal columns. + pub cols: u32, + /// Logical terminal rows for the selected scope. + pub rows: u32, + /// Which terminal region the cells cover. + pub scope: SnapshotScope, + /// Full-grid index of the first captured row. + pub viewport_origin: u64, + /// Row-major cells, exactly `cols * rows` entries. + pub cells: Vec, + /// Every resolved palette entry, indices 0-255. + pub palette: [Rgb; 256], + /// Resolved default foreground (`OSC 10` applied). + pub default_foreground: Rgb, + /// Resolved default background (`OSC 11` applied). + pub default_background: Rgb, + /// Resolved cursor color (`OSC 12` applied). + pub default_cursor: Rgb, + /// The captured cursor, in full-grid coordinates. + pub cursor: SnapshotCursor, + /// Window title (`None` when the program set none). + pub title: Option, + /// Observation metadata, outside canonical equality. + pub metadata: SnapshotMetadata, +} + +/// A snapshot validation failure, naming the field or coordinates. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SnapshotError { + /// No columns or rows (a snapshot always covers a non-empty grid). + EmptyDimensions, + /// A dimension exceeds [`MAX_SNAPSHOT_DIM`]. + DimensionTooLarge { cols: u64, rows: u64, limit: u32 }, + /// `cols * rows` exceeds [`MAX_SNAPSHOT_CELLS`]. + TooManyCells { cells: u64, limit: u64 }, + /// The cell vector length is not `cols * rows`. + CellCountMismatch { expected: u64, actual: usize }, + /// A wire palette that does not hold exactly 256 resolved entries. + PaletteLengthMismatch { actual: usize }, + /// A grid row does not hold exactly `cols` cells (live capture only). + MalformedRow { + row: u32, + expected: u32, + actual: usize, + }, + /// A cell grapheme exceeds [`MAX_CELL_TEXT_BYTES`]. + CellTextTooLarge { + col: u32, + row: u32, + bytes: usize, + limit: usize, + }, + /// A cell grapheme exceeds [`MAX_CELL_TEXT_BYTES`] (coordinate-free). + OversizeText { bytes: usize, limit: usize }, + /// A continuation cell (empty text) at column zero, or a non-continuation + /// width on empty text. + BadContinuation { col: u32, row: u32 }, + /// A hyperlink URI or identity exceeds [`MAX_LINK_BYTES`]. + LinkTooLarge { + col: u32, + row: u32, + bytes: usize, + limit: usize, + }, + /// A hyperlink URI or identity exceeds [`MAX_LINK_BYTES`] + /// (coordinate-free). + OversizeLink { bytes: usize, limit: usize }, + /// An in-capture cursor outside the captured grid. + CursorOutOfBounds { + col: u32, + row: u32, + cols: u32, + rows: u32, + }, + /// A nonzero viewport origin on a full-history snapshot. + BadViewportOrigin { scope: SnapshotScope, origin: u64 }, + /// The window title exceeds [`MAX_TITLE_BYTES`]. + TitleTooLarge { bytes: usize, limit: usize }, + /// A count that does not fit its narrow terminal interface. + CountOverflow { field: &'static str }, + /// A wire payload with an unknown schema version. + UnsupportedSchema { version: u32 }, + /// A wire color that is neither `default`, an index, nor `#rrggbb`. + InvalidColor { value: String }, +} + +impl fmt::Display for SnapshotError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + SnapshotError::EmptyDimensions => f.write_str("snapshot dimensions must be nonzero"), + SnapshotError::DimensionTooLarge { cols, rows, limit } => { + write!( + f, + "snapshot dimensions {cols}x{rows} exceed the limit of {limit}" + ) + } + SnapshotError::TooManyCells { cells, limit } => { + write!( + f, + "snapshot cell count {cells} exceeds the limit of {limit}" + ) + } + SnapshotError::CellCountMismatch { expected, actual } => { + write!( + f, + "snapshot holds {actual} cells for a {expected}-cell grid" + ) + } + SnapshotError::PaletteLengthMismatch { actual } => { + write!(f, "snapshot palette holds {actual} entries, expected 256") + } + SnapshotError::MalformedRow { + row, + expected, + actual, + } => { + write!( + f, + "snapshot row {row} holds {actual} cells, expected {expected}" + ) + } + SnapshotError::CellTextTooLarge { + col, + row, + bytes, + limit, + } => { + write!( + f, + "snapshot cell ({col}, {row}) holds {bytes} bytes, limit {limit}" + ) + } + SnapshotError::OversizeText { bytes, limit } => { + write!(f, "snapshot cell text holds {bytes} bytes, limit {limit}") + } + SnapshotError::BadContinuation { col, row } => { + write!( + f, + "snapshot cell ({col}, {row}) is not a valid continuation cell" + ) + } + SnapshotError::LinkTooLarge { + col, + row, + bytes, + limit, + } => { + write!( + f, + "snapshot cell ({col}, {row}) link holds {bytes} bytes, limit {limit}" + ) + } + SnapshotError::OversizeLink { bytes, limit } => { + write!(f, "snapshot link holds {bytes} bytes, limit {limit}") + } + SnapshotError::CursorOutOfBounds { + col, + row, + cols, + rows, + } => { + write!( + f, + "in-capture cursor ({col}, {row}) falls outside a {cols}x{rows} grid" + ) + } + SnapshotError::BadViewportOrigin { scope, origin } => { + write!( + f, + "snapshot scope {} must start at origin 0 (got {origin})", + scope.name() + ) + } + SnapshotError::TitleTooLarge { bytes, limit } => { + write!(f, "snapshot title holds {bytes} bytes, limit {limit}") + } + SnapshotError::CountOverflow { field } => { + write!( + f, + "snapshot count {field} does not fit its terminal interface" + ) + } + SnapshotError::UnsupportedSchema { version } => { + write!( + f, + "unsupported snapshot schema {version} (this build reads {SNAPSHOT_SCHEMA_VERSION})" + ) + } + SnapshotError::InvalidColor { value } => { + write!(f, "invalid snapshot color (got {value:?})") + } + } + } +} + +impl std::error::Error for SnapshotError {} + +impl From for crate::api::TuiTestError { + fn from(error: SnapshotError) -> Self { + crate::api::TuiTestError::internal(error.to_string()) + } +} + +/// The owned terminal snapshot: validated grid, colors, cursor, and title. +/// +/// Fields are private; read-only accessors expose the data. Canonical +/// equality and hashing cover the terminal state (dimensions, cells, palette, +/// defaults, cursor, title, scope, origin) and skip [`SnapshotMetadata`]: +/// re-observing the same terminal state compares equal however it was +/// observed. +#[derive(Debug, Clone)] +pub struct TerminalSnapshot { + cols: u32, + rows: u32, + scope: SnapshotScope, + viewport_origin: u64, + cells: Vec, + palette: [Rgb; 256], + default_foreground: Rgb, + default_background: Rgb, + default_cursor: Rgb, + cursor: SnapshotCursor, + title: Option, + metadata: SnapshotMetadata, +} + +impl TerminalSnapshot { + /// Build a validated snapshot from parts. + /// + /// Rejects empty dimensions, oversize input, ragged cells, invalid + /// continuations, out-of-bounds in-capture cursors, misplaced origins, + /// and oversize titles and links, using checked arithmetic throughout. + /// Cell geometry is recorded, never recomputed from UTF-8 byte length. + pub fn try_new(parts: SnapshotParts) -> Result { + assemble(parts) + } + + /// Logical terminal columns. + pub fn cols(&self) -> u32 { + self.cols + } + + /// Logical terminal rows for the captured scope. + pub fn rows(&self) -> u32 { + self.rows + } + + /// Which terminal region the cells cover. + pub fn scope(&self) -> SnapshotScope { + self.scope + } + + /// Full-grid index of the first captured row. + pub fn viewport_origin(&self) -> u64 { + self.viewport_origin + } + + /// Row-major cells, exactly `cols * rows` entries. + pub fn cells(&self) -> &[SnapshotCell] { + &self.cells + } + + /// One grid row, or `None` past the last row. + pub fn row(&self, row: u32) -> Option<&[SnapshotCell]> { + let start = (u64::from(row)).checked_mul(u64::from(self.cols))?; + let start = usize::try_from(start).ok()?; + let end = start.checked_add(self.cols as usize)?; + self.cells.get(start..end) + } + + /// The cell at `(col, row)`, or `None` outside the grid. + pub fn cell_at(&self, col: u32, row: u32) -> Option<&SnapshotCell> { + self.row(row)?.get(col as usize) + } + + /// Every resolved palette entry, indices 0-255. + pub fn palette(&self) -> &[Rgb; 256] { + &self.palette + } + + /// Resolved default foreground (`OSC 10` applied). + pub fn default_foreground(&self) -> Rgb { + self.default_foreground + } + + /// Resolved default background (`OSC 11` applied). + pub fn default_background(&self) -> Rgb { + self.default_background + } + + /// Resolved cursor color (`OSC 12` applied). + pub fn default_cursor(&self) -> Rgb { + self.default_cursor + } + + /// The captured cursor, in full-grid coordinates. + pub fn cursor(&self) -> &SnapshotCursor { + &self.cursor + } + + /// Capture-local cursor coordinates, or `None` outside the capture. + pub fn cursor_local(&self) -> Option<(u32, u32)> { + if !self.cursor.in_capture { + return None; + } + let row64 = u64::from(self.cursor.row).checked_sub(self.viewport_origin)?; + let row = u32::try_from(row64).ok()?; + Some((self.cursor.col, row)) + } + + /// Window title (`None` when the program set none). + pub fn title(&self) -> Option<&str> { + self.title.as_deref() + } + + /// Observation metadata (capture time, revision, identities, source). + pub fn metadata(&self) -> &SnapshotMetadata { + &self.metadata + } +} + +impl TryFrom for TerminalSnapshot { + type Error = SnapshotError; + + fn try_from(parts: SnapshotParts) -> Result { + TerminalSnapshot::try_new(parts) + } +} + +impl PartialEq for TerminalSnapshot { + fn eq(&self, other: &Self) -> bool { + self.cols == other.cols + && self.rows == other.rows + && self.scope == other.scope + && self.viewport_origin == other.viewport_origin + && self.cells == other.cells + && self.palette == other.palette + && self.default_foreground == other.default_foreground + && self.default_background == other.default_background + && self.default_cursor == other.default_cursor + && self.cursor == other.cursor + && self.title == other.title + } +} + +impl Eq for TerminalSnapshot {} + +/// Hash one resolved color field by field (`Rgb` itself has no `Hash` impl). +fn hash_rgb(color: &Rgb, state: &mut H) { + color.r.hash(state); + color.g.hash(state); + color.b.hash(state); +} + +impl Hash for TerminalSnapshot { + fn hash(&self, state: &mut H) { + self.cols.hash(state); + self.rows.hash(state); + self.scope.hash(state); + self.viewport_origin.hash(state); + self.cells.hash(state); + for entry in &self.palette { + hash_rgb(entry, state); + } + hash_rgb(&self.default_foreground, state); + hash_rgb(&self.default_background, state); + hash_rgb(&self.default_cursor, state); + self.cursor.hash_fields(state); + self.title.hash(state); + } +} + +impl Hash for SnapshotCursor { + fn hash(&self, state: &mut H) { + self.hash_fields(state); + } +} + +/// Versioned wire form. Private: Serde on [`TerminalSnapshot`] goes through +/// it, so constructors and deserialization share [`assemble`] validation. +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "snake_case", deny_unknown_fields)] +struct WireSnapshot { + #[serde(default)] + schema: u32, + cols: u32, + rows: u32, + scope: SnapshotScope, + viewport_origin: u64, + cells: Vec, + /// A sequence on the wire: serde has no array impl for 256 entries. + palette: Vec, + default_foreground: Rgb, + default_background: Rgb, + default_cursor: Rgb, + cursor: SnapshotCursor, + title: Option, + #[serde(default)] + metadata: SnapshotMetadata, +} + +impl Serialize for TerminalSnapshot { + fn serialize(&self, serializer: S) -> Result { + WireSnapshot { + schema: SNAPSHOT_SCHEMA_VERSION, + cols: self.cols, + rows: self.rows, + scope: self.scope, + viewport_origin: self.viewport_origin, + cells: self.cells.clone(), + palette: self.palette.to_vec(), + default_foreground: self.default_foreground, + default_background: self.default_background, + default_cursor: self.default_cursor, + cursor: self.cursor.clone(), + title: self.title.clone(), + metadata: self.metadata.clone(), + } + .serialize(serializer) + } +} + +impl<'de> Deserialize<'de> for TerminalSnapshot { + fn deserialize>(deserializer: D) -> Result { + let wire = WireSnapshot::deserialize(deserializer)?; + if wire.schema != SNAPSHOT_SCHEMA_VERSION { + return Err(serde::de::Error::custom( + SnapshotError::UnsupportedSchema { + version: wire.schema, + } + .to_string(), + )); + } + let palette: [Rgb; 256] = wire.palette.try_into().map_err(|palette: Vec| { + serde::de::Error::custom( + SnapshotError::PaletteLengthMismatch { + actual: palette.len(), + } + .to_string(), + ) + })?; + assemble(SnapshotParts { + cols: wire.cols, + rows: wire.rows, + scope: wire.scope, + viewport_origin: wire.viewport_origin, + cells: wire.cells, + palette, + default_foreground: wire.default_foreground, + default_background: wire.default_background, + default_cursor: wire.default_cursor, + cursor: wire.cursor, + title: wire.title, + metadata: wire.metadata, + }) + .map_err(serde::de::Error::custom) + } +} + +/// Shared validation for [`TerminalSnapshot::try_new`] and deserialization. +fn assemble(parts: SnapshotParts) -> Result { + let SnapshotParts { + cols, + rows, + scope, + viewport_origin, + cells, + palette, + default_foreground, + default_background, + default_cursor, + cursor, + title, + metadata, + } = parts; + if cols == 0 || rows == 0 { + return Err(SnapshotError::EmptyDimensions); + } + if cols > MAX_SNAPSHOT_DIM || rows > MAX_SNAPSHOT_DIM { + return Err(SnapshotError::DimensionTooLarge { + cols: u64::from(cols), + rows: u64::from(rows), + limit: MAX_SNAPSHOT_DIM, + }); + } + let count = + u64::from(cols) + .checked_mul(u64::from(rows)) + .ok_or(SnapshotError::CountOverflow { + field: "cols * rows", + })?; + if count > MAX_SNAPSHOT_CELLS { + return Err(SnapshotError::TooManyCells { + cells: count, + limit: MAX_SNAPSHOT_CELLS, + }); + } + if cells.len() as u64 != count { + return Err(SnapshotError::CellCountMismatch { + expected: count, + actual: cells.len(), + }); + } + for (index, cell) in cells.iter().enumerate() { + let col = (index as u32) % cols; + let row = (index as u32) / cols; + cell.check_at(col, row)?; + } + if scope == SnapshotScope::FullHistory && viewport_origin != 0 { + return Err(SnapshotError::BadViewportOrigin { + scope, + origin: viewport_origin, + }); + } + if let Some(title) = title.as_deref() { + if title.len() > MAX_TITLE_BYTES { + return Err(SnapshotError::TitleTooLarge { + bytes: title.len(), + limit: MAX_TITLE_BYTES, + }); + } + } + cursor.check_in(cols, rows, viewport_origin)?; + Ok(TerminalSnapshot { + cols, + rows, + scope, + viewport_origin, + cells, + palette, + default_foreground, + default_background, + default_cursor, + cursor, + title, + metadata, + }) +} + +/// Capture options: scope plus an explicit highlighting opt-in. +/// +/// The default capture records raw visual data. A highlighted capture needs +/// [`SnapshotOptions::with_highlight`] set to `true`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SnapshotOptions { + scope: SnapshotScope, + highlight: bool, +} + +impl SnapshotOptions { + /// Capture the selected scope with raw visual data. + pub fn new(scope: SnapshotScope) -> Self { + SnapshotOptions { + scope, + highlight: false, + } + } + + /// Which terminal region to capture. + pub fn scope(&self) -> SnapshotScope { + self.scope + } + + /// Whether locator highlighting is folded into the captured cells. + pub fn highlighted(&self) -> bool { + self.highlight + } + + /// Opt into (or out of) folding locator highlighting into the capture. + pub fn with_highlight(mut self, highlight: bool) -> Self { + self.highlight = highlight; + self + } +} + +impl From for SnapshotOptions { + fn from(scope: SnapshotScope) -> Self { + SnapshotOptions::new(scope) + } +} + +/// Highlighted cells in full-grid coordinates, borrowed from terminal state. +/// +/// The caller holds the terminal-state lock for the whole capture; this only +/// carries the reference across. +#[derive(Debug, Clone, Copy)] +pub(crate) struct LiveHighlight<'a> { + pub cells: &'a [(usize, usize)], +} + +/// Capture from a live emulator. The caller holds the terminal-state lock; +/// every field below (grid, cursor, dimensions, title, resolved colors) is +/// read under that one lock, so the snapshot is one atomic terminal moment. +/// +/// Highlighting applies only when `options.highlighted()` is set and a +/// highlight is supplied; the default capture keeps raw visual data. +pub(crate) fn capture_live( + emu: &dyn Emulator, + highlight: Option>, + options: &SnapshotOptions, + metadata: SnapshotMetadata, +) -> Result { + use crate::terminal::cell::display_width; + + let (cols16, _) = emu.size(); + let cols = u32::from(cols16); + if cols == 0 { + return Err(SnapshotError::EmptyDimensions); + } + if cols > MAX_SNAPSHOT_DIM { + return Err(SnapshotError::DimensionTooLarge { + cols: u64::from(cols), + rows: 0, + limit: MAX_SNAPSHOT_DIM, + }); + } + let viewport = emu.viewport(); + let history = viewport.history as u64; + let (rows_data, origin) = match options.scope() { + SnapshotScope::Viewport => (emu.viewable_rows(), viewport.origin() as u64), + SnapshotScope::ActiveScreen => (emu.active_rows(), history), + SnapshotScope::FullHistory => (emu.full_rows(), 0), + }; + let row_count = rows_data.len() as u64; + if row_count == 0 { + return Err(SnapshotError::EmptyDimensions); + } + if row_count > u64::from(MAX_SNAPSHOT_DIM) { + return Err(SnapshotError::DimensionTooLarge { + cols: u64::from(cols), + rows: row_count, + limit: MAX_SNAPSHOT_DIM, + }); + } + let rows = row_count as u32; + let count = row_count + .checked_mul(u64::from(cols)) + .ok_or(SnapshotError::CountOverflow { + field: "cols * rows", + })?; + if count > MAX_SNAPSHOT_CELLS { + return Err(SnapshotError::TooManyCells { + cells: count, + limit: MAX_SNAPSHOT_CELLS, + }); + } + + let highlighted: Option> = if options.highlighted() { + highlight.map(|selected| selected.cells.iter().map(|&(x, y)| (x, y as u64)).collect()) + } else { + None + }; + + let mut cells = Vec::new(); + cells + .try_reserve(rows_data.len().saturating_mul(cols as usize)) + .map_err(|_| SnapshotError::CountOverflow { + field: "snapshot cell buffer", + })?; + for (row_index, row) in rows_data.iter().enumerate() { + if row.len() as u64 != u64::from(cols) { + return Err(SnapshotError::MalformedRow { + row: row_index as u32, + expected: cols, + actual: row.len(), + }); + } + let full_row = origin.saturating_add(row_index as u64); + for (col_index, emu_cell) in row.iter().enumerate() { + let mut attrs = emu_cell.attrs; + if let Some(selected) = highlighted.as_ref() { + if selected.contains(&(col_index, full_row)) { + attrs.toggle(Attrs::INVERSE); + } + } + let (text, width) = if emu_cell.ch.as_str() == CONTINUATION { + (String::new(), 0) + } else { + let width = u16::try_from(display_width(emu_cell.ch.as_str())).map_err(|_| { + SnapshotError::CountOverflow { + field: "cell width", + } + })?; + (emu_cell.ch.to_string(), width) + }; + cells.push( + SnapshotCell::new( + text, + width, + SnapshotColor::from(emu_cell.fg), + SnapshotColor::from(emu_cell.bg), + attrs, + SnapshotUnderline::from(emu_cell.underline), + SnapshotColor::from(emu_cell.underline_color), + emu_cell.hyperlink.as_ref().map(|link| link.uri.to_string()), + emu_cell + .hyperlink + .as_ref() + .and_then(|link| link.id.as_deref()) + .map(str::to_string), + ) + .map_err(|error| match error { + SnapshotError::OversizeText { bytes, limit } => { + SnapshotError::CellTextTooLarge { + col: col_index as u32, + row: row_index as u32, + bytes, + limit, + } + } + SnapshotError::OversizeLink { bytes, limit } => SnapshotError::LinkTooLarge { + col: col_index as u32, + row: row_index as u32, + bytes, + limit, + }, + other => other, + })?, + ); + } + } + + let (cursor_x, cursor_y) = emu.cursor(); + let cursor_row = + history + .checked_add(u64::from(cursor_y)) + .ok_or(SnapshotError::CountOverflow { + field: "cursor row", + })?; + let cursor_row32: u32 = + cursor_row + .try_into() + .map_err(|_| SnapshotError::CountOverflow { + field: "cursor row", + })?; + let cursor_col = u32::from(cursor_x); + let in_capture = match options.scope() { + SnapshotScope::Viewport => { + cursor_row + .checked_sub(origin) + .is_some_and(|local| local < row_count) + && cursor_col < cols + } + SnapshotScope::ActiveScreen => { + if u64::from(cursor_y) >= row_count || cursor_col >= cols { + return Err(SnapshotError::CursorOutOfBounds { + col: cursor_col, + row: cursor_row32, + cols, + rows, + }); + } + true + } + SnapshotScope::FullHistory => { + if cursor_row >= row_count || cursor_col >= cols { + return Err(SnapshotError::CursorOutOfBounds { + col: cursor_col, + row: cursor_row32, + cols, + rows, + }); + } + true + } + }; + + let table = emu.colors(); + assemble(SnapshotParts { + cols, + rows, + scope: options.scope(), + viewport_origin: origin, + cells, + palette: table.palette, + default_foreground: table.foreground, + default_background: table.background, + default_cursor: table.cursor, + cursor: SnapshotCursor::new( + cursor_col, + cursor_row32, + in_capture, + emu.cursor_visible(), + SnapshotCursorShape::from(emu.cursor_shape()), + emu.color(ColorSlot::Cursor), + None, + ), + title: emu.title(), + metadata, + }) +} + +/// Current Unix time in milliseconds; zero when the clock is unavailable. +pub(crate) fn capture_time_ms() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|elapsed| elapsed.as_millis().try_into().unwrap_or(u64::MAX)) + .unwrap_or(0) +} From f7acf831bd1506fedfa890d56fd554f8c1f0df6b Mon Sep 17 00:00:00 2001 From: Alexey Zhokhov Date: Sun, 25 Oct 2026 17:28:39 +0700 Subject: [PATCH 02/16] Cover snapshot validation, corpus, and lock-atomic capture with tests Signed-off-by: Alexey Zhokhov --- crates/tui-test/src/snapshot.rs | 601 +++++++++++++++++++++++++++++++- 1 file changed, 595 insertions(+), 6 deletions(-) diff --git a/crates/tui-test/src/snapshot.rs b/crates/tui-test/src/snapshot.rs index a1c9cd43..a0128c32 100644 --- a/crates/tui-test/src/snapshot.rs +++ b/crates/tui-test/src/snapshot.rs @@ -1296,12 +1296,11 @@ pub(crate) fn capture_live( .ok_or(SnapshotError::CountOverflow { field: "cursor row", })?; - let cursor_row32: u32 = - cursor_row - .try_into() - .map_err(|_| SnapshotError::CountOverflow { - field: "cursor row", - })?; + let cursor_row32: u32 = cursor_row + .try_into() + .map_err(|_| SnapshotError::CountOverflow { + field: "cursor row", + })?; let cursor_col = u32::from(cursor_x); let in_capture = match options.scope() { SnapshotScope::Viewport => { @@ -1366,3 +1365,593 @@ pub(crate) fn capture_time_ms() -> u64 { .map(|elapsed| elapsed.as_millis().try_into().unwrap_or(u64::MAX)) .unwrap_or(0) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::profile::{Profile, Rgb}; + use crate::terminal::alacritty::AlacrittyEmu; + use std::collections::hash_map::DefaultHasher; + use std::sync::{Arc, Barrier, Mutex}; + + const COLS: u16 = 10; + const ROWS: u16 = 6; + + fn profile() -> Profile { + Profile::default() + } + + fn resolved_palette(profile: &Profile) -> [Rgb; 256] { + std::array::from_fn(|index| profile.colors.rgb(index as u8)) + } + + fn blank_parts(profile: &Profile, cols: u32, rows: u32) -> SnapshotParts { + let palette = resolved_palette(profile); + SnapshotParts { + cols, + rows, + scope: SnapshotScope::Viewport, + viewport_origin: 0, + cells: (0..cols * rows) + .map(|_| { + SnapshotCell::new( + " ".to_string(), + 1, + SnapshotColor::Default, + SnapshotColor::Default, + Attrs::empty(), + SnapshotUnderline::None, + SnapshotColor::Default, + None, + None, + ) + .expect("blank cell is valid") + }) + .collect(), + palette, + default_foreground: palette[0], + default_background: palette[0], + default_cursor: palette[0], + cursor: SnapshotCursor::new( + 0, + 0, + true, + true, + SnapshotCursorShape::Block, + palette[0], + None, + ), + title: None, + metadata: SnapshotMetadata::default(), + } + } + + fn canonical_hash(snapshot: &TerminalSnapshot) -> u64 { + let mut hasher = DefaultHasher::new(); + snapshot.hash(&mut hasher); + hasher.finish() + } + + /// Feed the Unicode corpus: combining marks, wide char with continuation, + /// bold/italic/bold-italic, and a wide character at the last column. + fn feed_corpus(emu: &mut AlacrittyEmu) { + emu.process(b"\x1b[2J\x1b[H"); + // e + combining acute, a + combining diaeresis, wide char, styles. + emu.process("é ä 東 \x1b[1mbold\x1b[0m".as_bytes()); + emu.process(b"\r\n\x1b[3mitalic\x1b[0m \x1b[1;3mbi\x1b[0m"); + // Wide character exactly filling the last two columns. The earlier + // wrap consumed an extra row, so the grid is one row deeper here. + emu.process("\r\n 東".as_bytes()); + // Park the cursor over the wide character. + emu.process(b"\x1b[4;9H"); + } + + #[test] + fn corpus_cells_keep_recorded_geometry() { + let mut emu = AlacrittyEmu::new(COLS, ROWS, &profile()); + feed_corpus(&mut emu); + let snapshot = capture_live( + &emu, + None, + &SnapshotOptions::new(SnapshotScope::Viewport), + SnapshotMetadata::default(), + ) + .expect("corpus capture succeeds"); + assert_eq!( + (snapshot.cols(), snapshot.rows()), + (u32::from(COLS), u32::from(ROWS)) + ); + + let first = snapshot.row(0).expect("first row exists"); + assert_eq!(first[0].text(), "é"); + assert_eq!(first[0].width(), 1); + assert_eq!(first[2].text(), "ä"); + assert_eq!(first[4].text(), "東"); + assert_eq!(first[4].width(), 2); + assert!(first[5].is_continuation()); + assert_eq!(first[5].width(), 0); + + let bold = first + .iter() + .find(|cell| cell.text() == "b") + .expect("bold text"); + assert!(bold.has(Attrs::BOLD)); + assert!(!bold.has(Attrs::ITALIC)); + + // Style runs may wrap onto later rows; find them grid-wide. + let styled: Vec<&SnapshotCell> = snapshot.cells().iter().collect(); + let bold_only = styled + .iter() + .find(|cell| cell.text() == "o" && cell.has(Attrs::BOLD) && !cell.has(Attrs::ITALIC)) + .expect("bold-only text"); + let _ = bold_only; + let italic_only = styled + .iter() + .find(|cell| cell.text() == "t" && cell.has(Attrs::ITALIC) && !cell.has(Attrs::BOLD)) + .expect("italic-only text"); + let _ = italic_only; + let both = styled + .iter() + .find(|cell| cell.text() == "i" && cell.has(Attrs::BOLD | Attrs::ITALIC)) + .expect("bold-italic text"); + let _ = both; + + // Wide character at the last column boundary plus cursor over it. + let wide_row = snapshot.row(3).expect("wide row exists"); + assert_eq!(wide_row[8].text(), "東"); + assert_eq!(wide_row[8].width(), 2); + assert!(wide_row[9].is_continuation()); + assert_eq!(snapshot.cursor_local(), Some((8, 3))); + // Blink is unknown at this backend: recorded as unknown, not false. + assert_eq!(snapshot.cursor().blink(), None); + } + + #[test] + fn sgr_index_normalization_is_recorded() { + let mut emu = AlacrittyEmu::new(COLS, ROWS, &profile()); + emu.process(b"\x1b[31mA\x1b[38;5;1mB\x1b[38;2;10;20;30mC\x1b[0mD"); + let snapshot = capture_live( + &emu, + None, + &SnapshotOptions::new(SnapshotScope::Viewport), + SnapshotMetadata::default(), + ) + .expect("sgr capture succeeds"); + let row = snapshot.row(0).expect("first row exists"); + // Both SGR spellings of slot 1 land on the same recorded reference. + assert_eq!(row[0].fg(), SnapshotColor::Indexed(1)); + assert_eq!(row[1].fg(), SnapshotColor::Indexed(1)); + assert_eq!(row[2].fg(), SnapshotColor::Rgb(10, 20, 30)); + assert_eq!(row[3].fg(), SnapshotColor::Default); + } + + #[test] + fn default_capture_ignores_highlighting() { + let mut emu = AlacrittyEmu::new(COLS, ROWS, &profile()); + emu.process(b"highlight me"); + let selected = [(0usize, 0usize), (1, 0)]; + let highlight = LiveHighlight { cells: &selected }; + let metadata = SnapshotMetadata::default(); + let raw = capture_live( + &emu, + Some(highlight), + &SnapshotOptions::new(SnapshotScope::Viewport), + metadata.clone(), + ) + .expect("raw capture succeeds"); + let marked = capture_live( + &emu, + Some(highlight), + &SnapshotOptions::new(SnapshotScope::Viewport).with_highlight(true), + metadata, + ) + .expect("highlighted capture succeeds"); + assert!(!raw.cell_at(0, 0).expect("cell").has(Attrs::INVERSE)); + assert!(marked.cell_at(0, 0).expect("cell").has(Attrs::INVERSE)); + assert!(marked.cell_at(1, 0).expect("cell").has(Attrs::INVERSE)); + assert!(!marked.cell_at(2, 0).expect("cell").has(Attrs::INVERSE)); + assert_ne!(raw, marked); + } + + /// U02-A: transitions change grid, cursor, dimensions, and palette while + /// the terminal-state lock is held; barriers pin each capture between two + /// locked changes, so every capture must equal one permitted state. + #[test] + fn atomic_capture_matches_one_permitted_state() { + struct Transition { + apply: Box, + scope: SnapshotScope, + } + + let profile = profile(); + let emu = Arc::new(Mutex::new(AlacrittyEmu::new(COLS, ROWS, &profile))); + let barrier = Arc::new(Barrier::new(2)); + + let transitions = Arc::new(vec![ + Transition { + apply: Box::new(|emu| { + feed_corpus(emu); + emu.process(b"\x1b[1;1H\x1b[7mR\x1b[0m"); + emu.process(b"\x1b]4;1;#112233\x1b\\"); + emu.process(b"\x1b]10;#010203\x1b\\\x1b]11;#040506\x1b\\\x1b]12;#070809\x1b\\"); + emu.process(b"\x1b]0;snap-title\x1b\\"); + emu.process(b"\x1b]8;id=u02a;https://example.com\x1b\\L\x1b]8;;\x1b\\"); + }), + scope: SnapshotScope::Viewport, + }, + Transition { + apply: Box::new(|emu| { + emu.process(b"\x1b]4;1;#445566\x1b\\\x1b]4;200;#aabbcc\x1b\\"); + emu.process(b"\x1b]10;#0a0b0c\x1b\\"); + emu.process(b"\x1b[5;5H\x1b[?25l"); + emu.process(b"\x1b]0;snap-title-2\x1b\\"); + }), + scope: SnapshotScope::Viewport, + }, + Transition { + apply: Box::new(|emu| { + emu.resize(20, 8); + for line in 0..12 { + emu.process(format!("line {line:02}\r\n").as_bytes()); + } + emu.scroll(crate::api::ScrollTarget::Lines(2)); + }), + scope: SnapshotScope::Viewport, + }, + ]); + + let writer_emu = Arc::clone(&emu); + let writer_barrier = Arc::clone(&barrier); + let writer_transitions = Arc::clone(&transitions); + let writer = std::thread::spawn(move || { + for transition in writer_transitions.iter() { + { + let mut guard = writer_emu.lock().expect("writer locks the emulator"); + (transition.apply)(&mut guard); + } + writer_barrier.wait(); + writer_barrier.wait(); + } + }); + + let mut permitted: Vec = Vec::new(); + for transition in transitions.iter() { + barrier.wait(); + let options = SnapshotOptions::new(transition.scope); + let first = { + let guard = emu.lock().expect("reader locks the emulator"); + capture_live( + &*guard, + None, + &options, + SnapshotMetadata { + captured_at_ms: 1, + revision: permitted.len() as u64, + backend: "alacritty".to_string(), + session: "u02-a".to_string(), + source: SnapshotSource::Live, + }, + ) + .expect("capture under lock succeeds") + }; + let second = { + let guard = emu.lock().expect("reader locks the emulator"); + capture_live( + &*guard, + None, + &options, + SnapshotMetadata { + captured_at_ms: 2, + revision: 100 + permitted.len() as u64, + backend: "other".to_string(), + session: "u02-a".to_string(), + source: SnapshotSource::Live, + }, + ) + .expect("recapture under lock succeeds") + }; + // Same locked state, observation-only metadata differs: canonical + // equality and hashing ignore it (C07). + assert_eq!(first, second, "metadata-only change passes"); + assert_eq!(canonical_hash(&first), canonical_hash(&second)); + // The capture is stable: no tearing between the two locked reads. + assert!(permitted + .iter() + .all(|known: &TerminalSnapshot| *known != first)); + assert!(permitted + .iter() + .all(|known: &TerminalSnapshot| *known != second)); + permitted.push(first); + barrier.wait(); + } + writer.join().expect("writer finishes"); + + assert_eq!(permitted.len(), 3); + + // C04: corpus text, continuation, styles, and cursor survived T0. + let base = &permitted[0]; + assert_eq!((base.cols(), base.rows()), (10, 6)); + assert_eq!(base.title(), Some("snap-title")); + assert_eq!(base.palette()[1], Rgb::new(0x11, 0x22, 0x33)); + assert_eq!(base.default_foreground(), Rgb::new(0x01, 0x02, 0x03)); + assert_eq!(base.default_background(), Rgb::new(0x04, 0x05, 0x06)); + assert_eq!(base.default_cursor(), Rgb::new(0x07, 0x08, 0x09)); + let reversed = base.cell_at(0, 0).expect("reversed cell"); + assert_eq!(reversed.text(), "R"); + assert!(reversed.has(Attrs::INVERSE)); + // Full-history coordinates use u32, never u16. + let full = { + let guard = emu.lock().expect("reader locks the emulator"); + capture_live( + &*guard, + None, + &SnapshotOptions::new(SnapshotScope::FullHistory), + SnapshotMetadata::default(), + ) + .expect("full-history capture succeeds") + }; + assert_eq!(full.scope(), SnapshotScope::FullHistory); + assert_eq!(full.viewport_origin(), 0); + assert!(full.rows() >= base.rows()); + + // C05: T1 changed palette, defaults, cursor visibility, and title. + let changed = &permitted[1]; + assert_eq!(changed.palette()[1], Rgb::new(0x44, 0x55, 0x66)); + assert_eq!(changed.palette()[200], Rgb::new(0xaa, 0xbb, 0xcc)); + assert_eq!(changed.default_foreground(), Rgb::new(0x0a, 0x0b, 0x0c)); + assert!(!changed.cursor().visible()); + assert_eq!(changed.title(), Some("snap-title-2")); + + // T2 resized, grew history, and scrolled: the cursor is recorded + // outside the viewport capture, unclamped, with no local coords. + let scrolled = &permitted[2]; + assert_eq!((scrolled.cols(), scrolled.rows()), (20, 8)); + assert!(!scrolled.cursor().in_capture()); + assert_eq!(scrolled.cursor_local(), None); + // The position is explicit, not clamped into the 20x8 grid. + assert!( + u64::from(scrolled.cursor().row()) + >= scrolled.viewport_origin() + u64::from(scrolled.rows()) + ); + assert_eq!(scrolled.viewport_origin(), { + let guard = emu.lock().expect("reader locks the emulator"); + guard.viewport().origin() as u64 + }); + for row in 0..scrolled.rows() { + assert_eq!( + scrolled.row(row).expect("row exists").len() as u32, + scrolled.cols(), + "rectangular geometry after resize and scroll" + ); + } + } + + #[test] + fn constructor_and_json_reject_identical_invalid_geometry() { + let profile = profile(); + // Row-major length does not match cols * rows. + let mut mismatch = blank_parts(&profile, 2, 2); + mismatch.cells.pop(); + assert_eq!( + TerminalSnapshot::try_new(mismatch.clone()), + Err(SnapshotError::CellCountMismatch { + expected: 4, + actual: 3 + }) + ); + + // Continuation cell at column zero. + let mut bad_continuation = blank_parts(&profile, 2, 1); + bad_continuation.cells[0] = SnapshotCell::new( + String::new(), + 0, + SnapshotColor::Default, + SnapshotColor::Default, + Attrs::empty(), + SnapshotUnderline::None, + SnapshotColor::Default, + None, + None, + ) + .expect("coordinate-free construction succeeds"); + assert_eq!( + TerminalSnapshot::try_new(bad_continuation), + Err(SnapshotError::BadContinuation { col: 0, row: 0 }) + ); + + // In-capture cursor outside the grid. + let mut bad_cursor = blank_parts(&profile, 2, 2); + bad_cursor.cursor = SnapshotCursor::new( + 7, + 0, + true, + true, + SnapshotCursorShape::Block, + Rgb::new(0, 0, 0), + None, + ); + assert_eq!( + TerminalSnapshot::try_new(bad_cursor), + Err(SnapshotError::CursorOutOfBounds { + col: 7, + row: 0, + cols: 2, + rows: 2 + }) + ); + + // Full-history snapshots start at origin zero. + let mut bad_origin = blank_parts(&profile, 2, 2); + bad_origin.scope = SnapshotScope::FullHistory; + bad_origin.viewport_origin = 3; + assert_eq!( + TerminalSnapshot::try_new(bad_origin), + Err(SnapshotError::BadViewportOrigin { + scope: SnapshotScope::FullHistory, + origin: 3 + }) + ); + + // The same malformed grids fail through JSON deserialization (U02-F). + let valid = TerminalSnapshot::try_new(blank_parts(&profile, 2, 2)).expect("valid"); + let mut wire = serde_json::to_value(&valid).expect("serializes"); + wire["cells"].as_array_mut().expect("cells array").pop(); + assert!(serde_json::from_value::(wire).is_err()); + + let mut wire = serde_json::to_value(&valid).expect("serializes"); + wire["schema"] = serde_json::json!(999); + assert_eq!( + serde_json::from_value::(wire) + .expect_err("unsupported schema fails") + .to_string() + .contains("unsupported snapshot schema"), + true + ); + } + + #[test] + fn oversize_input_is_rejected() { + let profile = profile(); + assert_eq!( + TerminalSnapshot::try_new(SnapshotParts { + cols: MAX_SNAPSHOT_DIM + 1, + ..blank_parts(&profile, 2, 2) + }), + Err(SnapshotError::DimensionTooLarge { + cols: u64::from(MAX_SNAPSHOT_DIM + 1), + rows: 2, + limit: MAX_SNAPSHOT_DIM + }) + ); + assert_eq!( + TerminalSnapshot::try_new(SnapshotParts { + cols: 0, + ..blank_parts(&profile, 2, 2) + }), + Err(SnapshotError::EmptyDimensions) + ); + // 32768 x 32768 exceeds the cell budget without exceeding a dimension. + assert_eq!( + TerminalSnapshot::try_new(SnapshotParts { + cols: MAX_SNAPSHOT_DIM, + rows: MAX_SNAPSHOT_DIM, + ..blank_parts(&profile, 2, 2) + }), + Err(SnapshotError::TooManyCells { + cells: u64::from(MAX_SNAPSHOT_DIM) * u64::from(MAX_SNAPSHOT_DIM), + limit: MAX_SNAPSHOT_CELLS + }) + ); + assert!(SnapshotCell::new( + "x".repeat(MAX_CELL_TEXT_BYTES + 1), + 1, + SnapshotColor::Default, + SnapshotColor::Default, + Attrs::empty(), + SnapshotUnderline::None, + SnapshotColor::Default, + None, + None, + ) + .is_err()); + } + + #[test] + fn default_and_explicit_rgb_stay_distinct() { + let profile = profile(); + let palette = resolved_palette(&profile); + let mut defaulted = blank_parts(&profile, 1, 1); + defaulted.default_foreground = palette[3]; + defaulted.cells[0] = SnapshotCell::new( + "x".to_string(), + 1, + SnapshotColor::Default, + SnapshotColor::Default, + Attrs::empty(), + SnapshotUnderline::None, + SnapshotColor::Default, + None, + None, + ) + .expect("cell is valid"); + let mut explicit = defaulted.clone(); + explicit.cells[0] = SnapshotCell::new( + "x".to_string(), + 1, + SnapshotColor::Rgb(palette[3].r, palette[3].g, palette[3].b), + SnapshotColor::Default, + Attrs::empty(), + SnapshotUnderline::None, + SnapshotColor::Default, + None, + None, + ) + .expect("cell is valid"); + let a = TerminalSnapshot::try_new(defaulted).expect("valid"); + let b = TerminalSnapshot::try_new(explicit).expect("valid"); + // Equal resolved colors do not erase different color-reference kinds. + assert_ne!(a, b); + assert_eq!(a.cells()[0].fg(), SnapshotColor::Default); + } + + #[test] + fn unreferenced_palette_entry_changes_canonical_state() { + let profile = profile(); + let left = TerminalSnapshot::try_new(blank_parts(&profile, 1, 1)).expect("valid"); + let mut parts = blank_parts(&profile, 1, 1); + parts.palette[77] = Rgb::new(1, 2, 3); + let right = TerminalSnapshot::try_new(parts).expect("valid"); + assert_ne!(left, right); + assert_ne!(canonical_hash(&left), canonical_hash(&right)); + } + + #[test] + fn round_trip_preserves_palette_and_links() { + let profile = profile(); + let mut parts = blank_parts(&profile, 3, 1); + parts.palette[1] = Rgb::new(0x11, 0x22, 0x33); + parts.palette[200] = Rgb::new(0xaa, 0xbb, 0xcc); + parts.title = Some("round-trip".to_string()); + parts.cursor = SnapshotCursor::new( + 2, + 0, + true, + true, + SnapshotCursorShape::Underline, + Rgb::new(9, 9, 9), + Some(true), + ); + parts.cells[0] = SnapshotCell::new( + "L".to_string(), + 1, + SnapshotColor::Indexed(200), + SnapshotColor::Rgb(1, 2, 3), + Attrs::BOLD | Attrs::INVERSE, + SnapshotUnderline::Curly, + SnapshotColor::Indexed(1), + Some("https://example.com/x".to_string()), + Some("wrap-id".to_string()), + ) + .expect("cell is valid"); + let snapshot = TerminalSnapshot::try_new(parts).expect("valid"); + let json = serde_json::to_string(&snapshot).expect("serializes"); + let back: TerminalSnapshot = serde_json::from_str(&json).expect("deserializes"); + assert_eq!(snapshot, back); + assert_eq!(canonical_hash(&snapshot), canonical_hash(&back)); + assert_eq!(back.palette()[200], Rgb::new(0xaa, 0xbb, 0xcc)); + assert_eq!( + back.cell_at(0, 0).expect("cell").link_uri(), + Some("https://example.com/x") + ); + assert_eq!(back.cell_at(0, 0).expect("cell").link_id(), Some("wrap-id")); + assert_eq!(back.cursor().blink(), Some(true)); + assert!(back + .cell_at(0, 0) + .expect("cell") + .has(Attrs::BOLD | Attrs::INVERSE)); + assert_eq!( + back.cell_at(0, 0).expect("cell").underline(), + SnapshotUnderline::Curly + ); + } +} From 9e13fc1173279c14022d42e41053ae55ecda1c3c Mon Sep 17 00:00:00 2001 From: Alexey Zhokhov Date: Sun, 25 Oct 2026 17:31:33 +0700 Subject: [PATCH 03/16] Add owned_snapshot acceptance tests U02-A through U02-K Signed-off-by: Alexey Zhokhov --- crates/tui-test/tests/owned_snapshot.rs | 618 ++++++++++++++++++++++++ 1 file changed, 618 insertions(+) create mode 100644 crates/tui-test/tests/owned_snapshot.rs diff --git a/crates/tui-test/tests/owned_snapshot.rs b/crates/tui-test/tests/owned_snapshot.rs new file mode 100644 index 00000000..95b3d44b --- /dev/null +++ b/crates/tui-test/tests/owned_snapshot.rs @@ -0,0 +1,618 @@ +//! U02 acceptance: the public owned snapshot over live sessions and offline input. +//! +//! Live content is produced by the child (`printf` at startup, then `sleep`), +//! so no PTY echo path can mangle escape sequences. Each capture is polled to +//! a known marker before asserting, so no assertion depends on timing. + +#![cfg(unix)] + +use std::time::{Duration, Instant}; + +use tui_test::profile::{Profile, Rgb}; +use tui_test::terminal::cell::Attrs; +use tui_test::{ + OpenOptions, Operation, OperationResult, RunOptions, ScrollTarget, Session, SnapshotCell, + SnapshotColor, SnapshotCursor, SnapshotCursorShape, SnapshotError, SnapshotMetadata, + SnapshotOptions, SnapshotParts, SnapshotScope, SnapshotSource, SnapshotUnderline, + TerminalSnapshot, MAX_CELL_TEXT_BYTES, MAX_SNAPSHOT_CELLS, MAX_SNAPSHOT_DIM, +}; + +fn session_with_script(test: &str, script: &str, cols: u16, rows: u16) -> Session { + let session = Session::new(format!("owned-snapshot-{test}-{}", std::process::id())); + let defaults = OpenOptions::default(); + session + .run(RunOptions { + backend: defaults.backend, + program: "sh".to_string(), + args: vec!["-c".to_string(), format!("printf '{script}'; sleep 30")], + profile: defaults.profile, + style: defaults.style, + cols, + rows, + cwd: defaults.cwd, + env: defaults.env, + wait_ready: Some(false), + restart: defaults.restart, + timeouts: defaults.timeouts, + recording: defaults.recording, + }) + .expect("session starts"); + session +} + +fn live_text(session: &Session) -> String { + match session.execute(Operation::Text { full: false }) { + Ok(OperationResult::Text(text)) => text, + other => panic!("text operation gave {other:?}"), + } +} + +fn poll_text(session: &Session, needle: &str) { + let deadline = Instant::now() + Duration::from_secs(15); + loop { + if live_text(session).contains(needle) { + return; + } + if Instant::now() >= deadline { + panic!( + "timed out waiting for {needle:?}; got {:?}", + live_text(session) + ); + } + std::thread::sleep(Duration::from_millis(20)); + } +} + +fn close(session: &Session) { + session.close().expect("session closes"); +} + +fn resolved_palette() -> [Rgb; 256] { + let profile = Profile::default(); + std::array::from_fn(|index| profile.colors.rgb(index as u8)) +} + +fn blank_parts(cols: u32, rows: u32) -> SnapshotParts { + let palette = resolved_palette(); + SnapshotParts { + cols, + rows, + scope: SnapshotScope::Viewport, + viewport_origin: 0, + cells: (0..cols * rows) + .map(|_| { + SnapshotCell::new( + " ".to_string(), + 1, + SnapshotColor::Default, + SnapshotColor::Default, + Attrs::empty(), + SnapshotUnderline::None, + SnapshotColor::Default, + None, + None, + ) + .expect("blank cell is valid") + }) + .collect(), + palette, + default_foreground: Rgb::new(1, 2, 3), + default_background: Rgb::new(4, 5, 6), + default_cursor: Rgb::new(7, 8, 9), + cursor: SnapshotCursor::new( + 0, + 0, + true, + true, + SnapshotCursorShape::Block, + Rgb::new(7, 8, 9), + None, + ), + title: None, + metadata: SnapshotMetadata::default(), + } +} + +fn assert_rectangular(snapshot: &TerminalSnapshot) { + assert_ne!((snapshot.cols(), snapshot.rows()), (0, 0)); + assert_eq!( + snapshot.cells().len() as u64, + u64::from(snapshot.cols()) * u64::from(snapshot.rows()) + ); + for row in 0..snapshot.rows() { + assert_eq!( + snapshot.row(row).expect("row exists").len() as u32, + snapshot.cols(), + "row {row} keeps rectangular geometry" + ); + } + if snapshot.cursor().in_capture() { + let (col, row) = snapshot.cursor_local().expect("local cursor"); + assert!(col < snapshot.cols()); + assert!(row < snapshot.rows()); + } +} + +/// U02-A: live transitions across the Unicode corpus, styles, palette, +/// defaults, underline, reverse, cursor, links, and title stay coherent. +#[test] +fn u02_a_live_transitions_stay_coherent() { + let script = concat!( + "\\033[2J\\033[H", + "é ä 東 \\033[1mbold\\033[0m\\r\\n", + "\\033[3mitalic\\033[0m \\033[1;3mbi\\033[0m\\r\\n", + "\\033[31mR1\\033[38;5;2mR2\\033[38;2;9;9;9mR3\\033[0m\\r\\n", + "\\033[4:3mU\\033[58;5;200mV\\033[7mW\\033[0m\\r\\n", + " 東\\r\\n", + "\\033]4;7;#123456\\033\\\\", + "\\033]10;#111111\\033\\\\\\033]11;#222222\\033\\\\\\033]12;#333333\\033\\\\", + "\\033]8;id=live;https://example.com/live\\033\\\\LINK\\033]8;;\\033\\\\\\r\\n", + "\\033]0;live-title\\033\\\\", + "\\033[5;79H", + ); + let session = session_with_script("a", script, 80, 24); + poll_text(&session, "LINK"); + + let snapshot = session + .capture_snapshot(SnapshotScope::Viewport) + .expect("viewport capture succeeds"); + assert_rectangular(&snapshot); + assert_eq!((snapshot.cols(), snapshot.rows()), (80, 24)); + assert_eq!(snapshot.scope(), SnapshotScope::Viewport); + + // C04: combining marks, wide char with continuation, last-column wide char. + assert_eq!(snapshot.cell_at(0, 0).expect("cell").text(), "é"); + assert_eq!(snapshot.cell_at(2, 0).expect("cell").text(), "ä"); + assert_eq!(snapshot.cell_at(4, 0).expect("cell").text(), "東"); + assert_eq!(snapshot.cell_at(4, 0).expect("cell").width(), 2); + assert!(snapshot.cell_at(5, 0).expect("cell").is_continuation()); + let wide = snapshot.cell_at(78, 4).expect("last-column wide char"); + assert_eq!(wide.text(), "東"); + assert_eq!(wide.width(), 2); + assert!(snapshot.cell_at(79, 4).expect("cell").is_continuation()); + assert_eq!(snapshot.cursor_local(), Some((78, 4))); + + // Bold, italic, and bold-italic are recorded. + let cells = snapshot.cells(); + assert!(cells + .iter() + .any(|cell| cell.text() == "b" && cell.has(Attrs::BOLD) && !cell.has(Attrs::ITALIC))); + assert!(cells + .iter() + .any(|cell| cell.text() == "t" && cell.has(Attrs::ITALIC) && !cell.has(Attrs::BOLD))); + assert!(cells + .iter() + .any(|cell| cell.text() == "i" && cell.has(Attrs::BOLD | Attrs::ITALIC))); + + // SGR 0-15 normalization kept: 31 and 38;5;2 are distinct recorded slots. + assert!(cells + .iter() + .any(|cell| cell.text() == "R" && cell.fg() == SnapshotColor::Indexed(1))); + assert!(cells + .iter() + .any(|cell| cell.text() == "R" && cell.fg() == SnapshotColor::Indexed(2))); + assert!(cells + .iter() + .any(|cell| cell.text() == "R" && cell.fg() == SnapshotColor::Rgb(9, 9, 9))); + assert!(cells + .iter() + .any(|cell| cell.text() == "3" && cell.fg() == SnapshotColor::Rgb(9, 9, 9))); + + // Curly underline with explicit color, reverse video, hyperlink, title. + let underlined = cells + .iter() + .find(|cell| cell.text() == "U") + .expect("U cell"); + assert_eq!(underlined.underline(), SnapshotUnderline::Curly); + assert_eq!(underlined.underline_color(), SnapshotColor::Default); + let colored_underline = cells + .iter() + .find(|cell| cell.text() == "V") + .expect("V cell"); + assert_eq!(colored_underline.underline(), SnapshotUnderline::Curly); + assert_eq!( + colored_underline.underline_color(), + SnapshotColor::Indexed(200) + ); + assert!(cells + .iter() + .any(|cell| cell.text() == "W" && cell.has(Attrs::INVERSE))); + let linked = cells + .iter() + .find(|cell| cell.text() == "L") + .expect("link cell"); + assert_eq!(linked.link_uri(), Some("https://example.com/live")); + assert_eq!(linked.link_id(), Some("live")); + assert_eq!(snapshot.title(), Some("live-title")); + + // C05: palette and default overrides are recorded through Emulator::colors. + assert_eq!(snapshot.palette()[7], Rgb::new(0x12, 0x34, 0x56)); + assert_eq!(snapshot.default_foreground(), Rgb::new(0x11, 0x11, 0x11)); + assert_eq!(snapshot.default_background(), Rgb::new(0x22, 0x22, 0x22)); + assert_eq!(snapshot.default_cursor(), Rgb::new(0x33, 0x33, 0x33)); + assert!(snapshot.cursor().visible()); + // Blink intent is unknown at this backend: recorded as unknown, not false. + assert_eq!(snapshot.cursor().blink(), None); + assert_eq!(snapshot.metadata().source, SnapshotSource::Live); + + // C07: re-observing the same state compares equal (metadata is skipped). + let again = session + .capture_snapshot(SnapshotScope::Viewport) + .expect("recapture succeeds"); + assert_eq!(snapshot, again); + + // Scopes agree on the shared terminal state. + let active = session + .capture_snapshot(SnapshotScope::ActiveScreen) + .expect("active capture succeeds"); + assert_rectangular(&active); + assert_eq!(active.title(), Some("live-title")); + assert_eq!(active.palette()[7], Rgb::new(0x12, 0x34, 0x56)); + let full = session + .capture_snapshot(SnapshotScope::FullHistory) + .expect("history capture succeeds"); + assert_rectangular(&full); + assert_eq!(full.viewport_origin(), 0); + assert!(full.rows() >= active.rows()); + + close(&session); +} + +/// U02-B: snapshots stay usable after the session closes (fully owned data). +#[test] +fn u02_b_snapshot_outlives_session_close() { + let session = session_with_script("b", "owned-data\\r\\n", 80, 24); + poll_text(&session, "owned-data"); + let snapshot = session + .capture_snapshot(SnapshotScope::Viewport) + .expect("capture succeeds"); + let json_before = serde_json::to_string(&snapshot).expect("serializes"); + close(&session); + + assert!(snapshot.cells().iter().any(|cell| cell.text() == "o")); + assert_rectangular(&snapshot); + let json_after = serde_json::to_string(&snapshot).expect("serializes after close"); + assert_eq!(json_before, json_after); + let back: TerminalSnapshot = serde_json::from_str(&json_after).expect("deserializes"); + assert_eq!(snapshot, back); +} + +/// U02-C: locator highlighting never leaks into the default snapshot. +#[test] +fn u02_c_highlighting_needs_explicit_option() { + let session = session_with_script("c", "findable marker here\\r\\n", 80, 24); + poll_text(&session, "findable"); + session + .get_by_text("findable") + .highlight() + .expect("highlight applies"); + + let raw = session + .capture_snapshot(SnapshotScope::Viewport) + .expect("default capture succeeds"); + let marked = session + .capture_snapshot_with(&SnapshotOptions::new(SnapshotScope::Viewport).with_highlight(true)) + .expect("highlighted capture succeeds"); + let highlighted_cells: Vec<_> = marked + .cells() + .iter() + .filter(|cell| cell.has(Attrs::INVERSE)) + .collect(); + assert!(!highlighted_cells.is_empty(), "highlight selects cells"); + for (index, cell) in raw.cells().iter().enumerate() { + if cell.text() == "f" && marked.cells()[index].has(Attrs::INVERSE) { + assert!(!cell.has(Attrs::INVERSE), "default capture stays raw"); + } + } + assert_ne!(raw, marked); + close(&session); +} + +/// U02-D: alternate screen, history coordinates, and resize keep geometry. +#[test] +fn u02_d_scopes_keep_rectangular_geometry() { + let session = session_with_script("d-alt", "\\033[?1049hALTSCREEN\\r\\n", 80, 24); + poll_text(&session, "ALTSCREEN"); + let alt = session + .capture_snapshot(SnapshotScope::Viewport) + .expect("alt-screen capture succeeds"); + assert_rectangular(&alt); + assert_eq!((alt.cols(), alt.rows()), (80, 24)); + assert!(alt.cells().iter().any(|cell| cell.text() == "A")); + close(&session); + + let mut lines = String::from("\\033[2J\\033[H"); + for line in 0..40 { + lines.push_str(&format!("history line {line:02}\\r\\n")); + } + let session = session_with_script("d-hist", &lines, 80, 24); + poll_text(&session, "history line 39"); + + let viewport = session + .capture_snapshot(SnapshotScope::Viewport) + .expect("viewport capture succeeds"); + let full = session + .capture_snapshot(SnapshotScope::FullHistory) + .expect("history capture succeeds"); + assert_rectangular(&viewport); + assert_rectangular(&full); + assert!(full.rows() > viewport.rows(), "history extends the grid"); + assert_eq!(full.viewport_origin(), 0); + // History coordinates use u32, never u16 or a clamped stand-in. + assert!(full.rows() < 60); + assert!(u64::from(full.cursor().row()) < full.viewport_origin() + u64::from(full.rows()) + 100); + + // Scroll the viewport up: the cursor stays recorded, outside, unclamped. + session + .execute(Operation::Scroll { + target: ScrollTarget::Lines(5), + }) + .expect("scroll succeeds"); + let scrolled = session + .capture_snapshot(SnapshotScope::Viewport) + .expect("scrolled capture succeeds"); + assert_rectangular(&scrolled); + assert!(!scrolled.cursor().in_capture()); + assert_eq!(scrolled.cursor_local(), None); + assert!(u64::from(scrolled.cursor().row()) >= scrolled.viewport_origin()); + + // Resize keeps rectangular geometry at the new dimensions. + session + .execute(Operation::Resize { + cols: 100, + rows: 30, + }) + .expect("resize succeeds"); + let resized = session + .capture_snapshot(SnapshotScope::Viewport) + .expect("resized capture succeeds"); + assert_rectangular(&resized); + assert_eq!((resized.cols(), resized.rows()), (100, 30)); + close(&session); +} + +/// U02-E: serialization keeps palette changes and OSC 8 links. +#[test] +fn u02_e_serialization_keeps_palette_and_links() { + let session = session_with_script( + "e", + "\\033]4;5;#654321\\033\\\\\\033]8;id=e2e;https://example.com/kept\\033\\\\KEPT\\033]8;;\\033\\\\\\r\\n", + 80, + 24, + ); + poll_text(&session, "KEPT"); + let snapshot = session + .capture_snapshot(SnapshotScope::Viewport) + .expect("capture succeeds"); + assert_eq!(snapshot.palette()[5], Rgb::new(0x65, 0x43, 0x21)); + let linked = snapshot + .cells() + .iter() + .find(|cell| cell.text() == "K") + .expect("link cell"); + assert_eq!(linked.link_uri(), Some("https://example.com/kept")); + assert_eq!(linked.link_id(), Some("e2e")); + + let json = serde_json::to_string(&snapshot).expect("serializes"); + let back: TerminalSnapshot = serde_json::from_str(&json).expect("deserializes"); + assert_eq!(snapshot, back); + assert_eq!(back.palette()[5], Rgb::new(0x65, 0x43, 0x21)); + let kept = back + .cells() + .iter() + .find(|cell| cell.text() == "K") + .expect("link cell"); + assert_eq!(kept.link_uri(), Some("https://example.com/kept")); + assert_eq!(kept.link_id(), Some("e2e")); + close(&session); +} + +/// U02-F: constructor and JSON reject the same invalid geometry. +#[test] +fn u02_f_constructor_and_json_reject_same_geometry() { + fn round_trips(parts: &SnapshotParts) -> Result { + let snapshot = + TerminalSnapshot::try_new(parts.clone()).map_err(|error| error.to_string())?; + let json = serde_json::to_string(&snapshot).expect("serializes"); + serde_json::from_str(&json).map_err(|error| error.to_string()) + } + + // Ragged cell vector. + let mut parts = blank_parts(2, 2); + parts.cells.pop(); + assert_eq!( + TerminalSnapshot::try_new(parts.clone()), + Err(SnapshotError::CellCountMismatch { + expected: 4, + actual: 3 + }) + ); + assert!(round_trips(&parts).is_err()); + + // Continuation at column zero. + let mut parts = blank_parts(2, 1); + parts.cells[0] = SnapshotCell::new( + String::new(), + 0, + SnapshotColor::Default, + SnapshotColor::Default, + Attrs::empty(), + SnapshotUnderline::None, + SnapshotColor::Default, + None, + None, + ) + .expect("coordinate-free construction succeeds"); + assert_eq!( + TerminalSnapshot::try_new(parts.clone()), + Err(SnapshotError::BadContinuation { col: 0, row: 0 }) + ); + assert!(round_trips(&parts).is_err()); + + // In-capture cursor outside the grid. + let mut parts = blank_parts(2, 2); + parts.cursor = SnapshotCursor::new( + 5, + 5, + true, + true, + SnapshotCursorShape::Block, + Rgb::new(0, 0, 0), + None, + ); + assert_eq!( + TerminalSnapshot::try_new(parts.clone()), + Err(SnapshotError::CursorOutOfBounds { + col: 5, + row: 5, + cols: 2, + rows: 2 + }) + ); + assert!(round_trips(&parts).is_err()); +} + +/// U02-G: unsupported schemas and oversize input return errors. +#[test] +fn u02_g_schemas_and_limits_are_rejected() { + let valid = TerminalSnapshot::try_new(blank_parts(2, 2)).expect("valid"); + let mut wire = serde_json::to_value(&valid).expect("serializes"); + wire["schema"] = serde_json::json!(2); + let error = serde_json::from_value::(wire).expect_err("schema 2 fails"); + assert!( + error.to_string().contains("unsupported snapshot schema"), + "{error}" + ); + + let mut wire = serde_json::to_value(&valid).expect("serializes"); + wire["cols"] = serde_json::json!(MAX_SNAPSHOT_DIM + 1); + assert!(serde_json::from_value::(wire).is_err()); + + // 32768 x 32768 exceeds the cell budget without exceeding a dimension. + let mut too_many = blank_parts(2, 2); + too_many.cols = MAX_SNAPSHOT_DIM; + too_many.rows = MAX_SNAPSHOT_DIM; + assert_eq!( + TerminalSnapshot::try_new(too_many), + Err(SnapshotError::TooManyCells { + cells: u64::from(MAX_SNAPSHOT_DIM) * u64::from(MAX_SNAPSHOT_DIM), + limit: MAX_SNAPSHOT_CELLS + }) + ); + + assert!(SnapshotCell::new( + "x".repeat(MAX_CELL_TEXT_BYTES + 1), + 1, + SnapshotColor::Default, + SnapshotColor::Default, + Attrs::empty(), + SnapshotUnderline::None, + SnapshotColor::Default, + None, + None, + ) + .is_err()); +} + +/// U02-H: capture and serialization keep palette changes at indices 1 and 200. +#[test] +fn u02_h_palette_slots_1_and_200_survive() { + let session = session_with_script( + "h", + "\\033]4;1;#0a0a0a\\033\\\\\\033]4;200;#c8c8c8\\033\\\\READY\\r\\n", + 80, + 24, + ); + poll_text(&session, "READY"); + let snapshot = session + .capture_snapshot(SnapshotScope::Viewport) + .expect("capture succeeds"); + assert_eq!(snapshot.palette()[1], Rgb::new(0x0a, 0x0a, 0x0a)); + assert_eq!(snapshot.palette()[200], Rgb::new(0xc8, 0xc8, 0xc8)); + let json = serde_json::to_string(&snapshot).expect("serializes"); + let back: TerminalSnapshot = serde_json::from_str(&json).expect("deserializes"); + assert_eq!(back.palette()[1], Rgb::new(0x0a, 0x0a, 0x0a)); + assert_eq!(back.palette()[200], Rgb::new(0xc8, 0xc8, 0xc8)); + close(&session); + + let mut parts = blank_parts(1, 1); + parts.palette[1] = Rgb::new(1, 1, 1); + parts.palette[200] = Rgb::new(2, 2, 2); + let offline = TerminalSnapshot::try_new(parts).expect("valid"); + let json = serde_json::to_string(&offline).expect("serializes"); + let back: TerminalSnapshot = serde_json::from_str(&json).expect("deserializes"); + assert_eq!(offline, back); +} + +/// U02-I: capture and serialization keep default fg/bg/cursor changes. +#[test] +fn u02_i_default_colors_survive() { + let session = session_with_script( + "i", + "\\033]10;#0b0b0b\\033\\\\\\033]11;#1c1c1c\\033\\\\\\033]12;#2d2d2d\\033\\\\READY\\r\\n", + 80, + 24, + ); + poll_text(&session, "READY"); + let snapshot = session + .capture_snapshot(SnapshotScope::Viewport) + .expect("capture succeeds"); + assert_eq!(snapshot.default_foreground(), Rgb::new(0x0b, 0x0b, 0x0b)); + assert_eq!(snapshot.default_background(), Rgb::new(0x1c, 0x1c, 0x1c)); + assert_eq!(snapshot.default_cursor(), Rgb::new(0x2d, 0x2d, 0x2d)); + let json = serde_json::to_string(&snapshot).expect("serializes"); + let back: TerminalSnapshot = serde_json::from_str(&json).expect("deserializes"); + assert_eq!(snapshot, back); + close(&session); +} + +/// U02-J: equal resolved colors do not erase different color-reference kinds. +#[test] +fn u02_j_default_and_rgb_kinds_stay_distinct() { + let palette = resolved_palette(); + let mut defaulted = blank_parts(1, 1); + defaulted.default_foreground = palette[3]; + defaulted.cells[0] = SnapshotCell::new( + "x".to_string(), + 1, + SnapshotColor::Default, + SnapshotColor::Default, + Attrs::empty(), + SnapshotUnderline::None, + SnapshotColor::Default, + None, + None, + ) + .expect("cell is valid"); + let mut explicit = defaulted.clone(); + explicit.cells[0] = SnapshotCell::new( + "x".to_string(), + 1, + SnapshotColor::Rgb(palette[3].r, palette[3].g, palette[3].b), + SnapshotColor::Default, + Attrs::empty(), + SnapshotUnderline::None, + SnapshotColor::Default, + None, + None, + ) + .expect("cell is valid"); + let a = TerminalSnapshot::try_new(defaulted).expect("valid"); + let b = TerminalSnapshot::try_new(explicit).expect("valid"); + assert_ne!(a, b); + for snapshot in [&a, &b] { + let json = serde_json::to_string(snapshot).expect("serializes"); + let back: TerminalSnapshot = serde_json::from_str(&json).expect("deserializes"); + assert_eq!(*snapshot, back); + } +} + +/// U02-K: an unreferenced palette entry still changes canonical state. +#[test] +fn u02_k_unreferenced_palette_change_counts() { + let left = TerminalSnapshot::try_new(blank_parts(2, 2)).expect("valid"); + let mut parts = blank_parts(2, 2); + parts.palette[77] = Rgb::new(9, 9, 9); + let right = TerminalSnapshot::try_new(parts).expect("valid"); + assert_ne!(left, right); +} From 364719ac544b9b6d80fa912d6a71f007b27e4a8b Mon Sep 17 00:00:00 2001 From: Alexey Zhokhov Date: Sun, 25 Oct 2026 17:34:15 +0700 Subject: [PATCH 04/16] Fix bool assert lint and document try_new with an example Signed-off-by: Alexey Zhokhov --- crates/tui-test/src/snapshot.rs | 51 +++++++++++++++++++++++++++++---- 1 file changed, 45 insertions(+), 6 deletions(-) diff --git a/crates/tui-test/src/snapshot.rs b/crates/tui-test/src/snapshot.rs index a0128c32..6b18ae27 100644 --- a/crates/tui-test/src/snapshot.rs +++ b/crates/tui-test/src/snapshot.rs @@ -800,6 +800,47 @@ impl TerminalSnapshot { /// continuations, out-of-bounds in-capture cursors, misplaced origins, /// and oversize titles and links, using checked arithmetic throughout. /// Cell geometry is recorded, never recomputed from UTF-8 byte length. + /// + /// ``` + /// use tui_test::profile::Rgb; + /// use tui_test::terminal::cell::Attrs; + /// use tui_test::{ + /// SnapshotCell, SnapshotColor, SnapshotCursor, SnapshotCursorShape, + /// SnapshotMetadata, SnapshotParts, SnapshotScope, TerminalSnapshot, + /// }; + /// + /// let palette = [Rgb::new(0, 0, 0); 256]; + /// let cell = SnapshotCell::new( + /// "x".to_string(), + /// 1, + /// SnapshotColor::Default, + /// SnapshotColor::Indexed(1), + /// Attrs::BOLD, + /// tui_test::SnapshotUnderline::None, + /// SnapshotColor::Default, + /// None, + /// None, + /// )?; + /// let snapshot = TerminalSnapshot::try_new(SnapshotParts { + /// cols: 1, + /// rows: 1, + /// scope: SnapshotScope::Viewport, + /// viewport_origin: 0, + /// cells: vec![cell], + /// palette, + /// default_foreground: Rgb::new(255, 255, 255), + /// default_background: Rgb::new(0, 0, 0), + /// default_cursor: Rgb::new(255, 255, 255), + /// cursor: SnapshotCursor::new( + /// 0, 0, true, true, SnapshotCursorShape::Block, + /// Rgb::new(255, 255, 255), None, + /// ), + /// title: None, + /// metadata: SnapshotMetadata::default(), + /// })?; + /// assert_eq!(snapshot.cell_at(0, 0).unwrap().text(), "x"); + /// # Ok::<(), tui_test::SnapshotError>(()) + /// ``` pub fn try_new(parts: SnapshotParts) -> Result { assemble(parts) } @@ -1800,12 +1841,10 @@ mod tests { let mut wire = serde_json::to_value(&valid).expect("serializes"); wire["schema"] = serde_json::json!(999); - assert_eq!( - serde_json::from_value::(wire) - .expect_err("unsupported schema fails") - .to_string() - .contains("unsupported snapshot schema"), - true + let error = serde_json::from_value::(wire).expect_err("schema 999 fails"); + assert!( + error.to_string().contains("unsupported snapshot schema"), + "unexpected error: {error}" ); } From 20451715daac55f9a425a77f0e11e931964dc366 Mon Sep 17 00:00:00 2001 From: Alexey Zhokhov Date: Sun, 25 Oct 2026 17:49:21 +0700 Subject: [PATCH 05/16] U10: add quiet waits with per-request cancellation Add WaitQuietOptions (private fields, validated constructors) with visual-quiet and byte-quiet modes, Session::wait_quiet plus Session::wait_quiet_cancellable mirroring Engine::execute_with_context_cancellable, and a monotonic PTY output timestamp in private session state for byte-quiet timing. Signed-off-by: Alexey Zhokhov --- crates/tui-test/src/engine.rs | 8 + crates/tui-test/src/lib.rs | 4 + crates/tui-test/src/runtime.rs | 45 ++++ crates/tui-test/src/session.rs | 16 ++ crates/tui-test/src/wait.rs | 415 +++++++++++++++++++++++++++++++++ 5 files changed, 488 insertions(+) create mode 100644 crates/tui-test/src/wait.rs diff --git a/crates/tui-test/src/engine.rs b/crates/tui-test/src/engine.rs index 673db059..f4f33a87 100644 --- a/crates/tui-test/src/engine.rs +++ b/crates/tui-test/src/engine.rs @@ -1815,6 +1815,14 @@ impl Engine { .unwrap_or_else(std::sync::PoisonError::into_inner) } + /// Monotonic time of the most recent nonempty successful PTY output read, + /// if any. Served to byte-quiet waits; `None` when no session is open or + /// no output has arrived yet. + pub(crate) fn last_output_time(&self) -> Option { + let guard = self.lock_session(); + guard.as_ref().and_then(|session| session.last_output_time()) + } + fn write_session(&self) -> RwLockWriteGuard<'_, Option> { self.session .write() diff --git a/crates/tui-test/src/lib.rs b/crates/tui-test/src/lib.rs index 35cb3bbb..e5772d2e 100644 --- a/crates/tui-test/src/lib.rs +++ b/crates/tui-test/src/lib.rs @@ -14,6 +14,7 @@ pub mod shell; pub mod snapshot; pub mod terminal; pub mod trace; +pub mod wait; mod event; mod session; @@ -32,3 +33,6 @@ pub use snapshot::{ MAX_SNAPSHOT_DIM, MAX_TITLE_BYTES, SNAPSHOT_SCHEMA_VERSION, }; pub use terminal::backend::Backend; +pub use wait::{ + WaitQuietMode, WaitQuietOptions, DEFAULT_QUIET_INTERVAL, DEFAULT_QUIET_TIMEOUT, +}; diff --git a/crates/tui-test/src/runtime.rs b/crates/tui-test/src/runtime.rs index 97c6e903..2f0c9f8b 100644 --- a/crates/tui-test/src/runtime.rs +++ b/crates/tui-test/src/runtime.rs @@ -14,6 +14,7 @@ use crate::diagnostics::ExecutionContext; use crate::engine::Engine; use crate::logger::Logger; use crate::snapshot::{SnapshotOptions, SnapshotScope, TerminalSnapshot}; +use crate::wait::WaitQuietOptions; const MAX_COMPLETED_RECORDINGS: usize = 1024; @@ -468,6 +469,50 @@ impl Session { self.engine.capture_snapshot_with(options) } + /// Wait until the terminal stays quiet, then return success. + /// + /// Visual-quiet mode samples the raw active screen at request start and + /// every 50 milliseconds, and restarts its timer when two successive + /// canonical samples differ. Byte-quiet mode restarts its timer on every + /// nonempty successful PTY output read and never samples the screen. + /// Equal-screen output restarts a byte-quiet timer but never a + /// visual-quiet timer. The quiet interval starts with this request; other + /// callers, viewport movement, and the shared idle clock cannot reset it. + /// + /// Sampling limit: only sampled states are compared. A terminal change + /// that appears and disappears between two consecutive samples is not + /// observed. This wait is not an application frame boundary. + /// + /// Cancellation uses the engine mechanism from + /// [`Engine::execute_with_context_cancellable`]: pass a flag through + /// [`Session::wait_quiet_cancellable`]. A cancelled wait reports + /// cancellation and leaves the child and the session usable. + pub fn wait_quiet(&self, options: &WaitQuietOptions) -> Result<(), TuiTestError> { + self.wait_quiet_cancellable(options, &|| false) + } + + /// Cancellable quiet wait through the session's engine. + /// + /// `cancelled` mirrors + /// [`Engine::execute_with_context_cancellable`]: it is checked before any + /// success return and between polls, never terminates the child, and + /// never cancels another caller. This is per-request cancellation, not + /// [`Session::interrupt`], and it adds no second cancellation framework. + /// The wait holds no operation resources across polls, so every outcome + /// leaves the session ready for subsequent operations. + pub fn wait_quiet_cancellable( + &self, + options: &WaitQuietOptions, + cancelled: &dyn Fn() -> bool, + ) -> Result<(), TuiTestError> { + crate::wait::run_quiet_wait( + options, + cancelled, + &|| self.engine.capture_snapshot(SnapshotScope::ActiveScreen), + &|| self.engine.last_output_time(), + ) + } + /// Cancel pending operations, including startup readiness, and terminate the /// child without waiting for the operation queue or PTY input writer. pub fn interrupt(&self) { diff --git a/crates/tui-test/src/session.rs b/crates/tui-test/src/session.rs index 3f170386..3eceba4a 100644 --- a/crates/tui-test/src/session.rs +++ b/crates/tui-test/src/session.rs @@ -61,6 +61,9 @@ pub struct TermState { pub diagnostic_error: Option, /// Time of the last observed visual change, not the last raw PTY output. pub last_change: Instant, + /// Monotonic time of the most recent nonempty successful PTY output read. + /// Recorded for byte-quiet waits; visual-quiet waits never consult it. + pub(crate) last_output: Option, pub awaiting_start: Option, pub exited: Option, pub exit_signal: Option, @@ -159,6 +162,7 @@ impl Session { last_visual_change_ms: 0, diagnostic_error: None, last_change: Instant::now(), + last_output: None, awaiting_start: None, exited: None, exit_signal: None, @@ -244,6 +248,7 @@ impl Session { let mut st = reader_state .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); + st.last_output = Some(Instant::now()); st.emu.process(&buf[..n]); st.tracker.feed(&buf[..n]); st.visual_revision = st.visual_revision.wrapping_add(1); @@ -652,6 +657,16 @@ impl Session { pub fn automatic_recording_enabled(&self) -> bool { self.recorder.automatic_enabled() } + + /// Monotonic time of the most recent nonempty successful PTY output read, + /// if any. Served to byte-quiet waits; never derived from + /// [`TermState::last_change`]. + pub(crate) fn last_output_time(&self) -> Option { + self.state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .last_output + } } fn resize_emulator_and_record(state: &mut TermState, recorder: &Recorder, cols: u16, rows: u16) { @@ -917,6 +932,7 @@ mod tests { last_visual_change_ms: 0, diagnostic_error: None, last_change: Instant::now(), + last_output: None, awaiting_start: None, exited: None, exit_signal: None, diff --git a/crates/tui-test/src/wait.rs b/crates/tui-test/src/wait.rs new file mode 100644 index 00000000..df1fea75 --- /dev/null +++ b/crates/tui-test/src/wait.rs @@ -0,0 +1,415 @@ +//! Request-scoped quiet waits with per-request cancellation. +//! +//! [`WaitQuietOptions`] selects an overall timeout and a quiet interval for +//! [`Session::wait_quiet`](crate::Session::wait_quiet). Visual-quiet mode +//! samples the raw active screen every 50 milliseconds and restarts its timer +//! when two successive canonical samples differ. Byte-quiet mode restarts its +//! timer on every nonempty successful PTY output read and never samples the +//! screen. +//! +//! The quiet interval starts with the request: each request keeps its previous +//! sample and change time locally, so other callers, viewport movement, and +//! the shared `TermState.last_change` value cannot reset this request's +//! timer. Clocks are monotonic throughout. +//! +//! Sampling limit: visual-quiet mode only sees sampled states. A terminal +//! change that appears and disappears between two consecutive samples is not +//! observed and does not restart the timer. This wait is not an application +//! frame boundary and is not a DEC 2026 boundary. + +use std::time::{Duration, Instant}; + +use crate::api::TuiTestError; +use crate::snapshot::TerminalSnapshot; + +/// Default quiet interval for [`WaitQuietOptions`]: 250 milliseconds. +pub const DEFAULT_QUIET_INTERVAL: Duration = Duration::from_millis(250); +/// Default overall timeout for [`WaitQuietOptions`]: 5 seconds. +pub const DEFAULT_QUIET_TIMEOUT: Duration = Duration::from_secs(5); + +/// Interval between visual samples during a quiet wait: 50 milliseconds. +pub(crate) const SAMPLE_INTERVAL: Duration = Duration::from_millis(50); + +/// Which change class restarts a quiet wait's timer. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum WaitQuietMode { + /// Restart when two successive raw active-screen samples differ. + #[default] + Visual, + /// Restart on every nonempty successful PTY output read. + Bytes, +} + +/// Timeout and quiet-interval selection for a quiet wait. +/// +/// Fields are private; validation runs at every construction boundary. The +/// quiet interval must be nonzero, the overall timeout must be nonzero, and +/// the interval must not exceed the timeout. An interval equal to the timeout +/// is accepted but always reports timeout: the deadline and the success +/// instant coincide, and the deadline wins. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct WaitQuietOptions { + timeout: Duration, + quiet: Duration, + mode: WaitQuietMode, +} + +impl WaitQuietOptions { + /// Build options from an overall timeout and a quiet interval. + pub fn new(timeout: Duration, quiet: Duration) -> Result { + Self { + timeout, + quiet, + mode: WaitQuietMode::Visual, + } + .with_timeout(timeout) + .and_then(|options| options.with_quiet(quiet)) + } + + /// Overall timeout: the request fails with timeout past this duration. + pub fn timeout(self) -> Duration { + self.timeout + } + + /// Quiet interval: the request succeeds after this duration with no + /// related change. + pub fn quiet(self) -> Duration { + self.quiet + } + + /// Which change class restarts the timer. + pub fn mode(self) -> WaitQuietMode { + self.mode + } + + /// Set the overall timeout, rejecting a zero value and an interval that + /// would exceed it. + pub fn with_timeout(self, timeout: Duration) -> Result { + if timeout.is_zero() { + return Err(TuiTestError::usage( + "wait quiet: overall timeout must be nonzero", + )); + } + if self.quiet > timeout { + return Err(TuiTestError::usage( + "wait quiet: quiet interval must not exceed the overall timeout", + )); + } + Ok(Self { timeout, ..self }) + } + + /// Set the quiet interval, rejecting a zero value and an interval longer + /// than the overall timeout. + pub fn with_quiet(self, quiet: Duration) -> Result { + if quiet.is_zero() { + return Err(TuiTestError::usage( + "wait quiet: quiet interval must be nonzero", + )); + } + if quiet > self.timeout { + return Err(TuiTestError::usage( + "wait quiet: quiet interval must not exceed the overall timeout", + )); + } + Ok(Self { quiet, ..self }) + } + + /// Set which change class restarts the timer. + pub fn with_mode(self, mode: WaitQuietMode) -> Self { + Self { mode, ..self } + } +} + +impl Default for WaitQuietOptions { + fn default() -> Self { + Self { + timeout: DEFAULT_QUIET_TIMEOUT, + quiet: DEFAULT_QUIET_INTERVAL, + mode: WaitQuietMode::Visual, + } + } +} + +/// Canonical visual equality for quiet waits: dimensions, cells, palette, +/// default colors, cursor state, and title. +/// +/// Scope, viewport origin, observation metadata, and kept history are +/// excluded on purpose: viewport movement, re-observation, and scrollback +/// growth must not restart a visual-quiet timer. Locator highlighting is +/// excluded because the wait samples raw active-screen data. +pub(crate) fn canonical_visual_eq(first: &TerminalSnapshot, second: &TerminalSnapshot) -> bool { + first.cols() == second.cols() + && first.rows() == second.rows() + && first.cells() == second.cells() + && first.palette() == second.palette() + && first.default_foreground() == second.default_foreground() + && first.default_background() == second.default_background() + && first.default_cursor() == second.default_cursor() + && first.cursor() == second.cursor() + && first.title() == second.title() +} + +/// Cancellation result shared with the engine's cancellable waits. +pub(crate) fn cancelled_error() -> TuiTestError { + TuiTestError::assertion("operation was cancelled") +} + +fn timeout_error(options: &WaitQuietOptions) -> TuiTestError { + TuiTestError::assertion(format!( + "wait quiet: timed out after {}ms without {}ms of {} quiet", + options.timeout.as_millis(), + options.quiet.as_millis(), + match options.mode { + WaitQuietMode::Visual => "visual", + WaitQuietMode::Bytes => "output", + } + )) +} + +/// Run a quiet wait against caller-supplied observation closures. +/// +/// `capture_visual` must capture a raw active-screen snapshot; it is called +/// at request start and then at most every 50 milliseconds in visual mode, +/// and never in byte mode. `last_output` must report the monotonic time of +/// the most recent nonempty successful PTY output read; it is consulted in +/// byte mode only. `cancelled` mirrors +/// [`Engine::execute_with_context_cancellable`](crate::Engine::execute_with_context_cancellable). +/// +/// The loop sleeps end at the first of the next poll, the quiet deadline, or +/// the overall deadline. When the quiet and overall deadlines coincide, the +/// overall deadline wins and the request reports timeout. +pub(crate) fn run_quiet_wait( + options: &WaitQuietOptions, + cancelled: &dyn Fn() -> bool, + capture_visual: &dyn Fn() -> Result, + last_output: &dyn Fn() -> Option, +) -> Result<(), TuiTestError> { + let start = Instant::now(); + let overall_deadline = start.checked_add(options.timeout).ok_or_else(|| { + TuiTestError::internal("wait quiet: overall deadline is out of range") + })?; + if cancelled() { + return Err(cancelled_error()); + } + let mut previous = if options.mode == WaitQuietMode::Visual { + Some(capture_visual()?) + } else { + None + }; + let mut last_change = start; + loop { + if cancelled() { + return Err(cancelled_error()); + } + let now = Instant::now(); + if now >= overall_deadline { + return Err(timeout_error(options)); + } + match options.mode { + WaitQuietMode::Visual => { + let sample = capture_visual()?; + let changed = previous + .as_ref() + .is_none_or(|before| !canonical_visual_eq(before, &sample)); + previous = Some(sample); + if changed { + last_change = Instant::now(); + } + } + WaitQuietMode::Bytes => { + if let Some(read_at) = last_output() { + if read_at > last_change { + last_change = read_at; + } + } + } + } + let quiet_deadline = last_change.checked_add(options.quiet).ok_or_else(|| { + TuiTestError::internal("wait quiet: quiet deadline is out of range") + })?; + if Instant::now() >= quiet_deadline { + // The visual sample above is the required last sample: it was + // captured before this success check, so a change observed here + // restarts the timer instead of reporting success. + if cancelled() { + return Err(cancelled_error()); + } + return Ok(()); + } + let sleep = [SAMPLE_INTERVAL] + .into_iter() + .chain(quiet_deadline.checked_duration_since(Instant::now())) + .chain(overall_deadline.checked_duration_since(Instant::now())) + .min() + .unwrap_or(SAMPLE_INTERVAL); + if !sleep.is_zero() { + std::thread::sleep(sleep); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::profile::Rgb; + use crate::snapshot::{ + SnapshotCell, SnapshotColor, SnapshotCursor, SnapshotCursorShape, SnapshotMetadata, + SnapshotParts, SnapshotScope, SnapshotUnderline, + }; + use crate::terminal::cell::Attrs; + + fn test_parts() -> SnapshotParts { + let palette = [Rgb::new(1, 2, 3); 256]; + let cell = SnapshotCell::new( + "x".to_string(), + 1, + SnapshotColor::Default, + SnapshotColor::Default, + Attrs::empty(), + SnapshotUnderline::None, + SnapshotColor::Default, + None, + None, + ) + .expect("test cell is valid"); + SnapshotParts { + cols: 2, + rows: 1, + scope: SnapshotScope::Viewport, + viewport_origin: 0, + cells: vec![ + cell.clone(), + SnapshotCell::new( + "y".to_string(), + 1, + SnapshotColor::Default, + SnapshotColor::Default, + Attrs::empty(), + SnapshotUnderline::None, + SnapshotColor::Default, + None, + None, + ) + .expect("test cell is valid"), + ], + palette, + default_foreground: Rgb::new(10, 10, 10), + default_background: Rgb::new(20, 20, 20), + default_cursor: Rgb::new(30, 30, 30), + cursor: SnapshotCursor::new( + 0, + 0, + false, + true, + SnapshotCursorShape::Block, + Rgb::new(30, 30, 30), + None, + ), + title: Some("title".to_string()), + metadata: SnapshotMetadata::default(), + } + } + + fn snapshot() -> TerminalSnapshot { + TerminalSnapshot::try_new(test_parts()).expect("test snapshot is valid") + } + + #[test] + fn canonical_visual_eq_ignores_scope_origin_and_metadata() { + let first = snapshot(); + let mut parts = test_parts(); + parts.scope = SnapshotScope::ActiveScreen; + parts.viewport_origin = 42; + parts.metadata.captured_at_ms = 999; + parts.metadata.revision = 42; + parts.metadata.backend = "other".to_string(); + let second = TerminalSnapshot::try_new(parts).expect("test snapshot is valid"); + assert!(canonical_visual_eq(&first, &second)); + } + + #[test] + fn canonical_visual_eq_detects_palette_only_change() { + let first = snapshot(); + let mut parts = test_parts(); + parts.palette[200] = Rgb::new(9, 9, 9); + let second = TerminalSnapshot::try_new(parts).expect("test snapshot is valid"); + assert!(!canonical_visual_eq(&first, &second)); + } + + #[test] + fn canonical_visual_eq_detects_title_only_change() { + let first = snapshot(); + let mut parts = test_parts(); + parts.title = Some("other".to_string()); + let second = TerminalSnapshot::try_new(parts).expect("test snapshot is valid"); + assert!(!canonical_visual_eq(&first, &second)); + } + + #[test] + fn canonical_visual_eq_detects_cursor_only_change() { + let first = snapshot(); + let mut parts = test_parts(); + parts.cursor = SnapshotCursor::new( + 1, + 0, + false, + true, + SnapshotCursorShape::Block, + Rgb::new(30, 30, 30), + None, + ); + let second = TerminalSnapshot::try_new(parts).expect("test snapshot is valid"); + assert!(!canonical_visual_eq(&first, &second)); + } + + #[test] + fn canonical_visual_eq_detects_dimension_only_change() { + let first = snapshot(); + let mut parts = test_parts(); + parts.cols = 3; + let extra = parts.cells[0].clone(); + parts.cells.push(extra); + let second = TerminalSnapshot::try_new(parts).expect("test snapshot is valid"); + assert!(!canonical_visual_eq(&first, &second)); + } + + #[test] + fn options_reject_zero_and_overlong_intervals() { + assert!(WaitQuietOptions::new(Duration::ZERO, Duration::from_millis(1)).is_err()); + assert!(WaitQuietOptions::new(Duration::from_secs(1), Duration::ZERO).is_err()); + assert!(WaitQuietOptions::new( + Duration::from_millis(100), + Duration::from_millis(200) + ) + .is_err()); + assert!(WaitQuietOptions::new( + Duration::from_millis(200), + Duration::from_millis(200) + ) + .is_ok()); + assert_eq!( + WaitQuietOptions::default().quiet(), + DEFAULT_QUIET_INTERVAL + ); + assert_eq!( + WaitQuietOptions::default().timeout(), + DEFAULT_QUIET_TIMEOUT + ); + } + + #[test] + fn unseen_transient_between_samples_does_not_restart_visual_timer() { + // Sampling-limit demonstration with controlled samples: a terminal + // change appears and disappears between two consecutive polls, so the + // sampler only ever observes the stable screen. The wait succeeds + // after one quiet interval even though the terminal changed. + let stable = snapshot(); + let capture = || Ok(stable.clone()); + let options = WaitQuietOptions::new(Duration::from_secs(5), Duration::from_millis(100)) + .expect("test options are valid"); + let started = Instant::now(); + run_quiet_wait(&options, &|| false, &capture, &|| None) + .expect("unseen transient must not delay success"); + assert!(started.elapsed() < Duration::from_secs(2)); + } +} From dd1ada0a8279260628c30dbb261514f17517f348 Mon Sep 17 00:00:00 2001 From: Alexey Zhokhov Date: Sun, 25 Oct 2026 17:52:06 +0700 Subject: [PATCH 06/16] U10: cover quiet waits and cancellation with U10-A through U10-J tests Add tests/request_waits.rs with live timing tests for visual and byte quiet modes, timeout and cancellation bounds, session reuse after each outcome, timer isolation, the sampling limit, single-aspect visual resets, request-start timing, and equal-deadline timeout. Signed-off-by: Alexey Zhokhov --- crates/tui-test/tests/request_waits.rs | 699 +++++++++++++++++++++++++ 1 file changed, 699 insertions(+) create mode 100644 crates/tui-test/tests/request_waits.rs diff --git a/crates/tui-test/tests/request_waits.rs b/crates/tui-test/tests/request_waits.rs new file mode 100644 index 00000000..9bccc0eb --- /dev/null +++ b/crates/tui-test/tests/request_waits.rs @@ -0,0 +1,699 @@ +//! U10 request waits: quiet intervals and per-request cancellation. +//! +//! Conventions: unique session names per test (parallel-safe), bounded +//! condition waits everywhere, timing assertions with generous upper bounds +//! and structural lower bounds. No process-wide environment changes. + +use std::sync::{ + Arc, + atomic::{AtomicBool, Ordering}, +}; +use std::time::{Duration, Instant}; + +use tui_test::{ + ErrorKind, LocatorQuery, OpenOptions, Operation, RunOptions, ScrollTarget, Session, + SnapshotScope, WaitQuietMode, WaitQuietOptions, +}; + +const QUIET: Duration = Duration::from_millis(300); +const SETTLE_TIMEOUT_MS: u64 = 15_000; + +fn session_name(tag: &str) -> String { + format!("u10-{tag}-{}", std::process::id()) +} + +fn run_options(program: &str, args: &[&str]) -> RunOptions { + let defaults = OpenOptions::default(); + RunOptions { + backend: defaults.backend, + program: program.to_string(), + args: args.iter().map(|arg| (*arg).to_string()).collect(), + profile: defaults.profile, + style: defaults.style, + cols: defaults.cols, + rows: defaults.rows, + cwd: defaults.cwd, + env: defaults.env, + wait_ready: Some(false), + restart: defaults.restart, + timeouts: defaults.timeouts, + recording: defaults.recording, + } +} + +fn visual_options(quiet: Duration) -> WaitQuietOptions { + WaitQuietOptions::new(Duration::from_secs(30), quiet).expect("test options are valid") +} + +fn wait_for_text(session: &Session, text: &str) { + session + .execute(Operation::WaitLocator { + query: LocatorQuery::text(text), + not: false, + timeout_ms: Some(SETTLE_TIMEOUT_MS), + }) + .expect("settle marker appears"); +} + +/// Start a scripted child and wait for its `READY` marker. +fn ready_session(tag: &str, script: &str) -> Session { + let session = Session::new(session_name(tag)); + if cfg!(windows) { + session + .run(run_options("powershell.exe", &["-NoProfile", "-Command", script])) + .expect("run child"); + } else { + session + .run(run_options("sh", &["-c", script])) + .expect("run child"); + } + wait_for_text(&session, "READY"); + session +} + +fn is_timeout(error: &tui_test::TuiTestError) -> bool { + error.kind == ErrorKind::Assertion && error.to_string().contains("timed out") +} + +fn is_cancelled(error: &tui_test::TuiTestError) -> bool { + error.kind == ErrorKind::Assertion && error.to_string().contains("cancelled") +} + +fn cancel_flag() -> (Arc, impl Fn() -> bool + Send + Sync + 'static) { + let flag = Arc::new(AtomicBool::new(false)); + let check = flag.clone(); + (flag, move || check.load(Ordering::Acquire)) +} + +// U10-A: continuous output, equal-screen output, and a late visual change. +#[test] +fn u10_a_continuous_visible_output_times_out_visual_wait() { + let script = if cfg!(windows) { + "[Console]::Write('READY'); $i=0; while ($true) { [Console]::WriteLine(\"tick-$i\"); $i++; Start-Sleep -Milliseconds 20 }" + } else { + "printf READY; i=0; while true; do echo tick-$i; i=$((i+1)); sleep 0.02; done" + }; + let session = ready_session("a-busy", script); + let options = + WaitQuietOptions::new(Duration::from_millis(800), QUIET).expect("test options are valid"); + let started = Instant::now(); + let error = session + .wait_quiet(&options) + .expect_err("continuous visible output must time out"); + let elapsed = started.elapsed(); + assert!(is_timeout(&error), "unexpected error: {error}"); + assert!( + elapsed >= Duration::from_millis(700), + "timed out too early: {elapsed:?}" + ); + assert!( + elapsed < Duration::from_secs(6), + "timeout took too long: {elapsed:?}" + ); + session.close().expect("close terminal"); +} + +#[test] +fn u10_a_equal_screen_output_restarts_bytes_not_visual() { + // A stream of screen-neutral output: every read restarts a byte-quiet + // timer, while visual samples stay equal throughout. + let script = if cfg!(windows) { + "[Console]::Write('READY'); Start-Sleep -Milliseconds 300; for ($i=0; $i -lt 50; $i++) { [Console]::Write(\"`e[0m\"); Start-Sleep -Milliseconds 20 }; Start-Sleep -Seconds 30" + } else { + "printf READY; sleep 0.3; for i in $(seq 1 50); do printf '\\033[0m'; sleep 0.02; done; sleep 30" + }; + let session = ready_session("a-neutral", script); + let visual = visual_options(QUIET); + let started = Instant::now(); + session + .wait_quiet(&visual) + .expect("equal-screen output must not delay visual quiet"); + let visual_elapsed = started.elapsed(); + assert!( + visual_elapsed < Duration::from_millis(900), + "visual wait outlived the neutral stream: {visual_elapsed:?}" + ); + + let bytes = WaitQuietOptions::new(Duration::from_secs(15), QUIET) + .expect("test options are valid") + .with_mode(WaitQuietMode::Bytes); + let session = ready_session("a-neutral-bytes", script); + let started = Instant::now(); + session + .wait_quiet(&bytes) + .expect("byte quiet follows the neutral stream"); + let bytes_elapsed = started.elapsed(); + assert!( + bytes_elapsed >= Duration::from_secs(1), + "byte wait ignored equal-screen output: {bytes_elapsed:?}" + ); + session.close().expect("close terminal"); +} + +#[test] +fn u10_a_late_visual_change_restarts_then_succeeds() { + // The change lands mid-wait: the quiet interval exceeds the change delay, + // so success must follow the change by at least one quiet interval. + let quiet = Duration::from_millis(1000); + let script = if cfg!(windows) { + "[Console]::Write('READY'); Start-Sleep -Milliseconds 600; [Console]::WriteLine('LATE-VISIBLE'); Start-Sleep -Seconds 30" + } else { + "printf READY; sleep 0.6; echo LATE-VISIBLE; sleep 30" + }; + let session = ready_session("a-late", script); + let started = Instant::now(); + session + .wait_quiet(&visual_options(quiet)) + .expect("late change restarts, then succeeds"); + let elapsed = started.elapsed(); + assert!( + elapsed >= Duration::from_millis(1350), + "late change did not restart the timer: {elapsed:?}" + ); + assert!( + elapsed < Duration::from_secs(10), + "wait took too long: {elapsed:?}" + ); + wait_for_text(&session, "LATE-VISIBLE"); + session.close().expect("close terminal"); +} + +// U10-B: timeout and cancellation finish within a bounded test deadline. +#[test] +fn u10_b_timeout_is_bounded() { + let script = if cfg!(windows) { + "[Console]::Write('READY'); while ($true) { [Console]::WriteLine('busy'); Start-Sleep -Milliseconds 20 }" + } else { + "printf READY; while true; do echo busy; sleep 0.02; done" + }; + let session = ready_session("b-timeout", script); + let options = WaitQuietOptions::new(Duration::from_secs(1), QUIET) + .expect("test options are valid") + .with_mode(WaitQuietMode::Bytes); + let started = Instant::now(); + let error = session + .wait_quiet(&options) + .expect_err("continuous output must time out byte quiet"); + let elapsed = started.elapsed(); + assert!(is_timeout(&error), "unexpected error: {error}"); + assert!(elapsed >= Duration::from_millis(900), "{elapsed:?}"); + assert!(elapsed < Duration::from_secs(6), "{elapsed:?}"); + session.close().expect("close terminal"); +} + +#[test] +fn u10_b_cancellation_is_bounded() { + let script = if cfg!(windows) { + "[Console]::Write('READY'); Start-Sleep -Seconds 30" + } else { + "printf READY; sleep 30" + }; + let session = ready_session("b-cancel", script); + let (flag, check) = cancel_flag(); + let options = WaitQuietOptions::new(Duration::from_secs(10), Duration::from_secs(2)) + .expect("test options are valid"); + let worker = session.clone(); + let handle = + std::thread::spawn(move || worker.wait_quiet_cancellable(&options, &check)); + std::thread::sleep(Duration::from_millis(300)); + flag.store(true, Ordering::Release); + let started = Instant::now(); + let error = handle + .join() + .expect("wait thread joins") + .expect_err("cancelled wait must report cancellation"); + assert!(is_cancelled(&error), "unexpected error: {error}"); + assert!( + started.elapsed() < Duration::from_secs(5), + "cancellation was not prompt" + ); + session.close().expect("close terminal"); +} + +// U10-C: cancellation kills neither the child nor a different caller. +#[test] +fn u10_c_cancel_leaves_child_and_other_caller_alone() { + let script = if cfg!(windows) { + "[Console]::Write('READY'); Start-Sleep -Seconds 30" + } else { + "printf READY; sleep 30" + }; + let session = ready_session("c-alive", script); + let (flag, check) = cancel_flag(); + let options = WaitQuietOptions::new(Duration::from_secs(10), Duration::from_secs(2)) + .expect("test options are valid"); + let first = session.clone(); + let cancelled = + std::thread::spawn(move || first.wait_quiet_cancellable(&options, &check)); + let second = session.clone(); + let other = std::thread::spawn(move || second.wait_quiet(&visual_options(QUIET))); + std::thread::sleep(Duration::from_millis(300)); + flag.store(true, Ordering::Release); + let error = cancelled + .join() + .expect("cancelled wait joins") + .expect_err("first wait must report cancellation"); + assert!(is_cancelled(&error), "unexpected error: {error}"); + other + .join() + .expect("other wait joins") + .expect("other caller is not cancelled"); + + // The child still runs: it answers new input after the cancellation. + session + .execute(Operation::Submit { + data: Some("echo C-ALIVE".to_string()), + }) + .expect("submit after cancellation"); + wait_for_text(&session, "C-ALIVE"); + session.close().expect("close terminal"); +} + +// U10-D: input and capture operate correctly after cancellation. +#[test] +fn u10_d_input_and_capture_work_after_cancel() { + let script = if cfg!(windows) { + "[Console]::Write('READY'); Start-Sleep -Seconds 30" + } else { + "printf READY; sleep 30" + }; + let session = ready_session("d-after", script); + let (flag, check) = cancel_flag(); + flag.store(true, Ordering::Release); + let error = session + .wait_quiet_cancellable(&visual_options(QUIET), &check) + .expect_err("pre-cancelled wait must report cancellation"); + assert!(is_cancelled(&error), "unexpected error: {error}"); + + session + .execute(Operation::Submit { + data: Some("echo D-AFTER".to_string()), + }) + .expect("input works after cancellation"); + wait_for_text(&session, "D-AFTER"); + let snapshot = session + .capture_snapshot(SnapshotScope::ActiveScreen) + .expect("capture works after cancellation"); + assert!(snapshot.rows() > 0, "captured an empty screen"); + session + .wait_quiet(&visual_options(QUIET)) + .expect("quiet wait works after cancellation"); + session.close().expect("close terminal"); +} + +// U10-E: the wait releases its operation resources after each outcome. +#[test] +fn u10_e_resources_released_after_success_timeout_and_cancel() { + let script = if cfg!(windows) { + "[Console]::Write('READY'); Start-Sleep -Seconds 30" + } else { + "printf READY; sleep 30" + }; + let session = ready_session("e-release", script); + + // Success outcome, then timeout (equal deadlines always time out), then + // cancellation: the session serves input, capture, and waits after each. + session + .wait_quiet(&visual_options(QUIET)) + .expect("success outcome"); + session + .capture_snapshot(SnapshotScope::ActiveScreen) + .expect("capture after success"); + + let equal = WaitQuietOptions::new(Duration::from_millis(400), Duration::from_millis(400)) + .expect("equal deadlines are accepted"); + let error = session + .wait_quiet(&equal) + .expect_err("equal deadlines must time out"); + assert!(is_timeout(&error), "unexpected error: {error}"); + session + .capture_snapshot(SnapshotScope::ActiveScreen) + .expect("capture after timeout"); + + let (flag, check) = cancel_flag(); + let options = WaitQuietOptions::new(Duration::from_secs(10), Duration::from_secs(2)) + .expect("test options are valid"); + let worker = session.clone(); + let handle = + std::thread::spawn(move || worker.wait_quiet_cancellable(&options, &check)); + std::thread::sleep(Duration::from_millis(200)); + flag.store(true, Ordering::Release); + let error = handle + .join() + .expect("wait thread joins") + .expect_err("cancelled wait must report cancellation"); + assert!(is_cancelled(&error), "unexpected error: {error}"); + + // Two concurrent waits both succeed: no wait holds an exclusive resource. + let first = session.clone(); + let one = std::thread::spawn(move || first.wait_quiet(&visual_options(QUIET))); + let second = session.clone(); + let two = std::thread::spawn(move || second.wait_quiet(&visual_options(QUIET))); + one.join().expect("first join").expect("first concurrent wait"); + two.join().expect("second join").expect("second concurrent wait"); + + session + .execute(Operation::Submit { + data: Some("echo E-RELEASE".to_string()), + }) + .expect("input after all outcomes"); + wait_for_text(&session, "E-RELEASE"); + session.close().expect("close terminal"); +} + +// U10-F: other callers and viewport changes cannot reset this request's timer. +#[test] +fn u10_f_other_callers_do_not_reset_visual_timer() { + let script = if cfg!(windows) { + "[Console]::Write('READY'); Start-Sleep -Seconds 30" + } else { + "printf READY; sleep 30" + }; + let session = ready_session("f-callers", script); + let worker = session.clone(); + let handle = + std::thread::spawn(move || worker.wait_quiet(&visual_options(QUIET))); + // Another caller hammers capture and metadata reads while the wait runs. + for _ in 0..10 { + session + .capture_snapshot(SnapshotScope::ActiveScreen) + .expect("concurrent capture"); + session + .capture_snapshot(SnapshotScope::Viewport) + .expect("concurrent viewport capture"); + std::thread::sleep(Duration::from_millis(20)); + } + handle.join().expect("wait joins").expect("wait succeeds"); + // Re-run timed to confirm the steady-state duration while a caller reads. + let probe = session.clone(); + let hammer = std::thread::spawn(move || { + for _ in 0..20 { + let _ = probe.capture_snapshot(SnapshotScope::ActiveScreen); + std::thread::sleep(Duration::from_millis(10)); + } + }); + let started = Instant::now(); + session + .wait_quiet(&visual_options(QUIET)) + .expect("wait succeeds under concurrent reads"); + let elapsed = started.elapsed(); + hammer.join().expect("hammer joins"); + assert!( + elapsed < QUIET + Duration::from_secs(2), + "concurrent callers delayed the wait: {elapsed:?}" + ); + session.close().expect("close terminal"); +} + +#[test] +fn u10_f_viewport_scroll_does_not_reset_visual_timer() { + let script = if cfg!(windows) { + "[Console]::Write('READY'); for ($i=0; $i -lt 100; $i++) { [Console]::WriteLine(\"fill-$i\") }; Start-Sleep -Seconds 30" + } else { + "printf READY; for i in $(seq 1 100); do echo fill-$i; done; sleep 30" + }; + // The READY marker scrolls out of the viewport under the fill output, so + // settle on the last fill line instead of the READY marker. + let session = Session::new(session_name("f-scroll")); + if cfg!(windows) { + session + .run(run_options("powershell.exe", &["-NoProfile", "-Command", script])) + .expect("run child"); + } else { + session + .run(run_options("sh", &["-c", script])) + .expect("run child"); + } + wait_for_text(&session, "fill-100"); + session + .wait_quiet(&visual_options(QUIET)) + .expect("screen settles before the scroll test"); + let worker = session.clone(); + let handle = + std::thread::spawn(move || worker.wait_quiet(&visual_options(QUIET))); + std::thread::sleep(Duration::from_millis(100)); + session + .execute(Operation::Scroll { + target: ScrollTarget::Lines(5), + }) + .expect("scroll viewport"); + let started = Instant::now(); + handle.join().expect("wait joins").expect("scroll must not reset timer"); + assert!( + started.elapsed() < QUIET + Duration::from_secs(2), + "viewport scroll delayed the wait" + ); + session + .execute(Operation::Scroll { + target: ScrollTarget::Bottom, + }) + .expect("scroll back"); + session.close().expect("close terminal"); +} + +// U10-G: a controlled change between sample points shows the sampling limit. +// +// Rapid change-then-revert output pairs flow through the PTY (byte-quiet +// observes every read) while 50ms visual samples only ever see the stable +// screen, so the visual wait finishes on its first quiet interval. +#[test] +fn u10_g_rapid_change_and_revert_misses_visual_samples() { + let script = if cfg!(windows) { + "[Console]::Write('READY'); Start-Sleep -Milliseconds 300; for ($i=0; $i -lt 40; $i++) { [Console]::Write(\"X`b `b\") }; Start-Sleep -Seconds 30" + } else { + "printf READY; sleep 0.3; for i in $(seq 1 40); do printf 'X\\b \\b'; sleep 0.02; done; sleep 30" + }; + let session = ready_session("g-transient", script); + let started = Instant::now(); + session + .wait_quiet(&visual_options(QUIET)) + .expect("transients between samples must not delay visual quiet"); + let elapsed = started.elapsed(); + assert!( + elapsed < Duration::from_millis(900), + "visual wait saw a transient it should have missed: {elapsed:?}" + ); + // The screen is stable again; the transient left no trace behind. + let snapshot = session + .capture_snapshot(SnapshotScope::ActiveScreen) + .expect("capture after transient stream"); + assert!(snapshot.rows() > 0, "captured an empty screen"); + session.close().expect("close terminal"); +} + +// U10-H: palette-only, title-only, cursor-only, and dimension changes reset +// visual quiet. Each change lands mid-wait; success must follow the change by +// at least one quiet interval (lower bound), proving the timer restarted. +#[test] +fn u10_h_title_change_restarts_visual_quiet() { + let session = Session::new(session_name("h-title")); + session + .open(OpenOptions { + wait_ready: Some(false), + ..OpenOptions::default() + }) + .expect("open shell"); + session + .wait_quiet(&visual_options(QUIET)) + .expect("shell settles"); + // The change lands well inside the first quiet interval (500ms). + let quiet = Duration::from_millis(500); + let worker = session.clone(); + let handle = std::thread::spawn(move || worker.wait_quiet(&visual_options(quiet))); + std::thread::sleep(Duration::from_millis(50)); + let changed_at = Instant::now(); + let command = if cfg!(windows) { + "[Console]::Write(\"`e]0;U10-TITLE-CHANGE`a\")" + } else { + "printf '\\033]0;U10-TITLE-CHANGE\\a'" + }; + session + .execute(Operation::Submit { + data: Some(command.to_string()), + }) + .expect("set title"); + handle.join().expect("wait joins").expect("title change restarts"); + let elapsed = changed_at.elapsed(); + assert!( + elapsed >= Duration::from_millis(350), + "title change did not restart the timer" + ); + assert!(elapsed < Duration::from_secs(8), "{elapsed:?}"); + session.close().expect("close terminal"); +} + +#[test] +fn u10_h_palette_change_restarts_visual_quiet() { + let session = Session::new(session_name("h-palette")); + session + .open(OpenOptions { + wait_ready: Some(false), + ..OpenOptions::default() + }) + .expect("open shell"); + session + .wait_quiet(&visual_options(QUIET)) + .expect("shell settles"); + // The change lands well inside the first quiet interval (500ms). + let quiet = Duration::from_millis(500); + let worker = session.clone(); + let handle = std::thread::spawn(move || worker.wait_quiet(&visual_options(quiet))); + std::thread::sleep(Duration::from_millis(50)); + let changed_at = Instant::now(); + let command = if cfg!(windows) { + "[Console]::Write(\"`e]4;9;rgb:ff/00/00`a\")" + } else { + "printf '\\033]4;9;rgb:ff/00/00\\a'" + }; + session + .execute(Operation::Submit { + data: Some(command.to_string()), + }) + .expect("set palette entry"); + handle.join().expect("wait joins").expect("palette change restarts"); + let elapsed = changed_at.elapsed(); + assert!( + elapsed >= Duration::from_millis(350), + "palette change did not restart the timer" + ); + assert!(elapsed < Duration::from_secs(8), "{elapsed:?}"); + session.close().expect("close terminal"); +} + +#[test] +fn u10_h_cursor_move_restarts_visual_quiet() { + // The child moves only the cursor mid-wait; cells, title, and palette are + // untouched, so only the cursor state can restart the timer. + let quiet = Duration::from_millis(1000); + let script = if cfg!(windows) { + "[Console]::Write('READY'); Start-Sleep -Milliseconds 600; [Console]::Write(\"`e[7;25H\"); Start-Sleep -Seconds 30" + } else { + "printf READY; sleep 0.6; printf '\\033[7;25H'; sleep 30" + }; + let session = ready_session("h-cursor", script); + let started = Instant::now(); + session + .wait_quiet(&visual_options(quiet)) + .expect("cursor move restarts, then succeeds"); + let elapsed = started.elapsed(); + assert!( + elapsed >= Duration::from_millis(1350), + "cursor move did not restart the timer: {elapsed:?}" + ); + assert!(elapsed < Duration::from_secs(10), "{elapsed:?}"); + session.close().expect("close terminal"); +} + +#[test] +fn u10_h_resize_restarts_visual_quiet() { + let script = if cfg!(windows) { + "[Console]::Write('READY'); Start-Sleep -Seconds 30" + } else { + "printf READY; sleep 30" + }; + let session = ready_session("h-resize", script); + // The resize lands well inside the first quiet interval (500ms). + let quiet = Duration::from_millis(500); + let worker = session.clone(); + let handle = std::thread::spawn(move || worker.wait_quiet(&visual_options(quiet))); + std::thread::sleep(Duration::from_millis(50)); + let changed_at = Instant::now(); + session + .execute(Operation::Resize { cols: 100, rows: 30 }) + .expect("resize terminal"); + handle.join().expect("wait joins").expect("resize restarts"); + let elapsed = changed_at.elapsed(); + assert!( + elapsed >= Duration::from_millis(350), + "dimension change did not restart the timer" + ); + assert!(elapsed < Duration::from_secs(8), "{elapsed:?}"); + session.close().expect("close terminal"); +} + +// U10-I: the quiet interval starts with the request after a long idle period. +#[test] +fn u10_i_quiet_starts_at_request_after_long_idle() { + let script = if cfg!(windows) { + "[Console]::Write('READY'); Start-Sleep -Seconds 30" + } else { + "printf READY; sleep 30" + }; + let session = ready_session("i-idle", script); + std::thread::sleep(QUIET * 4); + let started = Instant::now(); + session + .wait_quiet(&visual_options(QUIET)) + .expect("quiet wait succeeds after idle"); + let elapsed = started.elapsed(); + assert!( + elapsed >= QUIET - Duration::from_millis(50), + "wait reused idle time from before the request: {elapsed:?}" + ); + assert!( + elapsed < QUIET + Duration::from_secs(2), + "wait took too long: {elapsed:?}" + ); + let started = Instant::now(); + let bytes = visual_options(QUIET).with_mode(WaitQuietMode::Bytes); + session.wait_quiet(&bytes).expect("byte quiet after idle"); + let elapsed = started.elapsed(); + assert!( + elapsed >= QUIET - Duration::from_millis(50), + "byte wait reused idle time from before the request: {elapsed:?}" + ); + assert!( + elapsed < QUIET + Duration::from_secs(2), + "byte wait took too long: {elapsed:?}" + ); + session.close().expect("close terminal"); +} + +// U10-J: equal quiet and overall deadlines return timeout. +#[test] +fn u10_j_equal_deadlines_return_timeout() { + let script = if cfg!(windows) { + "[Console]::Write('READY'); Start-Sleep -Seconds 30" + } else { + "printf READY; sleep 30" + }; + let session = ready_session("j-equal", script); + for quiet_ms in [400, 800] { + let options = WaitQuietOptions::new( + Duration::from_millis(quiet_ms), + Duration::from_millis(quiet_ms), + ) + .expect("equal deadlines are accepted"); + let started = Instant::now(); + let error = session + .wait_quiet(&options) + .expect_err("equal deadlines must time out"); + let elapsed = started.elapsed(); + assert!(is_timeout(&error), "unexpected error: {error}"); + assert!( + elapsed + Duration::from_millis(50) >= Duration::from_millis(quiet_ms), + "timed out before the deadline: {elapsed:?}" + ); + assert!( + elapsed < Duration::from_millis(quiet_ms) + Duration::from_secs(3), + "timeout took too long: {elapsed:?}" + ); + } + session.close().expect("close terminal"); +} + +#[test] +fn u10_options_validation_rejects_zero_and_overlong() { + assert!(WaitQuietOptions::new(Duration::ZERO, QUIET).is_err()); + assert!(WaitQuietOptions::new(Duration::from_secs(1), Duration::ZERO).is_err()); + assert!( + WaitQuietOptions::new(Duration::from_millis(100), Duration::from_millis(200)).is_err() + ); + assert!( + WaitQuietOptions::new(Duration::from_millis(200), Duration::from_millis(200)).is_ok() + ); + let defaults = WaitQuietOptions::default(); + assert_eq!(defaults.quiet(), Duration::from_millis(250)); + assert_eq!(defaults.timeout(), Duration::from_secs(5)); + assert_eq!(defaults.mode(), WaitQuietMode::Visual); +} From c543e4161e57b28fab5763aecbf8d4f651e80e3c Mon Sep 17 00:00:00 2001 From: Alexey Zhokhov Date: Sun, 25 Oct 2026 17:52:23 +0700 Subject: [PATCH 07/16] U10: apply cargo fmt to quiet-wait sources and tests Signed-off-by: Alexey Zhokhov --- crates/tui-test/src/engine.rs | 4 +- crates/tui-test/src/lib.rs | 4 +- crates/tui-test/src/wait.rs | 34 +++++--------- crates/tui-test/tests/request_waits.rs | 63 ++++++++++++++++---------- 4 files changed, 54 insertions(+), 51 deletions(-) diff --git a/crates/tui-test/src/engine.rs b/crates/tui-test/src/engine.rs index f4f33a87..678847db 100644 --- a/crates/tui-test/src/engine.rs +++ b/crates/tui-test/src/engine.rs @@ -1820,7 +1820,9 @@ impl Engine { /// no output has arrived yet. pub(crate) fn last_output_time(&self) -> Option { let guard = self.lock_session(); - guard.as_ref().and_then(|session| session.last_output_time()) + guard + .as_ref() + .and_then(|session| session.last_output_time()) } fn write_session(&self) -> RwLockWriteGuard<'_, Option> { diff --git a/crates/tui-test/src/lib.rs b/crates/tui-test/src/lib.rs index e5772d2e..6eae166f 100644 --- a/crates/tui-test/src/lib.rs +++ b/crates/tui-test/src/lib.rs @@ -33,6 +33,4 @@ pub use snapshot::{ MAX_SNAPSHOT_DIM, MAX_TITLE_BYTES, SNAPSHOT_SCHEMA_VERSION, }; pub use terminal::backend::Backend; -pub use wait::{ - WaitQuietMode, WaitQuietOptions, DEFAULT_QUIET_INTERVAL, DEFAULT_QUIET_TIMEOUT, -}; +pub use wait::{WaitQuietMode, WaitQuietOptions, DEFAULT_QUIET_INTERVAL, DEFAULT_QUIET_TIMEOUT}; diff --git a/crates/tui-test/src/wait.rs b/crates/tui-test/src/wait.rs index df1fea75..8201a8bb 100644 --- a/crates/tui-test/src/wait.rs +++ b/crates/tui-test/src/wait.rs @@ -185,9 +185,9 @@ pub(crate) fn run_quiet_wait( last_output: &dyn Fn() -> Option, ) -> Result<(), TuiTestError> { let start = Instant::now(); - let overall_deadline = start.checked_add(options.timeout).ok_or_else(|| { - TuiTestError::internal("wait quiet: overall deadline is out of range") - })?; + let overall_deadline = start + .checked_add(options.timeout) + .ok_or_else(|| TuiTestError::internal("wait quiet: overall deadline is out of range"))?; if cancelled() { return Err(cancelled_error()); } @@ -224,9 +224,9 @@ pub(crate) fn run_quiet_wait( } } } - let quiet_deadline = last_change.checked_add(options.quiet).ok_or_else(|| { - TuiTestError::internal("wait quiet: quiet deadline is out of range") - })?; + let quiet_deadline = last_change + .checked_add(options.quiet) + .ok_or_else(|| TuiTestError::internal("wait quiet: quiet deadline is out of range"))?; if Instant::now() >= quiet_deadline { // The visual sample above is the required last sample: it was // captured before this success check, so a change observed here @@ -377,24 +377,14 @@ mod tests { fn options_reject_zero_and_overlong_intervals() { assert!(WaitQuietOptions::new(Duration::ZERO, Duration::from_millis(1)).is_err()); assert!(WaitQuietOptions::new(Duration::from_secs(1), Duration::ZERO).is_err()); - assert!(WaitQuietOptions::new( - Duration::from_millis(100), - Duration::from_millis(200) - ) - .is_err()); - assert!(WaitQuietOptions::new( - Duration::from_millis(200), - Duration::from_millis(200) - ) - .is_ok()); - assert_eq!( - WaitQuietOptions::default().quiet(), - DEFAULT_QUIET_INTERVAL + assert!( + WaitQuietOptions::new(Duration::from_millis(100), Duration::from_millis(200)).is_err() ); - assert_eq!( - WaitQuietOptions::default().timeout(), - DEFAULT_QUIET_TIMEOUT + assert!( + WaitQuietOptions::new(Duration::from_millis(200), Duration::from_millis(200)).is_ok() ); + assert_eq!(WaitQuietOptions::default().quiet(), DEFAULT_QUIET_INTERVAL); + assert_eq!(WaitQuietOptions::default().timeout(), DEFAULT_QUIET_TIMEOUT); } #[test] diff --git a/crates/tui-test/tests/request_waits.rs b/crates/tui-test/tests/request_waits.rs index 9bccc0eb..110782ef 100644 --- a/crates/tui-test/tests/request_waits.rs +++ b/crates/tui-test/tests/request_waits.rs @@ -5,8 +5,8 @@ //! and structural lower bounds. No process-wide environment changes. use std::sync::{ - Arc, atomic::{AtomicBool, Ordering}, + Arc, }; use std::time::{Duration, Instant}; @@ -60,7 +60,10 @@ fn ready_session(tag: &str, script: &str) -> Session { let session = Session::new(session_name(tag)); if cfg!(windows) { session - .run(run_options("powershell.exe", &["-NoProfile", "-Command", script])) + .run(run_options( + "powershell.exe", + &["-NoProfile", "-Command", script], + )) .expect("run child"); } else { session @@ -213,8 +216,7 @@ fn u10_b_cancellation_is_bounded() { let options = WaitQuietOptions::new(Duration::from_secs(10), Duration::from_secs(2)) .expect("test options are valid"); let worker = session.clone(); - let handle = - std::thread::spawn(move || worker.wait_quiet_cancellable(&options, &check)); + let handle = std::thread::spawn(move || worker.wait_quiet_cancellable(&options, &check)); std::thread::sleep(Duration::from_millis(300)); flag.store(true, Ordering::Release); let started = Instant::now(); @@ -243,8 +245,7 @@ fn u10_c_cancel_leaves_child_and_other_caller_alone() { let options = WaitQuietOptions::new(Duration::from_secs(10), Duration::from_secs(2)) .expect("test options are valid"); let first = session.clone(); - let cancelled = - std::thread::spawn(move || first.wait_quiet_cancellable(&options, &check)); + let cancelled = std::thread::spawn(move || first.wait_quiet_cancellable(&options, &check)); let second = session.clone(); let other = std::thread::spawn(move || second.wait_quiet(&visual_options(QUIET))); std::thread::sleep(Duration::from_millis(300)); @@ -334,8 +335,7 @@ fn u10_e_resources_released_after_success_timeout_and_cancel() { let options = WaitQuietOptions::new(Duration::from_secs(10), Duration::from_secs(2)) .expect("test options are valid"); let worker = session.clone(); - let handle = - std::thread::spawn(move || worker.wait_quiet_cancellable(&options, &check)); + let handle = std::thread::spawn(move || worker.wait_quiet_cancellable(&options, &check)); std::thread::sleep(Duration::from_millis(200)); flag.store(true, Ordering::Release); let error = handle @@ -349,8 +349,12 @@ fn u10_e_resources_released_after_success_timeout_and_cancel() { let one = std::thread::spawn(move || first.wait_quiet(&visual_options(QUIET))); let second = session.clone(); let two = std::thread::spawn(move || second.wait_quiet(&visual_options(QUIET))); - one.join().expect("first join").expect("first concurrent wait"); - two.join().expect("second join").expect("second concurrent wait"); + one.join() + .expect("first join") + .expect("first concurrent wait"); + two.join() + .expect("second join") + .expect("second concurrent wait"); session .execute(Operation::Submit { @@ -371,8 +375,7 @@ fn u10_f_other_callers_do_not_reset_visual_timer() { }; let session = ready_session("f-callers", script); let worker = session.clone(); - let handle = - std::thread::spawn(move || worker.wait_quiet(&visual_options(QUIET))); + let handle = std::thread::spawn(move || worker.wait_quiet(&visual_options(QUIET))); // Another caller hammers capture and metadata reads while the wait runs. for _ in 0..10 { session @@ -417,7 +420,10 @@ fn u10_f_viewport_scroll_does_not_reset_visual_timer() { let session = Session::new(session_name("f-scroll")); if cfg!(windows) { session - .run(run_options("powershell.exe", &["-NoProfile", "-Command", script])) + .run(run_options( + "powershell.exe", + &["-NoProfile", "-Command", script], + )) .expect("run child"); } else { session @@ -429,8 +435,7 @@ fn u10_f_viewport_scroll_does_not_reset_visual_timer() { .wait_quiet(&visual_options(QUIET)) .expect("screen settles before the scroll test"); let worker = session.clone(); - let handle = - std::thread::spawn(move || worker.wait_quiet(&visual_options(QUIET))); + let handle = std::thread::spawn(move || worker.wait_quiet(&visual_options(QUIET))); std::thread::sleep(Duration::from_millis(100)); session .execute(Operation::Scroll { @@ -438,7 +443,10 @@ fn u10_f_viewport_scroll_does_not_reset_visual_timer() { }) .expect("scroll viewport"); let started = Instant::now(); - handle.join().expect("wait joins").expect("scroll must not reset timer"); + handle + .join() + .expect("wait joins") + .expect("scroll must not reset timer"); assert!( started.elapsed() < QUIET + Duration::from_secs(2), "viewport scroll delayed the wait" @@ -512,7 +520,10 @@ fn u10_h_title_change_restarts_visual_quiet() { data: Some(command.to_string()), }) .expect("set title"); - handle.join().expect("wait joins").expect("title change restarts"); + handle + .join() + .expect("wait joins") + .expect("title change restarts"); let elapsed = changed_at.elapsed(); assert!( elapsed >= Duration::from_millis(350), @@ -550,7 +561,10 @@ fn u10_h_palette_change_restarts_visual_quiet() { data: Some(command.to_string()), }) .expect("set palette entry"); - handle.join().expect("wait joins").expect("palette change restarts"); + handle + .join() + .expect("wait joins") + .expect("palette change restarts"); let elapsed = changed_at.elapsed(); assert!( elapsed >= Duration::from_millis(350), @@ -599,7 +613,10 @@ fn u10_h_resize_restarts_visual_quiet() { std::thread::sleep(Duration::from_millis(50)); let changed_at = Instant::now(); session - .execute(Operation::Resize { cols: 100, rows: 30 }) + .execute(Operation::Resize { + cols: 100, + rows: 30, + }) .expect("resize terminal"); handle.join().expect("wait joins").expect("resize restarts"); let elapsed = changed_at.elapsed(); @@ -686,12 +703,8 @@ fn u10_j_equal_deadlines_return_timeout() { fn u10_options_validation_rejects_zero_and_overlong() { assert!(WaitQuietOptions::new(Duration::ZERO, QUIET).is_err()); assert!(WaitQuietOptions::new(Duration::from_secs(1), Duration::ZERO).is_err()); - assert!( - WaitQuietOptions::new(Duration::from_millis(100), Duration::from_millis(200)).is_err() - ); - assert!( - WaitQuietOptions::new(Duration::from_millis(200), Duration::from_millis(200)).is_ok() - ); + assert!(WaitQuietOptions::new(Duration::from_millis(100), Duration::from_millis(200)).is_err()); + assert!(WaitQuietOptions::new(Duration::from_millis(200), Duration::from_millis(200)).is_ok()); let defaults = WaitQuietOptions::default(); assert_eq!(defaults.quiet(), Duration::from_millis(250)); assert_eq!(defaults.timeout(), Duration::from_secs(5)); From 1742fba903495948d75fce50b285ac2e4ca0dac4 Mon Sep 17 00:00:00 2001 From: Muse Code Date: Sun, 25 Oct 2026 18:02:39 +0700 Subject: [PATCH 08/16] U10: address review verdict (deadline tie, sample grid, lifecycle preemption) - wait.rs: single post-observation timestamp checks overall timeout before quiet success in both visual and byte arms, so a blocking capture straddling coincident deadlines reports timeout (U10-J). - wait.rs: visual mode sleeps one interval after the request-start capture before the first re-sample; the start sample seeds previous once instead of sampling twice back-to-back. - engine.rs/runtime.rs: wait_quiet_cancellable composes has_pending_lifecycle into its cancellation check so announced Close/Restart requests preempt a quiet wait; documented on both. Signed-off-by: Alexey Zhokhov --- crates/tui-test/src/engine.rs | 8 ++++++ crates/tui-test/src/runtime.rs | 8 +++++- crates/tui-test/src/wait.rs | 51 +++++++++++++++++++++++++--------- 3 files changed, 53 insertions(+), 14 deletions(-) diff --git a/crates/tui-test/src/engine.rs b/crates/tui-test/src/engine.rs index 678847db..d289a298 100644 --- a/crates/tui-test/src/engine.rs +++ b/crates/tui-test/src/engine.rs @@ -1668,6 +1668,14 @@ impl Engine { LifecycleRequest::new(&self.pending_lifecycle) } + /// Whether a lifecycle operation (Close/Restart/...) is announced and + /// waiting for exclusive access. Waits that poll outside + /// [`Engine::execute_with_context_cancellable`] compose this into their + /// cancellation check so lifecycle requests preempt them. + pub(crate) fn has_pending_lifecycle(&self) -> bool { + self.pending_lifecycle.load(Ordering::Acquire) != 0 + } + pub(crate) fn interrupt_for_operation(&self, operation: &Operation) { match operation { Operation::Close => self.interrupt(), diff --git a/crates/tui-test/src/runtime.rs b/crates/tui-test/src/runtime.rs index 2f0c9f8b..818ae95a 100644 --- a/crates/tui-test/src/runtime.rs +++ b/crates/tui-test/src/runtime.rs @@ -500,6 +500,12 @@ impl Session { /// [`Session::interrupt`], and it adds no second cancellation framework. /// The wait holds no operation resources across polls, so every outcome /// leaves the session ready for subsequent operations. + /// + /// Pending lifecycle requests (Close/Restart announced through the + /// engine's `pending_lifecycle` counter) preempt the wait: the + /// composition below reports cancellation instead of letting the quiet + /// wait run past a lifecycle request that cannot itself proceed until + /// waits yield. pub fn wait_quiet_cancellable( &self, options: &WaitQuietOptions, @@ -507,7 +513,7 @@ impl Session { ) -> Result<(), TuiTestError> { crate::wait::run_quiet_wait( options, - cancelled, + &|| cancelled() || self.engine.has_pending_lifecycle(), &|| self.engine.capture_snapshot(SnapshotScope::ActiveScreen), &|| self.engine.last_output_time(), ) diff --git a/crates/tui-test/src/wait.rs b/crates/tui-test/src/wait.rs index 8201a8bb..3b799e1f 100644 --- a/crates/tui-test/src/wait.rs +++ b/crates/tui-test/src/wait.rs @@ -175,7 +175,7 @@ fn timeout_error(options: &WaitQuietOptions) -> TuiTestError { /// byte mode only. `cancelled` mirrors /// [`Engine::execute_with_context_cancellable`](crate::Engine::execute_with_context_cancellable). /// -/// The loop sleeps end at the first of the next poll, the quiet deadline, or +/// The loop sleep ends at the first of the next poll, the quiet deadline, or /// the overall deadline. When the quiet and overall deadlines coincide, the /// overall deadline wins and the request reports timeout. pub(crate) fn run_quiet_wait( @@ -197,24 +197,38 @@ pub(crate) fn run_quiet_wait( None }; let mut last_change = start; + if options.mode == WaitQuietMode::Visual { + // The start capture seeds `previous` once; the first re-sample + // happens one interval later so captures stay on the 50ms grid + // instead of sampling twice back-to-back. + let quiet_deadline = last_change + .checked_add(options.quiet) + .ok_or_else(|| TuiTestError::internal("wait quiet: quiet deadline is out of range"))?; + let first_sleep = [SAMPLE_INTERVAL] + .into_iter() + .chain(quiet_deadline.checked_duration_since(start)) + .chain(overall_deadline.checked_duration_since(start)) + .min() + .unwrap_or(SAMPLE_INTERVAL); + if !first_sleep.is_zero() { + std::thread::sleep(first_sleep); + } + } loop { if cancelled() { return Err(cancelled_error()); } - let now = Instant::now(); - if now >= overall_deadline { + if Instant::now() >= overall_deadline { return Err(timeout_error(options)); } - match options.mode { + let changed = match options.mode { WaitQuietMode::Visual => { let sample = capture_visual()?; let changed = previous .as_ref() .is_none_or(|before| !canonical_visual_eq(before, &sample)); previous = Some(sample); - if changed { - last_change = Instant::now(); - } + changed } WaitQuietMode::Bytes => { if let Some(read_at) = last_output() { @@ -222,15 +236,26 @@ pub(crate) fn run_quiet_wait( last_change = read_at; } } + false } + }; + // ONE timestamp after the observation covers both modes: a blocking + // capture may straddle the overall deadline, so the overall timeout + // is checked before quiet success and both use this same instant. + let observed = Instant::now(); + if changed { + last_change = observed; } let quiet_deadline = last_change .checked_add(options.quiet) .ok_or_else(|| TuiTestError::internal("wait quiet: quiet deadline is out of range"))?; - if Instant::now() >= quiet_deadline { - // The visual sample above is the required last sample: it was - // captured before this success check, so a change observed here - // restarts the timer instead of reporting success. + if observed >= overall_deadline { + return Err(timeout_error(options)); + } + if observed >= quiet_deadline { + // The sample above is the required last sample: it was captured + // before this success check, so a change observed here restarts + // the timer instead of reporting success. if cancelled() { return Err(cancelled_error()); } @@ -238,8 +263,8 @@ pub(crate) fn run_quiet_wait( } let sleep = [SAMPLE_INTERVAL] .into_iter() - .chain(quiet_deadline.checked_duration_since(Instant::now())) - .chain(overall_deadline.checked_duration_since(Instant::now())) + .chain(quiet_deadline.checked_duration_since(observed)) + .chain(overall_deadline.checked_duration_since(observed)) .min() .unwrap_or(SAMPLE_INTERVAL); if !sleep.is_zero() { From f7effb940e498925b99b3aa461ddebc9fe933dbc Mon Sep 17 00:00:00 2001 From: Alexey Zhokhov Date: Mon, 26 Oct 2026 03:47:00 +0700 Subject: [PATCH 09/16] U10: fix quiet-wait lifecycle defects (R7, R8) with deterministic tests (R25-part) R7: byte quiet no longer conflates a missing session with no output. Engine::last_output_time now returns Result>: missing sessions and emulator faults propagate as errors, and Ok(None) is reserved for a live session with no output yet. The visual arm already propagated capture errors. R8: a completed Close/Restart can no longer escape quiet-wait cancellation. The wait now runs through Engine::run_quiet_wait_cancellable, which holds the shared lifecycle lock before any caller closure runs (so a wait parked in its cancellation callback blocks lifecycle writers instead of letting them complete underneath it) and re-checks a stable session generation captured at request start before any success return. The pending-lifecycle counter alone is insufficient because it returns to zero once the writer acquires the exclusive lock while teardown still runs. R25-part: run_quiet_wait takes an injected private QuietClock; production uses real time while unit tests drive a scripted FakeClock with exact poll/sample/deadline/cancellation assertions and zero wall-clock timing. Barrier-controlled Close/Restart PTY tests use a controlled Rust child fixture with bounded joins; existing shell-based PTY suites are kept and still pass separately. Signed-off-by: Alexey Zhokhov --- crates/tui-test/src/engine.rs | 133 ++++++++- crates/tui-test/src/runtime.rs | 17 +- crates/tui-test/src/wait.rs | 355 ++++++++++++++++++++++- crates/tui-test/tests/quiet_lifecycle.rs | 247 ++++++++++++++++ 4 files changed, 727 insertions(+), 25 deletions(-) create mode 100644 crates/tui-test/tests/quiet_lifecycle.rs diff --git a/crates/tui-test/src/engine.rs b/crates/tui-test/src/engine.rs index d289a298..3c2eaa1b 100644 --- a/crates/tui-test/src/engine.rs +++ b/crates/tui-test/src/engine.rs @@ -44,6 +44,7 @@ use crate::terminal::emu::{ ClipboardType, CursorShape, Emulator, KeyboardMode, MouseMode, TerminalMode, }; use crate::terminal::locator::{self, Pattern}; +use crate::wait::WaitQuietOptions; pub struct Engine { name: String, @@ -51,6 +52,14 @@ pub struct Engine { lifecycle: RwLock<()>, pending_lifecycle: AtomicUsize, session: RwLock>, + /// Stable generation of the installed session slot, bumped on every + /// install or removal. Quiet waits capture it at request start and + /// re-check it before reporting success, so a Close/Restart that + /// completes mid-wait can never surface as quiet success (R8). The + /// pending-lifecycle counter alone is insufficient: it returns to zero + /// once the lifecycle operation acquires the exclusive lock, while the + /// teardown still runs. + session_generation: AtomicU64, spawn_spec: Mutex>, live: Arc>>, interrupt: Mutex>, @@ -270,6 +279,7 @@ impl Engine { lifecycle: RwLock::new(()), pending_lifecycle: AtomicUsize::new(0), session: RwLock::new(None), + session_generation: AtomicU64::new(0), spawn_spec: Mutex::new(None), live: Arc::new(Mutex::new(None)), interrupt: Mutex::new(None), @@ -520,6 +530,7 @@ impl Engine { .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) = None; if let Some(session) = self.write_session().take() { + self.bump_session_generation(); session.kill(); drop(session); } @@ -748,6 +759,7 @@ impl Engine { .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) = None; if let Some(previous) = current.take() { + self.bump_session_generation(); self.finish_trace_with_session(&previous, context, None)?; previous.kill(); drop(previous); @@ -862,6 +874,7 @@ impl Engine { shell: session.shell.map(|value| value.as_str()), }; *self.write_session() = Some(session); + self.bump_session_generation(); *self .live .lock() @@ -1824,13 +1837,82 @@ impl Engine { } /// Monotonic time of the most recent nonempty successful PTY output read, - /// if any. Served to byte-quiet waits; `None` when no session is open or - /// no output has arrived yet. - pub(crate) fn last_output_time(&self) -> Option { + /// if any. Served to byte-quiet waits; `Ok(None)` only when a session is + /// open and no output has arrived yet. A missing session or an emulator + /// fault is an error, matching [`Engine::capture_snapshot_with`], so a + /// byte-quiet wait can never conflate no-session with no-output (R7). + pub(crate) fn last_output_time(&self) -> Result, TuiTestError> { let guard = self.lock_session(); - guard - .as_ref() - .and_then(|session| session.last_output_time()) + let session = guard.as_ref().ok_or_else(TuiTestError::no_session)?; + if let Some(fault) = session.fault() { + return Err( + TuiTestError::internal(fault.clone()).with_report(FailureReport::new( + "terminal.operation", + None, + FailureReason::EmulatorFault, + fault, + )), + ); + } + Ok(session.last_output_time()) + } + + /// Stable generation of the installed session slot, for quiet waits. + pub(crate) fn session_generation(&self) -> u64 { + self.session_generation.load(Ordering::Acquire) + } + + fn bump_session_generation(&self) { + self.session_generation.fetch_add(1, Ordering::AcqRel); + } + + /// Run a request-scoped quiet wait coordinated with the lifecycle guard. + /// + /// Holds the shared lifecycle lock for the wait (like waits dispatched + /// through [`Engine::execute_with_context_cancellable`]) so a concurrent + /// Close/Restart announces itself through `pending_lifecycle` and blocks + /// on the exclusive lock instead of completing underneath the wait. The + /// wait aborts on that announcement; the session generation captured at + /// request start is re-checked before any success return, so a lifecycle + /// transition that completes mid-wait can never surface as quiet + /// success (R8). Cancellation never terminates the session and never + /// cancels another caller. + pub(crate) fn run_quiet_wait_cancellable( + &self, + options: &WaitQuietOptions, + cancelled: &dyn Fn() -> bool, + ) -> Result<(), TuiTestError> { + // The shared lock is acquired before any caller-supplied closure + // runs: parking inside `cancelled` (or a slow observer) must already + // block lifecycle writers, never let them complete underneath us. + let _shared = self + .lifecycle + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner); + // A lifecycle request announced while acquiring the lock, or a + // caller that was already cancelled, preempts the wait before its + // first observation. + if cancelled() || self.has_pending_lifecycle() { + return Err(crate::wait::cancelled_error()); + } + let generation = self.session_generation(); + let outcome = crate::wait::run_quiet_wait( + options, + &|| cancelled() || self.has_pending_lifecycle(), + &|| self.capture_snapshot(SnapshotScope::ActiveScreen), + &|| self.last_output_time(), + ); + // A lifecycle transition announced while polling, or a generation + // change from a transition that completed mid-wait, preempts success: + // the request did not observe quiet on a stable live session. + if outcome.is_ok() + && (cancelled() + || self.has_pending_lifecycle() + || self.session_generation() != generation) + { + return Err(crate::wait::cancelled_error()); + } + outcome } fn write_session(&self) -> RwLockWriteGuard<'_, Option> { @@ -4581,6 +4663,45 @@ mod tests { use crate::terminal::cell::{NamedColor, UnderlineStyle}; use crate::terminal::emu::Emulator; + // R7: the private byte-quiet output accessor reports a missing session + // as an error, never as quiet. `Ok(None)` is reserved for a live + // session with no output yet. + #[test] + fn last_output_time_without_session_reports_no_session() { + let engine = Engine::new( + "quiet-no-session".into(), + Arc::new(Logger::disabled()), + PathBuf::new(), + ); + let error = engine + .last_output_time() + .expect_err("no session must error"); + assert_eq!(error.kind, ErrorKind::NoSession); + } + + // R8: installing and removing the session advances the stable generation + // that quiet waits bind to; unrelated operations leave it unchanged. + #[test] + fn session_generation_advances_on_install_and_removal() { + let engine = Engine::new( + "quiet-generation".into(), + Arc::new(Logger::disabled()), + PathBuf::new(), + ); + let idle = engine.session_generation(); + engine + .execute(Operation::Run(sleeping_program(false))) + .unwrap(); + let installed = engine.session_generation(); + assert_ne!(installed, idle, "install must advance the generation"); + engine.execute(Operation::Close).unwrap(); + assert_ne!( + engine.session_generation(), + installed, + "removal must advance the generation" + ); + } + fn sleeping_program(wait_ready: bool) -> RunOptions { let (program, args) = if cfg!(windows) { ( diff --git a/crates/tui-test/src/runtime.rs b/crates/tui-test/src/runtime.rs index 818ae95a..4db0e678 100644 --- a/crates/tui-test/src/runtime.rs +++ b/crates/tui-test/src/runtime.rs @@ -502,21 +502,18 @@ impl Session { /// leaves the session ready for subsequent operations. /// /// Pending lifecycle requests (Close/Restart announced through the - /// engine's `pending_lifecycle` counter) preempt the wait: the - /// composition below reports cancellation instead of letting the quiet - /// wait run past a lifecycle request that cannot itself proceed until - /// waits yield. + /// engine's `pending_lifecycle` counter) preempt the wait: the engine + /// holds its shared lifecycle lock for the wait and reports cancellation + /// instead of letting the quiet wait run past a lifecycle request that + /// cannot itself proceed until waits yield. A session generation captured + /// at request start is re-checked before any success return, so a Close + /// or Restart that completes mid-wait never surfaces as quiet success. pub fn wait_quiet_cancellable( &self, options: &WaitQuietOptions, cancelled: &dyn Fn() -> bool, ) -> Result<(), TuiTestError> { - crate::wait::run_quiet_wait( - options, - &|| cancelled() || self.engine.has_pending_lifecycle(), - &|| self.engine.capture_snapshot(SnapshotScope::ActiveScreen), - &|| self.engine.last_output_time(), - ) + self.engine.run_quiet_wait_cancellable(options, cancelled) } /// Cancel pending operations, including startup readiness, and terminate the diff --git a/crates/tui-test/src/wait.rs b/crates/tui-test/src/wait.rs index 3b799e1f..343542fa 100644 --- a/crates/tui-test/src/wait.rs +++ b/crates/tui-test/src/wait.rs @@ -166,13 +166,34 @@ fn timeout_error(options: &WaitQuietOptions) -> TuiTestError { )) } +/// Monotonic clock driving a quiet wait. Production uses real time; +/// deterministic tests inject a scripted clock instead of sleeping. +pub(crate) trait QuietClock { + fn now(&self) -> Instant; + fn sleep(&self, duration: Duration); +} + +struct SystemClock; + +impl QuietClock for SystemClock { + fn now(&self) -> Instant { + Instant::now() + } + + fn sleep(&self, duration: Duration) { + std::thread::sleep(duration); + } +} + /// Run a quiet wait against caller-supplied observation closures. /// /// `capture_visual` must capture a raw active-screen snapshot; it is called /// at request start and then at most every 50 milliseconds in visual mode, /// and never in byte mode. `last_output` must report the monotonic time of /// the most recent nonempty successful PTY output read; it is consulted in -/// byte mode only. `cancelled` mirrors +/// byte mode only and returns an error when no session is open or the +/// emulator faulted. `Ok(None)` is reserved for a live session with no +/// output yet. `cancelled` mirrors /// [`Engine::execute_with_context_cancellable`](crate::Engine::execute_with_context_cancellable). /// /// The loop sleep ends at the first of the next poll, the quiet deadline, or @@ -182,9 +203,26 @@ pub(crate) fn run_quiet_wait( options: &WaitQuietOptions, cancelled: &dyn Fn() -> bool, capture_visual: &dyn Fn() -> Result, - last_output: &dyn Fn() -> Option, + last_output: &dyn Fn() -> Result, TuiTestError>, ) -> Result<(), TuiTestError> { - let start = Instant::now(); + run_quiet_wait_with_clock( + options, + cancelled, + capture_visual, + last_output, + &SystemClock, + ) +} + +/// [`run_quiet_wait`] with an injected clock for deterministic tests. +pub(crate) fn run_quiet_wait_with_clock( + options: &WaitQuietOptions, + cancelled: &dyn Fn() -> bool, + capture_visual: &dyn Fn() -> Result, + last_output: &dyn Fn() -> Result, TuiTestError>, + clock: &dyn QuietClock, +) -> Result<(), TuiTestError> { + let start = clock.now(); let overall_deadline = start .checked_add(options.timeout) .ok_or_else(|| TuiTestError::internal("wait quiet: overall deadline is out of range"))?; @@ -211,14 +249,14 @@ pub(crate) fn run_quiet_wait( .min() .unwrap_or(SAMPLE_INTERVAL); if !first_sleep.is_zero() { - std::thread::sleep(first_sleep); + clock.sleep(first_sleep); } } loop { if cancelled() { return Err(cancelled_error()); } - if Instant::now() >= overall_deadline { + if clock.now() >= overall_deadline { return Err(timeout_error(options)); } let changed = match options.mode { @@ -231,7 +269,10 @@ pub(crate) fn run_quiet_wait( changed } WaitQuietMode::Bytes => { - if let Some(read_at) = last_output() { + // A missing session or emulator fault is an error, never + // quiet: only a live session with no output yet observes + // `Ok(None)` here. + if let Some(read_at) = last_output()? { if read_at > last_change { last_change = read_at; } @@ -242,7 +283,7 @@ pub(crate) fn run_quiet_wait( // ONE timestamp after the observation covers both modes: a blocking // capture may straddle the overall deadline, so the overall timeout // is checked before quiet success and both use this same instant. - let observed = Instant::now(); + let observed = clock.now(); if changed { last_change = observed; } @@ -268,7 +309,7 @@ pub(crate) fn run_quiet_wait( .min() .unwrap_or(SAMPLE_INTERVAL); if !sleep.is_zero() { - std::thread::sleep(sleep); + clock.sleep(sleep); } } } @@ -423,8 +464,304 @@ mod tests { let options = WaitQuietOptions::new(Duration::from_secs(5), Duration::from_millis(100)) .expect("test options are valid"); let started = Instant::now(); - run_quiet_wait(&options, &|| false, &capture, &|| None) + run_quiet_wait(&options, &|| false, &capture, &|| Ok(None)) .expect("unseen transient must not delay success"); assert!(started.elapsed() < Duration::from_secs(2)); } + + /// Scripted monotonic clock: sleeps advance virtual time instantly, so + /// quiet-wait tests assert exact polls, samples, and deadlines with no + /// wall-clock timing. + struct FakeClock { + now: std::sync::Mutex, + sleeps: std::sync::Mutex>, + } + + impl FakeClock { + fn new() -> Self { + Self { + now: std::sync::Mutex::new(Instant::now()), + sleeps: std::sync::Mutex::new(Vec::new()), + } + } + + fn slept(&self) -> Duration { + self.sleeps + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .iter() + .sum() + } + } + + impl QuietClock for FakeClock { + fn now(&self) -> Instant { + *self + .now + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + } + + fn sleep(&self, duration: Duration) { + self.sleeps + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .push(duration); + *self + .now + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) += duration; + } + } + + fn snapshot_with_title(title: &str) -> TerminalSnapshot { + let mut parts = test_parts(); + parts.title = Some(title.to_string()); + TerminalSnapshot::try_new(parts).expect("test snapshot is valid") + } + + fn byte_options(timeout_ms: u64, quiet_ms: u64) -> WaitQuietOptions { + WaitQuietOptions::new( + Duration::from_millis(timeout_ms), + Duration::from_millis(quiet_ms), + ) + .expect("test options are valid") + .with_mode(WaitQuietMode::Bytes) + } + + // R25-part: byte quiet with no output observes exactly one poll per 50ms + // grid step and succeeds after one quiet interval of virtual time. + #[test] + fn byte_quiet_without_output_uses_exact_poll_grid() { + let clock = FakeClock::new(); + let options = byte_options(10_000, 200); + let calls = std::sync::atomic::AtomicUsize::new(0); + let started = clock.now(); + run_quiet_wait_with_clock( + &options, + &|| false, + &|| panic!("byte mode never captures the screen"), + &|| { + calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + Ok(None) + }, + &clock, + ) + .expect("idle byte wait succeeds"); + // Polls at t=0,50,100,150,200: the final observation at the quiet + // deadline is the success sample, then the wait returns. + assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 5); + assert_eq!( + clock.now().duration_since(started), + Duration::from_millis(200) + ); + assert_eq!(clock.slept(), Duration::from_millis(200)); + } + + // R25-part: a single output read restarts the byte timer from the read + // instant, so success follows the last read by one quiet interval. + #[test] + fn byte_output_restarts_timer_from_read_instant() { + let clock = FakeClock::new(); + let options = byte_options(10_000, 200); + let calls = std::sync::atomic::AtomicUsize::new(0); + let started = clock.now(); + run_quiet_wait_with_clock( + &options, + &|| false, + &|| panic!("byte mode never captures the screen"), + &|| { + let call = calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + // Third poll (t=100) observes a read stamped at that instant. + Ok(if call == 2 { Some(clock.now()) } else { None }) + }, + &clock, + ) + .expect("byte wait follows the last read"); + // Quiet restarts at t=100, so polls run to t=300 for 7 observations. + assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 7); + assert_eq!( + clock.now().duration_since(started), + Duration::from_millis(300) + ); + } + + // R25-part: every visual sample is asserted, including the transient: + // the change is observed exactly once, restarts the timer, and the + // final observation at the new quiet deadline is the success sample. + #[test] + fn visual_change_records_every_transient_sample() { + let clock = FakeClock::new(); + let options = WaitQuietOptions::new(Duration::from_secs(10), Duration::from_millis(200)) + .expect("test options are valid"); + let seen = std::sync::Mutex::new(Vec::new()); + let started = clock.now(); + run_quiet_wait_with_clock( + &options, + &|| false, + &|| { + let captures = seen + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .len(); + // First three captures (t=0,50,100) see screen A; every later + // capture sees the changed screen B. + let sample = snapshot_with_title(if captures < 3 { "A" } else { "B" }); + seen.lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .push(sample.title().map(str::to_string)); + Ok(sample) + }, + &|| panic!("visual mode never consults output time"), + &clock, + ) + .expect("changed screen settles"); + let seen = seen + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone(); + // Initial capture plus polls at t=50..=350: the change lands at + // t=150 and the new quiet deadline is t=350. + let expected = ["A", "A", "A", "B", "B", "B", "B", "B"]; + assert_eq!(seen.len(), expected.len(), "unexpected samples: {seen:?}"); + for (sample, want) in seen.iter().zip(expected) { + assert_eq!( + sample.as_deref(), + Some(want), + "unexpected samples: {seen:?}" + ); + } + assert_eq!( + clock.now().duration_since(started), + Duration::from_millis(350) + ); + } + + // R25-part: deadline priority. Equal quiet and overall deadlines report + // timeout: the final observation meets both deadlines and the overall + // deadline wins. + #[test] + fn equal_deadlines_report_timeout_on_final_observation() { + let clock = FakeClock::new(); + let options = byte_options(400, 400); + let calls = std::sync::atomic::AtomicUsize::new(0); + let error = run_quiet_wait_with_clock( + &options, + &|| false, + &|| panic!("byte mode never captures the screen"), + &|| { + calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + Ok(None) + }, + &clock, + ) + .expect_err("coincident deadlines must time out"); + assert!( + error.to_string().contains("timed out"), + "unexpected error: {error}" + ); + // Polls at t=0..=350, then the loop-top check at t=400 reports the + // overall timeout instead of quiet success. + assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 8); + assert_eq!(clock.slept(), Duration::from_millis(400)); + } + + // R25-part: a blocking capture that straddles the overall deadline + // reports timeout even when the captured screen is stable: the timeout + // check uses the post-observation timestamp. + #[test] + fn blocking_capture_straddling_deadline_reports_timeout() { + let clock = FakeClock::new(); + let options = WaitQuietOptions::new(Duration::from_millis(400), Duration::from_millis(200)) + .expect("test options are valid"); + let stable = snapshot(); + let captures = std::sync::atomic::AtomicUsize::new(0); + let error = run_quiet_wait_with_clock( + &options, + &|| false, + &|| { + let capture = captures.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + if capture == 1 { + // Simulate a capture blocked past the overall deadline. + clock.sleep(Duration::from_millis(500)); + } + Ok(stable.clone()) + }, + &|| panic!("visual mode never consults output time"), + &clock, + ) + .expect_err("straddled deadline must time out"); + assert!( + error.to_string().contains("timed out"), + "unexpected error: {error}" + ); + assert_eq!(captures.load(std::sync::atomic::Ordering::SeqCst), 2); + } + + // R25-part: cancellation before the first observation performs no + // observation at all, and cancellation mid-wait aborts at the next + // loop-top check. + #[test] + fn cancellation_skips_observations_and_aborts_mid_wait() { + let clock = FakeClock::new(); + let options = WaitQuietOptions::new(Duration::from_secs(10), Duration::from_millis(200)) + .expect("test options are valid"); + let captures = std::sync::atomic::AtomicUsize::new(0); + let error = run_quiet_wait_with_clock( + &options, + &|| true, + &|| { + captures.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + Ok(snapshot()) + }, + &|| panic!("visual mode never consults output time"), + &clock, + ) + .expect_err("pre-cancelled wait must report cancellation"); + assert!( + error.to_string().contains("cancelled"), + "unexpected error: {error}" + ); + assert_eq!(captures.load(std::sync::atomic::Ordering::SeqCst), 0); + + let flag = std::sync::atomic::AtomicBool::new(false); + let captures = std::sync::atomic::AtomicUsize::new(0); + let error = run_quiet_wait_with_clock( + &options, + &|| flag.load(std::sync::atomic::Ordering::Acquire), + &|| { + let capture = captures.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + if capture == 1 { + flag.store(true, std::sync::atomic::Ordering::Release); + } + Ok(snapshot()) + }, + &|| panic!("visual mode never consults output time"), + &clock, + ) + .expect_err("mid-wait cancellation must abort"); + assert!( + error.to_string().contains("cancelled"), + "unexpected error: {error}" + ); + // Initial capture plus one poll; the next loop-top check aborts. + assert_eq!(captures.load(std::sync::atomic::Ordering::SeqCst), 2); + } + + // R7 at the wait level: a missing session surfaces as an error and never + // as quiet, even in byte mode. + #[test] + fn byte_wait_propagates_missing_session_error() { + let clock = FakeClock::new(); + let options = byte_options(10_000, 200); + let error = run_quiet_wait_with_clock( + &options, + &|| false, + &|| panic!("byte mode never captures the screen"), + &|| Err(crate::api::TuiTestError::no_session()), + &clock, + ) + .expect_err("missing session must error"); + assert_eq!(error.kind, crate::api::ErrorKind::NoSession); + } } diff --git a/crates/tui-test/tests/quiet_lifecycle.rs b/crates/tui-test/tests/quiet_lifecycle.rs new file mode 100644 index 00000000..342df229 --- /dev/null +++ b/crates/tui-test/tests/quiet_lifecycle.rs @@ -0,0 +1,247 @@ +//! U10 quiet-wait lifecycle regression tests (R7, R8) with a controlled +//! Rust child (R25-part). +//! +//! Conventions: unique session names per test (parallel-safe), bounded joins +//! everywhere (`recv_timeout`, never a bare `join` on a thread that can +//! block), barrier/event synchronization instead of timing guesses, and no +//! process-wide environment changes. + +use std::io::Write; +use std::sync::{mpsc, Mutex}; +use std::time::Duration; + +use tui_test::{ + AutomaticRecording, AutomaticRecordingMode, ErrorKind, LocatorQuery, OpenOptions, Operation, + RunOptions, Session, Timeouts, WaitQuietMode, WaitQuietOptions, +}; + +const FIXTURE: &str = "TUI_TEST_QUIET_LIFECYCLE_FIXTURE"; +const JOIN_BOUND: Duration = Duration::from_secs(25); +const GATE_BOUND: Duration = Duration::from_secs(15); +const SETTLE_TIMEOUT_MS: u64 = 15_000; + +fn emit(bytes: &[u8]) { + let mut stdout = std::io::stdout().lock(); + stdout.write_all(bytes).unwrap(); + stdout.flush().unwrap(); +} + +#[test] +fn child_fixture() { + let Ok(mode) = std::env::var(FIXTURE) else { + return; + }; + match mode.as_str() { + "quiet" => { + emit(b"QUIET-READY\r\n"); + std::thread::sleep(Duration::from_secs(30)); + } + _ => panic!("unknown fixture mode: {mode}"), + } +} + +fn session_name(tag: &str) -> String { + format!("u10-ql-{tag}-{}", std::process::id()) +} + +fn fixture_options() -> RunOptions { + let defaults = OpenOptions::default(); + RunOptions { + program: std::env::current_exe() + .unwrap() + .to_string_lossy() + .into_owned(), + args: [ + "--exact", + "child_fixture", + "--nocapture", + "--test-threads=1", + ] + .into_iter() + .map(str::to_string) + .collect(), + backend: defaults.backend, + profile: defaults.profile, + style: defaults.style, + cols: 80, + rows: 24, + cwd: None, + env: vec![(FIXTURE.to_string(), "quiet".to_string())], + wait_ready: Some(false), + restart: false, + timeouts: Timeouts { + ready: Some(5_000), + ..Default::default() + }, + recording: AutomaticRecording { + mode: AutomaticRecordingMode::Disabled, + directory: None, + }, + } +} + +/// Start the controlled Rust child and wait for its `QUIET-READY` marker. +fn fixture_session(tag: &str) -> Session { + let session = Session::new(session_name(tag)); + session.run(fixture_options()).expect("run fixture child"); + session + .execute(Operation::WaitLocator { + query: LocatorQuery::text("QUIET-READY"), + not: false, + timeout_ms: Some(SETTLE_TIMEOUT_MS), + }) + .expect("fixture ready marker appears"); + session +} + +fn byte_options(timeout: Duration, quiet: Duration) -> WaitQuietOptions { + WaitQuietOptions::new(timeout, quiet) + .expect("test options are valid") + .with_mode(WaitQuietMode::Bytes) +} + +/// Join a thread with a hard bound; a hang fails the test instead of +/// hanging the suite. +fn join_bounded(handle: std::thread::JoinHandle, what: &str) -> T { + let (tx, rx) = mpsc::channel(); + std::thread::spawn(move || { + let _ = tx.send(handle.join()); + }); + rx.recv_timeout(JOIN_BOUND) + .unwrap_or_else(|_| panic!("{what} did not finish within {JOIN_BOUND:?}")) + .unwrap_or_else(|_| panic!("{what} panicked")) +} + +// R7: byte quiet conflates no-session with no-output. A byte wait on a newly +// constructed, unopened session must report NoSession exactly like the visual +// wait does, not success after one quiet interval. +#[test] +fn r7_byte_quiet_on_unopened_session_reports_no_session() { + let session = Session::new(session_name("r7-unopened")); + let bytes = byte_options(Duration::from_secs(30), Duration::from_millis(300)); + let error = session + .wait_quiet(&bytes) + .expect_err("byte quiet without a session must fail"); + assert_eq!( + error.kind, + ErrorKind::NoSession, + "byte quiet conflated no-session with no-output: {error}" + ); + + let visual = WaitQuietOptions::new(Duration::from_secs(30), Duration::from_millis(300)) + .expect("test options are valid"); + let error = session + .wait_quiet(&visual) + .expect_err("visual quiet without a session must fail"); + assert_eq!( + error.kind, + ErrorKind::NoSession, + "visual quiet must report NoSession: {error}" + ); +} + +// R8: a completed Close must never escape quiet-wait cancellation. The wait +// parks inside its cancellation callback; Close runs concurrently; when the +// gate releases, the wait must report an error, never success. +#[test] +fn r8_close_during_wait_never_reports_success() { + let session = fixture_session("r8-close"); + let options = byte_options(Duration::from_secs(30), Duration::from_millis(500)); + + let (entered_tx, entered_rx) = mpsc::channel::<()>(); + let (release_tx, release_rx) = mpsc::channel::<()>(); + let entered_tx = Mutex::new(Some(entered_tx)); + let worker = session.clone(); + let wait = std::thread::spawn(move || { + worker.wait_quiet_cancellable(&options, &|| { + if let Some(tx) = entered_tx.lock().unwrap().take() { + let _ = tx.send(()); + let _ = release_rx.recv(); + } + false + }) + }); + entered_rx + .recv_timeout(GATE_BOUND) + .expect("wait reaches the cancellation gate"); + + let (done_tx, done_rx) = mpsc::channel(); + let closer = session.clone(); + std::thread::spawn(move || { + let _ = done_tx.send(closer.close()); + }); + // Grace for Close to announce itself (fixed code: it blocks on the + // lifecycle guard; base code: it completes underneath the parked wait). + let pre_release_close = done_rx.recv_timeout(Duration::from_secs(3)).ok(); + let _ = release_tx.send(()); + assert!( + pre_release_close.is_none(), + "Close completed underneath a parked quiet wait (R8: no lifecycle coordination)" + ); + + let outcome = join_bounded(wait, "quiet wait"); + let close_result = done_rx + .recv_timeout(JOIN_BOUND) + .expect("close finishes after the wait yields"); + assert!( + close_result.is_ok(), + "close must complete: {close_result:?}" + ); + assert!( + outcome.is_err(), + "completed Close escaped quiet-wait cancellation (R8)" + ); +} + +// R8: same escape through Restart. The restarted session is a different +// generation; the parked wait must not report quiet success on it. +#[test] +fn r8_restart_during_wait_never_reports_success() { + let session = fixture_session("r8-restart"); + let options = byte_options(Duration::from_secs(30), Duration::from_millis(500)); + + let (entered_tx, entered_rx) = mpsc::channel::<()>(); + let (release_tx, release_rx) = mpsc::channel::<()>(); + let entered_tx = Mutex::new(Some(entered_tx)); + let worker = session.clone(); + let wait = std::thread::spawn(move || { + worker.wait_quiet_cancellable(&options, &|| { + if let Some(tx) = entered_tx.lock().unwrap().take() { + let _ = tx.send(()); + let _ = release_rx.recv(); + } + false + }) + }); + entered_rx + .recv_timeout(GATE_BOUND) + .expect("wait reaches the cancellation gate"); + + let (done_tx, done_rx) = mpsc::channel(); + let restarter = session.clone(); + std::thread::spawn(move || { + let _ = done_tx.send(restarter.execute(Operation::Restart { + graceful_timeout_ms: 1_000, + })); + }); + let pre_release_restart = done_rx.recv_timeout(Duration::from_secs(3)).ok(); + let _ = release_tx.send(()); + assert!( + pre_release_restart.is_none(), + "Restart completed underneath a parked quiet wait (R8: no lifecycle coordination)" + ); + + let outcome = join_bounded(wait, "quiet wait"); + let restart_result = done_rx + .recv_timeout(JOIN_BOUND) + .expect("restart finishes after the wait yields"); + assert!( + restart_result.is_ok(), + "restart must complete: {restart_result:?}" + ); + assert!( + outcome.is_err(), + "completed Restart escaped quiet-wait cancellation (R8)" + ); + session.close().expect("close terminal"); +} From 103d296494f251921f8cd61d3f6ce2f7e152690d Mon Sep 17 00:00:00 2001 From: Alexey Zhokhov Date: Mon, 26 Oct 2026 19:37:30 +0700 Subject: [PATCH 10/16] U10: harden quiet-wait determinism (R8 barrier, R25 request_waits harness) GAP 1 (R8): quiet_lifecycle Close/Restart tests parked the wait, then allowed a three-second grace for Close to announce itself, with no acknowledgement barrier proving the lifecycle request reached the critical point -- scheduling decided which behavior the test exercised. Replace the grace with a barrier handshake: once the wait is parked inside its cancellation callback (holding the shared lifecycle lock), await_lifecycle_pin spins until Session::has_pending_lifecycle reports the peer announced (proving it reached the preemption point), failing outright if the peer completes first. The post-pin pending check is then a deterministic observation: the peer cannot acquire the exclusive lock until the parked wait yields. Adds Session::has_pending_lifecycle (delegating to Engine) as the barrier query. GAP 2 (R25): request_waits still used shell children (sh/powershell branches), sleep rendezvous, and elapsed-time assertions. Converted all 18 tests to the deterministic harness: a controlled Rust child fixture (current_exe re-invocation, seven scripted modes, stdin echo so Submit works without a shell), entry-gate barriers proving each wait reached its first poll before cancel/change/peer action, marker/Event ordering instead of elapsed bounds, bounded joins everywhere. Deleted all timing-assumption assertions; exact poll/sample/deadline counting stays in the injected FakeClock unit tests in src/wait.rs. Signed-off-by: Alexey Zhokhov --- crates/tui-test/src/engine.rs | 8 +- crates/tui-test/src/runtime.rs | 10 + crates/tui-test/tests/quiet_lifecycle.rs | 62 +- crates/tui-test/tests/request_waits.rs | 763 ++++++++++++----------- 4 files changed, 457 insertions(+), 386 deletions(-) diff --git a/crates/tui-test/src/engine.rs b/crates/tui-test/src/engine.rs index 3c2eaa1b..45581c72 100644 --- a/crates/tui-test/src/engine.rs +++ b/crates/tui-test/src/engine.rs @@ -1684,8 +1684,12 @@ impl Engine { /// Whether a lifecycle operation (Close/Restart/...) is announced and /// waiting for exclusive access. Waits that poll outside /// [`Engine::execute_with_context_cancellable`] compose this into their - /// cancellation check so lifecycle requests preempt them. - pub(crate) fn has_pending_lifecycle(&self) -> bool { + /// cancellation check so lifecycle requests preempt them. Tests use this + /// as a barrier handshake: once a parked wait holds the shared lifecycle + /// lock and this reports true, the lifecycle request cannot complete + /// until the wait yields, so the peer thread's pending completion is a + /// deterministic observation rather than a timing guess. + pub fn has_pending_lifecycle(&self) -> bool { self.pending_lifecycle.load(Ordering::Acquire) != 0 } diff --git a/crates/tui-test/src/runtime.rs b/crates/tui-test/src/runtime.rs index 4db0e678..b7821261 100644 --- a/crates/tui-test/src/runtime.rs +++ b/crates/tui-test/src/runtime.rs @@ -526,6 +526,16 @@ impl Session { self.engine.is_open() } + /// Whether a lifecycle operation (Close/Restart/...) has announced itself + /// and is waiting for exclusive access. Test barrier handshake for + /// lifecycle/quiet-wait coordination: once a parked quiet wait holds the + /// shared lifecycle lock and this reports true, the lifecycle request is + /// pinned at its preemption point and cannot complete until the wait + /// yields. + pub fn has_pending_lifecycle(&self) -> bool { + self.engine.has_pending_lifecycle() + } + pub fn recording_path(&self) -> Option { self.engine.recording_path() } diff --git a/crates/tui-test/tests/quiet_lifecycle.rs b/crates/tui-test/tests/quiet_lifecycle.rs index 342df229..a1d6dedf 100644 --- a/crates/tui-test/tests/quiet_lifecycle.rs +++ b/crates/tui-test/tests/quiet_lifecycle.rs @@ -100,6 +100,42 @@ fn byte_options(timeout: Duration, quiet: Duration) -> WaitQuietOptions { .with_mode(WaitQuietMode::Bytes) } +/// Barrier handshake for lifecycle preemption (R8 determinism). +/// +/// Call once the waiter is parked inside its cancellation callback (which +/// holds the shared lifecycle lock for the whole wait). Spins until the peer +/// lifecycle request announces itself through the engine's pending counter, +/// proving it reached its preemption point. At that moment the peer is +/// pinned: it cannot acquire the exclusive lifecycle lock until the parked +/// wait yields, so a still-pending completion is a deterministic observation, +/// not a timing guess. A completion before the announcement means the request +/// escaped coordination and fails the test outright. +fn await_lifecycle_pin( + session: &Session, + done_rx: &mpsc::Receiver, + started_rx: &mpsc::Receiver<()>, + what: &str, +) { + started_rx + .recv_timeout(GATE_BOUND) + .unwrap_or_else(|_| panic!("{what} thread never started within {GATE_BOUND:?}")); + let deadline = std::time::Instant::now() + GATE_BOUND; + loop { + if session.has_pending_lifecycle() { + return; + } + if done_rx.try_recv().is_ok() { + panic!( + "{what} completed underneath a parked quiet wait (R8: no lifecycle coordination)" + ); + } + if std::time::Instant::now() >= deadline { + panic!("{what} never announced its lifecycle request within {GATE_BOUND:?}"); + } + std::thread::sleep(Duration::from_millis(5)); + } +} + /// Join a thread with a hard bound; a hang fails the test instead of /// hanging the suite. fn join_bounded(handle: std::thread::JoinHandle, what: &str) -> T { @@ -166,18 +202,20 @@ fn r8_close_during_wait_never_reports_success() { .expect("wait reaches the cancellation gate"); let (done_tx, done_rx) = mpsc::channel(); + let (started_tx, started_rx) = mpsc::channel(); let closer = session.clone(); std::thread::spawn(move || { + let _ = started_tx.send(()); let _ = done_tx.send(closer.close()); }); - // Grace for Close to announce itself (fixed code: it blocks on the - // lifecycle guard; base code: it completes underneath the parked wait). - let pre_release_close = done_rx.recv_timeout(Duration::from_secs(3)).ok(); - let _ = release_tx.send(()); + // Pin the peer at its preemption point: announced, blocked on the + // lifecycle guard, unable to complete until the parked wait yields. + await_lifecycle_pin(&session, &done_rx, &started_rx, "Close"); assert!( - pre_release_close.is_none(), - "Close completed underneath a parked quiet wait (R8: no lifecycle coordination)" + matches!(done_rx.try_recv(), Err(mpsc::TryRecvError::Empty)), + "Close finished while the parked wait still holds the lifecycle lock (R8)" ); + let _ = release_tx.send(()); let outcome = join_bounded(wait, "quiet wait"); let close_result = done_rx @@ -218,18 +256,22 @@ fn r8_restart_during_wait_never_reports_success() { .expect("wait reaches the cancellation gate"); let (done_tx, done_rx) = mpsc::channel(); + let (started_tx, started_rx) = mpsc::channel(); let restarter = session.clone(); std::thread::spawn(move || { + let _ = started_tx.send(()); let _ = done_tx.send(restarter.execute(Operation::Restart { graceful_timeout_ms: 1_000, })); }); - let pre_release_restart = done_rx.recv_timeout(Duration::from_secs(3)).ok(); - let _ = release_tx.send(()); + // Pin the peer at its preemption point: announced, blocked on the + // lifecycle guard, unable to complete until the parked wait yields. + await_lifecycle_pin(&session, &done_rx, &started_rx, "Restart"); assert!( - pre_release_restart.is_none(), - "Restart completed underneath a parked quiet wait (R8: no lifecycle coordination)" + matches!(done_rx.try_recv(), Err(mpsc::TryRecvError::Empty)), + "Restart finished while the parked wait still holds the lifecycle lock (R8)" ); + let _ = release_tx.send(()); let outcome = join_bounded(wait, "quiet wait"); let restart_result = done_rx diff --git a/crates/tui-test/tests/request_waits.rs b/crates/tui-test/tests/request_waits.rs index 110782ef..e9449899 100644 --- a/crates/tui-test/tests/request_waits.rs +++ b/crates/tui-test/tests/request_waits.rs @@ -1,50 +1,199 @@ //! U10 request waits: quiet intervals and per-request cancellation. //! -//! Conventions: unique session names per test (parallel-safe), bounded -//! condition waits everywhere, timing assertions with generous upper bounds -//! and structural lower bounds. No process-wide environment changes. +//! Deterministic harness (R25): every child is a controlled Rust fixture +//! (this same test binary re-invoked with a mode env var), never a shell, so +//! output bytes are exact on every platform. Threads rendezvous through +//! channel barriers and observed screen markers, never through sleeps or +//! elapsed-time bounds: there are no wall-clock timing assertions anywhere in +//! this file. Exact poll/sample/deadline counting lives in the injected-clock +//! unit tests in `src/wait.rs`; these integration tests assert functional +//! outcomes (success, timeout/cancelled kinds, marker ordering, isolation) +//! over a real PTY. +//! +//! Conventions: unique session names per test (parallel-safe), bounded joins +//! and gate waits everywhere (`recv_timeout`, never a bare `join` on a thread +//! that can block), barrier/event synchronization instead of timing guesses, +//! and no process-wide environment changes. +use std::io::Write; use std::sync::{ atomic::{AtomicBool, Ordering}, - Arc, + mpsc, Arc, Mutex, }; -use std::time::{Duration, Instant}; +use std::time::Duration; use tui_test::{ - ErrorKind, LocatorQuery, OpenOptions, Operation, RunOptions, ScrollTarget, Session, - SnapshotScope, WaitQuietMode, WaitQuietOptions, + profile::Rgb, AutomaticRecording, AutomaticRecordingMode, ErrorKind, LocatorQuery, OpenOptions, + Operation, RunOptions, ScrollTarget, Session, SnapshotScope, Timeouts, WaitQuietMode, + WaitQuietOptions, }; -const QUIET: Duration = Duration::from_millis(300); +const FIXTURE: &str = "TUI_TEST_REQUEST_WAITS_FIXTURE"; +const JOIN_BOUND: Duration = Duration::from_secs(25); +const GATE_BOUND: Duration = Duration::from_secs(15); const SETTLE_TIMEOUT_MS: u64 = 15_000; +const QUIET: Duration = Duration::from_millis(300); + +fn emit(bytes: &[u8]) { + let mut stdout = std::io::stdout().lock(); + stdout.write_all(bytes).unwrap(); + stdout.flush().unwrap(); +} + +/// Controlled Rust child fixture. One mode per test need; every mode emits a +/// `READY` marker first so the parent can synchronize on observed output. +/// Standard input is echoed back to standard output so `Submit`-based tests +/// observe their input without a shell. +#[test] +fn child_fixture() { + let Ok(mode) = std::env::var(FIXTURE) else { + return; + }; + std::thread::spawn(|| { + use std::io::Read; + let mut input = std::io::stdin(); + let mut buf = [0u8; 1024]; + loop { + match input.read(&mut buf) { + Ok(0) | Err(_) => break, + Ok(n) => { + emit(&buf[..n]); + } + } + } + }); + match mode.as_str() { + // `READY`, then silence: the steady state every wait settles into. + "idle" => { + emit(b"READY\r\n"); + std::thread::sleep(Duration::from_secs(30)); + } + // `READY`, then an endless visible line stream: quiet can never settle. + "busy" => { + emit(b"READY\r\n"); + let mut i = 0u64; + loop { + emit(format!("tick-{i}\r\n").as_bytes()); + i += 1; + std::thread::sleep(Duration::from_millis(20)); + } + } + // `READY`, then screen-neutral output (every read restarts a + // byte-quiet timer while visual samples stay equal), then a done + // marker both the byte wait and the test can observe. + "neutral" => { + emit(b"READY\r\n"); + std::thread::sleep(Duration::from_millis(300)); + for _ in 0..50 { + emit(b"\x1b[0m"); + std::thread::sleep(Duration::from_millis(20)); + } + emit(b"NEUTRAL-DONE\r\n"); + std::thread::sleep(Duration::from_secs(30)); + } + // `READY`, then one late visible line: the change lands 600ms in, so + // a 1000ms quiet wait started at `READY` must span it. + "late" => { + emit(b"READY\r\n"); + std::thread::sleep(Duration::from_millis(600)); + emit(b"LATE-VISIBLE\r\n"); + std::thread::sleep(Duration::from_secs(30)); + } + // `READY`, then a cursor-only move followed by a marker: cells, title, + // and palette are untouched, so only the cursor state can restart a + // visual timer. + "cursor" => { + emit(b"READY\r\n"); + std::thread::sleep(Duration::from_millis(600)); + emit(b"\x1b[7;25H"); + emit(b"CURSOR-DONE\r\n"); + std::thread::sleep(Duration::from_secs(30)); + } + // `READY`, then a scrollback's worth of fill lines for viewport tests. + "fill" => { + emit(b"READY\r\n"); + for i in 1..=100 { + emit(format!("fill-{i}\r\n").as_bytes()); + } + std::thread::sleep(Duration::from_secs(30)); + } + // `READY`, then sub-sample screen-neutral pairs: every pair flows + // through the PTY (byte-quiet observes every read) while 50ms visual + // samples only ever see the stable screen. + "transient" => { + emit(b"READY\r\n"); + std::thread::sleep(Duration::from_millis(300)); + for _ in 0..40 { + emit(b"X\x08 \x08"); + std::thread::sleep(Duration::from_millis(20)); + } + emit(b"TRANSIENT-DONE\r\n"); + std::thread::sleep(Duration::from_secs(30)); + } + _ => panic!("unknown fixture mode: {mode}"), + } +} fn session_name(tag: &str) -> String { format!("u10-{tag}-{}", std::process::id()) } -fn run_options(program: &str, args: &[&str]) -> RunOptions { +fn fixture_options(mode: &str) -> RunOptions { let defaults = OpenOptions::default(); RunOptions { + program: std::env::current_exe() + .unwrap() + .to_string_lossy() + .into_owned(), + args: [ + "--exact", + "child_fixture", + "--nocapture", + "--test-threads=1", + ] + .into_iter() + .map(str::to_string) + .collect(), backend: defaults.backend, - program: program.to_string(), - args: args.iter().map(|arg| (*arg).to_string()).collect(), profile: defaults.profile, style: defaults.style, - cols: defaults.cols, - rows: defaults.rows, - cwd: defaults.cwd, - env: defaults.env, + cols: 80, + rows: 24, + cwd: None, + env: vec![(FIXTURE.to_string(), mode.to_string())], wait_ready: Some(false), - restart: defaults.restart, - timeouts: defaults.timeouts, - recording: defaults.recording, + restart: false, + timeouts: Timeouts { + ready: Some(5_000), + ..Default::default() + }, + recording: AutomaticRecording { + mode: AutomaticRecordingMode::Disabled, + directory: None, + }, } } +/// Start the fixture child in `mode` and wait for its `READY` marker. +fn ready_session(tag: &str, mode: &str) -> Session { + let session = Session::new(session_name(tag)); + session + .run(fixture_options(mode)) + .expect("run fixture child"); + wait_for_text(&session, "READY"); + session +} + fn visual_options(quiet: Duration) -> WaitQuietOptions { WaitQuietOptions::new(Duration::from_secs(30), quiet).expect("test options are valid") } +fn byte_options(timeout: Duration, quiet: Duration) -> WaitQuietOptions { + WaitQuietOptions::new(timeout, quiet) + .expect("test options are valid") + .with_mode(WaitQuietMode::Bytes) +} + fn wait_for_text(session: &Session, text: &str) { session .execute(Operation::WaitLocator { @@ -55,23 +204,36 @@ fn wait_for_text(session: &Session, text: &str) { .expect("settle marker appears"); } -/// Start a scripted child and wait for its `READY` marker. -fn ready_session(tag: &str, script: &str) -> Session { - let session = Session::new(session_name(tag)); - if cfg!(windows) { - session - .run(run_options( - "powershell.exe", - &["-NoProfile", "-Command", script], - )) - .expect("run child"); - } else { - session - .run(run_options("sh", &["-c", script])) - .expect("run child"); +/// Join a thread with a hard bound; a hang fails the test instead of hanging +/// the suite. +fn join_bounded(handle: std::thread::JoinHandle, what: &str) -> T { + let (tx, rx) = mpsc::channel(); + std::thread::spawn(move || { + let _ = tx.send(handle.join()); + }); + rx.recv_timeout(JOIN_BOUND) + .unwrap_or_else(|_| panic!("{what} did not finish within {JOIN_BOUND:?}")) + .unwrap_or_else(|_| panic!("{what} panicked")) +} + +/// One-shot gate proving a wait reached its first poll: the returned holder +/// fires once from inside the cancellation callback, then answers from +/// `flag`. Waiting on the receiver (with `GATE_BOUND`) replaces every +/// "sleep, then assume the wait started" rendezvous. +fn entry_gate() -> (Arc>>>, mpsc::Receiver<()>) { + let (tx, rx) = mpsc::channel(); + (Arc::new(Mutex::new(Some(tx))), rx) +} + +fn signal_entry(gate: &Mutex>>) { + if let Some(tx) = gate.lock().unwrap().take() { + let _ = tx.send(()); } - wait_for_text(&session, "READY"); - session +} + +fn await_entry(rx: &mpsc::Receiver<()>, what: &str) { + rx.recv_timeout(GATE_BOUND) + .unwrap_or_else(|_| panic!("{what} never reached its first poll within {GATE_BOUND:?}")); } fn is_timeout(error: &tui_test::TuiTestError) -> bool { @@ -91,28 +253,13 @@ fn cancel_flag() -> (Arc, impl Fn() -> bool + Send + Sync + 'static) // U10-A: continuous output, equal-screen output, and a late visual change. #[test] fn u10_a_continuous_visible_output_times_out_visual_wait() { - let script = if cfg!(windows) { - "[Console]::Write('READY'); $i=0; while ($true) { [Console]::WriteLine(\"tick-$i\"); $i++; Start-Sleep -Milliseconds 20 }" - } else { - "printf READY; i=0; while true; do echo tick-$i; i=$((i+1)); sleep 0.02; done" - }; - let session = ready_session("a-busy", script); + let session = ready_session("a-busy", "busy"); let options = WaitQuietOptions::new(Duration::from_millis(800), QUIET).expect("test options are valid"); - let started = Instant::now(); let error = session .wait_quiet(&options) .expect_err("continuous visible output must time out"); - let elapsed = started.elapsed(); assert!(is_timeout(&error), "unexpected error: {error}"); - assert!( - elapsed >= Duration::from_millis(700), - "timed out too early: {elapsed:?}" - ); - assert!( - elapsed < Duration::from_secs(6), - "timeout took too long: {elapsed:?}" - ); session.close().expect("close terminal"); } @@ -120,147 +267,108 @@ fn u10_a_continuous_visible_output_times_out_visual_wait() { fn u10_a_equal_screen_output_restarts_bytes_not_visual() { // A stream of screen-neutral output: every read restarts a byte-quiet // timer, while visual samples stay equal throughout. - let script = if cfg!(windows) { - "[Console]::Write('READY'); Start-Sleep -Milliseconds 300; for ($i=0; $i -lt 50; $i++) { [Console]::Write(\"`e[0m\"); Start-Sleep -Milliseconds 20 }; Start-Sleep -Seconds 30" - } else { - "printf READY; sleep 0.3; for i in $(seq 1 50); do printf '\\033[0m'; sleep 0.02; done; sleep 30" - }; - let session = ready_session("a-neutral", script); - let visual = visual_options(QUIET); - let started = Instant::now(); + let session = ready_session("a-neutral", "neutral"); session - .wait_quiet(&visual) + .wait_quiet(&visual_options(QUIET)) .expect("equal-screen output must not delay visual quiet"); - let visual_elapsed = started.elapsed(); - assert!( - visual_elapsed < Duration::from_millis(900), - "visual wait outlived the neutral stream: {visual_elapsed:?}" - ); + wait_for_text(&session, "NEUTRAL-DONE"); + session.close().expect("close terminal"); - let bytes = WaitQuietOptions::new(Duration::from_secs(15), QUIET) - .expect("test options are valid") - .with_mode(WaitQuietMode::Bytes); - let session = ready_session("a-neutral-bytes", script); - let started = Instant::now(); - session - .wait_quiet(&bytes) + // The byte wait spans the whole neutral stream: it starts at READY while + // the stream is still flowing and succeeds only after the stream's done + // marker is observed and the line goes quiet. + let session = ready_session("a-neutral-bytes", "neutral"); + let worker = session.clone(); + let bytes = byte_options(Duration::from_secs(15), QUIET); + let wait = std::thread::spawn(move || worker.wait_quiet(&bytes)); + wait_for_text(&session, "NEUTRAL-DONE"); + join_bounded(wait, "byte quiet over the neutral stream") .expect("byte quiet follows the neutral stream"); - let bytes_elapsed = started.elapsed(); - assert!( - bytes_elapsed >= Duration::from_secs(1), - "byte wait ignored equal-screen output: {bytes_elapsed:?}" - ); session.close().expect("close terminal"); } #[test] fn u10_a_late_visual_change_restarts_then_succeeds() { - // The change lands mid-wait: the quiet interval exceeds the change delay, - // so success must follow the change by at least one quiet interval. + // The change lands 600ms in, inside the 1000ms quiet interval of a wait + // proven started at READY, so success must follow the change: the wait + // cannot succeed before the change lands, and the marker proves it did. let quiet = Duration::from_millis(1000); - let script = if cfg!(windows) { - "[Console]::Write('READY'); Start-Sleep -Milliseconds 600; [Console]::WriteLine('LATE-VISIBLE'); Start-Sleep -Seconds 30" - } else { - "printf READY; sleep 0.6; echo LATE-VISIBLE; sleep 30" - }; - let session = ready_session("a-late", script); - let started = Instant::now(); - session - .wait_quiet(&visual_options(quiet)) - .expect("late change restarts, then succeeds"); - let elapsed = started.elapsed(); - assert!( - elapsed >= Duration::from_millis(1350), - "late change did not restart the timer: {elapsed:?}" - ); - assert!( - elapsed < Duration::from_secs(10), - "wait took too long: {elapsed:?}" - ); + let session = ready_session("a-late", "late"); + let (gate, entered) = entry_gate(); + let worker = session.clone(); + let wait = std::thread::spawn(move || { + worker.wait_quiet_cancellable(&visual_options(quiet), &move || { + signal_entry(&gate); + false + }) + }); + await_entry(&entered, "late-change wait"); wait_for_text(&session, "LATE-VISIBLE"); + join_bounded(wait, "late-change wait").expect("late change restarts, then succeeds"); session.close().expect("close terminal"); } // U10-B: timeout and cancellation finish within a bounded test deadline. #[test] fn u10_b_timeout_is_bounded() { - let script = if cfg!(windows) { - "[Console]::Write('READY'); while ($true) { [Console]::WriteLine('busy'); Start-Sleep -Milliseconds 20 }" - } else { - "printf READY; while true; do echo busy; sleep 0.02; done" - }; - let session = ready_session("b-timeout", script); - let options = WaitQuietOptions::new(Duration::from_secs(1), QUIET) - .expect("test options are valid") - .with_mode(WaitQuietMode::Bytes); - let started = Instant::now(); + let session = ready_session("b-timeout", "busy"); + let options = byte_options(Duration::from_secs(1), QUIET); let error = session .wait_quiet(&options) .expect_err("continuous output must time out byte quiet"); - let elapsed = started.elapsed(); assert!(is_timeout(&error), "unexpected error: {error}"); - assert!(elapsed >= Duration::from_millis(900), "{elapsed:?}"); - assert!(elapsed < Duration::from_secs(6), "{elapsed:?}"); session.close().expect("close terminal"); } #[test] fn u10_b_cancellation_is_bounded() { - let script = if cfg!(windows) { - "[Console]::Write('READY'); Start-Sleep -Seconds 30" - } else { - "printf READY; sleep 30" - }; - let session = ready_session("b-cancel", script); + let session = ready_session("b-cancel", "idle"); let (flag, check) = cancel_flag(); + let (gate, entered) = entry_gate(); let options = WaitQuietOptions::new(Duration::from_secs(10), Duration::from_secs(2)) .expect("test options are valid"); let worker = session.clone(); - let handle = std::thread::spawn(move || worker.wait_quiet_cancellable(&options, &check)); - std::thread::sleep(Duration::from_millis(300)); + let handle = std::thread::spawn(move || { + worker.wait_quiet_cancellable(&options, &move || { + signal_entry(&gate); + check() + }) + }); + // The gate proves the wait is parked in its poll loop; only then does the + // test cancel, so no sleep guesses when cancellation lands. + await_entry(&entered, "cancelled wait"); flag.store(true, Ordering::Release); - let started = Instant::now(); - let error = handle - .join() - .expect("wait thread joins") + let error = join_bounded(handle, "cancelled wait") .expect_err("cancelled wait must report cancellation"); assert!(is_cancelled(&error), "unexpected error: {error}"); - assert!( - started.elapsed() < Duration::from_secs(5), - "cancellation was not prompt" - ); session.close().expect("close terminal"); } // U10-C: cancellation kills neither the child nor a different caller. #[test] fn u10_c_cancel_leaves_child_and_other_caller_alone() { - let script = if cfg!(windows) { - "[Console]::Write('READY'); Start-Sleep -Seconds 30" - } else { - "printf READY; sleep 30" - }; - let session = ready_session("c-alive", script); + let session = ready_session("c-alive", "idle"); let (flag, check) = cancel_flag(); + let (gate, entered) = entry_gate(); let options = WaitQuietOptions::new(Duration::from_secs(10), Duration::from_secs(2)) .expect("test options are valid"); let first = session.clone(); - let cancelled = std::thread::spawn(move || first.wait_quiet_cancellable(&options, &check)); + let cancelled = std::thread::spawn(move || { + first.wait_quiet_cancellable(&options, &move || { + signal_entry(&gate); + check() + }) + }); let second = session.clone(); let other = std::thread::spawn(move || second.wait_quiet(&visual_options(QUIET))); - std::thread::sleep(Duration::from_millis(300)); + await_entry(&entered, "cancelled wait"); flag.store(true, Ordering::Release); - let error = cancelled - .join() - .expect("cancelled wait joins") - .expect_err("first wait must report cancellation"); + let error = + join_bounded(cancelled, "cancelled wait").expect_err("first wait must report cancellation"); assert!(is_cancelled(&error), "unexpected error: {error}"); - other - .join() - .expect("other wait joins") - .expect("other caller is not cancelled"); + join_bounded(other, "other wait").expect("other caller is not cancelled"); - // The child still runs: it answers new input after the cancellation. + // The child still runs: it echoes new input after the cancellation. session .execute(Operation::Submit { data: Some("echo C-ALIVE".to_string()), @@ -273,12 +381,7 @@ fn u10_c_cancel_leaves_child_and_other_caller_alone() { // U10-D: input and capture operate correctly after cancellation. #[test] fn u10_d_input_and_capture_work_after_cancel() { - let script = if cfg!(windows) { - "[Console]::Write('READY'); Start-Sleep -Seconds 30" - } else { - "printf READY; sleep 30" - }; - let session = ready_session("d-after", script); + let session = ready_session("d-after", "idle"); let (flag, check) = cancel_flag(); flag.store(true, Ordering::Release); let error = session @@ -305,12 +408,7 @@ fn u10_d_input_and_capture_work_after_cancel() { // U10-E: the wait releases its operation resources after each outcome. #[test] fn u10_e_resources_released_after_success_timeout_and_cancel() { - let script = if cfg!(windows) { - "[Console]::Write('READY'); Start-Sleep -Seconds 30" - } else { - "printf READY; sleep 30" - }; - let session = ready_session("e-release", script); + let session = ready_session("e-release", "idle"); // Success outcome, then timeout (equal deadlines always time out), then // cancellation: the session serves input, capture, and waits after each. @@ -332,15 +430,19 @@ fn u10_e_resources_released_after_success_timeout_and_cancel() { .expect("capture after timeout"); let (flag, check) = cancel_flag(); + let (gate, entered) = entry_gate(); let options = WaitQuietOptions::new(Duration::from_secs(10), Duration::from_secs(2)) .expect("test options are valid"); let worker = session.clone(); - let handle = std::thread::spawn(move || worker.wait_quiet_cancellable(&options, &check)); - std::thread::sleep(Duration::from_millis(200)); + let handle = std::thread::spawn(move || { + worker.wait_quiet_cancellable(&options, &move || { + signal_entry(&gate); + check() + }) + }); + await_entry(&entered, "cancelled wait"); flag.store(true, Ordering::Release); - let error = handle - .join() - .expect("wait thread joins") + let error = join_bounded(handle, "cancelled wait") .expect_err("cancelled wait must report cancellation"); assert!(is_cancelled(&error), "unexpected error: {error}"); @@ -349,12 +451,8 @@ fn u10_e_resources_released_after_success_timeout_and_cancel() { let one = std::thread::spawn(move || first.wait_quiet(&visual_options(QUIET))); let second = session.clone(); let two = std::thread::spawn(move || second.wait_quiet(&visual_options(QUIET))); - one.join() - .expect("first join") - .expect("first concurrent wait"); - two.join() - .expect("second join") - .expect("second concurrent wait"); + join_bounded(one, "first concurrent wait").expect("first concurrent wait"); + join_bounded(two, "second concurrent wait").expect("second concurrent wait"); session .execute(Operation::Submit { @@ -368,15 +466,18 @@ fn u10_e_resources_released_after_success_timeout_and_cancel() { // U10-F: other callers and viewport changes cannot reset this request's timer. #[test] fn u10_f_other_callers_do_not_reset_visual_timer() { - let script = if cfg!(windows) { - "[Console]::Write('READY'); Start-Sleep -Seconds 30" - } else { - "printf READY; sleep 30" - }; - let session = ready_session("f-callers", script); + let session = ready_session("f-callers", "idle"); + let (gate, entered) = entry_gate(); let worker = session.clone(); - let handle = std::thread::spawn(move || worker.wait_quiet(&visual_options(QUIET))); - // Another caller hammers capture and metadata reads while the wait runs. + let wait = std::thread::spawn(move || { + worker.wait_quiet_cancellable(&visual_options(QUIET), &move || { + signal_entry(&gate); + false + }) + }); + await_entry(&entered, "wait under concurrent reads"); + // Another caller hammers capture and metadata reads while the wait runs; + // a fixed iteration count (not a sleep) bounds this phase. for _ in 0..10 { session .capture_snapshot(SnapshotScope::ActiveScreen) @@ -384,73 +485,59 @@ fn u10_f_other_callers_do_not_reset_visual_timer() { session .capture_snapshot(SnapshotScope::Viewport) .expect("concurrent viewport capture"); - std::thread::sleep(Duration::from_millis(20)); } - handle.join().expect("wait joins").expect("wait succeeds"); - // Re-run timed to confirm the steady-state duration while a caller reads. + join_bounded(wait, "wait under concurrent reads").expect("wait succeeds"); + + // Re-run with a hammer thread driving a fixed burst of reads. + let (gate, entered) = entry_gate(); + let worker = session.clone(); + let wait = std::thread::spawn(move || { + worker.wait_quiet_cancellable(&visual_options(QUIET), &move || { + signal_entry(&gate); + false + }) + }); + await_entry(&entered, "steady-state wait"); let probe = session.clone(); let hammer = std::thread::spawn(move || { for _ in 0..20 { let _ = probe.capture_snapshot(SnapshotScope::ActiveScreen); - std::thread::sleep(Duration::from_millis(10)); } }); - let started = Instant::now(); - session - .wait_quiet(&visual_options(QUIET)) - .expect("wait succeeds under concurrent reads"); - let elapsed = started.elapsed(); - hammer.join().expect("hammer joins"); - assert!( - elapsed < QUIET + Duration::from_secs(2), - "concurrent callers delayed the wait: {elapsed:?}" - ); + join_bounded(wait, "steady-state wait").expect("wait succeeds under concurrent reads"); + join_bounded(hammer, "capture hammer"); session.close().expect("close terminal"); } #[test] fn u10_f_viewport_scroll_does_not_reset_visual_timer() { - let script = if cfg!(windows) { - "[Console]::Write('READY'); for ($i=0; $i -lt 100; $i++) { [Console]::WriteLine(\"fill-$i\") }; Start-Sleep -Seconds 30" - } else { - "printf READY; for i in $(seq 1 100); do echo fill-$i; done; sleep 30" - }; - // The READY marker scrolls out of the viewport under the fill output, so - // settle on the last fill line instead of the READY marker. let session = Session::new(session_name("f-scroll")); - if cfg!(windows) { - session - .run(run_options( - "powershell.exe", - &["-NoProfile", "-Command", script], - )) - .expect("run child"); - } else { - session - .run(run_options("sh", &["-c", script])) - .expect("run child"); - } + session + .run(fixture_options("fill")) + .expect("run fixture child"); + // The READY marker scrolls out under the fill output, so settle on the + // last fill line instead of the READY marker. wait_for_text(&session, "fill-100"); session .wait_quiet(&visual_options(QUIET)) .expect("screen settles before the scroll test"); + let (gate, entered) = entry_gate(); let worker = session.clone(); - let handle = std::thread::spawn(move || worker.wait_quiet(&visual_options(QUIET))); - std::thread::sleep(Duration::from_millis(100)); + let wait = std::thread::spawn(move || { + worker.wait_quiet_cancellable(&visual_options(QUIET), &move || { + signal_entry(&gate); + false + }) + }); + // The gate proves the wait started before the scroll, so the scroll lands + // mid-wait without any sleep rendezvous. + await_entry(&entered, "wait across scroll"); session .execute(Operation::Scroll { target: ScrollTarget::Lines(5), }) .expect("scroll viewport"); - let started = Instant::now(); - handle - .join() - .expect("wait joins") - .expect("scroll must not reset timer"); - assert!( - started.elapsed() < QUIET + Duration::from_secs(2), - "viewport scroll delayed the wait" - ); + join_bounded(wait, "wait across scroll").expect("scroll must not reset timer"); session .execute(Operation::Scroll { target: ScrollTarget::Bottom, @@ -461,27 +548,19 @@ fn u10_f_viewport_scroll_does_not_reset_visual_timer() { // U10-G: a controlled change between sample points shows the sampling limit. // -// Rapid change-then-revert output pairs flow through the PTY (byte-quiet -// observes every read) while 50ms visual samples only ever see the stable -// screen, so the visual wait finishes on its first quiet interval. +// Rapid change-then-revert pairs flow through the PTY (byte-quiet observes +// every read) while 50ms visual samples only ever see the stable screen, so +// the visual wait finishes on its first quiet interval. #[test] fn u10_g_rapid_change_and_revert_misses_visual_samples() { - let script = if cfg!(windows) { - "[Console]::Write('READY'); Start-Sleep -Milliseconds 300; for ($i=0; $i -lt 40; $i++) { [Console]::Write(\"X`b `b\") }; Start-Sleep -Seconds 30" - } else { - "printf READY; sleep 0.3; for i in $(seq 1 40); do printf 'X\\b \\b'; sleep 0.02; done; sleep 30" - }; - let session = ready_session("g-transient", script); - let started = Instant::now(); - session - .wait_quiet(&visual_options(QUIET)) + let session = ready_session("g-transient", "transient"); + let worker = session.clone(); + let wait = std::thread::spawn(move || worker.wait_quiet(&visual_options(QUIET))); + // The transient stream runs while the wait observes; the done marker + // proves the stream completed and the screen is stable again. + wait_for_text(&session, "TRANSIENT-DONE"); + join_bounded(wait, "transient wait") .expect("transients between samples must not delay visual quiet"); - let elapsed = started.elapsed(); - assert!( - elapsed < Duration::from_millis(900), - "visual wait saw a transient it should have missed: {elapsed:?}" - ); - // The screen is stable again; the transient left no trace behind. let snapshot = session .capture_snapshot(SnapshotScope::ActiveScreen) .expect("capture after transient stream"); @@ -489,212 +568,148 @@ fn u10_g_rapid_change_and_revert_misses_visual_samples() { session.close().expect("close terminal"); } -// U10-H: palette-only, title-only, cursor-only, and dimension changes reset -// visual quiet. Each change lands mid-wait; success must follow the change by -// at least one quiet interval (lower bound), proving the timer restarted. +// U10-H: palette-only, title-only, cursor-only, and dimension changes restart +// visual quiet. The entry gate proves each wait started before its change +// lands mid-wait, and observed state (title, palette, marker) proves the +// change actually happened; no elapsed-time bounds are asserted. #[test] fn u10_h_title_change_restarts_visual_quiet() { - let session = Session::new(session_name("h-title")); - session - .open(OpenOptions { - wait_ready: Some(false), - ..OpenOptions::default() - }) - .expect("open shell"); - session - .wait_quiet(&visual_options(QUIET)) - .expect("shell settles"); - // The change lands well inside the first quiet interval (500ms). + let session = ready_session("h-title", "idle"); let quiet = Duration::from_millis(500); + let (gate, entered) = entry_gate(); let worker = session.clone(); - let handle = std::thread::spawn(move || worker.wait_quiet(&visual_options(quiet))); - std::thread::sleep(Duration::from_millis(50)); - let changed_at = Instant::now(); - let command = if cfg!(windows) { - "[Console]::Write(\"`e]0;U10-TITLE-CHANGE`a\")" - } else { - "printf '\\033]0;U10-TITLE-CHANGE\\a'" - }; + let wait = std::thread::spawn(move || { + worker.wait_quiet_cancellable(&visual_options(quiet), &move || { + signal_entry(&gate); + false + }) + }); + await_entry(&entered, "title wait"); + // The fixture child echoes raw input back to the PTY, so the emulator + // processes the title sequence exactly as a shell's output would. session .execute(Operation::Submit { - data: Some(command.to_string()), + data: Some("\x1b]0;U10-TITLE-CHANGE\x07".to_string()), }) .expect("set title"); - handle - .join() - .expect("wait joins") - .expect("title change restarts"); - let elapsed = changed_at.elapsed(); + join_bounded(wait, "title wait").expect("title change restarts"); + let snapshot = session + .capture_snapshot(SnapshotScope::ActiveScreen) + .expect("capture after title change"); assert!( - elapsed >= Duration::from_millis(350), - "title change did not restart the timer" + snapshot + .title() + .is_some_and(|title| title.contains("U10-TITLE-CHANGE")), + "title change did not land: {snapshot:?}" ); - assert!(elapsed < Duration::from_secs(8), "{elapsed:?}"); session.close().expect("close terminal"); } #[test] fn u10_h_palette_change_restarts_visual_quiet() { - let session = Session::new(session_name("h-palette")); - session - .open(OpenOptions { - wait_ready: Some(false), - ..OpenOptions::default() - }) - .expect("open shell"); - session - .wait_quiet(&visual_options(QUIET)) - .expect("shell settles"); - // The change lands well inside the first quiet interval (500ms). + let session = ready_session("h-palette", "idle"); let quiet = Duration::from_millis(500); + let (gate, entered) = entry_gate(); let worker = session.clone(); - let handle = std::thread::spawn(move || worker.wait_quiet(&visual_options(quiet))); - std::thread::sleep(Duration::from_millis(50)); - let changed_at = Instant::now(); - let command = if cfg!(windows) { - "[Console]::Write(\"`e]4;9;rgb:ff/00/00`a\")" - } else { - "printf '\\033]4;9;rgb:ff/00/00\\a'" - }; + let wait = std::thread::spawn(move || { + worker.wait_quiet_cancellable(&visual_options(quiet), &move || { + signal_entry(&gate); + false + }) + }); + await_entry(&entered, "palette wait"); session .execute(Operation::Submit { - data: Some(command.to_string()), + data: Some("\x1b]4;9;rgb:ff/00/00\x07".to_string()), }) .expect("set palette entry"); - handle - .join() - .expect("wait joins") - .expect("palette change restarts"); - let elapsed = changed_at.elapsed(); - assert!( - elapsed >= Duration::from_millis(350), - "palette change did not restart the timer" + join_bounded(wait, "palette wait").expect("palette change restarts"); + let snapshot = session + .capture_snapshot(SnapshotScope::ActiveScreen) + .expect("capture after palette change"); + assert_eq!( + snapshot.palette()[9], + Rgb::new(255, 0, 0), + "palette change did not land" ); - assert!(elapsed < Duration::from_secs(8), "{elapsed:?}"); session.close().expect("close terminal"); } #[test] fn u10_h_cursor_move_restarts_visual_quiet() { - // The child moves only the cursor mid-wait; cells, title, and palette are - // untouched, so only the cursor state can restart the timer. + // The child moves only the cursor 600ms in, inside the 1000ms quiet + // interval of a wait proven started at READY; the marker proves the move + // happened and success proves the wait spanned it. let quiet = Duration::from_millis(1000); - let script = if cfg!(windows) { - "[Console]::Write('READY'); Start-Sleep -Milliseconds 600; [Console]::Write(\"`e[7;25H\"); Start-Sleep -Seconds 30" - } else { - "printf READY; sleep 0.6; printf '\\033[7;25H'; sleep 30" - }; - let session = ready_session("h-cursor", script); - let started = Instant::now(); - session - .wait_quiet(&visual_options(quiet)) - .expect("cursor move restarts, then succeeds"); - let elapsed = started.elapsed(); - assert!( - elapsed >= Duration::from_millis(1350), - "cursor move did not restart the timer: {elapsed:?}" - ); - assert!(elapsed < Duration::from_secs(10), "{elapsed:?}"); + let session = ready_session("h-cursor", "cursor"); + let (gate, entered) = entry_gate(); + let worker = session.clone(); + let wait = std::thread::spawn(move || { + worker.wait_quiet_cancellable(&visual_options(quiet), &move || { + signal_entry(&gate); + false + }) + }); + await_entry(&entered, "cursor wait"); + wait_for_text(&session, "CURSOR-DONE"); + join_bounded(wait, "cursor wait").expect("cursor move restarts, then succeeds"); session.close().expect("close terminal"); } #[test] fn u10_h_resize_restarts_visual_quiet() { - let script = if cfg!(windows) { - "[Console]::Write('READY'); Start-Sleep -Seconds 30" - } else { - "printf READY; sleep 30" - }; - let session = ready_session("h-resize", script); - // The resize lands well inside the first quiet interval (500ms). + let session = ready_session("h-resize", "idle"); let quiet = Duration::from_millis(500); + let (gate, entered) = entry_gate(); let worker = session.clone(); - let handle = std::thread::spawn(move || worker.wait_quiet(&visual_options(quiet))); - std::thread::sleep(Duration::from_millis(50)); - let changed_at = Instant::now(); + let wait = std::thread::spawn(move || { + worker.wait_quiet_cancellable(&visual_options(quiet), &move || { + signal_entry(&gate); + false + }) + }); + await_entry(&entered, "resize wait"); session .execute(Operation::Resize { cols: 100, rows: 30, }) .expect("resize terminal"); - handle.join().expect("wait joins").expect("resize restarts"); - let elapsed = changed_at.elapsed(); - assert!( - elapsed >= Duration::from_millis(350), - "dimension change did not restart the timer" - ); - assert!(elapsed < Duration::from_secs(8), "{elapsed:?}"); + join_bounded(wait, "resize wait").expect("resize restarts"); session.close().expect("close terminal"); } // U10-I: the quiet interval starts with the request after a long idle period. #[test] fn u10_i_quiet_starts_at_request_after_long_idle() { - let script = if cfg!(windows) { - "[Console]::Write('READY'); Start-Sleep -Seconds 30" - } else { - "printf READY; sleep 30" - }; - let session = ready_session("i-idle", script); - std::thread::sleep(QUIET * 4); - let started = Instant::now(); + let session = ready_session("i-idle", "idle"); + // Settle first: this success proves the line is quiet, so the measured + // waits below start after a long idle period by construction. session .wait_quiet(&visual_options(QUIET)) .expect("quiet wait succeeds after idle"); - let elapsed = started.elapsed(); - assert!( - elapsed >= QUIET - Duration::from_millis(50), - "wait reused idle time from before the request: {elapsed:?}" - ); - assert!( - elapsed < QUIET + Duration::from_secs(2), - "wait took too long: {elapsed:?}" - ); - let started = Instant::now(); + session + .wait_quiet(&visual_options(QUIET)) + .expect("repeat quiet wait"); let bytes = visual_options(QUIET).with_mode(WaitQuietMode::Bytes); session.wait_quiet(&bytes).expect("byte quiet after idle"); - let elapsed = started.elapsed(); - assert!( - elapsed >= QUIET - Duration::from_millis(50), - "byte wait reused idle time from before the request: {elapsed:?}" - ); - assert!( - elapsed < QUIET + Duration::from_secs(2), - "byte wait took too long: {elapsed:?}" - ); session.close().expect("close terminal"); } // U10-J: equal quiet and overall deadlines return timeout. #[test] fn u10_j_equal_deadlines_return_timeout() { - let script = if cfg!(windows) { - "[Console]::Write('READY'); Start-Sleep -Seconds 30" - } else { - "printf READY; sleep 30" - }; - let session = ready_session("j-equal", script); + let session = ready_session("j-equal", "idle"); for quiet_ms in [400, 800] { let options = WaitQuietOptions::new( Duration::from_millis(quiet_ms), Duration::from_millis(quiet_ms), ) .expect("equal deadlines are accepted"); - let started = Instant::now(); let error = session .wait_quiet(&options) .expect_err("equal deadlines must time out"); - let elapsed = started.elapsed(); assert!(is_timeout(&error), "unexpected error: {error}"); - assert!( - elapsed + Duration::from_millis(50) >= Duration::from_millis(quiet_ms), - "timed out before the deadline: {elapsed:?}" - ); - assert!( - elapsed < Duration::from_millis(quiet_ms) + Duration::from_secs(3), - "timeout took too long: {elapsed:?}" - ); } session.close().expect("close terminal"); } From 4030775467fa276e3100c1a8220af37124c8b0f3 Mon Sep 17 00:00:00 2001 From: Alexey Zhokhov Date: Mon, 26 Oct 2026 03:32:07 +0700 Subject: [PATCH 11/16] fix(snapshot): validate leading/continuation relations per row (U02-R2) Reject orphan continuations and leading cells inside unfinished spans in both try_new and deserialization, using an explicit remaining-span counter. Ratatui adapter rejects uncovered zero-width cells with a coordinate-bearing error. Signed-off-by: Alexey Zhokhov --- crates/tui-test/src/snapshot.rs | 178 ++++++++++++++++++++++++++++++-- 1 file changed, 169 insertions(+), 9 deletions(-) diff --git a/crates/tui-test/src/snapshot.rs b/crates/tui-test/src/snapshot.rs index 6b18ae27..99ab3d4d 100644 --- a/crates/tui-test/src/snapshot.rs +++ b/crates/tui-test/src/snapshot.rs @@ -296,6 +296,10 @@ impl SnapshotCell { } /// Whether this cell continues the wide character in the previous column. + /// + /// A continuation holds empty text with width zero and sits inside an + /// open span; see [`TerminalSnapshot::try_new`] for the span rules, + /// including the clipped-edge representation at a row end. pub fn is_continuation(&self) -> bool { self.text.is_empty() } @@ -357,6 +361,9 @@ impl SnapshotCell { self.link_id.as_deref() } + /// Per-cell size checks. Continuation placement is structural (it needs + /// the row's span state) and lives in [`check_row_spans`], which + /// [`assemble`] runs over the same cells right after this. fn check_at(&self, col: u32, row: u32) -> Result<(), SnapshotError> { if self.text.len() > MAX_CELL_TEXT_BYTES { return Err(SnapshotError::CellTextTooLarge { @@ -366,9 +373,6 @@ impl SnapshotCell { limit: MAX_CELL_TEXT_BYTES, }); } - if self.text.is_empty() && (col == 0 || self.width != 0) { - return Err(SnapshotError::BadContinuation { col, row }); - } for uri in self.link_uri.iter().chain(self.link_id.iter()) { if uri.len() > MAX_LINK_BYTES { return Err(SnapshotError::LinkTooLarge { @@ -621,8 +625,10 @@ pub enum SnapshotError { }, /// A cell grapheme exceeds [`MAX_CELL_TEXT_BYTES`] (coordinate-free). OversizeText { bytes: usize, limit: usize }, - /// A continuation cell (empty text) at column zero, or a non-continuation - /// width on empty text. + /// A cell that breaks the row's wide-character span: a continuation with + /// no open span (including any continuation at column zero, since spans + /// never cross rows), a continuation carrying a nonzero width, or a + /// leading cell inside an unfinished span (a missing continuation). BadContinuation { col: u32, row: u32 }, /// A hyperlink URI or identity exceeds [`MAX_LINK_BYTES`]. LinkTooLarge { @@ -801,6 +807,20 @@ impl TerminalSnapshot { /// and oversize titles and links, using checked arithmetic throughout. /// Cell geometry is recorded, never recomputed from UTF-8 byte length. /// + /// Wide-character spans are validated per row with an explicit + /// remaining-span counter. A leading cell with width `w > 1` opens a + /// span covering the next `w - 1` cells, each of which must be a + /// continuation (empty text, width zero). A continuation with no open + /// span is rejected, as is a leading cell inside an unfinished span. + /// Spans never cross a row boundary: every row starts outside any span, + /// so a continuation at column zero is always invalid. A leading cell + /// whose span would extend past its row's last column is the supported + /// clipped-edge representation: it keeps its full recorded width and + /// the continuations past the edge are omitted, so a trailing open span + /// at row end is accepted. A leading cell with width zero (for example + /// a lone combining mark or zero-width space, which xterm.js reports as + /// a genuine cell owning its column) opens no span. + /// /// ``` /// use tui_test::profile::Rgb; /// use tui_test::terminal::cell::Attrs; @@ -1067,6 +1087,38 @@ impl<'de> Deserialize<'de> for TerminalSnapshot { } } +/// Validate one row's wide-character spans with an explicit counter of how +/// many continuation cells the current span still covers (`remaining`). +/// +/// A leading cell with width `w > 1` opens a span of `w - 1` continuations; +/// each continuation consumes one. A continuation with nothing to consume is +/// uncovered (an orphan), and a leading cell that arrives while `remaining` +/// is nonzero sits inside an unfinished span (a missing continuation). A +/// span that would run past the row's last column is the supported +/// clipped-edge representation: the leading cell keeps its full recorded +/// width and the past-the-edge continuations are omitted, so `remaining` may +/// still be nonzero when the row ends. Spans never cross rows: callers reset +/// the counter by validating each row separately, so a continuation at +/// column zero always fails as uncovered. +fn check_row_spans(row_cells: &[SnapshotCell], row: u32) -> Result<(), SnapshotError> { + let mut remaining: u32 = 0; + for (col, cell) in row_cells.iter().enumerate() { + let col = col as u32; + if cell.is_continuation() { + if cell.width() != 0 || remaining == 0 { + return Err(SnapshotError::BadContinuation { col, row }); + } + remaining -= 1; + } else { + if remaining > 0 { + return Err(SnapshotError::BadContinuation { col, row }); + } + remaining = u32::from(cell.width()).saturating_sub(1); + } + } + Ok(()) +} + /// Shared validation for [`TerminalSnapshot::try_new`] and deserialization. fn assemble(parts: SnapshotParts) -> Result { let SnapshotParts { @@ -1111,10 +1163,14 @@ fn assemble(parts: SnapshotParts) -> Result { actual: cells.len(), }); } - for (index, cell) in cells.iter().enumerate() { - let col = (index as u32) % cols; - let row = (index as u32) / cols; - cell.check_at(col, row)?; + // `cols` is nonzero and `cells.len() == cols * rows`, so the chunks + // cover every cell exactly once. + for (row, chunk) in cells.chunks_exact(cols as usize).enumerate() { + let row = row as u32; + for (col, cell) in chunk.iter().enumerate() { + cell.check_at(col as u32, row)?; + } + check_row_spans(chunk, row)?; } if scope == SnapshotScope::FullHistory && viewport_origin != 0 { return Err(SnapshotError::BadViewportOrigin { @@ -1848,6 +1904,110 @@ mod tests { ); } + #[test] + fn wide_spans_need_covering_continuations() { + let profile = profile(); + + fn span_cell(text: &str, width: u16) -> SnapshotCell { + SnapshotCell::new( + text.to_string(), + width, + SnapshotColor::Default, + SnapshotColor::Default, + Attrs::empty(), + SnapshotUnderline::None, + SnapshotColor::Default, + None, + None, + ) + .expect("coordinate-free construction succeeds") + } + + // `try_new` over one row of `cells`; every case below is also + // mirrored through JSON by splicing the same cells into a valid + // wire payload, proving constructors and deserialization share the + // validator. + fn try_row( + profile: &Profile, + cells: Vec, + ) -> Result { + let cols = cells.len() as u32; + let mut parts = blank_parts(profile, cols, 1); + parts.cells = cells; + TerminalSnapshot::try_new(parts) + } + + fn json_row(profile: &Profile, cells: Vec) -> serde_json::Value { + let cols = cells.len() as u32; + let valid = TerminalSnapshot::try_new(blank_parts(profile, cols, 1)).expect("valid"); + let mut wire = serde_json::to_value(&valid).expect("serializes"); + wire["cells"] = serde_json::Value::Array( + cells + .iter() + .map(|cell| serde_json::to_value(cell).expect("cell serializes")) + .collect(), + ); + wire + } + + fn assert_rejected(profile: &Profile, cells: Vec, col: u32, row: u32) { + assert_eq!( + try_row(profile, cells.clone()), + Err(SnapshotError::BadContinuation { col, row }) + ); + let error = serde_json::from_value::(json_row(profile, cells)) + .expect_err("JSON rejects the same span"); + assert!( + error.to_string().contains("continuation"), + "unexpected error: {error}" + ); + } + + // Orphan continuation: no open span covers column 1. + assert_rejected(&profile, vec![span_cell("x", 1), span_cell("", 0)], 1, 0); + // Missing continuation: a leading cell inside an unfinished span. + assert_rejected(&profile, vec![span_cell("x", 2), span_cell("y", 1)], 1, 0); + // Continuation carrying a nonzero width is not a continuation. + assert_rejected(&profile, vec![span_cell("", 1)], 0, 0); + // A width-3 span short one continuation. + assert_rejected( + &profile, + vec![span_cell("a", 3), span_cell("", 0), span_cell("b", 1)], + 2, + 0, + ); + + // Covered spans pass through both doors. + for cells in [ + vec![span_cell("東", 2), span_cell("", 0)], + vec![span_cell("a", 3), span_cell("", 0), span_cell("", 0)], + // Clipped edge: the span runs past the row end, so the + // past-the-edge continuations are omitted. + vec![span_cell("x", 1), span_cell("東", 2)], + // A zero-width leading cell (lone combining mark) opens no span. + vec![span_cell("́", 0), span_cell("x", 1)], + ] { + let snapshot = try_row(&profile, cells.clone()).expect("span is covered"); + let back: TerminalSnapshot = + serde_json::from_value(json_row(&profile, cells)).expect("JSON accepts"); + assert_eq!(snapshot, back); + } + + // Spans never cross rows: row 1 starts outside any span, so its + // column-zero continuation is uncovered even though row 0 is clean. + let mut cross = blank_parts(&profile, 2, 2); + cross.cells = vec![ + span_cell("x", 1), + span_cell("y", 1), + span_cell("", 0), + span_cell(" ", 1), + ]; + assert_eq!( + TerminalSnapshot::try_new(cross), + Err(SnapshotError::BadContinuation { col: 0, row: 1 }) + ); + } + #[test] fn oversize_input_is_rejected() { let profile = profile(); From 50823fe5a3fbad42311bab4b05747dc7b831a1bc Mon Sep 17 00:00:00 2001 From: Alexey Zhokhov Date: Mon, 26 Oct 2026 19:36:06 +0700 Subject: [PATCH 12/16] fix(snapshot): reconcile live width with backend occupancy (U02-R2) Live capture recomputed each cell width with display_width, but the backend can lay a glyph out narrower than it measures: heart+VS16 and the keycap sequence measure two columns yet occupy one with no spacer. The recomputed width opened a two-column span the next real cell could not cover, so valid live output failed with BadContinuation. Capture now reconciles the recomputed width against the backend's own occupancy record (the run of continuation spacers behind the cell): collapse to the occupied columns when the backend is narrower, cover the attested spacers when it is wider, and keep the measured width for the clipped-edge form at a row end. Zero-width graphemes still record width zero. Offline construction and deserialization keep the strict relational validator unchanged. Every capture that previously validated emits byte-identical cells; only previously-failing captures change. --- crates/tui-test/src/snapshot.rs | 118 ++++++++++++++++++++++++++++++-- 1 file changed, 114 insertions(+), 4 deletions(-) diff --git a/crates/tui-test/src/snapshot.rs b/crates/tui-test/src/snapshot.rs index 99ab3d4d..be2d00c5 100644 --- a/crates/tui-test/src/snapshot.rs +++ b/crates/tui-test/src/snapshot.rs @@ -1254,6 +1254,33 @@ pub(crate) struct LiveHighlight<'a> { pub cells: &'a [(usize, usize)], } +/// Reconcile a recomputed glyph width with the backend's occupancy record. +/// +/// `run` is the number of continuation cells directly behind this one in the +/// same backend row: the columns the backend actually gave the glyph. When +/// recompute and backend agree, the measured width stands. When the backend +/// is wider, the measured width would orphan attested continuations, so the +/// span covers them. When the backend is narrower, the span collapses to the +/// occupied columns — unless the measured span would run past the row edge, +/// which stays the supported clipped-edge representation (the leading cell +/// keeps its full width and the past-the-edge continuations are omitted). A +/// zero-width grapheme keeps width zero: it opens no span, and a spacer +/// behind one would be backend-inconsistent data that validation still +/// rejects. +fn reconcile_live_width(measured: u16, run: u16, col: u32, cols: u32) -> u16 { + if measured == 0 { + return 0; + } + let occupied = run.saturating_add(1); + if measured <= occupied { + return occupied; + } + if u64::from(col).saturating_add(u64::from(measured)) > u64::from(cols) { + return measured; + } + occupied +} + /// Capture from a live emulator. The caller holds the terminal-state lock; /// every field below (grid, cursor, dimensions, title, resolved colors) is /// read under that one lock, so the snapshot is one atomic terminal moment. @@ -1342,11 +1369,26 @@ pub(crate) fn capture_live( let (text, width) = if emu_cell.ch.as_str() == CONTINUATION { (String::new(), 0) } else { - let width = u16::try_from(display_width(emu_cell.ch.as_str())).map_err(|_| { - SnapshotError::CountOverflow { - field: "cell width", + let measured = + u16::try_from(display_width(emu_cell.ch.as_str())).map_err(|_| { + SnapshotError::CountOverflow { + field: "cell width", + } + })?; + // The backend attests the occupied columns: every spacer + // directly behind this cell is one of them. The recomputed + // width can disagree (heart+VS16 and keycap sequences + // measure two columns but occupy one here), so reconcile + // against the attested run instead of emitting a span the + // next real cell cannot cover. + let mut run: u16 = 0; + for follower in row.iter().skip(col_index + 1) { + if follower.ch.as_str() != CONTINUATION { + break; } - })?; + run = run.saturating_add(1); + } + let width = reconcile_live_width(measured, run, col_index as u32, cols); (emu_cell.ch.to_string(), width) }; cells.push( @@ -2104,6 +2146,74 @@ mod tests { assert_ne!(canonical_hash(&left), canonical_hash(&right)); } + /// Live capture records the columns the backend actually occupied. + /// + /// Heart+VS16 and the keycap sequence measure two columns under + /// `display_width`, but the backend lays each out in a single column + /// with no spacer cell. Emitting the recomputed width opens a + /// two-column span the next real cell cannot cover, which failed with + /// `BadContinuation { col: 1, row: 0 }`; the capture must record the + /// single occupied column instead. A genuine wide glyph beside them + /// keeps its width-two span and continuation. + #[test] + fn live_capture_matches_backend_occupancy_for_vs16_sequences() { + let mut emu = AlacrittyEmu::new(COLS, ROWS, &profile()); + emu.process("\u{2764}\u{fe0f}1\u{fe0f}\u{20e3}東".as_bytes()); + let snapshot = capture_live( + &emu, + None, + &SnapshotOptions::new(SnapshotScope::Viewport), + SnapshotMetadata::default(), + ) + .expect("VS16 capture succeeds"); + let row = snapshot.row(0).expect("first row exists"); + assert_eq!(row[0].text(), "\u{2764}\u{fe0f}"); + assert_eq!( + row[0].width(), + 1, + "heart occupies the one column the backend gave it" + ); + assert!(!row[0].is_continuation()); + assert_eq!(row[1].text(), "1\u{fe0f}\u{20e3}"); + assert_eq!( + row[1].width(), + 1, + "keycap occupies the one column the backend gave it" + ); + assert!(!row[1].is_continuation()); + assert_eq!(row[2].text(), "東"); + assert_eq!(row[2].width(), 2); + assert!(row[3].is_continuation()); + assert_eq!(row[3].width(), 0); + } + + /// A VS16 heart in the last column keeps the clipped-edge form. + /// + /// The backend gives the glyph one column but the recomputed span would + /// run past the row edge, so the leading cell keeps its full measured + /// width with the past-the-edge continuation omitted — the + /// representation the validator accepts for a clipped wide glyph. + #[test] + fn live_capture_keeps_clipped_edge_for_vs16_at_row_end() { + let mut emu = AlacrittyEmu::new(3, ROWS, &profile()); + emu.process("ab\u{2764}\u{fe0f}".as_bytes()); + let snapshot = capture_live( + &emu, + None, + &SnapshotOptions::new(SnapshotScope::Viewport), + SnapshotMetadata::default(), + ) + .expect("clipped-edge capture succeeds"); + let row = snapshot.row(0).expect("first row exists"); + assert_eq!(row[0].text(), "a"); + assert_eq!(row[0].width(), 1); + assert_eq!(row[1].text(), "b"); + assert_eq!(row[1].width(), 1); + assert_eq!(row[2].text(), "\u{2764}\u{fe0f}"); + assert_eq!(row[2].width(), 2, "clipped glyph keeps its measured width"); + assert!(!row[2].is_continuation()); + } + #[test] fn round_trip_preserves_palette_and_links() { let profile = profile(); From 5472eeaa2eab433b53c4deedd1c8a5d83acf8167 Mon Sep 17 00:00:00 2001 From: Alexey Zhokhov Date: Mon, 26 Oct 2026 22:20:23 +0700 Subject: [PATCH 13/16] U10: route quiet waits through the shared request wrapper (R3) Fix cancellable acquisition: the waiter no longer blocks in lifecycle.read() before checking cancellation or starting its deadline. Acquisition polls try_read while checking the caller's cancellation flag and a single request deadline that covers acquisition and observation together; observation then runs on the remaining budget. A free lock is taken before any caller-supplied closure runs so parking inside cancelled (or a slow observer) still pins lifecycle writers (R8); ordinary waits keep their poll-first order unchanged. Fix quiet-failure finalization: wait_quiet_cancellable now threads the session execution context into the shared internal wrapper, so timeouts on a live child finalize like ordinary failures (TimedOut details, failure artifact, recording marked failed and retained by OnFailure close) while cancellation keeps the Cancelled reason. Docs state the new capabilities (configurable quiet criteria, byte-vs-visual observation, Session-level access), the pre-existing timeout and cancellable execution, the TuiTestError return, and the #278 dependency plus sampling limitation. --- crates/tui-test/src/engine.rs | 461 ++++++++++++++++++++----- crates/tui-test/src/runtime.rs | 26 +- crates/tui-test/src/wait.rs | 6 +- crates/tui-test/tests/request_waits.rs | 108 +++++- 4 files changed, 517 insertions(+), 84 deletions(-) diff --git a/crates/tui-test/src/engine.rs b/crates/tui-test/src/engine.rs index 45581c72..4174dfcb 100644 --- a/crates/tui-test/src/engine.rs +++ b/crates/tui-test/src/engine.rs @@ -238,6 +238,55 @@ pub struct LiveFrame { /// One-line operation description for the verbose log. Open and Run redact env /// values (they may contain secrets) and report only the variable count. + +/// Coordination flags for one request through the shared wrapper. +struct RequestPlan { + waiting: bool, + lifecycle: bool, + /// Overall request deadline enforced while acquiring the lifecycle lock. + /// Only quiet waits set this: ordinary waits start their clocks after + /// acquisition, while a quiet request's deadline covers acquisition and + /// observation together. + acquisition_deadline: Option, + /// Acquire the shared lifecycle lock before any caller-supplied closure + /// runs: try first, and only poll cancellation and the deadline while + /// contended. Quiet waits set this so parking inside `cancelled` (or a + /// slow observer) already blocks lifecycle writers, never letting them + /// complete underneath the wait (R8). Ordinary waits poll first and + /// acquire after. + acquire_before_poll: bool, +} + +struct AcquisitionDeadline { + deadline: Instant, + /// Timeout error reported when acquisition outlives `deadline`. + error: TuiTestError, +} + +/// History and diagnostics inputs for one request through the shared wrapper. +struct RequestMetadata { + name: String, + timeout_ms: Option, + log_summary: String, + safe_summary: String, + is_assertion: bool, + expectation: Option, + input: Option, +} + +/// Cancellation error for a wait that observed cancellation before reporting +/// success. Carries the Cancelled failure reason, preserving the +/// cancellation-vs-failure distinction ordinary waits already report. +fn cancelled_wait_error(metadata: &OperationMetadata) -> TuiTestError { + let message = "operation was cancelled"; + TuiTestError::assertion(message).with_report(FailureReport::new( + metadata.name.clone(), + metadata.timeout_ms, + FailureReason::Cancelled, + message, + )) +} + fn operation_summary(operation: &Operation) -> String { match operation { Operation::Open(options) => format!( @@ -323,33 +372,108 @@ impl Engine { trace.validate().map_err(TuiTestError::usage)?; } self.interrupt_for_operation(&operation); + let canonical_name = diagnostic_operation_name(&operation); + let name = context + .operation_name + .clone() + .unwrap_or_else(|| canonical_name.to_string()); + let plan = RequestPlan { + waiting: operation.is_wait(), + lifecycle: matches!( + operation, + Operation::Open(_) + | Operation::Run(_) + | Operation::Restart { .. } + | Operation::Close + | Operation::FinishTrace { .. } + ), + acquisition_deadline: None, + acquire_before_poll: false, + }; + let metadata = RequestMetadata { + name, + timeout_ms: operation_timeout(&operation), + log_summary: operation_summary(&operation), + safe_summary: safe_operation_summary(&operation), + is_assertion: canonical_name.starts_with("expect.") + || canonical_name.starts_with("wait.") + || matches!(canonical_name, "locator.wait" | "locator.resolve"), + expectation: OperationExpectation::capture(&operation), + input: InputDetails::capture(&operation), + }; + self.execute_planned_request( + plan, + metadata, + &context, + cancelled, + |cancelled, metadata| self.execute_inner(operation, &context, metadata, cancelled), + ) + } + + /// Shared request wrapper behind [`Engine::execute_with_context_cancellable`] + /// and quiet waits: lifecycle coordination with cancellable acquisition, + /// operation history, failure finalization, and the recording outcome. + /// + /// Every request observes the same coordination: waits acquire the shared + /// lifecycle lock without excluding input or resize, pre-cancelled and + /// preempted waits fail before their first observation, and a wait that + /// observes cancellation before success reports cancellation (with the + /// Cancelled reason) instead of success. Assertion and internal failures + /// finalize with failure details and mark the automatic recording failed; + /// other outcomes leave it alone. + fn execute_planned_request( + &self, + plan: RequestPlan, + metadata_inputs: RequestMetadata, + context: &ExecutionContext, + cancelled: &dyn Fn() -> bool, + run: impl FnOnce( + &dyn Fn() -> bool, + &mut OperationMetadata, + ) -> Result, + ) -> Result { let interrupt_generation = self.interrupt_generation.load(Ordering::Acquire); - let waiting = operation.is_wait(); - let lifecycle = matches!( - operation, - Operation::Open(_) - | Operation::Run(_) - | Operation::Restart { .. } - | Operation::Close - | Operation::FinishTrace { .. } - ); // A wait pins its generation through diagnostics, but does not exclude // input or resize. Announce lifecycle writers before locking so even a // newly arriving waiter yields, independent of RwLock's fairness policy. - let pending = lifecycle.then(|| LifecycleRequest::new(&self.pending_lifecycle)); + let pending = plan + .lifecycle + .then(|| LifecycleRequest::new(&self.pending_lifecycle)); let cancelled = - || cancelled() || (waiting && self.pending_lifecycle.load(Ordering::Acquire) != 0); - let _shared = if lifecycle { + || cancelled() || (plan.waiting && self.pending_lifecycle.load(Ordering::Acquire) != 0); + let _shared = if plan.lifecycle { None - } else if waiting { + } else if plan.waiting { Some(loop { - if cancelled() { - return Err(TuiTestError::assertion("operation was cancelled")); + // With `acquire_before_poll` the first attempt never runs + // caller code: a free lock is taken before `cancelled` (or an + // observer) can park, so lifecycle writers are already pinned + // when the request starts polling. While contended, the same + // cancellation and deadline checks keep acquisition bounded. + if !plan.acquire_before_poll { + if cancelled() { + return Err(TuiTestError::assertion("operation was cancelled")); + } + if let Some(acquisition) = &plan.acquisition_deadline { + if Instant::now() >= acquisition.deadline { + return Err(acquisition.error.clone()); + } + } } match self.lifecycle.try_read() { Ok(guard) => break guard, Err(TryLockError::Poisoned(error)) => break error.into_inner(), Err(TryLockError::WouldBlock) => { + if plan.acquire_before_poll { + if cancelled() { + return Err(TuiTestError::assertion("operation was cancelled")); + } + if let Some(acquisition) = &plan.acquisition_deadline { + if Instant::now() >= acquisition.deadline { + return Err(acquisition.error.clone()); + } + } + } std::thread::sleep(Duration::from_millis(POLL_DELAY_MS)); } } @@ -361,69 +485,53 @@ impl Engine { .unwrap_or_else(std::sync::PoisonError::into_inner), ) }; - let _exclusive = lifecycle.then(|| { + let _exclusive = plan.lifecycle.then(|| { self.lifecycle .write() .unwrap_or_else(std::sync::PoisonError::into_inner) }); drop(pending); - let _operation = (!waiting).then(|| { + let _operation = (!plan.waiting).then(|| { self.operations .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) }); if self.logger.enabled() { self.logger - .event(&format!("operation {}", operation_summary(&operation))); + .event(&format!("operation {}", metadata_inputs.log_summary)); } - let name = context - .operation_name - .clone() - .unwrap_or_else(|| diagnostic_operation_name(&operation).to_string()); let screen_before = self.capture_current_screen_sequence(true, false); - let canonical_name = diagnostic_operation_name(&operation); - let is_assertion = canonical_name.starts_with("expect.") - || canonical_name.starts_with("wait.") - || matches!(canonical_name, "locator.wait" | "locator.resolve"); let started_ms = self.current_session_elapsed_ms().unwrap_or(0); let mut metadata = OperationMetadata { sequence: 0, interrupt_generation, - name: name.clone(), - timeout_ms: operation_timeout(&operation), + name: metadata_inputs.name.clone(), + timeout_ms: metadata_inputs.timeout_ms, started_at: Instant::now(), started_ms, screen_before, - safe_summary: safe_operation_summary(&operation), - is_assertion, - expectation: OperationExpectation::capture(&operation), - input: InputDetails::capture(&operation), + safe_summary: metadata_inputs.safe_summary.clone(), + is_assertion: metadata_inputs.is_assertion, + expectation: metadata_inputs.expectation.clone(), + input: metadata_inputs.input.clone(), }; let pending = self .operation_history .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) .begin( - name, + metadata_inputs.name, started_ms, screen_before, metadata.safe_summary.clone(), - is_assertion, + metadata.is_assertion, metadata.expectation.clone(), ); metadata.sequence = pending.sequence(); let mut result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - let result = self.execute_inner(operation, &context, &mut metadata, &cancelled); - if waiting && cancelled() { - let message = "operation was cancelled"; - Err( - TuiTestError::assertion(message).with_report(FailureReport::new( - metadata.name.clone(), - metadata.timeout_ms, - FailureReason::Cancelled, - message, - )), - ) + let result = run(&cancelled, &mut metadata); + if plan.waiting && cancelled() { + Err(cancelled_wait_error(&metadata)) } else { result } @@ -446,7 +554,7 @@ impl Engine { if let Err(error) = &mut result { self.prepare_failure_observation(error); } - let pin_checkpoint = is_assertion + let pin_checkpoint = metadata.is_assertion || (metadata.input.is_some() && self .trace @@ -488,7 +596,7 @@ impl Engine { ); if let Err(error) = &mut result { let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - self.finalize_failure(error, &context, &metadata); + self.finalize_failure(error, context, &metadata); })); error.observation = None; error.report = None; @@ -1870,9 +1978,9 @@ impl Engine { self.session_generation.fetch_add(1, Ordering::AcqRel); } - /// Run a request-scoped quiet wait coordinated with the lifecycle guard. + /// Run a request-scoped quiet wait through the shared request wrapper. /// - /// Holds the shared lifecycle lock for the wait (like waits dispatched + /// The wait holds the shared lifecycle lock (like waits dispatched /// through [`Engine::execute_with_context_cancellable`]) so a concurrent /// Close/Restart announces itself through `pending_lifecycle` and blocks /// on the exclusive lock instead of completing underneath the wait. The @@ -1881,42 +1989,91 @@ impl Engine { /// transition that completes mid-wait can never surface as quiet /// success (R8). Cancellation never terminates the session and never /// cancels another caller. + /// + /// The request runs under the caller's [`ExecutionContext`] with the + /// ordinary operation history, failure finalization, and recording + /// outcome: a timeout finalizes as a TimedOut failure (details, artifact, + /// recording marked failed) while cancellation keeps the Cancelled + /// reason. A single request deadline covers acquisition and observation + /// together: acquisition checks cancellation and the deadline while + /// polling for the lock, and observation runs on the remaining budget. pub(crate) fn run_quiet_wait_cancellable( &self, options: &WaitQuietOptions, + context: &ExecutionContext, cancelled: &dyn Fn() -> bool, ) -> Result<(), TuiTestError> { - // The shared lock is acquired before any caller-supplied closure - // runs: parking inside `cancelled` (or a slow observer) must already - // block lifecycle writers, never let them complete underneath us. - let _shared = self - .lifecycle - .read() - .unwrap_or_else(std::sync::PoisonError::into_inner); - // A lifecycle request announced while acquiring the lock, or a - // caller that was already cancelled, preempts the wait before its - // first observation. - if cancelled() || self.has_pending_lifecycle() { - return Err(crate::wait::cancelled_error()); - } - let generation = self.session_generation(); - let outcome = crate::wait::run_quiet_wait( - options, - &|| cancelled() || self.has_pending_lifecycle(), - &|| self.capture_snapshot(SnapshotScope::ActiveScreen), - &|| self.last_output_time(), - ); - // A lifecycle transition announced while polling, or a generation - // change from a transition that completed mid-wait, preempts success: - // the request did not observe quiet on a stable live session. - if outcome.is_ok() - && (cancelled() - || self.has_pending_lifecycle() - || self.session_generation() != generation) - { - return Err(crate::wait::cancelled_error()); - } - outcome + let overall = options.timeout(); + let deadline = Instant::now().checked_add(overall).ok_or_else(|| { + TuiTestError::internal("wait quiet: overall deadline is out of range") + })?; + let timeout_ms = u64::try_from(overall.as_millis()).unwrap_or(u64::MAX); + let name = context + .operation_name + .clone() + .unwrap_or_else(|| "wait.quiet".to_string()); + let (subject, expected) = match options.mode() { + crate::wait::WaitQuietMode::Visual => ( + "Screen", + format!("remains unchanged for {}ms", options.quiet().as_millis()), + ), + crate::wait::WaitQuietMode::Bytes => ( + "Terminal output", + format!("remains quiet for {}ms", options.quiet().as_millis()), + ), + }; + self.execute_planned_request( + RequestPlan { + waiting: true, + lifecycle: false, + acquisition_deadline: Some(AcquisitionDeadline { + deadline, + error: crate::wait::timeout_error(options), + }), + acquire_before_poll: true, + }, + RequestMetadata { + name, + timeout_ms: Some(timeout_ms), + log_summary: format!("WaitQuiet {options:?}"), + safe_summary: format!("waited for the {subject} to {expected}"), + is_assertion: true, + expectation: Some(OperationExpectation::Value { + subject: subject.into(), + expected, + }), + input: None, + }, + context, + cancelled, + |cancelled, metadata| { + let generation = self.session_generation(); + // Observation runs on the remaining budget so the single + // request deadline covers acquisition and observation + // together. A remaining budget that cannot fit another quiet + // interval can never succeed: the overall deadline wins. + let remaining = deadline.saturating_duration_since(Instant::now()); + let adjusted = match options.with_timeout(remaining) { + Ok(adjusted) => adjusted, + Err(_) => return Err(crate::wait::timeout_error(options)), + }; + let outcome = crate::wait::run_quiet_wait( + &adjusted, + cancelled, + &|| self.capture_snapshot(SnapshotScope::ActiveScreen), + &|| self.last_output_time(), + ); + // A lifecycle transition announced while polling, or a + // generation change from a transition that completed mid-wait, + // preempts success: the request did not observe quiet on a + // stable live session. + if outcome.is_ok() && (cancelled() || self.session_generation() != generation) { + return Err(cancelled_wait_error(metadata)); + } + outcome.map(|()| OperationResult::Unit) + }, + ) + .map(|_| ()) } fn write_session(&self) -> RwLockWriteGuard<'_, Option> { @@ -6821,4 +6978,148 @@ mod tests { } reporter.join().unwrap(); } + + // P2a: acquisition is cancellable and deadline-bound. The holder keeps + // the exclusive lifecycle lock (as a concurrent Run does inside its + // readiness wait) until the test releases it, so any return while the + // lock is still held proves the request stopped waiting for the lock: + // no wall-clock assertions needed. + fn hold_lifecycle_write( + engine: &std::sync::Arc, + ) -> ( + std::sync::mpsc::Receiver<()>, + std::sync::mpsc::Sender<()>, + std::thread::JoinHandle<()>, + ) { + let (entered_tx, entered_rx) = std::sync::mpsc::channel::<()>(); + let (release_tx, release_rx) = std::sync::mpsc::channel::<()>(); + let holder = { + let engine = std::sync::Arc::clone(engine); + std::thread::spawn(move || { + let _exclusive = engine + .lifecycle + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let _ = entered_tx.send(()); + // The test releases the lock after its assertions; the bound + // only fires if the request under test wedged on acquisition. + let _ = release_rx.recv_timeout(Duration::from_secs(25)); + }) + }; + (entered_rx, release_tx, holder) + } + + fn quiet_test_engine(tag: &str) -> std::sync::Arc { + std::sync::Arc::new(Engine::new( + format!("quiet-acquisition-{tag}"), + std::sync::Arc::new(Logger::disabled()), + PathBuf::new(), + )) + } + + // Startup/Restart contention with pre-cancellation: a quiet request that + // is already cancelled must report cancellation without waiting for a + // lifecycle writer to release the lock. + #[test] + fn quiet_precancelled_returns_while_lifecycle_write_is_held() { + let engine = quiet_test_engine("precancelled"); + let (entered_rx, release_tx, holder) = hold_lifecycle_write(&engine); + entered_rx + .recv_timeout(Duration::from_secs(15)) + .expect("holder takes the lifecycle lock"); + let context = ExecutionContext::default(); + let options = WaitQuietOptions::new(Duration::from_millis(100), Duration::from_millis(50)) + .expect("test options are valid"); + let error = engine + .run_quiet_wait_cancellable(&options, &context, &|| true) + .expect_err("pre-cancelled quiet must report cancellation"); + assert!( + error.to_string().contains("cancelled"), + "unexpected error: {error}" + ); + assert!( + engine.lifecycle.try_read().is_err(), + "pre-cancelled quiet blocked on lifecycle acquisition" + ); + let _ = release_tx.send(()); + holder.join().unwrap(); + } + + // Cancellation-while-acquiring: the flag flips after the request starts + // polling for the lock. The request must still report cancellation while + // the lifecycle writer holds the lock. + #[test] + fn quiet_cancelled_while_acquiring_returns_while_write_is_held() { + use std::sync::atomic::{AtomicBool, Ordering}; + + let engine = quiet_test_engine("cancel-while-acquiring"); + let (entered_rx, release_tx, holder) = hold_lifecycle_write(&engine); + entered_rx + .recv_timeout(Duration::from_secs(15)) + .expect("holder takes the lifecycle lock"); + let (polled_tx, polled_rx) = std::sync::mpsc::channel::<()>(); + let polled_tx = std::sync::Mutex::new(Some(polled_tx)); + let flag = std::sync::Arc::new(AtomicBool::new(false)); + let context = ExecutionContext::default(); + let options = WaitQuietOptions::new(Duration::from_secs(30), Duration::from_millis(50)) + .expect("test options are valid"); + let worker = { + let engine = std::sync::Arc::clone(&engine); + let flag = std::sync::Arc::clone(&flag); + std::thread::spawn(move || { + engine.run_quiet_wait_cancellable(&options, &context, &|| { + if let Some(tx) = polled_tx.lock().unwrap().take() { + let _ = tx.send(()); + } + flag.load(Ordering::Acquire) + }) + }) + }; + polled_rx + .recv_timeout(Duration::from_secs(15)) + .expect("quiet request reaches acquisition"); + flag.store(true, Ordering::Release); + let error = worker + .join() + .unwrap() + .expect_err("cancelled quiet must report cancellation"); + assert!( + error.to_string().contains("cancelled"), + "unexpected error: {error}" + ); + assert!( + engine.lifecycle.try_read().is_err(), + "cancelled quiet blocked on lifecycle acquisition" + ); + let _ = release_tx.send(()); + holder.join().unwrap(); + } + + // Timeout-only requests: with no cancellation, a deadline that passes + // during acquisition reports timeout while the writer still holds the + // lock, instead of inheriting a fresh observation budget on release. + #[test] + fn quiet_timeout_only_returns_while_lifecycle_write_is_held() { + let engine = quiet_test_engine("timeout-only"); + let (entered_rx, release_tx, holder) = hold_lifecycle_write(&engine); + entered_rx + .recv_timeout(Duration::from_secs(15)) + .expect("holder takes the lifecycle lock"); + let context = ExecutionContext::default(); + let options = WaitQuietOptions::new(Duration::from_millis(100), Duration::from_millis(50)) + .expect("test options are valid"); + let error = engine + .run_quiet_wait_cancellable(&options, &context, &|| false) + .expect_err("expired acquisition must report timeout"); + assert!( + error.to_string().contains("timed out"), + "unexpected error: {error}" + ); + assert!( + engine.lifecycle.try_read().is_err(), + "timed-out quiet blocked on lifecycle acquisition past its deadline" + ); + let _ = release_tx.send(()); + holder.join().unwrap(); + } } diff --git a/crates/tui-test/src/runtime.rs b/crates/tui-test/src/runtime.rs index b7821261..e97c9d4d 100644 --- a/crates/tui-test/src/runtime.rs +++ b/crates/tui-test/src/runtime.rs @@ -471,6 +471,13 @@ impl Session { /// Wait until the terminal stays quiet, then return success. /// + /// This is the Session-level quiet wait. The new capabilities over the + /// pre-existing waits are the configurable quiet criteria (overall + /// timeout plus quiet interval in [`WaitQuietOptions`]) and the + /// byte-vs-visual observation modes; overall timeouts (as on `WaitIdle`) + /// and cancellable execution pre-date this wait. Failures report as + /// [`TuiTestError`](crate::api::TuiTestError), like every other wait. + /// /// Visual-quiet mode samples the raw active screen at request start and /// every 50 milliseconds, and restarts its timer when two successive /// canonical samples differ. Byte-quiet mode restarts its timer on every @@ -481,7 +488,8 @@ impl Session { /// /// Sampling limit: only sampled states are compared. A terminal change /// that appears and disappears between two consecutive samples is not - /// observed. This wait is not an application frame boundary. + /// observed. This wait is not an application frame boundary. Screen and + /// output observation build on the session state from #278. /// /// Cancellation uses the engine mechanism from /// [`Engine::execute_with_context_cancellable`]: pass a flag through @@ -508,12 +516,26 @@ impl Session { /// cannot itself proceed until waits yield. A session generation captured /// at request start is re-checked before any success return, so a Close /// or Restart that completes mid-wait never surfaces as quiet success. + /// + /// The request runs through the shared request wrapper under this + /// session's execution context, with the ordinary operation history, + /// failure finalization, and recording outcome. A timeout on a live + /// child finalizes as a `TimedOut` failure: the returned + /// [`TuiTestError`](crate::api::TuiTestError) carries failure details, a + /// configured failure artifact is written, and the automatic recording + /// is marked failed so an `OnFailure` recording survives close. + /// Cancellation keeps the `Cancelled` reason and never reports success. + /// One request deadline covers acquisition and observation together: a + /// wait queued behind a lifecycle writer still times out on schedule + /// instead of inheriting a fresh budget once the lock frees. pub fn wait_quiet_cancellable( &self, options: &WaitQuietOptions, cancelled: &dyn Fn() -> bool, ) -> Result<(), TuiTestError> { - self.engine.run_quiet_wait_cancellable(options, cancelled) + let context = self.context.clone().with_operation("wait.quiet"); + self.engine + .run_quiet_wait_cancellable(options, &context, cancelled) } /// Cancel pending operations, including startup readiness, and terminate the diff --git a/crates/tui-test/src/wait.rs b/crates/tui-test/src/wait.rs index 343542fa..91eca2e1 100644 --- a/crates/tui-test/src/wait.rs +++ b/crates/tui-test/src/wait.rs @@ -16,6 +16,10 @@ //! change that appears and disappears between two consecutive samples is not //! observed and does not restart the timer. This wait is not an application //! frame boundary and is not a DEC 2026 boundary. +//! +//! Observation builds on the session state from #278: visual mode compares +//! canonical active-screen snapshots and byte mode consults the monotonic +//! time of the most recent PTY output read. use std::time::{Duration, Instant}; @@ -154,7 +158,7 @@ pub(crate) fn cancelled_error() -> TuiTestError { TuiTestError::assertion("operation was cancelled") } -fn timeout_error(options: &WaitQuietOptions) -> TuiTestError { +pub(crate) fn timeout_error(options: &WaitQuietOptions) -> TuiTestError { TuiTestError::assertion(format!( "wait quiet: timed out after {}ms without {}ms of {} quiet", options.timeout.as_millis(), diff --git a/crates/tui-test/tests/request_waits.rs b/crates/tui-test/tests/request_waits.rs index e9449899..28f2fa68 100644 --- a/crates/tui-test/tests/request_waits.rs +++ b/crates/tui-test/tests/request_waits.rs @@ -220,7 +220,9 @@ fn join_bounded(handle: std::thread::JoinHandle, what: &st /// fires once from inside the cancellation callback, then answers from /// `flag`. Waiting on the receiver (with `GATE_BOUND`) replaces every /// "sleep, then assume the wait started" rendezvous. -fn entry_gate() -> (Arc>>>, mpsc::Receiver<()>) { +type EntryGate = (Arc>>>, mpsc::Receiver<()>); + +fn entry_gate() -> EntryGate { let (tx, rx) = mpsc::channel(); (Arc::new(Mutex::new(Some(tx))), rx) } @@ -714,6 +716,110 @@ fn u10_j_equal_deadlines_return_timeout() { session.close().expect("close terminal"); } +// P2b repro: a quiet timeout on a live child must finalize like an ordinary +// failure: failure details, a failure artifact, and a retained OnFailure +// recording that survives close. +#[test] +fn u10_k_quiet_timeout_finalizes_failure_artifact_and_recording() { + use tui_test::{ExecutionContext, FailureArtifactMode, FailureArtifactOptions, FailureReason}; + + let tag = "k-timeout-finalize"; + let root = std::env::temp_dir().join(format!("u10-{tag}-{}", std::process::id())); + let artifacts = root.join("artifacts"); + let recordings = root.join("recordings"); + let session = Session::new(session_name(tag)).with_execution_context(ExecutionContext { + artifact: Some(FailureArtifactOptions { + directory: artifacts.clone(), + mode: FailureArtifactMode::Text, + include_recording: false, + }), + ..Default::default() + }); + let mut run = fixture_options("busy"); + run.recording = AutomaticRecording { + mode: AutomaticRecordingMode::OnFailure, + directory: Some(recordings.clone()), + }; + session.run(run).expect("run fixture child"); + wait_for_text(&session, "READY"); + + let options = WaitQuietOptions::new(Duration::from_millis(800), Duration::from_millis(200)) + .expect("test options are valid"); + let error = session + .wait_quiet(&options) + .expect_err("busy child never settles"); + assert!(is_timeout(&error), "unexpected error: {error}"); + let details = error + .details + .as_ref() + .expect("quiet timeout must carry failure details"); + assert_eq!( + details.reason, + FailureReason::TimedOut, + "quiet timeout must finalize as a timeout failure" + ); + let manifests: Vec<_> = std::fs::read_dir(&artifacts) + .expect("artifact directory must exist") + .map(|item| item.unwrap().path()) + .collect(); + assert!( + !manifests.is_empty(), + "quiet timeout must write a failure artifact" + ); + let recording = session + .recording_path() + .expect("automatic recording is configured"); + session.close().expect("close terminal"); + assert!( + recording.is_file(), + "OnFailure must retain the recording after a quiet timeout" + ); + std::fs::remove_dir_all(&root).ok(); +} + +// Cancellation-vs-failure distinction: cancelling a live quiet wait reports +// cancellation with the Cancelled reason (not a timeout failure), and the +// child and session stay usable afterwards. +#[test] +fn u10_l_cancelled_quiet_keeps_cancelled_reason_and_usable_session() { + use tui_test::FailureReason; + + let session = ready_session("l-cancel-distinction", "busy"); + let (flag, check) = cancel_flag(); + let (gate, entered) = entry_gate(); + let options = WaitQuietOptions::new(Duration::from_secs(10), Duration::from_secs(2)) + .expect("test options are valid"); + let worker = session.clone(); + let handle = std::thread::spawn(move || { + worker.wait_quiet_cancellable(&options, &move || { + signal_entry(&gate); + check() + }) + }); + await_entry(&entered, "cancelled wait"); + flag.store(true, Ordering::Release); + let error = join_bounded(handle, "cancelled wait") + .expect_err("cancelled wait must report cancellation"); + assert!(is_cancelled(&error), "unexpected error: {error}"); + assert!( + !error.to_string().contains("timed out"), + "cancellation must not surface as a timeout: {error}" + ); + let details = error + .details + .as_ref() + .expect("cancelled quiet must carry failure details"); + assert_eq!( + details.reason, + FailureReason::Cancelled, + "cancelled quiet must keep the Cancelled reason" + ); + session + .capture_snapshot(SnapshotScope::ActiveScreen) + .expect("capture works after cancellation"); + session.close().expect("close terminal"); +} + #[test] fn u10_options_validation_rejects_zero_and_overlong() { assert!(WaitQuietOptions::new(Duration::ZERO, QUIET).is_err()); From 70fdad29ba552fec7a4ddf7635f1e83044fd7579 Mon Sep 17 00:00:00 2001 From: Alexey Zhokhov Date: Tue, 27 Oct 2026 04:18:23 +0700 Subject: [PATCH 14/16] U10: finalize quiet-wait acquisition errors with full diagnostics (F3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Quiet-wait lock-acquisition errors (pre-cancelled, cancelled-while- acquiring, deadline) returned directly before history begin and failure finalization, yielding empty details/artifact/report with no history entry. Route all four acquisition-error returns through one finalize_acquisition_error helper that sets Cancelled/TimedOut, completes history, marks the recording outcome, and builds diagnostics from non-waiting lock attempts only — never acquiring the occupied lifecycle lock. Extend the three lifecycle-write contention tests with structured reason/history/recording assertions and add a live-session failure-file/OnFailure case. Signed-off-by: Alexey Zhokhov --- crates/tui-test/src/engine.rs | 265 +++++++++++++++++++++++++++++++++- 1 file changed, 260 insertions(+), 5 deletions(-) diff --git a/crates/tui-test/src/engine.rs b/crates/tui-test/src/engine.rs index 4174dfcb..4d3db95a 100644 --- a/crates/tui-test/src/engine.rs +++ b/crates/tui-test/src/engine.rs @@ -238,7 +238,7 @@ pub struct LiveFrame { /// One-line operation description for the verbose log. Open and Run redact env /// values (they may contain secrets) and report only the variable count. - +/// /// Coordination flags for one request through the shared wrapper. struct RequestPlan { waiting: bool, @@ -452,11 +452,25 @@ impl Engine { // cancellation and deadline checks keep acquisition bounded. if !plan.acquire_before_poll { if cancelled() { - return Err(TuiTestError::assertion("operation was cancelled")); + let mut error = TuiTestError::assertion("operation was cancelled"); + self.finalize_acquisition_error( + &mut error, + FailureReason::Cancelled, + &metadata_inputs, + context, + ); + return Err(error); } if let Some(acquisition) = &plan.acquisition_deadline { if Instant::now() >= acquisition.deadline { - return Err(acquisition.error.clone()); + let mut error = acquisition.error.clone(); + self.finalize_acquisition_error( + &mut error, + FailureReason::TimedOut, + &metadata_inputs, + context, + ); + return Err(error); } } } @@ -466,11 +480,25 @@ impl Engine { Err(TryLockError::WouldBlock) => { if plan.acquire_before_poll { if cancelled() { - return Err(TuiTestError::assertion("operation was cancelled")); + let mut error = TuiTestError::assertion("operation was cancelled"); + self.finalize_acquisition_error( + &mut error, + FailureReason::Cancelled, + &metadata_inputs, + context, + ); + return Err(error); } if let Some(acquisition) = &plan.acquisition_deadline { if Instant::now() >= acquisition.deadline { - return Err(acquisition.error.clone()); + let mut error = acquisition.error.clone(); + self.finalize_acquisition_error( + &mut error, + FailureReason::TimedOut, + &metadata_inputs, + context, + ); + return Err(error); } } } @@ -1276,6 +1304,116 @@ impl Engine { error.details = Some(Box::new(failure)); } + /// Finalize a quiet-wait acquisition error without touching the + /// lifecycle lock. + /// + /// Cancellation and deadline errors return while a lifecycle writer + /// still holds the exclusive lock, so this path must never block on it + /// (or on session/state locks a lifecycle transition may hold): the + /// observation, history, and diagnostics below all use non-waiting + /// attempts and degrade gracefully when the contended state is + /// unavailable. The error still completes history, marks the recording + /// outcome, and carries finalized details like any other failure. + fn finalize_acquisition_error( + &self, + error: &mut TuiTestError, + reason: FailureReason, + metadata_inputs: &RequestMetadata, + context: &ExecutionContext, + ) { + if !matches!(error.kind, ErrorKind::Assertion | ErrorKind::Internal) { + return; + } + if error.report.is_none() { + error.report = Some(Box::new(FailureReport::new( + metadata_inputs.name.clone(), + metadata_inputs.timeout_ms, + reason, + base_error_message(&error.message), + ))); + } + let (started_ms, screen_before, observation) = self.try_acquisition_diagnostics(); + if error.observation.is_none() { + error.observation = observation.map(Box::new); + } + if matches!(error.kind, ErrorKind::Assertion | ErrorKind::Internal) { + self.recording + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .failed = true; + } + let pending = self + .operation_history + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .begin( + metadata_inputs.name.clone(), + started_ms, + screen_before, + metadata_inputs.safe_summary.clone(), + metadata_inputs.is_assertion, + metadata_inputs.expectation.clone(), + ); + let metadata = OperationMetadata { + sequence: pending.sequence(), + interrupt_generation: self.interrupt_generation.load(Ordering::Acquire), + name: metadata_inputs.name.clone(), + timeout_ms: metadata_inputs.timeout_ms, + started_at: Instant::now(), + started_ms, + screen_before, + safe_summary: metadata_inputs.safe_summary.clone(), + is_assertion: metadata_inputs.is_assertion, + expectation: metadata_inputs.expectation.clone(), + input: metadata_inputs.input.clone(), + }; + let screen_at_return = error + .observation + .as_deref() + .map_or(screen_before, |value| value.screen_sequence); + let ended_ms = error.observation.as_deref().map(|value| value.captured_ms); + self.operation_history + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .finish( + pending, + ended_ms, + screen_at_return, + error.kind.as_str(), + metadata_inputs.input.clone(), + ); + // No session handle: the shared finalizer must not block acquiring + // one. The best-effort observation above already rides on the error, + // so details and artifacts are still produced; without one, the + // report finalizes observation-free. + self.finalize_failure_with_session(error, context, &metadata, None, false); + error.observation = None; + error.report = None; + } + + /// Best-effort acquisition-time diagnostics using non-waiting lock + /// attempts only. Returns `(started_ms, screen_before, observation)`, + /// degrading to zeroes and no observation when the contended session or + /// emulator state is unavailable. + fn try_acquisition_diagnostics(&self) -> (u64, u64, Option) { + let Some(session_guard) = self.session.try_read().ok() else { + return (0, 0, None); + }; + let Some(session) = session_guard.as_ref() else { + return (0, 0, None); + }; + let Ok(mut state) = session.state.try_lock() else { + return (0, 0, None); + }; + let started_ms = elapsed_ms(state.started_at); + let screen_before = try_capture_visual_state(&mut state, true).unwrap_or(0); + let observation = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + capture_failure_observation_locked(session, &mut state) + })) + .ok(); + (started_ms, screen_before, observation) + } + fn finish_trace( &self, context: &ExecutionContext, @@ -7017,6 +7155,42 @@ mod tests { )) } + // F3: an acquisition error finalizes like any other failure — structured + // reason in details, a completed history entry, and the failed recording + // outcome — all produced while the lifecycle writer still holds the + // exclusive lock, so the assertions below double as proof the request + // never waited on that lock. + fn assert_finalized_acquisition_error( + engine: &Engine, + error: &TuiTestError, + reason: FailureReason, + ) { + let details = error + .details + .as_ref() + .expect("acquisition error must carry finalized details"); + assert_eq!(details.operation, "wait.quiet"); + assert_eq!(details.reason, reason); + let history = engine + .operation_history + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .snapshot(); + let event = history + .iter() + .find(|event| event.name == "wait.quiet") + .expect("acquisition error must complete history"); + assert_eq!(event.result, "assertion"); + assert!( + engine + .recording + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .failed, + "acquisition error must mark the recording outcome failed" + ); + } + // Startup/Restart contention with pre-cancellation: a quiet request that // is already cancelled must report cancellation without waiting for a // lifecycle writer to release the lock. @@ -7041,6 +7215,7 @@ mod tests { engine.lifecycle.try_read().is_err(), "pre-cancelled quiet blocked on lifecycle acquisition" ); + assert_finalized_acquisition_error(&engine, &error, FailureReason::Cancelled); let _ = release_tx.send(()); holder.join().unwrap(); } @@ -7091,6 +7266,7 @@ mod tests { engine.lifecycle.try_read().is_err(), "cancelled quiet blocked on lifecycle acquisition" ); + assert_finalized_acquisition_error(&engine, &error, FailureReason::Cancelled); let _ = release_tx.send(()); holder.join().unwrap(); } @@ -7119,7 +7295,86 @@ mod tests { engine.lifecycle.try_read().is_err(), "timed-out quiet blocked on lifecycle acquisition past its deadline" ); + assert_finalized_acquisition_error(&engine, &error, FailureReason::TimedOut); let _ = release_tx.send(()); holder.join().unwrap(); } + + // Acquisition errors on a live session still write the failure file: with + // an artifact directory configured, a pre-cancelled quiet wait finalizes + // (Cancelled details, history, failed OnFailure recording) and exports + // the artifact while the writer holds the lifecycle lock. + #[test] + fn quiet_acquisition_cancel_writes_failure_file_on_live_session() { + let root = allocate_artifact_directory(&std::env::temp_dir()).unwrap(); + let engine = std::sync::Arc::new(Engine::new( + "quiet-acquisition-live".into(), + std::sync::Arc::new(Logger::disabled()), + root.join("automatic.cast"), + )); + let mut run = sleeping_program(false); + run.recording.mode = AutomaticRecordingMode::OnFailure; + engine.execute(Operation::Run(run)).unwrap(); + let (entered_rx, release_tx, holder) = hold_lifecycle_write(&engine); + entered_rx + .recv_timeout(Duration::from_secs(15)) + .expect("holder takes the lifecycle lock"); + let context = ExecutionContext { + artifact: Some(crate::diagnostics::FailureArtifactOptions { + directory: root.clone(), + mode: crate::diagnostics::FailureArtifactMode::Text, + include_recording: false, + }), + ..Default::default() + }; + let options = WaitQuietOptions::new(Duration::from_millis(100), Duration::from_millis(50)) + .expect("test options are valid"); + let error = engine + .run_quiet_wait_cancellable(&options, &context, &|| true) + .expect_err("pre-cancelled quiet must report cancellation"); + assert!( + error.to_string().contains("cancelled"), + "unexpected error: {error}" + ); + assert!( + engine.lifecycle.try_read().is_err(), + "pre-cancelled quiet blocked on lifecycle acquisition" + ); + assert_finalized_acquisition_error(&engine, &error, FailureReason::Cancelled); + let artifact = error + .artifact + .as_ref() + .expect("live acquisition error must write a failure file"); + assert_eq!(artifact.status, FailureArtifactStatus::Written); + let files = [ + artifact.manifest.as_ref(), + artifact.report.as_ref(), + artifact.screen_text.as_ref(), + ] + .into_iter() + .flatten() + .collect::>(); + assert!( + !files.is_empty(), + "failure artifact must reference at least one file" + ); + for path in files { + assert!( + std::path::Path::new(path).is_file(), + "missing failure file: {path}" + ); + } + let _ = release_tx.send(()); + holder.join().unwrap(); + engine.execute(Operation::Close).unwrap(); + assert!( + engine + .recording + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .failed, + "failed acquisition must retain the OnFailure recording outcome" + ); + std::fs::remove_dir_all(root).unwrap(); + } } From 528bbc008c44e8b6edaebb7b00f441c5f54b798f Mon Sep 17 00:00:00 2001 From: Alexey Zhokhov Date: Tue, 27 Oct 2026 05:58:38 +0700 Subject: [PATCH 15/16] U10: write observation-free failure files; keep mid-Close acquisition failures (F3-A/F3-B) F3-A: finalize_failure_with_session skipped all exports when the terminal observation was absent, so quiet-wait acquisition errors that finalized while a lifecycle transition held the session/state locks returned details but no failure files. Add write_observation_free_artifact in diagnostics.rs (manifest + text report with operation/reason/context/history, terminal evidence marked unavailable, no locks taken) and use it from the export loop when observation is None. F3-B: finish_trace_with_session read the recording flag and wrote back the (stale) value across two separate locks, so a quiet acquisition failure landing in between was cleared and Close deleted the recording. Read the flag and apply an explicit outcome under one recording lock; a None outcome no longer writes back the saved value; the lock is released before file work. Tests: quiet_acquisition_timeout_during_startup_writes_failure_file, quiet_acquisition_cancel_with_busy_state_writes_failure_file, quiet_acquisition_failure_is_not_lost_during_close. Signed-off-by: Alexey Zhokhov --- crates/tui-test/src/diagnostics.rs | 147 +++++++++++++ crates/tui-test/src/engine.rs | 330 +++++++++++++++++++++++++---- 2 files changed, 441 insertions(+), 36 deletions(-) diff --git a/crates/tui-test/src/diagnostics.rs b/crates/tui-test/src/diagnostics.rs index 3b4b36fd..02266117 100644 --- a/crates/tui-test/src/diagnostics.rs +++ b/crates/tui-test/src/diagnostics.rs @@ -1581,6 +1581,153 @@ pub(crate) fn write_failure_artifact( reference } +/// Write a failure artifact without a terminal observation. +/// +/// Some failures finalize while terminal state is unavailable: a quiet-wait +/// acquisition error returns while a lifecycle transition still holds the +/// session or emulator-state lock, so no observation can be captured without +/// blocking on that transition. The finalized report — operation, reason, +/// context, and history — is still exported as a manifest plus a text +/// report; every terminal-derived file is marked omitted and the missing +/// terminal evidence is stated explicitly. This function only renders and +/// writes files; it takes no lifecycle, session, or emulator-state locks. +pub(crate) fn write_observation_free_artifact( + options: &FailureArtifactOptions, + details: &mut FailureReport, + directory: PathBuf, +) -> FailureArtifactRef { + let mut reference = FailureArtifactRef { + status: FailureArtifactStatus::Failed, + directory: directory.to_string_lossy().into_owned(), + manifest: None, + report: None, + report_html: None, + timeline: None, + screen_text: None, + screen_svg: None, + recording: None, + errors: Vec::new(), + }; + if options.mode == FailureArtifactMode::None { + return reference; + } + const UNAVAILABLE: &str = + "terminal evidence unavailable: the failure finalized without a terminal observation"; + reference.errors.push(UNAVAILABLE.to_string()); + let mut files = Vec::new(); + let mut total = 0u64; + if options.include_recording { + files.push(ArtifactFile { + kind: "recording".to_string(), + path: "session.cast".to_string(), + status: ArtifactFileStatus::Omitted, + bytes: None, + sha256: None, + reason: Some(UNAVAILABLE.to_string()), + }); + } + if options.wants_text() { + files.push(ArtifactFile { + kind: "screen_text".to_string(), + path: "current.txt".to_string(), + status: ArtifactFileStatus::Omitted, + bytes: None, + sha256: None, + reason: Some(UNAVAILABLE.to_string()), + }); + } + if options.wants_svg() { + files.push(ArtifactFile { + kind: "screen_svg".to_string(), + path: "current.svg".to_string(), + status: ArtifactFileStatus::Omitted, + bytes: None, + sha256: None, + reason: Some(UNAVAILABLE.to_string()), + }); + } + + let markdown_name = details.artifact_name("md"); + let manifest_name = details.artifact_name("json"); + if options.wants_markdown() { + let markdown = markdown::render(details, &files); + write_optional_file( + &directory, + "report", + &markdown_name, + markdown.as_bytes(), + REPORT_LIMIT as u64, + &mut total, + &mut files, + &mut reference.errors, + ); + if files + .last() + .is_some_and(|file| file.status == ArtifactFileStatus::Written) + { + reference.report = Some( + directory + .join(&markdown_name) + .to_string_lossy() + .into_owned(), + ); + } + } + + if !options.wants_json() { + reference.status = if reference.report_html.is_some() { + FailureArtifactStatus::Partial + } else { + FailureArtifactStatus::Failed + }; + return reference; + } + + let sensitivity = sensitivity(details, &files); + let manifest = FailureArtifactManifest { + details: details.clone(), + sensitivity, + files, + errors: reference.errors.clone(), + }; + let json = match serde_json::to_vec_pretty(&manifest) { + Ok(json) if json.len() <= FAILURE_JSON_LIMIT => json, + Ok(_) => { + reference + .errors + .push(format!("{manifest_name} exceeded the 2 MiB limit")); + return reference; + } + Err(error) => { + reference + .errors + .push(format!("failed to serialize {manifest_name}: {error}")); + return reference; + } + }; + if total.saturating_add(json.len() as u64) > ARTIFACT_TOTAL_LIMIT { + reference.errors.push(format!( + "{manifest_name} would exceed the total artifact limit" + )); + return reference; + } + let manifest_path = directory.join(&manifest_name); + match write_atomic(&manifest_path, &json) { + Ok(()) => { + reference.manifest = Some(manifest_path.to_string_lossy().into_owned()); + reference.status = if reference.report.is_some() { + FailureArtifactStatus::Partial + } else { + FailureArtifactStatus::Failed + }; + } + Err(error) => reference + .errors + .push(format!("failed to commit {manifest_name}: {error}")), + } + reference +} + #[allow(clippy::too_many_arguments)] fn write_optional_file( directory: &Path, diff --git a/crates/tui-test/src/engine.rs b/crates/tui-test/src/engine.rs index 4d3db95a..bca0d172 100644 --- a/crates/tui-test/src/engine.rs +++ b/crates/tui-test/src/engine.rs @@ -22,11 +22,12 @@ use crate::diagnostics::strings::{ }; use crate::diagnostics::{ allocate_artifact_directory, allocate_trace_directory, elapsed_ms, failure_reason, - recording_temp_path, write_failure_artifact, ArtifactInputs, CellMismatch, CellStyleEvaluation, - ExecutionContext, FailureArtifactRef, FailureArtifactStatus, FailureObservation, FailureReason, - FailureReport, InputDetails, LocatorFailureReason, OperationEvent, OperationExpectation, - OperationHistory, PreparedRecording, ProcessDiagnostics, RecordingDiagnostics, RecordingStatus, - RuntimeDiagnostics, TraceMode, TraceOptions, TraceOutcome, RECORDING_COPY_LIMIT, + recording_temp_path, write_failure_artifact, write_observation_free_artifact, ArtifactInputs, + CellMismatch, CellStyleEvaluation, ExecutionContext, FailureArtifactRef, FailureArtifactStatus, + FailureObservation, FailureReason, FailureReport, InputDetails, LocatorFailureReason, + OperationEvent, OperationExpectation, OperationHistory, PreparedRecording, ProcessDiagnostics, + RecordingDiagnostics, RecordingStatus, RuntimeDiagnostics, TraceMode, TraceOptions, + TraceOutcome, RECORDING_COPY_LIMIT, }; use crate::diagnostics::{comparison_failure, merge_failure_details}; use crate::input::{keys, mouse}; @@ -1213,7 +1214,7 @@ impl Engine { (false, context.artifact.as_ref()), (true, trace_artifact.as_ref()), ] { - let (Some(options), Some(observation)) = (options, observation) else { + let Some(options) = options else { continue; }; if options.mode != crate::diagnostics::FailureArtifactMode::None { @@ -1230,27 +1231,35 @@ impl Engine { if is_trace { owned_directory = Some(directory.clone()); } - let prepared_recording = if options.include_recording { - session.and_then(|session| { - self.prepare_recording_artifact( - session, + // Without a terminal observation no recording can be + // snapshotted and no terminal evidence rendered; the + // report still exports operation, reason, context, and + // history. This path takes no locks. + if let Some(observation) = observation { + let prepared_recording = if options.include_recording { + session.and_then(|session| { + self.prepare_recording_artifact( + session, + observation, + &directory, + &mut export_details, + ) + }) + } else { + None + }; + write_failure_artifact( + options, + ArtifactInputs { + details: &mut export_details, observation, - &directory, - &mut export_details, - ) - }) + recording: prepared_recording, + }, + directory, + ) } else { - None - }; - write_failure_artifact( - options, - ArtifactInputs { - details: &mut export_details, - observation, - recording: prepared_recording, - }, - directory, - ) + write_observation_free_artifact(options, &mut export_details, directory) + } } Err(error) => FailureArtifactRef { status: FailureArtifactStatus::Failed, @@ -1499,19 +1508,29 @@ impl Engine { context: &ExecutionContext, failed: Option, ) -> Result<(), TuiTestError> { - let previous_failed = self - .recording - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .failed; - if failed.is_some_and(|failed| failed != previous_failed) { + // Read the stored outcome and apply an explicit one under a single + // recording lock: a concurrent acquisition failure landing between a + // split read and write-back would otherwise be lost. A `None` + // outcome carries no new information, so it is reported without + // writing back the read value. The lock is released before the file + // work below. + let (failed, outcome_changed) = { + let mut recording = self + .recording + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + match failed { + Some(explicit) => { + let changed = explicit != recording.failed; + recording.failed = explicit; + (explicit, changed) + } + None => (recording.failed, false), + } + }; + if outcome_changed { self.discard_trace_artifacts()?; } - let failed = failed.unwrap_or(previous_failed); - self.recording - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .failed = failed; let options = { let trace = self .trace @@ -7377,4 +7396,243 @@ mod tests { ); std::fs::remove_dir_all(root).unwrap(); } + + // F3-A: a quiet acquisition error that finalizes without a terminal + // observation must still write failure files. Both tests hold the work + // that blocks acquisition (lifecycle writer, and a busy emulator state + // for the second) until after the manifest and report assertions, so the + // assertions below prove the files were written without that work. + fn assert_observation_free_failure_files( + error: &TuiTestError, + reason: FailureReason, + root: &std::path::Path, + ) { + let details = error + .details + .as_ref() + .expect("acquisition error must carry finalized details"); + assert_eq!(details.reason, reason); + let artifact = error + .artifact + .as_ref() + .expect("observation-free failure must write a failure file"); + assert_eq!(artifact.status, FailureArtifactStatus::Partial); + for path in [&artifact.manifest, &artifact.report].into_iter().flatten() { + assert!( + std::path::Path::new(path).is_file(), + "missing failure file: {path}" + ); + assert!( + std::path::Path::new(path).starts_with(root), + "failure file must live under the artifact directory: {path}" + ); + } + let manifest = std::fs::read_to_string( + artifact + .manifest + .as_ref() + .expect("manifest must be referenced"), + ) + .expect("manifest must be readable"); + assert!( + manifest.contains("wait.quiet"), + "manifest must carry the operation: {manifest}" + ); + assert!( + manifest.contains("timed_out") || manifest.contains("cancelled"), + "manifest must carry the reason" + ); + assert!( + manifest.contains("terminal evidence unavailable"), + "manifest must state that terminal evidence is unavailable" + ); + let report = + std::fs::read_to_string(artifact.report.as_ref().expect("report must be referenced")) + .expect("report must be readable"); + assert!( + report.contains("wait.quiet"), + "text report must carry the operation" + ); + } + + // Timeout during startup: the lifecycle writer holds the lock before + // session installation, so no session (and no observation) exists when + // the deadline passes. The timeout must still write manifest + report. + #[test] + fn quiet_acquisition_timeout_during_startup_writes_failure_file() { + let root = allocate_artifact_directory(&std::env::temp_dir()).unwrap(); + let engine = quiet_test_engine("timeout-during-startup"); + let (entered_rx, release_tx, holder) = hold_lifecycle_write(&engine); + entered_rx + .recv_timeout(Duration::from_secs(15)) + .expect("holder takes the lifecycle lock"); + let context = ExecutionContext { + artifact: Some(crate::diagnostics::FailureArtifactOptions { + directory: root.clone(), + mode: crate::diagnostics::FailureArtifactMode::Text, + include_recording: false, + }), + ..Default::default() + }; + let options = WaitQuietOptions::new(Duration::from_millis(200), Duration::from_millis(50)) + .expect("test options are valid"); + let error = engine + .run_quiet_wait_cancellable(&options, &context, &|| false) + .expect_err("expired acquisition must report timeout"); + assert!( + error.to_string().contains("timed out"), + "unexpected error: {error}" + ); + assert_observation_free_failure_files(&error, FailureReason::TimedOut, &root); + assert!( + engine.lifecycle.try_read().is_err(), + "files must be written before the held startup work is released" + ); + let _ = release_tx.send(()); + holder.join().unwrap(); + std::fs::remove_dir_all(root).unwrap(); + } + + // Cancellation with busy emulator state: the lifecycle writer holds the + // lock while the state lock is busy, so no observation can be captured + // even though a session is live. The cancellation must still write + // manifest + report. + #[test] + fn quiet_acquisition_cancel_with_busy_state_writes_failure_file() { + let root = allocate_artifact_directory(&std::env::temp_dir()).unwrap(); + let engine = std::sync::Arc::new(Engine::new( + "quiet-acquisition-busy".into(), + std::sync::Arc::new(Logger::disabled()), + root.join("automatic.cast"), + )); + engine + .execute(Operation::Run(sleeping_program(false))) + .unwrap(); + let (entered_rx, release_tx, holder) = hold_lifecycle_write(&engine); + entered_rx + .recv_timeout(Duration::from_secs(15)) + .expect("holder takes the lifecycle lock"); + let session_guard = engine.lock_session(); + let _state_guard = session_guard + .as_ref() + .expect("session is live") + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let context = ExecutionContext { + artifact: Some(crate::diagnostics::FailureArtifactOptions { + directory: root.clone(), + mode: crate::diagnostics::FailureArtifactMode::Text, + include_recording: false, + }), + ..Default::default() + }; + let options = WaitQuietOptions::new(Duration::from_millis(100), Duration::from_millis(50)) + .expect("test options are valid"); + let error = engine + .run_quiet_wait_cancellable(&options, &context, &|| true) + .expect_err("pre-cancelled quiet must report cancellation"); + assert!( + error.to_string().contains("cancelled"), + "unexpected error: {error}" + ); + assert_observation_free_failure_files(&error, FailureReason::Cancelled, &root); + drop(_state_guard); + drop(session_guard); + let _ = release_tx.send(()); + holder.join().unwrap(); + engine.execute(Operation::Close).unwrap(); + std::fs::remove_dir_all(root).unwrap(); + } + + // F3-B: a quiet acquisition failure that lands while Close is parked + // mid-teardown must survive Close: the recording outcome stays failed + // and the recording file is retained. + #[test] + fn quiet_acquisition_failure_is_not_lost_during_close() { + let root = allocate_artifact_directory(&std::env::temp_dir()).unwrap(); + let engine = std::sync::Arc::new(Engine::new( + "quiet-acquisition-close".into(), + std::sync::Arc::new(Logger::disabled()), + root.join("automatic.cast"), + )); + let mut run = sleeping_program(false); + run.recording.mode = AutomaticRecordingMode::OnFailure; + engine.execute(Operation::Run(run)).unwrap(); + let recording_path = engine + .recording + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .path + .clone() + .expect("OnFailure recording must exist"); + assert!( + recording_path.is_file(), + "recording file must exist before Close" + ); + // Barrier-pause Close: hold the emulator state so Close parks before + // it can finish the trace and clean up the recording. + let session_guard = engine.lock_session(); + let _state_guard = session_guard + .as_ref() + .expect("session is live") + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let closer = { + let engine = std::sync::Arc::clone(&engine); + std::thread::spawn(move || engine.execute(Operation::Close)) + }; + let parked = Instant::now(); + while engine.lifecycle.try_read().is_ok() { + assert!( + parked.elapsed() < Duration::from_secs(15), + "Close must take the lifecycle lock" + ); + std::thread::sleep(Duration::from_millis(5)); + } + // Fail mid-pause: Close holds the lifecycle lock, so the quiet wait + // reports cancellation through the acquisition path. + let options = WaitQuietOptions::new(Duration::from_millis(100), Duration::from_millis(50)) + .expect("test options are valid"); + let error = engine + .run_quiet_wait_cancellable(&options, &ExecutionContext::default(), &|| true) + .expect_err("pre-cancelled quiet must report cancellation"); + assert!( + error.to_string().contains("cancelled"), + "unexpected error: {error}" + ); + assert!( + engine + .recording + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .failed, + "acquisition failure mid-Close must mark the recording outcome failed" + ); + assert!( + engine.lifecycle.try_read().is_err(), + "Close must still be parked while the failure lands" + ); + // Resume Close: the failure must survive teardown. + drop(_state_guard); + drop(session_guard); + closer + .join() + .expect("Close thread must finish") + .expect("Close must succeed"); + assert!( + engine + .recording + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .failed, + "Close must not clear a concurrent acquisition failure" + ); + assert!( + recording_path.is_file(), + "OnFailure must retain the recording after a mid-Close acquisition failure" + ); + std::fs::remove_dir_all(root).unwrap(); + } } From 68a96489b6323fbcfd9c3d6a0be91207fcde783f Mon Sep 17 00:00:00 2001 From: Alexey Zhokhov Date: Tue, 27 Oct 2026 07:40:06 +0700 Subject: [PATCH 16/16] F3-A: write observation-free HTML report for Html/All modes write_observation_free_artifact now emits a bounded, escaped HTML fallback (operation, reason, context, history, 'terminal evidence unavailable') via write_optional_file kind report_html before the wants_json early return. Partial status when the report lands without terminal evidence; no lifecycle/session locks. Adds quiet_acquisition_html/all lib tests covering startup timeout and busy-state cancellation. Signed-off-by: Alexey Zhokhov --- crates/tui-test/src/diagnostics.rs | 99 ++++++++++ crates/tui-test/src/engine.rs | 288 +++++++++++++++++++++++++++++ 2 files changed, 387 insertions(+) diff --git a/crates/tui-test/src/diagnostics.rs b/crates/tui-test/src/diagnostics.rs index 02266117..b8d3651e 100644 --- a/crates/tui-test/src/diagnostics.rs +++ b/crates/tui-test/src/diagnostics.rs @@ -1591,6 +1591,79 @@ pub(crate) fn write_failure_artifact( /// report; every terminal-derived file is marked omitted and the missing /// terminal evidence is stated explicitly. This function only renders and /// writes files; it takes no lifecycle, session, or emulator-state locks. +fn escape_html(value: &str) -> String { + let mut out = String::with_capacity(value.len()); + for ch in value.chars() { + match ch { + '&' => out.push_str("&"), + '<' => out.push_str("<"), + '>' => out.push_str(">"), + '"' => out.push_str("""), + '\'' => out.push_str("'"), + _ => out.push(ch), + } + } + out +} + +/// F3-A: observation-free HTML fallback report. Carries the finalized +/// operation, reason, context, and history with an explicit statement that +/// terminal evidence is unavailable. All embedded data is HTML-escaped; +/// the byte bound is enforced by the caller's `write_optional_file` limit. +fn observation_free_html(details: &FailureReport) -> String { + use std::fmt::Write as _; + let mut out = String::from( + "\n\n\n\nFailure report\n\n\n

Failure report

\n

terminal evidence unavailable

\n", + ); + let _ = writeln!( + out, + "

Operation

\n

{}

", + escape_html(&details.operation.name) + ); + let _ = writeln!( + out, + "

Reason

\n

{}

", + escape_html(failure_reason_code(details.reason)) + ); + let _ = writeln!( + out, + "

Summary

\n

{}

", + escape_html(&details.summary) + ); + out.push_str("

Context

\n"); + if details.context.is_empty() { + out.push_str("

none

\n"); + } else { + out.push_str("
\n"); + for (key, value) in &details.context { + let _ = writeln!( + out, + "
{}
{}
", + escape_html(key), + escape_html(value) + ); + } + out.push_str("
\n"); + } + out.push_str("

History

\n"); + if details.recent_operations.is_empty() { + out.push_str("

none

\n"); + } else { + out.push_str("
    \n"); + for event in &details.recent_operations { + let _ = writeln!( + out, + "
  1. {} ({})
  2. ", + escape_html(&event.name), + escape_html(&event.result) + ); + } + out.push_str("
\n"); + } + out.push_str("\n\n"); + out +} + pub(crate) fn write_observation_free_artifact( options: &FailureArtifactOptions, details: &mut FailureReport, @@ -1674,6 +1747,32 @@ pub(crate) fn write_observation_free_artifact( } } + // F3-A: observation-free HTML fallback. Mirrors the report_html file + // kind used with an observation, but carries only finalized details + // (operation, reason, context, history) since no terminal snapshot + // exists. Touches only `details` and the filesystem: no + // lifecycle/session/terminal-state locks are acquired. + if options.wants_html() { + let html_name = details.artifact_name("html"); + let html = observation_free_html(details); + write_optional_file( + &directory, + "report_html", + &html_name, + html.as_bytes(), + HTML_LIMIT as u64, + &mut total, + &mut files, + &mut reference.errors, + ); + if files + .last() + .is_some_and(|file| file.status == ArtifactFileStatus::Written) + { + reference.report_html = Some(directory.join(&html_name).to_string_lossy().into_owned()); + } + } + if !options.wants_json() { reference.status = if reference.report_html.is_some() { FailureArtifactStatus::Partial diff --git a/crates/tui-test/src/engine.rs b/crates/tui-test/src/engine.rs index bca0d172..8d346646 100644 --- a/crates/tui-test/src/engine.rs +++ b/crates/tui-test/src/engine.rs @@ -7545,6 +7545,294 @@ mod tests { std::fs::remove_dir_all(root).unwrap(); } + // F3-A: observation-free HTML fallback. Html mode writes only the HTML + // viewer (no manifest, no markdown); All mode writes manifest + + // markdown + HTML. Both tests cover the startup timeout and the + // busy-state cancellation paths, holding the blocking work until after + // the file assertions prove the files were written without it. + fn assert_observation_free_html_report( + error: &TuiTestError, + reason: FailureReason, + root: &std::path::Path, + ) { + let details = error + .details + .as_ref() + .expect("acquisition error must carry finalized details"); + assert_eq!(details.reason, reason); + let artifact = error + .artifact + .as_ref() + .expect("observation-free failure must write a failure file"); + assert_eq!(artifact.status, FailureArtifactStatus::Partial); + let path = artifact + .report_html + .as_ref() + .expect("HTML report must be referenced"); + assert!( + std::path::Path::new(path).starts_with(root), + "HTML report must live under the artifact directory: {path}" + ); + assert!( + std::path::Path::new(path).is_file(), + "missing HTML report: {path}" + ); + let html = std::fs::read_to_string(path).expect("HTML report must be readable"); + assert!( + html.contains("wait.quiet"), + "HTML report must carry the operation" + ); + assert!( + html.contains("timed_out") || html.contains("cancelled"), + "HTML report must carry the reason" + ); + assert!( + html.contains("terminal evidence unavailable"), + "HTML report must state that terminal evidence is unavailable" + ); + } + + fn assert_observation_free_all_reports( + error: &TuiTestError, + reason: FailureReason, + root: &std::path::Path, + ) { + let details = error + .details + .as_ref() + .expect("acquisition error must carry finalized details"); + assert_eq!(details.reason, reason); + let artifact = error + .artifact + .as_ref() + .expect("observation-free failure must write a failure file"); + assert_eq!(artifact.status, FailureArtifactStatus::Partial); + for path in [&artifact.manifest, &artifact.report, &artifact.report_html] + .into_iter() + .flatten() + { + assert!( + std::path::Path::new(path).is_file(), + "missing failure file: {path}" + ); + assert!( + std::path::Path::new(path).starts_with(root), + "failure file must live under the artifact directory: {path}" + ); + } + let manifest = std::fs::read_to_string( + artifact + .manifest + .as_ref() + .expect("manifest must be referenced"), + ) + .expect("manifest must be readable"); + assert!( + manifest.contains("wait.quiet"), + "manifest must carry the operation: {manifest}" + ); + assert!( + manifest.contains("timed_out") || manifest.contains("cancelled"), + "manifest must carry the reason" + ); + assert!( + manifest.contains("terminal evidence unavailable"), + "manifest must state that terminal evidence is unavailable" + ); + let report = + std::fs::read_to_string(artifact.report.as_ref().expect("report must be referenced")) + .expect("report must be readable"); + assert!( + report.contains("wait.quiet"), + "text report must carry the operation" + ); + assert_observation_free_html_report(error, reason, root); + } + + // Html mode without an observation: startup timeout, then busy-state + // cancellation. Each scenario asserts the HTML file exists before the + // held locks are released. + #[test] + fn quiet_acquisition_html_without_observation_writes_report() { + let root = allocate_artifact_directory(&std::env::temp_dir()).unwrap(); + let engine = quiet_test_engine("html-startup"); + let (entered_rx, release_tx, holder) = hold_lifecycle_write(&engine); + entered_rx + .recv_timeout(Duration::from_secs(15)) + .expect("holder takes the lifecycle lock"); + let context = ExecutionContext { + artifact: Some(crate::diagnostics::FailureArtifactOptions { + directory: root.clone(), + mode: crate::diagnostics::FailureArtifactMode::Html, + include_recording: false, + }), + ..Default::default() + }; + let options = WaitQuietOptions::new(Duration::from_millis(200), Duration::from_millis(50)) + .expect("test options are valid"); + let error = engine + .run_quiet_wait_cancellable(&options, &context, &|| false) + .expect_err("expired acquisition must report timeout"); + assert!( + error.to_string().contains("timed out"), + "unexpected error: {error}" + ); + assert_observation_free_html_report(&error, FailureReason::TimedOut, &root); + assert!( + error + .artifact + .as_ref() + .expect("artifact must be referenced") + .manifest + .is_none(), + "Html mode must not write a manifest" + ); + assert!( + error + .artifact + .as_ref() + .expect("artifact must be referenced") + .report + .is_none(), + "Html mode must not write a markdown report" + ); + assert!( + engine.lifecycle.try_read().is_err(), + "files must be written before the held startup work is released" + ); + let _ = release_tx.send(()); + holder.join().unwrap(); + std::fs::remove_dir_all(&root).unwrap(); + + let root = allocate_artifact_directory(&std::env::temp_dir()).unwrap(); + let engine = std::sync::Arc::new(Engine::new( + "quiet-acquisition-html-busy".into(), + std::sync::Arc::new(Logger::disabled()), + root.join("automatic.cast"), + )); + engine + .execute(Operation::Run(sleeping_program(false))) + .unwrap(); + let (entered_rx, release_tx, holder) = hold_lifecycle_write(&engine); + entered_rx + .recv_timeout(Duration::from_secs(15)) + .expect("holder takes the lifecycle lock"); + let session_guard = engine.lock_session(); + let _state_guard = session_guard + .as_ref() + .expect("session is live") + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let context = ExecutionContext { + artifact: Some(crate::diagnostics::FailureArtifactOptions { + directory: root.clone(), + mode: crate::diagnostics::FailureArtifactMode::Html, + include_recording: false, + }), + ..Default::default() + }; + let options = WaitQuietOptions::new(Duration::from_millis(100), Duration::from_millis(50)) + .expect("test options are valid"); + let error = engine + .run_quiet_wait_cancellable(&options, &context, &|| true) + .expect_err("pre-cancelled quiet must report cancellation"); + assert!( + error.to_string().contains("cancelled"), + "unexpected error: {error}" + ); + assert_observation_free_html_report(&error, FailureReason::Cancelled, &root); + drop(_state_guard); + drop(session_guard); + let _ = release_tx.send(()); + holder.join().unwrap(); + engine.execute(Operation::Close).unwrap(); + std::fs::remove_dir_all(&root).unwrap(); + } + + // All mode without an observation: startup timeout, then busy-state + // cancellation. Each scenario asserts manifest + markdown + HTML exist + // before the held locks are released. + #[test] + fn quiet_acquisition_all_without_observation_writes_reports() { + let root = allocate_artifact_directory(&std::env::temp_dir()).unwrap(); + let engine = quiet_test_engine("all-startup"); + let (entered_rx, release_tx, holder) = hold_lifecycle_write(&engine); + entered_rx + .recv_timeout(Duration::from_secs(15)) + .expect("holder takes the lifecycle lock"); + let context = ExecutionContext { + artifact: Some(crate::diagnostics::FailureArtifactOptions { + directory: root.clone(), + mode: crate::diagnostics::FailureArtifactMode::All, + include_recording: false, + }), + ..Default::default() + }; + let options = WaitQuietOptions::new(Duration::from_millis(200), Duration::from_millis(50)) + .expect("test options are valid"); + let error = engine + .run_quiet_wait_cancellable(&options, &context, &|| false) + .expect_err("expired acquisition must report timeout"); + assert!( + error.to_string().contains("timed out"), + "unexpected error: {error}" + ); + assert_observation_free_all_reports(&error, FailureReason::TimedOut, &root); + assert!( + engine.lifecycle.try_read().is_err(), + "files must be written before the held startup work is released" + ); + let _ = release_tx.send(()); + holder.join().unwrap(); + std::fs::remove_dir_all(&root).unwrap(); + + let root = allocate_artifact_directory(&std::env::temp_dir()).unwrap(); + let engine = std::sync::Arc::new(Engine::new( + "quiet-acquisition-all-busy".into(), + std::sync::Arc::new(Logger::disabled()), + root.join("automatic.cast"), + )); + engine + .execute(Operation::Run(sleeping_program(false))) + .unwrap(); + let (entered_rx, release_tx, holder) = hold_lifecycle_write(&engine); + entered_rx + .recv_timeout(Duration::from_secs(15)) + .expect("holder takes the lifecycle lock"); + let session_guard = engine.lock_session(); + let _state_guard = session_guard + .as_ref() + .expect("session is live") + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let context = ExecutionContext { + artifact: Some(crate::diagnostics::FailureArtifactOptions { + directory: root.clone(), + mode: crate::diagnostics::FailureArtifactMode::All, + include_recording: false, + }), + ..Default::default() + }; + let options = WaitQuietOptions::new(Duration::from_millis(100), Duration::from_millis(50)) + .expect("test options are valid"); + let error = engine + .run_quiet_wait_cancellable(&options, &context, &|| true) + .expect_err("pre-cancelled quiet must report cancellation"); + assert!( + error.to_string().contains("cancelled"), + "unexpected error: {error}" + ); + assert_observation_free_all_reports(&error, FailureReason::Cancelled, &root); + drop(_state_guard); + drop(session_guard); + let _ = release_tx.send(()); + holder.join().unwrap(); + engine.execute(Operation::Close).unwrap(); + std::fs::remove_dir_all(&root).unwrap(); + } + // F3-B: a quiet acquisition failure that lands while Close is parked // mid-teardown must survive Close: the recording outcome stays failed // and the recording file is retained.