1use std::path::PathBuf;
4use std::sync::{Arc, Mutex, MutexGuard};
5use std::time::{Duration, Instant};
6
7use crate::api::{
8 AutomaticRecording, AutomaticRecordingMode, Cell, CellColor, ClipboardPattern, Cursor,
9 EffectiveTimeouts, ErrorKind, LocatorQuery, LocatorSelector, OpenOptions, OpenResult,
10 Operation, OperationResult, PackedScreen, RunOptions, RuntimeStatus, ScreenshotResult, Size,
11 SnapshotResult, TextAnchor, TextMatch, TextSelector, TextStyle, TuiTestError,
12};
13use crate::assert::color::{self, Expected};
14use crate::assert::snapshot::{self, SnapshotStatus};
15use crate::config::{self, POLL_DELAY_MS};
16use crate::diagnostics::strings::{
17 base_error_message, capture_error_message, diagnostic_hints, diagnostic_operation_name,
18 format_timeout, locator_failure_message, operation_timeout, safe_operation_summary,
19 timeout_message, title_timeout_message_from_actual, truncate_diagnostic_value,
20};
21use crate::diagnostics::{
22 allocate_artifact_directory, allocate_trace_directory, elapsed_ms, failure_reason,
23 recording_temp_path, write_failure_artifact, ArtifactInputs, CellMismatch, CellStyleEvaluation,
24 ExecutionContext, FailureArtifactRef, FailureArtifactStatus, FailureObservation, FailureReason,
25 FailureReport, InputDetails, LocatorFailureReason, OperationEvent, OperationExpectation,
26 OperationHistory, PreparedRecording, ProcessDiagnostics, RecordingDiagnostics, RecordingStatus,
27 RuntimeDiagnostics, TraceMode, TraceOptions, TraceOutcome, RECORDING_COPY_LIMIT,
28};
29use crate::diagnostics::{comparison_failure, merge_failure_details};
30use crate::input::{keys, mouse};
31use crate::logger::Logger;
32use crate::session::{
33 capture_visual_state, try_capture_visual_state, Session as TerminalSession, TermState,
34 TextHighlight,
35};
36use crate::terminal::cell::{rows_to_strings, Attrs, Color, EmuCell};
37use crate::terminal::emu::{
38 ClipboardType, CursorShape, Emulator, KeyboardMode, MouseMode, TerminalMode,
39};
40use crate::terminal::locator::{self, Pattern};
41
42pub struct Engine {
43 name: String,
44 operations: Mutex<()>,
45 session: Mutex<Option<TerminalSession>>,
46 spawn_spec: Mutex<Option<SpawnSpec>>,
47 live: Arc<Mutex<Option<LiveTarget>>>,
48 interrupt: Mutex<Option<InterruptTarget>>,
49 logger: Arc<Logger>,
50 default_recording_path: PathBuf,
51 recording: Mutex<RecordingState>,
52 trace: Mutex<TraceState>,
53 operation_history: Mutex<OperationHistory>,
54}
55
56#[derive(Clone)]
57struct SpawnSpec {
58 command: SpawnCommand,
59 resolved_cwd: Option<PathBuf>,
60 retention: crate::diagnostics::DiagnosticRetentionOptions,
61 trace: Option<TraceOptions>,
62}
63
64#[derive(Clone)]
65enum SpawnCommand {
66 Open(OpenOptions),
67 Run(RunOptions),
68}
69
70impl SpawnSpec {
71 fn restart(mut self) -> Self {
72 match &mut self.command {
73 SpawnCommand::Open(options) => options.restart = true,
74 SpawnCommand::Run(options) => options.restart = true,
75 }
76 self
77 }
78
79 fn resize(&mut self, cols: u16, rows: u16) {
80 match &mut self.command {
81 SpawnCommand::Open(options) => {
82 options.cols = cols;
83 options.rows = rows;
84 }
85 SpawnCommand::Run(options) => {
86 options.cols = cols;
87 options.rows = rows;
88 }
89 }
90 }
91}
92
93#[derive(Clone)]
94struct RecordingState {
95 path: Option<PathBuf>,
96 mode: AutomaticRecordingMode,
97 failed: bool,
98}
99
100#[derive(Default)]
101struct TraceState {
102 options: TraceOptions,
103 artifact: Option<FailureArtifactRef>,
104 context: std::collections::BTreeMap<String, String>,
105 owned_directories: Vec<PathBuf>,
106 pending_startup_outcome: bool,
107}
108
109impl TraceState {
110 fn diagnostic_context(
111 &self,
112 context: &ExecutionContext,
113 ) -> std::collections::BTreeMap<String, String> {
114 let mut diagnostic_context = self.context.clone();
115 diagnostic_context.extend(context.sanitized_context());
116 ExecutionContext {
117 diagnostic_context,
118 ..Default::default()
119 }
120 .sanitized_context()
121 }
122}
123
124#[derive(Clone)]
125struct InterruptTarget {
126 pty: Arc<Mutex<crate::terminal::pty::Pty>>,
127 cancelled: Arc<std::sync::atomic::AtomicBool>,
128}
129
130struct LiveTarget {
131 state: Arc<Mutex<TermState>>,
132 pty: Arc<Mutex<crate::terminal::pty::Pty>>,
133 shell: Option<&'static str>,
134}
135
136#[derive(Clone)]
137struct OperationMetadata {
138 sequence: u64,
139 name: String,
140 timeout_ms: Option<u64>,
141 started_at: Instant,
142 started_ms: u64,
143 screen_before: u64,
144 safe_summary: String,
145 is_assertion: bool,
146 expectation: Option<OperationExpectation>,
147 input: Option<InputDetails>,
148}
149
150impl OperationMetadata {
151 fn pending_event(&self, result: &str, screen_at_return: u64) -> OperationEvent {
152 OperationEvent {
153 sequence: self.sequence,
154 name: self.name.clone(),
155 started_ms: self.started_ms,
156 ended_ms: self.started_ms.saturating_add(elapsed_ms(self.started_at)),
157 result: result.into(),
158 screen_before: self.screen_before,
159 screen_at_return,
160 safe_summary: self.safe_summary.clone(),
161 is_assertion: self.is_assertion,
162 expectation: self.expectation.clone(),
163 input: self.input.clone(),
164 }
165 }
166}
167
168pub struct LiveFrame {
169 pub grid: Vec<Vec<EmuCell>>,
170 pub cursor: (u16, u16),
171 pub size: (u16, u16),
172 pub keyboard_mode: KeyboardMode,
173 pub cursor_key_application: bool,
174 pub bracketed_paste: bool,
175 pub mouse_mode: MouseMode,
176 pub exited: Option<i32>,
177 pub shell: Option<&'static str>,
178}
179
180fn operation_summary(operation: &Operation) -> String {
183 match operation {
184 Operation::Open(options) => format!(
185 "Open {{ backend: {}, shell: {:?}, scrollback: {}, {}x{}, cwd: {:?}, wait_ready: {:?}, restart: {}, timeouts: {:?}, env: <{} vars> }}",
186 options.backend.as_str(),
187 options.shell,
188 options.profile.scrollback,
189 options.cols,
190 options.rows,
191 options.cwd,
192 options.wait_ready,
193 options.restart,
194 options.timeouts,
195 options.env.len()
196 ),
197 Operation::Run(options) => format!(
198 "Run {{ backend: {}, program: {:?}, args: {:?}, scrollback: {}, {}x{}, cwd: {:?}, wait_ready: {:?}, restart: {}, timeouts: {:?}, env: <{} vars> }}",
199 options.backend.as_str(),
200 options.program,
201 options.args,
202 options.profile.scrollback,
203 options.cols,
204 options.rows,
205 options.cwd,
206 options.wait_ready,
207 options.restart,
208 options.timeouts,
209 options.env.len()
210 ),
211 other => format!("{other:?}"),
212 }
213}
214
215impl Engine {
216 pub fn new(name: String, logger: Arc<Logger>, recording_path: PathBuf) -> Self {
217 Self {
218 name,
219 operations: Mutex::new(()),
220 session: Mutex::new(None),
221 spawn_spec: Mutex::new(None),
222 live: Arc::new(Mutex::new(None)),
223 interrupt: Mutex::new(None),
224 logger,
225 default_recording_path: recording_path.clone(),
226 recording: Mutex::new(RecordingState {
227 path: None,
228 mode: AutomaticRecordingMode::Disabled,
229 failed: false,
230 }),
231 trace: Mutex::new(TraceState::default()),
232 operation_history: Mutex::new(OperationHistory::new()),
233 }
234 }
235
236 pub fn execute(&self, operation: Operation) -> Result<OperationResult, TuiTestError> {
237 self.execute_with_context(operation, ExecutionContext::default())
238 }
239
240 pub fn execute_with_context(
241 &self,
242 operation: Operation,
243 context: ExecutionContext,
244 ) -> Result<OperationResult, TuiTestError> {
245 if let Some(artifact) = &context.artifact {
246 artifact.validate().map_err(TuiTestError::usage)?;
247 }
248 if let Some(trace) = &context.trace {
249 trace.validate().map_err(TuiTestError::usage)?;
250 }
251 let _operation = self
252 .operations
253 .lock()
254 .unwrap_or_else(std::sync::PoisonError::into_inner);
255 if self.logger.enabled() {
256 self.logger
257 .event(&format!("operation {}", operation_summary(&operation)));
258 }
259 let name = context
260 .operation_name
261 .clone()
262 .unwrap_or_else(|| diagnostic_operation_name(&operation).to_string());
263 let screen_before = self.capture_current_screen_sequence(true, false);
264 let canonical_name = diagnostic_operation_name(&operation);
265 let is_assertion = canonical_name.starts_with("expect.")
266 || canonical_name.starts_with("wait.")
267 || matches!(canonical_name, "locator.wait" | "locator.resolve");
268 let started_ms = self.current_session_elapsed_ms().unwrap_or(0);
269 let mut metadata = OperationMetadata {
270 sequence: 0,
271 name: name.clone(),
272 timeout_ms: operation_timeout(&operation),
273 started_at: Instant::now(),
274 started_ms,
275 screen_before,
276 safe_summary: safe_operation_summary(&operation),
277 is_assertion,
278 expectation: OperationExpectation::capture(&operation),
279 input: InputDetails::capture(&operation),
280 };
281 let pending = self
282 .operation_history
283 .lock()
284 .unwrap_or_else(std::sync::PoisonError::into_inner)
285 .begin(
286 name,
287 started_ms,
288 screen_before,
289 metadata.safe_summary.clone(),
290 is_assertion,
291 metadata.expectation.clone(),
292 );
293 metadata.sequence = pending.sequence();
294 let mut result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
295 self.execute_inner(operation, &context, &mut metadata)
296 }))
297 .unwrap_or_else(|payload| {
298 Err(TuiTestError::internal(format!(
299 "native terminal operation panicked: {}",
300 panic_message(payload.as_ref())
301 )))
302 });
303 let failed = result
304 .as_ref()
305 .is_err_and(|error| matches!(error.kind, ErrorKind::Assertion | ErrorKind::Internal));
306 if failed {
307 self.recording
308 .lock()
309 .unwrap_or_else(std::sync::PoisonError::into_inner)
310 .failed = true;
311 }
312 if let Err(error) = &mut result {
313 self.prepare_failure_observation(error);
314 }
315 let pin_checkpoint = is_assertion
316 || (metadata.input.is_some()
317 && self
318 .trace
319 .lock()
320 .unwrap_or_else(std::sync::PoisonError::into_inner)
321 .options
322 .mode
323 != TraceMode::Off);
324 let screen_at_return = result
325 .as_ref()
326 .err()
327 .and_then(|error| error.observation.as_deref())
328 .map_or_else(
329 || self.capture_current_screen_sequence(true, pin_checkpoint),
330 |observation| observation.screen_sequence,
331 );
332 let result_name = match &result {
333 Ok(_) => "ok",
334 Err(error) => error.kind.as_str(),
335 };
336 let ended_ms = result
338 .as_ref()
339 .err()
340 .and_then(|error| error.observation.as_deref())
341 .map_or_else(
342 || self.current_session_elapsed_ms(),
343 |observation| Some(observation.captured_ms),
344 );
345 self.operation_history
346 .lock()
347 .unwrap_or_else(std::sync::PoisonError::into_inner)
348 .finish(
349 pending,
350 ended_ms,
351 screen_at_return,
352 result_name,
353 metadata.input.clone(),
354 );
355 if let Err(error) = &mut result {
356 let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
357 self.finalize_failure(error, &context, &metadata);
358 }));
359 error.observation = None;
360 error.report = None;
361 }
362 result
363 }
364
365 fn execute_inner(
366 &self,
367 operation: Operation,
368 context: &ExecutionContext,
369 metadata: &mut OperationMetadata,
370 ) -> Result<OperationResult, TuiTestError> {
371 match operation {
372 Operation::FinishTrace { failed } => {
373 self.finish_trace(context, Some(failed))?;
374 Ok(OperationResult::Unit)
375 }
376 Operation::Open(options) => self
377 .open(options, context, metadata)
378 .map(OperationResult::Open),
379 Operation::Run(options) => self
380 .run(options, context, metadata)
381 .map(OperationResult::Open),
382 Operation::Restart {
383 graceful_timeout_ms,
384 } => self
385 .restart(graceful_timeout_ms, context, metadata)
386 .map(OperationResult::Open),
387 Operation::Close => {
388 let trace_result = self.finish_trace(context, None);
389 *self
390 .live
391 .lock()
392 .unwrap_or_else(std::sync::PoisonError::into_inner) = None;
393 *self
394 .interrupt
395 .lock()
396 .unwrap_or_else(std::sync::PoisonError::into_inner) = None;
397 if let Some(session) = self.lock_session().take() {
398 session.kill();
399 drop(session);
400 }
401 *self
402 .spawn_spec
403 .lock()
404 .unwrap_or_else(std::sync::PoisonError::into_inner) = None;
405 self.cleanup_recording();
406 self.trace
407 .lock()
408 .unwrap_or_else(std::sync::PoisonError::into_inner)
409 .pending_startup_outcome = false;
410 trace_result?;
411 Ok(OperationResult::Unit)
412 }
413 Operation::Resize { cols, rows } => {
414 let result = self.with_session(|session| {
415 dispatch(
416 session,
417 Operation::Resize { cols, rows },
418 &mut metadata.input,
419 )
420 });
421 if result.is_ok() {
422 if let Some(spec) = self
423 .spawn_spec
424 .lock()
425 .unwrap_or_else(std::sync::PoisonError::into_inner)
426 .as_mut()
427 {
428 spec.resize(cols, rows);
429 }
430 }
431 result
432 }
433 other => self.with_session(|session| dispatch(session, other, &mut metadata.input)),
434 }
435 }
436
437 fn open(
438 &self,
439 options: OpenOptions,
440 context: &ExecutionContext,
441 metadata: &OperationMetadata,
442 ) -> Result<OpenResult, TuiTestError> {
443 self.spawn(
444 SpawnSpec {
445 command: SpawnCommand::Open(options),
446 resolved_cwd: None,
447 retention: context.retention,
448 trace: context.trace.clone(),
449 },
450 context,
451 metadata,
452 )
453 }
454
455 fn run(
456 &self,
457 options: RunOptions,
458 context: &ExecutionContext,
459 metadata: &OperationMetadata,
460 ) -> Result<OpenResult, TuiTestError> {
461 self.spawn(
462 SpawnSpec {
463 command: SpawnCommand::Run(options),
464 resolved_cwd: None,
465 retention: context.retention,
466 trace: context.trace.clone(),
467 },
468 context,
469 metadata,
470 )
471 }
472
473 fn restart(
474 &self,
475 graceful_timeout_ms: u64,
476 context: &ExecutionContext,
477 metadata: &OperationMetadata,
478 ) -> Result<OpenResult, TuiTestError> {
479 let spec = self
480 .spawn_spec
481 .lock()
482 .unwrap_or_else(std::sync::PoisonError::into_inner)
483 .clone()
484 .ok_or_else(TuiTestError::no_restart_metadata)?;
485
486 if let Some(session) = self.lock_session().as_ref() {
487 if session.is_alive()? {
488 if let Err(error) = session
489 .pty
490 .lock()
491 .unwrap_or_else(std::sync::PoisonError::into_inner)
492 .signal("INT")
493 {
494 self.logger
495 .event(&format!("restart interrupt failed error={error}"));
496 }
497 let start = Instant::now();
498 let timeout = Duration::from_millis(graceful_timeout_ms);
499 while session.is_alive()? && start.elapsed() < timeout {
500 std::thread::sleep(Duration::from_millis(POLL_DELAY_MS));
501 }
502 }
503 }
504
505 let mut context = context.clone();
506 if context.trace.is_none() {
507 context.trace = spec.trace.clone();
508 }
509 self.spawn(spec.restart(), &context, metadata)
510 }
511
512 fn spawn(
513 &self,
514 mut spec: SpawnSpec,
515 context: &ExecutionContext,
516 metadata: &OperationMetadata,
517 ) -> Result<OpenResult, TuiTestError> {
518 let diagnostics = spec.retention;
519 let (
520 shell,
521 program,
522 backend,
523 profile,
524 cols,
525 rows,
526 cwd,
527 env,
528 wait_ready,
529 restart,
530 timeouts,
531 mut recording,
532 ) = match &spec.command {
533 SpawnCommand::Open(options) => (
534 options.shell,
535 None,
536 options.backend,
537 options.profile,
538 options.cols,
539 options.rows,
540 options.cwd.clone(),
541 options.env.clone(),
542 options.wait_ready,
543 options.restart,
544 options.timeouts,
545 options.recording.clone(),
546 ),
547 SpawnCommand::Run(options) => {
548 let mut program = Vec::with_capacity(options.args.len() + 1);
549 program.push(options.program.clone());
550 program.extend(options.args.clone());
551 (
552 None,
553 Some(program),
554 options.backend,
555 options.profile,
556 options.cols,
557 options.rows,
558 options.cwd.clone(),
559 options.env.clone(),
560 options.wait_ready,
561 options.restart,
562 options.timeouts,
563 options.recording.clone(),
564 )
565 }
566 };
567 recording.validate()?;
568 let mut trace_options = context.trace.clone().unwrap_or_default();
569 trace_options.directory =
570 std::path::absolute(&trace_options.directory).map_err(|error| {
571 TuiTestError::internal(format!("failed to resolve trace directory: {error}"))
572 })?;
573 spec.trace = context.trace.as_ref().map(|_| trace_options.clone());
574 if context.trace.is_some() {
575 recording.mode = match trace_options.mode {
576 TraceMode::Off => AutomaticRecordingMode::Disabled,
577 TraceMode::On => AutomaticRecordingMode::Always,
578 TraceMode::OnFailure => AutomaticRecordingMode::OnFailure,
579 };
580 } else if context
581 .artifact
582 .as_ref()
583 .is_some_and(|artifact| artifact.include_recording)
584 && recording.mode == AutomaticRecordingMode::Disabled
585 {
586 recording.mode = AutomaticRecordingMode::OnFailure;
587 }
588 match &mut spec.command {
589 SpawnCommand::Open(options) => options.recording = recording.clone(),
590 SpawnCommand::Run(options) => options.recording = recording.clone(),
591 }
592 diagnostics.validate().map_err(TuiTestError::usage)?;
593 let mut current = self.lock_session();
594 if let Some(previous) = current.as_ref() {
595 if !restart && previous.is_alive()? {
596 return Ok(OpenResult {
597 shell_pid: previous.pid(),
598 session: self.name.clone(),
599 ready: previous.is_ready(),
600 recording: self.recording_path_string(),
601 });
602 }
603 }
604 let cwd = match &spec.resolved_cwd {
605 Some(cwd) => cwd.clone(),
606 None => std::path::absolute(cwd.as_deref().unwrap_or(".")).map_err(|error| {
607 TuiTestError::internal(format!("failed to resolve session cwd: {error}"))
608 })?,
609 };
610 spec.resolved_cwd = Some(cwd.clone());
611 let recording_required = recording.directory.is_some();
612 let recording_path = self.resolve_recording_path(&recording)?;
613
614 *self
615 .live
616 .lock()
617 .unwrap_or_else(std::sync::PoisonError::into_inner) = None;
618 *self
619 .interrupt
620 .lock()
621 .unwrap_or_else(std::sync::PoisonError::into_inner) = None;
622 if let Some(previous) = current.take() {
623 self.finish_trace_with_session(&previous, context, None)?;
624 previous.kill();
625 drop(previous);
626 }
627 drop(current);
628 self.operation_history
629 .lock()
630 .unwrap_or_else(std::sync::PoisonError::into_inner)
631 .reset_session();
632 self.discard_recording();
633 *self
634 .trace
635 .lock()
636 .unwrap_or_else(std::sync::PoisonError::into_inner) = TraceState {
637 options: trace_options,
638 artifact: None,
639 context: context.sanitized_context(),
640 owned_directories: Vec::new(),
641 pending_startup_outcome: false,
642 };
643 *self
644 .recording
645 .lock()
646 .unwrap_or_else(std::sync::PoisonError::into_inner) = RecordingState {
647 path: None,
648 mode: recording.mode,
649 failed: false,
650 };
651 let session = TerminalSession::open(
652 shell,
653 program.clone(),
654 backend,
655 profile,
656 cols,
657 rows,
658 Some(cwd),
659 env,
660 timeouts,
661 diagnostics,
662 self.logger.clone(),
663 recording_path.clone(),
664 recording_required,
665 )
666 .map_err(|error| TuiTestError::internal(format!("failed to open session: {error}")))?;
667 self.recording
668 .lock()
669 .unwrap_or_else(std::sync::PoisonError::into_inner)
670 .path = session
671 .automatic_recording_enabled()
672 .then_some(recording_path)
673 .flatten();
674
675 let shell_pid = session.pid();
676 let ready_timeout = open_ready_timeout(&session);
677 let ready = if wait_ready.unwrap_or(program.is_none()) {
678 await_ready(&session, ready_timeout)
679 } else {
680 session
681 .state
682 .lock()
683 .unwrap_or_else(std::sync::PoisonError::into_inner)
684 .tracker
685 .is_ready()
686 };
687 if wait_ready == Some(true) && !ready {
688 let mut error = startup_readiness_error(&metadata.name, ready_timeout);
689 error.observation = Some(Box::new(capture_failure_observation(&session)));
690 let metadata = OperationMetadata {
691 started_ms: 0,
692 screen_before: 0,
693 ..metadata.clone()
694 };
695 self.finalize_failure_with_session(
696 &mut error,
697 context,
698 &metadata,
699 Some(&session),
700 true,
701 );
702 self.trace
703 .lock()
704 .unwrap_or_else(std::sync::PoisonError::into_inner)
705 .pending_startup_outcome = true;
706 session.kill();
707 return Err(error);
708 }
709 let live = LiveTarget {
710 state: session.state.clone(),
711 pty: session.pty.clone(),
712 shell: session.shell.map(|value| value.as_str()),
713 };
714 *self
715 .interrupt
716 .lock()
717 .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(InterruptTarget {
718 pty: session.pty.clone(),
719 cancelled: session.cancelled.clone(),
720 });
721 *self.lock_session() = Some(session);
722 *self
723 .live
724 .lock()
725 .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(live);
726 *self
727 .spawn_spec
728 .lock()
729 .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(spec);
730 Ok(OpenResult {
731 shell_pid,
732 session: self.name.clone(),
733 ready,
734 recording: self.recording_path_string(),
735 })
736 }
737
738 fn with_session<F>(&self, operation: F) -> Result<OperationResult, TuiTestError>
739 where
740 F: FnOnce(&mut TerminalSession) -> Result<OperationResult, TuiTestError>,
741 {
742 let mut guard = self.lock_session();
743 let session = guard.as_mut().ok_or_else(TuiTestError::no_session)?;
744 if let Some(fault) = session.fault() {
750 return Err(
751 TuiTestError::internal(fault.clone()).with_report(FailureReport::new(
752 "terminal.operation",
753 None,
754 FailureReason::EmulatorFault,
755 fault,
756 )),
757 );
758 }
759 operation(session)
760 }
761
762 fn capture_current_screen_sequence(&self, force: bool, pin: bool) -> u64 {
763 let mut guard = self.lock_session();
764 let Some(session) = guard.as_mut() else {
765 return 0;
766 };
767 let mut state = session
768 .state
769 .lock()
770 .unwrap_or_else(std::sync::PoisonError::into_inner);
771 let sequence = try_capture_visual_state(&mut state, force).unwrap_or(0);
772 if pin && sequence != 0 {
773 state.screen_history.pin_current();
774 }
775 sequence
776 }
777
778 fn current_session_elapsed_ms(&self) -> Option<u64> {
779 let guard = self.lock_session();
780 guard.as_ref().map(|session| {
781 let state = session
782 .state
783 .lock()
784 .unwrap_or_else(std::sync::PoisonError::into_inner);
785 elapsed_ms(state.started_at)
786 })
787 }
788
789 fn prepare_failure_observation(&self, error: &mut TuiTestError) {
790 if error.observation.is_some()
791 || !matches!(error.kind, ErrorKind::Assertion | ErrorKind::Internal)
792 {
793 return;
794 }
795 let guard = self.lock_session();
796 if let Some(session) = guard.as_ref() {
797 error.observation = safe_capture_failure_observation(session).map(Box::new);
798 }
799 }
800
801 fn finalize_failure(
802 &self,
803 error: &mut TuiTestError,
804 context: &ExecutionContext,
805 metadata: &OperationMetadata,
806 ) {
807 if !matches!(error.kind, ErrorKind::Assertion | ErrorKind::Internal) {
808 return;
809 }
810 if error.details.is_some() {
811 return;
812 }
813 let guard = self.lock_session();
814 self.finalize_failure_with_session(error, context, metadata, guard.as_ref(), false);
815 }
816
817 fn finalize_failure_with_session(
818 &self,
819 error: &mut TuiTestError,
820 context: &ExecutionContext,
821 metadata: &OperationMetadata,
822 session: Option<&TerminalSession>,
823 include_pending_operation: bool,
824 ) {
825 if !matches!(error.kind, ErrorKind::Assertion | ErrorKind::Internal) {
826 return;
827 }
828 if error.details.is_some() {
829 return;
830 }
831 let captured = if error.observation.is_none() {
832 session.and_then(safe_capture_failure_observation)
833 } else {
834 None
835 };
836 let observation = error.observation.as_deref().or(captured.as_ref());
837 let (summary, summary_truncated) =
838 truncate_diagnostic_value(base_error_message(&error.message), 64 * 1024);
839 let mut details = FailureReport::new(
840 metadata.name.clone(),
841 metadata.timeout_ms,
842 failure_reason(error, observation),
843 summary,
844 );
845 details.truncated = summary_truncated;
846 details.operation.elapsed_ms = metadata.started_at.elapsed().as_millis() as u64;
847 details.operation.started_screen_sequence = metadata.screen_before;
848 details.operation.failed_screen_sequence = observation
849 .as_ref()
850 .map_or(0, |value| value.screen_sequence);
851 if let Some(existing) = error.report.take() {
852 merge_failure_details(&mut details, *existing);
853 }
854 details.truncated |= details
855 .locator
856 .as_ref()
857 .is_some_and(|locator| locator.stages_truncated);
858 let trace = self
859 .trace
860 .lock()
861 .unwrap_or_else(std::sync::PoisonError::into_inner)
862 .options
863 .clone();
864 let trace_artifact = (trace.mode != TraceMode::Off).then(|| trace.artifact_options());
865 if !context
866 .artifact
867 .iter()
868 .chain(trace_artifact.iter())
869 .any(|options| options.mode != crate::diagnostics::FailureArtifactMode::None)
870 {
871 let failure = details.failure_details();
872 error.message = failure.summary.clone();
873 error.details = Some(Box::new(failure));
874 return;
875 }
876 details.context = self
877 .trace
878 .lock()
879 .unwrap_or_else(std::sync::PoisonError::into_inner)
880 .diagnostic_context(context);
881 details.recent_operations = self
882 .operation_history
883 .lock()
884 .unwrap_or_else(std::sync::PoisonError::into_inner)
885 .snapshot();
886 if include_pending_operation {
887 details.recent_operations.push(
888 metadata.pending_event(
889 error.kind.as_str(),
890 observation
891 .as_ref()
892 .map_or(metadata.screen_before, |value| value.screen_sequence),
893 ),
894 );
895 }
896
897 details.truncated |= details.recent_operations.iter().any(|event| {
898 matches!(
899 event.expectation,
900 Some(OperationExpectation::Unavailable { .. })
901 ) || event
902 .input
903 .as_ref()
904 .is_some_and(InputDetails::is_unavailable)
905 });
906 if let Some(observation) = &observation {
907 details.terminal = Some(observation.terminal());
908 details.process = Some(observation.process.clone());
909 details.runtime = Some(RuntimeDiagnostics {
910 session_name: Some(self.name.clone()),
911 ..observation.runtime.clone()
912 });
913 details.recording = Some(self.recording_diagnostics(observation));
914 }
915 details.hints = diagnostic_hints(&details);
916
917 details.finish_signature();
918
919 let mut exports_truncated = false;
920 for (is_trace, options) in [
921 (false, context.artifact.as_ref()),
922 (true, trace_artifact.as_ref()),
923 ] {
924 let (Some(options), Some(observation)) = (options, observation) else {
925 continue;
926 };
927 if options.mode != crate::diagnostics::FailureArtifactMode::None {
928 let mut export_details = details.clone();
929 export_details.outcome = is_trace.then_some(TraceOutcome::Failed);
930 let allocated = if is_trace {
931 allocate_trace_directory(&options.directory)
932 } else {
933 allocate_artifact_directory(&options.directory)
934 };
935 let mut owned_directory = None;
936 let artifact = match allocated {
937 Ok(directory) => {
938 if is_trace {
939 owned_directory = Some(directory.clone());
940 }
941 let prepared_recording = if options.include_recording {
942 session.and_then(|session| {
943 self.prepare_recording_artifact(
944 session,
945 observation,
946 &directory,
947 &mut export_details,
948 )
949 })
950 } else {
951 None
952 };
953 write_failure_artifact(
954 options,
955 ArtifactInputs {
956 details: &mut export_details,
957 observation,
958 recording: prepared_recording,
959 },
960 directory,
961 )
962 }
963 Err(error) => FailureArtifactRef {
964 status: FailureArtifactStatus::Failed,
965 directory: options.directory.to_string_lossy().into_owned(),
966 manifest: None,
967 report: None,
968 report_html: None,
969 timeline: None,
970 screen_text: None,
971 screen_svg: None,
972 recording: None,
973 errors: vec![format!(
974 "failed to allocate failure artifact directory: {error}"
975 )],
976 },
977 };
978 if is_trace {
979 let mut trace = self
980 .trace
981 .lock()
982 .unwrap_or_else(std::sync::PoisonError::into_inner);
983 trace.artifact = Some(artifact.clone());
984 trace.owned_directories.extend(owned_directory);
985 }
986 exports_truncated |= export_details.truncated;
987 if is_trace && artifact.status != FailureArtifactStatus::Written {
988 if let Some(primary) = error.artifact.as_mut() {
989 if primary.status == FailureArtifactStatus::Written {
990 primary.status = FailureArtifactStatus::Partial;
991 }
992 primary.errors.push(format!(
993 "trace export was not fully written at {}",
994 artifact.directory
995 ));
996 primary.errors.extend(
997 artifact
998 .errors
999 .iter()
1000 .map(|message| format!("trace: {message}")),
1001 );
1002 }
1003 }
1004 if !is_trace || error.artifact.is_none() {
1005 error.artifact = Some(Box::new(artifact));
1006 }
1007 }
1008 }
1009 details.truncated |= exports_truncated;
1010 let failure = details.failure_details();
1011 error.message = failure.summary.clone();
1012 error.details = Some(Box::new(failure));
1013 }
1014
1015 fn finish_trace(
1016 &self,
1017 context: &ExecutionContext,
1018 failed: Option<bool>,
1019 ) -> Result<(), TuiTestError> {
1020 std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1021 let session = self.lock_session();
1022 if let Some(session) = session.as_ref() {
1023 self.finish_trace_with_session(session, context, failed)?;
1024 } else {
1025 self.finish_failed_startup_trace(failed)?;
1026 }
1027 Ok(())
1028 }))
1029 .unwrap_or_else(|payload| {
1030 Err(TuiTestError::internal(format!(
1031 "terminal trace finalization panicked: {}",
1032 panic_message(payload.as_ref())
1033 )))
1034 })
1035 }
1036
1037 fn finish_failed_startup_trace(&self, failed: Option<bool>) -> Result<(), TuiTestError> {
1038 let Some(failed) = failed else {
1039 return Ok(());
1040 };
1041 let mode = {
1042 let trace = self
1043 .trace
1044 .lock()
1045 .unwrap_or_else(std::sync::PoisonError::into_inner);
1046 if !trace.pending_startup_outcome {
1047 return Ok(());
1048 }
1049 trace.options.mode
1050 };
1051 if !failed && mode == TraceMode::OnFailure {
1052 self.discard_trace_artifacts()?;
1053 }
1054 let mut recording = self
1055 .recording
1056 .lock()
1057 .unwrap_or_else(std::sync::PoisonError::into_inner);
1058 if !failed && recording.mode == AutomaticRecordingMode::OnFailure {
1059 if let Some(path) = &recording.path {
1060 match std::fs::remove_file(path) {
1061 Ok(()) => {}
1062 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
1063 Err(error) => {
1064 return Err(TuiTestError::internal(format!(
1065 "failed to discard automatic recording {}: {error}",
1066 path.display()
1067 )));
1068 }
1069 }
1070 }
1071 }
1072 recording.failed = failed;
1073 Ok(())
1074 }
1075
1076 fn discard_trace_artifacts(&self) -> Result<(), TuiTestError> {
1077 let mut trace = self
1078 .trace
1079 .lock()
1080 .unwrap_or_else(std::sync::PoisonError::into_inner);
1081 while let Some(directory) = trace.owned_directories.last() {
1082 std::fs::remove_dir_all(directory).map_err(|error| {
1083 TuiTestError::internal(format!(
1084 "failed to discard superseded trace {}: {error}",
1085 directory.display()
1086 ))
1087 })?;
1088 trace.owned_directories.pop();
1089 }
1090 trace.artifact = None;
1091 Ok(())
1092 }
1093
1094 fn finish_trace_with_session(
1095 &self,
1096 session: &TerminalSession,
1097 context: &ExecutionContext,
1098 failed: Option<bool>,
1099 ) -> Result<(), TuiTestError> {
1100 let previous_failed = self
1101 .recording
1102 .lock()
1103 .unwrap_or_else(std::sync::PoisonError::into_inner)
1104 .failed;
1105 if failed.is_some_and(|failed| failed != previous_failed) {
1106 self.discard_trace_artifacts()?;
1107 }
1108 let failed = failed.unwrap_or(previous_failed);
1109 self.recording
1110 .lock()
1111 .unwrap_or_else(std::sync::PoisonError::into_inner)
1112 .failed = failed;
1113 let options = {
1114 let trace = self
1115 .trace
1116 .lock()
1117 .unwrap_or_else(std::sync::PoisonError::into_inner);
1118 if trace.options.mode == TraceMode::Off || trace.artifact.is_some() {
1119 return Ok(());
1120 }
1121 trace.options.clone()
1122 };
1123 if options.mode == TraceMode::OnFailure && !failed {
1124 return Ok(());
1125 }
1126 let observation = capture_failure_observation(session);
1127 let mut details = FailureReport::new(
1128 "test",
1129 None,
1130 if failed {
1131 FailureReason::TestFailed
1132 } else {
1133 FailureReason::Completed
1134 },
1135 if failed {
1136 "Test failed outside a terminal assertion."
1137 } else {
1138 "Session completed successfully."
1139 },
1140 );
1141 details.outcome = Some(if failed {
1142 TraceOutcome::Failed
1143 } else {
1144 TraceOutcome::Passed
1145 });
1146 details.operation.failed_screen_sequence = observation.screen_sequence;
1147 details.operation.elapsed_ms = observation.captured_ms;
1148 details.terminal = Some(observation.terminal());
1149 details.runtime = Some(RuntimeDiagnostics {
1150 session_name: Some(self.name.clone()),
1151 ..observation.runtime.clone()
1152 });
1153 details.process = Some(observation.process.clone());
1154 details.recording = Some(self.recording_diagnostics(&observation));
1155 details.context = self
1156 .trace
1157 .lock()
1158 .unwrap_or_else(std::sync::PoisonError::into_inner)
1159 .diagnostic_context(context);
1160 details.recent_operations = self
1161 .operation_history
1162 .lock()
1163 .unwrap_or_else(std::sync::PoisonError::into_inner)
1164 .snapshot();
1165 details.truncated = details.recent_operations.iter().any(|event| {
1166 matches!(
1167 event.expectation,
1168 Some(OperationExpectation::Unavailable { .. })
1169 ) || event
1170 .input
1171 .as_ref()
1172 .is_some_and(InputDetails::is_unavailable)
1173 });
1174 details.finish_signature();
1175 let directory = allocate_trace_directory(&options.directory).map_err(|error| {
1176 TuiTestError::internal(format!("failed to allocate trace directory: {error}"))
1177 })?;
1178 let recording =
1179 self.prepare_recording_artifact(session, &observation, &directory, &mut details);
1180 let artifact = write_failure_artifact(
1181 &options.artifact_options(),
1182 ArtifactInputs {
1183 details: &mut details,
1184 observation: &observation,
1185 recording,
1186 },
1187 directory.clone(),
1188 );
1189 {
1190 let mut trace = self
1191 .trace
1192 .lock()
1193 .unwrap_or_else(std::sync::PoisonError::into_inner);
1194 trace.artifact = Some(artifact.clone());
1195 trace.owned_directories.push(directory);
1196 }
1197 if artifact.status != FailureArtifactStatus::Written {
1198 let mut error = TuiTestError::internal(format!(
1199 "trace could not be fully written at {}: {}",
1200 artifact.directory,
1201 if artifact.errors.is_empty() {
1202 "evidence was omitted; see the manifest for details".to_string()
1203 } else {
1204 artifact.errors.join("; ")
1205 }
1206 ));
1207 error.artifact = Some(Box::new(artifact));
1208 return Err(error);
1209 }
1210 Ok(())
1211 }
1212
1213 fn prepare_recording_artifact(
1214 &self,
1215 session: &TerminalSession,
1216 observation: &FailureObservation,
1217 directory: &std::path::Path,
1218 details: &mut FailureReport,
1219 ) -> Option<PreparedRecording> {
1220 if details
1221 .recording
1222 .as_ref()
1223 .is_some_and(|recording| recording.status == RecordingStatus::Disabled)
1224 {
1225 return None;
1226 }
1227 let state = session
1228 .state
1229 .lock()
1230 .unwrap_or_else(std::sync::PoisonError::into_inner);
1231 if details.outcome.is_none()
1232 && (state.visual_revision != observation.output_revision
1233 || state.screen_dirty
1234 || state.screen_history.current_sequence() != observation.screen_sequence)
1235 {
1236 if let Some(recording) = details.recording.as_mut() {
1237 recording.status = RecordingStatus::Omitted;
1238 recording.reason = Some(
1239 "terminal output advanced after the pinned failure observation".to_string(),
1240 );
1241 }
1242 return None;
1243 }
1244 let temporary_path = recording_temp_path(directory);
1245 let result =
1246 session.snapshot_automatic_recording(temporary_path.clone(), RECORDING_COPY_LIMIT);
1247 drop(state);
1248 match result {
1249 Ok(snapshot) => {
1250 if let Some(recording) = details.recording.as_mut() {
1251 recording.status = RecordingStatus::Live;
1252 recording.last_committed_ms = snapshot.last_committed_ms;
1253 recording.path = None;
1254 recording.bytes = Some(snapshot.bytes);
1255 recording.reason = None;
1256 recording.ephemeral = false;
1257 }
1258 Some(PreparedRecording {
1259 temporary_path,
1260 bytes: snapshot.bytes,
1261 sha256: snapshot.sha256,
1262 })
1263 }
1264 Err(error) => {
1265 let message = capture_error_message(&error);
1266 if let Some(recording) = details.recording.as_mut() {
1267 recording.status = if message.contains("maximum byte limit") {
1268 RecordingStatus::Omitted
1269 } else {
1270 RecordingStatus::Failed
1271 };
1272 recording.reason = Some(message);
1273 }
1274 None
1275 }
1276 }
1277 }
1278
1279 fn recording_diagnostics(&self, observation: &FailureObservation) -> RecordingDiagnostics {
1280 let recording = self
1281 .recording
1282 .lock()
1283 .unwrap_or_else(std::sync::PoisonError::into_inner);
1284 let (status, reason) = match (&recording.mode, &recording.path) {
1285 (AutomaticRecordingMode::Disabled, _) => {
1286 (RecordingStatus::Disabled, Some("disabled".to_string()))
1287 }
1288 (_, Some(_)) => (RecordingStatus::Live, None),
1289 _ => (
1290 RecordingStatus::Unavailable,
1291 Some("automatic recording could not be created".to_string()),
1292 ),
1293 };
1294 RecordingDiagnostics {
1295 mode: recording.mode,
1296 status,
1297 failure_offset_ms: observation.captured_ms,
1298 last_committed_ms: None,
1299 path: None,
1300 bytes: None,
1301 reason,
1302 ephemeral: false,
1303 }
1304 }
1305
1306 pub fn status(&self) -> RuntimeStatus {
1307 let guard = self.lock_session();
1308 match guard.as_ref() {
1309 Some(session) => {
1310 let state = session
1311 .state
1312 .lock()
1313 .unwrap_or_else(std::sync::PoisonError::into_inner);
1314 RuntimeStatus {
1315 session: self.name.clone(),
1316 shell_pid: session.pid(),
1317 cols: Some(session.cols),
1318 rows: Some(session.rows),
1319 shell: session.shell.map(|value| value.as_str().to_string()),
1320 exited: state.exited,
1321 timeouts: Some(effective_timeouts(session)),
1322 }
1323 }
1324 None => RuntimeStatus {
1325 session: self.name.clone(),
1326 shell_pid: None,
1327 cols: None,
1328 rows: None,
1329 shell: None,
1330 exited: None,
1331 timeouts: None,
1332 },
1333 }
1334 }
1335
1336 pub fn frame(&self) -> Option<LiveFrame> {
1337 let live = self
1338 .live
1339 .lock()
1340 .unwrap_or_else(std::sync::PoisonError::into_inner);
1341 live.as_ref().map(|target| {
1342 let state = target
1343 .state
1344 .lock()
1345 .unwrap_or_else(std::sync::PoisonError::into_inner);
1346 LiveFrame {
1347 grid: highlighted_rows(&state, false),
1348 cursor: state.emu.cursor(),
1349 size: state.emu.size(),
1350 keyboard_mode: state.emu.keyboard_mode(),
1351 cursor_key_application: state.emu.cursor_key_application(),
1352 bracketed_paste: state.emu.mode(TerminalMode::BracketedPaste),
1353 mouse_mode: state.mouse_mode.relayable(),
1354 exited: state.exited,
1355 shell: target.shell,
1356 }
1357 })
1358 }
1359
1360 pub fn monitor_mouse_size(&self) -> Option<(u16, u16)> {
1361 let live = self
1362 .live
1363 .lock()
1364 .unwrap_or_else(std::sync::PoisonError::into_inner);
1365 live.as_ref().and_then(|target| {
1366 let state = target
1367 .state
1368 .lock()
1369 .unwrap_or_else(std::sync::PoisonError::into_inner);
1370 (state.mouse_mode.relayable() != MouseMode::None).then(|| state.emu.size())
1371 })
1372 }
1373
1374 pub fn write_monitor_input_raw(&self, data: &[u8]) -> Result<(), TuiTestError> {
1377 let Some((state, pty)) = ({
1378 let live = self
1379 .live
1380 .lock()
1381 .unwrap_or_else(std::sync::PoisonError::into_inner);
1382 live.as_ref()
1383 .map(|target| (Arc::clone(&target.state), Arc::clone(&target.pty)))
1384 }) else {
1385 return Ok(());
1386 };
1387 let exited = || {
1388 state
1389 .lock()
1390 .unwrap_or_else(std::sync::PoisonError::into_inner)
1391 .exited
1392 .is_some()
1393 };
1394 if exited() {
1395 return Ok(());
1396 }
1397 let written = pty
1399 .lock()
1400 .unwrap_or_else(std::sync::PoisonError::into_inner)
1401 .write(data);
1402 match written {
1403 Err(error)
1404 if !matches!(
1405 error.kind(),
1406 std::io::ErrorKind::BrokenPipe | std::io::ErrorKind::NotConnected
1407 ) && !exited() =>
1408 {
1409 Err(TuiTestError::internal(error.to_string()))
1410 }
1411 _ => Ok(()),
1412 }
1413 }
1414
1415 pub fn log_event(&self, message: &str) {
1416 self.logger.event(message);
1417 }
1418
1419 pub fn interrupt(&self) {
1420 let target = self
1421 .interrupt
1422 .lock()
1423 .unwrap_or_else(std::sync::PoisonError::into_inner)
1424 .clone();
1425 if let Some(target) = target {
1426 target
1427 .cancelled
1428 .store(true, std::sync::atomic::Ordering::Release);
1429 target
1430 .pty
1431 .lock()
1432 .unwrap_or_else(std::sync::PoisonError::into_inner)
1433 .kill();
1434 }
1435 }
1436
1437 pub fn is_open(&self) -> bool {
1438 self.lock_session().is_some()
1439 }
1440
1441 pub fn recording_path(&self) -> Option<PathBuf> {
1442 self.recording
1443 .lock()
1444 .unwrap_or_else(std::sync::PoisonError::into_inner)
1445 .path
1446 .clone()
1447 }
1448
1449 pub(crate) fn retained_recording_path(&self) -> Option<PathBuf> {
1450 let recording = self
1451 .recording
1452 .lock()
1453 .unwrap_or_else(std::sync::PoisonError::into_inner);
1454 let retain = match recording.mode {
1455 AutomaticRecordingMode::Disabled => false,
1456 AutomaticRecordingMode::OnFailure => recording.failed,
1457 AutomaticRecordingMode::Always => true,
1458 };
1459 retain
1460 .then(|| recording.path.clone())
1461 .flatten()
1462 .filter(|path| path.is_file())
1463 }
1464
1465 pub fn flush_recording(&self) -> Result<(), TuiTestError> {
1466 let _operation = self
1467 .operations
1468 .lock()
1469 .unwrap_or_else(std::sync::PoisonError::into_inner);
1470 if self.recording_path().is_none() {
1471 return Err(TuiTestError::usage("automatic recording is disabled"));
1472 }
1473 let guard = self.lock_session();
1474 if let Some(session) = guard.as_ref() {
1475 return session.flush_recording();
1476 }
1477 if self.recording_path().is_some_and(|path| path.is_file()) {
1478 Ok(())
1479 } else {
1480 Err(TuiTestError::no_session())
1481 }
1482 }
1483
1484 fn resolve_recording_path(
1485 &self,
1486 recording: &AutomaticRecording,
1487 ) -> Result<Option<PathBuf>, TuiTestError> {
1488 if recording.mode == AutomaticRecordingMode::Disabled {
1489 return Ok(None);
1490 }
1491 let Some(directory) = &recording.directory else {
1492 return Ok(Some(self.default_recording_path.clone()));
1493 };
1494 if directory.as_os_str().is_empty() {
1495 return Err(TuiTestError::usage(
1496 "automatic recording directory must not be empty",
1497 ));
1498 }
1499 let directory = if directory.is_absolute() {
1500 directory.clone()
1501 } else {
1502 std::env::current_dir()
1503 .map_err(|error| {
1504 TuiTestError::internal(format!(
1505 "failed to resolve automatic recording directory: {error}"
1506 ))
1507 })?
1508 .join(directory)
1509 };
1510 let name = self
1511 .default_recording_path
1512 .file_name()
1513 .ok_or_else(|| TuiTestError::internal("automatic recording path has no file name"))?;
1514 Ok(Some(directory.join(name)))
1515 }
1516
1517 fn recording_path_string(&self) -> String {
1518 self.recording_path()
1519 .map(|path| path.to_string_lossy().into_owned())
1520 .unwrap_or_default()
1521 }
1522
1523 fn cleanup_recording(&self) {
1524 let recording = self
1525 .recording
1526 .lock()
1527 .unwrap_or_else(std::sync::PoisonError::into_inner);
1528 let keep = match recording.mode {
1529 AutomaticRecordingMode::Disabled => false,
1530 AutomaticRecordingMode::OnFailure => recording.failed,
1531 AutomaticRecordingMode::Always => true,
1532 };
1533 if !keep {
1534 if let Some(path) = &recording.path {
1535 let _ = std::fs::remove_file(path);
1536 }
1537 }
1538 }
1539
1540 fn discard_recording(&self) {
1541 if let Some(path) = self
1542 .recording
1543 .lock()
1544 .unwrap_or_else(std::sync::PoisonError::into_inner)
1545 .path
1546 .as_ref()
1547 {
1548 let _ = std::fs::remove_file(path);
1549 }
1550 }
1551
1552 fn lock_session(&self) -> MutexGuard<'_, Option<TerminalSession>> {
1553 self.session
1554 .lock()
1555 .unwrap_or_else(std::sync::PoisonError::into_inner)
1556 }
1557}
1558
1559impl Drop for Engine {
1560 fn drop(&mut self) {
1561 if let Err(error) = self.finish_trace(
1562 &ExecutionContext::default(),
1563 std::thread::panicking().then_some(true),
1564 ) {
1565 eprintln!("failed to finish terminal trace: {error}");
1566 }
1567 if let Ok(session) = self.session.get_mut() {
1568 if let Some(session) = session.take() {
1569 session.kill();
1570 drop(session);
1571 }
1572 }
1573 self.cleanup_recording();
1574 }
1575}
1576
1577fn capture_failure_observation(session: &TerminalSession) -> FailureObservation {
1578 let mut state = session
1579 .state
1580 .lock()
1581 .unwrap_or_else(std::sync::PoisonError::into_inner);
1582 capture_failure_observation_locked(session, &mut state)
1583}
1584
1585fn safe_capture_failure_observation(session: &TerminalSession) -> Option<FailureObservation> {
1586 match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1587 capture_failure_observation(session)
1588 })) {
1589 Ok(observation) => Some(observation),
1590 Err(payload) => {
1591 let message = format!(
1592 "terminal diagnostic capture panicked: {}",
1593 panic_message(payload.as_ref())
1594 );
1595 let mut state = session
1596 .state
1597 .lock()
1598 .unwrap_or_else(std::sync::PoisonError::into_inner);
1599 if state.diagnostic_error.is_none() {
1600 state.diagnostic_error = Some(message);
1601 }
1602 None
1603 }
1604 }
1605}
1606
1607fn capture_failure_observation_locked(
1608 session: &TerminalSession,
1609 state: &mut TermState,
1610) -> FailureObservation {
1611 let screen_sequence = capture_visual_state(state, true);
1612 state.screen_history.pin_current();
1613 let snapshot = svg_snapshot_from(state.emu.as_ref(), false);
1614 let captured_ms = elapsed_ms(state.started_at);
1615 let last_visual_change_ms = state.last_visual_change_ms;
1616 let cancelled = session.cancelled.load(std::sync::atomic::Ordering::Acquire);
1617 let process_state = if cancelled {
1618 "cancelled"
1619 } else if state.exited.is_some() {
1620 "exited"
1621 } else if state.exit_error.is_some() {
1622 "unknown"
1623 } else {
1624 "running"
1625 };
1626 let process = ProcessDiagnostics {
1627 pid: session.child_pid,
1628 state: process_state.to_string(),
1629 exit_code: state.exited,
1630 status_error: state.exit_error.clone(),
1631 cancelled,
1632 ready: state.tracker.is_ready(),
1633 command_running: state.tracker.executing(),
1634 last_command_exit: state.tracker.last_exit(),
1635 };
1636 let runtime = RuntimeDiagnostics {
1637 session_name: None,
1638 shell: session.shell.map(|shell| shell.as_str().to_string()),
1639 timeouts: Some(effective_timeouts(session)),
1640 tui_test_version: env!("CARGO_PKG_VERSION").to_string(),
1641 backend: session.backend.as_str().to_string(),
1642 target_os: std::env::consts::OS.to_string(),
1643 target_arch: std::env::consts::ARCH.to_string(),
1644 };
1645 FailureObservation {
1646 rows: snapshot.rows,
1647 cols: snapshot.cols,
1648 title: snapshot.title,
1649 cursor: snapshot.cursor,
1650 cursor_position: state.emu.cursor(),
1651 cursor_visible: state.emu.cursor_visible(),
1652 cursor_shape: state.emu.cursor_shape(),
1653 render_state: snapshot.render_state,
1654 screen_sequence,
1655 output_revision: state.visual_revision,
1656 captured_ms,
1657 last_visual_change_ms,
1658 history: state.screen_history.clone(),
1659 process,
1660 runtime,
1661 }
1662}
1663
1664fn open_ready_timeout(session: &TerminalSession) -> u64 {
1665 session
1666 .timeouts
1667 .get(config::TimeoutClass::Ready)
1668 .or_else(|| config::TimeoutClass::Ready.env_ms())
1669 .unwrap_or(config::OPEN_READY_CAP_MS)
1670}
1671
1672fn startup_readiness_error(operation: &str, timeout_ms: u64) -> TuiTestError {
1673 let message = format!(
1674 "open: the session started but reported no prompt within \
1675 {timeout_ms}ms; pass --no-wait-ready if it has no shell \
1676 integration"
1677 );
1678 TuiTestError::assertion(message.clone()).with_report(FailureReport::new(
1679 operation,
1680 Some(timeout_ms),
1681 FailureReason::TimedOut,
1682 message,
1683 ))
1684}
1685
1686fn await_ready(session: &TerminalSession, timeout_ms: u64) -> bool {
1687 let start = Instant::now();
1688 let cap = Duration::from_millis(timeout_ms);
1689 loop {
1690 if session.cancelled.load(std::sync::atomic::Ordering::Acquire) {
1691 return false;
1692 }
1693 {
1694 let state = session
1695 .state
1696 .lock()
1697 .unwrap_or_else(std::sync::PoisonError::into_inner);
1698 if state.tracker.is_ready() {
1699 return true;
1700 }
1701 if state.exited.is_some() {
1702 return false;
1703 }
1704 }
1705 if start.elapsed() >= cap {
1706 return false;
1707 }
1708 std::thread::sleep(Duration::from_millis(POLL_DELAY_MS));
1709 }
1710}
1711
1712fn viewable(session: &TerminalSession) -> Vec<Vec<EmuCell>> {
1713 session
1714 .state
1715 .lock()
1716 .unwrap_or_else(std::sync::PoisonError::into_inner)
1717 .emu
1718 .viewable_rows()
1719}
1720
1721fn grid(session: &TerminalSession, full: bool) -> Vec<Vec<EmuCell>> {
1722 let state = session
1723 .state
1724 .lock()
1725 .unwrap_or_else(std::sync::PoisonError::into_inner);
1726 if full {
1727 state.emu.full_rows()
1728 } else {
1729 state.emu.viewable_rows()
1730 }
1731}
1732
1733fn highlighted_rows(state: &TermState, full: bool) -> Vec<Vec<EmuCell>> {
1734 let mut rows = if full {
1735 state.emu.full_rows()
1736 } else {
1737 state.emu.viewable_rows()
1738 };
1739 apply_highlight(&mut rows, state.highlight.as_ref(), full);
1740 rows
1741}
1742
1743fn apply_highlight(rows: &mut [Vec<EmuCell>], highlight: Option<&TextHighlight>, full: bool) {
1744 let Some(highlight) = highlight else {
1745 return;
1746 };
1747 let row_offset = if full { 0 } else { highlight.viewport_offset };
1748 for &(x, absolute_y) in &highlight.cells {
1749 let Some(y) = absolute_y.checked_sub(row_offset) else {
1750 continue;
1751 };
1752 if let Some(cell) = rows.get_mut(y).and_then(|row| row.get_mut(x)) {
1753 cell.attrs.toggle(Attrs::INVERSE);
1754 }
1755 }
1756}
1757
1758fn text_of(rows: &[Vec<EmuCell>]) -> String {
1759 rows_to_strings(rows)
1760 .iter()
1761 .map(|line| line.trim_end())
1762 .collect::<Vec<_>>()
1763 .join("\n")
1764 .trim_end()
1765 .to_string()
1766}
1767
1768fn dispatch(
1769 session: &mut TerminalSession,
1770 operation: Operation,
1771 input: &mut Option<InputDetails>,
1772) -> Result<OperationResult, TuiTestError> {
1773 match operation {
1774 Operation::State => Ok(OperationResult::State(Box::new(state(session)))),
1775 Operation::Text { full } => Ok(OperationResult::Text(text_of(&grid(session, full)))),
1776 Operation::PackedScreen { full } => {
1777 Ok(OperationResult::PackedScreen(packed_screen(session, full)))
1778 }
1779 Operation::Cells { x, y, w, h } => Ok(OperationResult::Cells(cells(session, x, y, w, h))),
1780 Operation::GetCommand => Ok(OperationResult::Command(
1781 session
1782 .state
1783 .lock()
1784 .unwrap_or_else(std::sync::PoisonError::into_inner)
1785 .tracker
1786 .last_command()
1787 .map(str::to_string),
1788 )),
1789 Operation::GetOutput => Ok(OperationResult::Output(
1790 session
1791 .state
1792 .lock()
1793 .unwrap_or_else(std::sync::PoisonError::into_inner)
1794 .tracker
1795 .last_output()
1796 .map(str::to_string),
1797 )),
1798 Operation::GetExitCode => Ok(OperationResult::ExitCode(
1799 session
1800 .state
1801 .lock()
1802 .unwrap_or_else(std::sync::PoisonError::into_inner)
1803 .tracker
1804 .last_exit(),
1805 )),
1806 Operation::GetCwd => Ok(OperationResult::Cwd(
1807 session
1808 .state
1809 .lock()
1810 .unwrap_or_else(std::sync::PoisonError::into_inner)
1811 .tracker
1812 .cwd()
1813 .map(str::to_string),
1814 )),
1815 Operation::GetTitle => Ok(OperationResult::Title(title_of(session))),
1816 Operation::GetClipboard => Ok(OperationResult::Clipboard(get_clipboard(session)?)),
1817 Operation::GetCursor => {
1818 let state = session
1819 .state
1820 .lock()
1821 .unwrap_or_else(std::sync::PoisonError::into_inner);
1822 Ok(OperationResult::Cursor(cursor_model(state.emu.as_ref())))
1823 }
1824 Operation::GetColors => {
1825 let state = session
1826 .state
1827 .lock()
1828 .unwrap_or_else(std::sync::PoisonError::into_inner);
1829 Ok(OperationResult::Colors(colors_of(
1830 state.emu.as_ref(),
1831 &state.profile,
1832 )))
1833 }
1834 Operation::ExpectColors {
1835 foreground,
1836 background,
1837 cursor,
1838 palette,
1839 timeout_ms,
1840 } => {
1841 expect_colors(
1842 session,
1843 foreground.as_deref(),
1844 background.as_deref(),
1845 cursor.as_deref(),
1846 &palette,
1847 timeout_ms.unwrap_or_else(|| session.timeout_for(config::TimeoutClass::Text)),
1848 )?;
1849 Ok(OperationResult::Unit)
1850 }
1851 Operation::GetModes => {
1852 let state = session
1853 .state
1854 .lock()
1855 .unwrap_or_else(std::sync::PoisonError::into_inner);
1856 Ok(OperationResult::Modes(modes_of(state.emu.as_ref())))
1857 }
1858 Operation::ExpectMode {
1859 mode,
1860 enabled,
1861 timeout_ms,
1862 } => {
1863 expect_mode(
1864 session,
1865 &mode,
1866 enabled,
1867 timeout_ms.unwrap_or_else(|| session.timeout_for(config::TimeoutClass::Text)),
1868 )?;
1869 Ok(OperationResult::Unit)
1870 }
1871 Operation::ExpectCursor {
1872 visible,
1873 shape,
1874 x,
1875 y,
1876 timeout_ms,
1877 } => {
1878 expect_cursor(
1879 session,
1880 visible,
1881 shape.as_deref(),
1882 x,
1883 y,
1884 timeout_ms.unwrap_or_else(|| session.timeout_for(config::TimeoutClass::Text)),
1885 )?;
1886 Ok(OperationResult::Unit)
1887 }
1888 Operation::GetSize => {
1889 let (cols, rows) = session
1890 .state
1891 .lock()
1892 .unwrap_or_else(std::sync::PoisonError::into_inner)
1893 .emu
1894 .size();
1895 Ok(OperationResult::Size(Size { cols, rows }))
1896 }
1897 Operation::GetBellCount => Ok(OperationResult::BellCount(session.bells.count())),
1898 Operation::GetBellEvents => {
1899 Ok(OperationResult::BellEvents(session.bells.snapshot().events))
1900 }
1901 Operation::Write { data } => {
1902 write_input(session, data.as_bytes(), input, None)?;
1903 Ok(OperationResult::Unit)
1904 }
1905 Operation::Submit { data } => {
1906 let mut bytes = data.unwrap_or_default().into_bytes();
1907 let enter = session
1908 .shell
1909 .map(|shell| shell.return_char())
1910 .unwrap_or("\r");
1911 bytes.extend_from_slice(enter.as_bytes());
1912 write_input(session, &bytes, input, None)?;
1913 Ok(OperationResult::Unit)
1914 }
1915 Operation::Key { keys, action } => {
1916 key_action(session, keys, action, input)?;
1917 Ok(OperationResult::Unit)
1918 }
1919 Operation::Mouse { action } => {
1920 mouse_action(session, action, input)?;
1921 Ok(OperationResult::Unit)
1922 }
1923 Operation::Resize { cols, rows } => {
1924 act(session.resize(cols, rows))?;
1925 Ok(OperationResult::Unit)
1926 }
1927 Operation::Signal { name } => {
1928 act(session
1929 .pty
1930 .lock()
1931 .unwrap_or_else(std::sync::PoisonError::into_inner)
1932 .signal(&name))?;
1933 Ok(OperationResult::Unit)
1934 }
1935 Operation::WaitTitle {
1936 text,
1937 regex,
1938 timeout_ms,
1939 not,
1940 } => {
1941 wait_title(
1942 session,
1943 &text,
1944 regex,
1945 timeout_ms.unwrap_or_else(|| session.timeout_for(config::TimeoutClass::Text)),
1946 not,
1947 )?;
1948 Ok(OperationResult::Unit)
1949 }
1950 Operation::WaitClipboard { timeout_ms } => {
1951 wait_clipboard_change(
1952 session,
1953 timeout_ms.unwrap_or_else(|| session.timeout_for(config::TimeoutClass::Text)),
1954 )?;
1955 Ok(OperationResult::Unit)
1956 }
1957 Operation::WaitClipboardMatch {
1958 pattern,
1959 timeout_ms,
1960 } => {
1961 wait_clipboard_match(
1962 session,
1963 &pattern,
1964 timeout_ms.unwrap_or_else(|| session.timeout_for(config::TimeoutClass::Text)),
1965 )?;
1966 Ok(OperationResult::Unit)
1967 }
1968 Operation::WaitIdle { timeout_ms } => {
1969 wait_idle(
1970 session,
1971 timeout_ms.unwrap_or_else(|| session.timeout_for(config::TimeoutClass::Idle)),
1972 )?;
1973 Ok(OperationResult::Unit)
1974 }
1975 Operation::WaitCommand { timeout_ms } => {
1976 wait_command(
1977 session,
1978 timeout_ms.unwrap_or_else(|| session.timeout_for(config::TimeoutClass::Command)),
1979 )?;
1980 Ok(OperationResult::Unit)
1981 }
1982 Operation::WaitExit { timeout_ms } => {
1983 wait_exit(
1984 session,
1985 timeout_ms.unwrap_or_else(|| session.timeout_for(config::TimeoutClass::Exit)),
1986 )?;
1987 Ok(OperationResult::Unit)
1988 }
1989 Operation::WaitReady { timeout_ms } => {
1990 wait_ready(
1991 session,
1992 timeout_ms.unwrap_or_else(|| session.timeout_for(config::TimeoutClass::Ready)),
1993 )?;
1994 Ok(OperationResult::Unit)
1995 }
1996 Operation::WaitBell { timeout_ms } => {
1997 wait_bell(
1998 session,
1999 timeout_ms.unwrap_or_else(|| session.timeout_for(config::TimeoutClass::Text)),
2000 )?;
2001 Ok(OperationResult::Unit)
2002 }
2003 Operation::FindLocator { query } => Ok(OperationResult::Matches(find_locator(
2004 session, &query, false,
2005 )?)),
2006 Operation::ResolveLocator { query } => Ok(OperationResult::Matches(find_locator(
2007 session, &query, true,
2008 )?)),
2009 Operation::WaitLocator {
2010 query,
2011 not,
2012 timeout_ms,
2013 } => {
2014 wait_locator(
2015 session,
2016 &query,
2017 not,
2018 timeout_ms.unwrap_or_else(|| session.timeout_for(config::TimeoutClass::Text)),
2019 )?;
2020 Ok(OperationResult::Unit)
2021 }
2022 Operation::ClickLocator {
2023 query,
2024 options,
2025 clicks,
2026 timeout_ms,
2027 } => {
2028 click_locator(
2029 session,
2030 &query,
2031 options,
2032 clicks,
2033 timeout_ms.unwrap_or_else(|| session.timeout_for(config::TimeoutClass::Text)),
2034 input,
2035 )?;
2036 Ok(OperationResult::Unit)
2037 }
2038 Operation::HighlightLocator { query, timeout_ms } => {
2039 Ok(OperationResult::Matches(highlight_locator(
2040 session,
2041 &query,
2042 timeout_ms.unwrap_or_else(|| session.timeout_for(config::TimeoutClass::Text)),
2043 )?))
2044 }
2045 Operation::ExpectTitle {
2046 text,
2047 regex,
2048 not,
2049 timeout_ms,
2050 } => {
2051 expect_title(
2052 session,
2053 &text,
2054 regex,
2055 not,
2056 timeout_ms.unwrap_or_else(|| session.timeout_for(config::TimeoutClass::Text)),
2057 )?;
2058 Ok(OperationResult::Unit)
2059 }
2060 Operation::ExpectExitCode { code, timeout_ms } => {
2061 expect_exit_code(
2062 session,
2063 code,
2064 timeout_ms.unwrap_or_else(|| session.timeout_for(config::TimeoutClass::Command)),
2065 )?;
2066 Ok(OperationResult::Unit)
2067 }
2068 Operation::ExpectOutput { text, regex } => {
2069 expect_output(session, &text, regex)?;
2070 Ok(OperationResult::Unit)
2071 }
2072 Operation::ExpectBellCount { count, timeout_ms } => {
2073 expect_bell_count(
2074 session,
2075 count,
2076 timeout_ms.unwrap_or_else(|| session.timeout_for(config::TimeoutClass::Text)),
2077 )?;
2078 Ok(OperationResult::Unit)
2079 }
2080 Operation::Snapshot {
2081 name,
2082 update,
2083 include_style,
2084 include_title,
2085 cwd,
2086 } => Ok(OperationResult::Snapshot(do_snapshot(
2087 session,
2088 &name,
2089 update,
2090 include_style,
2091 include_title,
2092 cwd,
2093 )?)),
2094 Operation::Screenshot {
2095 full,
2096 path,
2097 zoom,
2098 background,
2099 } => Ok(OperationResult::Screenshot(screenshot(
2100 session, full, path, zoom, background,
2101 )?)),
2102 Operation::StartRecording {
2103 path,
2104 format,
2105 fps,
2106 speed,
2107 idle_time_limit,
2108 zoom,
2109 background,
2110 } => {
2111 session.start_recording(crate::session::ManualRecordingOptions {
2112 path,
2113 format,
2114 fps,
2115 speed,
2116 idle_time_limit,
2117 zoom,
2118 background,
2119 })?;
2120 Ok(OperationResult::Unit)
2121 }
2122 Operation::StopRecording => Ok(OperationResult::Recording(session.stop_recording()?)),
2123 Operation::Open(_)
2124 | Operation::Run(_)
2125 | Operation::Restart { .. }
2126 | Operation::Close
2127 | Operation::FinishTrace { .. } => {
2128 Err(TuiTestError::internal("unsupported nested operation"))
2129 }
2130 }
2131}
2132
2133fn act(result: anyhow::Result<()>) -> Result<(), TuiTestError> {
2134 result.map_err(|error| TuiTestError::internal(error.to_string()))
2135}
2136
2137fn state(session: &TerminalSession) -> crate::api::State {
2138 let state = session
2139 .state
2140 .lock()
2141 .unwrap_or_else(std::sync::PoisonError::into_inner);
2142 let (cols, rows) = state.emu.size();
2143 let bells = session.bells.snapshot();
2144 crate::api::State {
2145 session_shell: session.shell.map(|value| value.as_str().to_string()),
2146 cols,
2147 rows,
2148 cursor: cursor_model(state.emu.as_ref()),
2149 title: state.emu.title(),
2150 cwd: state.tracker.cwd().map(str::to_string),
2151 last_command: state.tracker.last_command().map(str::to_string),
2152 last_exit: state.tracker.last_exit(),
2153 exit_signal: state.exit_signal.clone(),
2154 exited: state.exited,
2155 ready: state.tracker.is_ready(),
2156 bell_count: bells.count,
2157 modes: crate::terminal::emu::TerminalMode::ALL
2158 .into_iter()
2159 .map(|mode| (mode.name().to_string(), state.emu.mode(mode)))
2160 .collect(),
2161 mouse_mode: state.mouse_mode.mode().name().to_string(),
2162 colors: colors_of(state.emu.as_ref(), &state.profile),
2163 timeouts: effective_timeouts(session),
2164 text: text_of(&state.emu.viewable_rows()),
2165 }
2166}
2167
2168fn effective_timeouts(session: &TerminalSession) -> EffectiveTimeouts {
2169 use config::TimeoutClass::*;
2170 EffectiveTimeouts {
2171 text: session.timeout_for(Text),
2172 idle: session.timeout_for(Idle),
2173 command: session.timeout_for(Command),
2174 exit: session.timeout_for(Exit),
2175 ready: session.timeout_for(Ready),
2176 }
2177}
2178
2179fn packed_screen(session: &TerminalSession, full: bool) -> PackedScreen {
2180 let rows = grid(session, full);
2181 PackedScreen {
2182 cols: session.cols,
2183 rows: rows.len().min(u16::MAX as usize) as u16,
2184 utf8: rows_to_strings(&rows).join("\n").into_bytes(),
2185 }
2186}
2187
2188fn cells(session: &TerminalSession, x: u16, y: u16, w: u16, h: u16) -> Vec<Cell> {
2189 let rows = viewable(session);
2190 let mut out = Vec::new();
2191 for row in y..y.saturating_add(h.max(1)) {
2192 for col in x..x.saturating_add(w.max(1)) {
2193 if let Some(cell) = rows
2194 .get(row as usize)
2195 .and_then(|line| line.get(col as usize))
2196 {
2197 out.push(cell_model(col, row, cell));
2198 }
2199 }
2200 }
2201 out
2202}
2203
2204fn cell_model(x: u16, y: u16, cell: &EmuCell) -> Cell {
2205 Cell {
2206 x,
2207 y,
2208 char: cell.ch.to_string(),
2209 fg: cell_color(cell.fg),
2210 bg: cell_color(cell.bg),
2211 bold: cell.has(Attrs::BOLD),
2212 dim: cell.has(Attrs::DIM),
2213 italic: cell.has(Attrs::ITALIC),
2214 inverse: cell.has(Attrs::INVERSE),
2215 invisible: cell.has(Attrs::INVISIBLE),
2216 strike: cell.has(Attrs::STRIKE),
2217 blink: cell.has(Attrs::BLINK),
2218 underline: cell.underline.is_underlined(),
2219 underline_style: cell.underline.name().to_string(),
2220 underline_color: cell_color(cell.underline_color),
2221 link: cell.uri().unwrap_or_default().to_string(),
2222 link_id: cell
2223 .hyperlink
2224 .as_ref()
2225 .and_then(|link| link.id.as_deref())
2226 .unwrap_or_default()
2227 .to_string(),
2228 }
2229}
2230
2231fn parse_mode(name: &str) -> Result<TerminalMode, TuiTestError> {
2234 TerminalMode::ALL
2235 .into_iter()
2236 .find(|mode| mode.name() == name)
2237 .ok_or_else(|| {
2238 let known = TerminalMode::ALL
2239 .iter()
2240 .map(|mode| mode.name())
2241 .collect::<Vec<_>>()
2242 .join(", ");
2243 TuiTestError::usage(format!(
2244 "unknown terminal mode '{name}'; expected one of: {known}"
2245 ))
2246 })
2247}
2248
2249fn colors_of(emu: &dyn Emulator, profile: &crate::profile::Profile) -> crate::api::TerminalColors {
2256 let colors = emu.colors();
2257 crate::api::TerminalColors {
2258 foreground: colors.foreground.to_hex(),
2259 background: colors.background.to_hex(),
2260 cursor: colors.cursor.to_hex(),
2261 palette: colors
2262 .palette
2263 .iter()
2264 .enumerate()
2265 .filter_map(|(index, now)| {
2266 let index = index as u8;
2267 (*now != profile.colors.rgb(index)).then(|| (index, now.to_hex()))
2268 })
2269 .collect(),
2270 }
2271}
2272
2273fn resolve_expected_color(
2281 spec: &str,
2282 emu: &dyn Emulator,
2283) -> Result<crate::profile::Rgb, TuiTestError> {
2284 use crate::assert::color::Expected;
2285 use crate::profile::ColorSlot;
2286 let invalid = || {
2289 TuiTestError::usage(format!(
2290 "terminal color must be ansi256 (0-255), hex (#rrggbb), or rgb (r,g,b) (got: {spec:?})"
2291 ))
2292 };
2293 match Expected::parse(spec).map_err(|_| invalid())? {
2294 Expected::Default => Err(TuiTestError::usage(
2295 "'default' has no meaning for a terminal color; name a hex value or an ANSI index"
2296 .to_string(),
2297 )),
2298 Expected::Ansi256(index) => Ok(emu.color(ColorSlot::Indexed(index))),
2299 Expected::Hex(r, g, b) | Expected::Rgb(r, g, b) => Ok(crate::profile::Rgb::new(r, g, b)),
2300 }
2301}
2302
2303fn expect_colors(
2309 session: &TerminalSession,
2310 foreground: Option<&str>,
2311 background: Option<&str>,
2312 cursor: Option<&str>,
2313 palette: &[(u8, String)],
2314 timeout_ms: u64,
2315) -> Result<(), TuiTestError> {
2316 use crate::profile::ColorSlot;
2317 if foreground.is_none() && background.is_none() && cursor.is_none() && palette.is_empty() {
2318 return Err(TuiTestError::usage(
2319 "expect colors needs at least one of --foreground, --background, --cursor, or --palette",
2320 ));
2321 }
2322 let (wanted, wanted_palette) = {
2326 let state = session
2327 .state
2328 .lock()
2329 .unwrap_or_else(std::sync::PoisonError::into_inner);
2330 let resolve = |spec: Option<&str>| -> Result<Option<crate::profile::Rgb>, TuiTestError> {
2331 spec.map(|spec| resolve_expected_color(spec, state.emu.as_ref()))
2332 .transpose()
2333 };
2334 let defaults = [resolve(foreground)?, resolve(background)?, resolve(cursor)?];
2335 let entries = palette
2336 .iter()
2337 .map(|(index, spec)| Ok((*index, resolve_expected_color(spec, state.emu.as_ref())?)))
2338 .collect::<Result<Vec<_>, TuiTestError>>()?;
2339 (defaults, entries)
2340 };
2341
2342 let mut matched = false;
2343 let mut last = None;
2344 poll_until(
2345 || {
2346 let (actual, actual_palette) = {
2347 let state = session
2348 .state
2349 .lock()
2350 .unwrap_or_else(std::sync::PoisonError::into_inner);
2351 let defaults = [
2352 state.emu.color(ColorSlot::Foreground),
2353 state.emu.color(ColorSlot::Background),
2354 state.emu.color(ColorSlot::Cursor),
2355 ];
2356 let entries = wanted_palette
2357 .iter()
2358 .map(|(index, _)| (*index, state.emu.color(ColorSlot::Indexed(*index))))
2359 .collect::<Vec<_>>();
2360 (defaults, entries)
2361 };
2362 matched = wanted
2363 .iter()
2364 .zip(actual)
2365 .all(|(expected, actual)| expected.is_none_or(|expected| expected == actual))
2366 && wanted_palette
2367 .iter()
2368 .zip(&actual_palette)
2369 .all(|((_, expected), (_, actual))| expected == actual);
2370 last = Some((actual, actual_palette));
2371 matched || session_stopped(session)
2372 },
2373 timeout_ms,
2374 );
2375 if matched {
2376 return Ok(());
2377 }
2378 let ([fg, bg, cur], entries) = last.expect("the colors are read at least once");
2379 let mut parts = Vec::new();
2382 for (label, wanted, actual) in [
2383 ("foreground", wanted[0], fg),
2384 ("background", wanted[1], bg),
2385 ("cursor", wanted[2], cur),
2386 ] {
2387 if let Some(wanted) = wanted {
2388 parts.push(format!(
2389 "{label} {} (wanted {})",
2390 actual.to_hex(),
2391 wanted.to_hex()
2392 ));
2393 }
2394 }
2395 for ((index, wanted), (_, actual)) in wanted_palette.iter().zip(&entries) {
2396 parts.push(format!(
2397 "palette {index} {} (wanted {})",
2398 actual.to_hex(),
2399 wanted.to_hex()
2400 ));
2401 }
2402 Err(TuiTestError::assertion(format!(
2403 "colors did not match within {timeout_ms}ms; {}",
2404 parts.join(", ")
2405 )))
2406}
2407
2408fn modes_of(emu: &dyn Emulator) -> std::collections::BTreeMap<String, bool> {
2409 TerminalMode::ALL
2410 .into_iter()
2411 .map(|mode| (mode.name().to_string(), emu.mode(mode)))
2412 .collect()
2413}
2414
2415fn expect_mode(
2416 session: &TerminalSession,
2417 name: &str,
2418 enabled: bool,
2419 timeout_ms: u64,
2420) -> Result<(), TuiTestError> {
2421 let mode = parse_mode(name)?;
2422 let reached = |session: &TerminalSession| {
2423 session
2424 .state
2425 .lock()
2426 .unwrap_or_else(std::sync::PoisonError::into_inner)
2427 .emu
2428 .mode(mode)
2429 == enabled
2430 };
2431 let mut matched = false;
2432 poll_until(
2433 || {
2434 matched = reached(session);
2435 matched || session_stopped(session)
2436 },
2437 timeout_ms,
2438 );
2439 if matched {
2440 return Ok(());
2441 }
2442 Err(TuiTestError::assertion(format!(
2443 "{} did not turn {} within {timeout_ms}ms",
2444 mode.name(),
2445 if enabled { "on" } else { "off" }
2446 )))
2447}
2448
2449fn expect_cursor(
2450 session: &TerminalSession,
2451 visible: Option<bool>,
2452 shape: Option<&str>,
2453 x: Option<u16>,
2454 y: Option<u16>,
2455 timeout_ms: u64,
2456) -> Result<(), TuiTestError> {
2457 if let Some(shape) = shape {
2458 if CursorShape::parse(shape).is_none() {
2459 return Err(TuiTestError::usage(format!(
2460 "unknown cursor shape '{shape}'; expected block, underline, or bar"
2461 )));
2462 }
2463 }
2464 let mut last = None;
2465 let mut matched = false;
2466 poll_until(
2467 || {
2468 let cursor = {
2469 let state = session
2470 .state
2471 .lock()
2472 .unwrap_or_else(std::sync::PoisonError::into_inner);
2473 cursor_model(state.emu.as_ref())
2474 };
2475 matched = visible.is_none_or(|want| want == cursor.visible)
2476 && shape.is_none_or(|want| want == cursor.shape)
2477 && x.is_none_or(|want| want == cursor.x)
2478 && y.is_none_or(|want| want == cursor.y);
2479 last = Some(cursor);
2480 matched || session_stopped(session)
2481 },
2482 timeout_ms,
2483 );
2484 if matched {
2485 return Ok(());
2486 }
2487 let cursor = last.expect("the cursor is read at least once");
2488 Err(TuiTestError::assertion(format!(
2489 "cursor did not match within {timeout_ms}ms; it is at {},{}, {}, shape {}",
2490 cursor.x,
2491 cursor.y,
2492 if cursor.visible { "visible" } else { "hidden" },
2493 cursor.shape
2494 )))
2495}
2496
2497fn cursor_model(emu: &dyn Emulator) -> Cursor {
2499 let (x, y) = emu.cursor();
2500 Cursor {
2501 x,
2502 y,
2503 visible: emu.cursor_visible(),
2504 shape: emu.cursor_shape().name().to_string(),
2505 color: emu.color(crate::profile::ColorSlot::Cursor).to_hex(),
2506 }
2507}
2508
2509pub(crate) fn cell_color(color: Option<Color>) -> CellColor {
2510 match color {
2511 None => CellColor::Default,
2512 Some(Color::Rgb(r, g, b)) => CellColor::Rgb(r, g, b),
2513 Some(color) => CellColor::Indexed(color.to_index()),
2514 }
2515}
2516
2517fn write_input(
2518 session: &TerminalSession,
2519 bytes: &[u8],
2520 input: &mut Option<InputDetails>,
2521 position: Option<(u16, u16)>,
2522) -> Result<(), TuiTestError> {
2523 act(session.write(bytes))?;
2524 if let Some(input) = input {
2525 input.record_sent(bytes, position);
2526 }
2527 Ok(())
2528}
2529
2530fn key_action(
2531 session: &TerminalSession,
2532 tokens: Vec<String>,
2533 action: crate::api::KeyAction,
2534 input: &mut Option<InputDetails>,
2535) -> Result<(), TuiTestError> {
2536 let mut sequence: Vec<u8> = Vec::new();
2542 {
2543 let state = session
2544 .state
2545 .lock()
2546 .unwrap_or_else(std::sync::PoisonError::into_inner);
2547 let modes = keys::InputModes {
2548 keyboard: state.emu.keyboard_mode(),
2549 cursor_key_application: state.emu.cursor_key_application(),
2550 };
2551 for token in &tokens {
2552 let presses = keys::token_to_presses(token, action)
2553 .map_err(|error| TuiTestError::usage(error.to_string()))?;
2554 let encoded: Option<Vec<Vec<u8>>> = presses
2555 .iter()
2556 .map(|press| state.emu.encode_key(press))
2557 .collect();
2558 match encoded {
2559 Some(parts) => sequence.extend(parts.concat()),
2563 None => {
2564 let text = keys::token_to_seq_for_action_with_mode(token, action, modes)
2565 .map_err(|error| TuiTestError::usage(error.to_string()))?;
2566 sequence.extend_from_slice(text.as_bytes());
2567 }
2568 }
2569 }
2570 }
2571 if sequence.is_empty() {
2572 if let Some(input) = input {
2573 input.record_sent(&[], None);
2574 }
2575 Ok(())
2576 } else {
2577 write_input(session, &sequence, input, None)
2578 }
2579}
2580
2581fn mouse_action(
2582 session: &TerminalSession,
2583 action: crate::api::MouseAction,
2584 input: &mut Option<InputDetails>,
2585) -> Result<(), TuiTestError> {
2586 let position;
2587 let sequence = match action {
2588 crate::api::MouseAction::Click {
2589 x,
2590 y,
2591 on_text,
2592 options,
2593 clicks,
2594 } => {
2595 let (x, y) = if let Some(text) = on_text {
2596 locate_center(session, &text).ok_or_else(|| {
2597 TuiTestError::assertion(format!("text not found on screen: {text}"))
2598 })?
2599 } else {
2600 (x.unwrap_or(0), y.unwrap_or(0))
2601 };
2602 position = (x, y);
2603 let mut out = String::new();
2604 for _ in 0..clicks.max(1) {
2605 out.push_str(&mouse::click(x, y, options));
2606 }
2607 out
2608 }
2609 crate::api::MouseAction::Move { x, y } => {
2610 position = (x, y);
2611 mouse::motion(x, y)
2612 }
2613 crate::api::MouseAction::Down { x, y, options } => {
2614 position = (x, y);
2615 mouse::down(x, y, options)
2616 }
2617 crate::api::MouseAction::Up { x, y, options } => {
2618 position = (x, y);
2619 mouse::up(x, y, options)
2620 }
2621 crate::api::MouseAction::Drag {
2622 x1,
2623 y1,
2624 x2,
2625 y2,
2626 options,
2627 } => {
2628 position = (x2, y2);
2629 format!(
2630 "{}{}{}",
2631 mouse::down(x1, y1, options),
2632 mouse::drag_motion(x2, y2, options),
2633 mouse::up(x2, y2, options)
2634 )
2635 }
2636 crate::api::MouseAction::Scroll { direction, amount } => {
2637 position = (0, 0);
2638 let up = direction.eq_ignore_ascii_case("up");
2639 (0..amount.max(1))
2640 .map(|_| mouse::scroll(0, 0, up))
2641 .collect()
2642 }
2643 };
2644 write_input(session, sequence.as_bytes(), input, Some(position))
2645}
2646
2647fn locate_center(session: &TerminalSession, text: &str) -> Option<(u16, u16)> {
2648 let mut query = LocatorQuery::text(text);
2649 query.occurrence = crate::api::MatchOccurrence::First;
2650 let evaluated = evaluate_locator(session, &query, false).ok()?;
2651 matched_center(evaluated.evaluation.matches.first()?)
2652 .and_then(|(x, y)| Some((u16::try_from(x).ok()?, u16::try_from(y).ok()?)))
2653}
2654
2655fn poll_until<F: FnMut() -> bool>(mut predicate: F, timeout_ms: u64) -> bool {
2656 let start = Instant::now();
2657 loop {
2658 if predicate() {
2659 return true;
2660 }
2661 if start.elapsed() >= Duration::from_millis(timeout_ms) {
2662 return false;
2663 }
2664 std::thread::sleep(Duration::from_millis(POLL_DELAY_MS));
2665 }
2666}
2667
2668fn session_stopped(session: &TerminalSession) -> bool {
2669 session.cancelled.load(std::sync::atomic::Ordering::Acquire)
2670 || session
2671 .state
2672 .lock()
2673 .unwrap_or_else(std::sync::PoisonError::into_inner)
2674 .exited
2675 .is_some()
2676}
2677
2678fn title_of(session: &TerminalSession) -> Option<String> {
2680 session
2681 .state
2682 .lock()
2683 .unwrap_or_else(std::sync::PoisonError::into_inner)
2684 .emu
2685 .title()
2686}
2687
2688fn clipboard_error(error: anyhow::Error) -> TuiTestError {
2689 TuiTestError::internal(error.to_string())
2690}
2691
2692fn get_clipboard(session: &TerminalSession) -> Result<String, TuiTestError> {
2693 let mut state = session
2694 .state
2695 .lock()
2696 .unwrap_or_else(std::sync::PoisonError::into_inner);
2697 let value = state
2698 .emu
2699 .clipboard(ClipboardType::Clipboard)
2700 .map_err(clipboard_error)?;
2701 state.observed_clipboard_revision = state
2702 .emu
2703 .clipboard_revision(ClipboardType::Clipboard)
2704 .map_err(clipboard_error)?;
2705 Ok(value)
2706}
2707
2708fn wait_clipboard_match(
2709 session: &TerminalSession,
2710 pattern: &ClipboardPattern,
2711 timeout_ms: u64,
2712) -> Result<(), TuiTestError> {
2713 let mut matched = false;
2714 let mut read_error = None;
2715 poll_until(
2716 || {
2717 let mut state = session
2718 .state
2719 .lock()
2720 .unwrap_or_else(std::sync::PoisonError::into_inner);
2721 let value = state
2722 .emu
2723 .clipboard(ClipboardType::Clipboard)
2724 .map_err(clipboard_error);
2725 let revision = state
2726 .emu
2727 .clipboard_revision(ClipboardType::Clipboard)
2728 .map_err(clipboard_error);
2729 match (value, revision) {
2730 (Ok(value), Ok(revision)) if pattern.matches(&value) => {
2731 state.observed_clipboard_revision = revision;
2732 matched = true;
2733 }
2734 (Err(error), _) | (_, Err(error)) => read_error = Some(error),
2735 _ => {}
2736 }
2737 drop(state);
2738 matched || read_error.is_some() || session_stopped(session)
2739 },
2740 timeout_ms,
2741 );
2742 if let Some(error) = read_error {
2743 Err(error)
2744 } else if matched {
2745 Ok(())
2746 } else if session_stopped(session) {
2747 Err(TuiTestError::assertion(format!(
2748 "session exited before the clipboard matched '{}'",
2749 pattern.as_str()
2750 )))
2751 } else {
2752 Err(TuiTestError::assertion(format!(
2753 "wait clipboard: timed out after {} waiting for '{}'",
2754 format_timeout(timeout_ms),
2755 pattern.as_str()
2756 )))
2757 }
2758}
2759
2760fn wait_clipboard_change(session: &TerminalSession, timeout_ms: u64) -> Result<(), TuiTestError> {
2761 let baseline = {
2762 let mut state = session
2763 .state
2764 .lock()
2765 .unwrap_or_else(std::sync::PoisonError::into_inner);
2766 let current = state
2767 .emu
2768 .clipboard_revision(ClipboardType::Clipboard)
2769 .map_err(clipboard_error)?;
2770 if current != state.observed_clipboard_revision {
2771 state.observed_clipboard_revision = current;
2772 return Ok(());
2773 }
2774 current
2775 };
2776 let mut changed = false;
2777 let mut read_error = None;
2778 poll_until(
2779 || {
2780 let mut state = session
2781 .state
2782 .lock()
2783 .unwrap_or_else(std::sync::PoisonError::into_inner);
2784 match state
2785 .emu
2786 .clipboard_revision(ClipboardType::Clipboard)
2787 .map_err(clipboard_error)
2788 {
2789 Ok(current) if current != baseline => {
2790 state.observed_clipboard_revision = current;
2791 changed = true;
2792 }
2793 Ok(_) => {}
2794 Err(error) => read_error = Some(error),
2795 }
2796 drop(state);
2797 changed || read_error.is_some() || session_stopped(session)
2798 },
2799 timeout_ms,
2800 );
2801 if let Some(error) = read_error {
2802 Err(error)
2803 } else if changed {
2804 Ok(())
2805 } else if session_stopped(session) {
2806 Err(TuiTestError::assertion(
2807 "session exited before the clipboard changed",
2808 ))
2809 } else {
2810 Err(TuiTestError::assertion(format!(
2811 "wait clipboard: timed out after {} without a change",
2812 format_timeout(timeout_ms)
2813 )))
2814 }
2815}
2816
2817fn title_matches(session: &TerminalSession, pattern: &Pattern) -> bool {
2820 title_of(session).is_some_and(|title| pattern.matches(&title))
2821}
2822
2823fn wait_title(
2824 session: &TerminalSession,
2825 text: &str,
2826 regex: bool,
2827 timeout_ms: u64,
2828 not: bool,
2829) -> Result<(), TuiTestError> {
2830 let pattern = Pattern::new(text, regex)
2831 .map_err(|error| TuiTestError::usage(format!("invalid regex: {error}")))?;
2832 let mut matched = false;
2833 poll_until(
2834 || {
2835 matched = title_matches(session, &pattern) != not;
2836 matched || session_stopped(session)
2837 },
2838 timeout_ms,
2839 );
2840 if matched {
2841 Ok(())
2842 } else if session_stopped(session) {
2843 Err(TuiTestError::assertion(format!(
2844 "session exited before the title '{}' became {}",
2845 pattern.describe(),
2846 if not { "hidden" } else { "visible" }
2847 )))
2848 } else {
2849 let expected = pattern.describe();
2850 let observation = capture_failure_observation(session);
2853 let actual = observation.title.clone();
2854 let message =
2855 title_timeout_message_from_actual(actual.as_deref(), &expected, timeout_ms, not);
2856 let mut error = comparison_failure(
2857 "wait.title",
2858 Some(timeout_ms),
2859 FailureReason::TimedOut,
2860 message,
2861 "title",
2862 Some(expected),
2863 actual,
2864 );
2865 error.observation = Some(Box::new(observation));
2866 Err(error)
2867 }
2868}
2869
2870fn expect_title(
2871 session: &TerminalSession,
2872 text: &str,
2873 regex: bool,
2874 not: bool,
2875 timeout_ms: u64,
2876) -> Result<(), TuiTestError> {
2877 let pattern = Pattern::new(text, regex)
2878 .map_err(|error| TuiTestError::usage(format!("invalid regex: {error}")))?;
2879 let mut matched = false;
2880 poll_until(
2881 || {
2882 matched = title_matches(session, &pattern) != not;
2883 matched || session_stopped(session)
2884 },
2885 timeout_ms,
2886 );
2887 if matched {
2888 Ok(())
2889 } else if session_stopped(session) {
2890 Err(TuiTestError::assertion(format!(
2891 "session exited before the title '{}' became {}",
2892 pattern.describe(),
2893 if not { "hidden" } else { "visible" }
2894 )))
2895 } else {
2896 let expected = pattern.describe();
2897 let observation = capture_failure_observation(session);
2898 let actual = observation.title.clone();
2899 let message =
2900 title_timeout_message_from_actual(actual.as_deref(), &expected, timeout_ms, not);
2901 let mut error = comparison_failure(
2902 "expect.title",
2903 Some(timeout_ms),
2904 FailureReason::TimedOut,
2905 message,
2906 "title",
2907 Some(expected),
2908 actual,
2909 );
2910 error.observation = Some(Box::new(observation));
2911 Err(error)
2912 }
2913}
2914
2915fn wait_idle(session: &TerminalSession, timeout_ms: u64) -> Result<(), TuiTestError> {
2916 let quiet = Duration::from_millis(250);
2917 if poll_until(
2918 || {
2919 session
2920 .state
2921 .lock()
2922 .unwrap_or_else(std::sync::PoisonError::into_inner)
2923 .last_change
2924 .elapsed()
2925 >= quiet
2926 || session.cancelled.load(std::sync::atomic::Ordering::Acquire)
2927 },
2928 timeout_ms,
2929 ) {
2930 Ok(())
2931 } else {
2932 Err(TuiTestError::assertion(
2933 "wait idle: screen kept changing until timeout",
2934 ))
2935 }
2936}
2937
2938fn awaiting_command_start(state: &TermState) -> bool {
2939 state
2940 .awaiting_start
2941 .is_some_and(|seen| state.tracker.started_count() == seen)
2942}
2943
2944fn command_settled(session: &TerminalSession, baseline: u64) -> bool {
2945 const QUIET: Duration = Duration::from_millis(300);
2946 if session.cancelled.load(std::sync::atomic::Ordering::Acquire) {
2947 return true;
2948 }
2949 let state = session
2950 .state
2951 .lock()
2952 .unwrap_or_else(std::sync::PoisonError::into_inner);
2953 if state.exited.is_some() {
2954 return true;
2955 }
2956 let tracker = &state.tracker;
2957 if !tracker.started() {
2958 return state.last_change.elapsed() >= QUIET;
2959 }
2960 if awaiting_command_start(&state) {
2961 return false;
2962 }
2963 tracker.finished_count() > baseline || !tracker.executing()
2964}
2965
2966fn wait_command(session: &TerminalSession, timeout_ms: u64) -> Result<(), TuiTestError> {
2967 let baseline = session
2968 .state
2969 .lock()
2970 .unwrap_or_else(std::sync::PoisonError::into_inner)
2971 .tracker
2972 .finished_count();
2973 if poll_until(|| command_settled(session, baseline), timeout_ms) {
2974 Ok(())
2975 } else {
2976 Err(TuiTestError::assertion(format!(
2977 "wait command: timed out after {timeout_ms}ms; {}",
2978 stall_reason(session)
2979 )))
2980 }
2981}
2982
2983fn stall_reason(session: &TerminalSession) -> String {
2984 let state = session
2985 .state
2986 .lock()
2987 .unwrap_or_else(std::sync::PoisonError::into_inner);
2988 if awaiting_command_start(&state) {
2989 "the shell never started a command for the input that was sent, so there \
2990 is nothing to wait for (was the line submitted?)"
2991 .to_string()
2992 } else {
2993 "the command was still running".to_string()
2994 }
2995}
2996
2997fn wait_exit(session: &TerminalSession, timeout_ms: u64) -> Result<(), TuiTestError> {
2998 let start = Instant::now();
2999 loop {
3000 let (exited, exit_error) = {
3001 let state = session
3002 .state
3003 .lock()
3004 .unwrap_or_else(std::sync::PoisonError::into_inner);
3005 (state.exited.is_some(), state.exit_error.clone())
3006 };
3007 if exited || session.cancelled.load(std::sync::atomic::Ordering::Acquire) {
3008 return Ok(());
3009 }
3010 if let Some(error) = exit_error {
3011 return Err(TuiTestError::internal(format!(
3012 "wait exit: failed to query process status: {error}"
3013 )));
3014 }
3015 if start.elapsed() >= Duration::from_millis(timeout_ms) {
3016 return Err(TuiTestError::assertion(
3017 "wait exit: session still running at timeout",
3018 ));
3019 }
3020 std::thread::sleep(Duration::from_millis(POLL_DELAY_MS));
3021 }
3022}
3023
3024fn wait_ready(session: &TerminalSession, timeout_ms: u64) -> Result<(), TuiTestError> {
3025 if await_ready(session, timeout_ms) {
3026 Ok(())
3027 } else {
3028 Err(TuiTestError::assertion(
3029 "wait ready: no prompt was reported within timeout",
3030 ))
3031 }
3032}
3033
3034fn wait_bell(session: &TerminalSession, timeout_ms: u64) -> Result<(), TuiTestError> {
3035 let baseline = session.bells.sequence();
3036 let mut rang = false;
3037 poll_until(
3038 || {
3039 rang = session.bells.sequence() != baseline;
3040 rang || session_stopped(session)
3041 },
3042 timeout_ms,
3043 );
3044 if rang {
3045 Ok(())
3046 } else if session_stopped(session) {
3047 Err(TuiTestError::assertion(
3048 "session exited before a bell was received",
3049 ))
3050 } else {
3051 Err(TuiTestError::assertion(format!(
3052 "wait bell: timed out after {timeout_ms}ms without receiving a bell"
3053 )))
3054 }
3055}
3056
3057fn validate_locator_query(query: &LocatorQuery) -> Result<(), TuiTestError> {
3058 validate_locator_node(query, 0, &mut 0)
3059}
3060
3061fn validate_locator_node(
3062 query: &LocatorQuery,
3063 depth: usize,
3064 count: &mut usize,
3065) -> Result<(), TuiTestError> {
3066 *count += 1;
3067 if depth >= 64 || *count > 4096 {
3068 return Err(TuiTestError::usage(
3069 "locator expression exceeds the size or depth limit",
3070 ));
3071 }
3072 if query.within.is_none() && query.direction != crate::api::LocatorDirection::Within {
3073 return Err(TuiTestError::usage(
3074 "locator direction requires a preceding locator",
3075 ));
3076 }
3077 match &query.selector {
3078 LocatorSelector::Text(selector) => validate_selector(selector)?,
3079 LocatorSelector::Style(selector) => {
3080 if selector.style.is_empty() {
3081 return Err(TuiTestError::usage(
3082 "getByStyle requires at least one style property",
3083 ));
3084 }
3085 validate_style(&selector.style)?;
3086 }
3087 LocatorSelector::Link(_) => {}
3088 LocatorSelector::And { .. }
3089 | LocatorSelector::Or { .. }
3090 | LocatorSelector::Filter { .. } => {
3091 if query.within.is_some() || !query.style.is_empty() {
3092 return Err(TuiTestError::usage(
3093 "composition nodes do not accept scope or style fields",
3094 ));
3095 }
3096 if let LocatorSelector::Filter {
3097 has: None,
3098 has_not: None,
3099 ..
3100 } = &query.selector
3101 {
3102 return Err(TuiTestError::usage("filter requires has or hasNot"));
3103 }
3104 for child in query.selector.children() {
3105 validate_locator_node(child, depth + 1, count)?;
3106 }
3107 }
3108 }
3109 if let Some(parent) = query.within.as_deref() {
3110 validate_locator_node(parent, depth + 1, count)?;
3111 }
3112 validate_style(&query.style)?;
3113 Ok(())
3114}
3115
3116struct EvaluatedLocator {
3117 evaluation: locator::LocatorEvaluation,
3118 screen_sequence: u64,
3119 visible_rows: usize,
3120}
3121
3122fn evaluate_locator_in_state_with_requirement(
3123 state: &mut TermState,
3124 query: &LocatorQuery,
3125 require_one: bool,
3126) -> anyhow::Result<EvaluatedLocator> {
3127 let screen_sequence = capture_visual_state(state, true);
3128 let visible_rows = state.emu.viewable_rows();
3129 let visible_len = visible_rows.len();
3130 let full = query.uses_full_grid();
3131 let rows = if full {
3132 state.emu.full_rows()
3133 } else {
3134 visible_rows
3135 };
3136 let mut evaluation = locator::evaluate_query(
3137 &rows,
3138 query,
3139 require_one,
3140 &mut |cell, style, x, y, budget| {
3141 evaluate_cell_style(cell, style, state.emu.as_ref(), x, y, budget)
3142 },
3143 )?;
3144 if full {
3145 evaluation.diagnostics.viewport_origin_y = rows
3146 .len()
3147 .saturating_sub(visible_len)
3148 .min(u32::MAX as usize) as u32;
3149 }
3150 Ok(EvaluatedLocator {
3151 evaluation,
3152 screen_sequence,
3153 visible_rows: visible_len,
3154 })
3155}
3156
3157fn evaluate_locator(
3158 session: &TerminalSession,
3159 query: &LocatorQuery,
3160 require_one: bool,
3161) -> Result<EvaluatedLocator, TuiTestError> {
3162 validate_locator_query(query)?;
3163 let mut state = session
3164 .state
3165 .lock()
3166 .unwrap_or_else(std::sync::PoisonError::into_inner);
3167 evaluate_locator_in_state_with_requirement(&mut state, query, require_one)
3168 .map_err(|error| TuiTestError::assertion(error.to_string()))
3169}
3170
3171fn evaluate_locator_with_observation(
3172 session: &TerminalSession,
3173 query: &LocatorQuery,
3174 require_one: bool,
3175) -> Result<(EvaluatedLocator, FailureObservation), TuiTestError> {
3176 validate_locator_query(query)?;
3177 let mut state = session
3178 .state
3179 .lock()
3180 .unwrap_or_else(std::sync::PoisonError::into_inner);
3181 let evaluated = evaluate_locator_in_state_with_requirement(&mut state, query, require_one)
3182 .map_err(|error| TuiTestError::assertion(error.to_string()))?;
3183 let observation = capture_failure_observation_locked(session, &mut state);
3184 Ok((evaluated, observation))
3185}
3186
3187fn find_locator(
3188 session: &TerminalSession,
3189 query: &LocatorQuery,
3190 require_one: bool,
3191) -> Result<Vec<TextMatch>, TuiTestError> {
3192 validate_locator_query(query)?;
3193 let mut state = session
3194 .state
3195 .lock()
3196 .unwrap_or_else(std::sync::PoisonError::into_inner);
3197 let evaluated = evaluate_locator_in_state_with_requirement(&mut state, query, require_one)
3198 .map_err(|error| TuiTestError::assertion(error.to_string()))?;
3199 let failure = evaluated.evaluation.diagnostics.failure_reason;
3200 if matches!(
3201 failure,
3202 Some(LocatorFailureReason::Ambiguous | LocatorFailureReason::AnchorAmbiguous)
3203 ) || (require_one && evaluated.evaluation.matches.len() != 1)
3204 {
3205 let observation = capture_failure_observation_locked(session, &mut state);
3206 drop(state);
3207 let message = locator_failure_message(query, &evaluated.evaluation.diagnostics);
3208 return Err(locator_failure_error(
3209 if require_one {
3210 "locator.location"
3211 } else {
3212 "locator.find"
3213 },
3214 None,
3215 message,
3216 evaluated,
3217 Vec::new(),
3218 false,
3219 Some(observation),
3220 ));
3221 }
3222 drop(state);
3223 Ok(evaluated
3224 .evaluation
3225 .matches
3226 .into_iter()
3227 .map(|matched| matched.value)
3228 .collect())
3229}
3230
3231fn wait_locator(
3232 session: &TerminalSession,
3233 query: &LocatorQuery,
3234 not: bool,
3235 timeout_ms: u64,
3236) -> Result<(), TuiTestError> {
3237 validate_locator_query(query)?;
3238 let description = query.selector.description();
3239 let started = Instant::now();
3240 let mut transitions = Vec::new();
3241 let mut last_signature = None;
3242 loop {
3243 let evaluated = evaluate_locator(session, query, false)?;
3244 let ambiguous = matches!(
3245 evaluated.evaluation.diagnostics.failure_reason,
3246 Some(LocatorFailureReason::Ambiguous | LocatorFailureReason::AnchorAmbiguous)
3247 );
3248 let visible = !evaluated.evaluation.matches.is_empty() && !ambiguous;
3249 let matched = !ambiguous && visible != not;
3250 push_evaluation_transition(
3251 &mut transitions,
3252 &mut last_signature,
3253 &evaluated,
3254 if ambiguous {
3255 "ambiguous"
3256 } else if visible {
3257 "matched"
3258 } else {
3259 "no_match"
3260 },
3261 started.elapsed().as_millis() as u64,
3262 );
3263 if matched {
3264 return Ok(());
3265 }
3266 if session_stopped(session) || started.elapsed() >= Duration::from_millis(timeout_ms) {
3267 let (final_evaluated, observation) =
3268 evaluate_locator_with_observation(session, query, false)?;
3269 let final_ambiguous = matches!(
3270 final_evaluated.evaluation.diagnostics.failure_reason,
3271 Some(LocatorFailureReason::Ambiguous | LocatorFailureReason::AnchorAmbiguous)
3272 );
3273 let final_visible = !final_evaluated.evaluation.matches.is_empty() && !final_ambiguous;
3274 if !final_ambiguous && final_visible != not {
3275 return Ok(());
3276 }
3277 push_evaluation_transition(
3278 &mut transitions,
3279 &mut last_signature,
3280 &final_evaluated,
3281 if final_ambiguous {
3282 "ambiguous"
3283 } else if final_visible {
3284 "matched"
3285 } else {
3286 "no_match"
3287 },
3288 started.elapsed().as_millis() as u64,
3289 );
3290 let stopped = observation.process.cancelled || observation.process.exit_code.is_some();
3291 let message = if stopped {
3292 format!(
3293 "session exited before '{description}' became {}",
3294 if not { "hidden" } else { "visible" }
3295 )
3296 } else if matches!(
3297 final_evaluated.evaluation.diagnostics.failure_reason,
3298 Some(
3299 LocatorFailureReason::Ambiguous
3300 | LocatorFailureReason::AnchorAmbiguous
3301 | LocatorFailureReason::AnchorNotFound
3302 )
3303 ) {
3304 locator_failure_message(query, &final_evaluated.evaluation.diagnostics)
3305 } else {
3306 timeout_message(&description, timeout_ms, not)
3307 };
3308 return Err(locator_failure_error(
3309 "locator.wait",
3310 Some(timeout_ms),
3311 message,
3312 final_evaluated,
3313 transitions,
3314 not,
3315 Some(observation),
3316 ));
3317 }
3318 std::thread::sleep(Duration::from_millis(POLL_DELAY_MS));
3319 }
3320}
3321
3322fn push_evaluation_transition(
3323 transitions: &mut Vec<crate::diagnostics::EvaluationTransition>,
3324 last_signature: &mut Option<String>,
3325 evaluated: &EvaluatedLocator,
3326 outcome: &str,
3327 elapsed_ms: u64,
3328) {
3329 let stage_counts = evaluated
3330 .evaluation
3331 .diagnostics
3332 .stages
3333 .iter()
3334 .map(|stage| stage.selected_count)
3335 .collect::<Vec<_>>();
3336 let signature = format!(
3337 "{outcome}:{:?}:{stage_counts:?}",
3338 evaluated.evaluation.diagnostics.failure_reason
3339 );
3340 if last_signature.as_deref() == Some(signature.as_str()) {
3341 return;
3342 }
3343 *last_signature = Some(signature);
3344 transitions.push(crate::diagnostics::EvaluationTransition {
3345 elapsed_ms,
3346 screen_sequence: evaluated.screen_sequence,
3347 outcome: outcome.to_string(),
3348 stage_index: evaluated.evaluation.diagnostics.failure_stage,
3349 stage_counts,
3350 });
3351 if transitions.len() > 16 {
3352 transitions.remove(0);
3353 }
3354}
3355
3356fn locator_failure_error(
3357 operation: &str,
3358 timeout_ms: Option<u64>,
3359 message: String,
3360 evaluated: EvaluatedLocator,
3361 transitions: Vec<crate::diagnostics::EvaluationTransition>,
3362 negated: bool,
3363 observation: Option<FailureObservation>,
3364) -> TuiTestError {
3365 let reason = if negated && !evaluated.evaluation.matches.is_empty() {
3366 FailureReason::UnexpectedMatch
3367 } else {
3368 match evaluated.evaluation.diagnostics.failure_reason {
3369 Some(LocatorFailureReason::Ambiguous | LocatorFailureReason::AnchorAmbiguous) => {
3370 FailureReason::LocatorAmbiguous
3371 }
3372 Some(LocatorFailureReason::OutsideViewport)
3373 | Some(LocatorFailureReason::MatchedNoCells) => FailureReason::MatchNotActionable,
3374 _ => FailureReason::LocatorNoMatch,
3375 }
3376 };
3377 let mut details = FailureReport::new(operation, timeout_ms, reason, message.clone());
3378 details.operation.failed_screen_sequence = evaluated.screen_sequence;
3379 details.locator = Some(evaluated.evaluation.diagnostics);
3380 details.evaluation_transitions = transitions;
3381 let mut error = TuiTestError::assertion(message).with_report(details);
3382 error.observation = observation.map(Box::new);
3383 error
3384}
3385
3386#[allow(clippy::too_many_arguments)]
3387fn observed_comparison_failure(
3388 session: &TerminalSession,
3389 operation: &str,
3390 timeout_ms: Option<u64>,
3391 reason: FailureReason,
3392 message: String,
3393 kind: &str,
3394 expected: Option<String>,
3395 actual: Option<String>,
3396) -> TuiTestError {
3397 let mut error = comparison_failure(
3398 operation, timeout_ms, reason, message, kind, expected, actual,
3399 );
3400 error.observation = Some(Box::new(capture_failure_observation(session)));
3401 error
3402}
3403
3404fn resolve_locator_click_point(
3405 session: &TerminalSession,
3406 query: &LocatorQuery,
3407 timeout_ms: u64,
3408) -> Result<(u16, u16), TuiTestError> {
3409 validate_locator_query(query)?;
3410 let description = query.selector.description();
3411 let started = Instant::now();
3412 let mut transitions = Vec::new();
3413 let mut last_signature = None;
3414 loop {
3415 let (evaluated, outcome) = {
3416 let mut state = session
3417 .state
3418 .lock()
3419 .unwrap_or_else(std::sync::PoisonError::into_inner);
3420 let visible_len = state.emu.viewable_rows().len();
3421 let evaluated = evaluate_locator_in_state_with_requirement(&mut state, query, true)
3422 .map_err(|error| TuiTestError::assertion(error.to_string()))?;
3423 let full = query.uses_full_grid();
3424 let viewport_offset = evaluated.evaluation.diagnostics.viewport_origin_y as usize;
3425 let outcome = click_point_from_candidates(
3426 evaluated.evaluation.matches.clone(),
3427 &description,
3428 full,
3429 viewport_offset,
3430 visible_len,
3431 );
3432 (evaluated, outcome)
3433 };
3434 match outcome {
3435 Ok(Some(point)) => return Ok(point),
3436 Ok(None) => push_evaluation_transition(
3437 &mut transitions,
3438 &mut last_signature,
3439 &evaluated,
3440 "no_match",
3441 started.elapsed().as_millis() as u64,
3442 ),
3443 Err(_) => push_evaluation_transition(
3444 &mut transitions,
3445 &mut last_signature,
3446 &evaluated,
3447 "not_actionable",
3448 started.elapsed().as_millis() as u64,
3449 ),
3450 }
3451 if session_stopped(session) || started.elapsed() >= Duration::from_millis(timeout_ms) {
3452 let (mut final_evaluated, observation) =
3453 evaluate_locator_with_observation(session, query, true)?;
3454 let full = query.uses_full_grid();
3455 let viewport_offset = final_evaluated.evaluation.diagnostics.viewport_origin_y as usize;
3456 let actionability = click_point_from_candidates(
3457 final_evaluated.evaluation.matches.clone(),
3458 &description,
3459 full,
3460 viewport_offset,
3461 final_evaluated.visible_rows,
3462 );
3463 let actionability_error = match actionability {
3464 Ok(Some(point)) => return Ok(point),
3465 Ok(None) => None,
3466 Err(error) => Some(error),
3467 };
3468 let message =
3469 if observation.process.cancelled || observation.process.exit_code.is_some() {
3470 format!("session exited before '{description}' could be clicked")
3471 } else if let Some(error) = actionability_error {
3472 let reason = if error.message.contains("outside the visible viewport")
3473 || error.message.contains("in scrollback")
3474 {
3475 LocatorFailureReason::OutsideViewport
3476 } else {
3477 LocatorFailureReason::MatchedNoCells
3478 };
3479 final_evaluated.evaluation.diagnostics.failure_reason = Some(reason);
3480 final_evaluated.evaluation.diagnostics.failure_stage = final_evaluated
3481 .evaluation
3482 .diagnostics
3483 .stages
3484 .len()
3485 .checked_sub(1);
3486 error.message
3487 } else if matches!(
3488 final_evaluated.evaluation.diagnostics.failure_reason,
3489 Some(
3490 LocatorFailureReason::Ambiguous
3491 | LocatorFailureReason::AnchorAmbiguous
3492 | LocatorFailureReason::AnchorNotFound
3493 )
3494 ) {
3495 locator_failure_message(query, &final_evaluated.evaluation.diagnostics)
3496 } else {
3497 format!(
3498 "timed out after {} waiting for exactly one '{description}' match",
3499 format_timeout(timeout_ms),
3500 )
3501 };
3502 return Err(locator_failure_error(
3503 "locator.click",
3504 Some(timeout_ms),
3505 message,
3506 final_evaluated,
3507 transitions,
3508 false,
3509 Some(observation),
3510 ));
3511 }
3512 std::thread::sleep(Duration::from_millis(POLL_DELAY_MS));
3513 }
3514}
3515
3516fn click_locator(
3517 session: &TerminalSession,
3518 query: &LocatorQuery,
3519 options: crate::api::MouseOptions,
3520 clicks: u8,
3521 timeout_ms: u64,
3522 input: &mut Option<InputDetails>,
3523) -> Result<(), TuiTestError> {
3524 let (x, y) = resolve_locator_click_point(session, query, timeout_ms)?;
3525 let mut sequence = String::new();
3526 for _ in 0..clicks.max(1) {
3527 sequence.push_str(&mouse::click(x, y, options));
3528 }
3529 write_input(session, sequence.as_bytes(), input, Some((x, y)))
3530}
3531
3532fn click_point_from_candidates(
3533 mut candidates: Vec<locator::LocatedMatch>,
3534 description: &str,
3535 full: bool,
3536 viewport_offset: usize,
3537 visible_rows: usize,
3538) -> Result<Option<(u16, u16)>, TuiTestError> {
3539 if candidates.len() > 1 {
3540 return Err(TuiTestError::assertion(format!(
3541 "click requires one match for '{description}', but found {}",
3542 candidates.len()
3543 )));
3544 }
3545 let Some(matched) = candidates.pop() else {
3546 return Ok(None);
3547 };
3548 let (x, absolute_y) = matched_center(&matched).ok_or_else(|| {
3549 TuiTestError::assertion(format!("'{description}' matched no terminal cells"))
3550 })?;
3551 let y = if full {
3552 absolute_y.checked_sub(viewport_offset).ok_or_else(|| {
3553 TuiTestError::assertion(format!(
3554 "'{description}' matched in scrollback outside the visible viewport and cannot be clicked"
3555 ))
3556 })?
3557 } else {
3558 absolute_y
3559 };
3560 if y >= visible_rows {
3561 return Err(TuiTestError::assertion(format!(
3562 "'{description}' matched outside the visible viewport and cannot be clicked"
3563 )));
3564 }
3565 let x = u16::try_from(x)
3566 .map_err(|_| TuiTestError::internal("matched column is outside terminal coordinates"))?;
3567 let y = u16::try_from(y)
3568 .map_err(|_| TuiTestError::internal("matched row is outside terminal coordinates"))?;
3569 Ok(Some((x, y)))
3570}
3571
3572fn matched_center(matched: &locator::LocatedMatch) -> Option<(usize, usize)> {
3573 matched
3574 .cells
3575 .get(matched.cells.len() / 2)
3576 .map(|cell| (cell.x, cell.y))
3577}
3578
3579fn highlight_locator(
3580 session: &TerminalSession,
3581 query: &LocatorQuery,
3582 timeout_ms: u64,
3583) -> Result<Vec<TextMatch>, TuiTestError> {
3584 validate_locator_query(query)?;
3585 let description = query.selector.description();
3586 let mut resolved = None;
3587 poll_until(
3588 || {
3589 let outcome = {
3590 let mut state = session
3591 .state
3592 .lock()
3593 .unwrap_or_else(std::sync::PoisonError::into_inner);
3594 let full_rows = state.emu.full_rows();
3595 let visible_rows = state.emu.viewable_rows();
3596 let viewport_offset = full_rows.len().saturating_sub(visible_rows.len());
3597 let full = query.uses_full_grid();
3598 let rows = if full { &full_rows } else { &visible_rows };
3599 match locator::locate_query(rows, query, &mut |cell, style| {
3600 cell_matches_style(cell, style, state.emu.as_ref())
3601 }) {
3602 Ok(candidates) if candidates.is_empty() => Ok(None),
3603 Ok(candidates) => {
3604 let row_offset = if full { 0 } else { viewport_offset };
3605 state.highlight = Some(TextHighlight {
3606 cells: candidates
3607 .iter()
3608 .flat_map(|matched| {
3609 matched
3610 .cells
3611 .iter()
3612 .map(|cell| (cell.x, row_offset.saturating_add(cell.y)))
3613 })
3614 .collect(),
3615 viewport_offset,
3616 });
3617 Ok(Some(
3618 candidates
3619 .into_iter()
3620 .map(|matched| matched.value)
3621 .collect(),
3622 ))
3623 }
3624 Err(error) => Err(TuiTestError::assertion(error.to_string())),
3625 }
3626 };
3627 if let Ok(Some(matches)) = outcome {
3628 resolved = Some(matches);
3629 }
3630 resolved.is_some() || session_stopped(session)
3631 },
3632 timeout_ms,
3633 );
3634 if let Some(matches) = resolved {
3635 Ok(matches)
3636 } else {
3637 let (evaluated, observation, final_matches) = {
3638 let mut state = session
3639 .state
3640 .lock()
3641 .unwrap_or_else(std::sync::PoisonError::into_inner);
3642 let evaluated = evaluate_locator_in_state_with_requirement(&mut state, query, false)
3643 .map_err(|error| TuiTestError::assertion(error.to_string()))?;
3644 let final_matches = if evaluated.evaluation.matches.is_empty()
3645 || matches!(
3646 evaluated.evaluation.diagnostics.failure_reason,
3647 Some(LocatorFailureReason::Ambiguous | LocatorFailureReason::AnchorAmbiguous)
3648 ) {
3649 None
3650 } else {
3651 let full_rows = state.emu.full_rows();
3652 let visible_rows = state.emu.viewable_rows();
3653 let viewport_offset = full_rows.len().saturating_sub(visible_rows.len());
3654 let row_offset = if query.uses_full_grid() {
3655 0
3656 } else {
3657 viewport_offset
3658 };
3659 state.highlight = Some(TextHighlight {
3660 cells: evaluated
3661 .evaluation
3662 .matches
3663 .iter()
3664 .flat_map(|matched| {
3665 matched
3666 .cells
3667 .iter()
3668 .map(|cell| (cell.x, row_offset.saturating_add(cell.y)))
3669 })
3670 .collect(),
3671 viewport_offset,
3672 });
3673 Some(
3674 evaluated
3675 .evaluation
3676 .matches
3677 .iter()
3678 .map(|matched| matched.value.clone())
3679 .collect::<Vec<_>>(),
3680 )
3681 };
3682 let observation = capture_failure_observation_locked(session, &mut state);
3683 (evaluated, observation, final_matches)
3684 };
3685 if let Some(matches) = final_matches {
3686 return Ok(matches);
3687 }
3688 let message = if observation.process.cancelled || observation.process.exit_code.is_some() {
3689 format!("session exited before '{description}' could be highlighted")
3690 } else if matches!(
3691 evaluated.evaluation.diagnostics.failure_reason,
3692 Some(
3693 LocatorFailureReason::Ambiguous
3694 | LocatorFailureReason::AnchorAmbiguous
3695 | LocatorFailureReason::AnchorNotFound
3696 )
3697 ) {
3698 locator_failure_message(query, &evaluated.evaluation.diagnostics)
3699 } else {
3700 format!(
3701 "timed out after {} waiting for a '{description}' match to highlight",
3702 format_timeout(timeout_ms),
3703 )
3704 };
3705 Err(locator_failure_error(
3706 "locator.highlight",
3707 Some(timeout_ms),
3708 message,
3709 evaluated,
3710 Vec::new(),
3711 false,
3712 Some(observation),
3713 ))
3714 }
3715}
3716
3717fn validate_selector(selector: &TextSelector) -> Result<(), TuiTestError> {
3718 let validate = |text: &str, regex: bool| {
3719 Pattern::new(text, regex)
3720 .map(|_| ())
3721 .map_err(|error| TuiTestError::usage(format!("invalid regex: {error}")))
3722 };
3723 validate(&selector.text, selector.regex)?;
3724 for TextAnchor { text, regex, .. } in [
3725 selector.scope.after.as_ref(),
3726 selector.scope.before.as_ref(),
3727 ]
3728 .into_iter()
3729 .flatten()
3730 {
3731 validate(text, *regex)?;
3732 }
3733 Ok(())
3734}
3735
3736fn validate_style(style: &TextStyle) -> Result<(), TuiTestError> {
3737 for spec in [&style.foreground, &style.background, &style.underline_color]
3738 .into_iter()
3739 .flatten()
3740 {
3741 Expected::parse(spec).map_err(|error| TuiTestError::usage(error.to_string()))?;
3742 }
3743 if let Some(style) = &style.underline_style {
3744 if !matches!(
3745 style.as_str(),
3746 "none" | "single" | "double" | "curly" | "dotted" | "dashed"
3747 ) {
3748 return Err(TuiTestError::usage(format!(
3749 "invalid underline style '{style}'"
3750 )));
3751 }
3752 }
3753 Ok(())
3754}
3755
3756fn cell_matches_style(cell: &EmuCell, style: &TextStyle, colors: &dyn Emulator) -> bool {
3757 evaluate_cell_style(cell, style, colors, 0, 0, 0).matched
3758}
3759
3760fn evaluate_cell_style(
3761 cell: &EmuCell,
3762 style: &TextStyle,
3763 colors: &dyn Emulator,
3764 x: usize,
3765 y: usize,
3766 mismatch_limit: usize,
3767) -> CellStyleEvaluation {
3768 let mut result = CellStyleEvaluation {
3769 matched: true,
3770 mismatches: Vec::new(),
3771 mismatches_truncated: false,
3772 };
3773 for (property, expected, actual) in [
3774 ("bold", style.bold, cell.has(Attrs::BOLD)),
3775 ("dim", style.dim, cell.has(Attrs::DIM)),
3776 ("italic", style.italic, cell.has(Attrs::ITALIC)),
3777 ("inverse", style.inverse, cell.has(Attrs::INVERSE)),
3778 ("hidden", style.hidden, cell.has(Attrs::INVISIBLE)),
3779 (
3780 "strikethrough",
3781 style.strikethrough,
3782 cell.has(Attrs::STRIKE),
3783 ),
3784 ("blink", style.blink, cell.has(Attrs::BLINK)),
3785 ] {
3786 if let Some(expected) = expected {
3787 if expected != actual
3788 && !result.reject(mismatch_limit, || {
3789 style_mismatch(
3790 cell,
3791 x,
3792 y,
3793 property,
3794 expected.to_string(),
3795 actual.to_string(),
3796 None,
3797 )
3798 })
3799 {
3800 return result;
3801 }
3802 }
3803 }
3804 if let Some(expected) = style.underline_style.as_deref() {
3805 let actual = cell.underline.name();
3806 if expected != actual
3807 && !result.reject(mismatch_limit, || {
3808 style_mismatch(
3809 cell,
3810 x,
3811 y,
3812 "underline_style",
3813 expected.to_string(),
3814 actual.to_string(),
3815 None,
3816 )
3817 })
3818 {
3819 return result;
3820 }
3821 }
3822 for (property, spec, actual, foreground) in [
3823 ("foreground", &style.foreground, cell.fg, true),
3824 ("background", &style.background, cell.bg, false),
3825 (
3826 "underline_color",
3827 &style.underline_color,
3828 cell.underline_color,
3829 true,
3830 ),
3831 ] {
3832 if let Some(spec) = spec {
3833 if let Ok(expected) = Expected::parse(spec) {
3834 if !color::matches(actual, &expected, colors, foreground)
3835 && !result.reject(mismatch_limit, || {
3836 style_mismatch(
3837 cell,
3838 x,
3839 y,
3840 property,
3841 expected.describe(),
3842 logical_color(actual),
3843 Some(colors.resolve(actual, foreground).to_hex()),
3844 )
3845 })
3846 {
3847 return result;
3848 }
3849 }
3850 }
3851 }
3852 result
3853}
3854
3855fn style_mismatch(
3856 cell: &EmuCell,
3857 x: usize,
3858 y: usize,
3859 property: &str,
3860 expected: String,
3861 actual: String,
3862 resolved: Option<String>,
3863) -> CellMismatch {
3864 CellMismatch {
3865 location: crate::api::TextPosition {
3866 row: y.min(u32::MAX as usize) as u32,
3867 column: x.min(u16::MAX as usize) as u16,
3868 },
3869 grapheme: cell.ch.to_string(),
3870 property: property.to_string(),
3871 operator: "equals".to_string(),
3872 expected,
3873 actual,
3874 resolved,
3875 reason: "value_mismatch".to_string(),
3876 }
3877}
3878
3879fn logical_color(color: Option<Color>) -> String {
3880 match color {
3881 None => "default".to_string(),
3882 Some(Color::Rgb(r, g, b)) => format!("#{r:02x}{g:02x}{b:02x}"),
3883 Some(color) => color.to_index().to_string(),
3884 }
3885}
3886
3887fn expect_exit_code(
3888 session: &TerminalSession,
3889 code: i32,
3890 timeout_ms: u64,
3891) -> Result<(), TuiTestError> {
3892 let baseline = session
3893 .state
3894 .lock()
3895 .unwrap_or_else(std::sync::PoisonError::into_inner)
3896 .tracker
3897 .finished_count();
3898 if !poll_until(|| command_settled(session, baseline), timeout_ms) {
3899 return Err(TuiTestError::assertion(format!(
3900 "expected exit code {code}: timed out after {timeout_ms}ms; {}",
3901 stall_reason(session)
3902 )));
3903 }
3904 let actual = session
3905 .state
3906 .lock()
3907 .unwrap_or_else(std::sync::PoisonError::into_inner)
3908 .tracker
3909 .last_exit();
3910 match actual {
3911 Some(actual) if actual == code => Ok(()),
3912 Some(actual) => Err(observed_comparison_failure(
3913 session,
3914 "expect.exit_code",
3915 Some(timeout_ms),
3916 FailureReason::ScalarMismatch,
3917 format!("expected exit code {code}, got {actual}"),
3918 "exit_code",
3919 Some(code.to_string()),
3920 Some(actual.to_string()),
3921 )),
3922 None => Err(observed_comparison_failure(
3923 session,
3924 "expect.exit_code",
3925 Some(timeout_ms),
3926 FailureReason::ScalarMismatch,
3927 "no command exit code tracked yet".to_string(),
3928 "exit_code",
3929 Some(code.to_string()),
3930 None,
3931 )),
3932 }
3933}
3934
3935fn expect_output(session: &TerminalSession, text: &str, regex: bool) -> Result<(), TuiTestError> {
3936 let output = session
3937 .state
3938 .lock()
3939 .unwrap_or_else(std::sync::PoisonError::into_inner)
3940 .tracker
3941 .last_output()
3942 .map(str::to_string)
3943 .ok_or_else(|| TuiTestError::assertion("no command output tracked yet"))?;
3944 let matched = if regex {
3945 regex::Regex::new(text)
3946 .map_err(|error| TuiTestError::usage(format!("invalid regex: {error}")))?
3947 .is_match(&output)
3948 } else {
3949 output.contains(text)
3950 };
3951 if matched {
3952 Ok(())
3953 } else {
3954 Err(TuiTestError::assertion(format!(
3955 "output did not contain '{text}'\n---\n{output}\n---"
3956 )))
3957 }
3958}
3959
3960fn expect_bell_count(
3961 session: &TerminalSession,
3962 expected: u64,
3963 timeout_ms: u64,
3964) -> Result<(), TuiTestError> {
3965 let mut actual = session.bells.count();
3966 poll_until(
3967 || {
3968 actual = session.bells.count();
3969 actual >= expected || session_stopped(session)
3970 },
3971 timeout_ms,
3972 );
3973 if actual >= expected {
3974 Ok(())
3975 } else if session_stopped(session) {
3976 Err(TuiTestError::assertion(format!(
3977 "session exited at bell count {actual} before reaching {expected}"
3978 )))
3979 } else {
3980 Err(observed_comparison_failure(
3981 session,
3982 "expect.bell_count",
3983 Some(timeout_ms),
3984 FailureReason::TimedOut,
3985 format!(
3986 "expected bell count {expected}: timed out after {timeout_ms}ms; current count is {actual}"
3987 ),
3988 "bell_count",
3989 Some(expected.to_string()),
3990 Some(actual.to_string()),
3991 ))
3992 }
3993}
3994
3995fn do_snapshot(
3996 session: &TerminalSession,
3997 name: &str,
3998 update: bool,
3999 include_style: bool,
4000 include_title: bool,
4001 cwd: Option<String>,
4002) -> Result<SnapshotResult, TuiTestError> {
4003 let observation = capture_failure_observation(session);
4007 let title = include_title.then(|| observation.title.clone()).flatten();
4008 let content = snapshot::serialize(
4009 &observation.rows,
4010 observation.cols,
4011 include_style,
4012 title.as_deref(),
4013 );
4014 let base = cwd
4015 .map(std::path::PathBuf::from)
4016 .or_else(|| std::env::current_dir().ok())
4017 .unwrap_or_default();
4018 match snapshot::compare(&base, name, &content, update) {
4019 Ok(SnapshotStatus::Passed) => Ok(SnapshotResult::Passed),
4020 Ok(SnapshotStatus::Written) => Ok(SnapshotResult::Written),
4021 Ok(SnapshotStatus::Updated) => Ok(SnapshotResult::Updated),
4022 Ok(SnapshotStatus::Failed { expected, actual }) => {
4023 let message = format!(
4024 "snapshot mismatch\n--- expected ---\n{expected}\n--- actual ---\n{actual}"
4025 );
4026 let mut error = comparison_failure(
4027 "expect.snapshot",
4028 None,
4029 FailureReason::SnapshotMismatch,
4030 message,
4031 "snapshot",
4032 Some(expected),
4033 Some(actual),
4034 );
4035 error.observation = Some(Box::new(observation));
4036 Err(error)
4037 }
4038 Err(error) => Err(TuiTestError::internal(error.to_string())),
4039 }
4040}
4041
4042fn cursor_in(
4048 rows: &[Vec<EmuCell>],
4049 emu: &dyn crate::terminal::emu::Emulator,
4050) -> Option<(u16, usize)> {
4051 if !emu.cursor_visible() {
4052 return None;
4053 }
4054 let (x, y) = emu.cursor();
4055 let (_, screen) = emu.size();
4056 let history = rows.len().saturating_sub(screen as usize);
4060 Some((x, history + y as usize))
4061}
4062
4063struct SvgSnapshot {
4064 rows: Vec<Vec<EmuCell>>,
4065 cols: u16,
4066 title: Option<String>,
4067 cursor: Option<(u16, usize)>,
4068 render_state: crate::render::svg::RenderState,
4069}
4070
4071fn svg_snapshot_from(emu: &dyn Emulator, full: bool) -> SvgSnapshot {
4072 let rows = if full {
4073 emu.full_rows()
4074 } else {
4075 emu.viewable_rows()
4076 };
4077 SvgSnapshot {
4078 cols: emu.size().0,
4079 title: emu.title(),
4080 cursor: cursor_in(&rows, emu),
4081 render_state: crate::render::svg::RenderState::capture(emu),
4082 rows,
4083 }
4084}
4085
4086fn svg_snapshot(session: &TerminalSession, full: bool) -> SvgSnapshot {
4089 let state = session
4090 .state
4091 .lock()
4092 .unwrap_or_else(std::sync::PoisonError::into_inner);
4093 let mut snapshot = svg_snapshot_from(state.emu.as_ref(), full);
4094 apply_highlight(&mut snapshot.rows, state.highlight.as_ref(), full);
4095 snapshot
4096}
4097
4098#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4099enum ScreenshotFormat {
4100 Svg,
4101 Png,
4102}
4103
4104impl ScreenshotFormat {
4105 fn infer(path: &str) -> Result<Self, TuiTestError> {
4106 let extension = std::path::Path::new(path)
4107 .extension()
4108 .and_then(|extension| extension.to_str())
4109 .map(str::to_ascii_lowercase);
4110 match extension.as_deref() {
4111 None | Some("svg") => Ok(Self::Svg),
4112 Some("png") => Ok(Self::Png),
4113 Some(extension) => Err(TuiTestError::usage(format!(
4114 "unsupported screenshot extension '.{extension}'; use .svg or .png"
4115 ))),
4116 }
4117 }
4118}
4119
4120fn screenshot(
4121 session: &TerminalSession,
4122 full: bool,
4123 path: Option<String>,
4124 zoom: Option<f64>,
4125 background: Option<crate::api::CaptureBackground>,
4126) -> Result<ScreenshotResult, TuiTestError> {
4127 match path {
4128 Some(path) => {
4129 let zoom = crate::api::resolve_zoom(zoom)?;
4130 let format = ScreenshotFormat::infer(&path)?;
4131 let snapshot = svg_snapshot(session, full);
4132 match format {
4133 ScreenshotFormat::Svg => {
4134 let svg = crate::render::svg::render_svg_with_zoom(
4135 &snapshot.rows,
4136 snapshot.cols,
4137 &snapshot.render_state,
4138 snapshot.cursor,
4139 snapshot.title.as_deref(),
4140 zoom,
4141 background,
4142 );
4143 std::fs::write(&path, svg)
4144 .map_err(|error| TuiTestError::internal(error.to_string()))?;
4145 }
4146 ScreenshotFormat::Png => {
4147 #[cfg(feature = "recording-raster")]
4148 {
4149 let rows = snapshot.rows.len();
4150 let frame = crate::record::frames::Frame {
4151 grid: snapshot.rows,
4152 title: snapshot.title,
4153 duration: Duration::ZERO,
4154 render_state: snapshot.render_state,
4155 cursor: snapshot.cursor,
4156 };
4157 let mut renderer = crate::render::raster::GridRenderer::for_screenshot(
4158 snapshot.cols,
4159 rows,
4160 zoom,
4161 background,
4162 )
4163 .map_err(|error| TuiTestError::internal(error.to_string()))?;
4164 crate::render::encode::encode_png(
4165 std::path::Path::new(&path),
4166 &frame,
4167 &mut renderer,
4168 )
4169 .map_err(|error| TuiTestError::internal(error.to_string()))?;
4170 }
4171 #[cfg(not(feature = "recording-raster"))]
4172 {
4173 return Err(TuiTestError::usage(
4174 "PNG screenshots require the tui-test 'recording-raster' feature",
4175 ));
4176 }
4177 }
4178 }
4179 Ok(ScreenshotResult::Path(path))
4180 }
4181 None if zoom.is_some() || background.is_some() => Err(TuiTestError::usage(
4182 "screenshot zoom and background options require an output path",
4183 )),
4184 None => Ok(ScreenshotResult::Text(text_of(&grid(session, full)))),
4185 }
4186}
4187
4188fn panic_message(payload: &(dyn std::any::Any + Send)) -> &str {
4189 if let Some(message) = payload.downcast_ref::<&'static str>() {
4190 message
4191 } else if let Some(message) = payload.downcast_ref::<String>() {
4192 message.as_str()
4193 } else {
4194 "unknown panic"
4195 }
4196}
4197
4198#[cfg(test)]
4199mod tests {
4200 use super::*;
4201 use crate::api::{
4202 AutomaticRecording, AutomaticRecordingMode, TextPosition, TextSpan, Timeouts,
4203 };
4204 use crate::profile::Profile;
4205 use crate::terminal::alacritty::AlacrittyEmu;
4206 use crate::terminal::cell::{NamedColor, UnderlineStyle};
4207 use crate::terminal::emu::Emulator;
4208
4209 fn sleeping_program(wait_ready: bool) -> RunOptions {
4210 let (program, args) = if cfg!(windows) {
4211 (
4212 "powershell.exe",
4213 vec!["-NoProfile", "-Command", "Start-Sleep -Seconds 30"],
4214 )
4215 } else {
4216 ("sh", vec!["-c", "sleep 30"])
4217 };
4218 let defaults = OpenOptions::default();
4219 RunOptions {
4220 program: program.into(),
4221 args: args.into_iter().map(str::to_string).collect(),
4222 backend: defaults.backend,
4223 profile: defaults.profile,
4224 cols: 80,
4225 rows: 24,
4226 cwd: None,
4227 env: Vec::new(),
4228 wait_ready: Some(wait_ready),
4229 restart: false,
4230 timeouts: crate::api::Timeouts {
4231 ready: Some(20),
4232 ..Default::default()
4233 },
4234 recording: AutomaticRecording {
4235 mode: AutomaticRecordingMode::Disabled,
4236 directory: None,
4237 },
4238 }
4239 }
4240
4241 fn populate_history(engine: &Engine) {
4242 let guard = engine.lock_session();
4243 let session = guard.as_ref().unwrap();
4244 let mut state = session.state.lock().unwrap();
4245 for index in 0..32 {
4246 state.emu.process(format!("\x1b[H{index:02}").as_bytes());
4247 state.screen_dirty = true;
4248 capture_visual_state(&mut state, true);
4249 state.screen_history.pin_current();
4250 }
4251 }
4252
4253 fn trace_engine(name: &str, mode: TraceMode) -> (Engine, ExecutionContext, PathBuf) {
4254 let root = allocate_artifact_directory(&std::env::temp_dir()).unwrap();
4255 let engine = Engine::new(
4256 name.into(),
4257 Arc::new(Logger::disabled()),
4258 root.join("automatic.cast"),
4259 );
4260 let context = ExecutionContext {
4261 trace: Some(TraceOptions {
4262 mode,
4263 directory: root.join("traces"),
4264 }),
4265 ..Default::default()
4266 };
4267 (engine, context, root)
4268 }
4269
4270 struct PanickingTraceEmulator(AlacrittyEmu);
4271
4272 impl Emulator for PanickingTraceEmulator {
4273 fn process(&mut self, bytes: &[u8]) {
4274 self.0.process(bytes);
4275 }
4276 fn take_pending_writes(&mut self) -> Vec<u8> {
4277 self.0.take_pending_writes()
4278 }
4279 fn mode(&self, mode: TerminalMode) -> bool {
4280 self.0.mode(mode)
4281 }
4282 fn resize(&mut self, cols: u16, rows: u16) {
4283 self.0.resize(cols, rows);
4284 }
4285 fn size(&self) -> (u16, u16) {
4286 self.0.size()
4287 }
4288 fn cursor(&self) -> (u16, u16) {
4289 self.0.cursor()
4290 }
4291 fn title(&self) -> Option<String> {
4292 self.0.title()
4293 }
4294 fn cursor_shape(&self) -> CursorShape {
4295 self.0.cursor_shape()
4296 }
4297 fn viewable_rows(&self) -> Vec<Vec<EmuCell>> {
4298 panic!("injected trace capture failure")
4299 }
4300 fn full_rows(&self) -> Vec<Vec<EmuCell>> {
4301 self.viewable_rows()
4302 }
4303 fn color(&self, slot: crate::profile::ColorSlot) -> crate::profile::Rgb {
4304 self.0.color(slot)
4305 }
4306 }
4307
4308 #[test]
4309 fn trace_capture_panics_do_not_skip_close_or_escape_drop() {
4310 for close in [true, false] {
4311 let (engine, context, root) = trace_engine("trace-panic", TraceMode::On);
4312 engine
4313 .execute_with_context(Operation::Run(sleeping_program(false)), context)
4314 .unwrap();
4315 {
4316 let guard = engine.lock_session();
4317 let session = guard.as_ref().unwrap();
4318 let mut state = session.state.lock().unwrap();
4319 state.emu = Box::new(PanickingTraceEmulator(AlacrittyEmu::new(
4320 80,
4321 24,
4322 &Profile::default(),
4323 )));
4324 state.screen_dirty = true;
4325 }
4326 let closed = if close {
4327 let error = engine.execute(Operation::Close).unwrap_err();
4328 assert_eq!(error.kind, ErrorKind::Internal);
4329 assert!(error.message.contains("injected trace capture failure"));
4330 Some(!engine.is_open())
4331 } else {
4332 None
4333 };
4334 let dropped = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| drop(engine)));
4335 std::fs::remove_dir_all(root).unwrap();
4336 assert_ne!(
4337 closed,
4338 Some(false),
4339 "trace capture must not prevent closing"
4340 );
4341 assert!(dropped.is_ok(), "trace capture must not panic during drop");
4342 }
4343 }
4344
4345 #[test]
4346 fn trace_failure_preserves_startup_context_and_operation_overrides() {
4347 let (engine, mut context, root) = trace_engine("trace-context", TraceMode::OnFailure);
4348 context
4349 .diagnostic_context
4350 .insert("test".into(), "case".into());
4351 context
4352 .diagnostic_context
4353 .insert("phase".into(), "startup".into());
4354 engine
4355 .execute_with_context(Operation::Run(sleeping_program(false)), context)
4356 .unwrap();
4357 let error = engine
4358 .execute_with_context(
4359 Operation::WaitLocator {
4360 query: LocatorQuery::text("missing trace marker"),
4361 not: false,
4362 timeout_ms: Some(0),
4363 },
4364 ExecutionContext {
4365 diagnostic_context: [("phase".into(), "assertion".into())].into(),
4366 ..Default::default()
4367 },
4368 )
4369 .unwrap_err();
4370 let manifest: crate::diagnostics::FailureArtifactManifest = serde_json::from_slice(
4371 &std::fs::read(error.artifact.unwrap().manifest.unwrap()).unwrap(),
4372 )
4373 .unwrap();
4374 engine.execute(Operation::Close).unwrap();
4375 std::fs::remove_dir_all(root).unwrap();
4376 assert_eq!(
4377 manifest.details.context.get("test").map(String::as_str),
4378 Some("case")
4379 );
4380 assert_eq!(
4381 manifest.details.context.get("phase").map(String::as_str),
4382 Some("assertion")
4383 );
4384 }
4385
4386 #[test]
4387 fn trace_context_merge_preserves_the_context_budget() {
4388 let trace = TraceState {
4389 context: (0..16)
4390 .map(|index| (format!("session-{index}"), "startup".into()))
4391 .collect(),
4392 ..Default::default()
4393 };
4394 let context = ExecutionContext {
4395 diagnostic_context: (0..16)
4396 .map(|index| (format!("operation-{index}"), "x".repeat(512)))
4397 .collect(),
4398 ..Default::default()
4399 };
4400 let merged = trace.diagnostic_context(&context);
4401 assert_eq!(merged.len(), 16);
4402 assert!(merged.values().all(|value| value.len() <= 259));
4403 }
4404
4405 #[test]
4406 fn failed_startup_final_outcome_controls_on_failure_evidence() {
4407 for failed in [false, true] {
4408 let (engine, mut context, root) =
4409 trace_engine("startup-retention", TraceMode::OnFailure);
4410 context.artifact = Some(crate::diagnostics::FailureArtifactOptions {
4411 directory: root.join("explicit-failures"),
4412 mode: crate::diagnostics::FailureArtifactMode::Text,
4413 include_recording: false,
4414 });
4415 let recording_directory = root.join("recordings");
4416 let mut options = sleeping_program(true);
4417 options.recording.directory = Some(recording_directory.clone());
4418 let error = engine
4419 .execute_with_context(Operation::Run(options), context)
4420 .unwrap_err();
4421 assert_eq!(error.kind, ErrorKind::Assertion);
4422 assert!(!engine.is_open());
4423 assert!(error.observation.is_none());
4424 let failure_manifest = error.artifact.unwrap().manifest.unwrap();
4425 let failure_bytes = std::fs::read(&failure_manifest).unwrap();
4426 let trace_artifact = engine.trace.lock().unwrap().artifact.clone().unwrap();
4427 let trace_manifest = trace_artifact.manifest.unwrap();
4428 let trace_bytes = std::fs::read(&trace_manifest).unwrap();
4429 let trace_recording = trace_artifact.recording.unwrap();
4430 let trace_recording_bytes = std::fs::read(&trace_recording).unwrap();
4431 let recording_path = engine.recording_path().unwrap();
4432 assert!(recording_path.starts_with(&recording_directory));
4433 let recording_bytes = std::fs::read(&recording_path).unwrap();
4434 let unrelated = recording_directory.join("keep.txt");
4435 std::fs::write(&unrelated, "not owned by the engine").unwrap();
4436
4437 engine.execute(Operation::FinishTrace { failed }).unwrap();
4438 engine.execute(Operation::FinishTrace { failed }).unwrap();
4439 assert_eq!(std::path::Path::new(&trace_manifest).is_file(), failed);
4440 assert_eq!(recording_path.is_file(), failed);
4441 engine.execute(Operation::Close).unwrap();
4442 engine.execute(Operation::Close).unwrap();
4443 drop(engine);
4444
4445 assert_eq!(std::fs::read(&failure_manifest).unwrap(), failure_bytes);
4446 assert_eq!(
4447 std::fs::read_to_string(&unrelated).unwrap(),
4448 "not owned by the engine"
4449 );
4450 if failed {
4451 assert_eq!(std::fs::read(&trace_manifest).unwrap(), trace_bytes);
4452 assert_eq!(
4453 std::fs::read(&trace_recording).unwrap(),
4454 trace_recording_bytes
4455 );
4456 assert_eq!(std::fs::read(&recording_path).unwrap(), recording_bytes);
4457 } else {
4458 assert!(!std::path::Path::new(&trace_manifest).exists());
4459 assert!(!std::path::Path::new(&trace_recording).exists());
4460 assert!(!recording_path.exists());
4461 }
4462 std::fs::remove_dir_all(root).unwrap();
4463 }
4464 }
4465
4466 #[test]
4467 fn failed_startup_outcomes_preserve_explicit_recording_policies() {
4468 for mode in [
4469 AutomaticRecordingMode::Disabled,
4470 AutomaticRecordingMode::OnFailure,
4471 AutomaticRecordingMode::Always,
4472 ] {
4473 for failed in [false, true] {
4474 let (engine, _, root) = trace_engine("startup-recording", TraceMode::Off);
4475 let path = root.join("automatic.cast");
4476 let mut options = sleeping_program(true);
4477 options.recording.mode = mode;
4478 if mode == AutomaticRecordingMode::Disabled {
4479 std::fs::write(&path, "not owned by the engine").unwrap();
4480 }
4481 engine.execute(Operation::Run(options)).unwrap_err();
4482 assert!(!engine.is_open());
4483 let bytes = std::fs::read(&path).unwrap();
4484 engine.execute(Operation::FinishTrace { failed }).unwrap();
4485 engine.execute(Operation::Close).unwrap();
4486 drop(engine);
4487 if mode == AutomaticRecordingMode::OnFailure && !failed {
4488 assert!(!path.exists());
4489 } else {
4490 assert_eq!(std::fs::read(&path).unwrap(), bytes);
4491 }
4492 std::fs::remove_dir_all(root).unwrap();
4493 }
4494 }
4495 }
4496
4497 #[test]
4498 fn failed_startup_recording_cleanup_errors_are_reported_and_retryable() {
4499 let (engine, _, root) = trace_engine("startup-cleanup-error", TraceMode::Off);
4500 let mut options = sleeping_program(true);
4501 options.recording.mode = AutomaticRecordingMode::OnFailure;
4502 engine.execute(Operation::Run(options)).unwrap_err();
4503 let path = engine.recording_path().unwrap();
4504 let bytes = std::fs::read(&path).unwrap();
4505 std::fs::remove_file(&path).unwrap();
4506 std::fs::create_dir(&path).unwrap();
4507
4508 let error = engine
4509 .execute(Operation::FinishTrace { failed: false })
4510 .unwrap_err();
4511 assert_eq!(error.kind, ErrorKind::Internal);
4512 assert!(error
4513 .message
4514 .contains("failed to discard automatic recording"));
4515 assert!(path.is_dir());
4516 assert!(engine.recording.lock().unwrap().failed);
4517
4518 std::fs::remove_dir(&path).unwrap();
4519 std::fs::write(&path, bytes).unwrap();
4520 engine
4521 .execute(Operation::FinishTrace { failed: false })
4522 .unwrap();
4523 assert!(!path.exists());
4524 engine.execute(Operation::Close).unwrap();
4525 std::fs::remove_dir_all(root).unwrap();
4526 }
4527
4528 #[test]
4529 fn failed_startup_retention_does_not_change_normal_close_contract() {
4530 let (engine, context, root) = trace_engine("closed-retention", TraceMode::OnFailure);
4531 engine
4532 .execute_with_context(Operation::Run(sleeping_program(false)), context)
4533 .unwrap();
4534 let error = engine
4535 .execute(Operation::WaitLocator {
4536 query: LocatorQuery::text("missing trace marker"),
4537 not: false,
4538 timeout_ms: Some(0),
4539 })
4540 .unwrap_err();
4541 engine.execute(Operation::Close).unwrap();
4542 let trace_manifest = error.artifact.unwrap().manifest.unwrap();
4543 let trace_bytes = std::fs::read(&trace_manifest).unwrap();
4544 let recording_path = engine.recording_path().unwrap();
4545 let recording_bytes = std::fs::read(&recording_path).unwrap();
4546 engine
4547 .execute(Operation::FinishTrace { failed: false })
4548 .unwrap();
4549 drop(engine);
4550 assert_eq!(std::fs::read(&trace_manifest).unwrap(), trace_bytes);
4551 assert_eq!(std::fs::read(&recording_path).unwrap(), recording_bytes);
4552 std::fs::remove_dir_all(root).unwrap();
4553 }
4554
4555 #[test]
4556 fn trace_retention_does_not_override_explicit_failure_exports() {
4557 for (include_recording, shared_directory) in [(false, false), (true, false), (false, true)]
4558 {
4559 let (engine, context, root) = trace_engine("trace-exports", TraceMode::OnFailure);
4560 let trace_directory = context.trace.as_ref().unwrap().directory.clone();
4561 let failure_directory = if shared_directory {
4562 trace_directory.clone()
4563 } else {
4564 root.join("failures")
4565 };
4566 engine
4567 .execute_with_context(Operation::Run(sleeping_program(false)), context)
4568 .unwrap();
4569 let error = engine
4570 .execute_with_context(
4571 Operation::WaitLocator {
4572 query: LocatorQuery::text("missing export marker"),
4573 not: false,
4574 timeout_ms: Some(0),
4575 },
4576 ExecutionContext {
4577 artifact: Some(crate::diagnostics::FailureArtifactOptions {
4578 directory: failure_directory.clone(),
4579 mode: crate::diagnostics::FailureArtifactMode::Text,
4580 include_recording,
4581 }),
4582 ..Default::default()
4583 },
4584 )
4585 .unwrap_err();
4586 let artifact = error.artifact.unwrap();
4587 let manifest_path = artifact.manifest.unwrap();
4588 let manifest: crate::diagnostics::FailureArtifactManifest =
4589 serde_json::from_slice(&std::fs::read(&manifest_path).unwrap()).unwrap();
4590 let trace_manifest = engine
4591 .trace
4592 .lock()
4593 .unwrap()
4594 .artifact
4595 .as_ref()
4596 .unwrap()
4597 .manifest
4598 .clone()
4599 .unwrap();
4600 assert_eq!(error.kind, ErrorKind::Assertion);
4601 assert!(std::path::Path::new(&manifest_path).starts_with(&failure_directory));
4602 assert!(std::path::Path::new(&manifest_path).ends_with("failure.json"));
4603 assert!(manifest.details.outcome.is_none());
4604 assert!(artifact.screen_text.is_some());
4605 assert!(artifact.screen_svg.is_none());
4606 assert_eq!(
4607 manifest.files.iter().any(|file| file.kind == "recording"),
4608 include_recording
4609 );
4610 assert!(std::path::Path::new(&trace_manifest).starts_with(&trace_directory));
4611 assert!(std::path::Path::new(&trace_manifest).ends_with("trace.json"));
4612 let trace_report: crate::diagnostics::FailureArtifactManifest =
4613 serde_json::from_slice(&std::fs::read(&trace_manifest).unwrap()).unwrap();
4614 assert_eq!(trace_report.details.outcome, Some(TraceOutcome::Failed));
4615 assert!(trace_report
4616 .files
4617 .iter()
4618 .any(|file| file.kind == "recording"));
4619 if !include_recording {
4620 assert!(manifest.details.recording.unwrap().path.is_none());
4621 }
4622 engine
4623 .execute(Operation::FinishTrace { failed: false })
4624 .unwrap();
4625 engine.execute(Operation::Close).unwrap();
4626 assert!(std::path::Path::new(&manifest_path).is_file());
4627 assert!(!std::path::Path::new(&trace_manifest).exists());
4628 std::fs::remove_dir_all(root).unwrap();
4629 }
4630 }
4631
4632 #[test]
4633 fn trace_and_explicit_failure_export_errors_are_independent() {
4634 for fail_trace in [false, true] {
4635 let (engine, context, root) = trace_engine("trace-export-errors", TraceMode::OnFailure);
4636 let trace_directory = context.trace.as_ref().unwrap().directory.clone();
4637 let failure_directory = root.join("failures");
4638 std::fs::write(
4639 if fail_trace {
4640 &trace_directory
4641 } else {
4642 &failure_directory
4643 },
4644 "not a directory",
4645 )
4646 .unwrap();
4647 engine
4648 .execute_with_context(Operation::Run(sleeping_program(false)), context)
4649 .unwrap();
4650 let error = engine
4651 .execute_with_context(
4652 Operation::WaitLocator {
4653 query: LocatorQuery::text("missing export marker"),
4654 not: false,
4655 timeout_ms: Some(0),
4656 },
4657 ExecutionContext {
4658 artifact: Some(crate::diagnostics::FailureArtifactOptions {
4659 directory: failure_directory.clone(),
4660 mode: crate::diagnostics::FailureArtifactMode::Text,
4661 include_recording: false,
4662 }),
4663 ..Default::default()
4664 },
4665 )
4666 .unwrap_err();
4667 let artifact = error.artifact.unwrap();
4668 let trace = engine.trace.lock().unwrap().artifact.clone().unwrap();
4669 engine.execute(Operation::Close).unwrap();
4670 std::fs::remove_dir_all(root).unwrap();
4671 assert_eq!(error.kind, ErrorKind::Assertion);
4672 assert!(std::path::Path::new(&artifact.directory).starts_with(&failure_directory));
4673 assert_eq!(artifact.manifest.is_some(), fail_trace);
4674 assert_eq!(trace.manifest.is_some(), !fail_trace);
4675 if fail_trace {
4676 assert_eq!(artifact.status, FailureArtifactStatus::Partial);
4677 assert!(artifact
4678 .errors
4679 .iter()
4680 .any(|error| error.contains("trace export")));
4681 }
4682 let failed_export = if fail_trace { trace } else { *artifact };
4683 assert_eq!(failed_export.status, FailureArtifactStatus::Failed);
4684 assert!(!failed_export.errors.is_empty());
4685 }
4686 }
4687
4688 #[test]
4689 fn trace_final_outcome_still_controls_retention_after_caught_failures() {
4690 for mode in [TraceMode::Off, TraceMode::On, TraceMode::OnFailure] {
4691 for failed in [false, true] {
4692 let (engine, context, root) = trace_engine("trace-retention", mode);
4693 let directory = context.trace.as_ref().unwrap().directory.clone();
4694 engine
4695 .execute_with_context(Operation::Run(sleeping_program(false)), context)
4696 .unwrap();
4697 for _ in 0..2 {
4698 engine
4699 .execute(Operation::WaitLocator {
4700 query: LocatorQuery::text("missing trace marker"),
4701 not: false,
4702 timeout_ms: Some(0),
4703 })
4704 .unwrap_err();
4705 }
4706 engine.execute(Operation::FinishTrace { failed }).unwrap();
4707 engine.execute(Operation::Close).unwrap();
4708 engine.execute(Operation::Close).unwrap();
4709 let bundles: Vec<_> = if directory.exists() {
4710 std::fs::read_dir(&directory)
4711 .unwrap()
4712 .map(|entry| entry.unwrap().path())
4713 .collect()
4714 } else {
4715 Vec::new()
4716 };
4717 let retained = mode == TraceMode::On || (mode == TraceMode::OnFailure && failed);
4718 assert_eq!(
4719 bundles.len(),
4720 if retained {
4721 if failed {
4722 2
4723 } else {
4724 1
4725 }
4726 } else {
4727 0
4728 }
4729 );
4730 for bundle in bundles {
4731 let manifest: crate::diagnostics::FailureArtifactManifest =
4732 serde_json::from_slice(&std::fs::read(bundle.join("trace.json")).unwrap())
4733 .unwrap();
4734 assert_eq!(
4735 manifest.details.outcome,
4736 Some(if failed {
4737 TraceOutcome::Failed
4738 } else {
4739 TraceOutcome::Passed
4740 })
4741 );
4742 }
4743 std::fs::remove_dir_all(root).unwrap();
4744 }
4745 }
4746 }
4747
4748 #[test]
4749 fn failure_recording_boundary_does_not_limit_session_traces() {
4750 let (engine, context, root) = trace_engine("trace-boundary", TraceMode::OnFailure);
4751 engine
4752 .execute_with_context(Operation::Run(sleeping_program(false)), context)
4753 .unwrap();
4754 {
4755 let guard = engine.lock_session();
4756 let session = guard.as_ref().unwrap();
4757 let observation = capture_failure_observation(session);
4758 {
4759 let mut state = session.state.lock().unwrap();
4760 state.emu.process(b"later output");
4761 state.visual_revision += 1;
4762 state.screen_dirty = true;
4763 capture_visual_state(&mut state, true);
4764 }
4765 let mut details = FailureReport::new(
4766 "locator.wait",
4767 Some(0),
4768 FailureReason::TimedOut,
4769 "missing trace marker",
4770 );
4771 details.recording = Some(engine.recording_diagnostics(&observation));
4772 let prepared =
4773 engine.prepare_recording_artifact(session, &observation, &root, &mut details);
4774 assert!(prepared.is_none());
4775 assert_eq!(
4776 details.recording.as_ref().unwrap().status,
4777 RecordingStatus::Omitted
4778 );
4779 details.outcome = Some(TraceOutcome::Failed);
4780 let prepared =
4781 engine.prepare_recording_artifact(session, &observation, &root, &mut details);
4782 let prepared = prepared.expect("a session trace can extend beyond its failure offset");
4783 std::fs::remove_file(prepared.temporary_path).unwrap();
4784 }
4785 engine.execute(Operation::Close).unwrap();
4786 std::fs::remove_dir_all(root).unwrap();
4787 }
4788
4789 #[test]
4790 fn returned_failures_release_private_observations_including_failed_open() {
4791 let root =
4792 std::env::temp_dir().join(format!("tui-test-error-memory-{}", std::process::id()));
4793 let context = ExecutionContext {
4794 artifact: Some(crate::diagnostics::FailureArtifactOptions {
4795 directory: root.clone(),
4796 mode: crate::diagnostics::FailureArtifactMode::Text,
4797 include_recording: false,
4798 }),
4799 ..Default::default()
4800 };
4801 let engine = Engine::new(
4802 "error-memory".into(),
4803 Arc::new(Logger::disabled()),
4804 root.join("unused.cast"),
4805 );
4806 engine
4807 .execute(Operation::Run(sleeping_program(false)))
4808 .unwrap();
4809 populate_history(&engine);
4810 for _ in 0..3 {
4811 let error = engine
4812 .execute_with_context(
4813 Operation::WaitLocator {
4814 query: LocatorQuery::text("missing diagnostic marker"),
4815 not: false,
4816 timeout_ms: Some(0),
4817 },
4818 context.clone(),
4819 )
4820 .unwrap_err();
4821 assert!(error.observation.is_none());
4822 assert!(error.details.is_some());
4823 assert!(error.report.is_none());
4824 assert!(error.artifact.as_ref().unwrap().manifest.is_some());
4825 let cloned = error.clone();
4826 assert!(cloned.observation.is_none());
4827 assert_eq!(cloned.details, error.details);
4828 assert_eq!(cloned.artifact, error.artifact);
4829 }
4830 engine.execute(Operation::Close).unwrap();
4831
4832 let failed_open = engine
4833 .execute_with_context(Operation::Run(sleeping_program(true)), context)
4834 .unwrap_err();
4835 assert!(failed_open.observation.is_none());
4836 assert!(failed_open.artifact.is_some());
4837 let details = failed_open.details.unwrap();
4838 assert!(!details.summary.is_empty());
4839 assert!(failed_open.report.is_none());
4840 let report: crate::diagnostics::FailureArtifactManifest = serde_json::from_slice(
4841 &std::fs::read(failed_open.artifact.unwrap().manifest.unwrap()).unwrap(),
4842 )
4843 .unwrap();
4844 assert_eq!(
4845 report.details.operation.failed_screen_sequence,
4846 report
4847 .details
4848 .recent_operations
4849 .last()
4850 .unwrap()
4851 .screen_at_return
4852 );
4853 engine.execute(Operation::Close).unwrap();
4854 std::fs::remove_dir_all(root).unwrap();
4855 }
4856
4857 #[test]
4858 fn successful_snapshots_preserve_the_compared_screen() {
4859 let root =
4860 std::env::temp_dir().join(format!("tui-test-snapshot-memory-{}", std::process::id()));
4861 let directory = allocate_artifact_directory(&root).unwrap();
4862 let engine = Engine::new(
4863 "snapshot-memory".into(),
4864 Arc::new(Logger::disabled()),
4865 root.join("unused.cast"),
4866 );
4867 engine
4868 .execute(Operation::Run(sleeping_program(false)))
4869 .unwrap();
4870 populate_history(&engine);
4871 {
4872 let guard = engine.lock_session();
4873 let session = guard.as_ref().unwrap();
4874 let cwd = Some(directory.to_string_lossy().into_owned());
4875 for update in [true, false] {
4876 let result =
4877 do_snapshot(session, "compared", update, false, false, cwd.clone()).unwrap();
4878 assert!(matches!(
4879 result,
4880 SnapshotResult::Written | SnapshotResult::Passed
4881 ));
4882 }
4883 let captured = capture_failure_observation(session);
4884 {
4885 let mut state = session.state.lock().unwrap();
4886 state.emu.process(b"\x1b[HLATER OUTPUT");
4887 state.screen_dirty = true;
4888 capture_visual_state(&mut state, true);
4889 }
4890 assert!(!rows_to_strings(&captured.rows)
4891 .join(
4892 "
4893"
4894 )
4895 .contains("LATER OUTPUT"));
4896 assert!(!captured
4897 .terminal()
4898 .screen_history
4899 .screens
4900 .last()
4901 .unwrap()
4902 .text
4903 .contains("LATER OUTPUT"));
4904 let result = do_snapshot(session, "compared", true, false, false, cwd).unwrap();
4905 assert!(matches!(result, SnapshotResult::Updated));
4906 }
4907 engine.execute(Operation::Close).unwrap();
4908 std::fs::remove_dir_all(root).unwrap();
4909 }
4910
4911 #[test]
4912 fn successful_open_stores_the_complete_spawn_spec_and_tracks_resize() {
4913 let recording_path = std::env::current_dir()
4914 .unwrap()
4915 .join(format!("restart-spec-{}.cast", std::process::id()));
4916 let engine = Engine::new(
4917 "restart-spec".to_string(),
4918 Arc::new(Logger::disabled()),
4919 recording_path,
4920 );
4921 let cwd = std::env::current_dir()
4922 .unwrap()
4923 .to_string_lossy()
4924 .into_owned();
4925 let profile = Profile {
4926 scrollback: 321,
4927 colors: crate::profile::Colors {
4928 foreground: crate::profile::Rgb::new(1, 2, 3),
4929 ..crate::profile::Colors::default()
4930 },
4931 };
4932 let options = OpenOptions {
4933 backend: crate::Backend::Alacritty,
4934 shell: None,
4935 profile,
4936 cols: 87,
4937 rows: 29,
4938 cwd: Some(cwd.clone()),
4939 env: vec![("RESTART_SPEC".to_string(), "preserved".to_string())],
4940 wait_ready: Some(false),
4941 restart: false,
4942 timeouts: Timeouts {
4943 text: Some(11),
4944 idle: Some(12),
4945 command: Some(13),
4946 exit: Some(14),
4947 ready: Some(15),
4948 },
4949 recording: AutomaticRecording {
4950 mode: AutomaticRecordingMode::Disabled,
4951 directory: None,
4952 },
4953 };
4954
4955 engine
4956 .execute(Operation::Open(options.clone()))
4957 .expect("open session");
4958 engine
4959 .execute(Operation::Resize { cols: 99, rows: 31 })
4960 .expect("resize session");
4961
4962 let stored = engine
4963 .spawn_spec
4964 .lock()
4965 .unwrap_or_else(std::sync::PoisonError::into_inner)
4966 .clone()
4967 .expect("stored spawn spec");
4968 assert_eq!(stored.resolved_cwd, Some(PathBuf::from(&cwd)));
4969 let SpawnCommand::Open(stored) = stored.command else {
4970 panic!("expected stored open options");
4971 };
4972 assert_eq!(stored.backend, options.backend);
4973 assert_eq!(stored.shell, options.shell);
4974 assert_eq!(stored.profile, options.profile);
4975 assert_eq!((stored.cols, stored.rows), (99, 31));
4976 assert_eq!(stored.cwd, Some(cwd));
4977 assert_eq!(stored.env, options.env);
4978 assert_eq!(stored.wait_ready, options.wait_ready);
4979 assert_eq!(stored.timeouts, options.timeouts);
4980 assert_eq!(stored.recording, options.recording);
4981
4982 engine.execute(Operation::Close).expect("close session");
4983 }
4984
4985 #[test]
4986 fn an_svg_snapshot_freezes_grid_palette_and_cursor_together() {
4987 let mut emu = AlacrittyEmu::new(2, 2, &Profile::default());
4988 emu.process(b"X\x1b[1G\x1b]12;#010203\x07");
4989 let snapshot = svg_snapshot_from(&emu, false);
4990
4991 emu.process(b"Y\x1b[2;2H\x1b[?25l\x1b[6 q\x1b]12;#ff00ff\x07");
4995 let svg = crate::render::svg::render_svg(
4996 &snapshot.rows,
4997 snapshot.cols,
4998 &snapshot.render_state,
4999 snapshot.cursor,
5000 snapshot.title.as_deref(),
5001 );
5002
5003 assert_eq!(svg.matches('X').count(), 2, "text plus block redraw: {svg}");
5004 assert!(!svg.contains('Y'), "later grid contents leaked in: {svg}");
5005 assert!(
5006 svg.contains("#010203"),
5007 "captured cursor color is used: {svg}"
5008 );
5009 assert!(
5010 !svg.contains("#ff00ff"),
5011 "later cursor state must not leak in: {svg}"
5012 );
5013 }
5014
5015 #[test]
5018 fn reported_colors_follow_the_dynamic_color_sequences() {
5019 let profile = Profile::default();
5020 let mut emu = AlacrittyEmu::new(10, 2, &profile);
5021 let before = colors_of(&emu, &profile);
5022
5023 emu.process(b"\x1b]10;#111111\x07\x1b]11;#222222\x07\x1b]12;#333333\x07");
5024 let set = colors_of(&emu, &profile);
5025 assert_eq!(set.foreground, "#111111");
5026 assert_eq!(set.background, "#222222");
5027 assert_eq!(set.cursor, "#333333");
5028
5029 emu.process(b"\x1b]111\x07");
5030 let reset = colors_of(&emu, &profile);
5031 assert_eq!(
5032 reset.background, before.background,
5033 "111 restores the profile background"
5034 );
5035 assert_eq!(reset.foreground, "#111111", "and leaves the others alone");
5036 assert_eq!(reset.cursor, "#333333");
5037 }
5038
5039 #[test]
5042 fn reported_palette_names_only_the_entries_a_program_moved() {
5043 let profile = Profile::default();
5044 let mut emu = AlacrittyEmu::new(10, 2, &profile);
5045 assert!(
5046 colors_of(&emu, &profile).palette.is_empty(),
5047 "nothing has overridden the palette yet"
5048 );
5049
5050 emu.process(b"\x1b]4;1;#00ff00\x07");
5051 assert_eq!(
5052 colors_of(&emu, &profile).palette,
5053 [(1, "#00ff00".to_string())].into_iter().collect(),
5054 "only the slot that moved is named"
5055 );
5056
5057 emu.process(b"\x1b]104\x07");
5058 assert!(
5059 colors_of(&emu, &profile).palette.is_empty(),
5060 "104 restores the whole palette"
5061 );
5062 }
5063
5064 #[test]
5067 fn an_expected_color_accepts_hex_and_an_ansi_index() {
5068 let profile = Profile::default();
5069 let emu = AlacrittyEmu::new(10, 2, &profile);
5070 assert_eq!(
5071 resolve_expected_color("#010203", &emu).unwrap(),
5072 crate::profile::Rgb::new(1, 2, 3)
5073 );
5074 assert_eq!(
5075 resolve_expected_color("1", &emu).unwrap(),
5076 emu.color(crate::profile::ColorSlot::Indexed(1)),
5077 "an index reads the session's own palette"
5078 );
5079 }
5080
5081 #[test]
5084 fn default_is_rejected_as_a_terminal_color() {
5085 let emu = AlacrittyEmu::new(10, 2, &Profile::default());
5086 let error = resolve_expected_color("default", &emu).unwrap_err();
5087 assert!(format!("{error:?}").contains("no meaning"), "{error:?}");
5088 }
5089
5090 #[test]
5091 fn screenshot_format_defaults_to_svg_and_rejects_unknown_extensions() {
5092 assert_eq!(
5093 ScreenshotFormat::infer("screen").unwrap(),
5094 ScreenshotFormat::Svg
5095 );
5096 assert_eq!(
5097 ScreenshotFormat::infer("screen.SVG").unwrap(),
5098 ScreenshotFormat::Svg
5099 );
5100 assert_eq!(
5101 ScreenshotFormat::infer("screen.PNG").unwrap(),
5102 ScreenshotFormat::Png
5103 );
5104 let error = ScreenshotFormat::infer("screen.gif").unwrap_err();
5105 assert_eq!(error.kind, ErrorKind::Usage);
5106 assert!(error.message.contains(".gif"));
5107 assert!(error.message.contains(".svg"));
5108 assert!(error.message.contains(".png"));
5109 }
5110
5111 #[test]
5112 fn cell_model_reports_the_whole_vocabulary() {
5113 let cell = EmuCell {
5114 ch: "x".into(),
5115 fg: Some(Color::Named(NamedColor::Red)),
5116 bg: Some(Color::Idx(196)),
5117 underline: UnderlineStyle::Curly,
5118 underline_color: Some(Color::Rgb(1, 2, 3)),
5119 attrs: Attrs::all(),
5120 hyperlink: Some(std::sync::Arc::new(crate::terminal::cell::Hyperlink {
5121 id: Some("anchor".into()),
5122 uri: "https://example.com".into(),
5123 })),
5124 };
5125 let value = cell_model(3, 4, &cell);
5126 assert_eq!(value.x, 3);
5127 assert_eq!(value.char, "x");
5128 assert_eq!(value.fg, CellColor::Indexed(1));
5129 assert_eq!(value.bg, CellColor::Indexed(196));
5130 assert!(value.bold);
5131 assert!(value.dim);
5132 assert!(value.italic);
5133 assert!(value.inverse);
5134 assert!(value.invisible);
5135 assert!(value.strike);
5136 assert!(value.blink);
5137 assert!(value.underline);
5138 assert_eq!(value.underline_style, "curly");
5139 assert_eq!(value.underline_color, CellColor::Rgb(1, 2, 3));
5140 }
5141
5142 #[test]
5143 fn cell_model_underline_fields_are_never_absent() {
5144 let value = cell_model(0, 0, &EmuCell::blank());
5145 assert!(!value.underline);
5146 assert_eq!(value.underline_style, "none");
5147 assert_eq!(value.underline_color, CellColor::Default);
5148 assert!(!value.blink);
5149
5150 let cell = EmuCell {
5151 underline: UnderlineStyle::Single,
5152 underline_color: None,
5153 ..EmuCell::blank()
5154 };
5155 let value = cell_model(0, 0, &cell);
5156 assert!(value.underline);
5157 assert_eq!(value.underline_style, "single");
5158 assert_eq!(value.underline_color, CellColor::Default);
5159 }
5160
5161 #[test]
5162 fn style_locators_resolve_palette_colors() {
5163 let emu = AlacrittyEmu::new(10, 2, &Profile::default());
5164 let cell = EmuCell {
5165 ch: "x".into(),
5166 fg: Some(Color::Named(NamedColor::Red)),
5167 ..EmuCell::blank()
5168 };
5169 assert!(cell_matches_style(
5170 &cell,
5171 &TextStyle {
5172 foreground: Some("#800000".into()),
5173 ..TextStyle::default()
5174 },
5175 &emu,
5176 ));
5177 assert!(!cell_matches_style(
5178 &cell,
5179 &TextStyle {
5180 foreground: Some("#ff0000".into()),
5181 ..TextStyle::default()
5182 },
5183 &emu,
5184 ));
5185 let evaluation = evaluate_cell_style(
5186 &cell,
5187 &TextStyle {
5188 foreground: Some("#ff0000".into()),
5189 bold: Some(true),
5190 ..TextStyle::default()
5191 },
5192 &emu,
5193 3,
5194 4,
5195 usize::MAX,
5196 );
5197 assert!(!evaluation.matched);
5198 assert_eq!(evaluation.mismatches.len(), 2);
5199 assert_eq!(evaluation.mismatches[0].location.row, 4);
5200 assert!(evaluation
5201 .mismatches
5202 .iter()
5203 .any(|mismatch| mismatch.property == "foreground"));
5204 assert!(evaluation
5205 .mismatches
5206 .iter()
5207 .any(|mismatch| mismatch.property == "bold"));
5208 }
5209
5210 #[test]
5211 fn style_mismatch_evidence_respects_the_requested_budget() {
5212 let emu = AlacrittyEmu::new(80, 24, &Profile::default());
5213 let cell = EmuCell::blank();
5214 let style = TextStyle {
5215 bold: Some(true),
5216 ..TextStyle::default()
5217 };
5218 assert!(!cell_matches_style(&cell, &style, &emu));
5219 let boolean = evaluate_cell_style(&cell, &style, &emu, 0, 0, 0);
5220 assert!(!boolean.matched);
5221 assert!(boolean.mismatches.is_empty());
5222 assert!(boolean.mismatches_truncated);
5223 let limited = evaluate_cell_style(
5224 &cell,
5225 &TextStyle {
5226 bold: Some(true),
5227 italic: Some(true),
5228 dim: Some(true),
5229 ..TextStyle::default()
5230 },
5231 &emu,
5232 0,
5233 0,
5234 1,
5235 );
5236 assert!(!limited.matched);
5237 assert_eq!(limited.mismatches.len(), 1);
5238 assert!(limited.mismatches_truncated);
5239 }
5240
5241 #[test]
5242 fn ambiguous_text_anchors_report_conflicting_locations() {
5243 for occurrence in [
5244 crate::api::MatchOccurrence::Any,
5245 crate::api::MatchOccurrence::Unique,
5246 ] {
5247 for before in [false, true] {
5248 let mut emu = AlacrittyEmu::new(80, 2, &Profile::default());
5249 emu.process(b"ANCHOR target ANCHOR");
5250 let anchor = crate::api::TextAnchor {
5251 text: "ANCHOR".into(),
5252 regex: false,
5253 occurrence: occurrence.clone(),
5254 };
5255 let mut selector = TextSelector::new("target");
5256 if before {
5257 selector.scope.before = Some(anchor);
5258 } else {
5259 selector.scope.after = Some(anchor);
5260 }
5261 let query = LocatorQuery::text(selector);
5262 let evaluation = locator::evaluate_query(
5263 &emu.viewable_rows(),
5264 &query,
5265 false,
5266 &mut |cell, style, x, y, budget| {
5267 evaluate_cell_style(cell, style, &emu, x, y, budget)
5268 },
5269 )
5270 .unwrap();
5271 let message = locator_failure_message(&query, &evaluation.diagnostics);
5272 let error = locator_failure_error(
5273 "locator.find",
5274 None,
5275 message,
5276 EvaluatedLocator {
5277 evaluation,
5278 screen_sequence: 1,
5279 visible_rows: 2,
5280 },
5281 Vec::new(),
5282 false,
5283 None,
5284 );
5285 let report = error.report.unwrap();
5286 assert_eq!(report.reason, FailureReason::LocatorAmbiguous);
5287 let failure = report.failure_details().locator.unwrap();
5288 assert_eq!(failure.reason, Some(LocatorFailureReason::AnchorAmbiguous));
5289 assert_eq!(
5290 failure.locations,
5291 vec![
5292 crate::api::TextPosition { column: 0, row: 0 },
5293 crate::api::TextPosition { column: 14, row: 0 },
5294 ],
5295 );
5296 }
5297 }
5298 }
5299
5300 #[test]
5301 fn compact_failure_preserves_url_and_style_mismatch_location() {
5302 let mut emu = AlacrittyEmu::new(10, 2, &Profile::default());
5303 emu.process(b"\x1b]8;;test:docs\x07docs\x1b]8;;\x07");
5304 let mut query = LocatorQuery::style(TextStyle {
5305 bold: Some(true),
5306 ..TextStyle::default()
5307 });
5308 query.within = Some(Box::new(LocatorQuery::link("test:docs")));
5309 let evaluation = locator::evaluate_query(
5310 &emu.viewable_rows(),
5311 &query,
5312 false,
5313 &mut |cell, style, x, y, budget| evaluate_cell_style(cell, style, &emu, x, y, budget),
5314 )
5315 .unwrap();
5316 let mut report = FailureReport::new(
5317 "locator.resolve",
5318 None,
5319 FailureReason::LocatorNoMatch,
5320 "style mismatch",
5321 );
5322 report.locator = Some(evaluation.diagnostics);
5323 let details = report.failure_details();
5324 let failure = details.locator.unwrap();
5325 assert_eq!(
5326 failure.reason,
5327 Some(LocatorFailureReason::StyleFilterRemovedAll)
5328 );
5329 assert!(failure
5330 .selectors
5331 .iter()
5332 .any(|selector| selector.contains("test:docs")));
5333 assert_eq!(failure.mismatches[0].property, "bold");
5334 assert_eq!(failure.mismatches[0].expected, "true");
5335 assert_eq!(failure.mismatches[0].actual, "false");
5336 assert_eq!(
5337 failure.mismatches[0].location,
5338 crate::api::TextPosition { column: 0, row: 0 }
5339 );
5340 assert!(!diagnostic_hints(&report)[0].message.contains("text"));
5341 }
5342
5343 #[test]
5346 fn link_locators_match_a_cell_by_its_link() {
5347 let emu = AlacrittyEmu::new(10, 2, &Profile::default());
5348 let linked = EmuCell {
5349 ch: "x".into(),
5350 hyperlink: Some(std::sync::Arc::new(crate::terminal::cell::Hyperlink {
5351 id: None,
5352 uri: "https://example.com".into(),
5353 })),
5354 ..EmuCell::blank()
5355 };
5356
5357 let rows = vec![vec![linked]];
5358 for (uri, count) in [("https://example.com", 1), ("https://other.example", 0)] {
5359 let found =
5360 locator::locate_query(&rows, &LocatorQuery::link(uri), &mut |cell, style| {
5361 cell_matches_style(cell, style, &emu)
5362 })
5363 .unwrap();
5364 assert_eq!(found.len(), count);
5365 }
5366 }
5367
5368 #[test]
5372 fn an_empty_link_requires_a_cell_that_links_nowhere() {
5373 let emu = AlacrittyEmu::new(10, 2, &Profile::default());
5374 let plain = EmuCell {
5375 ch: "x".into(),
5376 ..EmuCell::blank()
5377 };
5378 let linked = EmuCell {
5379 hyperlink: Some(std::sync::Arc::new(crate::terminal::cell::Hyperlink {
5380 id: None,
5381 uri: "https://example.com".into(),
5382 })),
5383 ..plain.clone()
5384 };
5385 let found = locator::locate_query(
5386 &[vec![plain, linked]],
5387 &LocatorQuery::link(""),
5388 &mut |cell, style| cell_matches_style(cell, style, &emu),
5389 )
5390 .unwrap();
5391 assert_eq!(found.len(), 1);
5392 assert_eq!(found[0].value.spans[0].end, 1);
5393 }
5394
5395 #[test]
5396 fn appearance_matches_independently_of_links() {
5397 let emu = AlacrittyEmu::new(10, 2, &Profile::default());
5398 let linked = EmuCell {
5399 ch: "x".into(),
5400 attrs: Attrs::BOLD,
5401 hyperlink: Some(std::sync::Arc::new(crate::terminal::cell::Hyperlink {
5402 id: None,
5403 uri: "https://example.com".into(),
5404 })),
5405 ..EmuCell::blank()
5406 };
5407 assert!(cell_matches_style(
5408 &linked,
5409 &TextStyle {
5410 bold: Some(true),
5411 ..TextStyle::default()
5412 },
5413 &emu,
5414 ));
5415 }
5416
5417 #[test]
5418 fn highlight_maps_full_grid_cells_into_the_viewport() {
5419 let mut rows = vec![vec![EmuCell::blank(); 3]; 2];
5420 let highlight = TextHighlight {
5421 cells: vec![(1, 4)],
5422 viewport_offset: 3,
5423 };
5424 apply_highlight(&mut rows, Some(&highlight), false);
5425 assert!(rows[1][1].has(Attrs::INVERSE));
5426 assert!(!rows[0][1].has(Attrs::INVERSE));
5427 }
5428
5429 #[test]
5430 fn highlight_uses_absolute_rows_for_full_grid_renders() {
5431 let mut rows = vec![vec![EmuCell::blank(); 3]; 5];
5432 let highlight = TextHighlight {
5433 cells: vec![(1, 4)],
5434 viewport_offset: 3,
5435 };
5436 apply_highlight(&mut rows, Some(&highlight), true);
5437 assert!(rows[4][1].has(Attrs::INVERSE));
5438 assert!(!rows[1][1].has(Attrs::INVERSE));
5439 }
5440
5441 #[test]
5442 fn locator_clicks_the_middle_match_cell() {
5443 let matched = locator::LocatedMatch {
5444 value: TextMatch {
5445 text: "save".into(),
5446 start: TextPosition { row: 2, column: 4 },
5447 end: TextPosition { row: 2, column: 8 },
5448 spans: vec![TextSpan {
5449 row: 2,
5450 start: 4,
5451 end: 8,
5452 }],
5453 },
5454 cells: (4..8)
5455 .map(|x| locator::MatchedCell {
5456 x,
5457 y: 2,
5458 cell: EmuCell::blank(),
5459 })
5460 .collect(),
5461 source_start: 4,
5462 source_end: 8,
5463 };
5464 assert_eq!(matched_center(&matched), Some((6, 2)));
5465 }
5466
5467 #[test]
5468 fn full_grid_clicks_map_visible_rows_to_viewport_coordinates() {
5469 let rows = ["old", "older", "history", "prompt", "row save"]
5470 .into_iter()
5471 .map(|line| {
5472 line.chars()
5473 .map(|ch| EmuCell {
5474 ch: ch.to_string().into(),
5475 ..EmuCell::blank()
5476 })
5477 .collect::<Vec<_>>()
5478 })
5479 .collect::<Vec<_>>();
5480 let mut parent = TextSelector::new("row save");
5481 parent.full = true;
5482 let query = LocatorQuery {
5483 selector: LocatorSelector::Text(TextSelector::new("save")),
5484 occurrence: crate::api::MatchOccurrence::Unique,
5485 within: Some(Box::new(LocatorQuery::text(parent))),
5486 direction: crate::api::LocatorDirection::Within,
5487 style: Default::default(),
5488 };
5489 let candidates = locator::locate_query(&rows, &query, &mut |_, _| false).unwrap();
5490 assert_eq!(
5491 click_point_from_candidates(candidates, "save", true, 3, 2).unwrap(),
5492 Some((6, 1))
5493 );
5494 }
5495
5496 #[test]
5497 fn full_grid_clicks_reject_matches_above_the_viewport() {
5498 let rows = ["save", "history", "prompt"]
5499 .into_iter()
5500 .map(|line| {
5501 line.chars()
5502 .map(|ch| EmuCell {
5503 ch: ch.to_string().into(),
5504 ..EmuCell::blank()
5505 })
5506 .collect::<Vec<_>>()
5507 })
5508 .collect::<Vec<_>>();
5509 let mut selector = TextSelector::new("save");
5510 selector.full = true;
5511 let mut query = LocatorQuery::text(selector);
5512 query.occurrence = crate::api::MatchOccurrence::Unique;
5513 let candidates = locator::locate_query(&rows, &query, &mut |_, _| false).unwrap();
5514 let error = click_point_from_candidates(candidates, "save", true, 1, 2).unwrap_err();
5515 assert!(error.message.contains("outside the visible viewport"));
5516 }
5517
5518 #[test]
5519 fn panic_payloads_become_internal_errors() {
5520 let error = std::panic::catch_unwind(|| panic!("ffi-panic"))
5521 .map_err(|payload| {
5522 TuiTestError::internal(format!(
5523 "native terminal operation panicked: {}",
5524 panic_message(payload.as_ref())
5525 ))
5526 })
5527 .unwrap_err();
5528 assert_eq!(error.kind, ErrorKind::Internal);
5529 assert!(error.message.contains("ffi-panic"));
5530 }
5531
5532 #[test]
5533 fn clean_screen_boundaries_reuse_the_grid_but_flush_dirty_output() {
5534 let engine = Engine::new(
5535 "cached-screen".into(),
5536 Arc::new(Logger::disabled()),
5537 std::env::temp_dir().join("unused-cached-screen.cast"),
5538 );
5539 engine
5540 .execute(Operation::Run(sleeping_program(false)))
5541 .unwrap();
5542 {
5543 let guard = engine.lock_session();
5544 let session = guard.as_ref().unwrap();
5545 let mut state = session.state.lock().unwrap();
5546 let sequence = capture_visual_state(&mut state, true);
5547 state.screen_history.pin_current();
5548 let frozen = state.screen_history.clone();
5549 let sample_time = state.last_screen_sample;
5550 let repeat_count = frozen.snapshot().screens.last().unwrap().repeat_count;
5551 for _ in 0..1000 {
5552 assert_eq!(capture_visual_state(&mut state, true), sequence);
5553 }
5554 assert_eq!(state.last_screen_sample, sample_time);
5555 assert_eq!(
5556 state
5557 .screen_history
5558 .snapshot()
5559 .screens
5560 .last()
5561 .unwrap()
5562 .repeat_count,
5563 repeat_count + 1000,
5564 );
5565 assert_eq!(
5566 frozen.snapshot().screens.last().unwrap().repeat_count,
5567 repeat_count
5568 );
5569 state.emu.process(b"\x1b[HFRESH OUTPUT");
5570 state.screen_dirty = true;
5571 let changed = capture_visual_state(&mut state, true);
5572 assert_ne!(changed, sequence);
5573 assert!(!state.screen_dirty);
5574 assert!(state
5575 .screen_history
5576 .snapshot()
5577 .screens
5578 .last()
5579 .unwrap()
5580 .text
5581 .contains("FRESH OUTPUT"));
5582 }
5583 engine.execute(Operation::Close).unwrap();
5584 }
5585
5586 #[test]
5587 fn resolving_a_unique_locator_pins_an_assertion_checkpoint() {
5588 let engine = Engine::new(
5589 "resolve-checkpoint".into(),
5590 Arc::new(Logger::disabled()),
5591 std::env::temp_dir().join("unused-resolve-checkpoint.cast"),
5592 );
5593 engine
5594 .execute(Operation::Run(sleeping_program(false)))
5595 .unwrap();
5596 let mut query = LocatorQuery::style(TextStyle {
5597 bold: Some(false),
5598 ..TextStyle::default()
5599 });
5600 query.occurrence = crate::api::MatchOccurrence::First;
5601 engine.execute(Operation::ResolveLocator { query }).unwrap();
5602 let event = engine
5603 .operation_history
5604 .lock()
5605 .unwrap()
5606 .snapshot()
5607 .pop()
5608 .unwrap();
5609 let checkpoints = engine
5610 .lock_session()
5611 .as_ref()
5612 .unwrap()
5613 .state
5614 .lock()
5615 .unwrap()
5616 .screen_history
5617 .snapshot()
5618 .checkpoints;
5619 engine.execute(Operation::Close).unwrap();
5620 assert!(event.is_assertion);
5621 assert!(checkpoints
5622 .iter()
5623 .any(|frame| frame.sequence == event.screen_at_return));
5624 }
5625
5626 #[test]
5627 fn startup_readiness_failures_distinguish_timeout_from_process_exit() {
5628 let engine = Engine::new(
5629 "startup-readiness".into(),
5630 Arc::new(Logger::disabled()),
5631 std::env::current_dir()
5632 .unwrap()
5633 .join("unused-startup-readiness.cast"),
5634 );
5635 let timed_out = engine
5636 .execute(Operation::Run(sleeping_program(true)))
5637 .unwrap_err();
5638 assert!(!engine.is_open());
5639 let details = timed_out.details.unwrap();
5640 assert_eq!(details.operation, "run");
5641 assert_eq!(details.reason, FailureReason::TimedOut);
5642 assert!(details.summary.contains("reported no prompt within 20ms"));
5643
5644 let mut options = sleeping_program(true);
5645 let (program, args) = if cfg!(windows) {
5646 ("cmd.exe", vec!["/D", "/C", "exit 7"])
5647 } else {
5648 ("sh", vec!["-c", "exit 7"])
5649 };
5650 options.program = program.into();
5651 options.args = args.into_iter().map(str::to_string).collect();
5652 options.timeouts.ready = Some(15_000);
5653 let exited = engine.execute(Operation::Run(options)).unwrap_err();
5654 assert!(!engine.is_open());
5655 let details = exited.details.unwrap();
5656 assert_eq!(details.operation, "run");
5657 assert_eq!(details.reason, FailureReason::SessionExited);
5658 }
5659
5660 #[test]
5661 fn startup_readiness_failures_preserve_observed_exit_and_cancellation() {
5662 let engine = Engine::new(
5663 "startup-readiness-reasons".into(),
5664 Arc::new(Logger::disabled()),
5665 std::env::current_dir()
5666 .unwrap()
5667 .join("unused-startup-readiness-reasons.cast"),
5668 );
5669 engine
5670 .execute(Operation::Run(sleeping_program(false)))
5671 .unwrap();
5672 let mut observation = {
5673 let guard = engine.lock_session();
5674 capture_failure_observation(guard.as_ref().unwrap())
5675 };
5676 engine.execute(Operation::Close).unwrap();
5677 for (cancelled, exit_code, reason) in [
5678 (false, None, FailureReason::TimedOut),
5679 (false, Some(7), FailureReason::SessionExited),
5680 (true, None, FailureReason::Cancelled),
5681 (true, Some(7), FailureReason::Cancelled),
5682 ] {
5683 observation.process.cancelled = cancelled;
5684 observation.process.exit_code = exit_code;
5685 let error = startup_readiness_error("open", 25);
5686 let report = error.report.as_ref().unwrap();
5687 assert_eq!(report.operation.timeout_ms, Some(25));
5688 assert_eq!(failure_reason(&error, Some(&observation)), reason);
5689 }
5690 }
5691
5692 #[test]
5693 fn closing_a_session_preserves_the_operation_clock() {
5694 let engine = Engine::new(
5695 "close-clock".into(),
5696 Arc::new(Logger::disabled()),
5697 std::env::current_dir()
5698 .unwrap()
5699 .join("unused-close-clock.cast"),
5700 );
5701 engine
5702 .execute(Operation::Run(sleeping_program(false)))
5703 .unwrap();
5704 engine
5705 .lock_session()
5706 .as_ref()
5707 .unwrap()
5708 .state
5709 .lock()
5710 .unwrap()
5711 .started_at = Instant::now() - Duration::from_secs(10);
5712 engine.execute(Operation::Close).unwrap();
5713 let event = engine
5714 .operation_history
5715 .lock()
5716 .unwrap()
5717 .snapshot()
5718 .pop()
5719 .unwrap();
5720 assert_eq!(event.name, "close");
5721 assert!(event.started_ms >= 10_000);
5722 assert!(event.ended_ms >= event.started_ms);
5723 }
5724
5725 #[test]
5726 fn pending_startup_events_keep_the_allocated_sequence_after_history_reset() {
5727 let mut history = OperationHistory::new();
5728 let previous = history.begin("close".into(), 100, 5, "close".into(), false, None);
5729 history.finish(previous, Some(110), 0, "ok", None);
5730 let pending = history.begin("run".into(), 110, 5, "run".into(), false, None);
5731 let metadata = OperationMetadata {
5732 sequence: pending.sequence(),
5733 name: "run".into(),
5734 timeout_ms: None,
5735 started_at: Instant::now(),
5736 started_ms: 110,
5737 screen_before: 5,
5738 safe_summary: "run".into(),
5739 is_assertion: false,
5740 expectation: None,
5741 input: None,
5742 };
5743 history.reset_session();
5744 assert!(history.snapshot().is_empty());
5745 let metadata = OperationMetadata {
5746 started_ms: 0,
5747 screen_before: 0,
5748 ..metadata
5749 };
5750 let reported = metadata.pending_event("assertion", 1);
5751 history.finish(pending, Some(reported.ended_ms), 1, "assertion", None);
5752 assert_eq!(reported.sequence, 2);
5753 assert_eq!(reported, history.snapshot()[0]);
5754 }
5755
5756 #[test]
5757 fn concurrent_history_and_failure_finalization_do_not_deadlock() {
5758 const CHILD: &str = "TUI_TEST_HISTORY_LOCK_CHILD";
5759 if std::env::var_os(CHILD).is_none() {
5760 let mut child = std::process::Command::new(std::env::current_exe().unwrap())
5761 .args([
5762 "--exact",
5763 "engine::tests::concurrent_history_and_failure_finalization_do_not_deadlock",
5764 ])
5765 .env(CHILD, "1")
5766 .spawn()
5767 .unwrap();
5768 let started = Instant::now();
5769 loop {
5770 if let Some(status) = child.try_wait().unwrap() {
5771 assert!(status.success());
5772 return;
5773 }
5774 if started.elapsed() > Duration::from_secs(10) {
5775 child.kill().unwrap();
5776 child.wait().unwrap();
5777 panic!("operation history and failure finalization deadlocked");
5778 }
5779 std::thread::sleep(Duration::from_millis(10));
5780 }
5781 }
5782 let engine = Arc::new(Engine::new(
5783 "history-lock".into(),
5784 Arc::new(Logger::disabled()),
5785 std::env::temp_dir().join("unused-history-lock.cast"),
5786 ));
5787 let barrier = Arc::new(std::sync::Barrier::new(2));
5788 let reporter = {
5789 let engine = engine.clone();
5790 let barrier = barrier.clone();
5791 std::thread::spawn(move || {
5792 let metadata = OperationMetadata {
5793 sequence: 0,
5794 name: "test.failure".into(),
5795 timeout_ms: None,
5796 started_at: Instant::now(),
5797 started_ms: 0,
5798 screen_before: 0,
5799 safe_summary: "synthetic failure".into(),
5800 is_assertion: false,
5801 expectation: None,
5802 input: None,
5803 };
5804 let context = ExecutionContext {
5805 artifact: Some(crate::diagnostics::FailureArtifactOptions {
5806 directory: std::env::temp_dir().join("unused-history-lock"),
5807 ..Default::default()
5808 }),
5809 ..Default::default()
5810 };
5811 barrier.wait();
5812 for _ in 0..2000 {
5813 let mut error = TuiTestError::internal("synthetic failure");
5814 engine.finalize_failure(&mut error, &context, &metadata);
5815 }
5816 })
5817 };
5818 barrier.wait();
5819 for _ in 0..2000 {
5820 engine.execute(Operation::Close).unwrap();
5821 }
5822 reporter.join().unwrap();
5823 }
5824}