diff --git a/crates/tui-test/src/diagnostics.rs b/crates/tui-test/src/diagnostics.rs
index 3b4b36fd..b8d3651e 100644
--- a/crates/tui-test/src/diagnostics.rs
+++ b/crates/tui-test/src/diagnostics.rs
@@ -1581,6 +1581,252 @@ 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.
+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\nFailure report \nterminal 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,
+ "{} ({}) ",
+ 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,
+ 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(),
+ );
+ }
+ }
+
+ // 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
+ } 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 07a80fb5..8d346646 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};
@@ -35,11 +36,16 @@ 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,
};
use crate::terminal::locator::{self, Pattern};
+use crate::wait::WaitQuietOptions;
pub struct Engine {
name: String,
@@ -47,6 +53,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>,
@@ -225,6 +239,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!(
@@ -266,6 +329,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),
@@ -309,33 +373,136 @@ 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() {
+ 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 {
+ let mut error = acquisition.error.clone();
+ self.finalize_acquisition_error(
+ &mut error,
+ FailureReason::TimedOut,
+ &metadata_inputs,
+ context,
+ );
+ return Err(error);
+ }
+ }
}
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() {
+ 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 {
+ let mut error = acquisition.error.clone();
+ self.finalize_acquisition_error(
+ &mut error,
+ FailureReason::TimedOut,
+ &metadata_inputs,
+ context,
+ );
+ return Err(error);
+ }
+ }
+ }
std::thread::sleep(Duration::from_millis(POLL_DELAY_MS));
}
}
@@ -347,69 +514,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
}
@@ -432,7 +583,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
@@ -474,7 +625,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;
@@ -516,6 +667,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);
}
@@ -744,6 +896,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);
@@ -858,6 +1011,7 @@ impl Engine {
shell: session.shell.map(|value| value.as_str()),
};
*self.write_session() = Some(session);
+ self.bump_session_generation();
*self
.live
.lock()
@@ -1060,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 {
@@ -1077,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,
@@ -1151,6 +1313,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,
@@ -1236,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
@@ -1472,6 +1754,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
@@ -1615,6 +1946,18 @@ 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. 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
+ }
+
pub(crate) fn interrupt_for_operation(&self, operation: &Operation) {
match operation {
Operation::Close => self.interrupt(),
@@ -1762,6 +2105,134 @@ 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; `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();
+ 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 through the shared request wrapper.
+ ///
+ /// 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
+ /// 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.
+ ///
+ /// 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> {
+ 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> {
self.session
.write()
@@ -4510,6 +4981,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) {
(
@@ -6625,4 +7135,792 @@ 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(),
+ ))
+ }
+
+ // 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.
+ #[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"
+ );
+ assert_finalized_acquisition_error(&engine, &error, FailureReason::Cancelled);
+ 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"
+ );
+ assert_finalized_acquisition_error(&engine, &error, FailureReason::Cancelled);
+ 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"
+ );
+ 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();
+ }
+
+ // 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-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.
+ #[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();
+ }
}
diff --git a/crates/tui-test/src/lib.rs b/crates/tui-test/src/lib.rs
index 230b7c20..6eae166f 100644
--- a/crates/tui-test/src/lib.rs
+++ b/crates/tui-test/src/lib.rs
@@ -11,8 +11,10 @@ pub mod record;
pub mod render;
pub mod runtime;
pub mod shell;
+pub mod snapshot;
pub mod terminal;
pub mod trace;
+pub mod wait;
mod event;
mod session;
@@ -24,4 +26,11 @@ 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;
+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 daaa83bd..e97c9d4d 100644
--- a/crates/tui-test/src/runtime.rs
+++ b/crates/tui-test/src/runtime.rs
@@ -13,6 +13,8 @@ use crate::api::{
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;
@@ -446,6 +448,96 @@ 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)
+ }
+
+ /// 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
+ /// 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. 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
+ /// [`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.
+ ///
+ /// Pending lifecycle requests (Close/Restart announced through the
+ /// 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.
+ ///
+ /// 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> {
+ 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
/// child without waiting for the operation queue or PTY input writer.
pub fn interrupt(&self) {
@@ -456,6 +548,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/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/snapshot.rs b/crates/tui-test/src/snapshot.rs
new file mode 100644
index 00000000..be2d00c5
--- /dev/null
+++ b/crates/tui-test/src/snapshot.rs
@@ -0,0 +1,2266 @@
+//! 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.
+ ///
+ /// 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()
+ }
+
+ /// 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()
+ }
+
+ /// 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 {
+ col,
+ row,
+ bytes: self.text.len(),
+ limit: MAX_CELL_TEXT_BYTES,
+ });
+ }
+ 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 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 {
+ 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.
+ ///
+ /// 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;
+ /// 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)
+ }
+
+ /// 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)
+ }
+}
+
+/// 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 {
+ 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(),
+ });
+ }
+ // `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 {
+ 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)],
+}
+
+/// 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.
+///
+/// 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 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(
+ 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)
+}
+
+#[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);
+ let error = serde_json::from_value::(wire).expect_err("schema 999 fails");
+ assert!(
+ error.to_string().contains("unsupported snapshot schema"),
+ "unexpected error: {error}"
+ );
+ }
+
+ #[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();
+ 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));
+ }
+
+ /// 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();
+ 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
+ );
+ }
+}
diff --git a/crates/tui-test/src/wait.rs b/crates/tui-test/src/wait.rs
new file mode 100644
index 00000000..91eca2e1
--- /dev/null
+++ b/crates/tui-test/src/wait.rs
@@ -0,0 +1,771 @@
+//! 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.
+//!
+//! 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};
+
+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")
+}
+
+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(),
+ options.quiet.as_millis(),
+ match options.mode {
+ WaitQuietMode::Visual => "visual",
+ WaitQuietMode::Bytes => "output",
+ }
+ ))
+}
+
+/// 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 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
+/// 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() -> Result, TuiTestError>,
+) -> Result<(), TuiTestError> {
+ 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"))?;
+ if cancelled() {
+ return Err(cancelled_error());
+ }
+ let mut previous = if options.mode == WaitQuietMode::Visual {
+ Some(capture_visual()?)
+ } else {
+ 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() {
+ clock.sleep(first_sleep);
+ }
+ }
+ loop {
+ if cancelled() {
+ return Err(cancelled_error());
+ }
+ if clock.now() >= overall_deadline {
+ return Err(timeout_error(options));
+ }
+ 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);
+ changed
+ }
+ WaitQuietMode::Bytes => {
+ // 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;
+ }
+ }
+ 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 = clock.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 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());
+ }
+ return Ok(());
+ }
+ let sleep = [SAMPLE_INTERVAL]
+ .into_iter()
+ .chain(quiet_deadline.checked_duration_since(observed))
+ .chain(overall_deadline.checked_duration_since(observed))
+ .min()
+ .unwrap_or(SAMPLE_INTERVAL);
+ if !sleep.is_zero() {
+ clock.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, &|| 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/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);
+}
diff --git a/crates/tui-test/tests/quiet_lifecycle.rs b/crates/tui-test/tests/quiet_lifecycle.rs
new file mode 100644
index 00000000..a1d6dedf
--- /dev/null
+++ b/crates/tui-test/tests/quiet_lifecycle.rs
@@ -0,0 +1,289 @@
+//! 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)
+}
+
+/// 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 {
+ 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 (started_tx, started_rx) = mpsc::channel();
+ let closer = session.clone();
+ std::thread::spawn(move || {
+ let _ = started_tx.send(());
+ let _ = done_tx.send(closer.close());
+ });
+ // 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!(
+ 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
+ .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 (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,
+ }));
+ });
+ // 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!(
+ 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
+ .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");
+}
diff --git a/crates/tui-test/tests/request_waits.rs b/crates/tui-test/tests/request_waits.rs
new file mode 100644
index 00000000..28f2fa68
--- /dev/null
+++ b/crates/tui-test/tests/request_waits.rs
@@ -0,0 +1,833 @@
+//! U10 request waits: quiet intervals and per-request cancellation.
+//!
+//! 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},
+ mpsc, Arc, Mutex,
+};
+use std::time::Duration;
+
+use tui_test::{
+ profile::Rgb, AutomaticRecording, AutomaticRecordingMode, ErrorKind, LocatorQuery, OpenOptions,
+ Operation, RunOptions, ScrollTarget, Session, SnapshotScope, Timeouts, WaitQuietMode,
+ WaitQuietOptions,
+};
+
+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 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,
+ profile: defaults.profile,
+ style: defaults.style,
+ cols: 80,
+ rows: 24,
+ cwd: None,
+ env: vec![(FIXTURE.to_string(), mode.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 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 {
+ query: LocatorQuery::text(text),
+ not: false,
+ timeout_ms: Some(SETTLE_TIMEOUT_MS),
+ })
+ .expect("settle marker appears");
+}
+
+/// 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.
+type EntryGate = (Arc>>>, mpsc::Receiver<()>);
+
+fn entry_gate() -> EntryGate {
+ 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(());
+ }
+}
+
+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 {
+ 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 session = ready_session("a-busy", "busy");
+ let options =
+ WaitQuietOptions::new(Duration::from_millis(800), QUIET).expect("test options are valid");
+ let error = session
+ .wait_quiet(&options)
+ .expect_err("continuous visible output must time out");
+ assert!(is_timeout(&error), "unexpected error: {error}");
+ 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 session = ready_session("a-neutral", "neutral");
+ session
+ .wait_quiet(&visual_options(QUIET))
+ .expect("equal-screen output must not delay visual quiet");
+ wait_for_text(&session, "NEUTRAL-DONE");
+ session.close().expect("close terminal");
+
+ // 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");
+ session.close().expect("close terminal");
+}
+
+#[test]
+fn u10_a_late_visual_change_restarts_then_succeeds() {
+ // 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 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 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");
+ assert!(is_timeout(&error), "unexpected error: {error}");
+ session.close().expect("close terminal");
+}
+
+#[test]
+fn u10_b_cancellation_is_bounded() {
+ 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, &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 error = join_bounded(handle, "cancelled wait")
+ .expect_err("cancelled wait must report cancellation");
+ assert!(is_cancelled(&error), "unexpected error: {error}");
+ 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 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, &move || {
+ signal_entry(&gate);
+ check()
+ })
+ });
+ let second = session.clone();
+ let other = std::thread::spawn(move || second.wait_quiet(&visual_options(QUIET)));
+ await_entry(&entered, "cancelled wait");
+ flag.store(true, Ordering::Release);
+ let error =
+ join_bounded(cancelled, "cancelled wait").expect_err("first wait must report cancellation");
+ assert!(is_cancelled(&error), "unexpected error: {error}");
+ join_bounded(other, "other wait").expect("other caller is not cancelled");
+
+ // The child still runs: it echoes 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 session = ready_session("d-after", "idle");
+ 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 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.
+ 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 (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}");
+
+ // 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)));
+ join_bounded(one, "first concurrent wait").expect("first concurrent wait");
+ join_bounded(two, "second concurrent wait").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 session = ready_session("f-callers", "idle");
+ 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, "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)
+ .expect("concurrent capture");
+ session
+ .capture_snapshot(SnapshotScope::Viewport)
+ .expect("concurrent viewport capture");
+ }
+ 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);
+ }
+ });
+ 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 session = Session::new(session_name("f-scroll"));
+ 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 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");
+ join_bounded(wait, "wait across scroll").expect("scroll must not reset timer");
+ 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 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 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 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 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 = ready_session("h-title", "idle");
+ let quiet = Duration::from_millis(500);
+ 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, "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("\x1b]0;U10-TITLE-CHANGE\x07".to_string()),
+ })
+ .expect("set title");
+ join_bounded(wait, "title wait").expect("title change restarts");
+ let snapshot = session
+ .capture_snapshot(SnapshotScope::ActiveScreen)
+ .expect("capture after title change");
+ assert!(
+ snapshot
+ .title()
+ .is_some_and(|title| title.contains("U10-TITLE-CHANGE")),
+ "title change did not land: {snapshot:?}"
+ );
+ session.close().expect("close terminal");
+}
+
+#[test]
+fn u10_h_palette_change_restarts_visual_quiet() {
+ let session = ready_session("h-palette", "idle");
+ let quiet = Duration::from_millis(500);
+ 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, "palette wait");
+ session
+ .execute(Operation::Submit {
+ data: Some("\x1b]4;9;rgb:ff/00/00\x07".to_string()),
+ })
+ .expect("set palette entry");
+ 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"
+ );
+ session.close().expect("close terminal");
+}
+
+#[test]
+fn u10_h_cursor_move_restarts_visual_quiet() {
+ // 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 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 session = ready_session("h-resize", "idle");
+ let quiet = Duration::from_millis(500);
+ 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, "resize wait");
+ session
+ .execute(Operation::Resize {
+ cols: 100,
+ rows: 30,
+ })
+ .expect("resize terminal");
+ 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 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");
+ 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");
+ session.close().expect("close terminal");
+}
+
+// U10-J: equal quiet and overall deadlines return timeout.
+#[test]
+fn u10_j_equal_deadlines_return_timeout() {
+ 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 error = session
+ .wait_quiet(&options)
+ .expect_err("equal deadlines must time out");
+ assert!(is_timeout(&error), "unexpected error: {error}");
+ }
+ 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());
+ 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);
+}