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\n

Failure report

\n

terminal evidence unavailable

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

Operation

\n

{}

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

Reason

\n

{}

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

Summary

\n

{}

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

Context

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

none

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

History

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

none

\n"); + } else { + out.push_str("
    \n"); + for event in &details.recent_operations { + let _ = writeln!( + out, + "
  1. {} ({})
  2. ", + escape_html(&event.name), + escape_html(&event.result) + ); + } + out.push_str("
\n"); + } + out.push_str("\n\n"); + out +} + +pub(crate) fn write_observation_free_artifact( + options: &FailureArtifactOptions, + details: &mut FailureReport, + 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); +}