Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions crates/tui-test-cli/src/protocol.rs
Original file line number Diff line number Diff line change
Expand Up @@ -370,6 +370,7 @@ fn operation_data(result: OperationResult) -> Result<Option<serde_json::Value>,
"text": String::from_utf8_lossy(&screen.utf8),
})),
OperationResult::Cells(cells) => Ok(json!({ "cells": cells })),
OperationResult::Matches(matches) => Ok(json!({ "matches": matches })),
OperationResult::Command(value) => Ok(json!({ "value": value })),
OperationResult::Output(value) => Ok(json!({ "value": value })),
OperationResult::ExitCode(value) => Ok(json!({ "value": value })),
Expand Down
82 changes: 82 additions & 0 deletions crates/tui-test/src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,61 @@ pub struct RunOptions {
pub timeouts: Timeouts,
}

#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum WhitespaceMode {
#[default]
Exact,
Normalize,
}

#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum MatchOccurrence {
Any,
#[default]
Unique,
First,
Last,
Nth(usize),
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TextAnchor {
pub text: String,
#[serde(default)]
pub regex: bool,
#[serde(default)]
pub occurrence: MatchOccurrence,
}

#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct TextScope {
pub after: Option<TextAnchor>,
pub before: Option<TextAnchor>,
}

#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct TextSelector {
pub text: String,
pub regex: bool,
pub full: bool,
pub whitespace: WhitespaceMode,
pub scope: TextScope,
pub occurrence: MatchOccurrence,
}

impl TextSelector {
pub fn new(text: impl Into<String>) -> Self {
Self {
text: text.into(),
..Self::default()
}
}
}

#[derive(Debug, Clone)]
pub enum Operation {
Open(OpenOptions),
Expand Down Expand Up @@ -144,6 +199,9 @@ pub enum Operation {
WaitReady {
timeout_ms: Option<u64>,
},
FindText {
selector: TextSelector,
},
ExpectText {
text: String,
regex: bool,
Expand Down Expand Up @@ -189,6 +247,7 @@ pub enum OperationResult {
Text(String),
PackedScreen(PackedScreen),
Cells(Vec<Cell>),
Matches(Vec<TextMatch>),
Command(Option<String>),
Output(Option<String>),
ExitCode(Option<i32>),
Expand Down Expand Up @@ -291,6 +350,29 @@ pub struct Size {
pub rows: u16,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
pub struct TextPosition {
pub row: u16,
pub column: u16,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
pub struct TextSpan {
pub row: u16,
pub start: u16,
pub end: u16,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct TextMatch {
pub text: String,
pub start: TextPosition,
/// Exclusive end position.
pub end: TextPosition,
/// Per-row column ranges with exclusive ends.
pub spans: Vec<TextSpan>,
}

#[derive(Debug, Clone, Copy, Serialize)]
pub struct EffectiveTimeouts {
pub text: u64,
Expand Down
34 changes: 33 additions & 1 deletion crates/tui-test/src/engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ use std::time::{Duration, Instant};
use crate::api::{
Cell, CellColor, Cursor, EffectiveTimeouts, ErrorKind, OpenOptions, OpenResult, Operation,
OperationResult, PackedScreen, RunOptions, RuntimeStatus, ScreenshotResult, Size,
SnapshotResult, TuiTestError,
SnapshotResult, TextAnchor, TextMatch, TextSelector, TuiTestError,
};
use crate::assert::color::{self, Expected};
use crate::assert::snapshot::{self, SnapshotStatus};
Expand Down Expand Up @@ -610,6 +610,9 @@ fn dispatch(
)?;
Ok(OperationResult::Unit)
}
Operation::FindText { selector } => {
Ok(OperationResult::Matches(find_text(session, &selector)?))
}
Operation::ExpectText {
text,
regex,
Expand Down Expand Up @@ -1224,6 +1227,35 @@ fn expect_text(
}
}

fn find_text(
session: &TerminalSession,
selector: &TextSelector,
) -> Result<Vec<TextMatch>, TuiTestError> {
validate_selector(selector)?;
locator::locate(&grid(session, selector.full), selector)
.map(|matches| matches.into_iter().map(|matched| matched.value).collect())
.map_err(|error| TuiTestError::assertion(error.to_string()))
}

fn validate_selector(selector: &TextSelector) -> Result<(), TuiTestError> {
let validate = |text: &str, regex: bool| {
Pattern::new(text, regex)
.map(|_| ())
.map_err(|error| TuiTestError::usage(format!("invalid regex: {error}")))
};
validate(&selector.text, selector.regex)?;
for TextAnchor { text, regex, .. } in [
selector.scope.after.as_ref(),
selector.scope.before.as_ref(),
]
.into_iter()
.flatten()
{
validate(text, *regex)?;
}
Ok(())
}

fn check_colors(
cells: &[locator::MatchedCell],
fg: &Option<String>,
Expand Down
Loading
Loading