diff --git a/Cargo.lock b/Cargo.lock index cc769590..ab0592d0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1409,6 +1409,7 @@ dependencies = [ "anyhow", "pyo3", "pyo3-async-runtimes", + "serde_json", "tokio", "tui-test-rs", ] diff --git a/bindings/python/README.md b/bindings/python/README.md index dab478dc..ad736d21 100644 --- a/bindings/python/README.md +++ b/bindings/python/README.md @@ -54,7 +54,13 @@ All derive from `TuiTestError`. `wait_*` and `expect_*` raise `ExpectationError` ## API -`TuiTest(session="default", *, timeouts=None, profile=None, artifacts=None)` mirrors the cli: `open` / `run`, `type` / `write`, `submit`, `press` / `keys`, `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`, `screenshot`, `wait_text` / `wait_title` / `wait_idle` / `wait_command` / `wait_exit` / `wait_ready`, `expect_text` / `expect_title` / `expect_exit_code` / `expect_output` / `expect_snapshot`, `close`, and `close_quiet`. +`TuiTest(session="default", *, timeouts=None, profile=None, artifacts=None)` mirrors the cli: `open` / `run`, `type` / `write`, `submit`, `press` / `keys`, `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`, `screenshot`, `wait_text` / `wait_title` / `wait_idle` / `wait_command` / `wait_exit` / `wait_ready`, `expect_text` / `expect_title` / `expect_exit_code` / `expect_output` / `expect_snapshot`, `close`, and `close_quiet`. + +`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()`. diff --git a/bindings/python/native/Cargo.toml b/bindings/python/native/Cargo.toml index 75328c34..8242b416 100644 --- a/bindings/python/native/Cargo.toml +++ b/bindings/python/native/Cargo.toml @@ -18,6 +18,7 @@ anyhow.workspace = true tui-test.workspace = true 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 bce23500..3ad0c00e 100644 --- a/bindings/python/native/src/lib.rs +++ b/bindings/python/native/src/lib.rs @@ -10,7 +10,7 @@ use tui_test::shell::Shell; use tui_test::{ Cell, CellColor, Cursor, ErrorKind, MouseAction, OpenOptions, OpenResult, Operation, OperationResult, PackedScreen, RunOptions, ScreenshotResult, Size, SnapshotResult, State, - Timeouts, TuiTestError, + TextMatch, TextSelector, TextStyle, Timeouts, TuiTestError, }; pyo3::create_exception!( @@ -219,6 +219,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( @@ -807,6 +824,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, @@ -1187,6 +1233,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), @@ -1354,6 +1407,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 d0b32990..fa8223fb 100644 --- a/bindings/python/src/tui_test/__init__.py +++ b/bindings/python/src/tui_test/__init__.py @@ -11,7 +11,19 @@ TerminalArtifact, UsageError, ) -from .types import Cell, Colors, Profile, State, Timeouts +from .types import ( + Cell, + Colors, + Profile, + State, + TextAnchor, + TextMatch, + TextOccurrence, + TextPosition, + TextSpan, + TextStyle, + Timeouts, +) __all__ = [ "TuiTest", @@ -29,6 +41,12 @@ "Colors", "Profile", "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 1d3bc57d..bc9af4b9 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. @@ -76,6 +77,7 @@ class NativeSession: def wait_exit(self, timeout_ms: typing.Optional[int]) -> typing.Awaitable[None]: ... def wait_ready(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 5c55b953..67fad44f 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,16 @@ TerminalArtifact, UsageError, ) -from .types import Cell, Profile, State, Timeouts +from .types import ( + Cell, + Profile, + State, + TextAnchor, + TextMatch, + TextOccurrence, + TextStyle, + Timeouts, +) _TERMINAL_MARKER = "Terminal content:\n" _TIMEOUT_CLASSES = ("text", "idle", "command", "exit", "ready") @@ -79,6 +90,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 @@ -326,6 +376,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]: @@ -439,21 +514,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 fa2457e2..6295e377 100644 --- a/bindings/python/src/tui_test/types.py +++ b/bindings/python/src/tui_test/types.py @@ -1,11 +1,12 @@ 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] #: ``"none"`` is a value, not an absence: an un-underlined cell reports it. UnderlineStyle = Literal["none", "single", "double", "curly", "dotted", "dashed"] +TextOccurrence = Union[Literal["any", "unique", "first", "last"], int] @dataclass @@ -72,6 +73,58 @@ class Cell: underline_color: Color +@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 dbc27d07..8af7ded7 100644 --- a/bindings/python/stub-gen/src/main.rs +++ b/bindings/python/stub-gen/src/main.rs @@ -129,6 +129,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]]]: ... @@ -190,6 +191,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 075a3821..d419e448 100644 --- a/bindings/python/tests/test_conformance.py +++ b/bindings/python/tests/test_conformance.py @@ -37,6 +37,7 @@ "kill": [("client", "kill")], "wait": [("client", "wait_title"), ("client", "wait_text"), ("client", "wait_idle"), ("client", "wait_command"), ("client", "wait_exit")], "expect": [("client", "expect_title"), ("client", "expect_text"), ("client", "expect_exit_code"), ("client", "expect_output"), ("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 c60245fa..568c78be 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, @@ -58,6 +60,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 8dc6bac1..96107bc3 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", @@ -53,6 +54,7 @@ def test_native_session_has_only_typed_terminal_methods(self): "wait_exit", "wait_ready", "expect_text", + "expect_text_selector", "expect_exit_code", "expect_output", "snapshot", diff --git a/bindings/python/tests/test_options.py b/bindings/python/tests/test_options.py index 139fa109..03c7b38c 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): @@ -303,6 +304,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(