diff --git a/Cargo.lock b/Cargo.lock index 3d485fa6..04ee407c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1583,6 +1583,7 @@ dependencies = [ "anyhow", "pyo3", "pyo3-async-runtimes", + "serde_json", "tokio", "tui-test-rs", ] diff --git a/README.md b/README.md index 2794fd03..619a7e5b 100644 --- a/README.md +++ b/README.md @@ -268,6 +268,7 @@ continues to build only the Alacritty backend and does not require Zig. | --------------------------------------------------- | ------------------------------------------------------------------------------------------- | | `state` | cwd, size, cursor, window title, last command + exit code, bell count, 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] [--zoom N]` | Terminal text to stdout, or a full-color SVG scaled without changing its terminal cells. | | `cells X Y [W H]` | Per-cell attributes (char, fg, bg, flags). | | `get command\|output\|exit-code\|cwd\|cursor\|size\|title\|bells\|bell-events` | Structured getters. | @@ -318,7 +319,7 @@ the top-level `press` command remains a compatibility alias for `key press`. | 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. | @@ -327,6 +328,14 @@ the top-level `press` command remains a compatibility alias for `key press`. 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 diff --git a/SKILL.md b/SKILL.md index c136ab84..a10055c6 100644 --- a/SKILL.md +++ b/SKILL.md @@ -80,6 +80,7 @@ without parsing text: | --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | | `state` | cwd, size, cursor, window title, last command + exit code, bell count, 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] [--zoom N]` | Terminal text to stdout, or a full-color SVG scaled without changing its terminal cells. | | `cells X Y [W H]` | Per-cell attributes (char, fg, bg, flags) for a region. | | `get command\|output\|exit-code\|cwd\|cursor\|size\|title\|bells\|bell-events` | One structured field. | @@ -128,7 +129,7 @@ and `Meta`. Top-level `press` remains a compatibility alias for `key press`. | 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. | @@ -137,6 +138,12 @@ and `Meta`. Top-level `press` remains a compatibility alias for `key press`. 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/bindings/js/README.md b/bindings/js/README.md index f1b8092f..015b6db5 100644 --- a/bindings/js/README.md +++ b/bindings/js/README.md @@ -49,7 +49,7 @@ All derive from `TuiTestError` and carry `kind` and `exitCode`. `waitX` and `exp ## API -`new TuiTest(session?, { backend?, profile?, timeouts?, artifacts? })` mirrors the cli: `open` / `run`, `type` / `write`, `submit`, `keyboard.press|down|repeat|up`, compatibility `press`, `mouse.click|move|down|up|drag|scroll`, `resize`, `signal` / `kill`, `state`, `text`, `cells`, `getCommand` / `getOutput` / `getExitCode` / `getCwd` / `getCursor` / `getSize` / `getTitle` / `getBellCount` / `getBellEvents`, `screenshot`, `startRecording` / `stopRecording`, `waitText` / `waitTitle` / `waitIdle` / `waitCommand` / `waitExit` / `waitReady` / `waitBell`, `expectText` / `expectTitle` / `expectExitCode` / `expectOutput` / `expectBellCount` / `expectSnapshot`, `close`, and `closeQuiet`. +`new TuiTest(session?, { backend?, profile?, timeouts?, artifacts? })` mirrors the cli: `open` / `run`, `type` / `write`, `submit`, `keyboard.press|down|repeat|up`, compatibility `press`, `mouse.click|move|down|up|drag|scroll`, `resize`, `signal` / `kill`, `state`, `text`, `findText`, `cells`, `getCommand` / `getOutput` / `getExitCode` / `getCwd` / `getCursor` / `getSize` / `getTitle` / `getBellCount` / `getBellEvents`, `screenshot`, `startRecording` / `stopRecording`, `waitText` / `waitTitle` / `waitIdle` / `waitCommand` / `waitExit` / `waitReady` / `waitBell`, `expectText` / `expectTitle` / `expectExitCode` / `expectOutput` / `expectBellCount` / `expectSnapshot`, `close`, and `closeQuiet`. `keyboard.press()` simulates key presses: it sends the normal press input and adds a release only when the negotiated Kitty mode can represent it. @@ -57,6 +57,11 @@ adds a release only when the negotiated Kitty mode can represent it. events; `keyboard.repeat()` simulates repeats. Top-level `press()` remains a compatibility alias. +`findText(text, options)` returns zero-based row/column spans and supports +normalized whitespace, `after` / `before` anchors, and any/unique/first/last/nth +occurrences. `expectText` accepts the same selector options plus `style` checks +for colors, bold, dim, italic, underline, inverse, hidden, strikethrough, and blink. + Module-level helpers: `sessions()`, `closeAll()`, `getRecording()`, `uniqueSession()`. `open` and `run` accept diff --git a/bindings/js/native/index.d.ts b/bindings/js/native/index.d.ts index 51ec63e1..5672af36 100644 --- a/bindings/js/native/index.d.ts +++ b/bindings/js/native/index.d.ts @@ -8,6 +8,7 @@ export declare class NativeSession { close(): Promise state(): Promise text(full?: boolean | undefined | null): Promise + findText(text: string, options?: TextSelectorOptions | undefined | null): Promise> packedScreen(full?: boolean | undefined | null): Promise cells(x: number, y: number, w?: number | undefined | null, h?: number | undefined | null): Promise> getCommand(): Promise @@ -105,9 +106,29 @@ export interface ExpectTextOptions { regex?: boolean full?: boolean strict?: boolean + whitespace?: string + occurrence?: string + nth?: number + afterText?: string + afterRegex?: boolean + afterOccurrence?: string + afterNth?: number + beforeText?: string + beforeRegex?: boolean + beforeOccurrence?: string + beforeNth?: number not?: boolean fg?: string bg?: string + bold?: boolean + dim?: boolean + italic?: boolean + underlineStyle?: string + underlineColor?: string + inverse?: boolean + hidden?: boolean + strikethrough?: boolean + blink?: boolean timeoutMs?: number } @@ -243,6 +264,40 @@ export interface State { text: string } +export interface TextMatch { + text: string + start: TextPosition + end: TextPosition + spans: Array +} + +export interface TextPosition { + row: number + column: number +} + +export interface TextSelectorOptions { + regex?: boolean + full?: boolean + whitespace?: string + occurrence?: string + nth?: number + afterText?: string + afterRegex?: boolean + afterOccurrence?: string + afterNth?: number + beforeText?: string + beforeRegex?: boolean + beforeOccurrence?: string + beforeNth?: number +} + +export interface TextSpan { + row: number + start: number + end: number +} + export interface Timeouts { text?: number idle?: number diff --git a/bindings/js/native/lib.rs b/bindings/js/native/lib.rs index f82e81a8..da2987aa 100644 --- a/bindings/js/native/lib.rs +++ b/bindings/js/native/lib.rs @@ -11,11 +11,14 @@ use tui_test::shell::Shell as CoreShell; use tui_test::{ global_registry, Backend as CoreBackend, BellEvent as CoreBellEvent, Cell as CoreCell, CellColor, Cursor as CoreCursor, EffectiveTimeouts as CoreEffectiveTimeouts, ErrorKind, - KeyAction, MouseAction, OpenOptions as CoreOpenOptions, OpenResult as CoreOpenResult, - Operation, OperationResult, RecordingFormat as CoreRecordingFormat, - RunOptions as CoreRunOptions, ScreenshotResult as CoreScreenshotResult, SessionHandle, - Size as CoreSize, SnapshotResult as CoreSnapshotResult, State as CoreState, - Timeouts as CoreTimeouts, TuiTestError, + KeyAction, MatchOccurrence as CoreMatchOccurrence, MouseAction, OpenOptions as CoreOpenOptions, + OpenResult as CoreOpenResult, Operation, OperationResult, + RecordingFormat as CoreRecordingFormat, RunOptions as CoreRunOptions, + ScreenshotResult as CoreScreenshotResult, SessionHandle, Size as CoreSize, + SnapshotResult as CoreSnapshotResult, State as CoreState, TextAnchor as CoreTextAnchor, + TextMatch as CoreTextMatch, TextScope as CoreTextScope, TextSelector as CoreTextSelector, + TextStyle as CoreTextStyle, Timeouts as CoreTimeouts, TuiTestError, + WhitespaceMode as CoreWhitespaceMode, }; const ERROR_PREFIX: &str = "__tui_test_native_error__:"; @@ -332,6 +335,52 @@ impl TryFrom for Cell { } } +#[napi(object)] +pub struct TextPosition { + pub row: u16, + pub column: u16, +} + +#[napi(object)] +pub struct TextSpan { + pub row: u16, + pub start: u16, + pub end: u16, +} + +#[napi(object)] +pub struct TextMatch { + pub text: String, + pub start: TextPosition, + pub end: TextPosition, + pub spans: Vec, +} + +impl From for TextMatch { + fn from(value: CoreTextMatch) -> Self { + Self { + text: value.text, + start: TextPosition { + row: value.start.row, + column: value.start.column, + }, + end: TextPosition { + row: value.end.row, + column: value.end.column, + }, + spans: value + .spans + .into_iter() + .map(|span| TextSpan { + row: span.row, + start: span.start, + end: span.end, + }) + .collect(), + } + } +} + #[napi(object)] /// Private native-owned packed screen snapshot. /// @@ -374,17 +423,56 @@ pub struct TitleOptions { pub timeout_ms: Option, } +#[derive(Default)] #[napi(object)] pub struct ExpectTextOptions { pub regex: Option, pub full: Option, pub strict: Option, + pub whitespace: Option, + pub occurrence: Option, + pub nth: Option, + pub after_text: Option, + pub after_regex: Option, + pub after_occurrence: Option, + pub after_nth: Option, + pub before_text: Option, + pub before_regex: Option, + pub before_occurrence: Option, + pub before_nth: Option, pub not: Option, pub fg: Option, pub bg: Option, + pub bold: Option, + pub dim: Option, + pub italic: Option, + pub underline_style: Option, + pub underline_color: Option, + pub inverse: Option, + pub hidden: Option, + pub strikethrough: Option, + pub blink: Option, pub timeout_ms: Option, } +#[derive(Default)] +#[napi(object)] +pub struct TextSelectorOptions { + pub regex: Option, + pub full: Option, + pub whitespace: Option, + pub occurrence: Option, + pub nth: Option, + pub after_text: Option, + pub after_regex: Option, + pub after_occurrence: Option, + pub after_nth: Option, + pub before_text: Option, + pub before_regex: Option, + pub before_occurrence: Option, + pub before_nth: Option, +} + #[napi(object)] pub struct SnapshotOptions { pub update: Option, @@ -495,6 +583,94 @@ fn u8_value(value: f64, name: &str) -> std::result::Result { Ok(integer(value, name, u64::from(u8::MAX))? as u8) } +fn core_occurrence( + value: Option, + nth: Option, + default: CoreMatchOccurrence, + name: &str, +) -> std::result::Result { + if let Some(index) = nth { + if value.as_deref().is_some_and(|value| value != "nth") { + return Err(TuiTestError::usage(format!( + "{name} cannot combine occurrence '{value:?}' with nth" + ))); + } + return Ok(CoreMatchOccurrence::Nth( + integer(index, name, usize::MAX as u64)? as usize, + )); + } + match value.as_deref() { + None => Ok(default), + Some("any") => Ok(CoreMatchOccurrence::Any), + Some("unique") => Ok(CoreMatchOccurrence::Unique), + Some("first") => Ok(CoreMatchOccurrence::First), + Some("last") => Ok(CoreMatchOccurrence::Last), + Some("nth") => Err(TuiTestError::usage(format!("{name} requires an nth index"))), + Some(value) => Err(TuiTestError::usage(format!( + "{name} must be any, unique, first, last, or nth (got '{value}')" + ))), + } +} + +fn core_anchor( + text: Option, + regex: Option, + occurrence: Option, + nth: Option, + name: &str, +) -> std::result::Result, TuiTestError> { + match text { + Some(text) => Ok(Some(CoreTextAnchor { + text, + regex: regex.unwrap_or(false), + occurrence: core_occurrence(occurrence, nth, CoreMatchOccurrence::Unique, name)?, + })), + None if regex.unwrap_or(false) || occurrence.is_some() || nth.is_some() => Err( + TuiTestError::usage(format!("{name} options require anchor text")), + ), + None => Ok(None), + } +} + +fn core_selector( + text: String, + options: TextSelectorOptions, + default: CoreMatchOccurrence, +) -> std::result::Result { + let whitespace = match options.whitespace.as_deref() { + None | Some("exact") => CoreWhitespaceMode::Exact, + Some("normalize") => CoreWhitespaceMode::Normalize, + Some(value) => { + return Err(TuiTestError::usage(format!( + "whitespace must be exact or normalize (got '{value}')" + ))) + } + }; + Ok(CoreTextSelector { + text, + regex: options.regex.unwrap_or(false), + full: options.full.unwrap_or(false), + whitespace, + scope: CoreTextScope { + after: core_anchor( + options.after_text, + options.after_regex, + options.after_occurrence, + options.after_nth, + "afterNth", + )?, + before: core_anchor( + options.before_text, + options.before_regex, + options.before_occurrence, + options.before_nth, + "beforeNth", + )?, + }, + occurrence: core_occurrence(options.occurrence, options.nth, default, "nth")?, + }) +} + fn i32_value(value: f64, name: &str) -> std::result::Result { if !value.is_finite() || value.fract() != 0.0 @@ -709,6 +885,26 @@ impl NativeSession { .await } + #[napi] + pub async fn find_text( + &self, + text: String, + options: Option, + ) -> Result> { + let handle = self.handle.clone(); + blocking("findText", move || { + let selector = + core_selector(text, options.unwrap_or_default(), CoreMatchOccurrence::Any)?; + match handle.execute(Operation::FindText { selector })? { + OperationResult::Matches(matches) => { + Ok(matches.into_iter().map(TextMatch::from).collect()) + } + _ => Err(unexpected("findText")), + } + }) + .await + } + #[napi] pub async fn packed_screen(&self, full: Option) -> Result { execute( @@ -1194,25 +1390,49 @@ impl NativeSession { text: String, options: Option, ) -> Result<()> { - let options = options.unwrap_or(ExpectTextOptions { - regex: None, - full: None, - strict: None, - not: None, - fg: None, - bg: None, - timeout_ms: None, - }); + let options = options.unwrap_or_default(); let handle = self.handle.clone(); blocking("expectText", move || { - let operation = Operation::ExpectText { + let default = if options.strict.unwrap_or(true) { + CoreMatchOccurrence::Unique + } else { + CoreMatchOccurrence::First + }; + let selector = core_selector( text, - regex: options.regex.unwrap_or(false), - full: options.full.unwrap_or(false), - strict: options.strict.unwrap_or(true), + TextSelectorOptions { + regex: options.regex, + full: options.full, + whitespace: options.whitespace, + occurrence: options.occurrence, + nth: options.nth, + after_text: options.after_text, + after_regex: options.after_regex, + after_occurrence: options.after_occurrence, + after_nth: options.after_nth, + before_text: options.before_text, + before_regex: options.before_regex, + before_occurrence: options.before_occurrence, + before_nth: options.before_nth, + }, + default, + )?; + let operation = Operation::ExpectTextSelector { + selector, not: options.not.unwrap_or(false), - fg: options.fg, - bg: options.bg, + style: CoreTextStyle { + foreground: options.fg, + background: options.bg, + bold: options.bold, + dim: options.dim, + italic: options.italic, + underline_style: options.underline_style, + underline_color: options.underline_color, + inverse: options.inverse, + hidden: options.hidden, + strikethrough: options.strikethrough, + blink: options.blink, + }, timeout_ms: timeout(options.timeout_ms, "timeoutMs")?, }; match handle.execute(operation)? { diff --git a/bindings/js/src/client.ts b/bindings/js/src/client.ts index 3841584f..884185db 100644 --- a/bindings/js/src/client.ts +++ b/bindings/js/src/client.ts @@ -16,6 +16,7 @@ import type { TimeoutClass } from "./config.js"; import { uniqueSession } from "./ephemeral.js"; import { ExpectationError } from "./errors.js"; import { NativeRuntime } from "./native.js"; +import type { NativeTextSelectorOptions } from "./native.js"; import type { BellEvent, Cell, @@ -26,6 +27,7 @@ import type { Size, SpawnOptions, State, + TextMatch, } from "./types.js"; export interface WaitTextOptions { @@ -41,13 +43,50 @@ export interface TitleOptions { timeout?: number; } -export interface ExpectTextOptions { +export type TextOccurrence = + | "any" + | "unique" + | "first" + | "last" + | { nth: number }; + +export interface TextAnchor { + text: string; + regex?: boolean; + occurrence?: TextOccurrence; +} + +export interface TextSelectorOptions { regex?: boolean; full?: boolean; + whitespace?: "exact" | "normalize"; + scope?: { + after?: TextAnchor; + before?: TextAnchor; + }; + occurrence?: TextOccurrence; +} + +export interface TextStyleExpectation { + foreground?: string; + background?: string; + bold?: boolean; + dim?: boolean; + italic?: boolean; + underlineStyle?: "none" | "single" | "double" | "curly" | "dotted" | "dashed"; + underlineColor?: string; + inverse?: boolean; + hidden?: boolean; + strikethrough?: boolean; + blink?: boolean; +} + +export interface ExpectTextOptions extends TextSelectorOptions { strict?: boolean; not?: boolean; fg?: string; bg?: string; + style?: TextStyleExpectation; timeout?: number; } @@ -123,6 +162,36 @@ class Keyboard { } } +function occurrenceOptions(occurrence?: TextOccurrence): { + occurrence?: string; + nth?: number; +} { + return typeof occurrence === "object" + ? { occurrence: "nth", nth: occurrence.nth } + : { occurrence }; +} + +function selectorOptions(opts: TextSelectorOptions): NativeTextSelectorOptions { + const after = opts.scope?.after; + const before = opts.scope?.before; + const afterOccurrence = occurrenceOptions(after?.occurrence); + const beforeOccurrence = occurrenceOptions(before?.occurrence); + return { + regex: opts.regex ?? false, + full: opts.full ?? false, + whitespace: opts.whitespace ?? "exact", + ...occurrenceOptions(opts.occurrence), + afterText: after?.text, + afterRegex: after?.regex, + afterOccurrence: afterOccurrence.occurrence, + afterNth: afterOccurrence.nth, + beforeText: before?.text, + beforeRegex: before?.regex, + beforeOccurrence: beforeOccurrence.occurrence, + beforeNth: beforeOccurrence.nth, + }; +} + class Mouse { #runtime: NativeRuntime; @@ -430,6 +499,14 @@ export class TuiTest { ); } + async findText(text: string, opts: TextSelectorOptions = {}): Promise { + return this.#guard("findText", () => + this.#runtime.findText(text, { + ...selectorOptions({ ...opts, occurrence: opts.occurrence ?? "any" }), + }), + ); + } + async waitIdle(opts: { timeout?: number } = {}): Promise { await this.#guard("waitIdle", () => this.#runtime.waitIdle(this.#timeout("idle", opts.timeout)), @@ -471,14 +548,23 @@ export class TuiTest { } async expectText(text: string, opts: ExpectTextOptions = {}): Promise { + const style = opts.style ?? {}; await this.#guard("expectText", () => this.#runtime.expectText(text, { - regex: opts.regex ?? false, - full: opts.full ?? false, + ...selectorOptions(opts), strict: opts.strict ?? true, not: opts.not ?? false, - fg: opts.fg, - bg: opts.bg, + fg: style.foreground ?? opts.fg, + bg: style.background ?? opts.bg, + bold: style.bold, + dim: style.dim, + italic: style.italic, + underlineStyle: style.underlineStyle, + underlineColor: style.underlineColor, + inverse: style.inverse, + hidden: style.hidden, + strikethrough: style.strikethrough, + blink: style.blink, timeoutMs: this.#timeout("text", opts.timeout), }), ); diff --git a/bindings/js/src/index.ts b/bindings/js/src/index.ts index f338f259..8fa6887b 100644 --- a/bindings/js/src/index.ts +++ b/bindings/js/src/index.ts @@ -2,6 +2,10 @@ export { TuiTest } from "./client.js"; export type { ExpectTextOptions, MouseButtonOptions, + TextAnchor, + TextOccurrence, + TextSelectorOptions, + TextStyleExpectation, TitleOptions, RecordingFormat, RecordingOptions, @@ -35,6 +39,7 @@ export type { Size, SpawnOptions, State, + TextMatch, TerminalArtifact, Timeouts, } from "./types.js"; diff --git a/bindings/js/src/native.ts b/bindings/js/src/native.ts index 2772f56c..cb1bdf34 100644 --- a/bindings/js/src/native.ts +++ b/bindings/js/src/native.ts @@ -15,6 +15,8 @@ import type { Size, SnapshotOptions, State, + TextMatch, + TextSelectorOptions, Timeouts, TitleOptions, WaitTextOptions, @@ -171,6 +173,10 @@ export class NativeRuntime { return this.#call((session) => session.text(full)); } + findText(text: string, options?: TextSelectorOptions): Promise { + return this.#call((session) => session.findText(text, options)); + } + /** * Private packed snapshot. The detached Uint8Array is read-only by contract * and contains newline-delimited full logical rows, including trailing spaces @@ -386,6 +392,8 @@ export type { Size as NativeSize, SnapshotOptions as NativeSnapshotOptions, State as NativeState, + TextMatch as NativeTextMatch, + TextSelectorOptions as NativeTextSelectorOptions, Timeouts as NativeTimeouts, WaitTextOptions as NativeWaitTextOptions, }; diff --git a/bindings/js/src/types.ts b/bindings/js/src/types.ts index 5843653d..be50c4a0 100644 --- a/bindings/js/src/types.ts +++ b/bindings/js/src/types.ts @@ -6,6 +6,7 @@ import type { OpenResult as NativeOpenResult, Size as NativeSize, State as NativeState, + TextMatch as NativeTextMatch, Timeouts as NativeTimeouts, } from "../native/index.js"; @@ -57,6 +58,8 @@ export type BellEvent = NativeBellEvent; export type State = NativeState; +export type TextMatch = NativeTextMatch; + export type OpenResult = NativeOpenResult; export interface Colors { diff --git a/bindings/js/test/conformance.test.mjs b/bindings/js/test/conformance.test.mjs index b4eb8680..103ecd0c 100644 --- a/bindings/js/test/conformance.test.mjs +++ b/bindings/js/test/conformance.test.mjs @@ -51,6 +51,7 @@ const MAPPING = { kill: [["client", "kill"]], wait: [["client", "waitTitle"], ["client", "waitText"], ["client", "waitIdle"], ["client", "waitCommand"], ["client", "waitExit"], ["client", "waitBell"]], expect: [["client", "expectTitle"], ["client", "expectText"], ["client", "expectExitCode"], ["client", "expectOutput"], ["client", "expectBellCount"], ["client", "expectSnapshot"]], + find: [["client", "findText"]], "get-recording": [["module", "getRecording"]], }; diff --git a/bindings/js/test/integration.test.mjs b/bindings/js/test/integration.test.mjs index afddbf10..a8eb36d6 100644 --- a/bindings/js/test/integration.test.mjs +++ b/bindings/js/test/integration.test.mjs @@ -379,6 +379,33 @@ test("private packed screens retain full UTF-8 logical rows and own their bytes" } }); +test("text locators scope matches and assert styles", async () => { + const su = new TuiTest(uniqueSession("text-locators")); + const script = + "process.stdout.write('Settings\\n Save\\n\\x1b[1mWarning\\x1b[0m\\n');" + + "setInterval(() => {}, 1000)"; + try { + await su.run(process.execPath, ["-e", script]); + await su.waitText("Warning", { timeout: 2000 }); + const matches = await su.findText("Save", { + whitespace: "normalize", + scope: { after: { text: "Settings" } }, + }); + assert.equal(matches.length, 1); + assert.equal(matches[0].start.row, 1); + assert.equal(matches[0].start.column, 2); + await su.expectText("Warning", { style: { bold: true } }); + await assert.rejects( + su.expectText("Warning", { style: { bold: false }, timeout: 20 }), + (error) => + error instanceof ExpectationError && + error.message.includes("expected bold=false"), + ); + } finally { + await su.closeQuiet(); + } +}); + test("panic containment rejects as InternalError and Node keeps running", async () => { const runtime = new NativeRuntime(uniqueSession("panic-probe")); await assert.rejects( diff --git a/bindings/js/test/native.test.mjs b/bindings/js/test/native.test.mjs index 3570c19b..23bb3b35 100644 --- a/bindings/js/test/native.test.mjs +++ b/bindings/js/test/native.test.mjs @@ -19,6 +19,7 @@ test("generated native declarations expose typed operations", async () => { "Cell", "PackedScreen", "RecordingOptions", + "TextMatch", ]) { assert.match(declarations, new RegExp(`export (?:interface|type) ${type}\\b`)); } @@ -28,6 +29,7 @@ test("generated native declarations expose typed operations", async () => { "close", "state", "text", + "findText", "cells", "getCommand", "getBellCount", diff --git a/bindings/python/README.md b/bindings/python/README.md index 92f70c05..5d79d3ce 100644 --- a/bindings/python/README.md +++ b/bindings/python/README.md @@ -54,7 +54,7 @@ All derive from `TuiTestError`. `wait_*` and `expect_*` raise `ExpectationError` ## API -`TuiTest(session="default", *, backend=None, timeouts=None, profile=None, artifacts=None)` mirrors the cli: `open` / `run`, `type` / `write`, `submit`, `keyboard.press|down|repeat|up`, compatibility `press`, `mouse.click|move|down|up|drag|scroll`, `resize`, `signal` / `kill`, `state`, `text`, `cells`, `get_command` / `get_output` / `get_exit_code` / `get_cwd` / `get_cursor` / `get_size` / `get_title` / `get_bell_count` / `get_bell_events`, `screenshot`, `start_recording` / `stop_recording`, `wait_text` / `wait_title` / `wait_idle` / `wait_command` / `wait_exit` / `wait_ready` / `wait_bell`, `expect_text` / `expect_title` / `expect_exit_code` / `expect_output` / `expect_bell_count` / `expect_snapshot`, `close`, and `close_quiet`. +`TuiTest(session="default", *, backend=None, timeouts=None, profile=None, artifacts=None)` mirrors the cli: `open` / `run`, `type` / `write`, `submit`, `keyboard.press|down|repeat|up`, compatibility `press`, `mouse.click|move|down|up|drag|scroll`, `resize`, `signal` / `kill`, `state`, `text`, `find_text`, `cells`, `get_command` / `get_output` / `get_exit_code` / `get_cwd` / `get_cursor` / `get_size` / `get_title` / `get_bell_count` / `get_bell_events`, `screenshot`, `start_recording` / `stop_recording`, `wait_text` / `wait_title` / `wait_idle` / `wait_command` / `wait_exit` / `wait_ready` / `wait_bell`, `expect_text` / `expect_title` / `expect_exit_code` / `expect_output` / `expect_bell_count` / `expect_snapshot`, `close`, and `close_quiet`. `keyboard.press()` simulates key presses: it sends the normal press input and adds a release only when the negotiated Kitty mode can represent it. @@ -62,6 +62,12 @@ adds a release only when the negotiated Kitty mode can represent it. events; `keyboard.repeat()` simulates repeats. Top-level `press()` remains a compatibility alias. +`find_text()` returns typed zero-based row/column spans and supports normalized +whitespace, `after` / `before` anchors, and any/unique/first/last/nth +occurrences. `expect_text()` accepts the same selector options plus `TextStyle` +checks for colors, bold, dim, italic, underline, inverse, hidden, +strikethrough, and blink. + Module-level helpers: `sessions()`, `close_all()`, `get_recording()`, `unique_session()`. `open()` and `run()` accept `backend=`, `wait_ready=`, `restart=`, `retries=`, diff --git a/bindings/python/native/Cargo.toml b/bindings/python/native/Cargo.toml index d5662a55..aad33a8d 100644 --- a/bindings/python/native/Cargo.toml +++ b/bindings/python/native/Cargo.toml @@ -18,6 +18,7 @@ anyhow.workspace = true tui-test = { workspace = true, features = ["ghostty", "recording-font-jetbrains-mono-styles"] } pyo3 = { version = "0.28" } pyo3-async-runtimes = { version = "0.28", features = ["tokio-runtime"] } +serde_json.workspace = true tokio = { version = "1", features = ["rt-multi-thread"] } [features] diff --git a/bindings/python/native/src/lib.rs b/bindings/python/native/src/lib.rs index e2c86c83..d7e4d7ae 100644 --- a/bindings/python/native/src/lib.rs +++ b/bindings/python/native/src/lib.rs @@ -10,7 +10,8 @@ use tui_test::shell::Shell; use tui_test::{ Backend, BellEvent, Cell, CellColor, Cursor, ErrorKind, KeyAction, MouseAction, OpenOptions, OpenResult, Operation, OperationResult, PackedScreen, RecordingFormat, RunOptions, - ScreenshotResult, Size, SnapshotResult, State, Timeouts, TuiTestError, + ScreenshotResult, Size, SnapshotResult, State, TextMatch, TextSelector, TextStyle, Timeouts, + TuiTestError, }; pyo3::create_exception!( @@ -231,6 +232,23 @@ impl NativeSession { ) } + fn find_text<'py>( + &self, + py: Python<'py>, + selector_json: String, + ) -> PyResult> { + let name = self.name.clone(); + future_blocking( + py, + move || { + let selector: TextSelector = serde_json::from_str(&selector_json) + .map_err(|error| TuiTestError::usage(error.to_string()))?; + execute_matches(&name, Operation::FindText { selector }) + }, + matches_to_py, + ) + } + fn packed_screen<'py>(&self, py: Python<'py>, full: bool) -> PyResult> { let name = self.name.clone(); future_blocking( @@ -909,6 +927,35 @@ impl NativeSession { ) } + #[pyo3(signature = (request_json, timeout_ms))] + fn expect_text_selector<'py>( + &self, + py: Python<'py>, + request_json: String, + timeout_ms: Option>, + ) -> PyResult> { + let timeout_ms = capture_optional_integer(timeout_ms); + let name = self.name.clone(); + future_blocking( + py, + move || { + let (selector, style, not): (TextSelector, TextStyle, bool) = + serde_json::from_str(&request_json) + .map_err(|error| TuiTestError::usage(error.to_string()))?; + execute_unit( + &name, + Operation::ExpectTextSelector { + selector, + not, + style, + timeout_ms: optional_u64(timeout_ms.as_ref(), "timeout")?, + }, + ) + }, + unit_to_py, + ) + } + #[pyo3(signature = (code, timeout_ms))] fn expect_exit_code<'py>( &self, @@ -1381,6 +1428,13 @@ fn execute_cells(name: &str, operation: Operation) -> Result, TuiTestE } } +fn execute_matches(name: &str, operation: Operation) -> Result, TuiTestError> { + match global_registry().execute(name, operation)? { + OperationResult::Matches(value) => Ok(value), + _ => Err(unexpected_result("text matches")), + } +} + fn execute_command(name: &str, operation: Operation) -> Result, TuiTestError> { match global_registry().execute(name, operation)? { OperationResult::Command(value) => Ok(value), @@ -1589,6 +1643,33 @@ fn cells_to_py(py: Python<'_>, cells: Vec) -> PyResult> { Ok(values.into_any().unbind()) } +fn matches_to_py(py: Python<'_>, matches: Vec) -> PyResult> { + let values = PyList::empty(py); + for matched in matches { + let value = PyDict::new(py); + value.set_item("text", matched.text)?; + let start = PyDict::new(py); + start.set_item("row", matched.start.row)?; + start.set_item("column", matched.start.column)?; + value.set_item("start", start)?; + let end = PyDict::new(py); + end.set_item("row", matched.end.row)?; + end.set_item("column", matched.end.column)?; + value.set_item("end", end)?; + let spans = PyList::empty(py); + for span in matched.spans { + let item = PyDict::new(py); + item.set_item("row", span.row)?; + item.set_item("start", span.start)?; + item.set_item("end", span.end)?; + spans.append(item)?; + } + value.set_item("spans", spans)?; + values.append(value)?; + } + Ok(values.into_any().unbind()) +} + fn cursor_to_py(py: Python<'_>, cursor: Cursor) -> PyResult> { Ok(cursor_dict(py, cursor)?.into_any().unbind()) } diff --git a/bindings/python/src/tui_test/__init__.py b/bindings/python/src/tui_test/__init__.py index da857390..3e6104e8 100644 --- a/bindings/python/src/tui_test/__init__.py +++ b/bindings/python/src/tui_test/__init__.py @@ -11,7 +11,22 @@ TerminalArtifact, UsageError, ) -from .types import Backend, BellEvent, Cell, Colors, Profile, RecordingFormat, State, Timeouts +from .types import ( + Backend, + BellEvent, + Cell, + Colors, + Profile, + RecordingFormat, + State, + TextAnchor, + TextMatch, + TextOccurrence, + TextPosition, + TextSpan, + TextStyle, + Timeouts, +) __all__ = [ "TuiTest", @@ -32,6 +47,12 @@ "Profile", "RecordingFormat", "State", + "TextAnchor", + "TextMatch", + "TextOccurrence", + "TextPosition", + "TextSpan", + "TextStyle", "Timeouts", "__version__", ] diff --git a/bindings/python/src/tui_test/_native.pyi b/bindings/python/src/tui_test/_native.pyi index 528dcdff..f990eba0 100644 --- a/bindings/python/src/tui_test/_native.pyi +++ b/bindings/python/src/tui_test/_native.pyi @@ -43,6 +43,7 @@ class NativeSession: def close(self) -> typing.Awaitable[None]: ... def state(self) -> typing.Awaitable[typing.Dict[str, typing.Any]]: ... def text(self, full: bool) -> typing.Awaitable[str]: ... + def find_text(self, selector_json: str) -> typing.Awaitable[typing.List[typing.Dict[str, typing.Any]]]: ... def packed_screen(self, full: bool) -> typing.Awaitable[typing.Tuple[memoryview, int, int]]: r""" Return immutable UTF-8 logical rows plus cell dimensions. @@ -81,6 +82,7 @@ class NativeSession: def wait_ready(self, timeout_ms: typing.Optional[int]) -> typing.Awaitable[None]: ... def wait_bell(self, timeout_ms: typing.Optional[int]) -> typing.Awaitable[None]: ... def expect_text(self, text: str, regex: bool, full: bool, strict: bool, not_: bool, fg: typing.Optional[str], bg: typing.Optional[str], timeout_ms: typing.Optional[int]) -> typing.Awaitable[None]: ... + def expect_text_selector(self, request_json: str, timeout_ms: typing.Optional[int]) -> typing.Awaitable[None]: ... def expect_title(self, text: str, regex: bool, not_: bool, timeout_ms: typing.Optional[int]) -> typing.Awaitable[None]: ... def expect_exit_code(self, code: int, timeout_ms: typing.Optional[int]) -> typing.Awaitable[None]: ... def expect_output(self, text: str, regex: bool) -> typing.Awaitable[None]: ... diff --git a/bindings/python/src/tui_test/client.py b/bindings/python/src/tui_test/client.py index 3430b4e8..2fe00d11 100644 --- a/bindings/python/src/tui_test/client.py +++ b/bindings/python/src/tui_test/client.py @@ -1,8 +1,10 @@ from __future__ import annotations import atexit +import json import os import time +from dataclasses import asdict from typing import ( Any, Awaitable, @@ -27,7 +29,19 @@ TerminalArtifact, UsageError, ) -from .types import Backend, BellEvent, Cell, Profile, RecordingFormat, State, Timeouts +from .types import ( + Backend, + BellEvent, + Cell, + Profile, + RecordingFormat, + State, + TextAnchor, + TextMatch, + TextOccurrence, + TextStyle, + Timeouts, +) _TERMINAL_MARKER = "Terminal content:\n" _TIMEOUT_CLASSES = ("text", "idle", "command", "exit", "ready") @@ -79,6 +93,45 @@ def _profile_values( return normalized.get("scrollback"), list(colors.items()) +def _occurrence_value(value: TextOccurrence) -> object: + if isinstance(value, int) and not isinstance(value, bool): + return {"nth": value} + return value + + +def _anchor_value(anchor: Optional[TextAnchor]) -> Optional[Dict[str, object]]: + if anchor is None: + return None + return { + "text": anchor.text, + "regex": anchor.regex, + "occurrence": _occurrence_value(anchor.occurrence), + } + + +def _selector_value( + text: str, + *, + regex: bool, + full: bool, + whitespace: str, + after: Optional[TextAnchor], + before: Optional[TextAnchor], + occurrence: TextOccurrence, +) -> Dict[str, object]: + return { + "text": text, + "regex": regex, + "full": full, + "whitespace": whitespace, + "scope": { + "after": _anchor_value(after), + "before": _anchor_value(before), + }, + "occurrence": _occurrence_value(occurrence), + } + + def _extract_terminal_text(message: Optional[str]) -> Optional[str]: if not message: return None @@ -355,6 +408,31 @@ async def state(self) -> State: async def text(self, *, full: bool = False) -> str: return await self._await(self._native.text(full)) + async def find_text( + self, + text: str, + *, + regex: bool = False, + full: bool = False, + whitespace: str = "exact", + after: Optional[TextAnchor] = None, + before: Optional[TextAnchor] = None, + occurrence: TextOccurrence = "any", + ) -> List[TextMatch]: + selector = _selector_value( + text, + regex=regex, + full=full, + whitespace=whitespace, + after=after, + before=before, + occurrence=occurrence, + ) + values = await self._guarded( + "find_text", self._native.find_text(json.dumps(selector)) + ) + return [TextMatch.from_dict(value) for value in values] + async def _packed_screen( self, *, full: bool = False ) -> Tuple[memoryview, int, int]: @@ -512,21 +590,38 @@ async def expect_text( regex: bool = False, full: bool = False, strict: bool = True, + whitespace: str = "exact", + after: Optional[TextAnchor] = None, + before: Optional[TextAnchor] = None, + occurrence: Optional[TextOccurrence] = None, not_: bool = False, fg: Optional[str] = None, bg: Optional[str] = None, + style: Optional[TextStyle] = None, timeout: Optional[int] = None, ) -> None: + selector = _selector_value( + text, + regex=regex, + full=full, + whitespace=whitespace, + after=after, + before=before, + occurrence=( + occurrence + if occurrence is not None + else ("unique" if strict else "first") + ), + ) + style_value = asdict(style or TextStyle()) + if style_value["foreground"] is None: + style_value["foreground"] = fg + if style_value["background"] is None: + style_value["background"] = bg await self._guarded( "expect_text", - self._native.expect_text( - text, - regex, - full, - strict, - not_, - fg, - bg, + self._native.expect_text_selector( + json.dumps([selector, style_value, not_]), self._timeout("text", timeout), ), ) diff --git a/bindings/python/src/tui_test/types.py b/bindings/python/src/tui_test/types.py index 45ee3431..b1753208 100644 --- a/bindings/python/src/tui_test/types.py +++ b/bindings/python/src/tui_test/types.py @@ -1,13 +1,14 @@ from __future__ import annotations from dataclasses import dataclass -from typing import Any, Dict, Literal, Optional, Union +from typing import Any, Dict, List, Literal, Optional, Union Color = Union[str, int] Backend = Literal["alacritty", "ghostty"] #: ``"none"`` is a value, not an absence: an un-underlined cell reports it. UnderlineStyle = Literal["none", "single", "double", "curly", "dotted", "dashed"] RecordingFormat = Literal["apng", "gif", "mp4", "cast"] +TextOccurrence = Union[Literal["any", "unique", "first", "last"], int] @dataclass @@ -80,6 +81,58 @@ class BellEvent: elapsed_ms: int +@dataclass +class TextAnchor: + text: str + regex: bool = False + occurrence: TextOccurrence = "unique" + + +@dataclass +class TextStyle: + foreground: Optional[str] = None + background: Optional[str] = None + bold: Optional[bool] = None + dim: Optional[bool] = None + italic: Optional[bool] = None + underline_style: Optional[UnderlineStyle] = None + underline_color: Optional[str] = None + inverse: Optional[bool] = None + hidden: Optional[bool] = None + strikethrough: Optional[bool] = None + blink: Optional[bool] = None + + +@dataclass +class TextPosition: + row: int + column: int + + +@dataclass +class TextSpan: + row: int + start: int + end: int + + +@dataclass +class TextMatch: + text: str + start: TextPosition + end: TextPosition + spans: List[TextSpan] + + @classmethod + def from_dict(cls, value: Dict[str, Any]) -> "TextMatch": + return cls( + text=value["text"], + start=TextPosition(**value["start"]), + end=TextPosition(**value["end"]), + spans=[TextSpan(**span) for span in value["spans"]], + ) + + @dataclass class State: cols: int diff --git a/bindings/python/stub-gen/src/main.rs b/bindings/python/stub-gen/src/main.rs index cad10f9d..e18a87c9 100644 --- a/bindings/python/stub-gen/src/main.rs +++ b/bindings/python/stub-gen/src/main.rs @@ -133,6 +133,7 @@ mod stubs { def close(self) -> typing.Awaitable[None]: ... def state(self) -> typing.Awaitable[typing.Dict[str, typing.Any]]: ... def text(self, full: bool) -> typing.Awaitable[str]: ... + def find_text(self, selector_json: str) -> typing.Awaitable[typing.List[typing.Dict[str, typing.Any]]]: ... def packed_screen(self, full: bool) -> typing.Awaitable[typing.Tuple[memoryview, int, int]]: """Return immutable UTF-8 logical rows plus cell dimensions.""" def cells(self, x: int, y: int, w: int, h: int) -> typing.Awaitable[typing.List[typing.Dict[str, typing.Any]]]: ... @@ -199,6 +200,11 @@ mod stubs { bg: typing.Optional[str], timeout_ms: typing.Optional[int], ) -> typing.Awaitable[None]: ... + def expect_text_selector( + self, + request_json: str, + timeout_ms: typing.Optional[int], + ) -> typing.Awaitable[None]: ... def expect_title( self, text: str, diff --git a/bindings/python/tests/test_conformance.py b/bindings/python/tests/test_conformance.py index 95dd23f3..7b324e29 100644 --- a/bindings/python/tests/test_conformance.py +++ b/bindings/python/tests/test_conformance.py @@ -40,6 +40,7 @@ "kill": [("client", "kill")], "wait": [("client", "wait_title"), ("client", "wait_text"), ("client", "wait_idle"), ("client", "wait_command"), ("client", "wait_exit"), ("client", "wait_bell")], "expect": [("client", "expect_title"), ("client", "expect_text"), ("client", "expect_exit_code"), ("client", "expect_output"), ("client", "expect_bell_count"), ("client", "expect_snapshot")], + "find": [("client", "find_text")], "get-recording": [("module", "get_recording")], } diff --git a/bindings/python/tests/test_integration.py b/bindings/python/tests/test_integration.py index 78b0addb..4a14a76e 100644 --- a/bindings/python/tests/test_integration.py +++ b/bindings/python/tests/test_integration.py @@ -15,6 +15,8 @@ NoSessionError, Profile, TuiTest, + TextAnchor, + TextStyle, Timeouts, UsageError, get_recording, @@ -183,6 +185,35 @@ async def scenario(): run(scenario()) + def test_text_locators_scope_matches_and_assert_styles(self): + async def scenario(): + script = ( + "import sys,time; " + "sys.stdout.write('Settings\\n Save\\n\\x1b[1mWarning\\x1b[0m\\n'); " + "sys.stdout.flush(); time.sleep(30)" + ) + async with self._client() as su: + await su.run(sys.executable, "-c", script) + await su.wait_text("Warning", timeout=2000) + matches = await su.find_text( + "Save", + whitespace="normalize", + after=TextAnchor("Settings"), + ) + self.assertEqual(len(matches), 1) + self.assertEqual(matches[0].start.row, 1) + self.assertEqual(matches[0].start.column, 2) + await su.expect_text("Warning", style=TextStyle(bold=True)) + with self.assertRaises(ExpectationError) as raised: + await su.expect_text( + "Warning", + style=TextStyle(bold=False), + timeout=20, + ) + self.assertIn("expected bold=false", str(raised.exception)) + + run(scenario()) + def test_effective_timeouts_are_exposed_in_typed_state(self): async def scenario(): expected = Timeouts( diff --git a/bindings/python/tests/test_native_api.py b/bindings/python/tests/test_native_api.py index 0580940e..47ed1552 100644 --- a/bindings/python/tests/test_native_api.py +++ b/bindings/python/tests/test_native_api.py @@ -25,6 +25,7 @@ def test_native_session_has_only_typed_terminal_methods(self): "close", "state", "text", + "find_text", "packed_screen", "cells", "get_command", @@ -58,6 +59,7 @@ def test_native_session_has_only_typed_terminal_methods(self): "wait_ready", "wait_bell", "expect_text", + "expect_text_selector", "expect_exit_code", "expect_output", "expect_bell_count", diff --git a/bindings/python/tests/test_options.py b/bindings/python/tests/test_options.py index aaf1b8f9..958f2d88 100644 --- a/bindings/python/tests/test_options.py +++ b/bindings/python/tests/test_options.py @@ -1,4 +1,5 @@ import asyncio +import json import re import unittest @@ -6,7 +7,7 @@ from tui_test import _ephemeral as ephemeral from tui_test import client from tui_test.errors import ExpectationError, TerminalArtifact -from tui_test.types import Colors, Profile, Timeouts +from tui_test.types import Colors, Profile, TextAnchor, TextStyle, Timeouts def run(coro): @@ -370,6 +371,40 @@ def test_all_wait_and_expect_methods_prefix(self): ) +class TextLocatorTests(unittest.TestCase): + def test_selector_and_style_options_use_typed_native_methods(self): + terminal = _CapturingClient("s") + terminal.fake.reply = [] + run( + terminal.find_text( + "Save", + whitespace="normalize", + after=TextAnchor("Settings", occurrence="last"), + occurrence=1, + ) + ) + name, args = terminal.fake.calls[0] + self.assertEqual(name, "find_text") + selector = json.loads(args[0]) + self.assertEqual(selector["scope"]["after"]["text"], "Settings") + self.assertEqual(selector["occurrence"], {"nth": 1}) + + run( + terminal.expect_text( + "Warning", + occurrence="first", + style=TextStyle(bold=True, underline_style="curly"), + ) + ) + name, args = terminal.fake.calls[1] + self.assertEqual(name, "expect_text_selector") + selector, style, not_ = json.loads(args[0]) + self.assertEqual(selector["occurrence"], "first") + self.assertTrue(style["bold"]) + self.assertEqual(style["underline_style"], "curly") + self.assertFalse(not_) + + class ArtifactCaptureTests(unittest.TestCase): def test_text_mode_captures_terminal_text_only(self): terminal = _CapturingClient( diff --git a/crates/tui-test-cli/src/cli.rs b/crates/tui-test-cli/src/cli.rs index 61d34941..a60b5690 100644 --- a/crates/tui-test-cli/src/cli.rs +++ b/crates/tui-test-cli/src/cli.rs @@ -338,6 +338,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 for playback in the asciicast ecosystem. @@ -708,6 +713,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 = @@ -922,6 +980,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). @@ -1003,28 +1168,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 65a0a2cc..f749a5cd 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, KeyCmd, MouseCmd, RecordCmd, WaitCmd}; +use cli::{ + Cli, Command, DaemonCmd, ExpectCmd, FindCmd, GetArg, KeyCmd, MatchArg, MouseCmd, RecordCmd, + 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"); @@ -267,6 +271,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"), }; @@ -392,25 +397,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 { @@ -1008,7 +1096,8 @@ SESSION open [--shell S] [--cols N --rows N] [--cwd D] [--env K=V]\n\ run [--config F] [--profile P] [--restart] [args...]\n\ sessions | close [--all] | daemon start|status | daemon stop --session N|--all\n\ INSPECT state | text [--full] | screenshot [-o file.svg] [--full] [--zoom N]\n\ - cells X Y [W H] | get command|output|exit-code|cwd|cursor|size|title|bells|bell-events\n\ + find text \"T\" [selector options] | cells X Y [W H]\n\ + get command|output|exit-code|cwd|cursor|size|title|bells|bell-events\n\ INPUT type \"text\" | submit [\"text\"]\n\ key press|down|repeat|up \n\ mouse click X Y | mouse click --on-text \"OK\" | mouse move|down|up|drag|scroll\n\ @@ -1016,7 +1105,7 @@ 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 | wait bell\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] | expect bell N\n\ expect snapshot NAME [-u] [--include-colors --include-title]\n\ @@ -1104,6 +1193,55 @@ mod tests { assert!(error.to_string().contains("must not be empty")); } + #[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_identifies_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 204e1688..d77cdeb6 100644 --- a/crates/tui-test-cli/src/protocol.rs +++ b/crates/tui-test-cli/src/protocol.rs @@ -3,7 +3,7 @@ use serde_json::json; use tui_test::{ Backend, Engine, KeyAction, OpenOptions, Operation, OperationResult, RecordingFormat, - RunOptions, ScreenshotResult, TuiTestError, + RunOptions, ScreenshotResult, TextSelector, TextStyle, TuiTestError, }; pub use tui_test::{ErrorKind, MouseAction, Timeouts}; @@ -119,6 +119,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, @@ -283,6 +294,7 @@ impl Request { Request::WaitExit { timeout_ms } => Ok(Operation::WaitExit { timeout_ms }), Request::WaitReady { timeout_ms } => Ok(Operation::WaitReady { timeout_ms }), Request::WaitBell { timeout_ms } => Ok(Operation::WaitBell { timeout_ms }), + Request::FindText { selector } => Ok(Operation::FindText { selector }), Request::ExpectText { text, regex, @@ -302,6 +314,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, @@ -440,6 +463,7 @@ fn operation_data(result: OperationResult) -> Result, "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 })), diff --git a/crates/tui-test/src/api.rs b/crates/tui-test/src/api.rs index 7b35e5b0..049154ee 100644 --- a/crates/tui-test/src/api.rs +++ b/crates/tui-test/src/api.rs @@ -91,6 +91,83 @@ pub enum KeyAction { Up, } +#[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, + pub before: Option, +} + +#[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) -> Self { + Self { + text: text.into(), + ..Self::default() + } + } +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct TextStyle { + pub foreground: Option, + pub background: Option, + pub bold: Option, + pub dim: Option, + pub italic: Option, + pub underline_style: Option, + pub underline_color: Option, + pub inverse: Option, + pub hidden: Option, + pub strikethrough: Option, + pub blink: Option, +} + +impl TextStyle { + pub fn is_empty(&self) -> bool { + self == &Self::default() + } +} + #[derive(Debug, Clone)] pub enum Operation { Open(OpenOptions), @@ -166,6 +243,9 @@ pub enum Operation { WaitBell { timeout_ms: Option, }, + FindText { + selector: TextSelector, + }, ExpectText { text: String, regex: bool, @@ -176,6 +256,12 @@ pub enum Operation { bg: Option, timeout_ms: Option, }, + ExpectTextSelector { + selector: TextSelector, + not: bool, + style: TextStyle, + timeout_ms: Option, + }, ExpectTitle { text: String, regex: bool, @@ -225,6 +311,7 @@ pub enum OperationResult { Text(String), PackedScreen(PackedScreen), Cells(Vec), + Matches(Vec), Command(Option), Output(Option), ExitCode(Option), @@ -336,6 +423,29 @@ pub struct BellEvent { pub elapsed_ms: u64, } +#[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, +} + #[derive(Debug, Clone, Copy, Serialize)] pub struct EffectiveTimeouts { pub text: u64, diff --git a/crates/tui-test/src/engine.rs b/crates/tui-test/src/engine.rs index fd8d218c..961d76fe 100644 --- a/crates/tui-test/src/engine.rs +++ b/crates/tui-test/src/engine.rs @@ -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, TextStyle, TuiTestError, }; use crate::assert::color::{self, Expected}; use crate::assert::snapshot::{self, SnapshotStatus}; @@ -655,6 +655,9 @@ fn dispatch( )?; Ok(OperationResult::Unit) } + Operation::FindText { selector } => { + Ok(OperationResult::Matches(find_text(session, &selector)?)) + } Operation::ExpectText { text, regex, @@ -678,6 +681,21 @@ fn dispatch( )?; Ok(OperationResult::Unit) } + Operation::ExpectTextSelector { + selector, + not, + style, + timeout_ms, + } => { + expect_selected_text( + session, + &selector, + not, + &style, + timeout_ms.unwrap_or_else(|| session.timeout_for(config::TimeoutClass::Text)), + )?; + Ok(OperationResult::Unit) + } Operation::ExpectTitle { text, regex, @@ -1334,6 +1352,221 @@ fn expect_text( } } +fn find_text( + session: &TerminalSession, + selector: &TextSelector, +) -> Result, 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 validate_style(style: &TextStyle) -> Result<(), TuiTestError> { + for spec in [&style.foreground, &style.background, &style.underline_color] + .into_iter() + .flatten() + { + Expected::parse(spec).map_err(|error| TuiTestError::usage(error.to_string()))?; + } + if let Some(style) = &style.underline_style { + if !matches!( + style.as_str(), + "none" | "single" | "double" | "curly" | "dotted" | "dashed" + ) { + return Err(TuiTestError::usage(format!( + "invalid underline style '{style}'" + ))); + } + } + Ok(()) +} + +fn expect_selected_text( + session: &TerminalSession, + selector: &TextSelector, + not: bool, + style: &TextStyle, + timeout_ms: u64, +) -> Result<(), TuiTestError> { + validate_selector(selector)?; + validate_style(style)?; + let mut last_error = None; + let mut matched = false; + poll_until( + || { + match locator::locate(&grid(session, selector.full), selector) { + Ok(candidates) if not => { + let unexpected = if style.is_empty() { + candidates.first() + } else { + candidates + .iter() + .find(|candidate| check_style(session, candidate, style).is_none()) + }; + matched = unexpected.is_none(); + if let Some(candidate) = unexpected { + last_error = Some(format!( + "unexpected '{}' match at row {}, column {}", + selector.text, candidate.value.start.row, candidate.value.start.column + )); + } + } + Ok(candidates) => { + last_error = None; + matched = candidates.iter().any(|candidate| { + if let Some(error) = check_style(session, candidate, style) { + if last_error.is_none() { + last_error = Some(error); + } + false + } else { + true + } + }); + } + Err(error) => { + matched = false; + last_error = Some(error.to_string()); + } + } + matched || session_stopped(session) + }, + timeout_ms, + ); + if matched { + Ok(()) + } else if let Some(error) = last_error { + Err(TuiTestError::assertion(error)) + } else if session_stopped(session) { + Err(TuiTestError::assertion(format!( + "session exited before '{}' matched", + selector.text + ))) + } else { + Err(TuiTestError::assertion(timeout_message( + &selector.text, + timeout_ms, + not, + ))) + } +} + +fn check_style( + session: &TerminalSession, + matched: &locator::LocatedMatch, + style: &TextStyle, +) -> Option { + let state = session + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + check_style_with_emulator(matched, style, state.emu.as_ref()) +} + +fn check_style_with_emulator( + matched: &locator::LocatedMatch, + style: &TextStyle, + colors: &dyn Emulator, +) -> Option { + let visible: Vec<_> = matched + .cells + .iter() + .filter(|cell| !cell.cell.ch.is_empty() && !cell.cell.ch.chars().all(char::is_whitespace)) + .collect(); + let cells: Vec<_> = if visible.is_empty() { + matched.cells.iter().collect() + } else { + visible + }; + let fail = |cell: &locator::MatchedCell, expected: &str, actual: String| { + format!( + "'{}' matched at row {}, column {}, but expected {expected}; found {actual} at row {}, column {}", + matched.value.text, + matched.value.start.row, + matched.value.start.column, + cell.y, + cell.x + ) + }; + for cell in cells { + for (name, expected, actual) in [ + ("bold", style.bold, cell.cell.has(Attrs::BOLD)), + ("dim", style.dim, cell.cell.has(Attrs::DIM)), + ("italic", style.italic, cell.cell.has(Attrs::ITALIC)), + ("inverse", style.inverse, cell.cell.has(Attrs::INVERSE)), + ("hidden", style.hidden, cell.cell.has(Attrs::INVISIBLE)), + ( + "strikethrough", + style.strikethrough, + cell.cell.has(Attrs::STRIKE), + ), + ("blink", style.blink, cell.cell.has(Attrs::BLINK)), + ] { + if let Some(expected) = expected { + if expected != actual { + return Some(fail( + cell, + &format!("{name}={expected}"), + actual.to_string(), + )); + } + } + } + if let Some(expected) = &style.underline_style { + let actual = cell.cell.underline.name(); + if expected != actual { + return Some(fail( + cell, + &format!("underline_style={expected}"), + actual.to_string(), + )); + } + } + for (name, spec, actual, foreground) in [ + ("foreground", &style.foreground, cell.cell.fg, true), + ("background", &style.background, cell.cell.bg, false), + ( + "underline_color", + &style.underline_color, + cell.cell.underline_color, + true, + ), + ] { + if let Some(spec) = spec { + let expected = Expected::parse(spec).ok()?; + if !color::matches(actual, &expected, colors, foreground) { + return Some(fail( + cell, + &format!("{name}={}", expected.describe()), + color::describe_cell(actual, &expected, colors, foreground), + )); + } + } + } + } + None +} + fn check_colors( cells: &[locator::MatchedCell], fg: &Option, @@ -1606,6 +1839,7 @@ fn panic_message(payload: &(dyn std::any::Any + Send)) -> &str { #[cfg(test)] mod tests { use super::*; + use crate::api::{TextPosition, TextSpan}; use crate::profile::Profile; use crate::terminal::alacritty::AlacrittyEmu; use crate::terminal::cell::{NamedColor, UnderlineStyle}; @@ -1687,6 +1921,49 @@ mod tests { assert_eq!(value.underline_color, CellColor::Default); } + #[test] + fn styled_text_failures_report_the_match_and_cell_location() { + let emu = AlacrittyEmu::new(10, 2, &Profile::default()); + let cell = EmuCell { + ch: "x".into(), + attrs: Attrs::BOLD, + ..EmuCell::blank() + }; + let matched = locator::LocatedMatch { + value: TextMatch { + text: "x".into(), + start: TextPosition { row: 1, column: 3 }, + end: TextPosition { row: 1, column: 4 }, + spans: vec![TextSpan { + row: 1, + start: 3, + end: 4, + }], + }, + cells: vec![locator::MatchedCell { x: 3, y: 1, cell }], + }; + assert!(check_style_with_emulator( + &matched, + &TextStyle { + bold: Some(true), + ..TextStyle::default() + }, + &emu + ) + .is_none()); + let error = check_style_with_emulator( + &matched, + &TextStyle { + bold: Some(false), + ..TextStyle::default() + }, + &emu, + ) + .unwrap(); + assert!(error.contains("row 1, column 3")); + assert!(error.contains("expected bold=false")); + } + #[test] fn panic_payloads_become_internal_errors() { let error = std::panic::catch_unwind(|| panic!("ffi-panic")) diff --git a/crates/tui-test/src/terminal/locator.rs b/crates/tui-test/src/terminal/locator.rs index b725c726..1727ac82 100644 --- a/crates/tui-test/src/terminal/locator.rs +++ b/crates/tui-test/src/terminal/locator.rs @@ -1,8 +1,12 @@ -//! Text/regex search over the terminal grid. Maps a flat match range back to -//! grid cells. +//! Text/regex search over the terminal grid, including scoped and normalized +//! selectors. Match offsets are mapped back to terminal cells. use regex::Regex; +use crate::api::{ + MatchOccurrence, TextAnchor, TextMatch, TextPosition, TextSelector, TextSpan, WhitespaceMode, +}; + use super::cell::EmuCell; pub enum Pattern { @@ -21,20 +25,35 @@ impl Pattern { pub fn describe(&self) -> String { match self { - Pattern::Text(t) => t.clone(), - Pattern::Regex(r) => r.as_str().to_string(), + Pattern::Text(text) => text.clone(), + Pattern::Regex(regex) => regex.as_str().to_string(), } } - /// Whether the pattern matches somewhere in `haystack`. - /// - /// For matching against the grid use [`find`], which maps the hit back to - /// cells. This is for the plain strings the terminal reports alongside the - /// grid, such as the window title. pub fn matches(&self, haystack: &str) -> bool { match self { - Pattern::Text(t) => haystack.contains(t.as_str()), - Pattern::Regex(r) => r.is_match(haystack), + Pattern::Text(text) => haystack.contains(text.as_str()), + Pattern::Regex(regex) => regex.is_match(haystack), + } + } + + fn ranges(&self, chars: &[char]) -> Vec<(usize, usize)> { + match self { + Pattern::Text(text) => { + let needle: Vec = text.chars().collect(); + text_ranges(chars, &needle) + } + Pattern::Regex(regex) => { + let block: String = chars.iter().collect(); + regex + .find_iter(&block) + .filter(|matched| !matched.is_empty()) + .map(|matched| { + let start = block[..matched.start()].chars().count(); + (start, start + matched.as_str().chars().count()) + }) + .collect() + } } } } @@ -46,8 +65,41 @@ pub struct MatchedCell { pub cell: EmuCell, } -/// Find the first match of `pattern` in the grid. Returns `Ok(None)` when there -/// is no match, and `Err` on a strict-mode violation (multiple matches). +#[derive(Debug, Clone)] +pub struct LocatedMatch { + pub value: TextMatch, + pub cells: Vec, +} + +struct FlatGrid { + chars: Vec, + sources: Vec, + width: usize, +} + +/// Locate the matches selected by `selector`. +pub fn locate(rows: &[Vec], selector: &TextSelector) -> anyhow::Result> { + if rows.is_empty() { + return Ok(Vec::new()); + } + let flat = flatten(rows, selector.whitespace); + let Some((start, end)) = scope(&flat, selector)? else { + return Ok(Vec::new()); + }; + let pattern = selector_pattern(&selector.text, selector.regex, selector.whitespace)?; + let ranges: Vec<_> = pattern + .ranges(&flat.chars) + .into_iter() + .filter(|(match_start, match_end)| *match_start >= start && *match_end <= end) + .collect(); + let selected = select(ranges, &selector.occurrence, &pattern.describe())?; + Ok(selected + .into_iter() + .filter_map(|range| materialize(rows, &flat, range)) + .collect()) +} + +/// Compatibility helper for the simple text waits and mouse text lookup. pub fn find( rows: &[Vec], pattern: &Pattern, @@ -56,95 +108,265 @@ pub fn find( if rows.is_empty() { return Ok(None); } - let width = rows.iter().map(|r| r.len()).max().unwrap_or(0); - // One char per *column*, so match offsets map straight back to (x, y). - // A continuation cell holds no grapheme but still occupies its column, so - // it gets a filler here rather than being skipped as in `rows_to_strings`. - let chars: Vec = rows - .iter() - .flat_map(|row| { - (0..width).map(move |x| row.get(x).and_then(|c| c.ch.chars().next()).unwrap_or(' ')) - }) - .collect(); + let flat = flatten(rows, WhitespaceMode::Exact); + let occurrence = if strict { + MatchOccurrence::Unique + } else { + MatchOccurrence::First + }; + let selected = select( + pattern.ranges(&flat.chars), + &occurrence, + &pattern.describe(), + )?; + Ok(selected + .into_iter() + .next() + .and_then(|range| materialize(rows, &flat, range)) + .map(|matched| matched.cells)) +} - let (index, length) = match pattern { - Pattern::Text(text) => { - let needle: Vec = text.chars().collect(); - if needle.is_empty() { - return Ok(None); - } - let occurrences = count_occurrences(&chars, &needle); - if occurrences == 0 { - return Ok(None); - } - if occurrences > 1 && strict { - anyhow::bail!( - "strict mode expected one match for '{}', but found {}", - text, - occurrences - ); +fn selector_pattern( + text: &str, + regex: bool, + whitespace: WhitespaceMode, +) -> anyhow::Result { + let text = if !regex && whitespace == WhitespaceMode::Normalize { + normalize(text) + } else { + text.to_string() + }; + Pattern::new(&text, regex) +} + +fn flatten(rows: &[Vec], whitespace: WhitespaceMode) -> FlatGrid { + let width = rows.iter().map(Vec::len).max().unwrap_or(0); + let source = rows.iter().enumerate().flat_map(|(y, row)| { + (0..width).map(move |x| { + ( + x + y * width, + row.get(x) + .and_then(|cell| cell.ch.chars().next()) + .unwrap_or(' '), + ) + }) + }); + let mut chars = Vec::new(); + let mut sources = Vec::new(); + let mut pending_space = None; + for (position, ch) in source { + if whitespace == WhitespaceMode::Normalize && ch.is_whitespace() { + if !chars.is_empty() && pending_space.is_none() { + pending_space = Some(position); } - let first = first_occurrence(&chars, &needle).unwrap(); - (first, needle.len()) + continue; } - Pattern::Regex(re) => { - let block: String = chars.iter().collect(); - let matches: Vec<_> = re.find_iter(&block).collect(); - if matches.is_empty() { - return Ok(None); - } - if matches.len() > 1 && strict { - anyhow::bail!( - "strict mode expected one match for '{}', but found {}", - re.as_str(), - matches.len() - ); - } - let m = &matches[0]; - let start = block[..m.start()].chars().count(); - let len = m.as_str().chars().count(); - (start, len) + if let Some(position) = pending_space.take() { + chars.push(' '); + sources.push(position); } + chars.push(ch); + sources.push(position); + } + FlatGrid { + chars, + sources, + width, + } +} + +fn normalize(text: &str) -> String { + text.split_whitespace().collect::>().join(" ") +} + +fn scope(flat: &FlatGrid, selector: &TextSelector) -> anyhow::Result> { + let start = match &selector.scope.after { + Some(anchor) => match anchor_range(flat, anchor, selector.whitespace, "after")? { + Some((_, end)) => end, + None => return Ok(None), + }, + None => 0, + }; + let end = match &selector.scope.before { + Some(anchor) => match anchor_range(flat, anchor, selector.whitespace, "before")? { + Some((start, _)) => start, + None => return Ok(None), + }, + None => flat.chars.len(), }; + Ok((start <= end).then_some((start, end))) +} + +fn anchor_range( + flat: &FlatGrid, + anchor: &TextAnchor, + whitespace: WhitespaceMode, + name: &str, +) -> anyhow::Result> { + let pattern = selector_pattern(&anchor.text, anchor.regex, whitespace)?; + let ranges = select( + pattern.ranges(&flat.chars), + &anchor.occurrence, + &format!("{name} anchor '{}'", pattern.describe()), + )?; + if ranges.len() > 1 { + anyhow::bail!("{name} anchor must select one match"); + } + Ok(ranges.into_iter().next()) +} + +fn select( + ranges: Vec<(usize, usize)>, + occurrence: &MatchOccurrence, + description: &str, +) -> anyhow::Result> { + let count = ranges.len(); + match occurrence { + MatchOccurrence::Any => Ok(ranges), + MatchOccurrence::Unique if count > 1 => anyhow::bail!( + "unique match expected one occurrence of '{description}', but found {count}" + ), + MatchOccurrence::Unique | MatchOccurrence::First => { + Ok(ranges.into_iter().next().into_iter().collect()) + } + MatchOccurrence::Last => Ok(ranges.into_iter().last().into_iter().collect()), + MatchOccurrence::Nth(index) => Ok(ranges.into_iter().nth(*index).into_iter().collect()), + } +} - let mut cells = Vec::with_capacity(length); - for (y, row) in rows.iter().enumerate() { - for x in 0..width { - let pos = x + y * width; - if pos >= index && pos < index + length { - if let Some(cell) = row.get(x) { - cells.push(MatchedCell { - x, - y, - cell: cell.clone(), - }); - } +fn materialize( + rows: &[Vec], + flat: &FlatGrid, + (start, end): (usize, usize), +) -> Option { + if start >= end { + return None; + } + let source_start = *flat.sources.get(start)?; + let source_end = flat.sources.get(end - 1)?.saturating_add(1); + let mut cells = Vec::new(); + for position in source_start..source_end { + let y = position / flat.width; + let x = position % flat.width; + if let Some(cell) = rows.get(y).and_then(|row| row.get(x)) { + cells.push(MatchedCell { + x, + y, + cell: cell.clone(), + }); + } + } + let first = cells.first()?; + let last = cells.last()?; + let mut spans = Vec::new(); + for cell in &cells { + match spans.last_mut() { + Some(TextSpan { row, end, .. }) + if *row as usize == cell.y && *end as usize == cell.x => + { + *end = end.saturating_add(1); } + _ => spans.push(TextSpan { + row: cell.y.min(u16::MAX as usize) as u16, + start: cell.x.min(u16::MAX as usize) as u16, + end: cell.x.saturating_add(1).min(u16::MAX as usize) as u16, + }), } } - Ok(Some(cells)) + Some(LocatedMatch { + value: TextMatch { + text: flat.chars[start..end].iter().collect(), + start: TextPosition { + row: first.y.min(u16::MAX as usize) as u16, + column: first.x.min(u16::MAX as usize) as u16, + }, + end: TextPosition { + row: last.y.min(u16::MAX as usize) as u16, + column: last.x.saturating_add(1).min(u16::MAX as usize) as u16, + }, + spans, + }, + cells, + }) } -fn count_occurrences(haystack: &[char], needle: &[char]) -> usize { +fn text_ranges(haystack: &[char], needle: &[char]) -> Vec<(usize, usize)> { if needle.is_empty() || haystack.len() < needle.len() { - return 0; - } - let mut count = 0; - let mut i = 0; - while i + needle.len() <= haystack.len() { - if haystack[i..i + needle.len()] == *needle { - count += 1; - i += needle.len(); + return Vec::new(); + } + let mut ranges = Vec::new(); + let mut index = 0; + while index + needle.len() <= haystack.len() { + if haystack[index..index + needle.len()] == *needle { + ranges.push((index, index + needle.len())); + index += needle.len(); } else { - i += 1; + index += 1; } } - count + ranges } -fn first_occurrence(haystack: &[char], needle: &[char]) -> Option { - if needle.is_empty() || haystack.len() < needle.len() { - return None; +#[cfg(test)] +mod tests { + use super::*; + use crate::api::{TextScope, WhitespaceMode}; + + fn grid(lines: &[&str]) -> Vec> { + lines + .iter() + .map(|line| { + line.chars() + .map(|ch| EmuCell { + ch: ch.to_string().into(), + ..EmuCell::blank() + }) + .collect() + }) + .collect() + } + + #[test] + fn normalizes_whitespace_and_preserves_locations() { + let mut selector = TextSelector::new("hello world"); + selector.whitespace = WhitespaceMode::Normalize; + let found = locate(&grid(&[" hello", " world "]), &selector).unwrap(); + assert_eq!(found[0].value.text, "hello world"); + assert_eq!(found[0].value.start, TextPosition { row: 0, column: 2 }); + assert_eq!(found[0].value.end, TextPosition { row: 1, column: 9 }); + } + + #[test] + fn scopes_a_match_after_an_anchor() { + let mut selector = TextSelector::new("Save"); + selector.occurrence = MatchOccurrence::First; + selector.scope = TextScope { + after: Some(TextAnchor { + text: "Settings".into(), + regex: false, + occurrence: MatchOccurrence::Unique, + }), + before: None, + }; + let found = locate(&grid(&["Save", "Settings", "Save"]), &selector).unwrap(); + assert_eq!(found[0].value.start, TextPosition { row: 2, column: 0 }); + } + + #[test] + fn selects_any_last_and_nth_occurrences() { + let rows = grid(&["item item item"]); + let mut selector = TextSelector::new("item"); + selector.occurrence = MatchOccurrence::Any; + assert_eq!(locate(&rows, &selector).unwrap().len(), 3); + selector.occurrence = MatchOccurrence::Last; + assert_eq!(locate(&rows, &selector).unwrap()[0].value.start.column, 10); + selector.occurrence = MatchOccurrence::Nth(1); + assert_eq!(locate(&rows, &selector).unwrap()[0].value.start.column, 5); + } + + #[test] + fn unique_reports_ambiguous_text() { + let error = locate(&grid(&["same same"]), &TextSelector::new("same")).unwrap_err(); + assert!(error.to_string().contains("found 2")); } - (0..=haystack.len() - needle.len()).find(|&i| haystack[i..i + needle.len()] == *needle) }