Skip to main content

tui_test/
engine.rs

1//! Reusable in-process terminal engine.
2
3use 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
180/// One-line operation description for the verbose log. Open and Run redact env
181/// values (they may contain secrets) and report only the variable count.
182fn 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        // Failure enrichment takes the session lock before the history lock.
337        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        // The emulator is fed on the reader thread, where there is nobody to
745        // return an error to, so a backend that failed to parse records it and
746        // the next operation reports it. Checked before the operation runs:
747        // once the grid has stopped tracking the bytes, every answer read out
748        // of it is a guess, and a wrong answer is worse than a failure.
749        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    /// Write viewer keystrokes straight to the pty: untracked and unlogged, so
1375    /// a human watching cannot disturb what the agent is waiting on.
1376    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        // A child that exits mid-write is a normal race, not a failure.
1398        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
2231/// Resolve a mode name, so an unknown one is a usage error naming the set
2232/// rather than a silent `false`.
2233fn 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
2249/// The terminal's colors, resolved through the profile so a slot nothing
2250/// has overridden still has an answer.
2251///
2252/// The three defaults (`OSC 10/11/12`) are always reported. Palette entries
2253/// (`OSC 4`) are reported only where they differ from the profile, so the
2254/// answer names what a program changed instead of all 256 slots.
2255fn 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
2273/// Resolve a color a caller named into a concrete value.
2274///
2275/// Takes the same spellings `--fg` and `--bg` do, minus `default`: a terminal
2276/// color is what `default` would resolve *to*, so there is nothing for it to
2277/// refer to. An ANSI index resolves through the session's own palette, so
2278/// `--background 0` means the black this profile paints rather than a fixed
2279/// one.
2280fn 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    // Not the parse error itself: it offers `default` as a spelling, which the
2287    // next arm rejects, so a typo would be answered with advice that fails.
2288    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
2303/// Wait for the terminal's colors to match every slot the caller named.
2304///
2305/// The three defaults (`OSC 10/11/12`) and any number of palette entries
2306/// (`OSC 4`) are matched together, so a program that recolors several slots
2307/// at once is asserted as a single state rather than a race between polls.
2308fn 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    // Resolve once, so a malformed color is a usage error rather than a wait
2323    // that can never succeed, and an indexed color is read against the
2324    // palette in force now rather than re-resolved on every poll.
2325    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    // Only the slots that were asked about: naming a foreground the caller
2380    // never mentioned invites reading it as the thing that failed.
2381    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
2497/// The cursor as a caller sees it: where it is, and how it is drawn.
2498fn 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    // A backend with its own key encoder is preferred, per token, because it
2537    // reads terminal state the shared encoder does not model: ghostty's
2538    // applies keypad application mode, `modifyOtherKeys`, and the alt-escape
2539    // prefix. A backend that has no encoder, or cannot express a particular
2540    // event, answers `None` and that token falls back.
2541    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                // All or nothing per token: a token half-encoded by the
2560                // backend and half by the fallback would interleave two
2561                // encodings of the same keypress.
2562                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
2678/// The window title the terminal is currently reporting.
2679fn 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
2817/// Whether the title matches now. An unset title matches nothing, so `--not`
2818/// on a session that never set one succeeds.
2819fn 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        // Share immutable history until a mismatch needs public diagnostics. The
2851        // compared grid and its history stay pinned even if output advances during I/O.
2852        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    // The title is off by default: a shell prompt routinely sets it to a
4004    // username, hostname, and absolute path, which would pin every baseline to
4005    // one machine and make it change on `cd` while the screen stayed the same.
4006    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
4042/// Where to draw the cursor within `rows`, or `None` when the terminal is not
4043/// showing one.
4044///
4045/// `Emulator::cursor` is relative to the visible screen, so a full screenshot
4046/// has to push it down past the scrollback that precedes it.
4047fn 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    // Counted in `usize`: a full render is as long as the scrollback, which a
4057    // profile can set past what a `u16` row would hold, and a wrapped offset
4058    // draws the cursor on a plausible but wrong line.
4059    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
4086/// Capture everything the SVG renderer can observe while the emulator is
4087/// locked, then release the reader before doing the expensive string work.
4088fn 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        // Change every piece that used to be read after the grid lock was
4992        // released. Rendering the captured value must still show the old
4993        // character, visible cursor position, shape, and color.
4994        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    /// `OSC 10/11/12` move the three defaults, and each reset frees only its
5016    /// own slot back to the profile.
5017    #[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    /// The palette is reported as what a program changed, so an untouched
5040    /// terminal reports nothing and `OSC 104` empties it again.
5041    #[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    /// A caller names a color the same way `--fg` lets them, and an index
5065    /// resolves through this session's palette rather than a fixed table.
5066    #[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    /// `default` is the one spelling that cannot mean anything here: these
5082    /// slots *are* the defaults, so there is nothing for it to refer to.
5083    #[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    /// A link is matched by where it points, so a locator can find the cells
5344    /// of one link and ignore an identical-looking one pointing elsewhere.
5345    #[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    /// An empty link is a real requirement, not an absent one: it asks for a
5369    /// cell that links nowhere, which is how a test asserts that a link was
5370    /// closed rather than merely that some other link was not found.
5371    #[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}