diff --git a/README.md b/README.md index 82c20394..ba9ce8bc 100644 --- a/README.md +++ b/README.md @@ -208,6 +208,7 @@ way. | --------------------------------------------------- | ------------------------------------------------------------------------------------------- | | `state` | cwd, size, cursor, window title, last command + exit code, effective timeouts, text snapshot. | | `text [--full]` | Plain text of the viewport (or scrollback). | +| `find text "T" [selector options]` | Return selected matches with zero-based row/column spans. | | `screenshot [-o file.svg] [--full]` | Terminal text to stdout, or a crisp full-color SVG image (svg-term-style window) to a file. | | `cells X Y [W H]` | Per-cell attributes (char, fg, bg, flags). | | `get command\|output\|exit-code\|cwd\|cursor\|size\|title` | Structured getters. | @@ -249,7 +250,7 @@ print the screen bare. | Command | Description | | ------------------------------------------------------------------------------- | ------------------------------------------ | -| `expect text "T" [--regex --full --no-strict --not --fg C --bg C --timeout MS]` | Visibility + optional color. | +| `expect text "T" [selector/style options]` | Visibility plus optional color and cell styles. | | `expect title "T" [--regex --not --timeout MS]` | Window title set with OSC 0/2. | | `expect exit-code N [--timeout MS]` | Last command's exit code. | | `expect output "T" [--regex]` | Last command's captured output. | @@ -257,6 +258,14 @@ print the screen bare. Colors accept ANSI-256 (`9`), hex (`#ff0000`), or rgb (`255,0,0`). +Text selectors support `--after-text`, `--before-text`, `--whitespace +normalize`, `--match any|unique|first|last`, and zero-based `--nth N`. +Anchors can select their own occurrence with `--after-match` / +`--before-match` or `--after-nth` / `--before-nth`. Style assertions use +`--fg`, `--bg`, `--bold[=false]`, `--italic[=false]`, `--underline-style`, +`--underline-color`, `--inverse[=false]`, `--hidden[=false]`, +`--strikethrough[=false]`, and `--blink[=false]`. + ### Screenshots Screenshots render a snapshot of the session in the current terminal by default, but can render an SVG using the `-o` output flag. Nerd Font icons are embedded as vector paths, so SVGs remain self-contained without changing the font stack for regular text. diff --git a/SKILL.md b/SKILL.md index 7a53029e..132e125c 100644 --- a/SKILL.md +++ b/SKILL.md @@ -74,6 +74,7 @@ without parsing text: | --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | | `state` | cwd, size, cursor, last command + exit code, timeouts, and a text snapshot. | | `text [--full]` | Rendered viewport text, or full scrollback with `--full`. | +| `find text "T" [selector options]` | Selected matches with zero-based row/column spans. | | `screenshot [PATH] [-o FILE] [--full]` | Terminal text to stdout, or a full-color SVG image (crisp at any zoom, svg-term-style window) when a path is given. | | `cells X Y [W H]` | Per-cell attributes (char, fg, bg, flags) for a region. | | `get command\|output\|exit-code\|cwd\|cursor\|size\|title` | One structured field. | @@ -113,7 +114,7 @@ without parsing text: | Command | Description | | ------------------------------------------------------------------------------- | ----------------------------------------------------------------------------- | -| `expect text "T" [--regex --full --no-strict --not --fg C --bg C --timeout MS]` | Visibility plus optional color. `--no-strict` relaxes a strict single-match. | +| `expect text "T" [selector/style options]` | Visibility plus optional color and cell styles. `--no-strict` selects the first match. | | `expect title "T" [--regex --not --timeout MS]` | The window title set with `OSC 0`/`OSC 2`. An unset title matches nothing. | | `expect exit-code N [--timeout MS]` | The last command's exit code. Waits for the command to finish first. | | `expect output "T" [--regex]` | The last command's captured output. | @@ -121,6 +122,12 @@ without parsing text: Colors accept ansi-256 (`9`), hex (`#ff0000`), or rgb (`255,0,0`). +Selector options include `--after-text`, `--before-text`, `--whitespace +normalize`, `--match any|unique|first|last`, and zero-based `--nth`. Anchors +also accept `--after-match` / `--before-match` and `--after-nth` / +`--before-nth`. Styles include `--fg`, `--bg`, boolean SGR attributes such as +`--bold[=false]`, and underline style/color. + ### Recording, monitor & self-docs | Command | Description | diff --git a/crates/tui-test-cli/src/cli.rs b/crates/tui-test-cli/src/cli.rs index de11597d..0c54d5ca 100644 --- a/crates/tui-test-cli/src/cli.rs +++ b/crates/tui-test-cli/src/cli.rs @@ -281,6 +281,11 @@ pub enum Command { #[command(subcommand)] what: ExpectCmd, }, + /// Locate text and return its row/column spans. + Find { + #[command(subcommand)] + what: FindCmd, + }, /// Print the session's recording (asciinema v2 cast) to stdout. /// /// Redirect to a `.cast` file, then `asciinema play` it or render a GIF @@ -479,6 +484,59 @@ mod tests { assert_eq!(timeout, None); } + #[test] + fn find_text_accepts_scope_and_occurrence() { + let cli = Cli::try_parse_from([ + "tui-test", + "find", + "text", + "Save", + "--after-text", + "Settings", + "--after-match", + "last", + "--whitespace", + "normalize", + "--nth", + "1", + ]) + .expect("parse find text"); + let Some(Command::Find { + what: FindCmd::Text { selector, .. }, + }) = cli.command + else { + panic!("expected Find text"); + }; + assert_eq!(selector.after_text.as_deref(), Some("Settings")); + assert_eq!(selector.after_match, Some(MatchArg::Last)); + assert_eq!(selector.whitespace, WhitespaceArg::Normalize); + assert_eq!(selector.nth, Some(1)); + } + + #[test] + fn expect_text_accepts_generic_styles() { + let cli = Cli::try_parse_from([ + "tui-test", + "expect", + "text", + "Warning", + "--bold", + "--italic=false", + "--underline-style", + "curly", + ]) + .expect("parse styled expectation"); + let Some(Command::Expect { + what: ExpectCmd::Text { style, .. }, + }) = cli.command + else { + panic!("expected Expect text"); + }; + assert_eq!(style.bold, Some(true)); + assert_eq!(style.italic, Some(false)); + assert_eq!(style.underline_style.as_deref(), Some("curly")); + } + #[test] fn expect_exit_code_accepts_a_timeout() { let cli = @@ -661,6 +719,113 @@ impl ScrollDir { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)] +#[clap(rename_all = "lower")] +pub enum WhitespaceArg { + Exact, + Normalize, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)] +#[clap(rename_all = "lower")] +pub enum MatchArg { + Any, + Unique, + First, + Last, +} + +#[derive(Args)] +pub struct TextSelectorArgs { + /// Treat the target text as a regular expression. + #[arg(long)] + pub regex: bool, + /// Search the full scrollback, not just the visible viewport. + #[arg(long)] + pub full: bool, + /// Compare whitespace exactly or collapse runs and line breaks. + #[arg(long, value_enum, default_value_t = WhitespaceArg::Exact)] + pub whitespace: WhitespaceArg, + /// Search only after this literal anchor. + #[arg(long)] + pub after_text: Option, + /// Treat --after-text as a regular expression. + #[arg(long, requires = "after_text")] + pub after_regex: bool, + /// Select the anchor occurrence used by --after-text. + #[arg( + long, + value_enum, + requires = "after_text", + conflicts_with = "after_nth" + )] + pub after_match: Option, + /// Use the zero-based nth --after-text occurrence. + #[arg(long, requires = "after_text", conflicts_with = "after_match")] + pub after_nth: Option, + /// Search only before this literal anchor. + #[arg(long)] + pub before_text: Option, + /// Treat --before-text as a regular expression. + #[arg(long, requires = "before_text")] + pub before_regex: bool, + /// Select the anchor occurrence used by --before-text. + #[arg( + long, + value_enum, + requires = "before_text", + conflicts_with = "before_nth" + )] + pub before_match: Option, + /// Use the zero-based nth --before-text occurrence. + #[arg(long, requires = "before_text", conflicts_with = "before_match")] + pub before_nth: Option, + /// Select all, unique, first, or last target occurrences. + #[arg(long = "match", value_enum, conflicts_with = "nth")] + pub match_mode: Option, + /// Select the zero-based nth target occurrence. + #[arg(long, conflicts_with = "match_mode")] + pub nth: Option, +} + +#[derive(Args)] +pub struct TextStyleArgs { + /// Required foreground color. + #[arg(long)] + pub fg: Option, + /// Required background color. + #[arg(long)] + pub bg: Option, + #[arg(long, num_args = 0..=1, default_missing_value = "true", require_equals = true)] + pub bold: Option, + #[arg(long, num_args = 0..=1, default_missing_value = "true", require_equals = true)] + pub dim: Option, + #[arg(long, num_args = 0..=1, default_missing_value = "true", require_equals = true)] + pub italic: Option, + #[arg(long)] + pub underline_style: Option, + #[arg(long)] + pub underline_color: Option, + #[arg(long, num_args = 0..=1, default_missing_value = "true", require_equals = true)] + pub inverse: Option, + #[arg(long, num_args = 0..=1, default_missing_value = "true", require_equals = true)] + pub hidden: Option, + #[arg(long, num_args = 0..=1, default_missing_value = "true", require_equals = true)] + pub strikethrough: Option, + #[arg(long, num_args = 0..=1, default_missing_value = "true", require_equals = true)] + pub blink: Option, +} + +#[derive(Subcommand)] +pub enum FindCmd { + /// Find text and return its row/column spans. + Text { + text: String, + #[command(flatten)] + selector: TextSelectorArgs, + }, +} + #[derive(Subcommand)] pub enum WaitCmd { /// Wait until text/regex appears on screen (the most precise wait). @@ -736,28 +901,20 @@ pub enum WaitCmd { pub enum ExpectCmd { /// Assert text is visible, optionally with a required color. Text { - /// Text or regex to match. text: String, - /// Treat as a regular expression. - #[arg(long)] - regex: bool, - /// Search the full scrollback, not just the visible viewport. - #[arg(long)] - full: bool, + #[command(flatten)] + selector: TextSelectorArgs, /// Allow multiple matches instead of requiring exactly one. - #[arg(long = "no-strict")] + #[arg( + long = "no-strict", + conflicts_with_all = ["match_mode", "nth"] + )] no_strict: bool, /// Invert: assert the text is NOT present. #[arg(long)] not: bool, - /// Require this foreground color on the match: `default`, an ansi256 - /// index (0-255), hex (#rrggbb), or rgb (r,g,b). - #[arg(long)] - fg: Option, - /// Require this background color on the match: `default`, an ansi256 - /// index (0-255), hex (#rrggbb), or rgb (r,g,b). - #[arg(long)] - bg: Option, + #[command(flatten)] + style: Box, /// Timeout in milliseconds. #[arg(long, value_name = "MS")] timeout: Option, diff --git a/crates/tui-test-cli/src/main.rs b/crates/tui-test-cli/src/main.rs index b3c98134..d0e5b9dd 100644 --- a/crates/tui-test-cli/src/main.rs +++ b/crates/tui-test-cli/src/main.rs @@ -12,8 +12,12 @@ use std::time::{Duration, Instant}; use clap::{CommandFactory, Parser}; -use cli::{Cli, Command, DaemonCmd, ExpectCmd, GetArg, MouseCmd, WaitCmd}; +use cli::{ + Cli, Command, DaemonCmd, ExpectCmd, FindCmd, GetArg, MatchArg, MouseCmd, TextSelectorArgs, + TextStyleArgs, WaitCmd, WhitespaceArg, +}; use protocol::{GetField, MouseAction, Request, Response}; +use tui_test::{MatchOccurrence, TextAnchor, TextScope, TextSelector, TextStyle, WhitespaceMode}; /// Long-form agent skill manifest, printed by `tui-test skill`. const SKILL_MD: &str = include_str!("../../../SKILL.md"); @@ -214,6 +218,7 @@ fn build_request(command: Command) -> anyhow::Result { name: "KILL".to_string(), }, Command::Wait { what } => map_wait(what), + Command::Find { what } => map_find(what), Command::Expect { what } => map_expect(what), _ => anyhow::bail!("unsupported command"), }; @@ -311,25 +316,108 @@ fn map_wait(what: WaitCmd) -> Request { } } +fn map_occurrence( + mode: Option, + nth: Option, + default: MatchOccurrence, +) -> MatchOccurrence { + if let Some(index) = nth { + return MatchOccurrence::Nth(index); + } + match mode { + Some(MatchArg::Any) => MatchOccurrence::Any, + Some(MatchArg::Unique) => MatchOccurrence::Unique, + Some(MatchArg::First) => MatchOccurrence::First, + Some(MatchArg::Last) => MatchOccurrence::Last, + None => default, + } +} + +fn map_anchor( + text: Option, + regex: bool, + mode: Option, + nth: Option, +) -> Option { + text.map(|text| TextAnchor { + text, + regex, + occurrence: map_occurrence(mode, nth, MatchOccurrence::Unique), + }) +} + +fn map_selector(text: String, args: TextSelectorArgs, default: MatchOccurrence) -> TextSelector { + TextSelector { + text, + regex: args.regex, + full: args.full, + whitespace: match args.whitespace { + WhitespaceArg::Exact => WhitespaceMode::Exact, + WhitespaceArg::Normalize => WhitespaceMode::Normalize, + }, + scope: TextScope { + after: map_anchor( + args.after_text, + args.after_regex, + args.after_match, + args.after_nth, + ), + before: map_anchor( + args.before_text, + args.before_regex, + args.before_match, + args.before_nth, + ), + }, + occurrence: map_occurrence(args.match_mode, args.nth, default), + } +} + +fn map_style(args: TextStyleArgs) -> TextStyle { + TextStyle { + foreground: args.fg, + background: args.bg, + bold: args.bold, + dim: args.dim, + italic: args.italic, + underline_style: args.underline_style, + underline_color: args.underline_color, + inverse: args.inverse, + hidden: args.hidden, + strikethrough: args.strikethrough, + blink: args.blink, + } +} + +fn map_find(what: FindCmd) -> Request { + match what { + FindCmd::Text { text, selector } => Request::FindText { + selector: map_selector(text, selector, MatchOccurrence::Any), + }, + } +} + fn map_expect(what: ExpectCmd) -> Request { match what { ExpectCmd::Text { text, - regex, - full, + selector, no_strict, not, - fg, - bg, + style, timeout, - } => Request::ExpectText { - text, - regex, - full, - strict: !no_strict, + } => Request::ExpectTextSelector { + selector: map_selector( + text, + selector, + if no_strict { + MatchOccurrence::First + } else { + MatchOccurrence::Unique + }, + ), not, - fg, - bg, + style: map_style(*style), timeout_ms: timeout, }, ExpectCmd::Title { @@ -713,14 +801,15 @@ SESSION open [--shell S] [--cols N --rows N] [--cwd D] [--env K=V]\n\ run [--config F] [--profile P] [args...]\n\ sessions | close [--all] | daemon start|status | daemon stop --session N|--all\n\ INSPECT state | text [--full] | screenshot [-o file.svg] [--full]\n\ - cells X Y [W H] | get command|output|exit-code|cwd|cursor|size|title\n\ + find text \"T\" [selector options] | cells X Y [W H]\n\ + get command|output|exit-code|cwd|cursor|size|title\n\ INPUT type \"text\" | submit [\"text\"] | press | keys \"Ctrl+a\"\n\ mouse click X Y | mouse click --on-text \"OK\" | mouse move|down|up|drag|scroll\n\ PTY resize COLS ROWS | write | signal INT|TERM|KILL|QUIT | kill\n\ WAIT wait text \"T\" [--regex --full --not --timeout MS]\n\ wait title \"T\" [--regex --not --timeout MS]\n\ wait idle | wait command | wait exit | wait ready\n\ -EXPECT expect text \"T\" [--regex --full --not --fg C --bg C --timeout MS]\n\ +EXPECT expect text \"T\" [selector/style options] [--not --timeout MS]\n\ expect title \"T\" [--regex --not --timeout MS]\n\ expect exit-code N | expect output \"T\" [--regex]\n\ expect snapshot NAME [-u] [--include-colors --include-title]\n\ @@ -783,6 +872,55 @@ mod tests { assert_eq!(ready_flag(false, true), Some(false)); } + #[test] + fn find_text_maps_selector_options_to_the_protocol() { + let cli = Cli::try_parse_from([ + "tui-test", + "find", + "text", + "Save", + "--after-text", + "Settings", + "--whitespace", + "normalize", + "--nth", + "1", + ]) + .unwrap(); + let Request::FindText { selector } = build_request(cli.command.expect("command")).unwrap() + else { + panic!("expected find text request"); + }; + assert_eq!(selector.scope.after.unwrap().text, "Settings"); + assert_eq!(selector.whitespace, WhitespaceMode::Normalize); + assert_eq!(selector.occurrence, MatchOccurrence::Nth(1)); + } + + #[test] + fn expect_text_maps_style_options_to_the_protocol() { + let cli = Cli::try_parse_from([ + "tui-test", + "expect", + "text", + "Warning", + "--match", + "first", + "--bold", + "--underline-style", + "curly", + ]) + .unwrap(); + let Request::ExpectTextSelector { + selector, style, .. + } = build_request(cli.command.expect("command")).unwrap() + else { + panic!("expected styled text request"); + }; + assert_eq!(selector.occurrence, MatchOccurrence::First); + assert_eq!(style.bold, Some(true)); + assert_eq!(style.underline_style.as_deref(), Some("curly")); + } + #[test] fn daemon_version_check_rejects_stale_or_unversioned_daemons() { let current = Response::with(json!({ "version": env!("CARGO_PKG_VERSION") })); diff --git a/crates/tui-test-cli/src/protocol.rs b/crates/tui-test-cli/src/protocol.rs index 9839c810..2e43ee4a 100644 --- a/crates/tui-test-cli/src/protocol.rs +++ b/crates/tui-test-cli/src/protocol.rs @@ -2,7 +2,8 @@ use serde::{Deserialize, Serialize}; use serde_json::json; use tui_test::{ - Engine, OpenOptions, Operation, OperationResult, RunOptions, ScreenshotResult, TuiTestError, + Engine, OpenOptions, Operation, OperationResult, RunOptions, ScreenshotResult, TextSelector, + TextStyle, TuiTestError, }; pub use tui_test::{ErrorKind, MouseAction, Timeouts}; @@ -105,6 +106,17 @@ pub enum Request { #[serde(default)] timeout_ms: Option, }, + FindText { + selector: TextSelector, + }, + ExpectTextSelector { + selector: TextSelector, + not: bool, + #[serde(default)] + style: TextStyle, + #[serde(default)] + timeout_ms: Option, + }, ExpectTitle { text: String, regex: bool, @@ -238,6 +250,7 @@ impl Request { Request::WaitCommand { timeout_ms } => Ok(Operation::WaitCommand { timeout_ms }), Request::WaitExit { timeout_ms } => Ok(Operation::WaitExit { timeout_ms }), Request::WaitReady { timeout_ms } => Ok(Operation::WaitReady { timeout_ms }), + Request::FindText { selector } => Ok(Operation::FindText { selector }), Request::ExpectText { text, regex, @@ -257,6 +270,17 @@ impl Request { bg, timeout_ms, }), + Request::ExpectTextSelector { + selector, + not, + style, + timeout_ms, + } => Ok(Operation::ExpectTextSelector { + selector, + not, + style, + timeout_ms, + }), Request::ExpectTitle { text, regex,