7 Commits

Author SHA1 Message Date
354c5e12e8 bump to 0.2: declarative test item parameters
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 13:16:45 +02:00
b1a7dac0f3 item params: migrate every structured item to declarative PARAMS
Migrates the remaining test items to the ParamSet/Param declaration
introduced in d0721af:
  - dialogs: image, question, value, choices, tested_references
  - actions: check, run, report
  - console: parent + open/read_until actions
  - py_func / lua_func
  - containers: group, parallel + parallel_branch, unittest
  - complex: cycle (sub-block exit_condition documented in
    EXIT_CONDITION_PARAMS), git
  - runtime_plot: parent + open/close/periodic/last_value actions
  - json_rpc: parent + query/receive actions

Items intentionally without PARAMS (and therefore not validated) are
those whose body is the unstructured user value: console write/writeln,
plot add/export, and the json_rpc/console open & close actions. Same
for the internally-instantiated TestItemUnittestElement which passes
dict_item=None.

Behavior on valid .tum files is unchanged (validation suite source
mode: SUCCESS). Typos on declared params now surface as warnings
listing the accepted names; missing required params surface as load-
time errors with file context.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 11:14:42 +02:00
d0721af719 item params: declarative descriptor foundation + 4 pilot items
Adds utils/param_decl.py with Param/ParamSet/kind descriptors. TestItem
declares COMMON_PARAMS (name, doc, condition, key, skipped, …) and a
new _validate_declared_params() method that warns on unknown keys and
errors on missing required ones — opt-in per subclass (skipped while
PARAMS is None to keep the migration incremental).

Migrates sleep, let, msg_dialog, note_dialog as pilots. Behavior is
unchanged for any well-formed .tum; typos like 'timeoot' on a sleep
item now produce a clear WARN listing the accepted parameters.

The descriptor intentionally carries no Python type information —
parameter values that are $(…) / <|…|> expressions only acquire their
effective type after expansion, so a static type would be misleading.
Per-param post-expansion validators stay opt-in via validate=lambda.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 10:45:57 +02:00
d4889c2a2e flatpak: run host interpreters via flatpak-spawn; validation --mode flag
py_func, lua_func and the run item now reach host binaries through
`flatpak-spawn --host` instead of trying to load them under the
sandbox runtime (which fails with a glibc ABI mismatch). Adds
`--talk-name=org.freedesktop.Flatpak` to the manifest, stages the
/app/lib/testium tree under /tmp so the host can read it, and drops
the dead `_FLATPAK_HOST_DIRS` / lib-injection code paths that the
new approach makes obsolete.

Validation suite gains a `--mode source|wheel|pyinstaller|flatpak|
appimage` flag so the same item set can run against every packaging
channel; per-mode report file names avoid clobbering.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 18:42:32 +02:00
a260e2a56c build_all: incremental build + per-step toolchain install
Skip steps whose dist/ artifact already exists; add --clean/-c to
force a full rebuild. Install sphinx/linuxdoc, build, pyinstaller
in their respective steps instead of upfront. Auto-add flathub
remote and install missing Flatpak SDK/runtime deps before step 4.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 13:51:22 +02:00
dd584c9064 gui: bypass XDG portal for all file/dir dialogs in Flatpak
The v0.1.2 fix that forced Qt's non-native dialog for the "open test"
dialog only covered one call site. The same XDG-portal-vs-sibling-files
problem applies to every other QFileDialog in the GUI (save report,
log file path, default report/log dirs in preferences, python/lua
interpreter pickers).

Extracted a single ``file_dialog.options()`` helper in main_win/ and
threaded it through every getOpenFileName / getSaveFileName /
getExistingDirectory call in main_win/. Outside Flatpak the helper
returns an empty Options(), so the native dialog stays in use on
KDE / GNOME / Windows / macOS.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 08:32:14 +02:00
4d8cafb5a0 validation: dedicated venv + fix python_bin override timing
eval_proc was started before -d/GUI defines reached gd, so
``-d python_bin=...`` and the GUI ``python_bin`` preference were
silently ignored by the very subprocess that runs ``<| ... |>`` evals
(and only took effect for later items once the discovery cache had
already been seeded with the system interpreter). apply_overrides() is
now applied before eval_process_init(), and bins._resolve()'s cache is
keyed by (name, override) so a later param.yaml change re-resolves on
the next lookup.

The validation suite now ships a wrapper (run.sh / run.bat) that
creates a dedicated venv in the system temp dir and pins it via
``-d python_bin=...``. A new ``venv`` item asserts the override took
effect for both eval_proc and py_func paths, with a
``sys.prefix != sys.base_prefix`` marker to catch the case where the
override happens to be a system interpreter (path-equality alone would
miss it, the venv's ``bin/python3`` being a symlink to the host).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 08:19:57 +02:00
45 changed files with 1564 additions and 173 deletions

View File

@@ -114,11 +114,20 @@ To add a new API call usable from subprocesses:
### External interpreter resolution (`bins.py`)
`src/testium/interpreter/utils/bins.py` — single source of truth for the paths to the external Python and Lua interpreters used by subprocesses.
- `python_bin()` / `lua_bin()` : resolve once, cache in memory. User can override via the `python_bin` / `lua_bin` global dict keys (typically populated from the YAML config). Falls back to discovery on PATH (candidates: `python3`/`python` and `lua`/`lua5.5`/`lua5.4`/`lua5.3`/`lua5.2`/`lua5.1`).
- `python_bin()` / `lua_bin()` : resolve and cache. The cache is keyed by `(name, override)` so that a later change to `gd[python_bin]` (typically when a `param.yaml` sets the key) triggers a re-resolution on the next lookup instead of returning the stale auto-discovered path. Falls back to discovery on PATH (candidates: `python3`/`python` and `lua`/`lua5.5`/`lua5.4`/`lua5.3`/`lua5.2`/`lua5.1`).
- `ensure(*names)` : called by `TestSet._validate_runtime_deps()` at test load. Always requires `python` (the eval engine always runs); requires `lua` only if a `lua_func` item is in the tree. Fails fast with a clear error citing tried candidates and override key.
Engines (`PyProcessBase`, `LuaProcessBase`, `EvalExecEngine`) call `bins.python_bin()`/`bins.lua_bin()` themselves — call sites never pass an explicit binary path.
#### Override-timing contract (`apply_overrides`)
`bins.python_bin()` is called for the **first** time inside `eval_process_init()` (the long-lived inline-`<| … |>` subprocess), which happens **before** the YAML param files are loaded. To make `-d python_bin=…` and the GUI `python_bin` preference take effect for `eval_proc` itself, `process.py:run()` applies them to gd **before** `eval_process_init()` via the `apply_overrides()` helper extracted from `update_global()`. The post-load `update_global()` call then re-applies the same overrides (after `prepare_global()` clears gd), keeping the gd value in sync with the cached resolution.
| Override source | `eval_proc` | `py_func` / `cycle` / `post_exec` |
|---|---|---|
| `-d python_bin=…` (CLI) | ✅ | ✅ |
| GUI `python_bin` preference | ✅ | ✅ |
| `python_bin: …` in `param.yaml` | ❌ (eval_proc already started) | ✅ (cache re-resolves on key change) |
## Key files
| Path | Role |
@@ -261,12 +270,18 @@ Both Flatpak and AppImage export `TESTIUM_VERSION` from a launcher (Flatpak: lau
- `unittest` item: renamed from `unittest_file`.
- GUI test tree: check and fold state preserved across same-file reloads.
- Licence: EUPL-1.2.
- Interpreter override timing: `apply_overrides()` extracted from `update_global()` and called by `process.py:run()` before `eval_process_init()`, so `-d python_bin=…` / GUI prefs reach `bins.python_bin()` on its first lookup. `bins._resolve()` cache is now keyed by `(name, override)` so later `param.yaml` changes are picked up by subsequently constructed engines.
## Validation tests
Located in `test/validation/`. Run with `-b` flag:
Located in `test/validation/`. Two entry points:
```
./run.sh -b -- test/validation/main.tum
./test/validation/run.sh # wrapper — uses a dedicated venv (see below)
./run.sh -b -- test/validation/main.tum # direct — testium's own python is used for test execution
```
The `run.sh` / `run.bat` wrappers create a dedicated Python venv at `${TMPDIR:-/tmp}/testium-validation-venv` (Linux) or `%TEMP%\testium-validation-venv` (Windows), with `--system-site-packages` + `pip install junit-xml`, and run the suite with `-d python_bin=…` so every test-execution subprocess (eval_proc, py_func, cycle, post_exec) runs inside the venv. testium itself keeps running in the project's own environment. `clean` as the first argument recreates the venv.
The `venv` item (`test/validation/items/venv/`) asserts that the override actually took effect: `python_bin` is set, `sys.executable` matches it, `sys.prefix == dirname(dirname(python_bin))`, and `sys.prefix != sys.base_prefix` (the last marker catches the case where `python_bin` happens to be a system interpreter, which path-equality alone would miss because the venv's `bin/python3` is a symlink to the host). Both `eval_proc` (inline `<| … |>`) and `py_func` paths are exercised.
Parallel item tests: `test/validation/items/parallel/test.tum`
## Dependencies

View File

@@ -8,6 +8,9 @@
# release_note.txt is copied to dist/ up front (with a warning if it has no
# entry for the current version).
#
# By default, a step is skipped if its artifact already exists in dist/.
# Pass --clean to remove existing dist/ artifacts and rebuild everything.
#
# 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,
@@ -15,17 +18,35 @@
#
# 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.
# dependencies. `build`, `pyinstaller`, `sphinx` and `linuxdoc` 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
CLEAN=0
for arg in "$@"; do
case "$arg" in
--clean|-c) CLEAN=1 ;;
*) echo "Unknown option: $arg" >&2; exit 1 ;;
esac
done
SCRIPT_DIR=$(realpath "$(dirname "$0")")
VERSION=$(cat "$SCRIPT_DIR/src/VERSION")
DIST_DIR="$SCRIPT_DIR/dist"
mkdir -p "$DIST_DIR"
if [ "$CLEAN" -eq 1 ]; then
echo "-- clean: removing existing dist artifacts for version $VERSION"
rm -f "$DIST_DIR/testium-manual-${VERSION}.pdf"
rm -f "$DIST_DIR"/testium-${VERSION}-*.whl
rm -f "$DIST_DIR/testium-${VERSION}"
rm -f "$DIST_DIR/testium-${VERSION}.flatpak"
rm -f "$DIST_DIR"/Testium-${VERSION}-*.AppImage
fi
# 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"
@@ -42,9 +63,6 @@ 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 "================================================================"
@@ -52,51 +70,90 @@ step() {
echo "================================================================"
}
skip() { echo " (already built — skipping)"; }
# 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"
step "1/5 Manual PDF (version $VERSION)"
if [ ! -f "$MANUAL" ]; then
python -m pip install --quiet --upgrade sphinx linuxdoc
bash "$SCRIPT_DIR/doc/manual/sphinx/build_doc.sh"
cp -f "$SCRIPT_DIR/doc/manual/testium_manual.pdf" "$MANUAL"
else
skip
fi
# 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"
WHEEL=$(ls -1t "$DIST_DIR"/testium-${VERSION}-*.whl 2>/dev/null | head -1)
if [ -z "$WHEEL" ]; then
python -m pip install --quiet --upgrade build
(
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"
else
skip
fi
# 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"
step "3/5 PyInstaller binary (version $VERSION)"
if [ ! -f "$PYI_BIN" ]; then
python -m pip install --quiet --upgrade pyinstaller
bash "$SCRIPT_DIR/package/pyinstaller/build.sh"
cp -f "$SCRIPT_DIR/package/pyinstaller/dist/testium" "$PYI_BIN"
else
skip
fi
# 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"
step "4/5 Flatpak bundle (version $VERSION)"
if [ ! -f "$FLATPAK_BUNDLE" ]; then
FLATPAK_DEPS=(
"org.kde.Platform//6.10"
"org.kde.Sdk//6.10"
"io.qt.PySide.BaseApp//6.10"
)
if ! flatpak remotes --user | grep -q "^flathub"; then
echo " Adding Flathub remote"
flatpak remote-add --user --if-not-exists flathub https://dl.flathub.org/repo/flathub.flatpakrepo
fi
for dep in "${FLATPAK_DEPS[@]}"; do
if ! flatpak info --user "$dep" &>/dev/null && ! flatpak info --system "$dep" &>/dev/null; then
echo " Installing Flatpak dependency: $dep"
flatpak install --user --noninteractive flathub "$dep"
fi
done
(
cd "$SCRIPT_DIR/package/flatpak"
bash build.sh
)
cp -f "$SCRIPT_DIR/package/flatpak/testium.flatpak" "$FLATPAK_BUNDLE"
else
skip
fi
# 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"
APPIMAGE=$(ls -1t "$DIST_DIR"/Testium-${VERSION}-*.AppImage 2>/dev/null | head -1)
if [ -z "$APPIMAGE" ]; then
(
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"
else
skip
fi
step "All packages built"
printf " manual : %s\n" "$MANUAL"

Binary file not shown.

View File

@@ -16,6 +16,11 @@ finish-args:
- --filesystem=home
- --filesystem=/tmp
- --filesystem=host-os
# Allow flatpak-spawn --host to launch host binaries (Python, Lua, git…)
# outside the sandbox. Required because the sandbox glibc/ABI is
# incompatible with arbitrary host shared libraries — we route py_func and
# lua_func through the host instead.
- --talk-name=org.freedesktop.Flatpak
build-options:
build-args:

View File

@@ -1,3 +1,38 @@
version 0.2
==============
- Test items: each item type now declares its accepted parameters
(``PARAMS = ParamSet(...)``). Typos in a ``.tum`` are surfaced as a
WARN listing the accepted names instead of being silently ignored;
missing required parameters error out at load time with the source
``.tum`` file as context. No change to valid existing tests.
version 0.1.3
==============
- Stop interrupts engaged blocking steps (console, py_func, lua_func,
json_rpc, sleep) within ~200 ms instead of waiting for the step
to finish.
- GUI Start / Stop / Pause flow simplified.
- lua_func: a function returning nil is no longer reported as a failure.
- ``-d python_bin=...`` and the GUI ``python_bin`` preference now reach
the eval subprocess (used to be silently ignored). ``param.yaml`` can
also override ``python_bin`` for py_func / cycle / post_exec.
- Validation suite: ``test/validation/run.sh`` (and ``run.bat``)
runs the suite inside a dedicated venv in the system temp dir.
- build_all.sh: ``release_note.txt`` and the user manual copied into
``dist/``; warning if the file has no entry for the version being built.
- Flatpak: every GUI file/directory dialog (open test, save report, log
path, default report/log dirs, python/lua interpreter pickers) now
bypasses the XDG document portal — the v0.1.2 fix was only on the
"open test" dialog.
- Flatpak: py_func / lua_func / run sub-instance now execute on the host
via flatpak-spawn, lifting the previous glibc/ABI incompatibility that
prevented user-configured host Python or Lua interpreters from being
reached from the sandbox.
- Validation suite: single entry point with ``--mode source|wheel|
pyinstaller|flatpak|appimage`` to validate any packaging channel
against the same item set; reports are stamped per mode.
- GUI: the "Run tum" test item now uses the testium logo.
version 0.1.2
==============
- Flatpak: opening a test from the GUI now correctly finds its companion

View File

@@ -1 +1 @@
0.1.2
0.2

View File

@@ -16,6 +16,7 @@ from interpreter.utils.test_init import (
env_init,
prepare_global,
update_global,
apply_overrides,
set_standard_gd_keys,
test_run_init,
test_run_header,
@@ -210,6 +211,19 @@ class TestProcess(Process):
env_init()
# Apply GUI defaults and CLI defines to the global dict
# *before* eval_proc starts: bins.python_bin() reads
# ``python_bin`` from gd on its very first call (during
# eval_process_init) and caches the result. Without this,
# ``-d python_bin=...`` and the GUI ``python_bin`` preference
# would only take effect for items spawned *after* the cache
# was already populated with the auto-discovered interpreter,
# i.e. they would silently be ignored for eval_proc itself.
# _load_initial_params re-applies the same overrides after
# ``prepare_global()`` clears gd, so the gd value stays in
# sync with the cached path.
apply_overrides(self.__defs, self.__gui_defaults)
# Creation of the python evaluation process for loading of the complete test
eval_proc = eval_process_init(api_request, 10, test_dir)
eval_proc.start()

View File

@@ -5,6 +5,9 @@ from copy import deepcopy
from interpreter.test_items.test_result import TestResult, TestValue
import api.testium as tm
from interpreter.utils.params import TestItemParams
from interpreter.utils.param_decl import (
Param, ParamSet, LIST, BLOCK, unknown_keys, missing_required,
)
from interpreter.utils.constants import TestItemType as cst_type
from interpreter.utils.eval import eval_to_boolean, evaluate, post_evaluate
from runtime.tum_except import ETUMSyntaxError, item_load_context
@@ -13,6 +16,32 @@ LOG_TEST_STOP = '<----- step "{}" finished'
LOG_TEST_START = '-----> step "{}" started'
# Parameters accepted by every test item, regardless of its type. Subclasses
# concatenate their own ``PARAMS`` to this set; the merged result drives
# unknown-param warnings and (later) the LSP schema export.
COMMON_PARAMS = ParamSet(
Param("name", doc="Display name shown in the GUI tree and reports."),
Param("doc", doc="Free-form documentation; surfaced in tooltips."),
Param("skipped", doc="If truthy, the step is skipped (static expression, "
"evaluated at load time)."),
Param("key", doc="Report key used to classify the result "
"(typically <test>_PASS or <test>_FAIL)."),
Param("stop_on_failure", doc="If true, abort the surrounding container on failure."),
Param("execute_on_stop", doc="If true, run this step even when its container "
"is being stopped (cleanup)."),
Param("process_result", doc="Post-evaluation expression applied to the test result."),
Param("store_result", doc="Global-dict key in which to store the test result."),
Param("expected_result", doc="Expected outcome; the step is failed if it doesn't match."),
Param("no_fail", doc="If truthy, never report a FAILURE for this step."),
Param("report", doc="Per-step reporting override."),
Param("condition", doc="Optional gating expression evaluated before each "
"run; false ⇒ the step is skipped."),
Param("steps", kind=LIST, doc="Children (for container items)."),
Param("seq_filename", doc="(internal) source .tum file of this step; injected "
"by the loader."),
)
class TestItem:
pass
@@ -97,6 +126,11 @@ def test_data(item: TestItem, child: dict) -> dict:
class TestItem:
# Subclasses override with their own ParamSet to opt into the declarative
# validation. While ``PARAMS`` is empty / unset, the base class skips the
# unknown-param check for this item type — keeps the migration incremental.
PARAMS = None
def __init__(
self, dict_item: dict = None, parent: TestItem = None, status_queue=None, filename = ""
):
@@ -134,6 +168,13 @@ class TestItem:
# creation of the params object
self._prms = TestItemParams(dict_item, parent)
# Declarative-params validation. Only kicks in when the concrete
# subclass declares ``PARAMS`` — items not yet migrated stay
# silent. Warnings (not errors) during the migration window so
# existing .tum files don't break suddenly; will be flipped to
# errors once every item has migrated.
self._validate_declared_params(dict_item)
# getting parameters for the test item
try:
self._name = self._prms.getParam("name", default="", processed=True)
@@ -190,6 +231,36 @@ class TestItem:
self.result = TestResult(self, TestValue.FAILURE, "Failure by default")
def _validate_declared_params(self, dict_item):
"""Warn on unknown / missing-required params, if PARAMS is declared.
The check is opt-in per subclass: it only runs when the concrete
class sets a non-empty ``PARAMS`` attribute. Items not yet migrated
produce no diagnostics — preserving the historical "silently accept
anything" behavior until they get their declaration.
"""
if not self.PARAMS:
return
# ``self._type`` is the parent root type at this point (subclasses set
# it after super().__init__), so use the class name as a stable label
# in diagnostics. ``self._name`` was preset to the type name by every
# subclass before super() ran, which gives a useful prefix.
label = f"{type(self).__name__} '{self._name}'"
declared = COMMON_PARAMS + self.PARAMS
unknown = unknown_keys(declared, dict_item)
if unknown:
accepted = ", ".join(sorted(declared.names()))
for k in unknown:
tm.print_warn(
f"{label}: unknown parameter '{k}'. Accepted: {accepted}."
)
missing = missing_required(declared, dict_item)
for k in missing:
raise ETUMSyntaxError(
f"{label}: required parameter '{k}' is missing.",
self._seq_filename,
)
def _filter_dict_item(self, dict_item):
# Stores the content of the step to be displayed
# in the GUI

View File

@@ -5,11 +5,22 @@ from runtime.tum_except import ETUMSyntaxError, item_load_context
import api.testium as tm
from interpreter.utils.constants import TestItemType as cst
from interpreter.utils.eval import evaluate
from interpreter.utils.param_decl import Param, ParamSet, LIST
class TestItemCheckValue(TestItem):
"""check item usage.
check usage:{check: {name: check my func output, steps: ['$(pfn_echo) < 5']}}
"""
PARAMS = ParamSet(
Param("values", kind=LIST, required=True,
doc="List of expressions to evaluate. Each is expanded then "
"evaluated; non-truthy results fail the check."),
# 'steps' is intentionally not redeclared here — it's the deprecated
# alias of 'values' and is already accepted by COMMON_PARAMS for
# container items. A runtime warning is emitted when 'steps' is used.
)
def __init__(self, dict_item, parent = None, status_queue=None, filename=""):
self._name = cst.TYPE_CHECK.item_name
super().__init__(dict_item, parent, status_queue, filename=filename)

View File

@@ -2,11 +2,25 @@ from interpreter.test_items.test_item import test_run
from interpreter.test_items.test_result import TestValue
from interpreter.test_items.test_item_dialog_base import TestItemDialogBase, _is_text_mode, _is_interactive
from interpreter.utils.constants import TestItemType as cst
from interpreter.utils.param_decl import Param, ParamSet, BLOCK
from runtime.tum_except import item_load_context
import api.testium as tm
class TestItemChoicesDialog(TestItemDialogBase):
PARAMS = ParamSet(
Param("question", required=True,
doc="Prompt shown above the list of choices."),
Param("choices", kind=BLOCK, required=True,
doc="Tree of choices: either a list of strings, or a nested "
"mapping {label: subchoices, ...} to build a multi-level menu."),
Param("icon", default=None,
doc="Default icon name shown next to each choice."),
Param("auto_result", default=None,
doc="Batch-mode selection (path or label). None ⇒ FAILURE."),
)
def __init__(self, dict_item, parent=None, status_queue=None, filename=""):
self._name = cst.TYPE_CHOICES_DLG.item_name
super().__init__(dict_item, parent, status_queue, filename=filename)

View File

@@ -10,6 +10,7 @@ from interpreter.test_items.test_item import test_run
from interpreter.test_items.item_actions import TestItemActions
from interpreter.test_items.item_actions.action import TestItemAction
from interpreter.utils.constants import TestItemType as cst
from interpreter.utils.param_decl import Param, ParamSet
from interpreter.test_items.test_result import TestResult, TestValue
@@ -21,6 +22,38 @@ class TestItemConsoleAction(TestItemAction):
class TestItemConsoleOpen(TestItemConsoleAction):
PARAMS = ParamSet(
Param("protocol", required=True,
doc="Transport: 'telnet', 'ssh', 'rawtcp', 'serial' or 'terminal'."),
Param("write_delay", default=0,
doc="Inter-character write delay in ms (slow devices)."),
Param("log", doc="Path to a log file capturing the console traffic."),
Param("overwrite_log", default=True,
doc="If true, truncate the log file at open; else append."),
# telnet
Param("telnet_host", doc="Hostname/IP for the telnet target."),
Param("telnet_port", default=69, doc="TCP port for telnet."),
# ssh
Param("ssh_host", doc="Hostname/IP for the SSH target."),
Param("ssh_user", doc="SSH login user."),
Param("ssh_pwd", doc="SSH password (if key-based auth is not used)."),
# rawtcp
Param("tcp_host", doc="Hostname/IP for a raw-TCP connection."),
Param("tcp_port", doc="TCP port for a raw-TCP connection."),
# serial
Param("serial_port", doc="Serial device path (e.g. /dev/ttyUSB0 or COM3)."),
Param("serial_baudrate", doc="Serial baudrate."),
Param("buffered", default=True,
doc="If true, the serial console buffers received bytes between reads."),
# terminal
Param("terminal_path",
doc="Working directory for the local terminal protocol."),
Param("shell",
doc="Shell command used for the local terminal protocol "
"(default: 'cmd.exe' on Windows, '/usr/bin/env bash' elsewhere)."),
)
def __init__(
self, action_name, dict_item, parent=None, status_queue=None, filename=""
):
@@ -283,6 +316,17 @@ class TestItemConsoleWriteLn(TestItemConsoleAction):
class TestItemConsoleReadUntil(TestItemConsoleAction):
PARAMS = ParamSet(
Param("expected", required=True,
doc="Regex matched against incoming console output until found "
"or until 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."),
)
def __init__(
self, action_name, dict_item, parent=None, status_queue=None, filename=""
):
@@ -336,6 +380,14 @@ class TestItemConsoleReadUntil(TestItemConsoleAction):
class TestItemConsole(TestItemActions):
PARAMS = ParamSet(
Param("console_name", required=True,
doc="Identifier of the console — used by every nested action to "
"reach back the same transport. Multiple consoles can coexist "
"as long as their names differ."),
)
def __init__(self, dict_item, parent=None, status_queue=None, filename=""):
super().__init__(
cst.TYPE_CONSOLE, dict_item, parent, status_queue, filename=filename

View File

@@ -8,9 +8,36 @@ from interpreter.test_items.test_result import TestResult, TestValue
import api.testium as tm
from interpreter.utils.params import TestItemParams
from interpreter.utils.constants import TestItemType as cst
from interpreter.utils.param_decl import Param, ParamSet, BLOCK
# Sub-block validation: 'cycle' accepts an 'exit_condition:' mapping whose
# own params are reported here so unknown keys inside it can be flagged
# during a future Block-aware diagnostic pass. For now the parent only
# declares that 'exit_condition' is an accepted top-level key.
EXIT_CONDITION_PARAMS = ParamSet(
Param("time", doc="HH:MM time of day after which the loop exits."),
Param("value", doc="Expression; when truthy the loop exits."),
Param("file", doc="Python file containing the exit-condition function."),
Param("func_name", doc="Function name in 'file' returning the exit value."),
Param("param", doc="Arguments passed to the exit function."),
Param("eval", default="",
doc="Post-evaluation expression applied to the function's return."),
)
class TestItemCycle(TestItem):
PARAMS = ParamSet(
Param("iterator",
doc="Iterable (or string expanding to one) driving the loop. "
"The current value is exposed as $(loop_param)."),
Param("exit_condition", kind=BLOCK,
doc="Optional block stopping the loop early: combine 'time', "
"'value', or a 'file'+'func_name' pair (with optional "
"'param' and 'eval')."),
)
def __init__(self, dict_cycle, parent=None, status_queue=None, filename=""):
self._name = cst.TYPE_CYCLE.item_name
super().__init__(dict_cycle, parent, status_queue, filename=filename)

View File

@@ -1,6 +1,7 @@
from interpreter.test_items.test_item import (TestItem, test_run)
from interpreter.test_items.test_result import (TestValue)
from interpreter.utils.constants import TestItemType as cst
from interpreter.utils.param_decl import Param, ParamSet, LIST
from runtime.tum_except import ETUMParamError, ETUMSyntaxError
import interpreter.utils.version as git
@@ -8,6 +9,13 @@ class TestItemGit(TestItem):
"""
This item expect only one parameter which is a string or list of string being the path to the git folder
"""
PARAMS = ParamSet(
Param("repo", kind=LIST, required=True,
doc="Path to a git checkout, or list of such paths. Each is "
"reported with its current version (tag + dirty state)."),
)
def __init__(self, dict_item, parent = None, status_queue=None, filename=""):
self._name = cst.TYPE_GIT.item_name
super().__init__(dict_item, parent, status_queue, filename=filename)

View File

@@ -1,10 +1,17 @@
from interpreter.test_items.test_item import (TestItem, test_run)
from interpreter.test_items.test_result import (TestResult, TestValue)
from interpreter.utils.constants import TestItemType as cst
from interpreter.utils.param_decl import ParamSet
from runtime.tum_except import ETUMSyntaxError
import api.testium as tm
class TestItemGroup(TestItem):
# 'group' has no item-specific parameters; 'steps' is handled by COMMON_PARAMS.
# Declaring an empty ParamSet still opts in to unknown-param validation
# (e.g. typo 'stop_on_failures').
PARAMS = ParamSet()
def __init__(self, dict_cycle, parent = None, status_queue=None, filename=""):
self._name = cst.TYPE_GROUP.item_name
super().__init__(dict_cycle, parent, status_queue, filename=filename)

View File

@@ -4,6 +4,7 @@ from interpreter.test_items.test_item import test_run
from interpreter.test_items.test_result import TestValue
from interpreter.test_items.test_item_dialog_base import TestItemDialogBase, _is_text_mode, _is_interactive
from interpreter.utils.constants import TestItemType as cst
from interpreter.utils.param_decl import Param, ParamSet
from runtime.tum_except import item_load_context
import api.testium as tm
@@ -12,6 +13,17 @@ class TestItemImageDialog(TestItemDialogBase):
"""dialog_image item usage.
dialog_image name: Nice image, question: could you press the red button, filename: img.jpg
"""
PARAMS = ParamSet(
Param("question", required=True,
doc="Prompt shown above the image."),
Param("filename", required=True,
doc="Path to the image file (relative to the test directory or absolute)."),
Param("auto_result", default=None,
doc="Outcome used in batch/non-interactive mode. Truthy ⇒ SUCCESS, "
"None ⇒ FAILURE."),
)
def __init__(self, dict_item, parent=None, status_queue=None, filename=""):
self._name = cst.TYPE_IMAGE_DLG.item_name
super().__init__(dict_item, parent, status_queue, filename=filename)

View File

@@ -11,6 +11,7 @@ from interpreter.test_items.item_actions.action import TestItemAction
from interpreter.utils.constants import TestItemType as cst
from interpreter.utils.eval import evaluate
from interpreter.utils.param_decl import Param, ParamSet, BLOCK
from interpreter.test_items.test_item_json_rpc.jsonrpc_adapters import (
JrpcAdapter,
@@ -76,6 +77,20 @@ class TestItemJSRPCActionClose(TestItemAction):
class TestItemJSRPCActionQuery(TestItemAction):
PARAMS = ParamSet(
Param("method", required=True,
doc="JSON-RPC method name to call."),
Param("params",
doc="Parameters payload (list, dict or scalar) sent to the method."),
Param("id", default="rand",
doc="JSON-RPC request id. 'rand' (default) ⇒ a random integer is used."),
Param("no_wait", default=False,
doc="If true, send the request without waiting for a response."),
Param("timeout", default=None,
doc="Seconds to wait for a response. None ⇒ inherits the transport "
"default."),
)
def __init__(
self, action_name, dict_item, parent=None, status_queue=None, filename=""
):
@@ -129,6 +144,13 @@ class TestItemJSRPCActionQuery(TestItemAction):
class TestItemJSRPCActionReceive(TestItemAction):
PARAMS = ParamSet(
Param("id", required=True,
doc="JSON-RPC request id whose response we expect."),
Param("timeout", default=None,
doc="Seconds to wait for the response. None ⇒ transport default."),
)
def __init__(
self, action_name, dict_item, parent=None, status_queue=None, filename=""
):
@@ -172,6 +194,22 @@ class TestItemJSON_RPC(TestItemActions):
This item TBD
"""
PARAMS = ParamSet(
Param("console", kind=BLOCK,
doc="Console-transport block: {console_name, …}. Either 'console' "
"or 'udp' must be set."),
Param("udp", kind=BLOCK,
doc="UDP-transport block: {host, port, …}. Either 'console' or "
"'udp' must be set."),
Param("version", default="1.0",
doc="JSON-RPC protocol version ('1.0' or '2.0')."),
Param("timeout", required=True,
doc="Default seconds to wait for a JSON-RPC response across all "
"child query/receive actions."),
Param("mute", default=False,
doc="If true, don't echo wire traffic to the log."),
)
def __init__(
self, dict_item: dict, parent: TestItem = None, status_queue=None, filename=""
):

View File

@@ -8,12 +8,20 @@ from interpreter.test_items.test_result import (TestResult, TestValue)
from runtime.tum_except import ETUMSyntaxError, item_load_context
import api.testium as tm
from interpreter.utils.constants import TestItemType as cst
from interpreter.utils.param_decl import Param, ParamSet, LIST
class TestItemLet(TestItem):
"""let item usage.
let values: {variable1: a, variable2: /dev/ttyUSB0, variable3: 115200}
let eval: {conditional_exec: "random.randint(1, 4)"}
"""
PARAMS = ParamSet(
Param("values", kind=LIST, required=True,
doc="Mapping (or list of single-pair mappings) of global-dict "
"key → value to set. Values are expanded at execution time."),
)
def __init__(self, dict_item, parent = None, status_queue=None, filename=""):
self._name = cst.TYPE_LET.item_name
super().__init__(dict_item, parent, status_queue, filename=filename)

View File

@@ -11,6 +11,7 @@ import api.testium as tm
from interpreter.utils.lua_func_exec import LuaFuncExecEngine
from interpreter.utils.api_srv import api_request
from interpreter.utils.constants import TestItemType as cst
from interpreter.utils.param_decl import Param, ParamSet, LIST
_LUA_FUNC_CONTEXTS_KEY = "_lua_func_contexts"
@@ -21,6 +22,21 @@ class TestItemLuaFunc(TestItem):
Optional: context_id: <id> — share a persistent process with other lua_func items using the same id.
"""
PARAMS = ParamSet(
Param("file", required=True,
doc="Path to the .lua file containing the function."),
Param("func_name", required=True,
doc="Name of the function to call in the file."),
Param("param", kind=LIST,
doc="Arguments passed to the function. Each entry is expanded "
"before the call. Special tokens $(loop_param) / $(loop_index) "
"resolve from the surrounding cycle."),
Param("context_id", default=None,
doc="If set, the lua_func subprocess is kept alive and reused by "
"every other lua_func item with the same context_id — enables "
"shared in-memory state between successive calls."),
)
def __init__(self, dict_item, parent=None, status_queue=None, filename=""):
self._name = cst.TYPE_LUA_FUNCTION.item_name
super().__init__(dict_item, parent, status_queue, filename=filename)

View File

@@ -5,6 +5,7 @@ from interpreter.test_items.test_item import test_run
from interpreter.test_items.test_result import TestValue
from interpreter.test_items.test_item_dialog_base import TestItemDialogBase, _is_text_mode, _is_interactive
from interpreter.utils.constants import TestItemType as cst
from interpreter.utils.param_decl import Param, ParamSet
from runtime.tum_except import item_load_context
@@ -12,6 +13,15 @@ class TestItemMsgDialog(TestItemDialogBase):
"""dialog_message item usage.
dialog_message name: Nice message, question: Open the door and press OK
"""
PARAMS = ParamSet(
Param("question", required=True,
doc="Message body shown to the user. Multi-line strings are supported."),
Param("auto_result", default=None,
doc="Outcome used in batch/non-interactive mode instead of waiting "
"for the user. Truthy ⇒ SUCCESS, None ⇒ FAILURE."),
)
def __init__(self, dict_item, parent=None, status_queue=None, filename=""):
self._name = cst.TYPE_MESSAGE_DLG.item_name
super().__init__(dict_item, parent, status_queue, filename=filename)

View File

@@ -2,11 +2,23 @@ from interpreter.test_items.test_item import test_run
from interpreter.test_items.test_result import TestValue
from interpreter.test_items.test_item_dialog_base import TestItemDialogBase, _is_text_mode, _is_interactive
from interpreter.utils.constants import TestItemType as cst
from interpreter.utils.param_decl import Param, ParamSet
from runtime.tum_except import item_load_context
import api.testium as tm
class TestItemNoteDialog(TestItemDialogBase):
PARAMS = ParamSet(
Param("question", required=True,
doc="Prompt shown above the note input field."),
Param("auto_result", default=None,
doc="Batch-mode outcome: None ⇒ FAILURE, 'cancel' ⇒ cancelled, "
"any other truthy ⇒ SUCCESS with auto_value."),
Param("auto_value", default=None,
doc="Note text used in batch mode when auto_result is set."),
)
def __init__(self, dict_item, parent=None, status_queue=None, filename=""):
self._name = cst.TYPE_NOTE_DLG.item_name
super().__init__(dict_item, parent, status_queue, filename=filename)

View File

@@ -6,6 +6,7 @@ from interpreter.test_items.test_item import test_run
from interpreter.test_items.test_result import TestResult, TestValue
from interpreter.utils.constants import TestItemType as cst
from interpreter.utils.eval import eval_to_boolean
from interpreter.utils.param_decl import Param, ParamSet, LIST, BLOCK, Enum
from runtime.tum_except import ETUMSyntaxError
from runtime.string_queue import StringQueue
from runtime.stdout_redirect import stdio_redir
@@ -15,6 +16,12 @@ class TestItemParallelBranch(TestItemContainer):
"""One branch of a parallel item. Runs its children sequentially,
optionally waiting for a condition before starting."""
PARAMS = ParamSet(
Param("wait_for", kind=BLOCK,
doc="Optional block {condition, timeout} that defers the branch "
"start until the condition is truthy (or the timeout elapses)."),
)
def __init__(self, dict_item, parent=None, status_queue=None, filename=""):
super().__init__(cst.TYPE_PARALLEL_BRANCH, dict_item, parent, status_queue, filename=filename)
self._wait_condition = None
@@ -87,6 +94,15 @@ class TestItemParallel(TestItemContainer):
- ...
"""
PARAMS = ParamSet(
Param("branches", kind=LIST, required=True,
doc="List of branch blocks (each branch holds its own 'steps' "
"and optional 'wait_for')."),
Param("sync", kind=Enum("all", "any"), default="all",
doc="'all' (default) waits for every branch; 'any' returns as "
"soon as the first branch completes."),
)
def __init__(self, dict_item, parent=None, status_queue=None, filename=""):
branches = dict_item.get("branches", [])
if not branches:

View File

@@ -11,6 +11,7 @@ import api.testium as tm
from interpreter.utils.py_func_exec import PyFuncExecEngine
from interpreter.utils.api_srv import api_request
from interpreter.utils.constants import TestItemType as cst
from interpreter.utils.param_decl import Param, ParamSet, LIST
_PY_FUNC_CONTEXTS_KEY = "_py_func_contexts"
@@ -21,6 +22,21 @@ class TestItemPyFunc(TestItem):
Optional: context_id: <id> — share a persistent process with other py_func items using the same id.
"""
PARAMS = ParamSet(
Param("file", required=True,
doc="Path to the .py file containing the function."),
Param("func_name", required=True,
doc="Name of the function to call in the file."),
Param("param", kind=LIST,
doc="Arguments passed to the function. Each entry is expanded "
"before the call. Special tokens $(loop_param) / $(loop_index) "
"resolve from the surrounding cycle."),
Param("context_id", default=None,
doc="If set, the py_func subprocess is kept alive and reused by "
"every other py_func item with the same context_id — enables "
"shared in-memory state between successive calls."),
)
def __init__(self, dict_item, parent=None, status_queue=None, filename=""):
self._name = cst.TYPE_PY_FUNCTION.item_name
super().__init__(dict_item, parent, status_queue, filename=filename)

View File

@@ -2,6 +2,7 @@ from interpreter.test_items.test_item import test_run
from interpreter.test_items.test_result import TestValue
from interpreter.test_items.test_item_dialog_base import TestItemDialogBase, _is_text_mode, _is_interactive
from interpreter.utils.constants import TestItemType as cst
from interpreter.utils.param_decl import Param, ParamSet
from runtime.tum_except import item_load_context
@@ -9,6 +10,14 @@ class TestItemQuestionDialog(TestItemDialogBase):
"""dialog_question item usage.
dialog_question name: Nice question, question: "If OK, press OK, If not, press cancel"
"""
PARAMS = ParamSet(
Param("question", required=True,
doc="Yes/No prompt presented to the user."),
Param("auto_result", default=None,
doc="Batch-mode answer ('yes'/'no' or truthy/falsy). None ⇒ FAILURE."),
)
def __init__(self, dict_item, parent=None, status_queue=None, filename=""):
self._name = cst.TYPE_QUESTION_DLG.item_name
super().__init__(dict_item, parent, status_queue, filename=filename)

View File

@@ -3,9 +3,17 @@ from interpreter.test_items.test_item import (TestItem, test_run)
from interpreter.test_items.test_result import (TestValue)
from runtime.tum_except import ETUMSyntaxError
from interpreter.utils.constants import TestItemType as cst
from interpreter.utils.param_decl import Param, ParamSet, LIST
from interpreter.test_report.test_report import Export
class TestItemReport(TestItem):
PARAMS = ParamSet(
Param("export", kind=LIST, required=True,
doc="List of exporters to run (junit, sqlite, …). Each entry is a "
"mapping describing the exporter type and its parameters."),
)
def __init__(self, dict_item, parent = None, status_queue=None, filename=""):
self._name = cst.TYPE_REPORT.item_name
super().__init__(dict_item, parent, status_queue, filename=filename)

View File

@@ -10,6 +10,7 @@ from interpreter.test_items.test_item import (TestItem, test_run)
from interpreter.test_items.test_result import (TestValue)
import api.testium as tm
from interpreter.utils.constants import TestItemType as cst
from interpreter.utils.param_decl import Param, ParamSet
from runtime.tum_except import ETUMSyntaxError, ETUMRuntimeError, item_load_context
@@ -24,9 +25,15 @@ def _testium_launch_cmd():
appimage = os.environ.get("APPIMAGE")
if appimage:
return [appimage]
# Flatpak: re-launch via the Flatpak app id.
# Flatpak: re-launch via the Flatpak app id, but on the host side —
# the `flatpak` CLI cannot run inside our sandbox (no D-Bus access to the
# host Flatpak service, and the host binary would need host libs that are
# ABI-incompatible with the sandbox runtime). flatpak-spawn proxies the
# call to the host via org.freedesktop.Flatpak (allowed by --talk-name in
# the manifest).
if os.path.isfile("/.flatpak-info"):
return ["flatpak", "run", "org.testium.Testium"]
return ["flatpak-spawn", "--host",
"flatpak", "run", "org.testium.Testium"]
# PyInstaller frozen exe: sys.executable is the binary itself.
if getattr(sys, "frozen", False):
return [sys.executable]
@@ -51,6 +58,25 @@ def nowInBetween(start, end):
class TestItemRun(TestItem):
PARAMS = ParamSet(
Param("tum", required=True,
doc="Path to the .tum file launched in a fresh testium instance."),
Param("param_file", default="",
doc="Optional path to a param.yaml passed to the sub-instance."),
Param("log_file", default="",
doc="Path where the sub-instance writes its log."),
Param("report_file", default="",
doc="Path where the sub-instance writes its report."),
Param("start_time",
doc="HH:MM time of day after which the sub-instance may run."),
Param("end_time",
doc="HH:MM time of day after which the sub-instance no longer runs."),
Param("wait_for_exec",
doc="If true, block until the time window opens. Requires both "
"start_time and end_time."),
)
def __init__(self, dict_item, parent = None, status_queue=None, filename=""):
self._name = cst.TYPE_RUN.item_name
super().__init__(dict_item, parent, status_queue, filename=filename)

View File

@@ -11,6 +11,7 @@ from interpreter.test_items.item_actions import TestItemActions
from interpreter.test_items.item_actions.action import TestItemAction
from interpreter.utils.constants import TestItemType as cst
from interpreter.utils.eval import evaluate
from interpreter.utils.param_decl import Param, ParamSet, LIST
class TestItemPlotAction(TestItemAction):
@@ -21,6 +22,12 @@ class TestItemPlotAction(TestItemAction):
class TestItemPlotActionOpen(TestItemPlotAction):
PARAMS = ParamSet(
Param("log_path", default=None,
doc="Optional file to which the plot data are appended."),
)
def __init__(
self, action_name, dict_item, parent=None, status_queue=None, filename=""
):
@@ -57,6 +64,15 @@ class TestItemPlotActionOpen(TestItemPlotAction):
class TestItemPlotActionClose(TestItemPlotAction):
PARAMS = ParamSet(
Param("wait_dialog_exit", default=False,
doc="If true, the close action blocks until the user closes the "
"plot window (or timeout)."),
Param("timeout", default=-1,
doc="Seconds to wait when wait_dialog_exit is true. Negative ⇒ infinite."),
)
def __init__(
self, action_name, dict_item, parent=None, status_queue=None, filename=""
):
@@ -96,6 +112,20 @@ class TestItemPlotActionClose(TestItemPlotAction):
class TestItemPlotActionPeriodic(TestItemPlotAction):
PARAMS = ParamSet(
Param("period", required=True,
doc="Seconds between two calls of the periodic function."),
Param("file", required=True,
doc="Path to the .py file holding the periodic function."),
Param("func_name", required=True,
doc="Name of the periodic function."),
Param("param", kind=LIST,
doc="Arguments passed to the periodic function on each call."),
Param("eval", default="",
doc="Post-evaluation applied to the function's return value."),
)
def __init__(
self, action_name, dict_item, parent=None, status_queue=None, filename=""
):
@@ -169,6 +199,13 @@ class TestItemPlotActionAdd(TestItemPlotAction):
class TestItemPlotActionLastValues(TestItemPlotAction):
PARAMS = ParamSet(
Param("name", kind=LIST,
doc="List of plot variable names whose last sample is returned. "
"Result is stored in $(plv_<plot_name>) as a dict."),
)
def __init__(self, action_name, dict_item, parent=None, status_queue=None, filename=""):
super().__init__(
action_name, cst.TYPE_GRAPH_ACTION, dict_item, parent, status_queue, filename=filename
@@ -219,6 +256,13 @@ class TestItemPlotActionExport(TestItemPlotAction):
class TestItemPlot(TestItemActions):
PARAMS = ParamSet(
Param("plot_name", required=True,
doc="Identifier of the plot window — referenced by every nested "
"action and by $(plv_<plot_name>) for last-values output."),
)
def __init__(self, dict_item, parent=None, status_queue=None, filename=""):
super().__init__(
cst.TYPE_GRAPH, dict_item, parent, status_queue, filename=filename

View File

@@ -7,6 +7,7 @@ import api.testium as tm
from interpreter.test_items.test_item import (TestItem, test_run)
from interpreter.test_items.test_result import (TestValue)
from interpreter.utils.constants import TestItemType as cst
from interpreter.utils.param_decl import Param, ParamSet
from runtime.tum_except import ETUMSyntaxError, ETUMRuntimeError, item_load_context
class TestItemSleep(TestItem):
@@ -14,6 +15,15 @@ class TestItemSleep(TestItem):
sleep timeout: 10
"""
PARAMS = ParamSet(
Param("timeout", required=True,
doc="Duration to sleep. Number of seconds, or a string "
"like '1d 2h 30m 15s'."),
Param("dialog", default=False,
doc="If true, show a cancel dialog (GUI mode) or an interactive "
"Ctrl+C-able countdown (text mode)."),
)
def __init__(self, dict_item, parent = None, status_queue=None, filename=""):
self._name = cst.TYPE_SLEEP.item_name
super().__init__(dict_item, parent, status_queue, filename=filename)

View File

@@ -2,11 +2,23 @@ from interpreter.test_items.test_item import test_run
from interpreter.test_items.test_result import TestValue
from interpreter.test_items.test_item_dialog_base import TestItemDialogBase, _is_text_mode, _is_interactive
from interpreter.utils.constants import TestItemType as cst
from interpreter.utils.param_decl import Param, ParamSet, LIST
from runtime.tum_except import item_load_context
import api.testium as tm
class TestItemTestedRefsDialog(TestItemDialogBase):
PARAMS = ParamSet(
Param("question", required=True,
doc="Prompt asking the operator to enter the tested references."),
Param("reference", kind=LIST,
doc="Pre-filled list of references shown in the dialog."),
Param("auto_result", default=None,
doc="Batch-mode outcome: None ⇒ FAILURE, truthy ⇒ SUCCESS with "
"the pre-filled references."),
)
def __init__(self, dict_item, parent=None, status_queue=None, filename=""):
self._name = cst.TYPE_REFERENCE_DLG.item_name
super().__init__(dict_item, parent, status_queue, filename=filename)

View File

@@ -11,6 +11,7 @@ from interpreter.test_items.test_item import (TestItem, test_run, LOG_TEST_STOP,
from interpreter.test_items.test_result import (TestResult, TestValue)
from interpreter.test_items.test_item import test_data
from interpreter.utils.constants import TestItemType as cst
from interpreter.utils.param_decl import Param, ParamSet, LIST
from runtime.stdout_redirect import stdio_redir
class UnittestResult(TextTestResult):
@@ -95,6 +96,15 @@ class TestItemUnittestElement(TestItem):
class TestItemUnittestFile(TestItem):
PARAMS = ParamSet(
Param("test_file", required=True,
doc="Path to the Python unittest file (TestCase subclass)."),
Param("test_method", kind=LIST,
doc="Optional list of method names to restrict the run to. "
"When empty, every test_* method in the file is run."),
)
def __init__(self, dict_item, parent = None, status_queue=None, filename=""):
self._name = cst.TYPE_UNITTEST.item_name
super().__init__(dict_item, parent, status_queue, filename=filename)

View File

@@ -2,6 +2,7 @@ from interpreter.test_items.test_item import test_run
from interpreter.test_items.test_result import TestValue
from interpreter.test_items.test_item_dialog_base import TestItemDialogBase, _is_text_mode, _is_interactive
from interpreter.utils.constants import TestItemType as cst
from interpreter.utils.param_decl import Param, ParamSet
from runtime.tum_except import item_load_context
import api.testium as tm
@@ -10,6 +11,19 @@ class TestItemValueDialog(TestItemDialogBase):
"""dialog_value item usage.
dialog_value name: Enter value, question: "Which value did you measure?"
"""
PARAMS = ParamSet(
Param("question", required=True,
doc="Prompt shown above the value input field."),
Param("default", default="",
doc="Pre-filled value of the input field."),
Param("auto_result", default=None,
doc="Batch-mode outcome: None ⇒ FAILURE, 'cancel' ⇒ cancelled, "
"any other truthy ⇒ SUCCESS with auto_value."),
Param("auto_value", default=None,
doc="Value used in batch mode when auto_result is set."),
)
def __init__(self, dict_item, parent=None, status_queue=None, filename=""):
self._name = cst.TYPE_VALUE_DLG.item_name
super().__init__(dict_item, parent, status_queue, filename=filename)

View File

@@ -17,8 +17,11 @@ Public API
``reset()`` : clear the cache (mostly useful for tests)
"""
import atexit
import os
import shutil
import subprocess
import tempfile
import api.testium as tm
from interpreter.utils.paths import sys_app_path_lin, sys_app_path_win
@@ -30,20 +33,6 @@ from runtime.tum_except import ETUMRuntimeError
_PYTHON_CANDIDATES = ["python3", "python"]
_LUA_CANDIDATES = ["lua", "lua5.5", "lua5.4", "lua5.3", "lua5.2", "lua5.1"]
# When running inside a Flatpak, --filesystem=host-os mounts the host at
# /run/host (read-only). Binaries and libraries from the host are not on the
# sandbox PATH/LD_LIBRARY_PATH, so we probe and inject them explicitly.
_FLATPAK_HOST_DIRS = [
"/run/host/usr/local/bin",
"/run/host/usr/bin",
"/run/host/bin",
]
_FLATPAK_HOST_LIB_DIRS = [
"/run/host/usr/lib",
"/run/host/usr/lib64",
"/run/host/usr/local/lib",
]
# Inside an AppImage, AppRun prepends $APPDIR/usr/bin to PATH and exports a
# bundle-local PYTHONHOME / PYTHONPATH / LD_LIBRARY_PATH. We want py_func and
# lua_func to run under the *host* interpreter (not the bundled one), so we
@@ -64,78 +53,162 @@ def _in_appimage():
return "APPIMAGE" in os.environ
def apply_host_lua_paths(env):
"""Prepend host Lua module dirs to LUA_PATH / LUA_CPATH (Flatpak only).
Must be called after user-defined lua_env overrides are applied, so host
paths are always first regardless of user config. User-defined paths remain
in the variable but after the host ones.
"""
if not _in_flatpak():
return
_LUA_VERSIONS = ["5.5", "5.4", "5.3", "5.2", "5.1"]
_HOST = "/run/host/usr"
cpath_dirs, lpath_dirs = [], []
for v in _LUA_VERSIONS:
for base in [f"{_HOST}/lib/lua/{v}",
f"{_HOST}/lib64/lua/{v}",
f"{_HOST}/lib/x86_64-linux-gnu/lua/{v}"]:
cpath_dirs.append(f"{base}/?.so")
lpath_dirs.append(f"{_HOST}/share/lua/{v}/?.lua")
lpath_dirs.append(f"{_HOST}/share/lua/{v}/?/init.lua")
sep = ";"
host_cpath = sep.join(cpath_dirs)
host_lpath = sep.join(lpath_dirs)
# ;; keeps Lua's compiled-in defaults at the end as last resort
env["LUA_CPATH"] = host_cpath + sep + env.get("LUA_CPATH", ";;")
env["LUA_PATH"] = host_lpath + sep + env.get("LUA_PATH", ";;")
def apply_host_libs(env):
"""Prepare *env* for launching a host binary from inside our bundle.
"""Strip bundle-local entries from *env* so a host binary can run cleanly.
- Flatpak: prepend host library dirs to LD_LIBRARY_PATH so the dynamic
linker can find host .so files mounted under /run/host.
- AppImage: strip $APPDIR-prefixed entries from LD_LIBRARY_PATH and
PYTHONPATH and drop PYTHONHOME, so the host interpreter doesn't try
to load the bundled (incompatible) Python lib/site-packages.
- Otherwise: no-op.
Only meaningful for AppImage: removes $APPDIR-prefixed entries from
LD_LIBRARY_PATH / PYTHONPATH / PATH and drops PYTHONHOME, so the host
interpreter doesn't try to load the bundled (incompatible) Python
lib/site-packages. Flatpak is handled via flatpak-spawn --host instead
(see flatpak_host_spawn), so the sandbox env is irrelevant there.
"""
if _in_flatpak():
dirs = ":".join(d for d in _FLATPAK_HOST_LIB_DIRS if os.path.isdir(d))
if dirs:
existing = env.get("LD_LIBRARY_PATH", "")
env["LD_LIBRARY_PATH"] = dirs + (":" + existing if existing else "")
if not _in_appimage():
return
if _in_appimage():
appdir = os.environ.get("APPDIR", "")
if appdir:
for var, sep in (("LD_LIBRARY_PATH", ":"),
("PYTHONPATH", os.pathsep),
("PATH", os.pathsep)):
cur = env.get(var, "")
if not cur:
continue
cleaned = sep.join(
p for p in cur.split(sep)
if p and not p.startswith(appdir)
)
if cleaned:
env[var] = cleaned
else:
env.pop(var, None)
env.pop("PYTHONHOME", None)
appdir = os.environ.get("APPDIR", "")
if appdir:
for var, sep in (("LD_LIBRARY_PATH", ":"),
("PYTHONPATH", os.pathsep),
("PATH", os.pathsep)):
cur = env.get(var, "")
if not cur:
continue
cleaned = sep.join(
p for p in cur.split(sep)
if p and not p.startswith(appdir)
)
if cleaned:
env[var] = cleaned
else:
env.pop(var, None)
env.pop("PYTHONHOME", None)
# ---------- Flatpak: spawn on host outside the sandbox -----------------------
#
# Inside a Flatpak the sandbox glibc is incompatible with host shared libraries,
# so we can't run host Python/Lua under the sandbox runtime — `LD_LIBRARY_PATH`
# tricks hit a `_dl_call_libc_early_init` assertion. The supported way out is
# `flatpak-spawn --host`, which talks to the session-bus Flatpak D-Bus service
# (org.freedesktop.Flatpak.Development) and asks it to spawn a process in the
# host execution environment instead of inside our sandbox. The manifest must
# grant `--talk-name=org.freedesktop.Flatpak` for the D-Bus call to be allowed.
#
# The host process can't see our /app/ contents (sandbox-only), so when we
# spawn host Python/Lua to run `py_func` / `lua_func`, the cwd must be a
# directory both sides can reach. /tmp is shared (--filesystem=/tmp), so we
# stage the testium package there once per process and reuse it for every
# spawn. In source mode (testium under $HOME) the host already sees the
# original path, so we skip the copy.
_staged_testium_path = None
def _get_host_testium_path():
"""Return a path to the testium package that the host can read.
- Source / wheel / PyInstaller install under $HOME → return testium_path()
as-is (host sees the same path via --filesystem=home).
- Flatpak bundle (testium under /app/) → stage a copy under /tmp on first
call and reuse it for the rest of the process.
"""
global _staged_testium_path
if _staged_testium_path is not None:
return _staged_testium_path
# Imported lazily to avoid a circular import (paths.py -> api.testium).
from interpreter.utils.paths import testium_path
tp = testium_path()
if not tp.startswith("/app/"):
_staged_testium_path = tp
return tp
staged = tempfile.mkdtemp(prefix="testium_host_", dir="/tmp")
# copytree refuses to write into an existing dir unless dirs_exist_ok=True.
# mkdtemp creates the dir, so we copy *into* it.
for entry in os.listdir(tp):
src = os.path.join(tp, entry)
dst = os.path.join(staged, entry)
if os.path.isdir(src):
shutil.copytree(src, dst, symlinks=True)
else:
shutil.copy2(src, dst, follow_symlinks=False)
_staged_testium_path = staged
atexit.register(shutil.rmtree, staged, ignore_errors=True)
return staged
_FORWARDED_ENV_KEYS = (
"HOME", "USER", "LOGNAME", "TMPDIR",
"XDG_RUNTIME_DIR", "XDG_DATA_HOME", "XDG_CONFIG_HOME", "XDG_CACHE_HOME",
"DBUS_SESSION_BUS_ADDRESS", "DISPLAY", "WAYLAND_DISPLAY",
"LANG", "LC_ALL",
)
def flatpak_host_spawn(interp_bin, cmd_args, host_cwd, extra_env=None):
"""Build a flatpak-spawn --host command vector.
Args:
interp_bin: absolute path to the host interpreter (e.g. /usr/bin/python3).
cmd_args: list of arguments passed to the interpreter.
host_cwd: working directory on the host (must be reachable from host).
extra_env: optional {name: value} of env vars to set on the host side
in addition to the default forwarded set. Values of ""
unset the variable on the host.
Returns a list suitable for subprocess.Popen.
"""
spawn = ["flatpak-spawn", "--host", f"--directory={host_cwd}"]
forwarded = {}
for key in _FORWARDED_ENV_KEYS:
val = os.environ.get(key)
if val:
forwarded[key] = val
if extra_env:
forwarded.update(extra_env)
for k, v in forwarded.items():
if v == "":
spawn.append(f"--unset-env={k}")
else:
spawn.append(f"--env={k}={v}")
spawn.append(interp_bin)
spawn.extend(cmd_args)
return spawn
def _which_host_flatpak(name):
"""Resolve a binary name (or absolute path) on the host via flatpak-spawn.
We can't probe /run/host/... because (a) only host-os is mounted there,
not arbitrary paths like /scratch, and (b) returning a /run/host path
would be useless — the host-side spawn sees a different filesystem and
needs the host-native path anyway.
"""
if os.path.isabs(name):
cmd = flatpak_host_spawn("/bin/sh", ["-c", f'test -x "{name}"'],
host_cwd="/tmp")
try:
r = subprocess.run(cmd, capture_output=True, timeout=10)
except (FileNotFoundError, PermissionError, subprocess.TimeoutExpired):
return ""
return name if r.returncode == 0 else ""
cmd = flatpak_host_spawn("/bin/sh", ["-c", f'command -v "{name}"'],
host_cwd="/tmp")
try:
r = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
except (FileNotFoundError, PermissionError, subprocess.TimeoutExpired):
return ""
if r.returncode != 0:
return ""
return r.stdout.strip()
def _which(name):
if tm.OS() == "Windows":
return sys_app_path_win(name)
if _in_flatpak():
for d in _FLATPAK_HOST_DIRS:
p = os.path.join(d, name)
if os.path.isfile(p) and os.access(p, os.X_OK):
return p
return ""
return _which_host_flatpak(name)
if _in_appimage():
for d in _APPIMAGE_HOST_DIRS:
p = os.path.join(d, name)
@@ -146,14 +219,33 @@ def _which(name):
def _probe_env():
"""Subprocess env for probing host binaries (adds host libs in Flatpak)."""
"""Subprocess env for probing host binaries.
In AppImage we still need to scrub APPDIR-prefixed entries; in Flatpak we
delegate execution to the host via flatpak-spawn so the sandbox env doesn't
matter, but apply_host_libs is a no-op cost.
"""
env = os.environ.copy()
apply_host_libs(env)
return env
def _python_version(path):
cmd = [path, "-c", "import sys; print(sys.version_info[:3])"]
def _run_probe(cmd):
"""Run a probe command, dispatching through flatpak-spawn --host in Flatpak.
Returns (stdout, stderr) as str, or None on failure.
"""
if _in_flatpak():
spawn = flatpak_host_spawn(cmd[0], cmd[1:], host_cwd="/tmp")
try:
r = subprocess.run(
spawn, capture_output=True, text=True, timeout=10,
)
except (FileNotFoundError, PermissionError, subprocess.TimeoutExpired):
return None
if r.returncode != 0:
return None
return r.stdout, r.stderr
try:
r = subprocess.run(
cmd, capture_output=True, text=True,
@@ -161,8 +253,15 @@ def _python_version(path):
)
except (FileNotFoundError, PermissionError, subprocess.TimeoutExpired):
return None
return r.stdout, r.stderr
def _python_version(path):
out = _run_probe([path, "-c", "import sys; print(sys.version_info[:3])"])
if out is None:
return None
try:
return eval(r.stdout)
return eval(out[0])
except Exception:
return None
@@ -173,15 +272,11 @@ def _is_python3(path):
def _lua_version(path):
try:
r = subprocess.run(
[path, "-v"], capture_output=True, text=True, timeout=10,
env=_probe_env(),
)
except (FileNotFoundError, PermissionError, subprocess.TimeoutExpired):
out = _run_probe([path, "-v"])
if out is None:
return None
# On Windows the version banner goes to stderr.
line = r.stdout or r.stderr
line = out[0] or out[1]
try:
major, minor, _patch = line.split(" ")[1].split(".")
return (int(major), int(minor))
@@ -202,22 +297,33 @@ _SPECS = {
"lua": ("Lua 5.1+", "lua_bin", _LUA_CANDIDATES, _is_lua51),
}
# Cached per (name, override) so that runtime changes to gd[gd_key] —
# e.g. ``python_bin`` set from a YAML config file loaded *after*
# eval_proc has already resolved its own interpreter — are picked up by
# the next lookup instead of returning the stale, auto-discovered path.
# Long-lived subprocesses (eval_proc) keep whatever they captured at
# construction time, but every new PyProcessBase / FuncExecEngine spawned
# afterwards sees the current override.
_resolved = {}
def _resolve(name):
if name in _resolved:
return _resolved[name]
display, gd_key, candidates, validator = _SPECS[name]
override = tm.gd(gd_key, "") or ""
cached = _resolved.get(name)
if cached is not None and cached[0] == override:
return cached[1]
path = ""
if override:
# Absolute path: accept as-is (user knows exactly what they want).
# Bare name: resolve via _which() so the override stays host-only in
# Flatpak/AppImage instead of silently picking the bundled interpreter.
if os.path.isabs(override):
# In Flatpak we always defer to _which() so even absolute paths are
# checked from the host's perspective (the sandbox can't see e.g.
# /scratch/... paths that the user may have configured).
if os.path.isabs(override) and not _in_flatpak():
resolved = override if (os.path.isfile(override)
and os.access(override, os.X_OK)) else ""
else:
@@ -239,7 +345,7 @@ def _resolve(name):
path = p
break
_resolved[name] = path
_resolved[name] = (override, path)
return path

View File

@@ -47,9 +47,16 @@ class LuaProcessBase:
if self._process is not None:
raise ETUMRuntimeError("The function subprocess has already been started.")
func_proc_path = os.path.realpath(
os.path.join(subproc_path(), "lua_func")
)
# In Flatpak the host can't see /app/lib/testium/lua_func, so use a
# staged copy under /tmp (shared between sandbox and host).
if bins._in_flatpak():
func_proc_path = os.path.join(
bins._get_host_testium_path(), "lua_func"
)
else:
func_proc_path = os.path.realpath(
os.path.join(subproc_path(), "lua_func")
)
# POpen config
CUST_ENV = {
@@ -71,7 +78,6 @@ class LuaProcessBase:
env[k] = e
else:
env[k] = e + ";" + env.get(k, "")
bins.apply_host_lua_paths(env)
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.bind(("localhost", 0))
@@ -79,8 +85,7 @@ class LuaProcessBase:
sock.close()
# POpen params
params = [
self._lbin,
cmd_args = [
"main.lua",
"--timeout",
f"{self._timeout}",
@@ -91,14 +96,31 @@ class LuaProcessBase:
]
if tm.debug_enabled() and tm.gd("debug_rpc", False):
params.append("--verbose")
cmd_args.append("--verbose")
if bins._in_flatpak():
# Run on the host outside the sandbox: avoids glibc ABI mismatches
# between the Flatpak runtime and host shared libraries.
host_env = {
k: env[k] for k in ("LUA_PATH", "LUA_CPATH", "PATH")
if k in env and env[k]
}
params = bins.flatpak_host_spawn(
self._lbin, cmd_args, host_cwd=func_proc_path,
extra_env=host_env,
)
popen_kwargs = {}
else:
params = [self._lbin, *cmd_args]
popen_kwargs = {"env": env, "cwd": func_proc_path}
self._process = subprocess.Popen(
params, env=env, cwd=func_proc_path,
params,
stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
restore_signals=False,
**popen_kwargs,
)
# Route subprocess stdout/stderr (lua require failures, syntax
# errors, anything written to fd 1/2 before the in-script

View File

@@ -0,0 +1,175 @@
"""Declarative description of a test item's accepted parameters.
Each ``TestItem`` subclass declares its parameter surface as a class
attribute::
class TestItemFoo(TestItem):
PARAMS = ParamSet(
Param("bar", required=True, doc="The bar value."),
Param("baz", default=0, doc="Optional baz."),
Param("modes", kind=LIST, doc="Iterable of modes."),
Param("strategy", kind=ENUM("a", "b"), doc="..."),
Param("opts", kind=BLOCK, doc="Sub-block."),
)
The base ``TestItem.__init__`` consumes both ``COMMON_PARAMS`` (defined
in ``test_item.py``) and the subclass ``PARAMS`` to:
* warn on any key in the user's YAML that isn't declared anywhere
(catches typos like ``param_filee``);
* expose a machine-readable schema for documentation generation and,
eventually, an LSP server.
The descriptor is **purely about shape and naming**. Type coercion and
runtime checking of expanded values remain the responsibility of each
item's ``execute()`` method — most parameters are expressions
(``$(...)`` / ``<| ... |>``) whose effective type is only known after
expansion, so a static type would be misleading.
Validation of *values* (e.g. ``start_time`` must match HH:MM) can be
attached per-param via ``validate=lambda v: ...`` and is applied at
execution time on the expanded value, not at load time.
"""
from dataclasses import dataclass, field
from typing import Any, Callable, Optional, Union
# ---------- Parameter "kinds" -------------------------------------------------
#
# These describe the YAML *shape* expected for a parameter, not its
# semantic type. They drive the LSP completion (do we suggest a single
# value, a list, a sub-block, an enum picker?) and the unknown-param
# diagnostic; nothing more.
SCALAR = "scalar" # single value (string, number, bool, expression, ...)
LIST = "list" # YAML list — the historical ``getParamAll`` case
BLOCK = "block" # nested dict — e.g. ``cycle.exit:``
@dataclass(frozen=True)
class Enum:
"""Closed enumeration of acceptable scalar values."""
values: tuple
def __init__(self, *values):
# frozen=True forbids assignment; bypass via object.__setattr__.
object.__setattr__(self, "values", tuple(values))
def __repr__(self):
return f"Enum({', '.join(repr(v) for v in self.values)})"
Kind = Union[str, Enum]
# ---------- The descriptor ----------------------------------------------------
_MISSING = object()
@dataclass(frozen=True)
class Param:
"""Declarative description of one accepted parameter.
Attributes
----------
name : str
The YAML key.
kind : ``SCALAR`` (default) | ``LIST`` | ``BLOCK`` | ``Enum(...)``
The YAML shape expected.
required : bool
If True, missing the parameter is a load-time error.
default : Any
Default value when the parameter is absent. ``_MISSING`` when no
default was set (used to distinguish "absent" from "None").
doc : str
Free-form description used for hover / generated documentation.
validate : Optional[Callable[[Any], bool]]
Optional post-expansion validator, evaluated at ``execute()``
time on the effective (expanded) value. Returning ``False``
raises a clear error pointing at the param.
"""
name: str
kind: Kind = SCALAR
required: bool = False
default: Any = _MISSING
doc: str = ""
validate: Optional[Callable[[Any], bool]] = None
def has_default(self):
return self.default is not _MISSING
def to_schema(self):
"""Return a dict suitable for JSON Schema generation."""
s = {"name": self.name, "required": self.required, "doc": self.doc}
if isinstance(self.kind, Enum):
s["kind"] = "enum"
s["enum"] = list(self.kind.values)
else:
s["kind"] = self.kind
if self.has_default():
s["default"] = self.default
return s
class ParamSet:
"""Ordered, name-indexed collection of ``Param`` descriptors.
Supports concatenation (``COMMON_PARAMS + SUBCLASS_PARAMS``) to
merge the common surface with each item's own params. Later
declarations override earlier ones (so a subclass can tighten a
common param's docstring without redeclaring everything).
"""
def __init__(self, *params):
self._params = {}
for p in params:
self.add(p)
def add(self, param):
if not isinstance(param, Param):
raise TypeError(f"ParamSet only accepts Param instances, got {type(param).__name__}")
self._params[param.name] = param
def __iter__(self):
return iter(self._params.values())
def __contains__(self, name):
return name in self._params
def __getitem__(self, name):
return self._params[name]
def names(self):
return tuple(self._params.keys())
def __add__(self, other):
if not isinstance(other, ParamSet):
return NotImplemented
merged = ParamSet()
merged._params = {**self._params, **other._params}
return merged
def to_schema(self):
return [p.to_schema() for p in self._params.values()]
# ---------- Validation primitives --------------------------------------------
def unknown_keys(declared, user_dict):
"""Return the user-provided keys that are not declared in *declared*.
*declared* is a ``ParamSet``; *user_dict* is the raw YAML mapping
for the item. Unknown keys catch typos and obsolete parameters.
"""
if not isinstance(user_dict, dict):
return ()
return tuple(k for k in user_dict.keys() if k not in declared)
def missing_required(declared, user_dict):
"""Return the names of declared required params absent from *user_dict*."""
if not isinstance(user_dict, dict):
return tuple(p.name for p in declared if p.required)
return tuple(p.name for p in declared if p.required and p.name not in user_dict)

View File

@@ -61,14 +61,18 @@ class PyProcessBase:
if sock is not None:
sock.close()
# Add the path of the subprocess (root sources of testium)
tstium_path = os.path.realpath(testium_path())
func_proc_path = os.path.realpath(subproc_path())
# In Flatpak the host can't see /app/lib/testium, so use a staged copy
# under /tmp (shared between sandbox and host) for both cwd and as the
# root in PYTHONPATH. Outside Flatpak the original paths are used.
if bins._in_flatpak():
tstium_path = bins._get_host_testium_path()
func_proc_path = tstium_path
else:
tstium_path = os.path.realpath(testium_path())
func_proc_path = os.path.realpath(subproc_path())
env["PYTHONPATH"] = tstium_path + os.pathsep + self._ppath + os.pathsep + env.get("PYTHONPATH", "")
params = [
self._pbin,
# "-m",
cmd_args = [
"py_func",
"-p",
f"{self._port}",
@@ -77,14 +81,31 @@ class PyProcessBase:
]
if tm.debug_enabled() and tm.gd("debug_rpc", False):
params.append("-v")
cmd_args.append("-v")
if bins._in_flatpak():
# Run on the host outside the sandbox: avoids glibc ABI mismatches
# between the Flatpak runtime and host shared libraries.
host_env = {
k: env[k] for k in ("PYTHONPATH", "PATH")
if k in env and env[k]
}
params = bins.flatpak_host_spawn(
self._pbin, cmd_args, host_cwd=func_proc_path,
extra_env=host_env,
)
popen_kwargs = {}
else:
params = [self._pbin, *cmd_args]
popen_kwargs = {"env": env, "cwd": func_proc_path}
self._process = subprocess.Popen(
params, env=env, cwd=func_proc_path,
params,
stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
restore_signals=False,
**popen_kwargs,
)
# Route subprocess stdout/stderr (early-startup errors,
# unhandled exceptions, anything written to fd 1/2 before the

View File

@@ -165,11 +165,14 @@ def env_init():
_constants_init()
def update_global(config_files, defines, gui_defaults, silent=False):
"""Global dict updated with the content of the config file and a dict provided.
this function returns the resulting dict.
def apply_overrides(defines, gui_defaults):
"""Push GUI defaults then CLI defines into the global dict.
Extracted from update_global so it can be called *before* eval_proc
starts: interpreter overrides (python_bin, lua_bin) must be visible
to bins.python_bin() on its first lookup, which happens during
eval_process_init.
"""
# GUI preferences applied first
for k, v in gui_defaults.items():
try:
val = ast.literal_eval(v)
@@ -177,7 +180,6 @@ def update_global(config_files, defines, gui_defaults, silent=False):
val = v
tm.setgd(k, val)
# Then command line defines
for k, v in defines.items():
try:
val = ast.literal_eval(v)
@@ -185,6 +187,14 @@ def update_global(config_files, defines, gui_defaults, silent=False):
val = v
tm.setgd(k, val)
def update_global(config_files, defines, gui_defaults, silent=False):
"""Global dict updated with the content of the config file and a dict provided.
this function returns the resulting dict.
"""
# GUI preferences applied first, then command line defines
apply_overrides(defines, gui_defaults)
# Then the configuration files
# load global dic before test item
_feed_gd_with_params(config_files, silent)

View File

@@ -0,0 +1,21 @@
"""Helpers for Qt file/directory dialogs.
In Flatpak the native QFileDialog goes through the XDG document portal,
which returns ``/run/user/UID/doc/.../<file>`` and only exposes the
selected file — sibling files (param.yaml, scripts, recent paths in
preferences, ...) are unreachable. Forcing Qt's own non-native dialog
makes it walk the real filesystem mounted via ``--filesystem=home``
and return a regular path.
"""
import os
from PySide6.QtWidgets import QFileDialog
def options():
"""Default ``QFileDialog`` options for the current runtime."""
opts = QFileDialog.Options()
if os.path.isfile("/.flatpak-info"):
opts |= QFileDialog.Option.DontUseNativeDialog
return opts

View File

@@ -3,6 +3,7 @@ from PySide6.QtWidgets import QDialog, QFileDialog
from PySide6.QtGui import QFont
from main_win.preference_win.preference_core_win import Ui_preferenceWindow
from main_win import file_dialog
import interpreter.utils.settings as prefs
@@ -193,6 +194,7 @@ class PrefWindow(QDialog):
self,
caption="Select the default report directory",
dir=self.ui.editDefaultReportPath.text(),
options=file_dialog.options(),
)
if path:
self.ui.editDefaultReportPath.setText(path)
@@ -203,6 +205,7 @@ class PrefWindow(QDialog):
self,
caption="Select the default log directory",
dir=self.ui.editDefaultLogPath.text(),
options=file_dialog.options(),
)
if path:
self.ui.editDefaultLogPath.setText(path)
@@ -213,6 +216,7 @@ class PrefWindow(QDialog):
self,
caption="Select the python interpreter",
dir=self.ui.editPythonPath.text(),
options=file_dialog.options(),
)
if path:
self.ui.editPythonPath.setText(path)
@@ -220,7 +224,10 @@ class PrefWindow(QDialog):
@Slot()
def on_butLuaPath_pressed(self):
path, _ = QFileDialog.getOpenFileName(
self, caption="Select the lua interpreter", dir=self.ui.editLuaPath.text()
self,
caption="Select the lua interpreter",
dir=self.ui.editLuaPath.text(),
options=file_dialog.options(),
)
if path:
self.ui.editLuaPath.setText(path)

View File

@@ -9,6 +9,7 @@ from PySide6.QtWidgets import QApplication, QFileDialog, QProgressDialog
from interpreter.process import TestProcess
from interpreter.utils.test_ctrl import TestSetController
from main_win.test_controller_service import TestControllerService
from main_win import file_dialog
import interpreter.utils.settings as prefs
from runtime.tum_except import ETUMFileError, ETUMRuntimeError
@@ -212,17 +213,9 @@ class TestFileManager:
d = ""
if w.testFile is not None:
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(
w, "Open the test file", d,
"testium file (*.tum);;All Files (*)", options=options
"testium file (*.tum);;All Files (*)", options=file_dialog.options()
)
if file_name:
self.reload(file_name)

View File

@@ -37,6 +37,7 @@ from interpreter.utils.icons import icon_prefix
from main_win.test_run.outlog import OutLog
from main_win.test_run.test_run import ThreadTestStatus
from main_win import file_dialog
import interpreter.utils.settings as prefs
from runtime.stdout_redirect import stdio_redir
import api.testium as tm
@@ -484,7 +485,8 @@ class MainWindow(QMainWindow, Ui_MainWindow):
else:
initialPath = None
fileName, _ = QFileDialog.getSaveFileName(
self, "Path to Log file", initialPath, "Log Files (*.log);;All Files (*)"
self, "Path to Log file", initialPath, "Log Files (*.log);;All Files (*)",
options=file_dialog.options(),
)
if fileName:
shutil.copy(self.logFileName, fileName)
@@ -525,7 +527,8 @@ class MainWindow(QMainWindow, Ui_MainWindow):
else:
initialPath = None
fileName, _ = QFileDialog.getSaveFileName(
self, "Path to log file", initialPath, "Log Files (*.log);;All Files (*)"
self, "Path to log file", initialPath, "Log Files (*.log);;All Files (*)",
options=file_dialog.options(),
)
if fileName:
self.editLogFilePath.setText(fileName)

View File

@@ -1,10 +1,76 @@
# Validation
This directory contains the necessary material to run the testium validation.
This directory contains the testium validation suite. A single set of
items (`items/`), fixtures and post-processing (`post_execution.py`) is
re-used across every packaging channel.
Here is the documentation on how to configure the validation, run it and check that the
results are correct.
## Running the suite
# Tests
```sh
./test/validation/run.sh # default mode = source
./test/validation/run.sh --mode wheel
./test/validation/run.sh --mode pyinstaller
./test/validation/run.sh --mode flatpak
./test/validation/run.sh --mode appimage
```
TBD
On Windows (only `source`, `wheel`, `pyinstaller` are supported):
```bat
test\validation\run.bat --mode pyinstaller
```
Pass `clean` as the **first** argument to recreate the validation venv
from scratch (useful after a system Python upgrade):
```sh
./test/validation/run.sh clean --mode flatpak
```
Any extra arguments after the mode flag are forwarded to testium.
## Modes
| Mode | What it launches | Prerequisite |
|---------------|-------------------------------------------------------------|------------------------------------------------------------------|
| `source` | `python3 src/testium` via the project's `run.sh` | none — works straight out of the repo |
| `wheel` | `python -m testium` inside a dedicated wheel venv | `./build_all.sh` produced `dist/testium-<v>-py3-none-any.whl` |
| `pyinstaller` | `dist/testium-<v>` (frozen binary) | `./build_all.sh` produced the PyInstaller binary |
| `flatpak` | `flatpak run --command=testium org.testium.Testium` | the Flatpak bundle is installed (`flatpak install --user dist/testium-<v>.flatpak`) |
| `appimage` | `dist/Testium-<v>-x86_64.AppImage` | `./build_all.sh` produced the AppImage |
Each mode writes its results to a distinct report file
(`validation-<mode>.sqlite` / `validation-<mode>-<item>.xml`), so you
can run several modes in a row without clobbering previous reports.
## How `python_bin` is pinned
Every test-execution subprocess (inline `<| ... |>` evaluation,
`py_func`, `cycle`, `post_execution`, …) is routed through a dedicated
venv at `${TMPDIR:-/tmp}/testium-validation-venv`. The venv is created
with `--system-site-packages` so existing system packages stay visible,
then `junit-xml` is pip-installed for `post_execution.py`.
This is a **host** venv. In every mode (including Flatpak) the
test-execution subprocesses end up running on the host — directly for
source/wheel/pyinstaller/appimage, and via `flatpak-spawn --host` for
Flatpak — so the same venv works across modes. The wheel mode
additionally creates a separate `testium-wheel-venv-<v>` to hold the
installed wheel; that one is only used to launch testium itself.
## What is checked
The `venv` item under `items/venv/` asserts that the validation venv is
actually being used:
* `python_bin` is set in the global dict.
* The eval subprocess (used for `<| ... |>` expressions) has
`sys.executable == python_bin`, `sys.prefix == dirname(dirname(python_bin))`,
and `sys.prefix != sys.base_prefix` (i.e. is actually inside a venv).
* A `py_func` subprocess passes the same three checks.
These checks use `abspath`/`normpath` rather than `realpath` on
purpose: the venv's `bin/python3` is a symlink to the host interpreter,
so `realpath` would map both venv and non-venv interpreters to the same
target. `sys.prefix != sys.base_prefix` is the venv-specific marker
that distinguishes the two cases.

View File

@@ -0,0 +1 @@
no_param: Null

View File

@@ -0,0 +1,53 @@
# venv test: assert that the dedicated validation venv is the python
# being used for every test-execution subprocess (eval_proc / py_func /
# cycle / ...). The venv path is pinned by ``-d python_bin=...`` in
# test/validation/run.sh (or run.bat).
#
# We use ``abspath``/``normpath`` rather than ``realpath`` on purpose:
# the venv's ``bin/python3`` is a symlink to the host python, so
# realpath would map every venv interpreter to the same system path and
# the comparison would silently pass even *without* a venv.
# ``sys.prefix != sys.base_prefix`` is the venv-specific marker that
# catches that case.
- check:
name: python_bin is set in the global dict
key: $(test)_PASS
values:
- <| bool(r"$(python_bin)") |>
- check:
name: eval_proc subprocess runs in the validation venv
key: $(test)_PASS
values:
- <| os.path.normpath(os.path.abspath(sys.executable)) == os.path.normpath(os.path.abspath(r"$(python_bin)")) |>
- check:
name: eval_proc sys.prefix matches python_bin venv root
key: $(test)_PASS
values:
- <| os.path.normpath(os.path.abspath(sys.prefix)) == os.path.dirname(os.path.dirname(os.path.normpath(os.path.abspath(r"$(python_bin)")))) |>
- check:
name: eval_proc is actually inside a venv (sys.prefix != sys.base_prefix)
key: $(test)_PASS
values:
- <| os.path.normpath(os.path.abspath(sys.prefix)) != os.path.normpath(os.path.abspath(sys.base_prefix)) |>
- py_func:
name: py_func subprocess runs in the validation venv
key: $(test)_PASS
file: $(test_path)$(psep)verify_venv.py
func_name: check_sys_executable
- py_func:
name: py_func sys.prefix matches python_bin venv root
key: $(test)_PASS
file: $(test_path)$(psep)verify_venv.py
func_name: check_sys_prefix_in_venv
- py_func:
name: py_func is actually inside a venv
key: $(test)_PASS
file: $(test_path)$(psep)verify_venv.py
func_name: check_is_venv

View File

@@ -0,0 +1,62 @@
import os
import sys
import py_func.tm as tm
def _norm(p):
# normpath + normcase, without resolving symlinks. realpath() would
# follow the venv's ``python3`` symlink to ``/usr/bin/python3.X`` and
# defeat the comparison.
return os.path.normcase(os.path.normpath(os.path.abspath(p)))
def _venv_dir():
# python_bin is at ``<venv>/(bin|Scripts)/python*`` so the venv root
# is two levels above the executable.
exe = tm.gd("python_bin", "")
if not exe:
return ""
return os.path.dirname(os.path.dirname(_norm(exe)))
def check_sys_executable():
"""py_func subprocess: sys.executable must match the configured python_bin."""
expected = _norm(tm.gd("python_bin", ""))
actual = _norm(sys.executable)
if expected and actual == expected:
return True
return (
-1,
f"sys.executable={actual!r} differs from python_bin={expected!r}",
)
def check_sys_prefix_in_venv():
"""py_func subprocess: sys.prefix must match the venv root derived
from python_bin (two levels up from the executable)."""
venv = _venv_dir()
if not venv:
return (-1, "python_bin is not set in the global dict")
actual = _norm(sys.prefix)
if actual == venv:
return True
return (
-1,
f"sys.prefix={actual!r} is not the validation venv {venv!r}",
)
def check_is_venv():
"""py_func subprocess: confirm we are inside a venv, i.e. sys.prefix
differs from sys.base_prefix. This catches the case where python_bin
happens to be a system interpreter and the path-based check would
pass trivially."""
actual = _norm(sys.prefix)
base = _norm(sys.base_prefix)
if actual != base:
return True
return (
-1,
f"sys.prefix == sys.base_prefix == {actual!r}: not running in a venv",
)

131
test/validation/run.bat Normal file
View File

@@ -0,0 +1,131 @@
@echo off
SETLOCAL EnableExtensions EnableDelayedExpansion
REM Runs the testium validation suite against any installable channel of
REM testium on Windows (source, wheel, pyinstaller).
REM
REM Usage:
REM test\validation\run.bat [clean] [--mode MODE] [extra testium args]
REM
REM clean remove the validation venv before recreating it
REM (must be the first argument; useful after a Python upgrade)
REM
REM --mode MODE which testium build to validate. One of:
REM source (default) project's run.bat (src\testium)
REM wheel dist\testium-<v>-py3-none-any.whl
REM pyinstaller dist\testium-<v>.exe (or dist\testium-<v>)
REM
REM Every test-execution subprocess runs in a dedicated host venv under
REM %TEMP%\testium-validation-venv (created with --system-site-packages,
REM then junit-xml is pip-installed for post_execution.py).
REM
REM The report file is suffixed with the mode so consecutive runs in
REM different modes don't overwrite each other.
SET "SCRIPT_DIR=%~dp0"
IF "%SCRIPT_DIR:~-1%"=="\" SET "SCRIPT_DIR=%SCRIPT_DIR:~0,-1%"
SET "PROJECT_DIR=%SCRIPT_DIR%\..\.."
SET /P VERSION=<"%PROJECT_DIR%\src\VERSION"
REM ---------- arg parsing ----------------------------------------------------
SET "MODE=source"
SET "CLEAN=0"
IF /I "%~1"=="clean" (
SET "CLEAN=1"
SHIFT
)
SET "EXTRA="
:PARSE_ARGS
IF "%~1"=="" GOTO ARGS_DONE
IF /I "%~1"=="--mode" (
SET "MODE=%~2"
SHIFT
SHIFT
GOTO PARSE_ARGS
)
SET "EXTRA=!EXTRA! "%~1""
SHIFT
GOTO PARSE_ARGS
:ARGS_DONE
REM ---------- locate host python ---------------------------------------------
SET "PYTHON_EXE="
py --version >nul 2>&1
IF %ERRORLEVEL% EQU 0 (
SET "PYTHON_EXE=py"
GOTO PYTHON_FOUND
)
python --version >nul 2>&1
IF %ERRORLEVEL% EQU 0 (
SET "PYTHON_EXE=python"
GOTO PYTHON_FOUND
)
echo ERROR: Python could not be found on this system.
exit /b 1
:PYTHON_FOUND
REM ---------- validation venv -------------------------------------------------
SET "VENV_DIR=%TEMP%\testium-validation-venv"
IF "%CLEAN%"=="1" IF EXIST "%VENV_DIR%" rmdir /s /q "%VENV_DIR%"
IF NOT EXIST "%VENV_DIR%" (
echo Creating validation venv at %VENV_DIR%
%PYTHON_EXE% -m venv --system-site-packages "%VENV_DIR%"
IF !ERRORLEVEL! NEQ 0 (
echo ERROR while creating the validation venv.
exit /b 1
)
call "%VENV_DIR%\Scripts\pip" install --quiet --upgrade pip
call "%VENV_DIR%\Scripts\pip" install --quiet junit-xml
)
SET "VENV_PYTHON=%VENV_DIR%\Scripts\python.exe"
REM ---------- shared "tail" forwarded to every launcher -----------------------
REM Reports are stamped with the mode so successive runs don't clobber each other.
SET "TAIL=-b -d "python_bin=%VENV_PYTHON%" -d "validation_report_file=validation-%MODE%" -- "%SCRIPT_DIR%\main.tum"%EXTRA%"
REM ---------- per-mode launcher ----------------------------------------------
echo -- validation mode: %MODE%
IF /I "%MODE%"=="source" GOTO MODE_SOURCE
IF /I "%MODE%"=="wheel" GOTO MODE_WHEEL
IF /I "%MODE%"=="pyinstaller" GOTO MODE_PYI
echo ERROR: unknown --mode '%MODE%'. Expected: source ^| wheel ^| pyinstaller.
exit /b 1
:MODE_SOURCE
call "%PROJECT_DIR%\run.bat" %TAIL%
exit /b %ERRORLEVEL%
:MODE_WHEEL
SET "WHEEL=%PROJECT_DIR%\dist\testium-%VERSION%-py3-none-any.whl"
IF NOT EXIST "%WHEEL%" (
echo ERROR: wheel not found at %WHEEL% -- run build_all.sh first.
exit /b 1
)
SET "WHEEL_VENV=%TEMP%\testium-wheel-venv-%VERSION%"
IF "%CLEAN%"=="1" IF EXIST "%WHEEL_VENV%" rmdir /s /q "%WHEEL_VENV%"
IF NOT EXIST "%WHEEL_VENV%" (
echo Creating wheel venv at %WHEEL_VENV%
%PYTHON_EXE% -m venv --system-site-packages "%WHEEL_VENV%"
call "%WHEEL_VENV%\Scripts\pip" install --quiet --upgrade pip
call "%WHEEL_VENV%\Scripts\pip" install --quiet "%WHEEL%"
)
"%WHEEL_VENV%\Scripts\python.exe" -m testium %TAIL%
exit /b %ERRORLEVEL%
:MODE_PYI
SET "PYI_BIN=%PROJECT_DIR%\dist\testium-%VERSION%.exe"
IF NOT EXIST "%PYI_BIN%" SET "PYI_BIN=%PROJECT_DIR%\dist\testium-%VERSION%"
IF NOT EXIST "%PYI_BIN%" (
echo ERROR: PyInstaller binary not found in %PROJECT_DIR%\dist -- run build_all.sh first.
exit /b 1
)
"%PYI_BIN%" %TAIL%
exit /b %ERRORLEVEL%

143
test/validation/run.sh Executable file
View File

@@ -0,0 +1,143 @@
#!/bin/bash
# Runs the testium validation suite against any installable channel of
# testium (source, wheel, pyinstaller, flatpak, appimage).
#
# Usage:
# ./test/validation/run.sh [clean] [--mode MODE] [extra testium args]
#
# clean remove the validation venv before recreating it
# (must be the first argument; useful after a Python upgrade)
#
# --mode MODE which testium build to validate. One of:
# source (default) src/testium via project run.sh
# wheel dist/testium-<v>-py3-none-any.whl
# pyinstaller dist/testium-<v>
# flatpak installed org.testium.Testium
# appimage dist/Testium-<v>-*.AppImage
#
# Every test-execution subprocess (inline <| ... |>, py_func, cycle,
# post_execution, ...) runs in a dedicated host venv under
# /tmp/testium-validation-venv. That venv is shared across modes —
# even Flatpak reaches it via flatpak-spawn --host. The validation venv
# is created with --system-site-packages so existing system packages
# (PySide6, lxml, ...) stay visible, then junit-xml is pip-installed
# for post_execution.py.
#
# The report file is suffixed with the mode (e.g. validation-flatpak.sqlite)
# so consecutive runs in different modes don't overwrite each other.
set -e
SCRIPT_PATH="$(readlink -f "$0")"
SCRIPT_DIR="$(realpath "$(dirname "$SCRIPT_PATH")")"
PROJECT_DIR="$(realpath "$SCRIPT_DIR/../..")"
VERSION="$(cat "$PROJECT_DIR/src/VERSION")"
# ---------- arg parsing -------------------------------------------------------
MODE=source
if [ "${1:-}" = "clean" ]; then
CLEAN=1
shift
else
CLEAN=0
fi
EXTRA=()
while [ $# -gt 0 ]; do
case "$1" in
--mode)
MODE="$2"
shift 2
;;
--mode=*)
MODE="${1#--mode=}"
shift
;;
*)
EXTRA+=("$1")
shift
;;
esac
done
# ---------- validation venv ---------------------------------------------------
VENV_DIR="${TMPDIR:-/tmp}/testium-validation-venv"
if [ "$CLEAN" -eq 1 ]; then
rm -rf "$VENV_DIR"
fi
if [ ! -d "$VENV_DIR" ]; then
echo "Creating validation venv at $VENV_DIR"
python3 -m venv --system-site-packages "$VENV_DIR"
"$VENV_DIR/bin/pip" install --quiet --upgrade pip
"$VENV_DIR/bin/pip" install --quiet junit-xml
fi
VENV_PYTHON="$VENV_DIR/bin/python3"
# ---------- per-mode launcher -------------------------------------------------
case "$MODE" in
source)
CMD=("$PROJECT_DIR/run.sh")
;;
wheel)
WHEEL="$PROJECT_DIR/dist/testium-${VERSION}-py3-none-any.whl"
if [ ! -f "$WHEEL" ]; then
echo "ERROR: wheel not found at $WHEEL — run ./build_all.sh first." >&2
exit 1
fi
WHEEL_VENV="${TMPDIR:-/tmp}/testium-wheel-venv-${VERSION}"
if [ "$CLEAN" -eq 1 ]; then
rm -rf "$WHEEL_VENV"
fi
if [ ! -d "$WHEEL_VENV" ]; then
echo "Creating wheel venv at $WHEEL_VENV"
python3 -m venv --system-site-packages "$WHEEL_VENV"
"$WHEEL_VENV/bin/pip" install --quiet --upgrade pip
"$WHEEL_VENV/bin/pip" install --quiet "$WHEEL"
fi
CMD=("$WHEEL_VENV/bin/python" -m testium)
;;
pyinstaller)
PYI_BIN="$PROJECT_DIR/dist/testium-${VERSION}"
if [ ! -x "$PYI_BIN" ]; then
echo "ERROR: PyInstaller binary not found at $PYI_BIN — run ./build_all.sh first." >&2
exit 1
fi
CMD=("$PYI_BIN")
;;
flatpak)
if ! flatpak info --user org.testium.Testium &>/dev/null \
&& ! flatpak info --system org.testium.Testium &>/dev/null; then
echo "ERROR: org.testium.Testium is not installed." >&2
echo " flatpak install --user $PROJECT_DIR/dist/testium-${VERSION}.flatpak" >&2
exit 1
fi
CMD=(flatpak run --command=testium org.testium.Testium)
;;
appimage)
APPIMAGE=$(ls -1t "$PROJECT_DIR/dist"/Testium-"${VERSION}"-*.AppImage 2>/dev/null | head -1)
if [ -z "$APPIMAGE" ] || [ ! -x "$APPIMAGE" ]; then
echo "ERROR: no AppImage for version $VERSION under $PROJECT_DIR/dist — run ./build_all.sh first." >&2
exit 1
fi
CMD=("$APPIMAGE")
;;
*)
echo "ERROR: unknown --mode '$MODE'. Expected: source|wheel|pyinstaller|flatpak|appimage." >&2
exit 1
;;
esac
# ---------- launch ------------------------------------------------------------
echo "-- validation mode: $MODE"
echo "-- launch: ${CMD[*]}"
exec "${CMD[@]}" -b \
-d "python_bin=$VENV_PYTHON" \
-d "validation_report_file=validation-$MODE" \
-- "$SCRIPT_DIR/main.tum" "${EXTRA[@]}"