README: add the AppImage release, an 'Editor support' section (testium lsp / schema, [lsp] extra, the testium_assist client), note the LSP ships in every channel. DESIGN.md: document build_all parallelism, --ram tmpfs mode and the Ctrl+C job-tree kill. release_note.txt: 0.2 entries for the language server and the build_all parallel/--ram work. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
35 KiB
Testium — Design Context
What is testium
Testium is a test sequencer/runner written in Python. It executes YAML-based test scripts (".tum" files) and supports two execution modes:
- GUI mode (default, no flag): PySide6 Qt application (
src/testium/main_win/) - Batch mode (
-b/--batch-execution): headless, non-interactive, runs tests and exits
Run from repo root: ./run.sh (Linux) or run.bat / run.ps1 (Windows).
Direct invocation: python3 -m src/testium [-b] <test_file.tum>
Architecture
Entry point
src/testium/__init__.py — parses CLI args, dispatches to the two modes.
multiprocessing.set_start_method('spawn') is called early (required for Linux dialog subprocesses).
Core execution
src/testium/interpreter/process.py—TestProcess(multiprocessing.Process): runs the test in a child process. Stdout is redirected via aStringQueue→ pipe → parent thread (capture_stdout) that writes to real stdout.src/testium/interpreter/batch.py—Batch: parent-side orchestrator for-bmode. Creates themsg_queue, startsTestProcess, waits for the "finished" signal.src/testium/interpreter/test_set.py—TestSet: builds and executes the tree of test items.src/testium/interpreter/test_items/test_item*.py— one file per test item type (check, cycle, group, let, unittest, py_func, lua_func, console, git, dialogs, report, parallel, …).
Communication channels (parent ↔ child process)
msg_queue(multiprocessing.Queue): carries status messages from child to parent.- Item status:
{"id": <non-None>, "name": ..., "status": "started"|"finished", ...} - Global dict updates:
{"type": "gd_update"|"gd_delete", "key": ..., "value": ...}— no "id" key - Process finished:
{"id": None, "name": "test_process", "status": "finished"}— id key present butNone
- Item status:
tst_ctrl(TestSetController): sends control commands (execute, stop, pause, close, …) from parent to child.- stdout pipe (
multiprocessing.Pipe): streams test output from child back to parent'scapture_stdoutthread.
Stdout pipeline (batch mode)
test item print()
→ sys.stdout (StringQueue, in child)
→ send_stdout thread (child) → pipe → capture_stdout thread (parent)
→ print() → sys.stdout (TermLog wrapping real stdout, in parent)
→ terminal
Global dictionary
src/testium/interpreter/utils/globdict.py — shared state accessible from test scripts via tm.gd() / tm.setgd(). When set_update_queue() is active (during test execution), every setgd/delgd on a non-_-prefixed key pushes a message to msg_queue.
Coloring (-o disables it)
src/testium/interpreter/utils/termlog.py — TermLog wraps stdout with colorama-based line coloring (PASS=green, FAIL=red, WARN=yellow, …). Applied in parent process for batch mode. Auto-detects light/dark terminal background via (in order): COLORFGBG env var, OSC 11 query, default dark.
Dialog items in batch mode
All dialog items (dialog_image, dialog_question, dialog_references, dialog_value, dialog_message, dialog_choices, dialog_note) follow this rule in non-interactive text mode (-b):
auto_resultdefined in the.tum→ result controlled by it (ok/yes→ SUCCESS,cancel/no→ FAIL)auto_resultabsent → FAIL with"Dialog not supported in batch mode"sleep dialog: true→ exception: just sleeps normally, no GUI, no failure
auto_result (and auto_value for value/note dialogs) is intended for the validation test suite (test/validation/) only.
parallel item
src/testium/interpreter/test_items/test_item_parallel.py — runs multiple branches concurrently.
- parallel:
name: My parallel block
sync: all # all: wait for all; any: stop as soon as one finishes
no_fail: true # (optional) don't propagate branch failures to parent
branches:
- name: Branch A
wait_for: # (optional) poll condition before starting
condition: <| expr |>
timeout: 10
steps:
- ...
- name: Branch B
steps:
- ...
TestItemParallel(TestItemContainer): mutatesdict_item["steps"]to inject syntheticparallel_branchitems soload_test_recursivelyloads branches normally as children.TestItemParallelBranch(TestItemContainer): container for one branch.wait_forpolls every 0.1s up totimeoutseconds before running steps.sync: anycalls_stop_branch_recursively()on all other branches when one actually runs (SUCCESS/FAILURE). ANORUNbranch (disabled, condition not met) never wins the race.- Each branch runs in a daemon thread; the parent waits with
.join(). - Branches stopped late (e.g. user disabled them in the GUI, or another sync:any branch already won) go through the normal
branch.stop() + branch.execute()path so they always produce a clean DB entry viaaddTest(). - Exceptions raised in a branch's
execute()are caught byrun_branch, logged to stdout, and converted to aFAILUREresult so they never disappear silently. sync: allignoresNORUNbranches when computing success (matches Group/Cycle semantics): only an actualFAILUREfails the parallel.TestItemSleepis interruptible (pollsself._is_stoppedin a loop) sosync: anycan stop slow branches quickly.py_funcandconsoleitems are not interruptible; their full duration is observed before the branch returns.
TestItemContainer base class
src/testium/interpreter/test_items/test_item_container.py — shared base for Group, Cycle, Parallel, and ParallelBranch. Provides _run_children_sequentially() which handles stop-on-failure, executedOnStop items, and returns (TestResult, stopped_bool).
Report threading
src/testium/interpreter/test_report/test_report.py — SQLite report with thread-safe writes:
sqlite3.connect(..., check_same_thread=False)self._lock = threading.Lock()guards the SQLiteINSERTonly.- Per-item log capture (
stdio_redir.read()) is naturally race-free thanks to per-thread buffers (seeStdoutProxy).
Thread-aware stdout (StdoutProxy)
src/testium/runtime/stdout_redirect.py — when log_stored: True, intercept() installs a StdoutProxy as sys.stdout/sys.stderr instead of a single shared StringQueue. The proxy:
- Holds one
StringQueueper thread (registered viaregister_thread(buffer=...)). The main thread uses a default buffer; each parallel branch's thread registers its own at start and unregisters at end.stdio_redir.read()reads the calling thread's buffer →addTest()of an item running in branch X reads X's clean, non-interleaved output. - For the live stream (terminal in batch / GUI panel), prefixes every line emitted from a branch's thread with
[<branch_name>]so concurrent branches stay readable. - Exposes
write/writeln/flush(Python 3.14'sunittestcallsstream.writeln()directly without_WritelnDecorator).
Subprocess API contract (py_func / lua_func)
User test scripts running inside a py_func or lua_func subprocess must use the JSON-RPC bridge to interact with testium state:
- Python:
import py_func.tm as tm— auto-generates wrappers for every function inruntime/api.py:SUPPORTED_API.tm.gd/tm.setgd/tm.delgdgo through JSON-RPC to the parent. - Lua:
local tm = require("tm")— same idea on the Lua side.
api.testium is the main-process implementation; it is not exposed to subprocesses by design (not bundled in PyInstaller, not on the subprocess PYTHONPATH in pip-installed mode either when isolation is preserved). An import attempt from a subprocess script is a code smell and is detected by test/validation/items/isolation/.
To add a new API call usable from subprocesses:
- Add the function to
api/testium.py - Add its name to
SUPPORTED_APIinruntime/api.py - It is auto-exposed via JSON-RPC by
interpreter/utils/api_srv.pyand auto-wrapped bypy_func/tm.py:_make_api
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 and cache. The cache is keyed by(name, override)so that a later change togd[python_bin](typically when aparam.yamlsets 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/pythonandlua/lua5.5/lua5.4/lua5.3/lua5.2/lua5.1).ensure(*names): called byTestSet._validate_runtime_deps()at test load. Always requirespython(the eval engine always runs); requiresluaonly if alua_funcitem 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 |
|---|---|
src/testium/__init__.py |
CLI entry, mode dispatch |
src/testium/interpreter/batch.py |
-b mode orchestrator |
src/testium/interpreter/process.py |
Child test process |
src/testium/interpreter/test_set.py |
Test tree builder/executor |
src/testium/interpreter/test_items/test_item_container.py |
Base class for container items |
src/testium/interpreter/test_items/test_item_parallel.py |
parallel and parallel_branch items |
src/testium/interpreter/utils/globdict.py |
Global variable dict |
src/testium/interpreter/utils/termlog.py |
Terminal color output |
src/testium/runtime/stdout_redirect.py |
StdioRedirect singleton (stdio_redir) |
src/testium/runtime/string_queue.py |
Thread-safe string buffer used for stdout redirection |
src/testium/api/testium.py |
Public API for test scripts (tm.*) |
src/testium/py_func/ |
Python subprocess for py_func items (sandboxed: imports only runtime/ and py_func/) |
src/testium/lua_func/ |
Lua subprocess scripts for lua_func items |
Package layout
The whole project is a single Python package under src/testium/:
src/testium/
├── __init__.py / __main__.py
├── runtime/ internal plumbing (jrpc, stdout_redirect, string_queue, tum_except, api)
├── api/ public SDK exposed to test scripts (`import api.testium as tm`)
├── interpreter/ test execution engine (NOT visible to py_func/lua_func)
├── main_win/ GUI (NOT visible to py_func/lua_func)
├── py_func/ subprocess code for python_func items
└── lua_func/ subprocess scripts for lua_func items (data files)
subproc_path() and testium_path() both return the package directory. The py_func subprocess is launched with cwd=that directory and python3 py_func. The contract that py_func/ and lua_func/ only depend on runtime/ (no interpreter, main_win, api, testium) is enforced by test/validation/items/isolation/.
GUI icons (main_win)
Icons live in src/testium/main_win/resources/ with three theme variants:
| Folder | Theme index | Usage |
|---|---|---|
color/ |
0 (default) | Coloured icons |
black/ |
1 | Black silhouette on transparent |
white/ |
2 | White silhouette on transparent (LA mode) |
Icons are 64×64 PNG. Black variants: RGBA with RGB=(0,0,0), alpha varies. White variants: LA with luminance=255, alpha varies.
The mapping item-type → icon filename is in _ITEM_CONFIG (src/testium/main_win/test_tree_items/test_tree_item.py). At runtime, icon_prefix() returns :/color, :/black, or :/white (Qt resource prefix) based on the user preference.
All icons must be declared in src/testium/main_win/resources/testium_core_win.qrc (one entry per theme section). After any QRC change, regenerate the compiled resource file:
cd src/testium/main_win/resources
pyside6-rcc testium_core_win.qrc -o testium_core_win_rc.py
Icons are assigned once when the test file is loaded (not updated live on theme change — a file reload is required).
run item
src/testium/interpreter/test_items/test_item_run.py — launches a .tum file in a new testium instance (-b in batch mode, -r in GUI mode). Result:
- PASS if the sub-instance launched and ran to completion (exit code is ignored)
- FAIL if the file is not found,
wait_for_execis set withoutstart_time/end_time, the time window was not reached, or any other launch error
The sub-test's own pass/fail result is intentionally not propagated.
The interpreter and entry point used to spawn the sub-instance are picked automatically by _testium_launch_cmd() based on how the parent was started (AppImage → $APPIMAGE; Flatpak → flatpak run; PyInstaller → the frozen binary; source/wheel → [sys.executable, abspath(sys.argv[0])]). The user cannot override either via the YAML — selecting a different testium binary or Python from a sub-test was removed because it was either ill-defined (bundle modes have no separable Python) or could mismatch the parent's environment in surprising ways.
Report exporters & plugins
src/testium/interpreter/test_report/test_report.py — _EXPORTER_REGISTRY dict maps a format name (cmd key in the YAML report.export) to a lazy loader. Built-ins: text, json, junit (needs junit_xml), html (needs lxml). sqlite is the storage layer, no-op as an export.
Third-party plugins are discovered at module import via importlib.metadata.entry_points(group="testium.exporters") — installing a wheel that declares such an entry point is enough, no testium config change needed:
[project.entry-points."testium.exporters"]
my_format = "my_pkg:MyExporter"
Exporter contract: __init__(self, name, con, path, pats, keys, no_header=False) — the class does its work in __init__ and writes to path.
Behaviour on errors:
- Unknown format → info line
[report] Export skipped: format "X" not found. Available: ..., run continues. - Optional dependency missing → same info line with a pip-install hint, run continues.
A real-world test plugin lives at test/validation/fake_exporter/ (CSV exporter, auto-installed by scripts/build_env.sh and exercised by test/validation/items/report_plugin/).
Packaging
Four distribution channels coexist, all sharing the single src/testium/ package and the single src/requirements.txt dependency list:
| Channel | Where | Build | Notes |
|---|---|---|---|
Wheel (pip install) |
src/pyproject.toml |
python -m build |
Vanilla Python package; entry point testium = "testium:main". |
| PyInstaller binary | package/pyinstaller/ |
build.sh |
Single ~130 MB binary. py_func, runtime, lua_func bundled at _MEIPASS root so the host Python can find them when launched as python3 py_func. api/interpreter are not exposed (subprocess isolation). |
| Flatpak | package/flatpak/ |
build.sh (uses flatpak-builder) |
KDE 6.10 runtime. The bundled Python runs only the main process; py_func / lua_func MUST run under the host interpreter (no Python/Lua bundled). Produces a distributable .flatpak bundle. |
| AppImage | package/appimage/ |
build.sh (Debian Bookworm container via Podman/Docker) |
Bundles Python 3.11 for the main process; py_func / lua_func MUST run under the host interpreter. Build runs in a container so it works on Arch / any non-Debian host. |
The .deb work-in-progress lives in package/deb/:
test_distro.sh debian:bookworm | debian:trixie | ubuntu:24.04spins up a Docker/Podman container, reports system package availability, falls back to pip for what's missing (pyside6on bookworm/ubuntu,telnetlib3,junit_xml), runs the validation suite. Currently green on the three targets.
Building all channels (build_all.sh)
build_all.sh builds every artifact into dist/ (manual PDF, wheel, PyInstaller binary, Flatpak bundle, AppImage). It reuses scripts/build_env.sh + set_env.sh so the venv at test/tmp/.venv stays the single source of Python deps; build/pyinstaller/sphinx/linuxdoc (and pygls, via the [lsp] extra) are installed there on demand. A step is skipped if its artifact already exists; --clean forces a rebuild.
- Parallelism (default). A serial prep phase does everything that writes the shared venv (the
pip installs) plus the Flatpak runtime install and the wheel (the AppImage installs it). Then manual + PyInstaller + Flatpak + AppImage build concurrently — they only read the venv, so there is no concurrent-pip race. Per-step output goes todist/.build-logs/<step>.log; results print in completion order (wait -n), and a failing step's log is dumped at the end.--serialbuilds one at a time. Ctrl+C is trapped to kill each job's whole process tree (subshell + grandchildren: podman container, flatpak-builder, pyinstaller), so no orphans survive. --ram(slow/flash storage). Redirects the build scratch to/dev/shmand skips UPX, a large win when building from a USB stick / SD card (I/O-bound on flash):TMPDIR+PIP_CACHE_DIR, the PyInstaller--workpath(PYI_WORKPATH), and a tmpfs bind-mount at the in-container AppImage AppDir (APPIMAGE_APPDIR_TMPFS); UPX is disabled viaTESTIUM_NO_UPX(read by the.spec). Flatpak is excluded —flatpak-buildermounts its state dir withrofiles-fuseand FUSE cannot mount on/dev/shm(fusermount: Permission denied), so it builds on disk. Eachpackage/*/build.shhonours these env vars with on-disk defaults, so behaviour is unchanged without--ram; the tmpfs scratch is freed on exit. On a RAM-limited machine combine with--serial.
Host-only py_func / lua_func in sandboxed bundles (Flatpak, AppImage)
The bundled Python (Flatpak's runtime python, AppImage's python3.11) is reserved for the main process only. Subprocesses (py_func, lua_func, git, the run item's sub-instance) must use the host's interpreters and tools so user-installed modules (pyserial, junit_xml, …) are visible. This is enforced by interpreter/utils/bins.py:
_in_flatpak()(checks/.flatpak-info) and_in_appimage()(checksAPPIMAGEenv var) detect the sandbox.- Flatpak: the sandbox glibc/ABI is incompatible with arbitrary host shared libraries, so we cannot run host binaries inside the Flatpak runtime —
LD_LIBRARY_PATHinjection trips a_dl_call_libc_early_initassertion. The supported way out isflatpak-spawn --host, a stub on$PATHinside every Flatpak that proxies anexecover D-Bus to the host'sorg.freedesktop.Flatpakservice. The manifest grants--talk-name=org.freedesktop.Flatpakso the call is allowed. Helpers:flatpak_host_spawn(interp, args, host_cwd, extra_env=…)builds the spawn command vector with a curated set of forwarded env vars (HOME,USER,DISPLAY,DBUS_SESSION_BUS_ADDRESS, …) plus any explicit overrides._get_host_testium_path()returns a path to the testium package the host can read. In Flatpak the package lives under/app/lib/testiumwhich the host cannot see, so the package is staged once per process under/tmp/testium_host_*(/tmpis shared) and reused. In source / wheel / PyInstaller installs under$HOMEthe original path is returned untouched._which_host_flatpak(name)resolves a binary by spawningcommand -von the host (ortest -xfor absolute paths) — sandbox-visible probing under/run/host/...is unreliable (onlyhost-osis mounted; user paths like/scratcharen't there)._python_version()and_lua_version()go through_run_probe()which dispatches toflatpak-spawnin Flatpak so validation happens against the actual host interpreter.py_process.py/lua_process.pystart()useflatpak_host_spawnwithhost_cwd = _get_host_testium_path()[+/lua_func]and forwardPYTHONPATH/LUA_PATH/LUA_CPATH/PATHas--env=arguments.- The
runitem's_testium_launch_cmd()prefixesflatpak run org.testium.Testiumwithflatpak-spawn --hostso the sub-instance is launched by the host'sflatpakCLI, not by an unworkable in-sandboxflatpakbinary.
- AppImage: we are directly on the host filesystem, so the regular discovery on
/usr/local/bin,/usr/bin,/binsuffices.apply_host_libs(env)strips$APPDIR-prefixed entries fromLD_LIBRARY_PATH/PYTHONPATH/PATHand dropsPYTHONHOMEso the host Python doesn't try to load the bundled stdlib/site-packages. - User overrides (
python_bin/lua_binin globdict): in Flatpak, both bare names and absolute paths go through_which()so they are validated on the host side (the sandbox can't see e.g./scratch/...). Outside Flatpak, absolute paths are accepted as-is and bare names go through PATH discovery. - If the host has no python3/lua,
ensure()raisesETUMRuntimeErrorat test load with the candidate list — no silent fallback to a bundled interpreter. py_process.pyadditionally popsPYTHONUSERBASE(set to/var/data/pythonby the Flatpak runtime, which would hide~/.local/lib/...).
Declarative test item parameters
Each TestItem subclass declares its accepted parameters as a class attribute PARAMS = ParamSet(Param(...), ...) (interpreter/utils/param_decl.py). The descriptor carries the parameter name, kind (SCALAR — the default and may be omitted; LIST; BLOCK; Enum("a", "b", ...)), required flag, default, and free-form doc. There is no Python type in the descriptor on purpose: most parameter values are expressions ($(...) / <| ... |>) whose effective type is only known after expansion, so a static type would be misleading. Post-expansion validate=lambda v: ... callbacks are available as an opt-in for the rare cases where a runtime check is warranted (e.g. a specific format).
TestItem.COMMON_PARAMS (in test_item.py) declares the 14 parameters accepted by every item: name, doc, skipped, key, stop_on_failure, execute_on_stop, process_result, store_result, expected_result, no_fail, report, condition, steps, and the internal seq_filename injected by the loader. The base class concatenates COMMON_PARAMS + subclass.PARAMS in _validate_declared_params() and:
- emits a
tm.print_warn(...)listing the accepted names when an unknown key appears in the user YAML (catches typos likeparam_filee); - raises
ETUMSyntaxError(with the.tumsource as context) when arequired=Trueparam is missing.
Validation is opt-in per subclass: while a subclass keeps PARAMS = None (the base-class default), the check is skipped entirely. This kept the migration incremental — items can be visited one by one without forcing a big-bang change. All structured items have been migrated; only the "unstructured-body" classes (TestItemConsoleWrite/WriteLn which carry the message as the raw value, TestItemPlotActionAdd/Export which take arbitrary plot-data keys, TestItemUnittestElement which is internally instantiated with dict_item=None) intentionally remain unvalidated.
Diagnostics are currently warnings for unknown params so an out-of-tree .tum with a pre-existing typo doesn't suddenly fail. The flip to a hard error is a one-line change in _validate_declared_params() once the user is comfortable.
Action items follow the same declarative principle. A TestItemActions parent (console, plot, json_rpc) declares its nested actions as a class attribute ACTIONS = {yaml_key: action_class} (e.g. {"open": TestItemConsoleOpen, "write": …}), mirroring PARAMS. The base TestItemActions.__init__ seeds self.action_classes from type(self).ACTIONS; the imperative register_actions(**…) method is retained only as an escape hatch for actions that can't be known at class-definition time (none today). Because the action classes are always defined above their parent in the module, the class-level dict resolves without forward-reference gymnastics.
The schema is the realized source of truth for the LSP server (testium lsp), the testium schema CLI dump, and future auto-generated manual sections: ParamSet.to_schema() returns the JSON-Schema-shaped representation, and lsp/schema.py reads both PARAMS and ACTIONS purely from class attributes — no inspect.getsource/AST parsing. This is what lets the full schema (including nested actions) survive a frozen PyInstaller build where the .py source isn't on disk.
Language server (testium lsp) across channels
The testium_assist editor extension is a thin LSP client that spawns testium lsp and talks JSON-RPC over stdio, so the language server must work from every distribution channel. Two requirements:
pygls(+lsprotocol,cattrs,attrs,typing_extensions) must be bundled. It is the pyproject[lsp]extra (kept optional so a plainpip install testiumstays lean), wired into each full-app channel:build_env.shinstalls it into the sharedtest/tmp/.venv(covers source run and the PyInstaller build env); the AppImage installs the wheel as…whl[lsp]; the Flatpak adds apython3-lsppip module (network-at-build, consistent with the manifest's global--share=network); the PyInstaller.specforce-collects the submodules viacollect_submodules+ explicithiddenimports(including the lazily-importedlsp,lsp.server,lsp.schema).- The schema must build without source — handled by the declarative
PARAMS/ACTIONSabove; PyInstaller is the only channel that strips.pysource, and it no longer matters.
test/validation/lsp_check.py enforces both per channel: run.sh calls it before launching the suite, asserting that <channel> schema returns JSON whose console/plot/json_rpc items still carry their actions, and that <channel> lsp answers an initialize request with capabilities (and never reports the pygls dependency missing). So ./test/validation/run.sh --mode flatpak|pyinstaller|appimage now fails loudly if a channel ships a broken or pygls-less language server.
Version reporting (interpreter/utils/version.py)
Both Flatpak and AppImage export TESTIUM_VERSION from a launcher (Flatpak: launcher script in org.testium.Testium.yaml; AppImage: runtime.env in AppImageBuilder.yml). get_testium_version() checks /.flatpak-info / APPIMAGE and reads TESTIUM_VERSION rather than relying on package metadata or repo introspection.
Recent fixes / notable changes
build_all.sh: builds the four heavy channels in parallel (serial prep for the shared venv + wheel), results in completion order, Ctrl+C kills the whole job tree;--ramputs the build scratch on tmpfs (/dev/shm) + skips UPX for fast builds on USB/SD storage (Flatpak excluded — rofiles-fuse can't mount tmpfs). See the "Building all channels" section.- LSP across packaging channels:
testium lsp(and thetestium_assisteditor extension that spawns it) now works from source, wheel, PyInstaller, Flatpak and AppImage. Two enablers — (1) action items declare a class-levelACTIONS = {key: class}registry (likePARAMS), solsp/schema.pybuilds the full schema from class attributes with noinspect.getsource/AST (which broke under frozen PyInstaller); (2) the[lsp]extra (pygls) is wired into every full-app channel.test/validation/lsp_check.py, run byrun.shbefore the suite, asserts per-channel thatschemakeeps its actions andlspanswersinitialize. See the matching architecture sections. - Declarative test item parameters (v0.2): each
TestItemsubclass exposes aPARAMS = ParamSet(...)class attribute consumed by the base__init__. Catches unknown YAML keys (typo warnings listing the accepted names) and missing required params (load-time errors with.tumcontext). Lays the schema foundation for a future LSP server and auto-generated manual sections. See the matching architecture section. - Flatpak:
py_func/lua_func/runsub-instance now execute on the host viaflatpak-spawn --host. The previous attempt to inject host lib dirs into the sandbox'sLD_LIBRARY_PATHwas abandoned — host shared libs are ABI-incompatible with the Flatpak runtime's glibc and would trip_dl_call_libc_early_init. The manifest gained--talk-name=org.freedesktop.Flatpakso the spawn proxy call is allowed. The testium package is staged once per process under/tmp(shared with the host) so the host interpreter can locatepy_func/lua_func. - Validation suite: single entry point with
--mode source|wheel|pyinstaller|flatpak|appimageto validate every packaging channel against the same items. Per-mode report filenames prevent clobbering. - Restructure: single
src/testium/Python package (was 4 sibling top-levels:testium,lib,py_func,lua_func).lib/→runtime/,libs/→api/.pip installnow produces a cleansite-packages/testium/with no top-level pollution;.luafiles travel viapackage_data. bins.py: centralised resolution + cache of externalpython3/luabinaries. Replaces the scatteredtm.gd("python_bin")/tm.gd("lua_bin")dance and the duplicated discovery logic inpy_process.py/lua_process.py. Validates at test load viaTestSet._validate_runtime_deps()so missing interpreters fail fast.- Subprocess API contract: user scripts in
py_func/lua_funcuse the JSON-RPC bridge (py_func.tm/ Luatm) — neverapi.testium/interpreter.*directly.SUPPORTED_APIextended withOS,get_main_dir,init_timestamp,timestamp,timestamp_as_secso subprocess scripts have the same surface as main-process code. - Report exporter plugin registry (
test_report.py):_EXPORTER_REGISTRY+entry_points("testium.exporters")discovery. Missing format → info line, run continues. - About dialog rework:
QVBoxLayout(resizable), version + dirty/branch info in aQLabel(auto-sized), copyright + clickable EUPL-1.2 link. test_ctrl.control(): drain stale responses (left over from polledloaded()afterclear()race) instead of failing on a wrong cmd key — fixes a "Unexpected return error in test set controller" seen in GUI mode after a fast reload.lua_process.py: stderr no longer DEVNULL'd so actual Lua errors (missingcjson/socket) surface instead of "Connection refused".run_post_exec: failure message usesprint_warn(wasprint_debug— silent in non-debug runs).- Python 3.11 compat: replaced PEP 701 nested-quote f-strings (e.g.
f"... {d["k"]} ...") with single-quote inner strings or string concatenation. parallelitem: new item withsync: all|any,wait_for, daemon threads,_stop_branch_recursively(). Each branch thread registers a per-thread stdout buffer.parallel_branchicon: distinct single-arrow icon (parallel_branch.png).parallelF1 panel:stepsstripped from each branch dict.test_item_container.py: shared base class extracted from Group/Cycle.test_item_sleep.py: interruptible loop sosync: anycan stop slow branches quickly.stdout_redirect.py:StdoutProxy(thread-aware buffers + branch-prefixed live output,writeln()for Python 3.14 unittest).test_report.py: thread-safe SQLite INSERT for parallel branch concurrency.terminal.py: deleted —-m/--terminalmode removed.batch.py: premature finish bug ongd_update(no"id"key) — fix uses"id" in m and m["id"] is None.batch.py:control("loaded")deadlock on TestProcess crash — fix uses daemon thread +threading.Event+is_alive()polling.termlog.py: light/dark terminal auto-detection (COLORFGBG, OSC 11) + write residue bug.- Dialog items:
auto_result/auto_valuefor non-interactive text mode; dialogs withoutauto_resultFAIL immediately in batch. runitem: renamedtum_fime→tum; removedstdout=PIPEdeadlock; PASS on any completed subprocess.unittestitem: renamed fromunittest_file.- GUI test tree: check and fold state preserved across same-file reloads.
- Licence: EUPL-1.2.
- Interpreter override timing:
apply_overrides()extracted fromupdate_global()and called byprocess.py:run()beforeeval_process_init(), so-d python_bin=…/ GUI prefs reachbins.python_bin()on its first lookup.bins._resolve()cache is now keyed by(name, override)so laterparam.yamlchanges are picked up by subsequently constructed engines.
Validation tests
Located in test/validation/. Two entry points:
./test/validation/run.sh [clean] [--mode MODE] [extra args] # 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 same item set is reused across every packaging channel — --mode source|wheel|pyinstaller|flatpak|appimage selects which testium binary launches the suite (source is the default, invoking the project's run.sh). Each mode stamps its results into a distinct report file (validation-<mode>.sqlite, validation-<mode>-<item>.xml) so successive runs in different modes don't clobber each other. Prerequisites (PyInstaller binary built, Flatpak bundle installed, …) are checked before launch with a hint pointing at build_all.sh. On Windows only source, wheel, pyinstaller are supported.
The run.sh / run.bat wrappers create a dedicated host 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 that venv. testium itself keeps running in its own environment for the chosen mode. The venv is shared across modes because every test-execution subprocess ends up on the host either directly (source/wheel/pyinstaller/appimage) or via flatpak-spawn --host (flatpak). clean as the first argument recreates the venv. wheel mode also creates a separate testium-wheel-venv-<v> to hold the installed package.
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
See src/requirements.txt. Key ones: pyside6, pyyaml, jinja2, colorama, gitpython, pexpect, matplotlib.