2 Commits

Author SHA1 Message Date
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
46 changed files with 167 additions and 1625 deletions

View File

@@ -224,66 +224,27 @@ 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`, 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`: 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`:
- `_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.
- **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: - `_which(name)` probes only host bin dirs in those modes:
- `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. - Flatpak: `/run/host/usr/{local/,}bin`, `/run/host/bin` (host mounted via `--filesystem=host-os`).
- `_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. - AppImage: `/usr/local/bin`, `/usr/bin`, `/bin` (we are directly on the host filesystem).
- `_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). - If the host has no python3/lua, `ensure()` raises `ETUMRuntimeError` at test load with the candidate list — no silent fallback to a bundled interpreter.
- `_python_version()` and `_lua_version()` go through `_run_probe()` which dispatches to `flatpak-spawn` in Flatpak so validation happens against the actual host interpreter. - User overrides (`python_bin`/`lua_bin` in globdict): bare names are resolved through `_which()` (host-only), absolute paths are accepted as-is.
- `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. - `apply_host_libs(env)` is called by `py_process.py` / `lua_process.py` on the env passed to Popen:
- 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. - Flatpak: prepends host lib dirs to `LD_LIBRARY_PATH` so the dynamic linker finds host `.so`'s.
- **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. - 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.
- 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. - `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.
- 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.
@@ -314,12 +275,10 @@ 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 [clean] [--mode MODE] [extra args] # wrapper — uses a dedicated venv (see below) ./test/validation/run.sh # wrapper — uses a dedicated venv (see below)
./run.sh -b -- test/validation/main.tum # direct — testium's own python is used for test execution ./run.sh -b -- test/validation/main.tum # direct — testium's own python is used for test execution
``` ```
The same item set is reused across every packaging channel — `--mode source|wheel|pyinstaller|flatpak|appimage` selects which testium binary launches the suite (`source` is the default, invoking the project's `run.sh`). Each mode stamps its results into a distinct report file (`validation-<mode>.sqlite`, `validation-<mode>-<item>.xml`) so successive runs in different modes don't clobber each other. Prerequisites (PyInstaller binary built, Flatpak bundle installed, …) are checked before launch with a hint pointing at `build_all.sh`. On Windows only `source`, `wheel`, `pyinstaller` are supported. The `run.sh` / `run.bat` wrappers create a dedicated 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 `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,20 +62,6 @@ 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).
* **AppImage** (`Testium-<version>-x86_64.AppImage`) — a single self-contained
executable bundling its own Python. Make it executable and run it:
```sh
chmod +x Testium-*-x86_64.AppImage
./Testium-*-x86_64.AppImage -b mytest.tum
```
As with the binary and Flatpak, `py_func` / `lua_func` items run under the
*host* Python / Lua so your own modules stay visible.
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:
@@ -96,29 +103,6 @@ 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.
## 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: # Build every distribution channel of testium, in order:
# 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,21 +11,6 @@
# 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,
@@ -41,13 +26,9 @@
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
@@ -82,31 +63,6 @@ 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 "================================================================"
@@ -114,100 +70,50 @@ step() {
echo "================================================================" echo "================================================================"
} }
# Kill a process and its whole descendant tree (children first) — used by the skip() { echo " (already built — skipping)"; }
# 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"
PYI_BIN="$DIST_DIR/testium-${VERSION}" step "1/5 Manual PDF (version $VERSION)"
FLATPAK_BUNDLE="$DIST_DIR/testium-${VERSION}.flatpak" if [ ! -f "$MANUAL" ]; then
wheel_in_dist() { ls -1t "$DIST_DIR"/testium-${VERSION}-*.whl 2>/dev/null | head -1; } python -m pip install --quiet --upgrade sphinx linuxdoc
appimage_in_dist() { ls -1t "$DIST_DIR"/Testium-${VERSION}-*.AppImage 2>/dev/null | head -1; } bash "$SCRIPT_DIR/doc/manual/sphinx/build_doc.sh"
cp -f "$SCRIPT_DIR/doc/manual/testium_manual.pdf" "$MANUAL"
else
skip
fi
# ---------- per-step build functions (assume tools are installed) ------------- # 2. Wheel — PEP 427 name kept (already contains version)
step "2/5 Wheel (version $VERSION)"
build_wheel() { WHEEL=$(ls -1t "$DIST_DIR"/testium-${VERSION}-*.whl 2>/dev/null | head -1)
if [ -n "$(wheel_in_dist)" ]; then echo "wheel: already built — skipping"; return 0; fi if [ -z "$WHEEL" ]; then
echo "wheel: building" python -m pip install --quiet --upgrade build
( (
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
) )
local src; src=$(ls -1t "$SCRIPT_DIR/src/dist"/*.whl | head -1) WHEEL_SRC=$(ls -1t "$SCRIPT_DIR/src/dist"/*.whl | head -1)
cp -f "$src" "$DIST_DIR/$(basename "$src")" WHEEL="$DIST_DIR/$(basename "$WHEEL_SRC")"
echo "wheel: done" cp -f "$WHEEL_SRC" "$WHEEL"
} else
skip
fi
build_manual() { # 3. PyInstaller binary
if [ -f "$MANUAL" ]; then echo "manual: already built — skipping"; return 0; fi PYI_BIN="$DIST_DIR/testium-${VERSION}"
echo "manual: building" step "3/5 PyInstaller binary (version $VERSION)"
bash "$SCRIPT_DIR/doc/manual/sphinx/build_doc.sh" if [ ! -f "$PYI_BIN" ]; then
cp -f "$SCRIPT_DIR/doc/manual/testium_manual.pdf" "$MANUAL" python -m pip install --quiet --upgrade pyinstaller
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"
echo "pyinstaller: done" else
} 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"
@@ -224,76 +130,35 @@ if [ ! -f "$FLATPAK_BUNDLE" ]; then
flatpak install --user --noninteractive flathub "$dep" flatpak install --user --noninteractive flathub "$dep"
fi fi
done done
fi (
cd "$SCRIPT_DIR/package/flatpak"
# ---------- serial: wheel (the AppImage installs it) -------------------------- bash build.sh
)
step "1/5 Wheel (version $VERSION)" cp -f "$SCRIPT_DIR/package/flatpak/testium.flatpak" "$FLATPAK_BUNDLE"
build_wheel
# ---------- build the rest --------------------------------------------------
REST=(manual pyinstaller flatpak appimage)
if [ "$SERIAL" -eq 1 ]; then
n=2
for name in "${REST[@]}"; do
step "$n/5 $name (version $VERSION)"
"build_$name"
n=$((n + 1))
done
else else
step "2-5/5 manual + pyinstaller + flatpak + appimage (parallel)" skip
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 ----------------------------------------------------------- # 5. AppImage
step "5/5 AppImage (version $VERSION)"
APPIMAGE=$(ls -1t "$DIST_DIR"/Testium-${VERSION}-*.AppImage 2>/dev/null | head -1)
if [ -z "$APPIMAGE" ]; then
(
cd "$SCRIPT_DIR/package/appimage"
bash build.sh
)
APPIMAGE_SRC=$(ls -1t "$SCRIPT_DIR/package/appimage"/*.AppImage 2>/dev/null | head -1)
APPIMAGE="$DIST_DIR/$(basename "$APPIMAGE_SRC")"
cp -f "$APPIMAGE_SRC" "$APPIMAGE"
chmod +x "$APPIMAGE"
else
skip
fi
step "All packages built" step "All packages built"
printf " manual : %s\n" "$MANUAL" printf " manual : %s\n" "$MANUAL"
printf " wheel : %s\n" "$(wheel_in_dist)" printf " wheel : %s\n" "$WHEEL"
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_in_dist)" printf " appimage : %s\n" "$APPIMAGE"
printf " release_note : %s\n" "$RELEASE_NOTE" printf " release_note : %s\n" "$RELEASE_NOTE"

View File

@@ -77,10 +77,7 @@ 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
# Install the wheel with the [lsp] extra so `testium lsp` (pygls) works 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
# 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,20 +17,11 @@ 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,19 +7,11 @@
set -e set -e
# Build + install local. FLATPAK_BUILDDIR / FLATPAK_STATEDIR / FLATPAK_REPODIR # Build + install local
# (set by build_all --ram) redirect the build dir, the state dir flatpak-builder --user --verbose --force-clean --install --repo=repo build org.testium.Testium.yaml
# (.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 "$REPODIR" testium.flatpak org.testium.Testium flatpak build-bundle repo 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,23 +28,6 @@ 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,15 +2,11 @@
SCRIPT_DIR=$(realpath $( dirname "$0")) SCRIPT_DIR=$(realpath $( dirname "$0"))
rm -rf "${SCRIPT_DIR}/build" "${SCRIPT_DIR}/dist" rm -r "${SCRIPT_DIR}/build" "${SCRIPT_DIR}/dist"
pwd=$(pwd) pwd=$(pwd)
cd ${SCRIPT_DIR} cd ${SCRIPT_DIR}
# PYI_WORKPATH (set by build_all --ram) puts the big intermediate build tree on pyinstaller testium.spec
# 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,21 +1,5 @@
# -*- 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
@@ -70,7 +54,7 @@ a = Analysis(
"colorama", "colorama",
"matplotlib", "matplotlib",
"junit_xml", "junit_xml",
"lxml"] + _LSP_HIDDEN, "lxml"],
hookspath=[], hookspath=[],
hooksconfig={}, hooksconfig={},
runtime_hooks=[], runtime_hooks=[],
@@ -89,9 +73,7 @@ exe = EXE(
debug=False, debug=False,
bootloader_ignore_signals=False, bootloader_ignore_signals=False,
strip=False, strip=False,
# UPX is CPU+IO heavy for a marginal size gain — build_all --ram sets upx=True,
# 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,22 +1,3 @@
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,11 +33,6 @@ 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.2 0.1.3

View File

@@ -30,12 +30,6 @@ 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,30 +11,6 @@ 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

@@ -5,13 +5,6 @@ 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=""
): ):
@@ -19,7 +12,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 = dict(type(self).ACTIONS) self.action_classes = {}
self.actions_token = None self.actions_token = None
self.actions = [] self.actions = []
try: try:
@@ -31,9 +24,6 @@ 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,9 +5,6 @@ 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
@@ -16,32 +13,6 @@ 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
@@ -126,11 +97,6 @@ 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 = ""
): ):
@@ -168,13 +134,6 @@ 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)
@@ -231,36 +190,6 @@ 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,22 +5,11 @@ 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,25 +2,11 @@ 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,7 +10,6 @@ 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
@@ -22,38 +21,6 @@ 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=""
): ):
@@ -316,17 +283,6 @@ 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=""
): ):
@@ -380,27 +336,18 @@ 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,36 +8,9 @@ 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,7 +1,6 @@
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
@@ -9,13 +8,6 @@ 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,17 +1,10 @@
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,7 +4,6 @@ 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
@@ -13,17 +12,6 @@ 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,7 +11,6 @@ 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,
@@ -77,20 +76,6 @@ 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=""
): ):
@@ -144,13 +129,6 @@ 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=""
): ):
@@ -194,29 +172,6 @@ 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=""
): ):
@@ -224,6 +179,13 @@ 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,20 +8,12 @@ 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,7 +11,6 @@ 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"
@@ -22,21 +21,6 @@ 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,7 +5,6 @@ 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
@@ -13,15 +12,6 @@ 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,23 +2,11 @@ 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,7 +6,6 @@ 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
@@ -16,12 +15,6 @@ 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
@@ -94,15 +87,6 @@ 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,7 +11,6 @@ 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"
@@ -22,21 +21,6 @@ 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,7 +2,6 @@ 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
@@ -10,14 +9,6 @@ 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,17 +3,9 @@ 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,7 +10,6 @@ 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
@@ -58,25 +57,6 @@ 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,7 +11,6 @@ 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):
@@ -22,12 +21,6 @@ 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=""
): ):
@@ -64,15 +57,6 @@ 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=""
): ):
@@ -112,20 +96,6 @@ 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=""
): ):
@@ -199,13 +169,6 @@ 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
@@ -256,25 +219,18 @@ 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,7 +7,6 @@ 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):
@@ -15,15 +14,6 @@ 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,23 +2,11 @@ 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,7 +11,6 @@ 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):
@@ -96,15 +95,6 @@ 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,7 +2,6 @@ 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
@@ -11,19 +10,6 @@ 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

@@ -1,175 +0,0 @@
"""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,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,16 +0,0 @@
"""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

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

View File

@@ -1,122 +0,0 @@
"""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)

View File

@@ -1,313 +0,0 @@
"""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

@@ -1,102 +0,0 @@
#!/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

@@ -137,13 +137,6 @@ 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" \