tui-test-rs 0.1.0-beta.5

In-process terminal automation, inspection, assertions, and recording
Documentation
use serde::{Deserialize, Serialize};

use crate::api::{ClipboardPattern, LocatorQuery, Operation};

const MAX_EXPECTATION_BYTES: usize = 8 * 1024;

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum LocatorExpectation {
    Matches,
    Visible,
    Hidden,
    Unique,
    Actionable,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum OperationExpectation {
    Locator {
        query: Box<LocatorQuery>,
        outcome: LocatorExpectation,
    },
    Value {
        subject: String,
        expected: String,
    },
    Unavailable {
        reason: String,
    },
}

impl OperationExpectation {
    pub(crate) fn capture(operation: &Operation) -> Option<Self> {
        let value = |subject: &str, expected: String| Self::Value {
            subject: subject.into(),
            expected,
        };
        let pattern = |text: &str, regex: bool| {
            format!(
                "{} {text:?}",
                if regex { "matches regex" } else { "contains" }
            )
        };
        let expectation = match operation {
            Operation::FindLocator { query } => Self::Locator {
                query: Box::new(query.clone()),
                outcome: LocatorExpectation::Matches,
            },
            Operation::WaitLocator { query, not, .. } => Self::Locator {
                query: Box::new(query.clone()),
                outcome: if *not {
                    LocatorExpectation::Hidden
                } else {
                    LocatorExpectation::Visible
                },
            },
            Operation::ResolveLocator { query } => Self::Locator {
                query: Box::new(query.clone()),
                outcome: LocatorExpectation::Unique,
            },
            Operation::ClickLocator { query, .. } => Self::Locator {
                query: Box::new(query.clone()),
                outcome: LocatorExpectation::Actionable,
            },
            Operation::HighlightLocator { query, .. } => Self::Locator {
                query: Box::new(query.clone()),
                outcome: LocatorExpectation::Visible,
            },
            Operation::WaitTitle {
                text, regex, not, ..
            }
            | Operation::ExpectTitle {
                text, regex, not, ..
            } => value(
                "Terminal title",
                format!(
                    "{}{}",
                    if *not { "not " } else { "" },
                    pattern(text, *regex)
                ),
            ),
            Operation::ExpectOutput { text, regex } => {
                value("Command output", pattern(text, *regex))
            }
            Operation::ExpectExitCode { code, .. } => value("Command exit code", code.to_string()),
            Operation::ExpectBellCount { count, .. } => {
                value("Bell count", format!("at least {count}"))
            }
            Operation::ExpectMode { mode, enabled, .. } => {
                value(&format!("Terminal mode {mode:?}"), enabled.to_string())
            }
            Operation::ExpectColors {
                foreground,
                background,
                cursor,
                palette,
                ..
            } => {
                let colors = [
                    ("foreground", foreground),
                    ("background", background),
                    ("cursor", cursor),
                ]
                .into_iter()
                .filter_map(|(name, color)| color.as_ref().map(|color| format!("{name}={color:?}")))
                .chain(
                    palette
                        .iter()
                        .map(|(index, color)| format!("palette[{index}]={color:?}")),
                )
                .collect::<Vec<_>>()
                .join(", ");
                value("Terminal colors", colors)
            }
            Operation::ExpectCursor {
                visible,
                shape,
                x,
                y,
                ..
            } => {
                let fields = [
                    visible.map(|value| format!("visible={value}")),
                    shape.as_ref().map(|value| format!("shape={value:?}")),
                    x.map(|value| format!("column={value}")),
                    y.map(|value| format!("row={value}")),
                ]
                .into_iter()
                .flatten()
                .collect::<Vec<_>>()
                .join(", ");
                value("Cursor", fields)
            }
            Operation::WaitClipboardMatch {
                pattern: expected, ..
            } => value(
                "Clipboard",
                pattern(
                    expected.as_str(),
                    matches!(expected, ClipboardPattern::Regex(_)),
                ),
            ),
            Operation::WaitClipboard { .. } => value("Clipboard", "changes".into()),
            Operation::WaitIdle { .. } => value("Screen", "becomes idle".into()),
            Operation::WaitCommand { .. } => value("Command", "completes".into()),
            Operation::WaitExit { .. } => value("Process", "exits".into()),
            Operation::WaitReady { .. } => value("Shell", "becomes ready".into()),
            Operation::WaitBell { .. } => value("Bell", "a new event arrives".into()),
            Operation::Snapshot {
                name,
                update,
                include_style,
                include_title,
                ..
            } => value(
                &format!("Snapshot {name:?}"),
                format!(
                    "{} (style={include_style}, title={include_title})",
                    if *update {
                        "write or update"
                    } else {
                        "matches stored snapshot"
                    }
                ),
            ),
            _ => return None,
        };
        Some(match serde_json::to_vec(&expectation) {
            Ok(bytes) if bytes.len() <= MAX_EXPECTATION_BYTES => expectation,
            Ok(_) => Self::Unavailable {
                reason: format!(
                    "Expectation exceeded the {MAX_EXPECTATION_BYTES}-byte retention limit"
                ),
            },
            Err(error) => Self::Unavailable {
                reason: format!("Could not serialize expectation: {error}"),
            },
        })
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::api::{MatchOccurrence, TextStyle};

    #[test]
    fn preserves_the_complete_locator_and_negation() {
        let mut query = LocatorQuery::text("Save");
        query.within = Some(Box::new(LocatorQuery::text("Settings")));
        query.occurrence = MatchOccurrence::Nth(2);
        query.style = TextStyle {
            foreground: Some("2".into()),
            bold: Some(true),
            ..TextStyle::default()
        };
        let expected = OperationExpectation::capture(&Operation::WaitLocator {
            query: query.clone(),
            not: true,
            timeout_ms: Some(50),
        })
        .unwrap();
        assert_eq!(
            expected,
            OperationExpectation::Locator {
                query: Box::new(query),
                outcome: LocatorExpectation::Hidden,
            }
        );
    }

    #[test]
    fn bounds_operands_and_does_not_capture_input_or_environment() {
        let oversized = OperationExpectation::capture(&Operation::WaitLocator {
            query: LocatorQuery::text("\u{4f60}".repeat(MAX_EXPECTATION_BYTES)),
            not: false,
            timeout_ms: None,
        })
        .unwrap();
        assert!(matches!(
            oversized,
            OperationExpectation::Unavailable { .. }
        ));
        assert!(serde_json::to_vec(&oversized).unwrap().len() < MAX_EXPECTATION_BYTES);
        assert!(OperationExpectation::capture(&Operation::Write {
            data: "secret".into()
        })
        .is_none());
        assert!(OperationExpectation::capture(&Operation::Submit {
            data: Some("secret".into())
        })
        .is_none());
        assert!(OperationExpectation::capture(&Operation::Open(Default::default())).is_none());
    }
}