From 7dc33af3047430996ebdeb4e123f81ff9fe24d44 Mon Sep 17 00:00:00 2001 From: cpendery Date: Sat, 15 Aug 2026 20:37:47 -0700 Subject: [PATCH] feat: add text locator js api Signed-off-by: cpendery --- bindings/js/README.md | 7 +- bindings/js/native/index.d.ts | 55 ++++++ bindings/js/native/lib.rs | 259 ++++++++++++++++++++++++-- bindings/js/src/client.ts | 96 +++++++++- bindings/js/src/index.ts | 5 + bindings/js/src/native.ts | 8 + bindings/js/src/types.ts | 3 + bindings/js/test/conformance.test.mjs | 1 + bindings/js/test/integration.test.mjs | 27 +++ bindings/js/test/native.test.mjs | 2 + 10 files changed, 437 insertions(+), 26 deletions(-) diff --git a/bindings/js/README.md b/bindings/js/README.md index a589b71b..f96aab80 100644 --- a/bindings/js/README.md +++ b/bindings/js/README.md @@ -49,7 +49,12 @@ All derive from `TuiTestError` and carry `kind` and `exitCode`. `waitX` and `exp ## API -`new TuiTest(session?, { profile?, timeouts?, artifacts? })` mirrors the cli: `open` / `run`, `type` / `write`, `submit`, `press` / `keys`, `mouse.click|move|down|up|drag|scroll`, `resize`, `signal` / `kill`, `state`, `text`, `cells`, `getCommand` / `getOutput` / `getExitCode` / `getCwd` / `getCursor` / `getSize` / `getTitle`, `screenshot`, `waitText` / `waitTitle` / `waitIdle` / `waitCommand` / `waitExit` / `waitReady`, `expectText` / `expectTitle` / `expectExitCode` / `expectOutput` / `expectSnapshot`, `close`, and `closeQuiet`. +`new TuiTest(session?, { profile?, timeouts?, artifacts? })` mirrors the cli: `open` / `run`, `type` / `write`, `submit`, `press` / `keys`, `mouse.click|move|down|up|drag|scroll`, `resize`, `signal` / `kill`, `state`, `text`, `findText`, `cells`, `getCommand` / `getOutput` / `getExitCode` / `getCwd` / `getCursor` / `getSize` / `getTitle`, `screenshot`, `waitText` / `waitTitle` / `waitIdle` / `waitCommand` / `waitExit` / `waitReady`, `expectText` / `expectTitle` / `expectExitCode` / `expectOutput` / `expectSnapshot`, `close`, and `closeQuiet`. + +`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()`. diff --git a/bindings/js/native/index.d.ts b/bindings/js/native/index.d.ts index 3501a82f..21404289 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 @@ -86,9 +87,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 } @@ -202,6 +223,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 3693b045..6c1ba701 100644 --- a/bindings/js/native/lib.rs +++ b/bindings/js/native/lib.rs @@ -10,11 +10,13 @@ use tui_test::profile::{Profile as CoreProfile, Rgb}; use tui_test::shell::Shell as CoreShell; use tui_test::{ global_registry, Cell as CoreCell, CellColor, Cursor as CoreCursor, - EffectiveTimeouts as CoreEffectiveTimeouts, ErrorKind, MouseAction, - OpenOptions as CoreOpenOptions, OpenResult as CoreOpenResult, Operation, OperationResult, - RunOptions as CoreRunOptions, ScreenshotResult as CoreScreenshotResult, SessionHandle, - Size as CoreSize, SnapshotResult as CoreSnapshotResult, State as CoreState, - Timeouts as CoreTimeouts, TuiTestError, + EffectiveTimeouts as CoreEffectiveTimeouts, ErrorKind, MatchOccurrence as CoreMatchOccurrence, + MouseAction, OpenOptions as CoreOpenOptions, OpenResult as CoreOpenResult, Operation, + OperationResult, 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__:"; @@ -274,6 +276,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. /// @@ -316,17 +364,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, @@ -426,6 +513,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 @@ -636,6 +811,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( @@ -1040,25 +1235,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 0b8f9dea..9437be25 100644 --- a/bindings/js/src/client.ts +++ b/bindings/js/src/client.ts @@ -15,6 +15,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 { Cell, ClientOptions, @@ -24,6 +25,7 @@ import type { Size, SpawnOptions, State, + TextMatch, } from "./types.js"; export interface WaitTextOptions { @@ -39,13 +41,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; } @@ -82,6 +121,36 @@ function optional(value: T | null | undefined): T | undefined { return value ?? undefined; } +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; @@ -359,6 +428,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)), @@ -394,14 +471,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 7e6b5c53..e2452385 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, WaitTextOptions, } from "./client.js"; @@ -30,6 +34,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 89e94c6d..afc12d5a 100644 --- a/bindings/js/src/native.ts +++ b/bindings/js/src/native.ts @@ -13,6 +13,8 @@ import type { Size, SnapshotOptions, State, + TextMatch, + TextSelectorOptions, Timeouts, TitleOptions, WaitTextOptions, @@ -162,6 +164,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 @@ -340,6 +346,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 cfdb38fc..e256d5b6 100644 --- a/bindings/js/src/types.ts +++ b/bindings/js/src/types.ts @@ -5,6 +5,7 @@ import type { OpenResult as NativeOpenResult, Size as NativeSize, State as NativeState, + TextMatch as NativeTextMatch, Timeouts as NativeTimeouts, } from "../native/index.js"; @@ -53,6 +54,8 @@ export type EffectiveTimeouts = NativeEffectiveTimeouts; 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 89d98b1f..befd6ed1 100644 --- a/bindings/js/test/conformance.test.mjs +++ b/bindings/js/test/conformance.test.mjs @@ -48,6 +48,7 @@ const MAPPING = { kill: [["client", "kill"]], wait: [["client", "waitTitle"], ["client", "waitText"], ["client", "waitIdle"], ["client", "waitCommand"], ["client", "waitExit"]], expect: [["client", "expectTitle"], ["client", "expectText"], ["client", "expectExitCode"], ["client", "expectOutput"], ["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 b031b194..f71a1361 100644 --- a/bindings/js/test/integration.test.mjs +++ b/bindings/js/test/integration.test.mjs @@ -266,6 +266,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 a830ddb6..58337dba 100644 --- a/bindings/js/test/native.test.mjs +++ b/bindings/js/test/native.test.mjs @@ -17,6 +17,7 @@ test("generated native declarations expose typed operations", async () => { "Timeouts", "Cell", "PackedScreen", + "TextMatch", ]) { assert.match(declarations, new RegExp(`export (?:interface|type) ${type}\\b`)); } @@ -26,6 +27,7 @@ test("generated native declarations expose typed operations", async () => { "close", "state", "text", + "findText", "cells", "getCommand", "write",