13 Commits

Author SHA1 Message Date
50d183d191 removed lua param, useless. 2026-05-17 10:43:25 +02:00
2177715641 examples: long_wait py_func/lua_func to exercise Stop
Two extra steps in example_simple.tum that sleep for 10s, used to
verify that pressing Stop interrupts engaged blocking steps.
2026-05-17 10:42:49 +02:00
a728f561be Make Stop interrupt blocking steps promptly
console.read_until polls a should_stop callback in 0.2s chunks across
all protocols. py_func/lua_func override stop() to tear down the worker
and wake the parent RPC wait. json_rpc adapters honor should_stop too.
Engaged leaf steps now report FAILURE on stop (sleep no-dialog was
silently SUCCESS).
2026-05-17 10:42:40 +02:00
116e528a7d Simplify the Start Stop Pause process (v-and-v/testium#20) 2026-05-16 13:36:18 +02:00
cc744e17a1 Adding ensurepip verification for the build environnement (required by venv) 2026-05-16 13:29:37 +02:00
ab39b49558 now the release note and the manual are copied into dist with build_all 2026-05-13 21:24:35 +02:00
95275c4418 Merge branch 'main' of ssh://git.beafrancois.fr:8328/v-and-v/testium 2026-05-13 14:09:41 +02:00
0d614c2921 release: 0.1.2 2026-05-13 14:05:47 +02:00
9466b091dd docs: rebuild manual PDF 2026-05-13 14:05:47 +02:00
511288bd03 build_all.sh: build wheel + pyinstaller + flatpak + appimage in one go
Collects all four artifacts under <repo>/dist/ (PyInstaller and Flatpak
renamed to testium-<version>(.suff); wheel and AppImage keep PEP 427 /
appimage-builder original names). Re-uses scripts/build_env.sh and
set_env.sh, same venv as run.sh. AppImage build.sh now picks the actual
output file dynamically instead of a hardcoded lowercase name.
2026-05-13 14:03:20 +02:00
51b144f60c Flatpak: bypass XDG portal for .tum open dialog
Native file dialog routes through the XDG document portal, which exposes
only the selected file at /run/user/UID/doc/... — siblings (param.yaml,
.py) are unreachable. Force Qt's non-native dialog in Flatpak so it walks
the real filesystem via --filesystem=home and returns a usable path.
2026-05-13 12:49:46 +02:00
dee8d4a682 generic design elements 2026-05-10 17:41:43 +02:00
e726d47547 generic design elements 2026-05-10 17:40:52 +02:00
24 changed files with 389 additions and 156 deletions

View File

@@ -1,4 +1,4 @@
# Testium — Claude Context # Testium — Design Context
## What is testium ## What is testium

107
build_all.sh Executable file
View File

@@ -0,0 +1,107 @@
#!/bin/bash
# Build every distribution channel of testium, in order:
# 1. Manual PDF -> dist/testium-manual-<v>.pdf
# 2. Wheel -> dist/testium-<v>-py3-none-any.whl (PEP 427 name)
# 3. PyInstaller binary -> dist/testium-<v>
# 4. Flatpak bundle -> dist/testium-<v>.flatpak
# 5. AppImage -> dist/Testium-<v>-x86_64.AppImage (original name)
# release_note.txt is copied to dist/ up front (with a warning if it has no
# entry for the current version).
#
# All artifacts are collected (copied) under <repo>/dist/. Original outputs in
# src/dist/, package/*/dist/, doc/manual/ are left in place. Wheel and AppImage
# keep their original names (which already contain the version); manual,
# pyinstaller and flatpak are renamed to testium(-manual)-<version>(.suff).
#
# Re-uses scripts/build_env.sh and scripts/set_env.sh — the same pair invoked
# by run.sh — so the venv at test/tmp/.venv stays the single source of Python
# dependencies. `build` and `pyinstaller` are installed into that venv on
# demand if not already there. Flatpak and AppImage build in their own
# container/sandbox; their build.sh scripts have their own toolchain checks.
set -e
SCRIPT_DIR=$(realpath "$(dirname "$0")")
VERSION=$(cat "$SCRIPT_DIR/src/VERSION")
DIST_DIR="$SCRIPT_DIR/dist"
mkdir -p "$DIST_DIR"
# Release note: copy it to dist/ and warn (but don't fail) if it has no entry
# for the current version.
RELEASE_NOTE_SRC="$SCRIPT_DIR/release_note.txt"
RELEASE_NOTE="$DIST_DIR/release_note.txt"
cp -f "$RELEASE_NOTE_SRC" "$RELEASE_NOTE"
if ! grep -qE "^version $VERSION([^.0-9]|$)" "$RELEASE_NOTE_SRC"; then
echo "WARNING: release_note.txt has no entry for version $VERSION." >&2
fi
export PY_VENV_NAME=".venv"
export PY_VENV_DIR="$SCRIPT_DIR/test/tmp/$PY_VENV_NAME"
export REQ_PATH="$SCRIPT_DIR/src/requirements.txt"
bash "$SCRIPT_DIR/scripts/build_env.sh"
source "$SCRIPT_DIR/scripts/set_env.sh"
# Ensure wheel/PyInstaller toolchains are present in the venv.
python -m pip install --quiet --upgrade build pyinstaller
step() {
echo
echo "================================================================"
echo " $1"
echo "================================================================"
}
# 1. Manual PDF
step "1/5 Manual PDF (version $VERSION)"
bash "$SCRIPT_DIR/doc/manual/sphinx/build_doc.sh"
MANUAL_SRC="$SCRIPT_DIR/doc/manual/testium_manual.pdf"
MANUAL="$DIST_DIR/testium-manual-${VERSION}.pdf"
cp -f "$MANUAL_SRC" "$MANUAL"
# 2. Wheel — PEP 427 name kept (already contains version)
step "2/5 Wheel (version $VERSION)"
(
cd "$SCRIPT_DIR/src"
rm -rf dist build *.egg-info
python -m build --wheel
)
WHEEL_SRC=$(ls -1t "$SCRIPT_DIR/src/dist"/*.whl | head -1)
WHEEL="$DIST_DIR/$(basename "$WHEEL_SRC")"
cp -f "$WHEEL_SRC" "$WHEEL"
# 3. PyInstaller binary
step "3/5 PyInstaller binary (version $VERSION)"
bash "$SCRIPT_DIR/package/pyinstaller/build.sh"
PYI_SRC="$SCRIPT_DIR/package/pyinstaller/dist/testium"
PYI_BIN="$DIST_DIR/testium-${VERSION}"
cp -f "$PYI_SRC" "$PYI_BIN"
# 4. Flatpak bundle
step "4/5 Flatpak bundle (version $VERSION)"
(
cd "$SCRIPT_DIR/package/flatpak"
bash build.sh
)
FLATPAK_SRC="$SCRIPT_DIR/package/flatpak/testium.flatpak"
FLATPAK_BUNDLE="$DIST_DIR/testium-${VERSION}.flatpak"
cp -f "$FLATPAK_SRC" "$FLATPAK_BUNDLE"
# 5. AppImage
step "5/5 AppImage (version $VERSION)"
(
cd "$SCRIPT_DIR/package/appimage"
bash build.sh
)
APPIMAGE_SRC=$(ls -1t "$SCRIPT_DIR/package/appimage"/*.AppImage 2>/dev/null | head -1)
APPIMAGE="$DIST_DIR/$(basename "$APPIMAGE_SRC")"
cp -f "$APPIMAGE_SRC" "$APPIMAGE"
chmod +x "$APPIMAGE"
step "All packages built"
printf " manual : %s\n" "$MANUAL"
printf " wheel : %s\n" "$WHEEL"
printf " pyinstaller : %s\n" "$PYI_BIN"
printf " flatpak : %s\n" "$FLATPAK_BUNDLE"
printf " appimage : %s\n" "$APPIMAGE"
printf " release_note : %s\n" "$RELEASE_NOTE"

View File

@@ -20,6 +20,22 @@ main:
param: param:
- 123 - 123
- py_func:
name: python long wait
doc: The purpose of this step is to try the tasks "stop" interruption
file: utils.py
func_name: long_wait
param:
- 10
- lua_func:
name: lua long wait
doc: The purpose of this step is to try the tasks "stop" interruption
file: lua_func.lua
func_name: long_wait
param:
- 10
- sleep: - sleep:
name: sleep item name: sleep item
dialog: true dialog: true

View File

@@ -1,4 +1,5 @@
tm = require("tm") tm = require("tm")
socket = require("socket")
local module = {} local module = {}
@@ -7,4 +8,8 @@ function module.func_to_be_executed(param)
return param return param
end end
function module.long_wait(sec)
socket.sleep(sec)
end
return module return module

View File

@@ -17,18 +17,3 @@ plot_log_path: /tmp/testium_plot/$(testrun_date)/$(testrun_time)/
python_path_Windows: C:\Users\François\Applications\Python313\python.exe python_path_Windows: C:\Users\François\Applications\Python313\python.exe
python_path_Linux: $(home)/tmp/tum_venv/bin/python3 python_path_Linux: $(home)/tmp/tum_venv/bin/python3
# lua_bin_Windows: C:\Lua\5.1
# lua_bin_Linux: /usr/bin/lua
LUA_PATH_Linux: /usr/share/lua/5.4/?.lua;/usr/local/share/lua/5.4/?.lua;/usr/local/share/lua/5.4/?/init.lua;/usr/share/lua/5.4/?/init.lua;/usr/local/lib/lua/5.4/?.lua;/usr/local/lib/lua/5.4/?/init.lua;/usr/lib/lua/5.4/?.lua;/usr/lib/lua/5.4/?/init.lua;./?.lua;./?/init.lua;/home/francois/.luarocks/share/lua/5.4/?.lua;/home/francois/.luarocks/share/lua/5.4/?/init.lua
LUA_CPATH_Linux: /usr/local/lib/lua/5.4/?.so;/usr/lib/lua/5.4/?.so;/usr/local/lib/lua/5.4/loadall.so;/usr/lib/lua/5.4/loadall.so;./?.so;/home/francois/.luarocks/lib/lua/5.4/?.so
PATH_Linux:
LUA_PATH_Windows: ;.\?.lua;C:\Lua\5.1\lua\?.lua;C:\Lua\5.1\lua\?\init.lua;C:\Lua\5.1\?.lua;C:\Lua\5.1\?\init.lua;C:\Lua\5.1\lua\?.luac
LUA_CPATH_Windows: .\?.dll;C:\Lua\5.1\?.dll;C:\Lua\5.1\loadall.dll;C:\Lua\5.1\clibs\?.dll;C:\Lua\5.1\clibs\loadall.dll;.\?51.dll;C:\Lua\5.1\?51.dll;C:\Lua\5.1\clibs\?51.dll
PATH_Windows: ""
lua_env:
PATH: $(PATH_$(os))
LUA_PATH: $(LUA_PATH_$(os))
LUA_CPATH: $(LUA_CPATH_$(os))

View File

@@ -1,3 +1,5 @@
from time import sleep
def dummy_exit(useless1, useless2): def dummy_exit(useless1, useless2):
return True return True
@@ -10,4 +12,7 @@ def funcToBeExecuted (bla):
def funcToBeExecuted2 (bla): def funcToBeExecuted2 (bla):
print(bla) print(bla)
return blo return blo
def long_wait (sec):
sleep(sec)

Binary file not shown.

View File

@@ -46,8 +46,9 @@ $RUNTIME run --rm \
appimage-builder --recipe AppImageBuilder.yml --skip-test appimage-builder --recipe AppImageBuilder.yml --skip-test
" "
echo "Done: testium-${APP_VERSION}-x86_64.AppImage" APPIMAGE_FILE=$(ls -1t Testium-*-x86_64.AppImage 2>/dev/null | head -1)
echo "Done: ${APPIMAGE_FILE}"
if [ "${1}" = "install" ]; then if [ "${1}" = "install" ] && [ -n "${APPIMAGE_FILE}" ]; then
install -v "testium-${APP_VERSION}-x86_64.AppImage" "${HOME}/.local/bin/testium" install -v "${APPIMAGE_FILE}" "${HOME}/.local/bin/testium"
fi fi

View File

@@ -1,23 +1,19 @@
version 0.1.2
==============
- Flatpak: opening a test from the GUI now correctly finds its companion
files (param.yaml, .py scripts, ...).
version 0.1.1 version 0.1.1
============== ==============
- Packaging: Flatpak bundle (desktop entry, MIME, distributable .flatpak) - New install channels: Flatpak bundle and AppImage. The AppImage runs
and AppImage (containerized build, runs on Arch / non-Debian hosts). on any distribution (built inside a Debian container).
- bins.py: host-only Python/Lua resolution from sandboxed bundles - About dialog: version is now correct in Flatpak and AppImage builds
(Flatpak / AppImage); fail fast at test load if the host interpreter (used to display "unknown").
is missing. - GUI dialogs no longer hang on pure-Wayland sessions.
- run item: runtime-aware launcher (AppImage / Flatpak / PyInstaller / - Plot "last values" API: more tolerant timeout on loaded machines.
source / wheel); drop testium_path / python_bin parameters. - run item: `testium_path` and `python_bin` parameters removed —
- dialog_env: auto-detect Wayland vs xcb from $DISPLAY / $WAYLAND_DISPLAY sub-instances are launched in the same packaging mode as the parent.
instead of forcing xcb (was hanging dialogs on pure-Wayland sessions). - License: EUPL-1.2.
- version: read TESTIUM_VERSION env in Flatpak/AppImage so the About
dialog stops reporting "unknown".
- runtime_plot last_values: bump timeout 1s -> 5s and narrow the bare
except to queue.Empty.
- py_func/__main__: robust sys.path init, diagnostic on import failure.
- Subprocess stdio (py_func / lua_func) routed into the parent log.
- README refocused on users (quick_start, tutorial); CONTRIBUTING filled.
- Docs: CLAUDE.md Packaging section rewritten.
- LICENSE file (EUPL-1.2) added.
version 0.1 version 0.1
============== ==============

View File

@@ -20,6 +20,12 @@ if [ "$?" -ne 0 ]; then
echo "venv must be installed on the host distribution." echo "venv must be installed on the host distribution."
exit -1 exit -1
fi fi
# Check if venv is installed
python3 -c "import ensurepip"
if [ "$?" -ne 0 ]; then
echo "ensurepip must be installed on the host distribution."
exit -1
fi
# Install the virtual environment if needed # Install the virtual environment if needed
if [ ! -d "$PY_VENV_DIR" ]; then if [ ! -d "$PY_VENV_DIR" ]; then

View File

@@ -1 +1 @@
0.1.1 0.1.2

View File

@@ -11,6 +11,7 @@ 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
TIMEOUT_NULL = 0.000001 TIMEOUT_NULL = 0.000001
STOP_POLL_INTERVAL = 0.2
class BytesStore(object): class BytesStore(object):
@@ -123,12 +124,14 @@ 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): def read_until(self, match, timeout=None, return_data=False, mute=False, should_stop=None):
""" """
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
If timeout is set to zero, this function returns immediately If timeout is set to zero, this function returns immediately
If mute is set to True the characters read from the console will not be displayed If mute is set to True the characters read from the console will not be displayed
If should_stop is a callable, it is polled between reads (every STOP_POLL_INTERVAL
at most) and the loop exits early — like a timeout — when it returns True.
If function fails (because of a timeout) it will return a 'status' integer set to -1 If function fails (because of a timeout) it will return a 'status' integer set to -1
otherwise it will return 0. otherwise it will return 0.
@@ -139,13 +142,6 @@ A {classname}.close() is missing somewhere in your code !'.format(classname=type
status = -1 status = -1
if not match: if not match:
raise ValueError('match parameter can not be empty') raise ValueError('match parameter can not be empty')
# replace all '\r' by '\n' as any '\r' read will undergo the same replacement
# match = match.replace('\r\n', '\n')
# match = match.replace('\r', '')
# update the console timeout in conformity with what is required.
self.set_read_timeout(timeout)
if timeout is None: if timeout is None:
timeout = 1000000 timeout = 1000000
@@ -159,6 +155,7 @@ A {classname}.close() is missing somewhere in your code !'.format(classname=type
# buffer is empty # buffer is empty
# Otherwise we are waiting for the timeout to rise # Otherwise we are waiting for the timeout to rise
if timeout < TIMEOUT_NULL: if timeout < TIMEOUT_NULL:
self.set_read_timeout(0)
data = self.readchar(0) data = self.readchar(0)
while (status < 0) and ((data is not None) and (data != b'')): while (status < 0) and ((data is not None) and (data != b'')):
@@ -191,39 +188,45 @@ A {classname}.close() is missing somewhere in your code !'.format(classname=type
# Timeout different than zero # Timeout different than zero
else: else:
# Poll in short chunks so a stop request is honored within
# STOP_POLL_INTERVAL, regardless of the per-protocol blocking
# behavior of readchar().
self.set_read_timeout(STOP_POLL_INTERVAL)
time_is_out = threading.Event() time_is_out = threading.Event()
timer = threading.Timer(timeout, lambda: time_is_out.set()) timer = threading.Timer(timeout, lambda: time_is_out.set())
timer.start() timer.start()
# We are waiting for the timeout to rise try:
while (status < 0) and (not time_is_out.is_set()):
if should_stop is not None and should_stop():
break
while (status < 0) and (not time_is_out.isSet()): data = self.readchar(STOP_POLL_INTERVAL)
if data is not None:
data = self.readchar(timeout) data = self._compute_char(data)
if data is not None: if data != '':
data = self._compute_char(data)
if data != '':
if not mute:
self.string_buffer += data
read_data += data
search_deque.append(data)
if search_deque == match_deque:
timer.cancel()
status = 0
if (not mute) and (data != '\n'):
self.string_buffer += '\n'
if data == '\n' or (status >= 0):
# the datas are written line by line for display optimisation in GUI mode
if not mute: if not mute:
self.string_buffer = self.string_buffer.replace('\r\n', '\n') self.string_buffer += data
self.string_buffer = self.string_buffer.replace('\r', '') read_data += data
self.stream.write(self.string_buffer)
date_str = str(datetime.now()).split('.')[0].split(' ')[1] search_deque.append(data)
self.string_buffer = '[{} {}]'.format(date_str, self.name) if search_deque == match_deque:
status = 0
if (not mute) and (data != '\n'):
self.string_buffer += '\n'
if data == '\n' or (status >= 0):
# the datas are written line by line for display optimisation in GUI mode
if not mute:
self.string_buffer = self.string_buffer.replace('\r\n', '\n')
self.string_buffer = self.string_buffer.replace('\r', '')
self.stream.write(self.string_buffer)
date_str = str(datetime.now()).split('.')[0].split(' ')[1]
self.string_buffer = '[{} {}]'.format(date_str, self.name)
finally:
timer.cancel()
if return_data: if return_data:
return status, read_data return status, read_data

View File

@@ -20,52 +20,64 @@ class TestItem:
def test_run(f): def test_run(f):
@wraps(f) @wraps(f)
def wrapper(self): def wrapper(self):
if not self.skipped: if self.skipped:
if self.enabled:
self.run_test_init()
# Conditional execution
raw_condition = self._prms.getParam(
"condition", default=None, processed=False
)
if raw_condition is None:
condition = True
else:
c = self._prms.expanse(raw_condition)
if isinstance(c, bool):
condition = c
else:
condition = False
c = False
if raw_condition == c:
msg = f'"{c}"'
else:
msg = f'"{raw_condition}" --> "{c}"'
# Do we have to skip the test because of a true condition ?
if condition:
if not raw_condition is None:
msg = "condition met: " + msg
self.result.reported = {"input_condition": msg}
print(msg)
# Test preparation
self.run_before_test()
# Test execution
f(self)
else:
msg = "condition not met: " + msg
self.result.set(TestValue.NORUN, msg)
self.result.reported = {"input_condition": msg}
self.run_test_end()
else:
self.result.set(TestValue.NORUN, "test disabled")
print("Test is disabled.")
else:
self.result.set(TestValue.NORUN, "test skipped") self.result.set(TestValue.NORUN, "test skipped")
print("Test is skipped.") print("Test is skipped.")
return self.result
if not self.enabled:
self.result.set(TestValue.NORUN, "test disabled")
print("Test is disabled.")
return self.result
self.run_test_init()
while self._is_paused:
sleep(0.2)
if self.isStopped() :
self.result.set(TestValue.NORUN, "test stopped")
print("Test is Stopped.")
self._is_stopped = False # Restore state for next run
return self.result
# Conditional execution
raw_condition = self._prms.getParam(
"condition", default=None, processed=False
)
if raw_condition is None:
condition = True
else:
c = self._prms.expanse(raw_condition)
if isinstance(c, bool):
condition = c
else:
condition = False
c = False
if raw_condition == c:
msg = f'"{c}"'
else:
msg = f'"{raw_condition}" --> "{c}"'
# Do we have to skip the test because of a true condition ?
if condition:
if not raw_condition is None:
msg = "condition met: " + msg
self.result.reported = {"input_condition": msg}
print(msg)
# Test preparation
self.run_before_test()
# Test execution
f(self)
else:
msg = "condition not met: " + msg
self.result.set(TestValue.NORUN, msg)
self.result.reported = {"input_condition": msg}
self.run_test_end()
return self.result return self.result
return wrapper return wrapper
@@ -255,8 +267,6 @@ class TestItem:
self._sendStatusStarted() self._sendStatusStarted()
if self._is_breakpoint: if self._is_breakpoint:
self._is_paused = True self._is_paused = True
while self._is_paused:
sleep(0.2)
if self.is_container: if self.is_container:
self.report.incLevel() self.report.incLevel()
@@ -274,9 +284,6 @@ class TestItem:
if self.is_container: if self.is_container:
self.report.decLevel() self.report.decLevel()
while self._is_paused:
sleep(0.2)
# Post evaluation of the test result # Post evaluation of the test result
self.process_result() self.process_result()
# expected_result treatment # expected_result treatment
@@ -310,6 +317,7 @@ class TestItem:
self.process_report(self._reported) self.process_report(self._reported)
self.report.addTest(self, self.result, rk) self.report.addTest(self, self.result, rk)
self._sendStatusFinished() self._sendStatusFinished()
def process_result(self): def process_result(self):
if self._post_eval is None: if self._post_eval is None:

View File

@@ -307,11 +307,17 @@ class TestItemConsoleReadUntil(TestItemConsoleAction):
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,
) )
if status == 0: if status == 0:
self.result.set(TestValue.SUCCESS) self.result.set(TestValue.SUCCESS)
self.result.value = data self.result.value = data
elif self.isStopped():
self.result.set(
result=TestValue.FAILURE,
message="Console read aborted on stop request",
)
else: else:
self.result.set(result=TestValue.FAILURE, message="No matching text") self.result.set(result=TestValue.FAILURE, message="No matching text")
if mute: if mute:

View File

@@ -105,6 +105,7 @@ class TestItemJSRPCActionQuery(TestItemAction):
jrpc_id = randint(1, (2**32) - 1) jrpc_id = randint(1, (2**32) - 1)
send_only = self._prms.expanse(self._send_only) send_only = self._prms.expanse(self._send_only)
timeout = self._prms.expanse(self._timeout) timeout = self._prms.expanse(self._timeout)
self.token.set_should_stop(self.isStopped)
try: try:
success, result = self.token.query( success, result = self.token.query(
meth, obj, jrpc_id, send_only, timeout=timeout meth, obj, jrpc_id, send_only, timeout=timeout
@@ -146,6 +147,7 @@ class TestItemJSRPCActionReceive(TestItemAction):
def execute(self): def execute(self):
timeout = self._prms.expanse(self._timeout) timeout = self._prms.expanse(self._timeout)
jrpc_id = self._prms.expanse(self._jrpc_id) jrpc_id = self._prms.expanse(self._jrpc_id)
self.token.set_should_stop(self.isStopped)
try: try:
success, result = self.token.receive(jrpc_id, timeout) success, result = self.token.receive(jrpc_id, timeout)

View File

@@ -2,10 +2,11 @@ import json
import socket import socket
import re import re
import struct import struct
import time
from runtime.tum_except import ETUMRuntimeError from runtime.tum_except import ETUMRuntimeError
import api.testium as tm import api.testium as tm
from api.console import Console from api.console import Console, STOP_POLL_INTERVAL
def is_ip_address(address): def is_ip_address(address):
@@ -45,9 +46,16 @@ class JrpcAdapter:
self._jrpc_version = version self._jrpc_version = version
self._mute = mute self._mute = mute
self._timeout = timeout self._timeout = timeout
# Optional callable polled by _receive() implementations to abort
# waits early when the test is being stopped. Set by the test item
# action before each query/receive call.
self._should_stop = None
if not (version == "1.0" or version == "2.0"): if not (version == "1.0" or version == "2.0"):
raise ETUMRuntimeError("Invalid JSONRPC version passed.") raise ETUMRuntimeError("Invalid JSONRPC version passed.")
def set_should_stop(self, cb):
self._should_stop = cb
@property @property
def timeout(self): def timeout(self):
return self._timeout return self._timeout
@@ -249,32 +257,38 @@ class JrpcUdpAdapter(JrpcAdapter):
print(f" | sent to @{self._server}:{self._snd_port}") print(f" | sent to @{self._server}:{self._snd_port}")
def _receive(self, timeout: float) -> str: def _receive(self, timeout: float) -> str:
# Poll in short chunks so a stop request is honored within
# STOP_POLL_INTERVAL.
self.sock.settimeout(STOP_POLL_INTERVAL)
deadline = time.monotonic() + float(timeout)
data = None
addr = None
while True:
if self._should_stop is not None and self._should_stop():
raise ETUMRuntimeError("JSONRPC udp receive aborted on stop request.")
try:
data, addr = self.sock.recvfrom(self._bufsize)
break
except socket.timeout:
if time.monotonic() >= deadline:
raise ETUMRuntimeError(
"JSONRPC udp answer took too long. Try to increase the timeout."
)
# configures the reception timeout # In case of buffer overload we chose to complain
self.sock.settimeout(timeout) if len(data) >= self._bufsize:
# Receives the answer from the server
try:
data, addr = self.sock.recvfrom(self._bufsize)
# In case of buffer overload we chose to complain
if len(data) >= self._bufsize:
raise ETUMRuntimeError(
"JSONRPC udp answer size overflow. Try to increase the bufsize"
)
# Converts binary to string
res = data.decode()
# Don't log if mute
if not self._mute:
print(f" | UDP answer: '{res}'")
print(f" | received from @{addr[0]}:{addr[1]}")
except socket.timeout:
raise ETUMRuntimeError( raise ETUMRuntimeError(
"JSONRPC udp answer took too long. Try to increase the timeout." "JSONRPC udp answer size overflow. Try to increase the bufsize"
) )
# Converts binary to string
res = data.decode()
# Don't log if mute
if not self._mute:
print(f" | UDP answer: '{res}'")
print(f" | received from @{addr[0]}:{addr[1]}")
return res return res
def _build_query(self, method: str, obj, jrpc_id: int): def _build_query(self, method: str, obj, jrpc_id: int):
@@ -339,11 +353,16 @@ class JrpcConsoleAdapter(JrpcAdapter):
def _receive(self, timeout: float) -> str: def _receive(self, timeout: float) -> str:
status, data = self._cons.read_until( status, data = self._cons.read_until(
self._endswith, timeout, return_data=True, mute=self._mute self._endswith, timeout, return_data=True, mute=self._mute,
should_stop=self._should_stop,
) )
# if we did not receive anything, we complain # if we did not receive anything, we complain
if not status == 0: if not status == 0:
if self._should_stop is not None and self._should_stop():
raise ETUMRuntimeError(
f"JSONRPC console receive aborted on stop request."
)
raise ETUMRuntimeError( raise ETUMRuntimeError(
f"The '{self._cons.name}' console did not answer in the requested time." f"The '{self._cons.name}' console did not answer in the requested time."
) )

View File

@@ -45,6 +45,18 @@ class TestItemLuaFunc(TestItem):
tm.setgd(_LUA_FUNC_CONTEXTS_KEY, contexts) tm.setgd(_LUA_FUNC_CONTEXTS_KEY, contexts)
return contexts[ctx_id], True return contexts[ctx_id], True
def stop(self):
super().stop()
# Tear down the worker so any in-flight func_call returns promptly.
# join() clears _rpc/_process so a subsequent item reusing the same
# context_id can restart the engine cleanly.
try:
engine, _ = self._get_engine()
engine.stop()
engine.join()
except Exception:
pass
@test_run @test_run
def execute(self): def execute(self):
self.result.set( self.result.set(
@@ -96,9 +108,15 @@ Is the lua environnment well defined in the "LUA_PATH" and "LUA_CPATH" variables
return return
except ConnectionAbortedError:
self.result.set(TestValue.FAILURE, "lua_func aborted on stop request")
print("lua_func aborted on stop request.")
except: except:
traceback.print_exception(*sys.exc_info()) traceback.print_exception(*sys.exc_info())
self.result.set( if self.isStopped():
TestValue.FAILURE, self.result.set(TestValue.FAILURE, "lua_func aborted on stop request")
'Unrecoverable "lua_func" item error from {}'.format(self.func_name), else:
) self.result.set(
TestValue.FAILURE,
'Unrecoverable "lua_func" item error from {}'.format(self.func_name),
)

View File

@@ -45,6 +45,18 @@ class TestItemPyFunc(TestItem):
tm.setgd(_PY_FUNC_CONTEXTS_KEY, contexts) tm.setgd(_PY_FUNC_CONTEXTS_KEY, contexts)
return contexts[ctx_id], True return contexts[ctx_id], True
def stop(self):
super().stop()
# Tear down the worker so any in-flight func_call returns promptly.
# join() clears _rpc/_process so a subsequent item reusing the same
# context_id can restart the engine cleanly.
try:
engine, _ = self._get_engine()
engine.stop()
engine.join()
except Exception:
pass
@test_run @test_run
def execute(self): def execute(self):
self.result.set( self.result.set(
@@ -94,9 +106,15 @@ python_bin = {tm.gd("python_bin", "no python path defined")}"""
return return
except ConnectionAbortedError:
self.result.set(TestValue.FAILURE, "py_func aborted on stop request")
print("py_func aborted on stop request.")
except: except:
traceback.print_exception(*sys.exc_info()) traceback.print_exception(*sys.exc_info())
self.result.set( if self.isStopped():
TestValue.FAILURE, self.result.set(TestValue.FAILURE, "py_func aborted on stop request")
'Unrecoverable "py_func" item error from {}'.format(self.func_name), else:
) self.result.set(
TestValue.FAILURE,
'Unrecoverable "py_func" item error from {}'.format(self.func_name),
)

View File

@@ -80,4 +80,7 @@ class TestItemSleep(TestItem):
end_time = _time.time() + float(timeout) end_time = _time.time() + float(timeout)
while _time.time() < end_time and not self._is_stopped: while _time.time() < end_time and not self._is_stopped:
sleep(min(0.05, end_time - _time.time())) sleep(min(0.05, end_time - _time.time()))
self.result.set(TestValue.SUCCESS, 'Sleep %s sec' % (str(timeout))) if self._is_stopped:
self.result.set(TestValue.FAILURE, 'Sleep aborted on stop request')
else:
self.result.set(TestValue.SUCCESS, 'Sleep %s sec' % (str(timeout)))

View File

@@ -146,4 +146,12 @@ class LuaProcessBase:
""" """
if self._rpc is not None: if self._rpc is not None:
self._rpc.stop() self._rpc.stop()
# Force-kill the worker if it's still running. Needed when user code
# in the worker is stuck and won't notice the parent closing the RPC
# socket on its own.
if self._process is not None and self._process.poll() is None:
try:
self._process.terminate()
except Exception:
pass

View File

@@ -123,3 +123,11 @@ class PyProcessBase:
def stop(self): def stop(self):
if self._rpc is not None: if self._rpc is not None:
self._rpc.stop() self._rpc.stop()
# Force-kill the worker if it's still running. Needed when user code
# in the worker is stuck (e.g. sleep, blocking I/O) and won't notice
# the parent closing the RPC socket on its own.
if self._process is not None and self._process.poll() is None:
try:
self._process.terminate()
except Exception:
pass

View File

@@ -212,8 +212,17 @@ class TestFileManager:
d = "" d = ""
if w.testFile is not None: if w.testFile is not None:
d = os.path.dirname(w.testFile) d = os.path.dirname(w.testFile)
# In Flatpak the native dialog goes through the XDG document portal,
# which returns /run/user/UID/doc/.../test.tum and only exposes the
# selected file — sibling files (param.yaml, .py, etc.) are unreachable.
# Force Qt's own dialog, which walks the real filesystem mounted via
# --filesystem=home and returns a regular path with sibling access.
options = QFileDialog.Options()
if os.path.isfile("/.flatpak-info"):
options |= QFileDialog.Option.DontUseNativeDialog
file_name, _ = QFileDialog.getOpenFileName( file_name, _ = QFileDialog.getOpenFileName(
w, "Open the test file", d, "testium file (*.tum);;All Files (*)" w, "Open the test file", d,
"testium file (*.tum);;All Files (*)", options=options
) )
if file_name: if file_name:
self.reload(file_name) self.reload(file_name)

View File

@@ -176,7 +176,7 @@ class TestRunner:
w.actionOpenTest.setDisabled(True) w.actionOpenTest.setDisabled(True)
w.actionExit.setDisabled(True) w.actionExit.setDisabled(True)
icon = QtGui.QIcon() icon = QtGui.QIcon()
icon.addPixmap(QtGui.QPixmap(icon_prefix() + "/pause.png"), QtGui.QIcon.Normal, QtGui.QIcon.Off) icon.addPixmap(QtGui.QPixmap(icon_prefix() + "/pause2.png"), QtGui.QIcon.Normal, QtGui.QIcon.On)
w.actionStart_test.setIcon(icon) w.actionStart_test.setIcon(icon)
w.actionStart_test.setText("Pause test") w.actionStart_test.setText("Pause test")
w.actionPreferences.setDisabled(True) w.actionPreferences.setDisabled(True)

View File

@@ -200,6 +200,7 @@ class JsonRpcConnection:
Raises: Raises:
TimeoutError: If no response is received within `timeout`. TimeoutError: If no response is received within `timeout`.
ConnectionAbortedError: If stop() was called while waiting.
""" """
req_id = next(self.id_gen) req_id = next(self.id_gen)
@@ -214,7 +215,12 @@ class JsonRpcConnection:
self.pending.pop(req_id, None) self.pending.pop(req_id, None)
raise TimeoutError("Timeout JSON-RPC") raise TimeoutError("Timeout JSON-RPC")
return self.pending.pop(req_id)["response"] entry = self.pending.pop(req_id)
if entry["response"] is None:
# Woken by stop() (or by a malformed dispatch) rather than by a
# real response — abort the call so callers don't block further.
raise ConnectionAbortedError("JSON-RPC client stopped")
return entry["response"]
def print_info(self, msg): def print_info(self, msg):
if self.dbg_out is not None: if self.dbg_out is not None:
@@ -223,6 +229,10 @@ class JsonRpcConnection:
def stop(self): def stop(self):
if self.running: if self.running:
self.running = False self.running = False
# Wake any in-flight call() so it doesn't sit on its (default 1h)
# timeout. The response stays None and call() raises ConnectionAbortedError.
for entry in list(self.pending.values()):
entry["event"].set()
def join(self): def join(self):
self.recv_thread.join() self.recv_thread.join()