From e4300ecf7b580b4c980efbbb308f711881056edc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois?= Date: Sun, 14 Jun 2026 16:54:00 +0200 Subject: [PATCH] feat(console): list/regex read_until, serial error clarity; v0.3 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit read_until: - 'expected' now accepts a list of values (succeeds on any match). - new 'regex: true' flag: each pattern is a Python regex (re.search over a bounded tail, Console.REGEX_WINDOW). Reports which pattern matched. Serial console robustness & clarity: - failed open() raises a clear ETUMRuntimeError ("Serial device '…' does not exist." / permission hint) instead of a raw pyserial traceback. - a console whose open failed is safely "not open" (init _thd=None + isOpened guards in readchar/read_nowait/close) — no more cascading AttributeError: '_thd' on subsequent read steps. - action handlers: one-liner for expected (ETUMRuntimeError) errors, full traceback kept for unexpected ones. All console errors use testium exceptions (ETUMRuntimeError). Flatpak: grant --device=all so serial adapters (/dev/ttyUSB*, /dev/ttyACM*) are visible in the sandbox. Validation: new read_until list/regex (match + no-match) cases in items/console/test.tum. Version: 0.3. Co-Authored-By: Claude Opus 4.8 --- DESIGN.md | 1 + package/flatpak/org.testium.Testium.yaml | 6 +- src/VERSION | 2 +- src/testium/api/console.py | 119 ++++++++++++++---- .../test_items/test_item_console.py | 48 +++++-- test/validation/items/console/test.tum | 40 ++++++ 6 files changed, 180 insertions(+), 36 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index ae8ec58..36d6f18 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -302,6 +302,7 @@ The `testium_assist` editor extension is a thin LSP client that spawns `testium Both Flatpak and AppImage export `TESTIUM_VERSION` from a launcher (Flatpak: launcher script in `org.testium.Testium.yaml`; AppImage: `runtime.env` in `AppImageBuilder.yml`). `get_testium_version()` checks `/.flatpak-info` / `APPIMAGE` and reads `TESTIUM_VERSION` rather than relying on package metadata or repo introspection. ## Recent fixes / notable changes +- `console` item — serial robustness + richer `read_until`: (1) a failed serial `open()` now raises a clear `ETUMRuntimeError` ("Serial device '…' does not exist." / permission hint) instead of dumping a pyserial traceback, and a console whose open failed is safely "not open" (init `_thd=None` + `isOpened` guards in `readchar`/`read_nowait`/`close`) so later reads no longer crash with `AttributeError: '_thd'`; the action handlers show a one-liner for expected (`ETUMRuntimeError`) errors and keep the full traceback for unexpected ones. (2) `read_until`'s `expected` now accepts a **list of values** (match any) and a new `regex: true` flag treats each pattern as a Python regex (`re.search` over a bounded tail — `Console.REGEX_WINDOW`; limitation: cost/memory bounded, so a match only after a very long stream or beyond the window won't fire). Flatpak manifest now grants `--device=all` so serial adapters (`/dev/ttyUSB*`, `/dev/ttyACM*`) are visible in the sandbox. Validation: new `read_until` list/regex cases in `test/validation/items/console/test.tum`. - Subprocess RPC startup handshake: the `py_func`/`lua_func`/`eval_proc` worker now picks its own port (`bind 0`), announces it on stdout (`__TESTIUM_RPC_PORT__=`), and the parent connects only after reading it. Fixes intermittent Windows `failed to connect : timeout` and the matching `wait_ready()` hang; removes the reserve/close/rebind race and `SO_REUSEADDR`. See "Subprocess RPC startup handshake". - `build_all.sh`: builds the four heavy channels in parallel (serial prep for the shared venv + wheel), results in completion order, Ctrl+C kills the whole job tree; `--ram` puts the build scratch on tmpfs (`/dev/shm`) + skips UPX for fast builds on USB/SD storage (Flatpak excluded — rofiles-fuse can't mount tmpfs). See the "Building all channels" section. - LSP across packaging channels: `testium lsp` (and the `testium_assist` editor extension that spawns it) now works from source, wheel, PyInstaller, Flatpak and AppImage. Two enablers — (1) action items declare a class-level `ACTIONS = {key: class}` registry (like `PARAMS`), so `lsp/schema.py` builds the full schema from class attributes with no `inspect.getsource`/AST (which broke under frozen PyInstaller); (2) the `[lsp]` extra (pygls) is wired into every full-app channel. `test/validation/lsp_check.py`, run by `run.sh` before the suite, asserts per-channel that `schema` keeps its actions and `lsp` answers `initialize`. See the matching architecture sections. diff --git a/package/flatpak/org.testium.Testium.yaml b/package/flatpak/org.testium.Testium.yaml index a59f036..927ec01 100644 --- a/package/flatpak/org.testium.Testium.yaml +++ b/package/flatpak/org.testium.Testium.yaml @@ -11,7 +11,11 @@ finish-args: - --share=ipc - --socket=fallback-x11 - --socket=wayland - - --device=dri + # Expose all host devices to the sandbox. testium is a hardware-in-the-loop + # test tool: the console item must reach serial adapters (/dev/ttyUSB*, + # /dev/ttyACM*, …) which are otherwise invisible in the sandbox. --device=all + # also covers the GPU (supersedes --device=dri). + - --device=all - --share=network - --filesystem=home - --filesystem=/tmp diff --git a/src/VERSION b/src/VERSION index 7179039..be58634 100644 --- a/src/VERSION +++ b/src/VERSION @@ -1 +1 @@ -0.2.3 +0.3 diff --git a/src/testium/api/console.py b/src/testium/api/console.py index cf72a41..28ca177 100755 --- a/src/testium/api/console.py +++ b/src/testium/api/console.py @@ -2,6 +2,7 @@ from datetime import datetime import sys import os import re +import errno from queue import Queue, Empty from time import sleep import collections @@ -10,6 +11,8 @@ import threading from telnetlib3 import Telnet, DO, WILL, WONT, TTYPE, IAC, SB, SE, theNULL +from runtime.tum_except import ETUMRuntimeError + TIMEOUT_NULL = 0.000001 STOP_POLL_INTERVAL = 0.2 @@ -124,7 +127,32 @@ A {classname}.close() is missing somewhere in your code !'.format(classname=type # c = '' return c - def read_until(self, match, timeout=None, return_data=False, mute=False, should_stop=None): + # Upper bound (in characters) of the accumulated buffer tail scanned in + # regex mode, so cost/memory stay bounded on long-running streams. + REGEX_WINDOW = 65536 + + def _feed_match(self, data, search_deques, match_deques, matches): + """Append *data* to every rolling window and return the first matched + pattern string, or None if none completed on this character.""" + matched = None + for sd, md, m in zip(search_deques, match_deques, matches): + sd.append(data) + if matched is None and sd == md: + matched = m + return matched + + def _search_regex(self, read_data, compiled): + """Search the (bounded) tail of *read_data* with each compiled regex; + return the matched text of the first hit, or None.""" + tail = read_data[-self.REGEX_WINDOW:] + for p in compiled: + m = p.search(tail) + if m is not None: + return m.group(0) + return None + + def read_until(self, match, timeout=None, return_data=False, mute=False, + should_stop=None, regex=False): """ read until the string 'match is found If timeout is not set (None), this function runs indefinitely @@ -141,15 +169,37 @@ A {classname}.close() is missing somewhere in your code !'.format(classname=type read_data = '' status = -1 if not match: - raise ValueError('match parameter can not be empty') + raise ETUMRuntimeError("'expected' pattern can not be empty") + + # match may be a single string or a list/tuple of strings: the read + # succeeds as soon as ANY of them is seen in the stream. + if isinstance(match, (list, tuple)): + matches = [str(m) for m in match] + else: + matches = [str(match)] + if (not matches) or any(len(m) == 0 for m in matches): + raise ETUMRuntimeError("'expected' pattern can not be empty") if timeout is None: timeout = 1000000 - # Fixed-length queue that will contain the readout characters - search_deque = collections.deque(maxlen=len(match)) - # convert match string into a deque for faster comparisons - match_deque = collections.deque(match) + compiled = None + search_deques = match_deques = None + if regex: + # 'matches' are regular expressions; succeed on the first hit. + compiled = [] + for m in matches: + try: + compiled.append(re.compile(m)) + except re.error as e: + raise ETUMRuntimeError( + "Invalid regular expression {!r}: {}".format(m, e)) from None + else: + # One fixed-length rolling window per match pattern, compared + # against the corresponding pattern deque. + search_deques = [collections.deque(maxlen=len(m)) for m in matches] + match_deques = [collections.deque(m) for m in matches] + self._matched = None # In case of a timeout equal to zero, it must be looped until the # buffer is empty @@ -167,9 +217,13 @@ A {classname}.close() is missing somewhere in your code !'.format(classname=type self.string_buffer += data read_data += data - search_deque.append(data) - if search_deque == match_deque: + if regex: + matched = self._search_regex(read_data, compiled) + else: + matched = self._feed_match(data, search_deques, match_deques, matches) + if matched is not None: status = 0 + self._matched = matched if (not mute) and (data != '\n'): self.string_buffer += '\n' @@ -210,9 +264,13 @@ A {classname}.close() is missing somewhere in your code !'.format(classname=type self.string_buffer += data read_data += data - search_deque.append(data) - if search_deque == match_deque: + if regex: + matched = self._search_regex(read_data, compiled) + else: + matched = self._feed_match(data, search_deques, match_deques, matches) + if matched is not None: status = 0 + self._matched = matched if (not mute) and (data != '\n'): self.string_buffer += '\n' @@ -407,20 +465,35 @@ class SerialConsole(Console): self.stop = threading.Event() self.port = None self.port_id = port + self._thd = None def open(self): - self.port = serial.Serial(port=self.port_id, - baudrate=self.baudrate, - stopbits=self.stopbits, - parity=self.parity, - xonxoff=self.xonxoff, - timeout=None) + try: + self.port = serial.Serial(port=self.port_id, + baudrate=self.baudrate, + stopbits=self.stopbits, + parity=self.parity, + xonxoff=self.xonxoff, + timeout=None) + except (serial.SerialException, OSError) as e: + raise ETUMRuntimeError(self._open_error_message(e)) from None self.isOpened = True if self.bufferize: self.port.timeout = 2 self._thd = threading.Thread(target=self.read_thread) self._thd.start() + def _open_error_message(self, exc): + """Build a short, direct message for a failed serial open.""" + errno_ = getattr(exc, "errno", None) + if errno_ == errno.ENOENT: + return "Serial device '{}' does not exist.".format(self.port_id) + if errno_ == errno.EACCES: + return ("Permission denied opening serial device '{}' " + "(is your user allowed to access it, e.g. 'dialout' group?)." + .format(self.port_id)) + return "Could not open serial device '{}': {}".format(self.port_id, exc) + def read_thread(self): while not self.stop.is_set(): c = self.port.read(1) @@ -428,7 +501,7 @@ class SerialConsole(Console): self.rx_queue.put(c) def close(self): - if self.bufferize: + if self.bufferize and self._thd is not None: self.stop.set() self._thd.join() if self.port is not None: @@ -440,10 +513,12 @@ class SerialConsole(Console): self.port.timeout = timeout def readchar(self, timeout): + if not self.isOpened: + raise ETUMRuntimeError("Serial console '{}' is not open".format(self.name)) if self.bufferize: if not self._thd.is_alive() and not self.stop.isSet(): - raise RuntimeError( - "Impossible to read the serial console, it may be already openned") + raise ETUMRuntimeError( + "Impossible to read the serial console, it may be already opened") if timeout < TIMEOUT_NULL: return self.rx_queue.get(block=False) else: @@ -455,10 +530,12 @@ class SerialConsole(Console): self.port.flush() def read_nowait(self, mute=False): + if not self.isOpened: + raise ETUMRuntimeError("Serial console '{}' is not open".format(self.name)) if self.bufferize: if not self._thd.is_alive() and not self.stop.isSet(): - raise RuntimeError( - "Impossible to read the serial console, it may be already openned") + raise ETUMRuntimeError( + "Impossible to read the serial console, it may be already opened") st = self.rx_queue.getAll().decode(self.encoding, errors='replace') if not mute: date_str = str(datetime.now()).split('.')[0].split(' ')[1] diff --git a/src/testium/interpreter/test_items/test_item_console.py b/src/testium/interpreter/test_items/test_item_console.py index b339eed..78d5fc2 100644 --- a/src/testium/interpreter/test_items/test_item_console.py +++ b/src/testium/interpreter/test_items/test_item_console.py @@ -4,7 +4,7 @@ import importlib import traceback import api.testium as tm -from runtime.tum_except import ETUMSyntaxError +from runtime.tum_except import ETUMSyntaxError, ETUMRuntimeError from runtime.stdout_redirect import stdio_redir from interpreter.test_items.test_item import test_run from interpreter.test_items.item_actions import TestItemActions @@ -225,12 +225,17 @@ class TestItemConsoleOpen(TestItemConsoleAction): tm.add_console(cons) cons.open() self.result.set(TestValue.SUCCESS) + except ETUMRuntimeError as e: + # Expected, user-facing console error (device missing, no permission, + # …): report a single clear line, no traceback. + msg = "Impossible to open the console '{}': {}".format(cname, e._message) + self.result.set(result=TestValue.FAILURE, message=msg) + print(msg) except Exception as e: + # Unexpected error: keep the full traceback for diagnosis. self.result.set( result=TestValue.FAILURE, - message="Impossible to open the console ({}) (exception: {})".format( - cname, e - ), + message="Impossible to open the console '{}': {}".format(cname, e), ) traceback.print_exception(*sys.exc_info()) @@ -319,12 +324,17 @@ class TestItemConsoleReadUntil(TestItemConsoleAction): PARAMS = ParamSet( Param("expected", required=True, - doc="Regex matched against incoming console output until found " - "or until timeout."), + doc="Literal string — or a list of strings — matched against the " + "incoming console output. The read succeeds as soon as one of " + "them is seen, or fails on timeout."), Param("timeout", default=-1, doc="Seconds before giving up. Negative means infinite."), Param("mute", default=False, doc="If true, don't echo received bytes to testium's stdout/log."), + Param("regex", default=False, + doc="If true, each 'expected' entry is treated as a Python " + "regular expression (searched, not anchored) instead of a " + "literal string."), ) def __init__( @@ -343,16 +353,21 @@ class TestItemConsoleReadUntil(TestItemConsoleAction): @test_run def execute(self): cons = self.get_console() - ru = self._prms.expanse(self._read_until) + # 'expected' may be a single value or a list of values (match any). + if isinstance(self._read_until, (list, tuple)): + ru = [self._prms.expanse(m) for m in self._read_until] + else: + ru = self._prms.expanse(self._read_until) read_timeout = int(self._prms.getParam("timeout", default=-1, processed=True)) mute = self._prms.getParam("mute", default=False, processed=True) + use_regex = self._prms.getParam("regex", default=False, processed=True) if read_timeout < 0: read_timeout = None try: status, data = cons.read_until( ru, timeout=read_timeout, return_data=True, mute=mute, - should_stop=self.isStopped, + should_stop=self.isStopped, regex=bool(use_regex), ) if status == 0: self.result.set(TestValue.SUCCESS) @@ -364,14 +379,21 @@ class TestItemConsoleReadUntil(TestItemConsoleAction): ) else: self.result.set(result=TestValue.FAILURE, message="No matching text") - if mute: - self.result.reported = {"data": ""} - else: - self.result.reported = {"data": data} + reported = {"data": "" if mute else data} + # When several patterns were given, expose which one matched. + if status == 0 and isinstance(ru, (list, tuple)): + reported["matched"] = getattr(cons, "_matched", None) + self.result.reported = reported # The result is put in global dir tm.setgd("cn_" + self.parent()._name, data) - except: + except ETUMRuntimeError as e: + # Expected console error (e.g. console not open): clear one-liner. + msg = f"Console '{self.token['console_name']}': impossible to read ({e._message})" + self.result.set(result=TestValue.FAILURE, message=msg) + print(msg) + except Exception: + # Unexpected error: keep the full traceback for diagnosis. print(traceback.format_exc()) self.result.set( result=TestValue.FAILURE, diff --git a/test/validation/items/console/test.tum b/test/validation/items/console/test.tum index 5e84ad7..25614a0 100644 --- a/test/validation/items/console/test.tum +++ b/test/validation/items/console/test.tum @@ -105,6 +105,46 @@ - read_until: {expected: console_host_check_HOST, timeout: 5} {% endif %} +# --- read_until matching a list of values (succeeds on any) --- +- console: + name: Console read_until list match any + console_name: term + key: $(test)_PASS + steps: + - writeln: echo "list_marker_B" + - read_until: {expected: [list_marker_A, list_marker_B, list_marker_C], timeout: 5} + +- console: + name: Console read_until list no match + console_name: term + key: $(test)_FAIL + steps: + - read_until: {expected: [never_marker_A, never_marker_B], timeout: 1} + +# --- read_until with regular expressions --- +- console: + name: Console read_until regex + console_name: term + key: $(test)_PASS + steps: + - writeln: echo "regex_val_4242_end" + - read_until: {expected: 'regex_val_\d+_end', regex: true, timeout: 5} + +- console: + name: Console read_until regex list any + console_name: term + key: $(test)_PASS + steps: + - writeln: echo "STATUS=ready" + - read_until: {expected: ['ERR:.*', 'STATUS=(ready|busy)'], regex: true, timeout: 5} + +- console: + name: Console read_until regex no match + console_name: term + key: $(test)_FAIL + steps: + - read_until: {expected: 'never_\d{4}', regex: true, timeout: 1} + - console: name: Console closure execute_on_stop: true