Merge branch 'main' into feat/pytest-item

# Conflicts:
#	DESIGN.md
This commit is contained in:
2026-06-14 19:42:02 +02:00
12 changed files with 233 additions and 51 deletions

View File

@@ -313,6 +313,8 @@ Both Flatpak and AppImage export `TESTIUM_VERSION` from a launcher (Flatpak: lau
## Recent fixes / notable changes ## Recent fixes / notable changes
- `pytest` item: pytest analogue of `unittest`, but runs on the **host interpreter in a subprocess** (`bins.python_bin()`, like `py_func`) so it works across every packaging channel. A stdlib-only pytest plugin streams collected node-ids + per-test results back over stdout via sentinels; each test becomes a child item with its own PASS/FAIL/SKIP, duration and failure message. Params: `test_file`, `test_method`. Validation item: `test/validation/items/pytest/` (the validation venv now pip-installs `pytest`). See "### `pytest` item". - `pytest` item: pytest analogue of `unittest`, but runs on the **host interpreter in a subprocess** (`bins.python_bin()`, like `py_func`) so it works across every packaging channel. A stdlib-only pytest plugin streams collected node-ids + per-test results back over stdout via sentinels; each test becomes a child item with its own PASS/FAIL/SKIP, duration and failure message. Params: `test_file`, `test_method`. Validation item: `test/validation/items/pytest/` (the validation venv now pip-installs `pytest`). See "### `pytest` item".
- `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`.
- Parameters are expanded at **run time**, never at load: control-flow flags (`stop_on_failure`/`execute_on_stop`) resolve via properties at run, `cycle` iterator / `git` repo / `tested_references` references and `console` `telnet_port` are no longer (incorrectly) expanded or left unexpanded at load. Justified load-time exceptions: `name`, `doc`, `skipped`, and `unittest`/`pytest` `test_method`.
- 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

@@ -121,15 +121,44 @@ writeln function is similar to the write function except that a '\n' (newline) c
The ``read_until`` action is waiting for a string pattern from the console, The ``read_until`` action is waiting for a string pattern from the console,
its parameter are listed below its parameter are listed below
* ``expected``: Character string to wait for * ``expected``: the pattern(s) to wait for. It accepts either a **single
value** or a **list of values**; when a list is given the action succeeds
as soon as **any** of the values is seen.
* ``regex``: Boolean value (``True`` or ``False``, default ``False``). When
``True`` every ``expected`` entry is interpreted as a Python regular
expression (searched in the incoming stream, not anchored) instead of a
literal string.
* ``timeout``: Timeout setting for the action (in seconds) * ``timeout``: Timeout setting for the action (in seconds)
* ``no_fail``: Boolean value (``True`` or ``False``) leading to no error reported * ``no_fail``: Boolean value (``True`` or ``False``) leading to no error reported
if the expected input is not read if the expected input is not read
* ``mute``: Boolean value (``True`` or ``False``) does not log any readen data * ``mute``: Boolean value (``True`` or ``False``) does not log any readen data
.. code-block:: yaml
:caption: matching several values, and with a regular expression
# succeeds as soon as one of the three strings is received
- read_until:
expected: [login:, "Password:", "$ "]
timeout: 10
# regex: wait for "version X.Y.Z" with any numbers
- read_until:
expected: 'version \d+\.\d+\.\d+'
regex: True
timeout: 5
The text read by the ``read_until`` action is stored in the global The text read by the ``read_until`` action is stored in the global
variable named ``cn_<test_name>`` (See :ref:`global variables<sec_global_variables>` variable named ``cn_<test_name>`` (See :ref:`global variables<sec_global_variables>`
for more detail on accessing global variables from test items and scripts). for more detail on accessing global variables from test items and scripts).
When a list of values is given, the report also records, under the
``matched`` key, which pattern actually matched.
.. note::
``regex`` matching scans a bounded tail of the received stream
(``Console.REGEX_WINDOW`` characters), so a pattern that could only match
after a very large amount of output — or across more than that window —
may not be detected. Literal matching (the default) has no such limit.
In the example above, the global variable ``$(cn_test name in GUI)`` In the example above, the global variable ``$(cn_test name in GUI)``
would be created at the end of the step. It would contain the resulting would be created at the end of the step. It would contain the resulting

Binary file not shown.

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.2.4

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,29 @@ 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): # Max chars of the buffer tail scanned in regex mode (bounds cost/memory).
REGEX_WINDOW = 65536
def _feed_match(self, data, search_deques, match_deques, matches):
"""Append *data* to each window; return the first matched pattern or None."""
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 buffer tail with each regex; return the first hit's text 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 +166,35 @@ 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: a string or list of strings; succeed as soon as any is seen.
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 literal pattern.
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 +212,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 +259,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 +460,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):
self.port = serial.Serial(port=self.port_id, try:
baudrate=self.baudrate, self.port = serial.Serial(port=self.port_id,
stopbits=self.stopbits, baudrate=self.baudrate,
parity=self.parity, stopbits=self.stopbits,
xonxoff=self.xonxoff, parity=self.parity,
timeout=None) xonxoff=self.xonxoff,
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 +496,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 +508,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 +525,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

@@ -145,7 +145,7 @@ class TestItem:
self._report_key = None self._report_key = None
self._reported = None self._reported = None
self.status_queue = status_queue self.status_queue = status_queue
self._execute_on_stop = False self._execute_on_stop_raw = False
self._post_eval = None self._post_eval = None
self._store_result = None self._store_result = None
self._expected_result = None self._expected_result = None
@@ -154,7 +154,7 @@ class TestItem:
self._is_running = False self._is_running = False
self._is_breakpoint = False self._is_breakpoint = False
self._is_paused = False self._is_paused = False
self._stop_on_failure = False self._stop_on_failure_raw = False
self._doc = "" self._doc = ""
self._name = "" self._name = ""
self.report = None self.report = None
@@ -197,13 +197,14 @@ class TestItem:
self.skipped = False self.skipped = False
self._report_key = self._prms.getParam("key", default=None) self._report_key = self._prms.getParam("key", default=None)
self._stop_on_failure = self._prms.getParam( # Kept raw: expanded at run time by the matching properties.
"stop_on_failure", default=False, processed=True self._stop_on_failure_raw = self._prms.getParam(
"stop_on_failure", default=False
) )
self._doc = self._prms.getParam("doc", default="", processed=True) self._doc = self._prms.getParam("doc", default="", processed=True)
# #
self._execute_on_stop = self._prms.getParam( self._execute_on_stop_raw = self._prms.getParam(
"execute_on_stop", default=False, processed=True "execute_on_stop", default=False
) )
if "process_result" in dict_item: if "process_result" in dict_item:
@@ -570,6 +571,20 @@ class TestItem:
def setEnabled(self): def setEnabled(self):
self.enabled = True self.enabled = True
def _eval_flag(self, raw):
"""Run-time flag: bool as-is, otherwise expanded and coerced to bool."""
if isinstance(raw, bool):
return raw
return eval_to_boolean(self._prms.expanse(raw))
@property
def _stop_on_failure(self):
return self._eval_flag(self._stop_on_failure_raw)
@property
def _execute_on_stop(self):
return self._eval_flag(self._execute_on_stop_raw)
def executedOnStop(self): def executedOnStop(self):
return self._execute_on_stop return self._execute_on_stop

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
@@ -88,7 +88,7 @@ class TestItemConsoleOpen(TestItemConsoleAction):
telnet_host = self._prms.getParam( telnet_host = self._prms.getParam(
"telnet_host", required=True, processed=True "telnet_host", required=True, processed=True
) )
telnet_port = self._prms.getParam("telnet_port", default=69) telnet_port = self._prms.getParam("telnet_port", default=69, processed=True)
elif self._protocol == "ssh": elif self._protocol == "ssh":
if tm.OS() == "Windows": if tm.OS() == "Windows":
@@ -225,12 +225,16 @@ 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 console error (device missing, no permission…): one line.
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 +323,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 +352,21 @@ class TestItemConsoleReadUntil(TestItemConsoleAction):
@test_run @test_run
def execute(self): def execute(self):
cons = self.get_console() 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)) 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 +378,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

@@ -51,11 +51,8 @@ class TestItemCycle(TestItem):
self._niter = None self._niter = None
if "iterator" in dict_cycle: if "iterator" in dict_cycle:
# Kept raw: expanded at run time in execute().
self._iter = dict_cycle["iterator"] self._iter = dict_cycle["iterator"]
if isinstance(self._iter, str):
self._iter = self._prms.expanse(self._iter)
else: else:
self._iter = None self._iter = None

View File

@@ -21,7 +21,8 @@ class TestItemGit(TestItem):
super().__init__(dict_item, parent, status_queue, filename=filename) super().__init__(dict_item, parent, status_queue, filename=filename)
self._type = cst.TYPE_GIT self._type = cst.TYPE_GIT
self.is_container = False self.is_container = False
self.repo = self._prms.getParamAll('repo', processed=True, required=True) # Kept raw: each repo entry is expanded at run time in execute().
self.repo = self._prms.getParamAll('repo', required=True)
@test_run @test_run
def execute(self): def execute(self):

View File

@@ -26,13 +26,14 @@ class TestItemTestedRefsDialog(TestItemDialogBase):
self.is_container = False self.is_container = False
with item_load_context(self.cmd(), self.name(), self.seqFilename()): with item_load_context(self.cmd(), self.name(), self.seqFilename()):
self._question = self._prms.getParam('question', required=True) self._question = self._prms.getParam('question', required=True)
self._init_values = self._prms.getParamAll('reference', required=False, processed=True) # Kept raw: expanded at run time in execute().
self._init_values = self._prms.getParamAll('reference', required=False)
self._auto_result = self._prms.getParam('auto_result', required=False, default=None) self._auto_result = self._prms.getParam('auto_result', required=False, default=None)
@test_run @test_run
def execute(self): def execute(self):
q = self._prms.expanse(self._question) q = self._prms.expanse(self._question)
init_values = ','.join(self._init_values) init_values = ','.join(self._prms.expanse(v) for v in self._init_values)
if _is_text_mode(): if _is_text_mode():
print(f"References: {q}") print(f"References: {q}")
rows = init_values.split(',') if init_values else [''] rows = init_values.split(',') if init_values else ['']

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