33 Commits

Author SHA1 Message Date
2b0c4b5ee0 release 2026-06-01 23:48:56 +02:00
59e63e1338 fix(flatpak): console on host + dialog persistence
- term console via flatpak-spawn --host so host venvs resolve (bins.host_console_command)
- QSettings sync() before subprocess kill in choices/tested-refs dialogs
- console regression test: fails on the in-sandbox 0.2.1 console

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 23:42:48 +02:00
de32a524da docs: testium_assist install instructions (Open VSX / VSCode)
Manual (modes.rst) and README: install the extension from Open VSX in
VSCodium/Cursor/etc., and as a .vsix by hand in Microsoft VSCode; note
that testium must be on PATH or set via testium.serverPath.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 23:56:34 +02:00
2515213b14 release preparation 2026-05-31 16:15:52 +02:00
0376b77494 fix(gui): show the testium icon in the GNOME task bar (Wayland)
Set the app id via setDesktopFileName so the window stops inheriting the
launcher's class ("python3" under the AppImage), which is what GNOME was
keying the wrong icon off. On native Wayland the task-bar icon comes from
an installed desktop file matched to the app id (setWindowIcon is
ignored there), so on Linux drop an idempotent desktop entry + 256px icon
under ~/.local/share. Flatpak keeps its own id/desktop; Windows / macOS
use the window icon. No-op off Linux.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 16:14:04 +02:00
f2eedb5606 docs: add 0.2.1 release note (load-time optimisations + fix)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 15:33:13 +02:00
f02616dc3a perf(load): flatten step list in one pass; fix nested-list duplication
load_test_recursively expanded nested lists and included 'sequence'
entries by splicing each into the step list and rebuilding the whole
list every time (O(n^2)). The list branch also rebuilt after an in-place
splice, duplicating entries when a nested list held more than one item.

Replace both with a single linear _flatten_actions pass. Build phase
~12% faster at 6k items; the real fix is the duplication (a nested
2-element list now yields a,b,c,d not a,b,c,c,d). Validation suite
identical (post-exec SUCCESS, same verdicts/tracebacks).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 14:40:46 +02:00
5adba7fcd5 perf(load): use libyaml CLoader when available
Base the TUM loaders (and the param-file load) on yaml.CLoader when
PyYAML is built with libyaml, falling back to the pure-Python Loader
otherwise. Same ParserError/ScannerError, same custom !include
constructors. YAML parse time ~8x lower; validation suite identical
(same verdicts, same 8 expected-fail tracebacks, post-exec SUCCESS).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 11:22:26 +02:00
5086aa6c0e perf(load): cache compiled jinja templates, render in memory
Shared jinja Environment + compiled-template cache keyed on (path, mtime,
size), and render to an in-memory StringIO instead of a temp file.
Behaviour unchanged (validation suite passes); template time -10..40x,
total load -20..30% on template-heavy trees.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 10:41:42 +02:00
ef49789780 test: add load-time benchmark (jinja/include trees)
Generator + in-process harness timing the real loader's three stages and
template/YAML call counts, across tunable profiles. cases/ git-ignored;
see test/benchmark/README.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 10:41:42 +02:00
6e31ae971a removed unused robustness. 2026-05-31 10:17:54 +02:00
e989d131ad validation: install the wheel with the [lsp] extra in wheel mode
The wheel channel was installed without [lsp], so 'testium lsp' reported pygls
missing. Install testium[lsp] in the wheel venv — validating the wheel's
language-server-capable form, like the AppImage installs ...whl[lsp].

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 16:11:50 +02:00
cc561e961a manual: document the language server + parameter validation (0.2)
modes.rst: new 'Language server (editor support)' section — testium lsp /
testium schema, the testium_assist client, the [lsp] extra. tum_syntax.rst:
a note on parameter validation (unknown-key warning, missing-required error).
Builds clean (sphinx html, no RST errors).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 15:43:44 +02:00
87066fabd6 readme: drop duplicate AppImage section from the main merge
The 'main' merge (bd1cd03) brought a more detailed AppImage entry (with the
libfuse2 requirement); my earlier branch had added a simpler one, leaving two.
Keep main's, drop the duplicate, retain the language-server note.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 15:35:57 +02:00
bd1cd03334 Merge branch 'main' of ssh://git.beafrancois.fr:8329/v-and-v/testium 2026-05-30 15:31:30 +02:00
097b17124b docs: README editor-support + AppImage, DESIGN build_all, release note 0.2
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>
2026-05-30 13:18:32 +02:00
c950b8f3ca build_all: report parallel results in completion order (wait -n -p)
Previously the reaping loop waited on jobs in array order, so a finished build's
OK/FAILED line was delayed until the loop reached its PID (e.g. appimage done
but unreported while flatpak still ran). Use 'wait -n -p' to print each result
as soon as that build finishes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 11:12:37 +02:00
523a69698b build_all --ram: exclude flatpak from tmpfs (rofiles-fuse can't mount /dev/shm)
flatpak-builder mounts its state dir via rofiles-fuse; FUSE mounts fail on
/dev/shm ('fusermount: Permission denied'). So --ram no longer redirects the
flatpak dirs — it builds on disk as before. PyInstaller workpath, AppImage
AppDir and TMPDIR/PIP_CACHE_DIR still go to tmpfs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 10:56:16 +02:00
ab3058d789 build_all --ram: move flatpak state dir to tmpfs too (same-fs requirement)
flatpak-builder hardlinks between its state dir and the build dir, so they must
share a filesystem. With only the build dir on tmpfs it errored ('state dir not
on the same filesystem as the target dir'). Move .flatpak-builder to tmpfs as
well via FLATPAK_STATEDIR; its download cache no longer persists across --ram
runs, which is the accepted trade for the tmpfs speedup.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 10:50:57 +02:00
f748dae369 build_all: clean Ctrl+C in parallel mode (kill job trees on INT/TERM)
Trap INT/TERM around the parallel wait recursively kills each job's process
tree (subshell + grandchildren: podman container, flatpak-builder, pyinstaller),
then exits 130 — the EXIT trap frees the tmpfs scratch. Verified: SIGINT leaves
no orphan processes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 10:46:11 +02:00
46583f5622 build_all: --ram mode (build scratch on tmpfs) for slow storage
Redirect the per-channel build scratch to /dev/shm and skip UPX, a big win when
building from a USB stick / SD card (I/O-bound on flash):
- TMPDIR + PIP_CACHE_DIR -> tmpfs
- PyInstaller: --workpath -> tmpfs (PYI_WORKPATH); UPX off via TESTIUM_NO_UPX
- Flatpak: build dir + ostree repo -> tmpfs (FLATPAK_BUILDDIR/REPODIR); the
  .flatpak-builder download cache stays on disk
- AppImage: bind-mount a tmpfs dir at the in-container AppDir path
  (APPIMAGE_APPDIR_TMPFS)
Scratch is freed on exit. Each build.sh honors the env vars with on-disk
defaults, so behavior is unchanged without --ram. With --ram, prefer --serial
on RAM-limited machines (flatpak+appimage are ~1 GB each).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 10:42:46 +02:00
262dfd0240 build_all: parallelize manual/pyinstaller/flatpak/appimage
Serial prep (venv tool installs + flatpak runtimes + wheel, which the AppImage
depends on), then the four heavy builds run concurrently. The shared venv is
only written during prep, so the parallel builds (read-only on the venv) don't
race on pip. Per-step logs under dist/.build-logs/; failing logs are printed.
--serial falls back to one-at-a-time.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 10:30:38 +02:00
06cfaf33b7 flatpak: quote python3-lsp pip command (YAML parsed ':all: ' as a mapping)
The unquoted build-command was parsed by YAML as a dict because of the
':all: ' colon-space, so flatpak-builder ran an empty module and pygls was
never installed into the bundle.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 10:24:47 +02:00
c14a671b45 lsp: rename validation lsp_smoke -> lsp_check (clearer name)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 10:13:20 +02:00
8ab53f470d lsp: declarative action registry + cross-channel language server
Make `testium lsp` (and the testium_assist editor extension that spawns it)
work from every distribution channel: source, wheel, PyInstaller, Flatpak,
AppImage.

Two enablers:

1. Declarative ACTIONS registry. The TestItemActions parents (console, plot,
   json_rpc) now declare their nested actions as a class attribute
   `ACTIONS = {yaml_key: class}`, mirroring PARAMS. The base __init__ seeds
   action_classes from type(self).ACTIONS; register_actions() is kept only as
   an imperative escape hatch. lsp/schema.py reads ACTIONS directly, dropping
   the inspect.getsource/AST walk that returned no actions in a frozen
   PyInstaller build (no .py source on disk).

2. pygls bundled per channel. Kept as the pyproject [lsp] extra (lean
   `pip install testium`), layered into each full-app channel:
   - build_env.sh installs pygls into test/tmp/.venv (source run + PyInstaller
     build env)
   - AppImage installs the wheel as `…whl[lsp]`
   - Flatpak adds a python3-lsp network-pip module (matches the manifest's
     global --share=network)
   - PyInstaller .spec collect_submodules(pygls/lsprotocol) + hiddenimports for
     the lazily-imported lsp/lsp.server/lsp.schema

test/validation/lsp_smoke.py (run by run.sh before the suite) enforces both
per channel: `<channel> schema` must keep console/plot/json_rpc actions and
`<channel> lsp` must answer an initialize request without reporting pygls
missing. Verified for source mode; the other channels need a rebuild to verify.

DESIGN.md updated (declarative section + new "Language server across channels"
subsection + Recent fixes).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 23:17:59 +02:00
a01268cd0e lsp: hover + document symbols on test item types
Adds two new LSP features that share the schema with completion:

- textDocument/hover — when the cursor is on the item-type word of a
  step line (`- sleep:`), the server renders the same Markdown doc
  used by the completion item, listing required/optional params.
  Other words (string values, YAML keys other than item types) don't
  trigger the popup.

- textDocument/documentSymbol — the outline view now contains one
  entry per step, nested by leading-dash indentation so container
  items (group, parallel, cycle, console, plot, json_rpc) display
  their children as a subtree. Each symbol's `detail` shows the
  YAML `name:` field if present nearby — found via a small forward
  scan, no YAML parsing yet.

Action item types (console open/close/…, plot open/close/…, json_rpc
query/receive/…) are accepted by hover and outline too, so the
outline doesn't stop at the parent.

Markdown rendering is now shared by completion and hover via
`_render_item_markdown(cmd, entry)`; both surfaces show the same
description regardless of how the user reached it.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 16:01:39 +02:00
e47d422655 lsp: schema export + minimal language server (item-type completion)
Adds two new testium CLI subcommands:

  testium schema   dump the JSON-shaped schema of every test item type
                   (PARAMS merged with the common ones, console / plot /
                   json_rpc actions nested under their parent's "actions"
                   block). Zero runtime dependencies — usable by any editor
                   that speaks the YAML JSON Schema extension to get static
                   completion straight away.

  testium lsp      start a pygls LSP server over stdio. First feature:
                   completion of test item type names when the user starts
                   a new step (`- |`). The completion item carries the
                   item's display name and a hover doc listing its required
                   and optional non-common parameters.

pygls is declared as an optional extra ([project.optional-dependencies]
lsp = ["pygls>=1.3"]) so the core install isn't enlarged for users who
don't need the server. The import compatibility shim picks
pygls.lsp.server.LanguageServer (pygls 2.x) first and falls back to
pygls.server.LanguageServer (pygls 1.x).

The subcommands intercept argv[1] before argparse runs so they don't
share the GUI/batch flag surface.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 13:59:32 +02:00
2d44f52e96 DESIGN: declarative item params + refreshed flatpak-spawn / --mode notes
Brings DESIGN.md in sync with the v0.2 changes:
- new section describing the PARAMS/ParamSet/Param descriptor on every
  TestItem subclass and the unknown/missing-param diagnostics;
- rewrites the Flatpak section so it matches the flatpak-spawn --host
  pipeline instead of the obsolete LD_LIBRARY_PATH/apply_host_lua_paths
  injection;
- documents the --mode flag in the validation suite section.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 13:30:53 +02:00
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
63467c17c3 readme: document AppImage channel + libfuse2 requirement
The Pre-built releases section had no AppImage entry. Add one with
the per-distro libfuse2 package names and an APPIMAGE_EXTRACT_AND_RUN
escape hatch for hosts that no longer ship FUSE 2 by default.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 23:16:10 +02:00
7b569df202 flatpak: route gitpython through flatpak-spawn for host git
Inside a Flatpak the host /usr/bin/git is reachable at
/run/host/usr/bin/git but linked against host glibc/zlib, which the
sandbox cannot load (libz-ng.so.2 missing). gitpython resolves git
eagerly on import and crashed the whole validation run.

Install a tiny shell wrapper under /tmp at module load
(``exec flatpak-spawn --host git "$@"``) and point gitpython at it via
GIT_PYTHON_GIT_EXECUTABLE so test_version / test_modifs work in
flatpak mode. No-op outside Flatpak.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 23:16:05 +02:00
71 changed files with 2640 additions and 363 deletions

View File

@@ -224,27 +224,66 @@ Four distribution channels coexist, all sharing the single `src/testium/` packag
The `.deb` work-in-progress lives in `package/deb/`: The `.deb` work-in-progress lives in `package/deb/`:
- `test_distro.sh debian:bookworm | debian:trixie | ubuntu:24.04` spins up a Docker/Podman container, reports system package availability, falls back to pip for what's missing (`pyside6` on bookworm/ubuntu, `telnetlib3`, `junit_xml`), runs the validation suite. Currently green on the three targets. - `test_distro.sh debian:bookworm | debian:trixie | ubuntu:24.04` spins up a Docker/Podman container, reports system package availability, falls back to pip for what's missing (`pyside6` on 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 install`s) 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 to `dist/.build-logs/<step>.log`; results print in completion order (`wait -n`), and a failing step's log is dumped at the end. `--serial` builds 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/shm` and 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 via `TESTIUM_NO_UPX` (read by the `.spec`). **Flatpak is excluded**`flatpak-builder` mounts its state dir with `rofiles-fuse` and FUSE cannot mount on `/dev/shm` (`fusermount: Permission denied`), so it builds on disk. Each `package/*/build.sh` honours 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) ### 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`) 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`: 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()` (checks `APPIMAGE` env var) detect the sandbox. - `_in_flatpak()` (checks `/.flatpak-info`) and `_in_appimage()` (checks `APPIMAGE` env var) detect the sandbox.
- `_which(name)` probes only host bin dirs in those modes: - **Flatpak**: the sandbox glibc/ABI is incompatible with arbitrary host shared libraries, so we **cannot** run host binaries inside the Flatpak runtime — `LD_LIBRARY_PATH` injection trips a `_dl_call_libc_early_init` assertion. The supported way out is `flatpak-spawn --host`, a stub on `$PATH` inside every Flatpak that proxies an `exec` over D-Bus to the host's `org.freedesktop.Flatpak` service. The manifest grants `--talk-name=org.freedesktop.Flatpak` so the call is allowed. Helpers:
- Flatpak: `/run/host/usr/{local/,}bin`, `/run/host/bin` (host mounted via `--filesystem=host-os`). - `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.
- AppImage: `/usr/local/bin`, `/usr/bin`, `/bin` (we are directly on the host filesystem). - `_get_host_testium_path()` returns a path to the testium package the host can read. In Flatpak the package lives under `/app/lib/testium` which the host cannot see, so the package is staged once per process under `/tmp/testium_host_*` (`/tmp` is shared) and reused. In source / wheel / PyInstaller installs under `$HOME` the original path is returned untouched.
- If the host has no python3/lua, `ensure()` raises `ETUMRuntimeError` at test load with the candidate list — no silent fallback to a bundled interpreter. - `_which_host_flatpak(name)` resolves a binary by spawning `command -v` on the host (or `test -x` for absolute paths) — sandbox-visible probing under `/run/host/...` is unreliable (only `host-os` is mounted; user paths like `/scratch` aren't there).
- User overrides (`python_bin`/`lua_bin` in globdict): bare names are resolved through `_which()` (host-only), absolute paths are accepted as-is. - `_python_version()` and `_lua_version()` go through `_run_probe()` which dispatches to `flatpak-spawn` in Flatpak so validation happens against the actual host interpreter.
- `apply_host_libs(env)` is called by `py_process.py` / `lua_process.py` on the env passed to Popen: - `py_process.py` / `lua_process.py` `start()` use `flatpak_host_spawn` with `host_cwd = _get_host_testium_path()[+/lua_func]` and forward `PYTHONPATH` / `LUA_PATH` / `LUA_CPATH` / `PATH` as `--env=` arguments.
- Flatpak: prepends host lib dirs to `LD_LIBRARY_PATH` so the dynamic linker finds host `.so`'s. - The `run` item's `_testium_launch_cmd()` prefixes `flatpak run org.testium.Testium` with `flatpak-spawn --host` so the sub-instance is launched by the host's `flatpak` CLI, not by an unworkable in-sandbox `flatpak` binary.
- AppImage: strips `$APPDIR`-prefixed entries from `LD_LIBRARY_PATH` / `PYTHONPATH` / `PATH` and drops `PYTHONHOME`, so the host Python doesn't try to load the bundled stdlib/site-packages. - **AppImage**: we are directly on the host filesystem, so the regular discovery on `/usr/local/bin`, `/usr/bin`, `/bin` suffices. `apply_host_libs(env)` strips `$APPDIR`-prefixed entries from `LD_LIBRARY_PATH` / `PYTHONPATH` / `PATH` and drops `PYTHONHOME` so the host Python doesn't try to load the bundled stdlib/site-packages.
- `apply_host_lua_paths(env)` (Flatpak only) prepends `/run/host/usr/{lib,share}/lua/X.Y` to `LUA_PATH` / `LUA_CPATH` so `cjson`, `socket`, etc. resolve. Must be called **after** user `lua_env` overrides so host paths win. AppImage relies on host Lua's compiled-in defaults. - User overrides (`python_bin`/`lua_bin` in 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()` raises `ETUMRuntimeError` at test load with the candidate list — no silent fallback to a bundled interpreter.
- `py_process.py` additionally pops `PYTHONUSERBASE` (set to `/var/data/python` by the Flatpak runtime, which would hide `~/.local/lib/...`). - `py_process.py` additionally pops `PYTHONUSERBASE` (set to `/var/data/python` by 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 like `param_filee`);
- raises `ETUMSyntaxError` (with the `.tum` source as context) when a `required=True` param 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:
1. **`pygls` (+ `lsprotocol`, `cattrs`, `attrs`, `typing_extensions`) must be bundled.** It is the pyproject `[lsp]` extra (kept optional so a plain `pip install testium` stays lean), wired into each full-app channel: `build_env.sh` installs it into the shared `test/tmp/.venv` (covers **source run** and the **PyInstaller** build env); the **AppImage** installs the wheel as `…whl[lsp]`; the **Flatpak** adds a `python3-lsp` pip module (network-at-build, consistent with the manifest's global `--share=network`); the **PyInstaller** `.spec` force-collects the submodules via `collect_submodules` + explicit `hiddenimports` (including the lazily-imported `lsp`, `lsp.server`, `lsp.schema`).
2. **The schema must build without source** — handled by the declarative `PARAMS`/`ACTIONS` above; PyInstaller is the only channel that strips `.py` source, 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`) ### 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. Both Flatpak and AppImage export `TESTIUM_VERSION` from a launcher (Flatpak: launcher script in `org.testium.Testium.yaml`; AppImage: `runtime.env` in `AppImageBuilder.yml`). `get_testium_version()` checks `/.flatpak-info` / `APPIMAGE` and reads `TESTIUM_VERSION` rather than relying on package metadata or repo introspection.
## Recent fixes / notable changes ## Recent fixes / notable changes
- `build_all.sh`: builds the four heavy channels in parallel (serial prep for the shared venv + wheel), results in completion order, Ctrl+C kills the whole job tree; `--ram` puts the build scratch on tmpfs (`/dev/shm`) + skips UPX for fast builds on USB/SD storage (Flatpak excluded — rofiles-fuse can't mount tmpfs). See the "Building all channels" section.
- LSP across packaging channels: `testium lsp` (and the `testium_assist` editor extension that spawns it) now works from source, wheel, PyInstaller, Flatpak and AppImage. Two enablers — (1) action items declare a class-level `ACTIONS = {key: class}` registry (like `PARAMS`), so `lsp/schema.py` builds the full schema from class attributes with no `inspect.getsource`/AST (which broke under frozen PyInstaller); (2) the `[lsp]` extra (pygls) is wired into every full-app channel. `test/validation/lsp_check.py`, run by `run.sh` before the suite, asserts per-channel that `schema` keeps its actions and `lsp` answers `initialize`. See the matching architecture sections.
- Declarative test item parameters (v0.2): each `TestItem` subclass exposes a `PARAMS = 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 `.tum` context). Lays the schema foundation for a future LSP server and auto-generated manual sections. See the matching architecture section.
- Flatpak: `py_func` / `lua_func` / `run` sub-instance now execute on the host via `flatpak-spawn --host`. The previous attempt to inject host lib dirs into the sandbox's `LD_LIBRARY_PATH` was 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.Flatpak` so 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 locate `py_func` / `lua_func`.
- Validation suite: single entry point with `--mode source|wheel|pyinstaller|flatpak|appimage` to 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 install` now produces a clean `site-packages/testium/` with no top-level pollution; `.lua` files travel via `package_data`. - Restructure: single `src/testium/` Python package (was 4 sibling top-levels: `testium`, `lib`, `py_func`, `lua_func`). `lib/``runtime/`, `libs/``api/`. `pip install` now produces a clean `site-packages/testium/` with no top-level pollution; `.lua` files travel via `package_data`.
- `bins.py`: centralised resolution + cache of external `python3` / `lua` binaries. Replaces the scattered `tm.gd("python_bin")`/`tm.gd("lua_bin")` dance and the duplicated discovery logic in `py_process.py`/`lua_process.py`. Validates at test load via `TestSet._validate_runtime_deps()` so missing interpreters fail fast. - `bins.py`: centralised resolution + cache of external `python3` / `lua` binaries. Replaces the scattered `tm.gd("python_bin")`/`tm.gd("lua_bin")` dance and the duplicated discovery logic in `py_process.py`/`lua_process.py`. Validates at test load via `TestSet._validate_runtime_deps()` so missing interpreters fail fast.
- Subprocess API contract: user scripts in `py_func`/`lua_func` use the JSON-RPC bridge (`py_func.tm` / Lua `tm`) — never `api.testium` / `interpreter.*` directly. `SUPPORTED_API` extended with `OS`, `get_main_dir`, `init_timestamp`, `timestamp`, `timestamp_as_sec` so subprocess scripts have the same surface as main-process code. - Subprocess API contract: user scripts in `py_func`/`lua_func` use the JSON-RPC bridge (`py_func.tm` / Lua `tm`) — never `api.testium` / `interpreter.*` directly. `SUPPORTED_API` extended with `OS`, `get_main_dir`, `init_timestamp`, `timestamp`, `timestamp_as_sec` so subprocess scripts have the same surface as main-process code.
@@ -275,10 +314,12 @@ Both Flatpak and AppImage export `TESTIUM_VERSION` from a launcher (Flatpak: lau
## Validation tests ## Validation tests
Located in `test/validation/`. Two entry points: Located in `test/validation/`. Two entry points:
``` ```
./test/validation/run.sh # wrapper — uses a dedicated venv (see below) ./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 ./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 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. 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.

View File

@@ -27,6 +27,27 @@ Pre-built artifacts are published at
runnable directly, no Python installation required on the host. Lua runnable directly, no Python installation required on the host. Lua
support still needs a system `lua` interpreter and the `lua-socket` / support still needs a system `lua` interpreter and the `lua-socket` /
`lua-cjson` modules. `lua-cjson` modules.
* **AppImage** (`Testium-<version>-x86_64.AppImage`) — single-file
Linux binary, runnable directly:
```sh
chmod +x Testium-*-x86_64.AppImage
./Testium-*-x86_64.AppImage
```
Requires `libfuse2` on the host (FUSE 2 — distinct from `fuse3`, which
most distros now ship by default):
| Distro | Package |
|--------|---------|
| Arch / CachyOS / Manjaro | `fuse2` |
| Debian trixie / Ubuntu 24.04+ | `libfuse2t64` |
| Debian bookworm / Ubuntu 22.04 | `libfuse2` |
| Fedora | `fuse-libs` |
If you can't install libfuse2 (e.g. minimal container), prefix the
invocation with `APPIMAGE_EXTRACT_AND_RUN=1` — the AppImage will
self-extract to `/tmp` on each run instead of FUSE-mounting.
* **Flatpak bundle** (`testium.flatpak`) — install with: * **Flatpak bundle** (`testium.flatpak`) — install with:
```sh ```sh
@@ -41,6 +62,9 @@ Pre-built artifacts are published at
`testium` command is available in the terminal (requires `~/.local/bin` in `testium` command is available in the terminal (requires `~/.local/bin` in
`PATH`, which most modern distributions provide by default). `PATH`, which most modern distributions provide by default).
Every channel ships the language server, so `testium lsp` (see
[Editor support](#editor-support)) works out of the box from any of them.
## Quick start ## Quick start
From a checkout of the repository: From a checkout of the repository:
@@ -82,6 +106,45 @@ python3 src/testium # GUI
python3 src/testium -b mytest.tum # batch python3 src/testium -b mytest.tum # batch
``` ```
## Editor support
testium ships a Language Server Protocol (LSP) server that gives `.tum` files
completion of item types, hover documentation, and an outline view in any
LSP-capable editor:
```sh
testium lsp # speaks LSP over stdio; an editor's LSP client drives it
testium schema # dumps the item/parameter schema as JSON (what the LSP serves)
```
The server is bundled in every pre-built release (wheel, binary, Flatpak,
AppImage). For a source / wheel install, pull the language-server extra:
```sh
pip install 'testium[lsp]' # from PyPI / a wheel
pip install -e /path/to/testium/src[lsp] # from a source checkout
```
A VSCode / VSCodium client extension (`testium_assist`) wraps `testium lsp`;
the schema is built from testium itself, so new item types and parameters
appear in the editor on the next testium upgrade with no client change.
It is published on [Open VSX](https://open-vsx.org/extension/testium/testium-assist),
so in **VSCodium, Cursor, Windsurf, Theia and code-server** it installs from the
Extensions view (search `testium-assist`) or with
`codium --install-extension testium.testium-assist`.
**Microsoft VSCode** does not list Open VSX extensions, so install the `.vsix`
by hand — download it from the Open VSX page above, then *Extensions → ⋯ →
Install from VSIX…* or:
```sh
code --install-extension testium-assist-0.1.0.vsix
```
The extension runs `testium lsp`, so `testium` must be on the `PATH` (otherwise
point the `testium.serverPath` setting at the binary/AppImage).
## Troubleshooting ## Troubleshooting
### `wl_proxy_marshal_flags` symbol error ### `wl_proxy_marshal_flags` symbol error

View File

@@ -1,5 +1,5 @@
#!/bin/bash #!/bin/bash
# Build every distribution channel of testium, in order: # Build every distribution channel of testium:
# 1. Manual PDF -> dist/testium-manual-<v>.pdf # 1. Manual PDF -> dist/testium-manual-<v>.pdf
# 2. Wheel -> dist/testium-<v>-py3-none-any.whl (PEP 427 name) # 2. Wheel -> dist/testium-<v>-py3-none-any.whl (PEP 427 name)
# 3. PyInstaller binary -> dist/testium-<v> # 3. PyInstaller binary -> dist/testium-<v>
@@ -11,6 +11,21 @@
# By default, a step is skipped if its artifact already exists in dist/. # By default, a step is skipped if its artifact already exists in dist/.
# Pass --clean to remove existing dist/ artifacts and rebuild everything. # Pass --clean to remove existing dist/ artifacts and rebuild everything.
# #
# Parallelism: the wheel is built first (the AppImage installs it), then the
# manual, PyInstaller, Flatpak and AppImage builds run concurrently. The shared
# venv at test/tmp/.venv is only WRITTEN during the serial prep phase (the
# `pip install` of build/sphinx/pyinstaller); the parallel builds only read it,
# so there is no concurrent-pip race. Pass --serial to build one step at a time
# (useful when debugging or on a resource-constrained machine). Per-step output
# of the parallel phase is captured under dist/.build-logs/<step>.log and the
# log of any failing step is printed at the end.
#
# Pass --ram to redirect the per-channel build scratch (PyInstaller workpath,
# AppImage AppDir) and TMPDIR/PIP_CACHE_DIR to /dev/shm, and skip UPX. Big
# speedup on slow/flash storage. Flatpak is excluded (its rofiles-fuse can't
# mount on /dev/shm), so it still builds on disk. On a RAM-limited machine
# combine with --serial (e.g. ./build_all.sh --ram --serial).
#
# All artifacts are collected (copied) under <repo>/dist/. Original outputs in # All artifacts are collected (copied) under <repo>/dist/. Original outputs in
# src/dist/, package/*/dist/, doc/manual/ are left in place. Wheel and AppImage # src/dist/, package/*/dist/, doc/manual/ are left in place. Wheel and AppImage
# keep their original names (which already contain the version); manual, # keep their original names (which already contain the version); manual,
@@ -26,9 +41,13 @@
set -e set -e
CLEAN=0 CLEAN=0
SERIAL=0
RAM=0
for arg in "$@"; do for arg in "$@"; do
case "$arg" in case "$arg" in
--clean|-c) CLEAN=1 ;; --clean|-c) CLEAN=1 ;;
--serial) SERIAL=1 ;;
--ram) RAM=1 ;;
*) echo "Unknown option: $arg" >&2; exit 1 ;; *) echo "Unknown option: $arg" >&2; exit 1 ;;
esac esac
done done
@@ -63,6 +82,31 @@ export REQ_PATH="$SCRIPT_DIR/src/requirements.txt"
bash "$SCRIPT_DIR/scripts/build_env.sh" bash "$SCRIPT_DIR/scripts/build_env.sh"
source "$SCRIPT_DIR/scripts/set_env.sh" source "$SCRIPT_DIR/scripts/set_env.sh"
# ---------- RAM mode: put build scratch on tmpfs (--ram) ----------------------
# On slow storage (USB stick, SD card) the per-channel build dirs and temp
# churn dominate. --ram redirects the PyInstaller workpath, the AppImage AppDir
# and TMPDIR/PIP_CACHE_DIR to /dev/shm, and skips UPX. Flatpak is intentionally
# NOT moved: flatpak-builder mounts its state dir with rofiles-fuse, and FUSE
# can't mount on /dev/shm (fusermount: Permission denied) — so it builds on
# disk. The tmpfs scratch is freed on exit.
if [ "$RAM" -eq 1 ]; then
RAMROOT="/dev/shm/testium-build-${VERSION}"
echo "-- RAM mode: build scratch under $RAMROOT (tmpfs), freed on exit"
echo " (flatpak builds on disk — rofiles-fuse can't mount on /dev/shm)"
rm -rf "$RAMROOT"
mkdir -p "$RAMROOT"/{tmp,pip,pyi-work,appdir}
export TMPDIR="$RAMROOT/tmp"
export PIP_CACHE_DIR="$RAMROOT/pip"
export PYI_WORKPATH="$RAMROOT/pyi-work" # pyinstaller --workpath
export APPIMAGE_APPDIR_TMPFS="$RAMROOT/appdir" # AppDir bind-mount
export TESTIUM_NO_UPX=1 # skip slow UPX in the spec
trap 'rm -rf "$RAMROOT"' EXIT
if [ "$SERIAL" -ne 1 ]; then
echo " note: with --ram, prefer adding --serial so each step gets the"
echo " full tmpfs and you don't risk OOM (flatpak+appimage are ~1 GB each)."
fi
fi
step() { step() {
echo echo
echo "================================================================" echo "================================================================"
@@ -70,50 +114,100 @@ step() {
echo "================================================================" echo "================================================================"
} }
skip() { echo " (already built — skipping)"; } # Kill a process and its whole descendant tree (children first) — used by the
# interrupt handler so SIGINT also stops grandchildren the parallel jobs spawned
# (podman container, flatpak-builder, pyinstaller …), not just the subshells.
_kill_tree() {
local pid=$1 c
for c in $(pgrep -P "$pid" 2>/dev/null); do
_kill_tree "$c"
done
kill -TERM "$pid" 2>/dev/null || true
}
# Set as INT/TERM handler around the parallel wait. Stops every running build
# tree, then exits — the EXIT trap (set under --ram) frees the tmpfs scratch.
_interrupt() {
echo >&2
echo "-- interrupted: stopping running builds…" >&2
local pid
for pid in "${!PID2NAME[@]}"; do
_kill_tree "$pid"
done
exit 130
}
# ---------- artifact paths ----------------------------------------------------
# 1. Manual PDF
MANUAL="$DIST_DIR/testium-manual-${VERSION}.pdf" MANUAL="$DIST_DIR/testium-manual-${VERSION}.pdf"
step "1/5 Manual PDF (version $VERSION)" PYI_BIN="$DIST_DIR/testium-${VERSION}"
if [ ! -f "$MANUAL" ]; then FLATPAK_BUNDLE="$DIST_DIR/testium-${VERSION}.flatpak"
python -m pip install --quiet --upgrade sphinx linuxdoc wheel_in_dist() { ls -1t "$DIST_DIR"/testium-${VERSION}-*.whl 2>/dev/null | head -1; }
bash "$SCRIPT_DIR/doc/manual/sphinx/build_doc.sh" appimage_in_dist() { ls -1t "$DIST_DIR"/Testium-${VERSION}-*.AppImage 2>/dev/null | head -1; }
cp -f "$SCRIPT_DIR/doc/manual/testium_manual.pdf" "$MANUAL"
else
skip
fi
# 2. Wheel — PEP 427 name kept (already contains version) # ---------- per-step build functions (assume tools are installed) -------------
step "2/5 Wheel (version $VERSION)"
WHEEL=$(ls -1t "$DIST_DIR"/testium-${VERSION}-*.whl 2>/dev/null | head -1) build_wheel() {
if [ -z "$WHEEL" ]; then if [ -n "$(wheel_in_dist)" ]; then echo "wheel: already built — skipping"; return 0; fi
python -m pip install --quiet --upgrade build echo "wheel: building"
( (
cd "$SCRIPT_DIR/src" cd "$SCRIPT_DIR/src"
rm -rf dist build *.egg-info rm -rf dist build *.egg-info
python -m build --wheel python -m build --wheel
) )
WHEEL_SRC=$(ls -1t "$SCRIPT_DIR/src/dist"/*.whl | head -1) local src; src=$(ls -1t "$SCRIPT_DIR/src/dist"/*.whl | head -1)
WHEEL="$DIST_DIR/$(basename "$WHEEL_SRC")" cp -f "$src" "$DIST_DIR/$(basename "$src")"
cp -f "$WHEEL_SRC" "$WHEEL" echo "wheel: done"
else }
skip
fi
# 3. PyInstaller binary build_manual() {
PYI_BIN="$DIST_DIR/testium-${VERSION}" if [ -f "$MANUAL" ]; then echo "manual: already built — skipping"; return 0; fi
step "3/5 PyInstaller binary (version $VERSION)" echo "manual: building"
if [ ! -f "$PYI_BIN" ]; then bash "$SCRIPT_DIR/doc/manual/sphinx/build_doc.sh"
python -m pip install --quiet --upgrade pyinstaller cp -f "$SCRIPT_DIR/doc/manual/testium_manual.pdf" "$MANUAL"
echo "manual: done"
}
build_pyinstaller() {
if [ -f "$PYI_BIN" ]; then echo "pyinstaller: already built — skipping"; return 0; fi
echo "pyinstaller: building"
bash "$SCRIPT_DIR/package/pyinstaller/build.sh" bash "$SCRIPT_DIR/package/pyinstaller/build.sh"
cp -f "$SCRIPT_DIR/package/pyinstaller/dist/testium" "$PYI_BIN" cp -f "$SCRIPT_DIR/package/pyinstaller/dist/testium" "$PYI_BIN"
else echo "pyinstaller: done"
skip }
fi
build_flatpak() {
if [ -f "$FLATPAK_BUNDLE" ]; then echo "flatpak: already built — skipping"; return 0; fi
echo "flatpak: building"
(
cd "$SCRIPT_DIR/package/flatpak"
bash build.sh
)
cp -f "$SCRIPT_DIR/package/flatpak/testium.flatpak" "$FLATPAK_BUNDLE"
echo "flatpak: done"
}
build_appimage() {
if [ -n "$(appimage_in_dist)" ]; then echo "appimage: already built — skipping"; return 0; fi
echo "appimage: building"
(
cd "$SCRIPT_DIR/package/appimage"
bash build.sh
)
local src; src=$(ls -1t "$SCRIPT_DIR/package/appimage"/*.AppImage 2>/dev/null | head -1)
cp -f "$src" "$DIST_DIR/$(basename "$src")"
chmod +x "$DIST_DIR/$(basename "$src")"
echo "appimage: done"
}
# ---------- serial prep: tool installs (shared venv) + flatpak runtimes -------
step "Prep: build tools + runtimes (serial — shared venv)"
[ -f "$MANUAL" ] || python -m pip install --quiet --upgrade sphinx linuxdoc
[ -n "$(wheel_in_dist)" ] || python -m pip install --quiet --upgrade build
[ -f "$PYI_BIN" ] || python -m pip install --quiet --upgrade pyinstaller
# 4. Flatpak bundle
FLATPAK_BUNDLE="$DIST_DIR/testium-${VERSION}.flatpak"
step "4/5 Flatpak bundle (version $VERSION)"
if [ ! -f "$FLATPAK_BUNDLE" ]; then if [ ! -f "$FLATPAK_BUNDLE" ]; then
FLATPAK_DEPS=( FLATPAK_DEPS=(
"org.kde.Platform//6.10" "org.kde.Platform//6.10"
@@ -130,35 +224,76 @@ if [ ! -f "$FLATPAK_BUNDLE" ]; then
flatpak install --user --noninteractive flathub "$dep" flatpak install --user --noninteractive flathub "$dep"
fi fi
done done
(
cd "$SCRIPT_DIR/package/flatpak"
bash build.sh
)
cp -f "$SCRIPT_DIR/package/flatpak/testium.flatpak" "$FLATPAK_BUNDLE"
else
skip
fi fi
# 5. AppImage # ---------- serial: wheel (the AppImage installs it) --------------------------
step "5/5 AppImage (version $VERSION)"
APPIMAGE=$(ls -1t "$DIST_DIR"/Testium-${VERSION}-*.AppImage 2>/dev/null | head -1) step "1/5 Wheel (version $VERSION)"
if [ -z "$APPIMAGE" ]; then build_wheel
(
cd "$SCRIPT_DIR/package/appimage" # ---------- build the rest --------------------------------------------------
bash build.sh
) REST=(manual pyinstaller flatpak appimage)
APPIMAGE_SRC=$(ls -1t "$SCRIPT_DIR/package/appimage"/*.AppImage 2>/dev/null | head -1)
APPIMAGE="$DIST_DIR/$(basename "$APPIMAGE_SRC")" if [ "$SERIAL" -eq 1 ]; then
cp -f "$APPIMAGE_SRC" "$APPIMAGE" n=2
chmod +x "$APPIMAGE" for name in "${REST[@]}"; do
step "$n/5 $name (version $VERSION)"
"build_$name"
n=$((n + 1))
done
else else
skip step "2-5/5 manual + pyinstaller + flatpak + appimage (parallel)"
LOGDIR="$DIST_DIR/.build-logs"
mkdir -p "$LOGDIR"
declare -A PID2NAME
for name in "${REST[@]}"; do
log="$LOGDIR/$name.log"
echo " -> launching $name (log: $log)"
( "build_$name" ) >"$log" 2>&1 &
PID2NAME[$!]="$name"
done
# From here until all jobs are reaped, Ctrl+C stops every build tree.
trap _interrupt INT TERM
# Reap in completion order (wait -n) so each result prints the moment that
# build finishes, not when its slot comes up in the array.
FAILED=()
remaining=${#PID2NAME[@]}
while [ "$remaining" -gt 0 ]; do
if wait -n -p donepid; then rc=0; else rc=$?; fi
name="${PID2NAME[$donepid]:-}"
[ -z "$name" ] && continue
if [ "$rc" -eq 0 ]; then
echo " -> $name: OK"
else
echo " -> $name: FAILED (rc=$rc)"
FAILED+=("$name")
fi
remaining=$((remaining - 1))
done
trap - INT TERM
if [ "${#FAILED[@]}" -gt 0 ]; then
for name in "${FAILED[@]}"; do
echo
echo "===================== $name log ====================="
cat "$LOGDIR/$name.log"
done
echo >&2
echo "BUILD FAILED: ${FAILED[*]} (logs under $LOGDIR)" >&2
exit 1
fi
fi fi
# ---------- summary -----------------------------------------------------------
step "All packages built" step "All packages built"
printf " manual : %s\n" "$MANUAL" printf " manual : %s\n" "$MANUAL"
printf " wheel : %s\n" "$WHEEL" printf " wheel : %s\n" "$(wheel_in_dist)"
printf " pyinstaller : %s\n" "$PYI_BIN" printf " pyinstaller : %s\n" "$PYI_BIN"
printf " flatpak : %s\n" "$FLATPAK_BUNDLE" printf " flatpak : %s\n" "$FLATPAK_BUNDLE"
printf " appimage : %s\n" "$APPIMAGE" printf " appimage : %s\n" "$(appimage_in_dist)"
printf " release_note : %s\n" "$RELEASE_NOTE" printf " release_note : %s\n" "$RELEASE_NOTE"

View File

@@ -23,3 +23,80 @@ graphical interface.
:caption: call a test in batch mode :caption: call a test in batch mode
testium -b test/my_test/main.tum testium -b test/my_test/main.tum
.. _sec_language_server:
Language server (editor support)
--------------------------------
*testium* ships a `Language Server Protocol
<https://microsoft.github.io/language-server-protocol/>`_ server so that
``.tum`` files get editor assistance — completion of test item types, hover
documentation of their parameters, and an outline view — in any LSP-capable
editor.
The server speaks LSP over standard input/output and is started with:
.. code-block:: text
:caption: start the language server
testium lsp
It is not meant to be launched directly by the user: an editor's LSP client
spawns it and drives the exchange. A VSCode / VSCodium client extension,
*testium_assist*, is provided for that purpose; any other LSP client (Neovim,
Emacs ``lsp-mode``, …) can be pointed at ``testium lsp`` as well.
The information the server exposes is the test item schema, which can also be
dumped as JSON for inspection or tooling:
.. code-block:: text
:caption: dump the item / parameter schema
testium schema
Because the schema is built from *testium* itself, every new item type or
parameter becomes available in the editor on the next *testium* upgrade, with
no change to the client.
The language server is included in the pre-built binary, Flatpak and AppImage
releases. For a source or wheel installation, pull the optional ``lsp``
dependencies:
.. code-block:: text
:caption: enable the language server for a wheel / source install
pip install 'testium[lsp]'
Installing the VSCode / VSCodium extension
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
The *testium_assist* client extension is published on `Open VSX
<https://open-vsx.org/extension/testium/testium-assist>`_, the registry used by
VSCodium, Cursor, Windsurf, Eclipse Theia and code-server. In those editors,
open the Extensions view and search ``testium-assist``, or install it from the
command line:
.. code-block:: text
:caption: install in VSCodium and other Open VSX editors
codium --install-extension testium.testium-assist
Microsoft *VSCode* uses a different marketplace that does not list Open VSX
extensions, so install the packaged ``.vsix`` by hand. Download it from the
Open VSX page linked above, then either choose *Extensions**⋯*
*Install from VSIX…* in the UI, or run:
.. code-block:: text
:caption: install the .vsix in Microsoft VSCode
code --install-extension testium-assist-0.1.0.vsix
The extension launches ``testium lsp``, so the ``testium`` command must be on
the ``PATH``. If *testium* is installed elsewhere — a specific binary or an
AppImage — point the ``testium.serverPath`` setting at it instead.
Once installed, open a ``.tum`` file: completion of item types, hover
documentation and the outline view become available. If nothing happens, check
that no ``files.associations`` entry forces ``*.tum`` to another language (it
must stay the ``tum`` language the extension provides).

View File

@@ -232,6 +232,15 @@ list of the main test item (and eventually of the loop test item).
TUM file ``main`` item is itself a variant of test items with a name and an TUM file ``main`` item is itself a variant of test items with a name and an
step list attributes. step list attributes.
.. note::
Each test item declares the parameters it accepts. When a ``.tum`` file
uses a key the item does not know, *testium* emits a warning listing the
accepted parameter names (catching typos such as ``param_filee`` for
``param_file``); a missing **required** parameter aborts loading with an
error pointing at the source ``.tum`` file. Valid existing tests are
unaffected.
.. toctree:: .. toctree::

Binary file not shown.

View File

@@ -77,7 +77,10 @@ AppDir:
python3.11 -m pip install --break-system-packages --upgrade --isolated --no-input --ignore-installed --prefix=$TARGET_APPDIR/usr -r ../../src/requirements.txt python3.11 -m pip install --break-system-packages --upgrade --isolated --no-input --ignore-installed --prefix=$TARGET_APPDIR/usr -r ../../src/requirements.txt
export PIP_CONFIG_FILE=$HOME/.pip/pip.conf export PIP_CONFIG_FILE=$HOME/.pip/pip.conf
python3.11 -m pip install --break-system-packages --upgrade --isolated --no-input --ignore-installed --prefix=$TARGET_APPDIR/usr ../../src/dist/testium-{{APP_VERSION}}-py3-none-any.whl # Install the wheel with the [lsp] extra so `testium lsp` (pygls) works
# from the AppImage. The extra pulls pygls/lsprotocol/cattrs/attrs from
# the index (network is available at build time, see get-pip above).
python3.11 -m pip install --break-system-packages --upgrade --isolated --no-input --ignore-installed --prefix=$TARGET_APPDIR/usr "../../src/dist/testium-{{APP_VERSION}}-py3-none-any.whl[lsp]"
AppImage: AppImage:

View File

@@ -17,11 +17,20 @@ else
fi fi
echo "Using $RUNTIME — building testium $APP_VERSION AppImage..." echo "Using $RUNTIME — building testium $APP_VERSION AppImage..."
# APPIMAGE_APPDIR_TMPFS (set by build_all --ram) bind-mounts a host tmpfs dir at
# the AppDir build path, keeping the ~1 GB AppDir churn off slow storage.
APPDIR_MOUNT=""
if [ -n "$APPIMAGE_APPDIR_TMPFS" ]; then
mkdir -p "$APPIMAGE_APPDIR_TMPFS"
APPDIR_MOUNT="-v $APPIMAGE_APPDIR_TMPFS:/work/package/appimage/AppDir"
fi
# APPIMAGE_EXTRACT_AND_RUN=1 lets appimagetool run without FUSE in the container. # APPIMAGE_EXTRACT_AND_RUN=1 lets appimagetool run without FUSE in the container.
$RUNTIME run --rm \ $RUNTIME run --rm \
--privileged \ --privileged \
-e APPIMAGE_EXTRACT_AND_RUN=1 \ -e APPIMAGE_EXTRACT_AND_RUN=1 \
-v "$REPO_ROOT:/work" \ -v "$REPO_ROOT:/work" \
$APPDIR_MOUNT \
-w /work/package/appimage \ -w /work/package/appimage \
debian:bookworm bash -c " debian:bookworm bash -c "
set -e set -e

View File

@@ -7,11 +7,19 @@
set -e set -e
# Build + install local # Build + install local. FLATPAK_BUILDDIR / FLATPAK_STATEDIR / FLATPAK_REPODIR
flatpak-builder --user --verbose --force-clean --install --repo=repo build org.testium.Testium.yaml # (set by build_all --ram) redirect the build dir, the state dir
# (.flatpak-builder) and the ostree repo to tmpfs. flatpak-builder hardlinks
# between the state dir and the build dir, so they MUST be on the same
# filesystem — hence the state dir moves to tmpfs too (its download cache then
# doesn't persist across --ram runs).
BUILDDIR="${FLATPAK_BUILDDIR:-build}"
STATEDIR="${FLATPAK_STATEDIR:-.flatpak-builder}"
REPODIR="${FLATPAK_REPODIR:-repo}"
flatpak-builder --user --verbose --force-clean --install --state-dir="$STATEDIR" --repo="$REPODIR" "$BUILDDIR" org.testium.Testium.yaml
# Génère le bundle distribuable # Génère le bundle distribuable
flatpak build-bundle repo testium.flatpak org.testium.Testium flatpak build-bundle "$REPODIR" testium.flatpak org.testium.Testium
echo "Bundle généré : $(pwd)/testium.flatpak" echo "Bundle généré : $(pwd)/testium.flatpak"
# Crée ~/.local/bin/testium pour pouvoir taper "testium" en console # Crée ~/.local/bin/testium pour pouvoir taper "testium" en console

View File

@@ -28,6 +28,23 @@ build-options:
modules: modules:
- python3-requirements.json - python3-requirements.json
# Language-server deps for `testium lsp` (pygls + lsprotocol + cattrs + attrs
# + typing_extensions). Installed from PyPI at build time — the build already
# runs with --share=network (see build-options). The core runtime deps stay
# offline-pinned in python3-requirements.json; these are pure-python wheels,
# hence --only-binary=:all: (no compilation, deterministic).
- name: python3-lsp
buildsystem: simple
build-options:
build-args:
- --share=network
build-commands:
# Whole command single-quoted: the ':all: ' colon-space would otherwise
# make YAML parse this list item as a mapping, silently dropping the
# command (flatpak-builder then runs an empty module — installs nothing).
- 'pip3 install --prefix=${FLATPAK_DEST} --only-binary=:all: "pygls>=1.3"'
# 1. Dépendances Python tierces (HORS PySide6) # 1. Dépendances Python tierces (HORS PySide6)
# Utilisez flatpak-pip-generator pour vos autres libs (ex: pyserial, requests, etc.) # Utilisez flatpak-pip-generator pour vos autres libs (ex: pyserial, requests, etc.)
# - name: python3-requirements # - name: python3-requirements

View File

@@ -2,11 +2,15 @@
SCRIPT_DIR=$(realpath $( dirname "$0")) SCRIPT_DIR=$(realpath $( dirname "$0"))
rm -r "${SCRIPT_DIR}/build" "${SCRIPT_DIR}/dist" rm -rf "${SCRIPT_DIR}/build" "${SCRIPT_DIR}/dist"
pwd=$(pwd) pwd=$(pwd)
cd ${SCRIPT_DIR} cd ${SCRIPT_DIR}
pyinstaller testium.spec # PYI_WORKPATH (set by build_all --ram) puts the big intermediate build tree on
# tmpfs; dist/ stays local so build_all can collect the binary.
WORKARG=""
[ -n "$PYI_WORKPATH" ] && WORKARG="--workpath $PYI_WORKPATH"
pyinstaller $WORKARG testium.spec
RESULT=$? RESULT=$?
if [ -n "$1" ] && [ "$1" = "install" ]; then if [ -n "$1" ] && [ "$1" = "install" ]; then
if [ $RESULT -eq 0 ]; then if [ $RESULT -eq 0 ]; then

View File

@@ -1,5 +1,21 @@
# -*- mode: python ; coding: utf-8 -*- # -*- mode: python ; coding: utf-8 -*-
import os import os
from PyInstaller.utils.hooks import collect_submodules
# Language-server dependencies for `testium lsp`. pygls/lsprotocol register
# converters and features dynamically, so we collect their submodules wholesale
# and force-import their pure-python deps (cattrs/attrs/typing_extensions).
# The testium lsp modules are imported lazily by the CLI dispatch
# (`from lsp.server import serve`), which PyInstaller's static analysis misses —
# hence the explicit names. No source files need bundling: the schema export is
# now fully declarative (PARAMS + ACTIONS class attributes), so it no longer
# reads .py source via inspect.getsource (which fails in a frozen build).
_LSP_HIDDEN = (
collect_submodules("pygls")
+ collect_submodules("lsprotocol")
+ ["cattrs", "attr", "attrs", "typing_extensions",
"lsp", "lsp.server", "lsp.schema"]
)
# junit_xml is imported by post_exec scripts running under the *host* Python, # junit_xml is imported by post_exec scripts running under the *host* Python,
# not the frozen interpreter — so bundling it via hiddenimports alone is not # not the frozen interpreter — so bundling it via hiddenimports alone is not
@@ -54,7 +70,7 @@ a = Analysis(
"colorama", "colorama",
"matplotlib", "matplotlib",
"junit_xml", "junit_xml",
"lxml"], "lxml"] + _LSP_HIDDEN,
hookspath=[], hookspath=[],
hooksconfig={}, hooksconfig={},
runtime_hooks=[], runtime_hooks=[],
@@ -73,7 +89,9 @@ exe = EXE(
debug=False, debug=False,
bootloader_ignore_signals=False, bootloader_ignore_signals=False,
strip=False, strip=False,
upx=True, # UPX is CPU+IO heavy for a marginal size gain — build_all --ram sets
# TESTIUM_NO_UPX=1 to skip it (much faster on slow/flash storage).
upx=not os.environ.get("TESTIUM_NO_UPX"),
upx_exclude=[], upx_exclude=[],
runtime_tmpdir=None, runtime_tmpdir=None,
console=True, console=True,

View File

@@ -1,3 +1,41 @@
version 0.2.2
==============
- Flatpak sandbox issue fixed for term console. Now a term console is
exactly like a host console.
- Persistence fix of dialogs in case of flatpak.
version 0.2.1
==============
- Faster test loading, especially for large tests built from jinja
templates and ``!include``: compiled jinja templates are cached and
reused (a file included many times is compiled once), rendering happens
in memory instead of through a temporary file, and YAML is parsed with
the libyaml C loader when available. Typical load time is 3-6x lower on
include / template-heavy tests; behaviour is unchanged.
- Fix: a nested list holding more than one step under ``steps`` no longer
duplicates its entries while the step tree is built.
- New load-time benchmark under ``test/benchmark/`` (synthetic-tree
generator + in-process timing harness) to measure the load pipeline.
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.
- Editor support: testium now ships a language server. ``testium lsp``
gives ``.tum`` files item-type completion, hover documentation and an
outline view in any LSP-capable editor (a VSCode / VSCodium client is
provided separately). ``testium schema`` dumps the item/parameter
schema as JSON. The server works from every channel — bundled in the
binary / Flatpak / AppImage, and pulled by ``pip install testium[lsp]``
for wheel installs.
- build_all.sh: the four heavy channels now build in parallel (results
reported as each finishes; Ctrl+C stops them cleanly). New ``--ram``
option builds on a tmpfs (``/dev/shm``) and skips UPX for much faster
packaging on USB-stick / SD-card storage.
version 0.1.3 version 0.1.3
============== ==============
- Stop interrupts engaged blocking steps (console, py_func, lua_func, - Stop interrupts engaged blocking steps (console, py_func, lua_func,

View File

@@ -33,6 +33,11 @@ if [ ! -d "$PY_VENV_DIR" ]; then
python3 -m venv "$PY_VENV_DIR" python3 -m venv "$PY_VENV_DIR"
source "$PY_VENV_DIR/bin/activate" source "$PY_VENV_DIR/bin/activate"
pip install --extra-index-url https://pypi.python.org/pypi -r $REQ_PATH pip install --extra-index-url https://pypi.python.org/pypi -r $REQ_PATH
# Language-server deps (the pyproject [lsp] extra). Installed here so the
# source run AND the PyInstaller build — both of which use this venv — can
# start / collect the `testium lsp` server. pip-installed wheel users get
# them via `pip install testium[lsp]` instead.
pip install --extra-index-url https://pypi.python.org/pypi "pygls>=1.3"
# Validation suite plugin used to verify the report-exporter # Validation suite plugin used to verify the report-exporter
# entry-points discovery end-to-end. # entry-points discovery end-to-end.
FAKE_EXPORTER_DIR="$(dirname "$REQ_PATH")/../test/validation/fake_exporter" FAKE_EXPORTER_DIR="$(dirname "$REQ_PATH")/../test/validation/fake_exporter"

View File

@@ -1 +1 @@
0.1.3 0.2.2

View File

@@ -30,6 +30,12 @@ dependencies = [
] ]
dynamic = ["version"] dynamic = ["version"]
[project.optional-dependencies]
# `pip install testium[lsp]` adds the language-server dependencies. The
# stdio-only LSP server (`testium lsp`) reuses the schema export from the
# core install; pygls is the only marginal cost.
lsp = ["pygls>=1.3"]
[project.scripts] [project.scripts]
testium = "testium:main" testium = "testium:main"

View File

@@ -11,6 +11,30 @@ sys.path.append(os.path.abspath(ourpath.parent))
import interpreter.utils.constants as cst import interpreter.utils.constants as cst
def main(): def main():
# Subcommand dispatch (must run *before* argparse so neither 'schema' nor
# 'lsp' has to share the GUI/batch flag surface). The subcommands also
# skip the multiprocessing 'spawn' setup which is only meaningful for the
# main runtime — schema is a pure stdout dump and lsp speaks JSON-RPC
# over stdio without ever forking a test process.
if len(sys.argv) >= 2 and sys.argv[1] in ("schema", "lsp"):
sub = sys.argv[1]
if sub == "schema":
from lsp.schema import dump_all_schemas_json
print(dump_all_schemas_json())
return
# lsp
try:
from lsp.server import serve
except ImportError as e:
print(
f"testium lsp: language server dependencies missing ({e.name}). "
"Install with: pip install 'testium[lsp]'",
file=sys.stderr,
)
sys.exit(2)
serve()
return
# This line sets the method for the "Process" function. It is required for Linux # This line sets the method for the "Process" function. It is required for Linux
# support of the test dialogs. # support of the test dialogs.
multiprocessing.set_start_method('spawn') multiprocessing.set_start_method('spawn')

View File

@@ -81,9 +81,13 @@ class TermConsole(Console):
bufsize=0) bufsize=0)
else: else:
self.term = pexpect.spawn( shell_cmd, # In Flatpak this returns a `flatpak-spawn --host` wrapper so the
echo=False, # console behaves like a host shell (matching py_func / lua_func /
cwd=self.ppath) # run); elsewhere it's the chosen command unchanged.
from interpreter.utils import bins
argv = bins.host_console_command(shell_cmd, self.ppath)
self.term = pexpect.spawn(argv[0], args=argv[1:],
echo=False, cwd=self.ppath)
self.q = BytesStore() self.q = BytesStore()
self.t = threading.Thread(target=self.enqueue_output) self.t = threading.Thread(target=self.enqueue_output)

View File

@@ -221,6 +221,11 @@ def main(args, conn=None):
if conn: if conn:
settings.setValue(SettingsLastChoices, result) settings.setValue(SettingsLastChoices, result)
# Flush before sending: the parent terminates this subprocess as soon
# as it reads the result, so the QSettings destructor never runs and
# the write would race the kill (lost under Flatpak — see the
# tested-references dialog for the full rationale).
settings.sync()
conn.send([result, success]) conn.send([result, success])
conn.close() conn.close()
else: else:

View File

@@ -5,6 +5,13 @@ from interpreter.test_items.item_actions.action import TestItemAction
class TestItemActions(TestItem): class TestItemActions(TestItem):
# Declarative action registry: subclasses set ``ACTIONS = {yaml_key: class}``
# as a class attribute (mirroring ``PARAMS``). It is read here to populate
# the runtime registry, and read identically by the schema export — no
# instantiation or source inspection required. ``register_actions()`` stays
# available as an imperative escape hatch for dynamic/conditional cases.
ACTIONS = {}
def __init__( def __init__(
self, item_type, dict_actions, parent=None, status_queue=None, filename="" self, item_type, dict_actions, parent=None, status_queue=None, filename=""
): ):
@@ -12,7 +19,7 @@ class TestItemActions(TestItem):
super().__init__(dict_actions, parent, status_queue, filename=filename) super().__init__(dict_actions, parent, status_queue, filename=filename)
self._type = item_type self._type = item_type
self.is_container = False self.is_container = False
self.action_classes = {} self.action_classes = dict(type(self).ACTIONS)
self.actions_token = None self.actions_token = None
self.actions = [] self.actions = []
try: try:
@@ -24,6 +31,9 @@ class TestItemActions(TestItem):
) )
def register_actions(self, **args: TestItemAction): def register_actions(self, **args: TestItemAction):
# Imperative escape hatch. The declarative ``ACTIONS`` class attribute
# covers every current subclass; use this only to add actions that
# can't be known at class-definition time (e.g. platform-conditional).
for action_name, action_class in args.items(): for action_name, action_class in args.items():
self.action_classes.update({action_name: action_class}) self.action_classes.update({action_name: action_class})

View File

@@ -5,6 +5,9 @@ from copy import deepcopy
from interpreter.test_items.test_result import TestResult, TestValue from interpreter.test_items.test_result import TestResult, TestValue
import api.testium as tm import api.testium as tm
from interpreter.utils.params import TestItemParams 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.constants import TestItemType as cst_type
from interpreter.utils.eval import eval_to_boolean, evaluate, post_evaluate from interpreter.utils.eval import eval_to_boolean, evaluate, post_evaluate
from runtime.tum_except import ETUMSyntaxError, item_load_context from runtime.tum_except import ETUMSyntaxError, item_load_context
@@ -13,6 +16,32 @@ LOG_TEST_STOP = '<----- step "{}" finished'
LOG_TEST_START = '-----> step "{}" started' 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: class TestItem:
pass pass
@@ -97,6 +126,11 @@ def test_data(item: TestItem, child: dict) -> dict:
class TestItem: 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__( def __init__(
self, dict_item: dict = None, parent: TestItem = None, status_queue=None, filename = "" self, dict_item: dict = None, parent: TestItem = None, status_queue=None, filename = ""
): ):
@@ -134,6 +168,13 @@ class TestItem:
# creation of the params object # creation of the params object
self._prms = TestItemParams(dict_item, parent) 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 # getting parameters for the test item
try: try:
self._name = self._prms.getParam("name", default="", processed=True) self._name = self._prms.getParam("name", default="", processed=True)
@@ -190,6 +231,36 @@ class TestItem:
self.result = TestResult(self, TestValue.FAILURE, "Failure by default") 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): def _filter_dict_item(self, dict_item):
# Stores the content of the step to be displayed # Stores the content of the step to be displayed
# in the GUI # in the GUI

View File

@@ -5,11 +5,22 @@ from runtime.tum_except import ETUMSyntaxError, item_load_context
import api.testium as tm import api.testium as tm
from interpreter.utils.constants import TestItemType as cst from interpreter.utils.constants import TestItemType as cst
from interpreter.utils.eval import evaluate from interpreter.utils.eval import evaluate
from interpreter.utils.param_decl import Param, ParamSet, LIST
class TestItemCheckValue(TestItem): class TestItemCheckValue(TestItem):
"""check item usage. """check item usage.
check usage:{check: {name: check my func output, steps: ['$(pfn_echo) < 5']}} 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=""): def __init__(self, dict_item, parent = None, status_queue=None, filename=""):
self._name = cst.TYPE_CHECK.item_name self._name = cst.TYPE_CHECK.item_name
super().__init__(dict_item, parent, status_queue, filename=filename) 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_result import TestValue
from interpreter.test_items.test_item_dialog_base import TestItemDialogBase, _is_text_mode, _is_interactive 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.constants import TestItemType as cst
from interpreter.utils.param_decl import Param, ParamSet, BLOCK
from runtime.tum_except import item_load_context from runtime.tum_except import item_load_context
import api.testium as tm import api.testium as tm
class TestItemChoicesDialog(TestItemDialogBase): 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=""): def __init__(self, dict_item, parent=None, status_queue=None, filename=""):
self._name = cst.TYPE_CHOICES_DLG.item_name self._name = cst.TYPE_CHOICES_DLG.item_name
super().__init__(dict_item, parent, status_queue, filename=filename) 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 import TestItemActions
from interpreter.test_items.item_actions.action import TestItemAction from interpreter.test_items.item_actions.action import TestItemAction
from interpreter.utils.constants import TestItemType as cst 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 from interpreter.test_items.test_result import TestResult, TestValue
@@ -21,6 +22,38 @@ class TestItemConsoleAction(TestItemAction):
class TestItemConsoleOpen(TestItemConsoleAction): 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__( def __init__(
self, action_name, dict_item, parent=None, status_queue=None, filename="" self, action_name, dict_item, parent=None, status_queue=None, filename=""
): ):
@@ -283,6 +316,17 @@ class TestItemConsoleWriteLn(TestItemConsoleAction):
class TestItemConsoleReadUntil(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__( def __init__(
self, action_name, dict_item, parent=None, status_queue=None, filename="" self, action_name, dict_item, parent=None, status_queue=None, filename=""
): ):
@@ -336,18 +380,27 @@ class TestItemConsoleReadUntil(TestItemConsoleAction):
class TestItemConsole(TestItemActions): 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."),
)
ACTIONS = {
"open": TestItemConsoleOpen,
"close": TestItemConsoleClose,
"write": TestItemConsoleWrite,
"writeln": TestItemConsoleWriteLn,
"read_until": TestItemConsoleReadUntil,
}
def __init__(self, dict_item, parent=None, status_queue=None, filename=""): def __init__(self, dict_item, parent=None, status_queue=None, filename=""):
super().__init__( super().__init__(
cst.TYPE_CONSOLE, dict_item, parent, status_queue, filename=filename cst.TYPE_CONSOLE, dict_item, parent, status_queue, filename=filename
) )
self.register_actions(
open=TestItemConsoleOpen,
close=TestItemConsoleClose,
write=TestItemConsoleWrite,
writeln=TestItemConsoleWriteLn,
read_until=TestItemConsoleReadUntil,
)
self.actions_token = {} self.actions_token = {}
global console global console

View File

@@ -8,9 +8,36 @@ from interpreter.test_items.test_result import TestResult, TestValue
import api.testium as tm import api.testium as tm
from interpreter.utils.params import TestItemParams from interpreter.utils.params import TestItemParams
from interpreter.utils.constants import TestItemType as cst 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): 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=""): def __init__(self, dict_cycle, parent=None, status_queue=None, filename=""):
self._name = cst.TYPE_CYCLE.item_name self._name = cst.TYPE_CYCLE.item_name
super().__init__(dict_cycle, parent, status_queue, filename=filename) 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_item import (TestItem, test_run)
from interpreter.test_items.test_result import (TestValue) from interpreter.test_items.test_result import (TestValue)
from interpreter.utils.constants import TestItemType as cst from interpreter.utils.constants import TestItemType as cst
from interpreter.utils.param_decl import Param, ParamSet, LIST
from runtime.tum_except import ETUMParamError, ETUMSyntaxError from runtime.tum_except import ETUMParamError, ETUMSyntaxError
import interpreter.utils.version as git 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 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=""): def __init__(self, dict_item, parent = None, status_queue=None, filename=""):
self._name = cst.TYPE_GIT.item_name self._name = cst.TYPE_GIT.item_name
super().__init__(dict_item, parent, status_queue, filename=filename) 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_item import (TestItem, test_run)
from interpreter.test_items.test_result import (TestResult, TestValue) from interpreter.test_items.test_result import (TestResult, TestValue)
from interpreter.utils.constants import TestItemType as cst from interpreter.utils.constants import TestItemType as cst
from interpreter.utils.param_decl import ParamSet
from runtime.tum_except import ETUMSyntaxError from runtime.tum_except import ETUMSyntaxError
import api.testium as tm import api.testium as tm
class TestItemGroup(TestItem): 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=""): def __init__(self, dict_cycle, parent = None, status_queue=None, filename=""):
self._name = cst.TYPE_GROUP.item_name self._name = cst.TYPE_GROUP.item_name
super().__init__(dict_cycle, parent, status_queue, filename=filename) 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_result import TestValue
from interpreter.test_items.test_item_dialog_base import TestItemDialogBase, _is_text_mode, _is_interactive 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.constants import TestItemType as cst
from interpreter.utils.param_decl import Param, ParamSet
from runtime.tum_except import item_load_context from runtime.tum_except import item_load_context
import api.testium as tm import api.testium as tm
@@ -12,6 +13,17 @@ class TestItemImageDialog(TestItemDialogBase):
"""dialog_image item usage. """dialog_image item usage.
dialog_image name: Nice image, question: could you press the red button, filename: img.jpg 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=""): def __init__(self, dict_item, parent=None, status_queue=None, filename=""):
self._name = cst.TYPE_IMAGE_DLG.item_name self._name = cst.TYPE_IMAGE_DLG.item_name
super().__init__(dict_item, parent, status_queue, filename=filename) 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.constants import TestItemType as cst
from interpreter.utils.eval import evaluate 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 ( from interpreter.test_items.test_item_json_rpc.jsonrpc_adapters import (
JrpcAdapter, JrpcAdapter,
@@ -76,6 +77,20 @@ class TestItemJSRPCActionClose(TestItemAction):
class TestItemJSRPCActionQuery(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__( def __init__(
self, action_name, dict_item, parent=None, status_queue=None, filename="" self, action_name, dict_item, parent=None, status_queue=None, filename=""
): ):
@@ -129,6 +144,13 @@ class TestItemJSRPCActionQuery(TestItemAction):
class TestItemJSRPCActionReceive(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__( def __init__(
self, action_name, dict_item, parent=None, status_queue=None, filename="" self, action_name, dict_item, parent=None, status_queue=None, filename=""
): ):
@@ -172,6 +194,29 @@ class TestItemJSON_RPC(TestItemActions):
This item TBD 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."),
)
ACTIONS = {
"open": TestItemJSRPCActionOpen,
"close": TestItemJSRPCActionClose,
"query": TestItemJSRPCActionQuery,
"receive": TestItemJSRPCActionReceive,
}
def __init__( def __init__(
self, dict_item: dict, parent: TestItem = None, status_queue=None, filename="" self, dict_item: dict, parent: TestItem = None, status_queue=None, filename=""
): ):
@@ -179,13 +224,6 @@ class TestItemJSON_RPC(TestItemActions):
cst.TYPE_JSON_RPC, dict_item, parent, status_queue, filename=filename cst.TYPE_JSON_RPC, dict_item, parent, status_queue, filename=filename
) )
self.register_actions(
open=TestItemJSRPCActionOpen,
close=TestItemJSRPCActionClose,
query=TestItemJSRPCActionQuery,
receive=TestItemJSRPCActionReceive,
)
# Console specific params # Console specific params
self._console = self._prms.getParam("console", required=False) self._console = self._prms.getParam("console", required=False)
# UDP specific params # UDP specific params

View File

@@ -8,12 +8,20 @@ from interpreter.test_items.test_result import (TestResult, TestValue)
from runtime.tum_except import ETUMSyntaxError, item_load_context from runtime.tum_except import ETUMSyntaxError, item_load_context
import api.testium as tm import api.testium as tm
from interpreter.utils.constants import TestItemType as cst from interpreter.utils.constants import TestItemType as cst
from interpreter.utils.param_decl import Param, ParamSet, LIST
class TestItemLet(TestItem): class TestItemLet(TestItem):
"""let item usage. """let item usage.
let values: {variable1: a, variable2: /dev/ttyUSB0, variable3: 115200} let values: {variable1: a, variable2: /dev/ttyUSB0, variable3: 115200}
let eval: {conditional_exec: "random.randint(1, 4)"} 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=""): def __init__(self, dict_item, parent = None, status_queue=None, filename=""):
self._name = cst.TYPE_LET.item_name self._name = cst.TYPE_LET.item_name
super().__init__(dict_item, parent, status_queue, filename=filename) 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.lua_func_exec import LuaFuncExecEngine
from interpreter.utils.api_srv import api_request from interpreter.utils.api_srv import api_request
from interpreter.utils.constants import TestItemType as cst from interpreter.utils.constants import TestItemType as cst
from interpreter.utils.param_decl import Param, ParamSet, LIST
_LUA_FUNC_CONTEXTS_KEY = "_lua_func_contexts" _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. 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=""): def __init__(self, dict_item, parent=None, status_queue=None, filename=""):
self._name = cst.TYPE_LUA_FUNCTION.item_name self._name = cst.TYPE_LUA_FUNCTION.item_name
super().__init__(dict_item, parent, status_queue, filename=filename) 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_result import TestValue
from interpreter.test_items.test_item_dialog_base import TestItemDialogBase, _is_text_mode, _is_interactive 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.constants import TestItemType as cst
from interpreter.utils.param_decl import Param, ParamSet
from runtime.tum_except import item_load_context from runtime.tum_except import item_load_context
@@ -12,6 +13,15 @@ class TestItemMsgDialog(TestItemDialogBase):
"""dialog_message item usage. """dialog_message item usage.
dialog_message name: Nice message, question: Open the door and press OK 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=""): def __init__(self, dict_item, parent=None, status_queue=None, filename=""):
self._name = cst.TYPE_MESSAGE_DLG.item_name self._name = cst.TYPE_MESSAGE_DLG.item_name
super().__init__(dict_item, parent, status_queue, filename=filename) 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_result import TestValue
from interpreter.test_items.test_item_dialog_base import TestItemDialogBase, _is_text_mode, _is_interactive 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.constants import TestItemType as cst
from interpreter.utils.param_decl import Param, ParamSet
from runtime.tum_except import item_load_context from runtime.tum_except import item_load_context
import api.testium as tm import api.testium as tm
class TestItemNoteDialog(TestItemDialogBase): 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=""): def __init__(self, dict_item, parent=None, status_queue=None, filename=""):
self._name = cst.TYPE_NOTE_DLG.item_name self._name = cst.TYPE_NOTE_DLG.item_name
super().__init__(dict_item, parent, status_queue, filename=filename) 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.test_items.test_result import TestResult, TestValue
from interpreter.utils.constants import TestItemType as cst from interpreter.utils.constants import TestItemType as cst
from interpreter.utils.eval import eval_to_boolean 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.tum_except import ETUMSyntaxError
from runtime.string_queue import StringQueue from runtime.string_queue import StringQueue
from runtime.stdout_redirect import stdio_redir from runtime.stdout_redirect import stdio_redir
@@ -15,6 +16,12 @@ class TestItemParallelBranch(TestItemContainer):
"""One branch of a parallel item. Runs its children sequentially, """One branch of a parallel item. Runs its children sequentially,
optionally waiting for a condition before starting.""" 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=""): def __init__(self, dict_item, parent=None, status_queue=None, filename=""):
super().__init__(cst.TYPE_PARALLEL_BRANCH, dict_item, parent, status_queue, filename=filename) super().__init__(cst.TYPE_PARALLEL_BRANCH, dict_item, parent, status_queue, filename=filename)
self._wait_condition = None 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=""): def __init__(self, dict_item, parent=None, status_queue=None, filename=""):
branches = dict_item.get("branches", []) branches = dict_item.get("branches", [])
if not 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.py_func_exec import PyFuncExecEngine
from interpreter.utils.api_srv import api_request from interpreter.utils.api_srv import api_request
from interpreter.utils.constants import TestItemType as cst from interpreter.utils.constants import TestItemType as cst
from interpreter.utils.param_decl import Param, ParamSet, LIST
_PY_FUNC_CONTEXTS_KEY = "_py_func_contexts" _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. 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=""): def __init__(self, dict_item, parent=None, status_queue=None, filename=""):
self._name = cst.TYPE_PY_FUNCTION.item_name self._name = cst.TYPE_PY_FUNCTION.item_name
super().__init__(dict_item, parent, status_queue, filename=filename) 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_result import TestValue
from interpreter.test_items.test_item_dialog_base import TestItemDialogBase, _is_text_mode, _is_interactive 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.constants import TestItemType as cst
from interpreter.utils.param_decl import Param, ParamSet
from runtime.tum_except import item_load_context from runtime.tum_except import item_load_context
@@ -9,6 +10,14 @@ class TestItemQuestionDialog(TestItemDialogBase):
"""dialog_question item usage. """dialog_question item usage.
dialog_question name: Nice question, question: "If OK, press OK, If not, press cancel" 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=""): def __init__(self, dict_item, parent=None, status_queue=None, filename=""):
self._name = cst.TYPE_QUESTION_DLG.item_name self._name = cst.TYPE_QUESTION_DLG.item_name
super().__init__(dict_item, parent, status_queue, filename=filename) 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 interpreter.test_items.test_result import (TestValue)
from runtime.tum_except import ETUMSyntaxError from runtime.tum_except import ETUMSyntaxError
from interpreter.utils.constants import TestItemType as cst 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 from interpreter.test_report.test_report import Export
class TestItemReport(TestItem): 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=""): def __init__(self, dict_item, parent = None, status_queue=None, filename=""):
self._name = cst.TYPE_REPORT.item_name self._name = cst.TYPE_REPORT.item_name
super().__init__(dict_item, parent, status_queue, filename=filename) 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) from interpreter.test_items.test_result import (TestValue)
import api.testium as tm import api.testium as tm
from interpreter.utils.constants import TestItemType as cst 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 from runtime.tum_except import ETUMSyntaxError, ETUMRuntimeError, item_load_context
@@ -57,6 +58,25 @@ def nowInBetween(start, end):
class TestItemRun(TestItem): 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=""): def __init__(self, dict_item, parent = None, status_queue=None, filename=""):
self._name = cst.TYPE_RUN.item_name self._name = cst.TYPE_RUN.item_name
super().__init__(dict_item, parent, status_queue, filename=filename) 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.test_items.item_actions.action import TestItemAction
from interpreter.utils.constants import TestItemType as cst from interpreter.utils.constants import TestItemType as cst
from interpreter.utils.eval import evaluate from interpreter.utils.eval import evaluate
from interpreter.utils.param_decl import Param, ParamSet, LIST
class TestItemPlotAction(TestItemAction): class TestItemPlotAction(TestItemAction):
@@ -21,6 +22,12 @@ class TestItemPlotAction(TestItemAction):
class TestItemPlotActionOpen(TestItemPlotAction): class TestItemPlotActionOpen(TestItemPlotAction):
PARAMS = ParamSet(
Param("log_path", default=None,
doc="Optional file to which the plot data are appended."),
)
def __init__( def __init__(
self, action_name, dict_item, parent=None, status_queue=None, filename="" self, action_name, dict_item, parent=None, status_queue=None, filename=""
): ):
@@ -57,6 +64,15 @@ class TestItemPlotActionOpen(TestItemPlotAction):
class TestItemPlotActionClose(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__( def __init__(
self, action_name, dict_item, parent=None, status_queue=None, filename="" self, action_name, dict_item, parent=None, status_queue=None, filename=""
): ):
@@ -96,6 +112,20 @@ class TestItemPlotActionClose(TestItemPlotAction):
class TestItemPlotActionPeriodic(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__( def __init__(
self, action_name, dict_item, parent=None, status_queue=None, filename="" self, action_name, dict_item, parent=None, status_queue=None, filename=""
): ):
@@ -169,6 +199,13 @@ class TestItemPlotActionAdd(TestItemPlotAction):
class TestItemPlotActionLastValues(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=""): def __init__(self, action_name, dict_item, parent=None, status_queue=None, filename=""):
super().__init__( super().__init__(
action_name, cst.TYPE_GRAPH_ACTION, dict_item, parent, status_queue, filename=filename action_name, cst.TYPE_GRAPH_ACTION, dict_item, parent, status_queue, filename=filename
@@ -219,18 +256,25 @@ class TestItemPlotActionExport(TestItemPlotAction):
class TestItemPlot(TestItemActions): 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."),
)
ACTIONS = {
"open": TestItemPlotActionOpen,
"close": TestItemPlotActionClose,
"periodic": TestItemPlotActionPeriodic,
"add": TestItemPlotActionAdd,
"last_value": TestItemPlotActionLastValues,
"export": TestItemPlotActionExport,
}
def __init__(self, dict_item, parent=None, status_queue=None, filename=""): def __init__(self, dict_item, parent=None, status_queue=None, filename=""):
super().__init__( super().__init__(
cst.TYPE_GRAPH, dict_item, parent, status_queue, filename=filename cst.TYPE_GRAPH, dict_item, parent, status_queue, filename=filename
) )
self.register_actions(
open=TestItemPlotActionOpen,
close=TestItemPlotActionClose,
periodic=TestItemPlotActionPeriodic,
add=TestItemPlotActionAdd,
last_value=TestItemPlotActionLastValues,
export=TestItemPlotActionExport,
)
self.actions_token = self._prms.getParam("plot_name", required=True) self.actions_token = self._prms.getParam("plot_name", required=True)

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_item import (TestItem, test_run)
from interpreter.test_items.test_result import (TestValue) from interpreter.test_items.test_result import (TestValue)
from interpreter.utils.constants import TestItemType as cst 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 from runtime.tum_except import ETUMSyntaxError, ETUMRuntimeError, item_load_context
class TestItemSleep(TestItem): class TestItemSleep(TestItem):
@@ -14,6 +15,15 @@ class TestItemSleep(TestItem):
sleep timeout: 10 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=""): def __init__(self, dict_item, parent = None, status_queue=None, filename=""):
self._name = cst.TYPE_SLEEP.item_name self._name = cst.TYPE_SLEEP.item_name
super().__init__(dict_item, parent, status_queue, filename=filename) 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_result import TestValue
from interpreter.test_items.test_item_dialog_base import TestItemDialogBase, _is_text_mode, _is_interactive 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.constants import TestItemType as cst
from interpreter.utils.param_decl import Param, ParamSet, LIST
from runtime.tum_except import item_load_context from runtime.tum_except import item_load_context
import api.testium as tm import api.testium as tm
class TestItemTestedRefsDialog(TestItemDialogBase): 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=""): def __init__(self, dict_item, parent=None, status_queue=None, filename=""):
self._name = cst.TYPE_REFERENCE_DLG.item_name self._name = cst.TYPE_REFERENCE_DLG.item_name
super().__init__(dict_item, parent, status_queue, filename=filename) 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_result import (TestResult, TestValue)
from interpreter.test_items.test_item import test_data from interpreter.test_items.test_item import test_data
from interpreter.utils.constants import TestItemType as cst from interpreter.utils.constants import TestItemType as cst
from interpreter.utils.param_decl import Param, ParamSet, LIST
from runtime.stdout_redirect import stdio_redir from runtime.stdout_redirect import stdio_redir
class UnittestResult(TextTestResult): class UnittestResult(TextTestResult):
@@ -95,6 +96,15 @@ class TestItemUnittestElement(TestItem):
class TestItemUnittestFile(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=""): def __init__(self, dict_item, parent = None, status_queue=None, filename=""):
self._name = cst.TYPE_UNITTEST.item_name self._name = cst.TYPE_UNITTEST.item_name
super().__init__(dict_item, parent, status_queue, filename=filename) 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_result import TestValue
from interpreter.test_items.test_item_dialog_base import TestItemDialogBase, _is_text_mode, _is_interactive 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.constants import TestItemType as cst
from interpreter.utils.param_decl import Param, ParamSet
from runtime.tum_except import item_load_context from runtime.tum_except import item_load_context
import api.testium as tm import api.testium as tm
@@ -10,6 +11,19 @@ class TestItemValueDialog(TestItemDialogBase):
"""dialog_value item usage. """dialog_value item usage.
dialog_value name: Enter value, question: "Which value did you measure?" 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=""): def __init__(self, dict_item, parent=None, status_queue=None, filename=""):
self._name = cst.TYPE_VALUE_DLG.item_name self._name = cst.TYPE_VALUE_DLG.item_name
super().__init__(dict_item, parent, status_queue, filename=filename) super().__init__(dict_item, parent, status_queue, filename=filename)

View File

@@ -76,6 +76,12 @@ def main(args, conn=None):
if conn: if conn:
settings.setValue(SettingsLastReference, result) settings.setValue(SettingsLastReference, result)
# Flush to disk *before* handing the result back: as soon as the parent
# receives it on the pipe it terminates this subprocess (SIGTERM, no
# handler), so the QSettings destructor never runs. Without sync() the
# write races the kill and is lost — reliably so under Flatpak, where
# the .conf is atomically renamed on the slower ~/.var/app overlay.
settings.sync()
conn.send([result, success]) conn.send([result, success])
conn.close() conn.close()
else: else:

View File

@@ -29,6 +29,51 @@ def _build_item_path(item) -> str:
return " > ".join(reversed(parts)) return " > ".join(reversed(parts))
def _flatten_actions(actions, out, parent_seq_name):
"""Expand nested lists and included ``sequence`` entries into ``out`` as a
flat list of single test-item dicts, propagating each sequence's source
filename onto its items.
Replaces the previous approach, which spliced each entry into the step
list and rebuilt the whole list every time (O(n^2) over the step list, and
a rebuild that duplicated entries when a nested list held more than one
element). This single forward pass is linear.
"""
for idx, action in enumerate(actions):
# a bare list raises its elements to the same level
if isinstance(action, (list, tuple)):
_flatten_actions(action, out, parent_seq_name)
continue
# a NoneType (e.g. pointing at an unused alias) contributes nothing
if action is None:
continue
# a 'sequence' (an included file) is spliced in, with its filename
# propagated onto each of its items
if isinstance(action, dict) and "sequence" in action:
sequence = action["sequence"]["data"]
f = action["sequence"]["filename"]
if isinstance(sequence, dict):
sequence = [{k: v} for k, v in sequence.items()]
# Case of an empty sequence
elif sequence is None:
tm.print_info(
f"An empty sequence is loaded in '{parent_seq_name}'."
)
sequence = []
elif not isinstance(sequence, list):
raise ETUMSyntaxError(
f"Syntax error in '{parent_seq_name}' step number {idx+1}. Sequence definition: '{str(action)}'",
f
)
for s in sequence:
if isinstance(s, dict) and s:
s[list(s.keys())[0]]["seq_filename"] = f
_flatten_actions(sequence, out, parent_seq_name)
continue
out.append(action)
class TestSet: class TestSet:
def __init__( def __init__(
self, self,
@@ -434,56 +479,16 @@ class TestSet:
f"No valid list of actions in sequence {parent_seq_name}", f"No valid list of actions in sequence {parent_seq_name}",
file_name file_name
) )
# first we merged to the same level 'sequence dict entries and list within the list
counter = 0
test_dir = tm.gd("test_directory") test_dir = tm.gd("test_directory")
la = len(parent_seq_actions)
while counter < la:
action = parent_seq_actions[counter]
# if action is a list raise up to the the same level,
# ie insert action element into the parent_seq_actions
if isinstance(action, (list, tuple)):
parent_seq_actions[counter : counter + 1] = action
parent_seq_actions = (
parent_seq_actions[:counter]
+ action
+ parent_seq_actions[counter + 1 :]
)
la = len(parent_seq_actions)
continue
# if action is a NoneType skip and continue
# (when pointing to an unused alias for instance)
if action is None:
counter += 1
continue
# if action is a sequence we insert its entry into the action list
if "sequence" in action:
sequence = action["sequence"]["data"]
f = action["sequence"]["filename"]
if isinstance(sequence, dict):
sequence = [{k: v} for k, v in sequence.items()]
# Case of an empty sequence
elif sequence is None:
tm.print_info(
f"An empty sequence is loaded in '{parent_seq_name}'."
)
sequence = []
elif not isinstance(sequence, list):
raise ETUMSyntaxError(
f"Syntax error in '{parent_seq_name}' step number {counter+1}. Sequence definition: '{str(action)}'",
f
)
for s in sequence:
s[list(s.keys())[0]]["seq_filename"] = f
parent_seq_actions = (
parent_seq_actions[:counter]
+ sequence
+ parent_seq_actions[counter + 1 :]
)
la = len(parent_seq_actions)
continue
# Action is now for sure a list of dict of length 1 # Flatten nested lists and included 'sequence' entries to the same level
# in one linear pass (was an in-place splice + full list rebuild per
# entry: O(n^2) over the step list).
flat_actions = []
_flatten_actions(parent_seq_actions, flat_actions, parent_seq_name)
for action in flat_actions:
# Action is now for sure a dict of length 1
k = list(action.keys())[0] k = list(action.keys())[0]
if action[k].get("seq_filename", None) is None: if action[k].get("seq_filename", None) is None:
action[k]["seq_filename"] = file_name action[k]["seq_filename"] = file_name
@@ -546,8 +551,6 @@ class TestSet:
action[k]["seq_filename"] action[k]["seq_filename"]
) )
counter += 1
return ret return ret
def tree(self): def tree(self):

View File

@@ -19,6 +19,7 @@ Public API
import atexit import atexit
import os import os
import shlex
import shutil import shutil
import subprocess import subprocess
import tempfile import tempfile
@@ -177,6 +178,27 @@ def flatpak_host_spawn(interp_bin, cmd_args, host_cwd, extra_env=None):
return spawn return spawn
def host_console_command(shell_cmd, cwd):
"""Build the argv to start *shell_cmd* as an ordinary interactive console.
*shell_cmd* is the command the caller chose (a string — shell-split — or
an argv list); the choice is preserved verbatim.
Outside Flatpak the command is returned unchanged. Inside Flatpak a bare
spawn would run in the sandbox under the runtime python3, so a host venv
(``/path/venv/bin/python3 -m mod``) can't see its pip deps. We simply run
it on the host with ``flatpak-spawn --host`` so it behaves like any other
terminal: flatpak-spawn passes the current environment through unchanged
and the shell (sourced venv, profile, …) sets things up as the user wants.
No env forwarding or scrubbing — the launcher's leaked PYTHONPATH points at
/app paths absent on the host, so it's inert there.
"""
argv = shlex.split(shell_cmd) if isinstance(shell_cmd, str) else list(shell_cmd)
if not _in_flatpak():
return argv
return ["flatpak-spawn", "--host", f"--directory={cwd}", *argv]
def _which_host_flatpak(name): def _which_host_flatpak(name):
"""Resolve a binary name (or absolute path) on the host via flatpak-spawn. """Resolve a binary name (or absolute path) on the host via flatpak-spawn.

View File

@@ -6,10 +6,10 @@ from runtime.tum_except import ETUMFileError
from interpreter.utils.template import template_to_test from interpreter.utils.template import template_to_test
from copy import copy from copy import copy
from interpreter.utils.globdict import global_dict from interpreter.utils.globdict import global_dict
from interpreter.utils.yaml_load import yaml_load from interpreter.utils.yaml_load import yaml_load, YAML_BASE_LOADER
class TUMLoaderNoIncludes(yaml.Loader): class TUMLoaderNoIncludes(YAML_BASE_LOADER):
def __init__(self, stream): def __init__(self, stream):

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

@@ -1,33 +1,74 @@
import io
import os import os
from sys import exc_info from sys import exc_info
from jinja2 import Template from jinja2 import Environment
from jinja2.exceptions import TemplateSyntaxError, TemplateError, UndefinedError from jinja2.exceptions import TemplateSyntaxError, TemplateError, UndefinedError
from tempfile import TemporaryFile
from interpreter.utils.yaml_load import print_yaml from interpreter.utils.yaml_load import print_yaml
from runtime.tum_except import ETUMSyntaxError from runtime.tum_except import ETUMSyntaxError
# One Environment reused for every render (default settings, i.e. identical
# behaviour to jinja2.Template), plus a compiled-template cache so a file that
# is included many times — or a test that is reloaded — is compiled only once.
# Jinja compilation is the expensive step; render (variable substitution) stays
# per-call. Cache is keyed on path + mtime + size so an edited file recompiles.
_ENV = Environment()
_template_cache = {} # abspath -> (mtime_ns, size, compiled_template)
class _RenderedStream(io.StringIO):
"""A rendered template kept in memory.
Carries ``root`` (and ``name``) so the YAML loader resolves ``!include``
paths exactly as it did from the on-disk temp file this replaces — without
the write + seek + read round-trip (one temp file per included file). That
round-trip is pure overhead, and especially costly on slow storage.
"""
def _compiled_template(filename: str):
"""Return the compiled jinja template for *filename*, reusing the cached
one when the file is unchanged (path + mtime + size)."""
key = os.path.abspath(filename)
try:
st = os.stat(filename)
except OSError:
st = None
if st is not None:
cached = _template_cache.get(key)
if (cached is not None
and cached[0] == st.st_mtime_ns
and cached[1] == st.st_size):
return cached[2]
with open(filename, "r") as f:
source = f.read()
template = _ENV.from_string(source) # compile (may raise TemplateSyntaxError)
if st is not None:
_template_cache[key] = (st.st_mtime_ns, st.st_size, template)
return template
def template_to_test(filename: str, params: list): def template_to_test(filename: str, params: list):
""" Function which processes an eventual jinja2 template to a test file """ Function which processes an eventual jinja2 template to a test file
""" """
# Temporary file created to receive the processed include # Compile (cached) — a syntax error in the template surfaces here.
# file try:
tmpf = TemporaryFile('w+t') j2_template = _compiled_template(filename)
with open(filename, 'r') as f: except TemplateError as e:
try: with open(filename, "r") as f:
j2_template = Template(f.read())
except TemplateError as e:
print_yaml(f, filename) print_yaml(f, filename)
type, value, tb = exc_info() type, value, tb = exc_info()
msg = "Template error" msg = "Template error"
if hasattr(value, 'lineno'): if hasattr(value, 'lineno'):
msg = msg + f" on line {value.lineno}: " msg = msg + f" on line {value.lineno}: "
else: else:
msg += ": " msg += ": "
raise ETUMSyntaxError(msg + str(e), filename) raise ETUMSyntaxError(msg + str(e), filename)
# Render into memory (no temp file).
try: try:
params["include_directory"] = os.path.dirname(os.path.abspath(filename)) params["include_directory"] = os.path.dirname(os.path.abspath(filename))
tmpf.write(j2_template.render(params)) rendered = j2_template.render(params)
except TemplateSyntaxError as e: except TemplateSyntaxError as e:
raise ETUMSyntaxError(f"""Template loading of file '{filename}' with following parameters '{str(params)}' raise ETUMSyntaxError(f"""Template loading of file '{filename}' with following parameters '{str(params)}'
Syntax error in template: {e.message}""") Syntax error in template: {e.message}""")
@@ -42,8 +83,7 @@ Template rendering error: {e.message}""")
raise ETUMSyntaxError(f"""Template loading of file '{filename}' with following parameters '{str(params)}' raise ETUMSyntaxError(f"""Template loading of file '{filename}' with following parameters '{str(params)}'
Unexpected error: {str(e)}""") Unexpected error: {str(e)}""")
# return to begining of the temp file stream = _RenderedStream(rendered)
tmpf.seek(0, os.SEEK_SET) stream.root = os.path.dirname(filename)
tmpf.root = os.path.dirname(filename) stream.name = filename
return stream
return tmpf

View File

@@ -11,7 +11,7 @@ import api.testium as tm
import interpreter.utils.globdict as globdict import interpreter.utils.globdict as globdict
import interpreter.utils.settings as prefs import interpreter.utils.settings as prefs
from interpreter.utils.paths import testium_path from interpreter.utils.paths import testium_path
from interpreter.utils.yaml_load import yaml_load from interpreter.utils.yaml_load import yaml_load, YAML_BASE_LOADER
from interpreter.utils import clear_recursively from interpreter.utils import clear_recursively
from runtime.tum_except import ETUMSyntaxError from runtime.tum_except import ETUMSyntaxError
from interpreter.utils.params import expanse, eval_func_init from interpreter.utils.params import expanse, eval_func_init
@@ -89,7 +89,7 @@ def locate_report_file(rep_file):
def yamltodict(param_file, silent=True): def yamltodict(param_file, silent=True):
# load of the file # load of the file
with open(param_file, "r") as fd: with open(param_file, "r") as fd:
dp = yaml_load(fd, param_file, yaml.Loader) dp = yaml_load(fd, param_file, YAML_BASE_LOADER)
if dp is None: if dp is None:
tm.print_info(f"The YAML file '{param_file}' is empty.") tm.print_info(f"The YAML file '{param_file}' is empty.")

View File

@@ -1,10 +1,54 @@
import atexit
import os import os
import stat
import sys import sys
import tempfile
from importlib import import_module from importlib import import_module
import interpreter.utils.settings as prefs import interpreter.utils.settings as prefs
import api.testium as tm import api.testium as tm
# When running inside a Flatpak, the host /usr/bin/git is reachable at
# /run/host/usr/bin/git but linked against host glibc/zlib, which the
# sandbox can't load (``libz-ng.so.2`` not found). gitpython resolves git
# eagerly on import and would crash the whole test run. We install a
# tiny shell wrapper under /tmp that forwards to ``flatpak-spawn --host
# git``, and point gitpython at it via ``GIT_PYTHON_GIT_EXECUTABLE``.
_HOST_GIT_WRAPPER = None
def _setup_flatpak_git():
global _HOST_GIT_WRAPPER
if not os.path.isfile("/.flatpak-info"):
return
if _HOST_GIT_WRAPPER is not None:
return
fd, path = tempfile.mkstemp(prefix="testium-git-host-", suffix=".sh", dir="/tmp")
with os.fdopen(fd, "w") as f:
f.write('#!/bin/sh\nexec flatpak-spawn --host git "$@"\n')
os.chmod(path, stat.S_IRWXU)
_HOST_GIT_WRAPPER = path
atexit.register(_cleanup_flatpak_git)
os.environ["GIT_PYTHON_GIT_EXECUTABLE"] = path
# Silence gitpython's warning if its refresh probe ever still fails;
# the wrapper itself should make the probe succeed.
os.environ.setdefault("GIT_PYTHON_REFRESH", "quiet")
def _cleanup_flatpak_git():
global _HOST_GIT_WRAPPER
if _HOST_GIT_WRAPPER and os.path.isfile(_HOST_GIT_WRAPPER):
try:
os.unlink(_HOST_GIT_WRAPPER)
except OSError:
pass
_HOST_GIT_WRAPPER = None
_setup_flatpak_git()
_cached_versions = {} _cached_versions = {}
def repo_rev(path): def repo_rev(path):

View File

@@ -1,3 +1,4 @@
import yaml
from yaml.parser import ParserError from yaml.parser import ParserError
from yaml import load, Loader from yaml import load, Loader
from yaml.scanner import ScannerError from yaml.scanner import ScannerError
@@ -5,6 +6,12 @@ from api.testium import print_debug
from runtime.tum_except import ETUMSyntaxError from runtime.tum_except import ETUMSyntaxError
import io import io
# Use the libyaml-backed loader (much faster parsing) when PyYAML was built
# with it, falling back to the pure-Python loader otherwise. The C loader
# raises the same ParserError/ScannerError and supports the same custom
# constructors (!include) and construct_* helpers the TUM loaders rely on.
YAML_BASE_LOADER = yaml.CLoader if getattr(yaml, "__with_libyaml__", False) else yaml.Loader
def print_yaml(file: io.TextIOWrapper, file_name): def print_yaml(file: io.TextIOWrapper, file_name):
""" Prints YAML file if debug mode is activated. """ Prints YAML file if debug mode is activated.
@@ -21,10 +28,10 @@ def yaml_load(file, real_file_name: str, loader: Loader):
return load(file, loader) return load(file, loader)
except ParserError as e: except ParserError as e:
if isinstance(file, io.TextIOWrapper): if isinstance(file, (io.TextIOWrapper, io.StringIO)):
print_yaml(file, real_file_name) print_yaml(file, real_file_name)
raise ETUMSyntaxError(f"yaml file parsing error: " + str(e), real_file_name) raise ETUMSyntaxError(f"yaml file parsing error: " + str(e), real_file_name)
except ScannerError as e: except ScannerError as e:
if isinstance(file, io.TextIOWrapper): if isinstance(file, (io.TextIOWrapper, io.StringIO)):
print_yaml(file, real_file_name) print_yaml(file, real_file_name)
raise ETUMSyntaxError("yaml file scanning error: " + str(e), real_file_name) raise ETUMSyntaxError("yaml file scanning error: " + str(e), real_file_name)

View File

@@ -0,0 +1,16 @@
"""testium language tooling.
Hosts the JSON-Schema-style schema export of every test item type, and a
``pygls`` language server that consumes the same schema to provide
completion / hover / diagnostics for ``.tum`` files in any LSP-capable
editor (VSCode, neovim, Helix, Emacs, …).
Entry points (both surfaced through the ``testium`` CLI):
- ``testium schema`` — dump the schema of every item type as JSON on stdout.
Zero runtime dependencies; can be used by editors that already speak the
YAML JSON Schema extension to get static completion immediately.
- ``testium lsp`` — start the language server over stdio. Requires the
``pygls`` optional dependency (``pip install testium[lsp]``).
"""

View File

@@ -0,0 +1,6 @@
"""Entry point for ``python -m testium.lsp`` (alternative to ``testium lsp``)."""
from lsp.server import serve
if __name__ == "__main__":
serve()

122
src/testium/lsp/schema.py Normal file
View File

@@ -0,0 +1,122 @@
"""Schema export of the test item registry.
Walks every ``TestItemType`` entry (``interpreter/utils/constants.py``),
combines its declared ``PARAMS`` with the common ones, and returns a
serialisable structure keyed by ``item_cmd`` — the YAML key the user
writes (e.g. ``sleep``, ``py_func``, ``dialog_message``).
Items intentionally without ``PARAMS`` (the unstructured-body classes
like console ``write``/``writeln`` or plot ``add``/``export``) are
emitted as ``"params_declared": false`` so consumers know to suggest
nothing for them rather than reporting a closed empty set.
Action items (children of ``parallel``, ``console``, ``json_rpc``,
``plot``) are registered separately under each parent's ``actions``
entry — they're not top-level YAML keys, they live nested inside a
parent's ``steps:``.
"""
import json
from interpreter.utils.constants import TestItemType
from interpreter.utils.test_init import _constants_init
# Action class -> parent cmd (the action's parent in the YAML). Action classes
# aren't first-class TestItemType entries (TYPE_*_ACTION is one generic bucket),
# so we resolve their YAML key from the parent's declarative ``ACTIONS`` map.
def _collect_action_classes(parent_class):
"""Return {action_yaml_key: action_class} for a TestItemActions parent.
Each parent declares its actions as a class-level ``ACTIONS = {key: class}``
attribute (see ``item_actions/TestItemActions``). We read it directly — no
instantiation, no source inspection — so this works identically whether the
package runs from source, a wheel, or a frozen (PyInstaller) build where the
``.py`` source isn't on disk.
"""
return dict(getattr(parent_class, "ACTIONS", None) or {})
def _params_to_schema(item_class, common_params):
"""Return the params-portion of an item's schema entry.
Common params are flagged so consumers can render them differently
(an editor might show "common" parameters in a separate group).
"""
own = getattr(item_class, "PARAMS", None)
if own is None:
return {"params_declared": False}
common_names = set(common_params.names())
params = []
for p in common_params:
d = p.to_schema()
d["common"] = True
params.append(d)
for p in own:
if p.name in common_names:
# Subclass overrode a common param (e.g. tightened doc).
for d in params:
if d["name"] == p.name:
d.update(p.to_schema())
break
continue
d = p.to_schema()
d["common"] = False
params.append(d)
return {"params_declared": True, "params": params}
def dump_all_schemas():
"""Return the full schema as a Python dict ready for json.dumps.
Shape:
{
"items": {
"sleep": {
"display_name": "Sleep",
"params_declared": true,
"params": [{name, kind, required, default?, doc, common}, ...],
},
"console": {
...,
"actions": {"open": {...}, "close": {...}, ...},
},
...
}
}
"""
_constants_init()
# Imported lazily — pulls test_item.py which references constants.
from interpreter.test_items.test_item import COMMON_PARAMS
out = {"items": {}}
for tp in TestItemType:
cls = getattr(tp, "item_class", None)
if cls is None:
continue
# Action types (CONSOLE_ACTION, GRAPH_ACTION, JSON_RPC_ACTION) have no
# standalone YAML representation — skip them here, they show up under
# their parent's "actions" key.
cmd = tp.item_cmd
if cmd.endswith("_action"):
continue
entry = {"display_name": tp.item_name}
entry.update(_params_to_schema(cls, COMMON_PARAMS))
actions = _collect_action_classes(cls)
if actions:
entry["actions"] = {
name: _params_to_schema(acls, COMMON_PARAMS)
for name, acls in actions.items()
}
for name in entry["actions"]:
entry["actions"][name]["display_name"] = name
out["items"][cmd] = entry
return out
def dump_all_schemas_json(indent=2):
"""Same as ``dump_all_schemas`` but serialised to a JSON string."""
return json.dumps(dump_all_schemas(), indent=indent, sort_keys=False,
default=str)

313
src/testium/lsp/server.py Normal file
View File

@@ -0,0 +1,313 @@
"""LSP server for ``.tum`` files.
Features available so far:
- **Completion** — when the user starts a new YAML step (``- <cursor>``),
the server proposes the full list of known item types. The completion
item carries a short hover-style description listing required and
optional parameters.
- **Hover** — over a known item-type word (``sleep``, ``py_func``, …)
the server renders the same description in a popup.
- **Document symbols (outline)** — every ``- <type>:`` line becomes an
entry in the editor's outline view. Nesting follows YAML indentation,
so containers (``group``, ``loop``, ``parallel``, ``console`` …)
display their children as a subtree.
The server speaks LSP over stdio. Start it with::
testium lsp
Editors invoke it through their LSP client; the connection layer
(``vscode-languageclient``, ``nvim-lspconfig``, ``lsp-mode``, …) takes
care of the JSON-RPC framing.
Architecture notes
------------------
The schema is built once at server start (``dump_all_schemas()``) and
kept in memory; an editor restart picks up upstream changes. The schema
is the **only** source of truth — when testium adds a new item type or
parameter, the LSP automatically exposes it without any change here.
The current handlers stay deliberately heuristic on the parser side:
completion uses a line-prefix regex, outline a per-line ``- <known>:``
sweep with indentation tracking. A proper YAML+Jinja parsing pass is
still pending and is the prerequisite for *parameter*-level completion
and diagnostics.
"""
import re
try:
# pygls 2.x moved LanguageServer under pygls.lsp.server. We pin >=1.3 in
# the optional dependency to stay open to either family, but the import
# path differs — try the new one first, then the legacy one.
try:
from pygls.lsp.server import LanguageServer
except ImportError:
from pygls.server import LanguageServer # pygls < 2
from lsprotocol.types import (
TEXT_DOCUMENT_COMPLETION,
TEXT_DOCUMENT_DOCUMENT_SYMBOL,
TEXT_DOCUMENT_HOVER,
CompletionItem,
CompletionItemKind,
CompletionList,
CompletionOptions,
CompletionParams,
DocumentSymbol,
DocumentSymbolParams,
Hover,
HoverParams,
InsertTextFormat,
MarkupContent,
MarkupKind,
Position,
Range,
SymbolKind,
)
except ImportError as exc:
# Surfaced by the CLI dispatcher with a friendly install hint.
raise
from lsp.schema import dump_all_schemas
_LINE_START_STEP = re.compile(r"^\s*-\s*([A-Za-z_][A-Za-z0-9_]*)?\s*:?\s*$")
# Matches "- <identifier>:" for outline / hover purposes. Captures the start
# column of the identifier and the identifier itself. Trailing tokens after
# the colon (inline-form params, comments) are tolerated.
_STEP_LINE = re.compile(r"^(?P<lead>\s*-\s*)(?P<ident>[A-Za-z_][A-Za-z0-9_]*)\s*:")
# Matches a ``name: <value>`` line under an item — used by the outline pass
# to surface the user's display name next to the item type.
_NAME_FIELD = re.compile(r"^\s*name\s*:\s*(?P<value>.+?)\s*$")
# Word boundary used by hover to extract the identifier under the cursor.
_IDENT_AT = re.compile(r"[A-Za-z_][A-Za-z0-9_]*")
def _render_item_markdown(cmd, entry):
"""Render an item-type's schema entry as a Markdown hover string.
Reused by both the completion-item documentation and the hover
handler so the editor presents identical information regardless of
how the user reached it.
"""
detail = entry.get("display_name", cmd)
lines = [f"**{cmd}** — {detail}", ""]
if entry.get("params_declared"):
non_common = [p for p in entry["params"] if not p["common"]]
required = [p for p in non_common if p["required"]]
optional = [p for p in non_common if not p["required"]]
if required:
lines.append("Required parameters:")
for p in required:
lines.append(f"- `{p['name']}` — {p['doc']}")
lines.append("")
if optional:
lines.append("Optional parameters:")
for p in optional:
lines.append(f"- `{p['name']}` — {p['doc']}")
else:
lines.append("(Parameter list is not described — this item's body is the "
"raw user value.)")
return "\n".join(lines)
def _build_item_completions(schema):
"""Return a list of CompletionItem covering every top-level item type.
Each completion inserts ``<name>:`` with the cursor positioned after
the colon so the user can immediately start typing parameters.
"""
items = []
for cmd, entry in schema["items"].items():
if cmd == "default":
# Root sentinel; never appears as a YAML key.
continue
items.append(
CompletionItem(
label=cmd,
kind=CompletionItemKind.Class,
detail=entry.get("display_name", cmd),
documentation=MarkupContent(
kind=MarkupKind.Markdown,
value=_render_item_markdown(cmd, entry),
),
insert_text=f"{cmd}:",
insert_text_format=InsertTextFormat.PlainText,
)
)
items.sort(key=lambda it: it.label)
return items
def _word_at(line, character):
"""Return ``(start, end, text)`` of the identifier under ``character``.
Returns ``None`` when the cursor isn't on a word. Used by hover.
"""
for m in _IDENT_AT.finditer(line):
if m.start() <= character <= m.end():
return m.start(), m.end(), m.group(0)
return None
def _build_document_symbols(lines, item_cmds):
"""Walk ``lines`` and produce a nested ``DocumentSymbol`` tree.
Heuristics (no YAML parsing yet):
- Each ``- <known_cmd>:`` line becomes a symbol.
- Nesting follows the indentation of the leading ``-``: a deeper-
indented step is treated as a child of the most recent shallower
step.
- The symbol's ``detail`` is the ``name: <value>`` field if found
within a small window after the step header (no YAML parsing —
we just look at indented lines that aren't another ``- …`` step).
The result is suitable for the LSP outline panel even when the
surrounding YAML is mid-edit and structurally invalid.
"""
root_children = []
# Each stack entry: (indent_col, children_list_to_append_to,
# pending_parent_symbol or None).
stack = [(-1, root_children, None)]
def _attach_name(parent_symbol, start_line):
"""Look for the nearest ``name:`` field in the children of ``parent``."""
if parent_symbol is None or start_line + 1 >= len(lines):
return
base_indent = len(lines[start_line]) - len(lines[start_line].lstrip(" "))
for j in range(start_line + 1, min(start_line + 10, len(lines))):
l = lines[j]
stripped = l.lstrip(" ")
indent = len(l) - len(stripped)
if indent <= base_indent and stripped.strip() != "":
break
m = _NAME_FIELD.match(l)
if m:
value = m.group("value").strip("\"' ")
parent_symbol.detail = value
return
for i, raw_line in enumerate(lines):
m = _STEP_LINE.match(raw_line)
if not m:
continue
cmd = m.group("ident")
if cmd not in item_cmds:
continue
indent = len(m.group("lead")) - len(m.group("lead").lstrip(" "))
# Pop the stack until we find a parent with strictly smaller indent.
while stack and stack[-1][0] >= indent:
stack.pop()
if not stack:
stack.append((-1, root_children, None))
parent_children = stack[-1][1]
ident_start = m.start("ident")
ident_end = m.end("ident")
symbol = DocumentSymbol(
name=cmd,
detail=None,
kind=SymbolKind.Function,
range=Range(
start=Position(line=i, character=0),
end=Position(line=i, character=len(raw_line.rstrip("\n"))),
),
selection_range=Range(
start=Position(line=i, character=ident_start),
end=Position(line=i, character=ident_end),
),
children=[],
)
parent_children.append(symbol)
stack.append((indent, symbol.children, symbol))
_attach_name(symbol, i)
return root_children
def _make_server():
server = LanguageServer("testium-lsp", "0.1.0")
schema = dump_all_schemas()
item_completions = _build_item_completions(schema)
# Set of cmd names accepted by the outline / hover passes. We include
# action names (console open/close/…, plot open/close/…, …) too so they
# appear in the outline tree and respond to hover.
item_cmds = set()
for cmd, entry in schema["items"].items():
if cmd == "default":
continue
item_cmds.add(cmd)
item_cmds.update(entry.get("actions", {}).keys())
@server.feature(
TEXT_DOCUMENT_COMPLETION,
CompletionOptions(trigger_characters=["-", " "]),
)
def completion(params: CompletionParams):
doc = server.workspace.get_text_document(params.text_document.uri)
line_idx = params.position.line
if line_idx >= len(doc.lines):
return CompletionList(is_incomplete=False, items=[])
line = doc.lines[line_idx]
# Only look at what's left of the cursor.
prefix = line[: params.position.character]
if not _LINE_START_STEP.match(prefix):
return CompletionList(is_incomplete=False, items=[])
return CompletionList(is_incomplete=False, items=item_completions)
@server.feature(TEXT_DOCUMENT_HOVER)
def hover(params: HoverParams):
doc = server.workspace.get_text_document(params.text_document.uri)
line_idx = params.position.line
if line_idx >= len(doc.lines):
return None
line = doc.lines[line_idx]
# Only respond when the cursor is on the type part of a step line
# ("- sleep:") — never for arbitrary words in a string.
step_match = _STEP_LINE.match(line)
if not step_match:
return None
word = _word_at(line, params.position.character)
if word is None:
return None
start, end, text = word
if text != step_match.group("ident") or text not in item_cmds:
return None
# Resolve the entry: top-level item, or action of any parent.
entry = schema["items"].get(text)
if entry is None:
for parent_entry in schema["items"].values():
actions = parent_entry.get("actions") or {}
if text in actions:
entry = actions[text]
break
if entry is None:
return None
return Hover(
contents=MarkupContent(
kind=MarkupKind.Markdown,
value=_render_item_markdown(text, entry),
),
range=Range(
start=Position(line=line_idx, character=start),
end=Position(line=line_idx, character=end),
),
)
@server.feature(TEXT_DOCUMENT_DOCUMENT_SYMBOL)
def document_symbols(params: DocumentSymbolParams):
doc = server.workspace.get_text_document(params.text_document.uri)
return _build_document_symbols(doc.lines, item_cmds)
return server
def serve():
"""Start the LSP server on stdio. Blocks until the client disconnects."""
server = _make_server()
server.start_io()

View File

@@ -0,0 +1,95 @@
"""Install a desktop entry + icon under the user's data dir so desktop shells
show the testium icon in the task bar / dock.
On a native Wayland session GNOME takes a window's task-bar icon from the
``.desktop`` file whose name (or ``StartupWMClass``) matches the window
``app_id`` — ``QGuiApplication.setWindowIcon`` is ignored there. The portable
channels (source checkout, PyInstaller binary, AppImage) install no system
desktop file, so we drop an idempotent one in ``~/.local/share``. The window
``app_id`` is set to ``testium`` (see ``QApplication.setDesktopFileName`` in
``testium_win``), which is exactly this file's base name.
Flatpak ships its own ``org.testium.Testium.desktop`` and keeps its own app id,
so the caller skips this integration there.
"""
import os
import sys
from PySide6.QtCore import Qt
from PySide6.QtGui import QPixmap
# Must match QApplication.setDesktopFileName(...) for the GUI, and is used as
# both the desktop-file base name and the StartupWMClass.
APP_ID = "testium"
def _launch_command():
"""Best-effort Exec= for the menu entry. Not needed for icon matching, but
makes the entry actually launchable when possible."""
appimage = os.environ.get("APPIMAGE")
if appimage:
return f'"{appimage}"'
if getattr(sys, "frozen", False):
return f'"{os.path.abspath(sys.executable)}"'
argv0 = os.path.abspath(sys.argv[0]) if sys.argv and sys.argv[0] else ""
if argv0 and os.path.exists(argv0):
return f'"{os.path.abspath(sys.executable)}" "{argv0}"'
return f'"{os.path.abspath(sys.executable)}" -m testium'
def ensure_desktop_entry():
"""Create (or refresh) ~/.local/share icon + desktop entry. Best-effort:
any failure is swallowed so it can never take the GUI down.
Freedesktop-only: a no-op off Linux (Windows / macOS use the window icon)."""
if not sys.platform.startswith("linux"):
return
try:
data_home = os.environ.get("XDG_DATA_HOME") or os.path.join(
os.path.expanduser("~"), ".local", "share"
)
icon_dir = os.path.join(data_home, "icons", "hicolor", "256x256", "apps")
app_dir = os.path.join(data_home, "applications")
icon_path = os.path.join(icon_dir, f"{APP_ID}.png")
desktop_path = os.path.join(app_dir, f"{APP_ID}.desktop")
os.makedirs(icon_dir, exist_ok=True)
os.makedirs(app_dir, exist_ok=True)
# Icon: render the bundled Qt resource to a PNG once. Requires a live
# QGuiApplication (the caller creates it before calling us).
if not os.path.isfile(icon_path):
pixmap = QPixmap(u":/black/testium_logo.png")
if not pixmap.isNull():
pixmap = pixmap.scaled(
256, 256,
Qt.AspectRatioMode.KeepAspectRatio,
Qt.TransformationMode.SmoothTransformation,
)
pixmap.save(icon_path, "PNG")
# Absolute Icon= path so the shell resolves it without an icon-cache
# refresh; StartupWMClass lets X11 / XWayland match too.
desktop = (
"[Desktop Entry]\n"
"Type=Application\n"
"Name=Testium\n"
"Comment=Test sequencer\n"
f"Icon={icon_path}\n"
f"Exec={_launch_command()} %f\n"
"Terminal=false\n"
f"StartupWMClass={APP_ID}\n"
"Categories=Utility;Development;\n"
)
# Write only when missing or changed, to avoid needless menu churn.
current = None
if os.path.isfile(desktop_path):
with open(desktop_path, "r") as fh:
current = fh.read()
if current != desktop:
with open(desktop_path, "w") as fh:
fh.write(desktop)
except Exception:
# Desktop integration is a nicety, never a hard requirement.
pass

View File

@@ -678,6 +678,24 @@ def MainWin(
debug=False, debug=False,
): ):
app = QApplication(sys.argv) app = QApplication(sys.argv)
# Application identity so desktop shells (GNOME, ...) show the testium
# icon in the task bar / dock instead of a generic one. On Wayland this
# sets the surface app_id; on X11/XWayland it sets WM_CLASS, so the window
# stops inheriting the launcher's class (e.g. "python3" under the AppImage,
# which is what GNOME was keying the wrong icon off) and the window icon
# below is used as the fallback. In Flatpak the id must be the Flatpak app
# id so it matches the installed desktop file.
app.setApplicationName("Testium")
app.setApplicationDisplayName("Testium")
app.setDesktopFileName(os.environ.get("FLATPAK_ID", "testium"))
app.setWindowIcon(QIcon(u":/black/testium_logo.png"))
# On native Wayland the task-bar icon comes from an installed desktop file
# matched to the app_id, not from setWindowIcon(). Flatpak ships its own;
# for the other Linux channels drop an idempotent one under ~/.local/share.
# Windows / macOS use the window icon set above, so this is Linux-only.
if sys.platform.startswith("linux") and not os.environ.get("FLATPAK_ID"):
from main_win.desktop_integration import ensure_desktop_entry
ensure_desktop_entry()
ui = MainWindow( ui = MainWindow(
test_file, test_file,
config_files, config_files,

1
test/benchmark/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
cases/

116
test/benchmark/README.md Normal file
View File

@@ -0,0 +1,116 @@
# Load-time benchmark
Measures how long *testium* takes to **load** a `.tum` test tree — template
rendering (jinja) + YAML parsing + test-tree construction — *without* executing
it. Purpose: get reproducible numbers before/after load-path optimisations, and
attribute any gain to a specific part of the pipeline.
It is meant for *very long* tests, the kind you can build with `jinja` loops and
`!include`, where load time becomes noticeable.
## Files
| File | Role |
|------|------|
| `gen_bench_test.py` | Generates a synthetic `.tum` tree (the test input). |
| `load_bench.py` | Drives the **real** loader in-process and times it. |
| `run.sh` | Convenience: generate + time across profiles, using the project venv. |
| `cases/` | Generated trees (git-ignored, recreated on demand). |
The benchmark `.tum` files are **generated**, not committed — the generator is
the artifact. They use only `let` leaves and `group` containers, so loading has
no runtime side effect (no subprocess, no `<| |>` eval) and the timing reflects
the parse/build pipeline alone.
## Quick start
```bash
# default matrix (all profiles), 5 repeats each
./test/benchmark/run.sh
# one profile at one size
./test/benchmark/run.sh repeat 2000
# more repeats for a tighter min
REPEAT=10 ./test/benchmark/run.sh includes 1000
```
`run.sh` uses the project venv at `test/tmp/.venv` (created by `./run.sh`). If it
is missing, run `./run.sh` once first.
To drive the harness directly on any `.tum` (not just generated ones):
```bash
test/tmp/.venv/bin/python3 test/benchmark/load_bench.py --repeat 5 --quiet path/to/main.tum
```
## Profiles
Each profile isolates one cost. `--size` is the profile-specific count.
| Profile | What it builds | Stresses |
|---------|----------------|----------|
| `flat` | one main file, N inline `let` steps | big YAML parse + linear object build |
| `includes` | main `!include`s N **distinct** sub-files | per-include template+YAML+tempfile, `sequence` splice |
| `repeat` | main `!include`s the **same** parametrised leaf N times | jinja **recompilation** of an identical template |
| `jinja` | one main file, `{% for %}` emitting N steps | single large render + single large parse |
| `deep` | nested includes, depth N | include recursion (see caveat) |
| `mix` | groups + jinja loop + distinct + repeated includes | realistic blend |
## Reading the output
```
phase min median
initial 0.1131 0.1285 <- pass 1: discover config files (no includes)
loadtest 1.0724 1.0900 <- config fixpoint loop + full recursive include load
build 0.1850 0.1976 <- TestSet: load_test_recursively tree build
total 1.3886 1.4227
counters (last run):
templates : 1003 calls 0.5247s (exclusive: jinja compile+render+tempfile)
yaml : 1004 parses 1.4696s (inclusive of nested includes)
```
- **min** is the headline (least noisy); median is a sanity check.
- **initial / loadtest / build** map to the three pipeline stages in
`interpreter/process.py` and `interpreter/test_set.py`. The main file is
rendered+parsed across `initial` *and* `loadtest` (the loader does ~3 passes).
- **templates** = number of `template_to_test()` calls and their *exclusive*
wall time (one file render each — pure jinja compile+render+tempfile I/O).
A high count with the same source file = recompilation, the `repeat` case.
- **yaml** = number of `yaml_load()` parses. Its time is *inclusive* of nested
includes, so use the **count** for attribution, not the seconds.
## Mapping to the optimisation axes
| Axis (see DESIGN / discussion) | Watch | Best profile to prove it |
|--------------------------------|-------|--------------------------|
| 1 — cache compiled jinja templates | `templates` time drops, count unchanged | `repeat` |
| 2 — drop the tempfile round-trip | `templates` time drops | `includes`, `repeat`, `mix` |
| 3 — C YAML loader (libyaml) | `yaml` time / `loadtest` drops | `flat`, `jinja` |
| 6 — O(n²) sequence splice | `build` drops | `includes`, `mix` |
## How to compare before/after a change
1. Run the matrix on the current code, keep the output.
2. Apply one axis.
3. Re-run the **same** profiles/sizes; compare `min` per phase and the counters.
Change one axis at a time so the attribution is clean. Run on an idle machine
(and note the disk: on a USB stick the tempfile round-trip of axis 2 weighs
more).
## Caveat: deep includes
The loader is recursive and spends ~10 stack frames per include level, so
`deep` hits Python's `RecursionError` around ~90 nested levels. The harness
reports this cleanly instead of crashing. Real tests are *wide* (many steps /
many includes), not deep, so `includes`/`repeat`/`jinja`/`mix` are the
representative "very long" cases.
## Notes
- No execution is triggered — timing stops where `Batch` would mark the test
*loaded*.
- The profiles contain no `<| |>`, so the external eval process is not started.
Pass `--with-eval` to `load_bench.py` for trees that evaluate at load time.
- Numbers are machine- and disk-specific; only compare runs from the same host.

179
test/benchmark/gen_bench_test.py Executable file
View File

@@ -0,0 +1,179 @@
#!/usr/bin/env python3
"""Generate synthetic ``.tum`` test trees to benchmark *load* time.
The generated trees are deliberately cheap to *build* (only ``let`` leaves and
``group`` containers — no subprocess, no runtime side effect) so the load
benchmark measures the parse / template / tree-build pipeline and nothing else.
Profiles, each targeting a specific cost in the loader:
flat one main file, N inline ``let`` steps, no include, no jinja.
Baseline: YAML parse of a big document + linear object build.
includes main ``!include``s N *distinct* sub-files (a few steps each).
Stresses the per-include template+YAML+tempfile round-trip and the
``sequence`` splice in test_set.load_test_recursively.
repeat main ``!include``s the *same* parametrised leaf file N times.
Stresses jinja *recompilation*: the compiled template is identical
every time, only the render params (idx) differ -> the case a
template cache collapses.
jinja one main file whose ``{% for %}`` loop emits N steps.
Stresses a single large jinja render + a single large YAML parse.
deep nested includes, depth N (main -> d0 -> d1 -> ...).
Stresses include recursion and per-level template+YAML.
mix a realistic blend: groups, a jinja loop, distinct includes and a
repeated parametrised include.
Usage:
gen_bench_test.py --profile repeat --size 1000 --out cases/repeat_1000
-> writes <out>/main.tum (+ includes, + param.yaml) and prints the path.
"""
import argparse
import os
import shutil
def _let(indent, i, name=None):
name = name if name is not None else f"s{i}"
pad = " " * indent
return (
f"{pad}- let:\n"
f"{pad} name: {name}\n"
f"{pad} values:\n"
f"{pad} - k{i}: {i}\n"
)
def gen_flat(out, n):
body = "".join(_let(8, i) for i in range(n))
main = f"main:\n name: bench flat {n}\n steps:\n{body}"
_write(out, "main.tum", main)
def gen_includes(out, n):
steps = "".join(f" - !include inc_{i}.tum\n" for i in range(n))
main = f"main:\n name: bench includes {n}\n steps:\n{steps}"
_write(out, "main.tum", main)
for i in range(n):
# each include is a YAML *sequence* (list of steps)
seq = "".join(_let(0, i * 3 + j, name=f"inc{i}_{j}") for j in range(3))
_write(out, f"inc_{i}.tum", seq)
def gen_repeat(out, n):
steps = "".join(
f" - !include {{file: leaf.tum, idx: {i}}}\n" for i in range(n)
)
main = f"main:\n name: bench repeat {n}\n steps:\n{steps}"
_write(out, "main.tum", main)
leaf = (
"- let:\n"
" name: leaf_{{ idx }}\n"
" values:\n"
" - leaf_{{ idx }}: {{ idx }}\n"
)
_write(out, "leaf.tum", leaf)
def gen_jinja(out, n):
main = (
f"main:\n name: bench jinja {n}\n steps:\n"
"{% for i in range(" + str(n) + ") %}\n"
" - let:\n"
" name: j{{ i }}\n"
" values:\n"
" - k{{ i }}: {{ i }}\n"
"{% endfor %}\n"
)
_write(out, "main.tum", main)
def gen_deep(out, n):
main = (
f"main:\n name: bench deep {n}\n steps:\n"
" - let:\n name: top\n values:\n - a: 0\n"
" - !include d_0.tum\n"
)
_write(out, "main.tum", main)
for i in range(n):
seq = _let(0, i, name=f"d{i}")
if i < n - 1:
seq += f"- !include d_{i + 1}.tum\n"
_write(out, f"d_{i}.tum", seq)
def gen_mix(out, n):
# n groups, each: 2 inline lets, one distinct include, one repeated include,
# plus a small jinja loop. Roughly ~6*n steps.
per = max(1, n)
parts = [f"main:\n name: bench mix {n}\n steps:\n"]
for g in range(per):
parts.append(
f" - group:\n"
f" name: grp{g}\n"
f" steps:\n"
)
parts.append(_let(16, g * 2, name=f"g{g}_a"))
parts.append(_let(16, g * 2 + 1, name=f"g{g}_b"))
parts.append(f" - !include inc_{g}.tum\n")
parts.append(f" - !include {{file: leaf.tum, idx: {g}}}\n")
parts.append(
"{% for i in range(3) %}\n"
f" - let:\n"
f" name: g{g}_j{{{{ i }}}}\n"
f" values:\n"
f" - g{g}_k{{{{ i }}}}: {{{{ i }}}}\n"
"{% endfor %}\n"
)
_write(out, "main.tum", "".join(parts))
for g in range(per):
_write(out, f"inc_{g}.tum", _let(0, g, name=f"mixinc{g}"))
_write(
out,
"leaf.tum",
"- let:\n name: mixleaf_{{ idx }}\n values:\n - mixleaf_{{ idx }}: {{ idx }}\n",
)
PROFILES = {
"flat": gen_flat,
"includes": gen_includes,
"repeat": gen_repeat,
"jinja": gen_jinja,
"deep": gen_deep,
"mix": gen_mix,
}
def _write(out, name, content):
with open(os.path.join(out, name), "w") as f:
f.write(content)
def main():
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--profile", required=True, choices=sorted(PROFILES))
ap.add_argument("--size", type=int, default=1000,
help="profile-specific count (steps / includes / depth)")
ap.add_argument("--out", required=True, help="output directory (recreated)")
args = ap.parse_args()
out = os.path.abspath(args.out)
if os.path.isdir(out):
shutil.rmtree(out)
os.makedirs(out)
# minimal config file so the loader does not emit "no param file" noise
_write(out, "param.yaml", "bench_dummy: 1\n")
PROFILES[args.profile](out, args.size)
print(os.path.join(out, "main.tum"))
if __name__ == "__main__":
main()

200
test/benchmark/load_bench.py Executable file
View File

@@ -0,0 +1,200 @@
#!/usr/bin/env python3
"""Time the testium *load* pipeline on a given ``.tum`` tree.
It drives the real loader code (``TestProcess._load_initial_params`` /
``_load_test`` then ``TestSet(...)``) in-process, so the numbers track the
production path and stay honest as the code evolves. Execution is never
triggered — we stop exactly where ``Batch`` would report the test as *loaded*.
Reported per run, over ``--repeat`` iterations (min is the headline, least
noisy):
initial first pass: discover config files (template+YAML, no includes)
loadtest config-file fixpoint loop + full recursive include/template/YAML
build TestSet construction: the load_test_recursively tree build
total sum of the three
Plus instrumentation counters (exact call counts, wall time) for the two
hot leaves the optimisation axes target:
templates jinja template_to_test() calls (axis 1 compile cache, axis 2 tempfile)
yaml yaml_load() parses (axis 3 C loader)
template time is exclusive (one file render); yaml time is wall-inclusive of
nested includes, so lean on the *counts* for attribution.
Must run inside the project venv (jinja2, pyyaml, telnetlib3, ...). The
benchmark profiles contain no ``<| |>`` so the external eval process is not
needed; pass --with-eval to start it for faithfulness on eval-heavy trees.
Usage (see run.sh for the convenience wrapper):
test/tmp/.venv/bin/python3 test/benchmark/load_bench.py [--repeat 5] <main.tum>
"""
import argparse
import os
import statistics
import sys
from queue import Queue
from time import perf_counter
# --- bootstrap: src/testium for flat imports, src for `import testium` --------
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.abspath(os.path.join(HERE, "..", ".."))
sys.path.insert(0, os.path.join(ROOT, "src"))
sys.path.insert(0, os.path.join(ROOT, "src", "testium"))
import api.testium as tm # noqa: E402
from interpreter.utils.test_init import env_init, apply_overrides # noqa: E402
from interpreter.utils.test_ctrl import TestSetController # noqa: E402
from interpreter.process import TestProcess # noqa: E402
from interpreter.test_set import TestSet # noqa: E402
from interpreter.utils.py_eval import eval_process_init # noqa: E402
from interpreter.utils.api_srv import api_request # noqa: E402
# --- instrumentation: count + time the two hot leaves -------------------------
import interpreter.process as _proc # noqa: E402
import interpreter.utils.include as _inc # noqa: E402
import interpreter.utils.test_init as _ti # noqa: E402
import interpreter.utils.template as _tpl # noqa: E402
import interpreter.utils.yaml_load as _yl # noqa: E402
_C = {"tpl_n": 0, "tpl_t": 0.0, "yaml_n": 0, "yaml_t": 0.0}
_orig_tpl = _tpl.template_to_test
_orig_yaml = _yl.yaml_load
def _wrap_tpl(*a, **k):
t = perf_counter()
try:
return _orig_tpl(*a, **k)
finally:
_C["tpl_t"] += perf_counter() - t
_C["tpl_n"] += 1
def _wrap_yaml(*a, **k):
t = perf_counter()
try:
return _orig_yaml(*a, **k)
finally:
_C["yaml_t"] += perf_counter() - t
_C["yaml_n"] += 1
# rebind in every module that did `from ... import template_to_test / yaml_load`
for _m in (_proc, _inc):
_m.template_to_test = _wrap_tpl
for _m in (_proc, _inc, _ti):
_m.yaml_load = _wrap_yaml
def _reset_counters():
_C.update(tpl_n=0, tpl_t=0.0, yaml_n=0, yaml_t=0.0)
def load_once(tp, fname, test_dir):
"""One full load (no execution). Returns (initial, loadtest, build) seconds."""
t0 = perf_counter()
init_pf, gv = tp._load_initial_params(test_dir)
t1 = perf_counter()
test_dict, _pf = tp._load_test(init_pf, gv)
t2 = perf_counter()
TestSet(fname, test_dict, Queue())
t3 = perf_counter()
return (t1 - t0, t2 - t1, t3 - t2)
def main():
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("main_tum", help="path to the generated main.tum")
ap.add_argument("--repeat", type=int, default=5)
ap.add_argument("--with-eval", action="store_true",
help="start the external eval process (needed only for <| |> at load)")
ap.add_argument("--quiet", action="store_true",
help="silence the loader's INFO output during runs")
args = ap.parse_args()
fname = os.path.abspath(args.main_tum)
if not os.path.isfile(fname):
ap.error(f"not found: {fname}")
test_dir = os.path.dirname(fname)
env_init()
apply_overrides({}, {})
eval_proc = None
if args.with_eval:
eval_proc = eval_process_init(api_request, 10, test_dir)
eval_proc.start()
eval_proc.wait_ready(10)
if args.quiet:
# the loader prints a couple of INFO lines per config file; mute stdout
# around the measured section to avoid I/O skew.
devnull = open(os.devnull, "w")
real_stdout = sys.stdout
tp = TestProcess(fname, Queue(), TestSetController(),
config_files=[], defines={}, gui_defaults={}, text_mode=True)
samples = [] # list of (initial, loadtest, build)
last_counters = None
try:
for r in range(args.repeat):
_reset_counters()
if args.quiet:
sys.stdout = devnull
try:
samples.append(load_once(tp, fname, test_dir))
except RecursionError:
if args.quiet:
sys.stdout = real_stdout
print(f"file : {fname}")
print("ERROR : RecursionError during load — the include "
"nesting is too deep for the recursive loader.\n"
" (each include level costs ~10 stack frames; "
"raise sys.setrecursionlimit to probe further.)")
return 2
except Exception as e: # noqa: BLE001 - report, don't crash the bench
if args.quiet:
sys.stdout = real_stdout
print(f"file : {fname}")
print(f"ERROR : load failed: {type(e).__name__}: {e}")
return 2
finally:
if args.quiet:
sys.stdout = real_stdout
last_counters = dict(_C)
finally:
if eval_proc is not None:
eval_proc.stop()
eval_proc.join()
if args.quiet:
devnull.close()
initial = [s[0] for s in samples]
loadtest = [s[1] for s in samples]
build = [s[2] for s in samples]
total = [sum(s) for s in samples]
def stat(xs):
return min(xs), statistics.median(xs)
print(f"file : {fname}")
print(f"repeats : {args.repeat} (showing min | median, seconds)")
print(f"{'phase':<10}{'min':>12}{'median':>12}")
for name, xs in (("initial", initial), ("loadtest", loadtest),
("build", build), ("total", total)):
mn, md = stat(xs)
print(f"{name:<10}{mn:>12.4f}{md:>12.4f}")
if last_counters:
print("counters (last run):")
print(f" templates : {last_counters['tpl_n']:>7d} calls "
f"{last_counters['tpl_t']:>8.4f}s (exclusive: jinja compile+render+tempfile)")
print(f" yaml : {last_counters['yaml_n']:>7d} parses "
f"{last_counters['yaml_t']:>8.4f}s (inclusive of nested includes)")
if __name__ == "__main__":
sys.exit(main() or 0)

49
test/benchmark/run.sh Executable file
View File

@@ -0,0 +1,49 @@
#!/bin/bash
# Load-time benchmark driver: generate synthetic .tum trees and time the
# testium load pipeline on them, using the project venv.
#
# Usage:
# ./test/benchmark/run.sh # default matrix (all profiles)
# ./test/benchmark/run.sh <profile> <size> # one profile at one size
# REPEAT=10 ./test/benchmark/run.sh repeat 2000
#
# Profiles: flat includes repeat jinja deep mix (see gen_bench_test.py)
#
# Generated trees go under test/benchmark/cases/ (git-ignored). The numbers
# are wall-clock; run on an otherwise idle machine and compare min values.
set -e
SCRIPT_DIR="$(realpath "$(dirname "$(readlink -f "$0")")")"
PROJECT_DIR="$(realpath "$SCRIPT_DIR/../..")"
VPY="$PROJECT_DIR/test/tmp/.venv/bin/python3"
CASES="$SCRIPT_DIR/cases"
REPEAT="${REPEAT:-5}"
if [ ! -x "$VPY" ]; then
echo "ERROR: project venv not found at $VPY — run ./run.sh once to create it." >&2
exit 1
fi
bench() {
local profile="$1" size="$2"
local out="$CASES/${profile}_${size}"
local main
main="$("$VPY" "$SCRIPT_DIR/gen_bench_test.py" --profile "$profile" --size "$size" --out "$out")"
echo "===== profile=$profile size=$size ====="
"$VPY" "$SCRIPT_DIR/load_bench.py" --repeat "$REPEAT" --quiet "$main"
echo
}
if [ $# -eq 2 ]; then
bench "$1" "$2"
exit 0
fi
# Default matrix. 'deep' is kept small: the recursive loader hits Python's
# recursion limit around ~90 nested include levels.
bench flat 2000
bench includes 1000
bench repeat 1000
bench jinja 2000
bench deep 40
bench mix 300

View File

@@ -1,69 +0,0 @@
# Main
################################################################################
main:
name: Serial Terminal bug reproducer
version: 0.1
steps:
- group:
name: Test preparation
steps:
- console:
name: Open RSL Simulator Terminal
console_name: RSL_simulator
steps:
- open:
protocol: terminal
terminal_path: $(rslsimulatorpath)
- writeln: "pwd"
- read_until: {expected: "$", timeout: 5}
- writeln: "./RSverify $(rsTx)" # /dev/ttyMUE1
- read_until: {expected: "RSL controller>", timeout: 5}
- writeln: "setportconf 0 115200 none 8 1 1 255"
- read_until: {expected: "RSL controller>", timeout: 5}
- writeln: "send4ever 0 0"
- read_until: {expected: "RSL controller>", timeout: 5}
- console:
name: Open the EUT console
console_name: cons_target
doc: Initiates the console of the target in order
to be ready to capture its traces.
stop_on_failure: True
steps:
- open:
protocol: serial
serial_port: $(rsRx) # /dev/ttyMUE2
serial_baudrate: 115200
- loop:
name: Qualification loop
stop_on_failure: False
steps:
- py_func:
name: Capture the RS serial output
file: $(test_directory)/terminal_bug_reproducer.py
func_name: RetreiveData
param:
- cons_target
- sleep: {timeout: 1}
# Cleanup sequence
#-------------------------------------------------------------------------------
- group:
name: Cleanup
execute_on_stop: True
steps:
- console:
name: Close the target console
console_name: cons_target
execute_on_stop: True
steps:
- close:
- console:
name: Close the RSL_simulator
console_name: RSL_simulator
execute_on_stop: True
steps:
- close:

View File

@@ -1,26 +0,0 @@
import api.testium as tm
def RetreiveData(console_name):
print("--------------- retrieving data ---------------")
result = 0
cons = tm.console(console_name)
if cons is None:
print("--------------- The console does not exist ---------------")
else:
try:
is_finished = False
while not is_finished:
status, d = cons.read_until('\n', timeout=0, return_data=True, mute=True)
if 0 == status:
print("--------------- Data ---------------")
print(d)
else:
print("--------------- No data ---------------")
print("Status: ", status)
is_finished = True
except:
print("--------------- Error retrieving data ---------------")
result = -1
return result

View File

@@ -1,9 +0,0 @@
chars='<=>| -,;:!/."()[]{}*\&#%+012345689abcdefghiklmnopqrstuvwxyzABCD'
for j in {1..256} ;
do
for i in {1..256} ; do
echo -n "${chars:RANDOM%${#chars}:1}"
done
echo
sleep 0.01
done

View File

@@ -1,26 +0,0 @@
import api.testium as tm
def RetreiveData(console_name):
print("--------------- retrieving data ---------------")
result = 0
cons = tm.console(console_name)
if cons is None:
print("--------------- The console does not exist ---------------")
else:
try:
is_finished = False
while not is_finished:
status, d = cons.read_until('\n', timeout=0, return_data=True, mute=True)
if 0 == status:
print("--------------- Data ---------------")
print(d)
else:
print("--------------- No data ---------------")
print("Status: ", status)
is_finished = True
except:
print("--------------- Error retrieving data ---------------")
result = -1
return result

View File

@@ -1,50 +0,0 @@
# Main
################################################################################
main:
name: Terminal bug reproducer
version: 0.1
steps:
- group:
name: Test preparation
steps:
- console:
name: Open the EUT console
console_name: cons_target
doc: Initiates the console of the target in order
to be ready to capture its traces.
stop_on_failure: True
steps:
- open:
protocol: terminal
- loop:
name: Qualification loop
stop_on_failure: False
steps:
- console:
name: write random data
console_name: cons_target
steps:
- writeln: bash $(test_directory)/generate_char.sh
- py_func:
name: Capture the terminal output
file: $(test_directory)/terminal_bug_reproducer.py
func_name: RetreiveData
param:
- cons_target
- sleep: {timeout: 1}
# Cleanup sequence
#-------------------------------------------------------------------------------
- group:
name: Cleanup
execute_on_stop: True
steps:
- console:
name: Close the target console
console_name: cons_target
execute_on_stop: True
steps:
- close:

View File

@@ -94,6 +94,17 @@
{% endif %} {% endif %}
- read_until: {expected: endOfCmd, timeout: 1, process_result: "'Hello' in r'''$(result)''' and 'PASS' in r'''$(result)''' "} - read_until: {expected: endOfCmd, timeout: 1, process_result: "'Hello' in r'''$(result)''' and 'PASS' in r'''$(result)''' "}
{% if os == "Linux" %}
- console:
name: Console runs on host (not the Flatpak sandbox)
doc: Regression guard for the 0.2.1 Flatpak bug (term console spawned inside the sandbox instead of on the host). /.flatpak-info exists only inside the sandbox, so the host-only marker is emitted (and matched by read_until) ONLY when the shell really runs on the host. On a broken Flatpak the marker never appears, read_until times out and the item FAILS. The marker is built at runtime ($M) so it is never present in the command line itself. Passes on every other channel.
console_name: term
key: $(test)_PASS
steps:
- writeln: 'test -e /.flatpak-info && M=SANDBOX || M=HOST; echo "console_host_check_$M"'
- read_until: {expected: console_host_check_HOST, timeout: 5}
{% endif %}
- console: - console:
name: Console closure name: Console closure
execute_on_stop: true execute_on_stop: true

View File

@@ -0,0 +1,102 @@
#!/usr/bin/env python3
"""Per-channel check of the testium language server.
Given the channel's testium invocation as argv (e.g. ``flatpak run
--command=testium org.testium.Testium``, a PyInstaller binary path, or
``python -m testium``), verify two things end-to-end against that exact build:
1. ``<cmd> schema`` produces valid JSON whose item registry still includes the
nested action sets (console/plot/json_rpc). This catches a frozen build
that lost the actions — the failure mode the declarative ``ACTIONS``
refactor fixed (no more ``inspect.getsource`` at runtime).
2. ``<cmd> lsp`` starts a real language server: it must answer an LSP
``initialize`` request with a capabilities result and must NOT report the
pygls dependency as missing. This catches a channel that forgot to bundle
the ``[lsp]`` extra.
Exits non-zero (with a diagnostic) on the first failure so the validation run
fails loudly. Used by ``run.sh`` before launching the main suite.
"""
import json
import subprocess
import sys
EXPECTED_ACTION_PARENTS = ("console", "plot", "json_rpc")
def fail(msg):
print(f"LSP CHECK: FAIL — {msg}", file=sys.stderr)
sys.exit(1)
def _extract_json(raw):
"""Parse JSON from ``raw`` bytes, tolerating leading non-JSON noise.
The source-mode launcher (run.sh) may print env-setup lines before the
schema JSON, so we fall back to parsing from the first ``{``.
"""
try:
return json.loads(raw)
except json.JSONDecodeError:
start = raw.find(b"{")
if start < 0:
raise
return json.loads(raw[start:])
def check_schema(cmd):
try:
out = subprocess.run(cmd + ["schema"], capture_output=True, timeout=120)
except Exception as e: # noqa: BLE001
fail(f"`{' '.join(cmd)} schema` could not run: {e}")
if out.returncode != 0:
fail(f"`schema` exited {out.returncode}: {out.stderr.decode()[:300]}")
try:
data = _extract_json(out.stdout)
except json.JSONDecodeError as e:
fail(f"`schema` output is not valid JSON: {e}")
items = data.get("items", {})
for parent in EXPECTED_ACTION_PARENTS:
actions = (items.get(parent) or {}).get("actions") or {}
if not actions:
fail(f"schema item '{parent}' has no actions — a frozen build lost "
f"the declarative ACTIONS (item keys: {sorted(items)[:8]}…)")
print(f"LSP CHECK: schema OK ({len(items)} items; actions present for "
f"{', '.join(EXPECTED_ACTION_PARENTS)})")
def check_lsp(cmd):
body = json.dumps({
"jsonrpc": "2.0", "id": 1, "method": "initialize",
"params": {"processId": None, "rootUri": None, "capabilities": {}},
}).encode()
msg = b"Content-Length: %d\r\n\r\n%s" % (len(body), body)
try:
out = subprocess.run(cmd + ["lsp"], input=msg,
capture_output=True, timeout=30)
stdout, stderr = out.stdout, out.stderr
except subprocess.TimeoutExpired as e:
# A server that stays alive past initialize is fine — it just never saw
# a shutdown. Use whatever it wrote so far as the response.
stdout, stderr = e.stdout or b"", e.stderr or b""
blob = stdout + stderr
if b"dependencies missing" in blob:
fail("`lsp` reports the pygls dependency missing — this channel did "
"not bundle the [lsp] extra.")
if b'"capabilities"' not in stdout:
fail("`lsp` did not return an initialize result. "
f"stdout[:200]={stdout[:200]!r} stderr[:200]={stderr[:200]!r}")
print("LSP CHECK: lsp initialize OK (server answered with capabilities)")
def main():
cmd = sys.argv[1:]
if not cmd:
fail("usage: lsp_check.py <testium-invocation...>")
check_schema(cmd)
check_lsp(cmd)
print("LSP CHECK: PASS")
if __name__ == "__main__":
main()

View File

@@ -97,7 +97,10 @@ case "$MODE" in
echo "Creating wheel venv at $WHEEL_VENV" echo "Creating wheel venv at $WHEEL_VENV"
python3 -m venv --system-site-packages "$WHEEL_VENV" python3 -m venv --system-site-packages "$WHEEL_VENV"
"$WHEEL_VENV/bin/pip" install --quiet --upgrade pip "$WHEEL_VENV/bin/pip" install --quiet --upgrade pip
"$WHEEL_VENV/bin/pip" install --quiet "$WHEEL" # Install with the [lsp] extra so the wheel channel is validated in
# its language-server-capable form (pulls pygls), matching how a
# user enables `testium lsp` from a wheel: pip install testium[lsp].
"$WHEEL_VENV/bin/pip" install --quiet "${WHEEL}[lsp]"
fi fi
CMD=("$WHEEL_VENV/bin/python" -m testium) CMD=("$WHEEL_VENV/bin/python" -m testium)
;; ;;
@@ -137,6 +140,13 @@ esac
echo "-- validation mode: $MODE" echo "-- validation mode: $MODE"
echo "-- launch: ${CMD[*]}" echo "-- launch: ${CMD[*]}"
# ---------- LSP check (this exact channel) ------------------------------------
# Verify `testium lsp` / `testium schema` work in the build under test before
# running the suite: schema must keep its nested actions (declarative ACTIONS,
# survives frozen builds) and the language server must start (pygls bundled).
echo "-- LSP check ($MODE)"
"$VENV_PYTHON" "$SCRIPT_DIR/lsp_check.py" "${CMD[@]}"
exec "${CMD[@]}" -b \ exec "${CMD[@]}" -b \
-d "python_bin=$VENV_PYTHON" \ -d "python_bin=$VENV_PYTHON" \
-d "validation_report_file=validation-$MODE" \ -d "validation_report_file=validation-$MODE" \