feat(console): list/regex read_until, serial error clarity; v0.3

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 <noreply@anthropic.com>
This commit is contained in:
2026-06-14 16:54:00 +02:00
parent 8c4e1b56b5
commit e4300ecf7b
6 changed files with 180 additions and 36 deletions

View File

@@ -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. 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 ## 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". - 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. - `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. - 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.

View File

@@ -11,7 +11,11 @@ finish-args:
- --share=ipc - --share=ipc
- --socket=fallback-x11 - --socket=fallback-x11
- --socket=wayland - --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 - --share=network
- --filesystem=home - --filesystem=home
- --filesystem=/tmp - --filesystem=/tmp

View File

@@ -1 +1 @@
0.2.3 0.3

View File

@@ -2,6 +2,7 @@ from datetime import datetime
import sys import sys
import os import os
import re import re
import errno
from queue import Queue, Empty from queue import Queue, Empty
from time import sleep from time import sleep
import collections import collections
@@ -10,6 +11,8 @@ import threading
from telnetlib3 import Telnet, DO, WILL, WONT, TTYPE, IAC, SB, SE, theNULL from telnetlib3 import Telnet, DO, WILL, WONT, TTYPE, IAC, SB, SE, theNULL
from runtime.tum_except import ETUMRuntimeError
TIMEOUT_NULL = 0.000001 TIMEOUT_NULL = 0.000001
STOP_POLL_INTERVAL = 0.2 STOP_POLL_INTERVAL = 0.2
@@ -124,7 +127,32 @@ A {classname}.close() is missing somewhere in your code !'.format(classname=type
# c = '' # c = ''
return 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 read until the string 'match is found
If timeout is not set (None), this function runs indefinitely 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 = '' read_data = ''
status = -1 status = -1
if not match: 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: if timeout is None:
timeout = 1000000 timeout = 1000000
# Fixed-length queue that will contain the readout characters compiled = None
search_deque = collections.deque(maxlen=len(match)) search_deques = match_deques = None
# convert match string into a deque for faster comparisons if regex:
match_deque = collections.deque(match) # '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 # In case of a timeout equal to zero, it must be looped until the
# buffer is empty # buffer is empty
@@ -167,9 +217,13 @@ A {classname}.close() is missing somewhere in your code !'.format(classname=type
self.string_buffer += data self.string_buffer += data
read_data += data read_data += data
search_deque.append(data) if regex:
if search_deque == match_deque: 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 status = 0
self._matched = matched
if (not mute) and (data != '\n'): if (not mute) and (data != '\n'):
self.string_buffer += '\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 self.string_buffer += data
read_data += data read_data += data
search_deque.append(data) if regex:
if search_deque == match_deque: 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 status = 0
self._matched = matched
if (not mute) and (data != '\n'): if (not mute) and (data != '\n'):
self.string_buffer += '\n' self.string_buffer += '\n'
@@ -407,20 +465,35 @@ class SerialConsole(Console):
self.stop = threading.Event() self.stop = threading.Event()
self.port = None self.port = None
self.port_id = port self.port_id = port
self._thd = None
def open(self): def open(self):
try:
self.port = serial.Serial(port=self.port_id, self.port = serial.Serial(port=self.port_id,
baudrate=self.baudrate, baudrate=self.baudrate,
stopbits=self.stopbits, stopbits=self.stopbits,
parity=self.parity, parity=self.parity,
xonxoff=self.xonxoff, xonxoff=self.xonxoff,
timeout=None) timeout=None)
except (serial.SerialException, OSError) as e:
raise ETUMRuntimeError(self._open_error_message(e)) from None
self.isOpened = True self.isOpened = True
if self.bufferize: if self.bufferize:
self.port.timeout = 2 self.port.timeout = 2
self._thd = threading.Thread(target=self.read_thread) self._thd = threading.Thread(target=self.read_thread)
self._thd.start() 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): def read_thread(self):
while not self.stop.is_set(): while not self.stop.is_set():
c = self.port.read(1) c = self.port.read(1)
@@ -428,7 +501,7 @@ class SerialConsole(Console):
self.rx_queue.put(c) self.rx_queue.put(c)
def close(self): def close(self):
if self.bufferize: if self.bufferize and self._thd is not None:
self.stop.set() self.stop.set()
self._thd.join() self._thd.join()
if self.port is not None: if self.port is not None:
@@ -440,10 +513,12 @@ class SerialConsole(Console):
self.port.timeout = timeout self.port.timeout = timeout
def readchar(self, timeout): def readchar(self, timeout):
if not self.isOpened:
raise ETUMRuntimeError("Serial console '{}' is not open".format(self.name))
if self.bufferize: if self.bufferize:
if not self._thd.is_alive() and not self.stop.isSet(): if not self._thd.is_alive() and not self.stop.isSet():
raise RuntimeError( raise ETUMRuntimeError(
"Impossible to read the serial console, it may be already openned") "Impossible to read the serial console, it may be already opened")
if timeout < TIMEOUT_NULL: if timeout < TIMEOUT_NULL:
return self.rx_queue.get(block=False) return self.rx_queue.get(block=False)
else: else:
@@ -455,10 +530,12 @@ class SerialConsole(Console):
self.port.flush() self.port.flush()
def read_nowait(self, mute=False): def read_nowait(self, mute=False):
if not self.isOpened:
raise ETUMRuntimeError("Serial console '{}' is not open".format(self.name))
if self.bufferize: if self.bufferize:
if not self._thd.is_alive() and not self.stop.isSet(): if not self._thd.is_alive() and not self.stop.isSet():
raise RuntimeError( raise ETUMRuntimeError(
"Impossible to read the serial console, it may be already openned") "Impossible to read the serial console, it may be already opened")
st = self.rx_queue.getAll().decode(self.encoding, errors='replace') st = self.rx_queue.getAll().decode(self.encoding, errors='replace')
if not mute: if not mute:
date_str = str(datetime.now()).split('.')[0].split(' ')[1] date_str = str(datetime.now()).split('.')[0].split(' ')[1]

View File

@@ -4,7 +4,7 @@ import importlib
import traceback import traceback
import api.testium as tm 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 runtime.stdout_redirect import stdio_redir
from interpreter.test_items.test_item import test_run from interpreter.test_items.test_item import test_run
from interpreter.test_items.item_actions import TestItemActions from interpreter.test_items.item_actions import TestItemActions
@@ -225,12 +225,17 @@ class TestItemConsoleOpen(TestItemConsoleAction):
tm.add_console(cons) tm.add_console(cons)
cons.open() cons.open()
self.result.set(TestValue.SUCCESS) 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: except Exception as e:
# Unexpected error: keep the full traceback for diagnosis.
self.result.set( self.result.set(
result=TestValue.FAILURE, result=TestValue.FAILURE,
message="Impossible to open the console ({}) (exception: {})".format( message="Impossible to open the console '{}': {}".format(cname, e),
cname, e
),
) )
traceback.print_exception(*sys.exc_info()) traceback.print_exception(*sys.exc_info())
@@ -319,12 +324,17 @@ class TestItemConsoleReadUntil(TestItemConsoleAction):
PARAMS = ParamSet( PARAMS = ParamSet(
Param("expected", required=True, Param("expected", required=True,
doc="Regex matched against incoming console output until found " doc="Literal string — or a list of strings — matched against the "
"or until timeout."), "incoming console output. The read succeeds as soon as one of "
"them is seen, or fails on timeout."),
Param("timeout", default=-1, Param("timeout", default=-1,
doc="Seconds before giving up. Negative means infinite."), doc="Seconds before giving up. Negative means infinite."),
Param("mute", default=False, Param("mute", default=False,
doc="If true, don't echo received bytes to testium's stdout/log."), 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__( def __init__(
@@ -343,16 +353,21 @@ class TestItemConsoleReadUntil(TestItemConsoleAction):
@test_run @test_run
def execute(self): def execute(self):
cons = self.get_console() cons = self.get_console()
# '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) ru = self._prms.expanse(self._read_until)
read_timeout = int(self._prms.getParam("timeout", default=-1, processed=True)) read_timeout = int(self._prms.getParam("timeout", default=-1, processed=True))
mute = self._prms.getParam("mute", default=False, 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: if read_timeout < 0:
read_timeout = None read_timeout = None
try: try:
status, data = cons.read_until( status, data = cons.read_until(
ru, timeout=read_timeout, return_data=True, mute=mute, ru, timeout=read_timeout, return_data=True, mute=mute,
should_stop=self.isStopped, should_stop=self.isStopped, regex=bool(use_regex),
) )
if status == 0: if status == 0:
self.result.set(TestValue.SUCCESS) self.result.set(TestValue.SUCCESS)
@@ -364,14 +379,21 @@ class TestItemConsoleReadUntil(TestItemConsoleAction):
) )
else: else:
self.result.set(result=TestValue.FAILURE, message="No matching text") self.result.set(result=TestValue.FAILURE, message="No matching text")
if mute: reported = {"data": "" if mute else data}
self.result.reported = {"data": ""} # When several patterns were given, expose which one matched.
else: if status == 0 and isinstance(ru, (list, tuple)):
self.result.reported = {"data": data} reported["matched"] = getattr(cons, "_matched", None)
self.result.reported = reported
# The result is put in global dir # The result is put in global dir
tm.setgd("cn_" + self.parent()._name, data) 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()) print(traceback.format_exc())
self.result.set( self.result.set(
result=TestValue.FAILURE, result=TestValue.FAILURE,

View File

@@ -105,6 +105,46 @@
- read_until: {expected: console_host_check_HOST, timeout: 5} - read_until: {expected: console_host_check_HOST, timeout: 5}
{% endif %} {% 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: - console:
name: Console closure name: Console closure
execute_on_stop: true execute_on_stop: true