2 Commits

Author SHA1 Message Date
24366ee7f8 Allow floating number for console timeout 2026-05-22 22:55:10 +02:00
3c9d9816fb Unified let syntax to be a list of objects 2026-05-22 22:54:14 +02:00
115 changed files with 924 additions and 5356 deletions

136
DESIGN.md
View File

@@ -97,15 +97,6 @@ All dialog items (`dialog_image`, `dialog_question`, `dialog_references`, `dialo
- For the live stream (terminal in batch / GUI panel), prefixes every line emitted from a branch's thread with `[<branch_name>] ` so concurrent branches stay readable.
- Exposes `write` / `writeln` / `flush` (Python 3.14's `unittest` calls `stream.writeln()` directly without `_WritelnDecorator`).
### Subprocess RPC startup handshake (py_func / lua_func / eval_proc)
The parent ↔ subprocess JSON-RPC link runs over a localhost TCP socket. The **subprocess** owns the port: it binds `port 0` (OS-assigned), `listen()`s, then prints `__TESTIUM_RPC_PORT__=<port>` on stdout (constant `RPC_PORT_SENTINEL` in `runtime/jrpc.py`). The parent reads that line (`proc_drain.drain_and_read_port` + `wait_for_port`, deadline `gd("proc_start_timeout", 30)`) and only *then* connects — the server is guaranteed to be listening, so the connect succeeds on the first attempt.
This replaced the previous fragile scheme (parent reserved a port via `bind(0)`+close, child re-bound the same port, parent connected on a timing guess) which broke intermittently on Windows: cold-start/antivirus variance pushed the worker past the connect deadline, and `connect()` to a not-yet-listening localhost port *times out* (≈1 s) instead of refusing, exhausting the retry budget. Notes:
- The server no longer sets `SO_REUSEADDR` (a fresh ephemeral port needs no TIME_WAIT override; on Windows it would enable port hijacking).
- `JsonRpcBase.wait_ready()` always settles (event set on success **and** failure) and returns the actual connection outcome — a connect failure no longer hangs a `wait_ready()` caller.
- Non-sentinel subprocess stdout/stderr is still forwarded to the parent log (early-startup errors stay visible).
### Subprocess API contract (py_func / lua_func)
User test scripts running inside a `py_func` or `lua_func` subprocess **must** use the JSON-RPC bridge to interact with testium state:
@@ -123,20 +114,11 @@ To add a new API call usable from subprocesses:
### External interpreter resolution (`bins.py`)
`src/testium/interpreter/utils/bins.py` — single source of truth for the paths to the external Python and Lua interpreters used by subprocesses.
- `python_bin()` / `lua_bin()` : resolve and cache. The cache is keyed by `(name, override)` so that a later change to `gd[python_bin]` (typically when a `param.yaml` sets the key) triggers a re-resolution on the next lookup instead of returning the stale auto-discovered path. Falls back to discovery on PATH (candidates: `python3`/`python` and `lua`/`lua5.5`/`lua5.4`/`lua5.3`/`lua5.2`/`lua5.1`).
- `ensure(*names)` : called by `TestSet._validate_runtime_deps()` at test load. Always requires `python` (the eval engine always runs); requires `lua` only if a `lua_func` item is in the tree. Fails fast with a clear error citing tried candidates and override key. Also **publishes** each resolved path into gd (`python_bin` / `lua_bin`) when the key is unset, so test scripts can reference `$(python_bin)` / `$(lua_bin)` regardless of launch mode (e.g. GUI, where no `-d` override is passed). A user-provided value is never overwritten.
- `python_bin()` / `lua_bin()` : resolve once, cache in memory. User can override via the `python_bin` / `lua_bin` global dict keys (typically populated from the YAML config). Falls back to discovery on PATH (candidates: `python3`/`python` and `lua`/`lua5.5`/`lua5.4`/`lua5.3`/`lua5.2`/`lua5.1`).
- `ensure(*names)` : called by `TestSet._validate_runtime_deps()` at test load. Always requires `python` (the eval engine always runs); requires `lua` only if a `lua_func` item is in the tree. Fails fast with a clear error citing tried candidates and override key.
Engines (`PyProcessBase`, `LuaProcessBase`, `EvalExecEngine`) call `bins.python_bin()`/`bins.lua_bin()` themselves — call sites never pass an explicit binary path.
#### Override-timing contract (`apply_overrides`)
`bins.python_bin()` is called for the **first** time inside `eval_process_init()` (the long-lived inline-`<| … |>` subprocess), which happens **before** the YAML param files are loaded. To make `-d python_bin=…` and the GUI `python_bin` preference take effect for `eval_proc` itself, `process.py:run()` applies them to gd **before** `eval_process_init()` via the `apply_overrides()` helper extracted from `update_global()`. The post-load `update_global()` call then re-applies the same overrides (after `prepare_global()` clears gd), keeping the gd value in sync with the cached resolution.
| Override source | `eval_proc` | `py_func` / `cycle` / `post_exec` |
|---|---|---|
| `-d python_bin=…` (CLI) | ✅ | ✅ |
| GUI `python_bin` preference | ✅ | ✅ |
| `python_bin: …` in `param.yaml` | ❌ (eval_proc already started) | ✅ (cache re-resolves on key change) |
## Key files
| Path | Role |
@@ -194,13 +176,6 @@ pyside6-rcc testium_core_win.qrc -o testium_core_win_rc.py
Icons are assigned once when the test file is loaded (not updated live on theme change — a file reload is required).
## Test-tree search (GUI)
A find bar (Ctrl+F) over the `QTestTree` (`src/testium/main_win/test_tree.py`) highlights matching items and navigates them (Enter / ◂ ▸), with **Name / Type / Doc** checkboxes choosing which fields are searched. Ctrl+F toggles the bar (clearing the highlight); Esc / ✕ close it. The bar (`MainWindow._build_search_bar`, `testium_win.py`) is persistent and reset on each file load (`_reset_search`, called from `test_file_manager`).
- `QTestTree.search(text, fields)` / `clear_search()` run a **single pass wrapped in `blockSignals(True)`**: `setBackground` emits `itemChanged`, wired to `on_testChecked` (a per-item controller round-trip) — without blocking, searching storms the controller (100 % CPU / freeze) and corrupts the check-state. It expands the ancestors of each match and returns matches in **visual (pre-order)** order for navigation.
- `QTestTreeItem._refresh_highlight()` is the single source of truth for the name-column colours: the **search** highlight (pastel amber bg + forced black text, readable in light *and* dark themes) and the green **run** highlight (`setHighlighted`) are recomputed from state flags with precedence **run > search > default**. No brush is saved/restored, so the two layers never leave a stale/permanent colour when they overlap (e.g. searching while a test runs).
### `run` item
`src/testium/interpreter/test_items/test_item_run.py` — launches a `.tum` file in a new testium instance (`-b` in batch mode, `-r` in GUI mode). Result:
- **PASS** if the sub-instance launched and ran to completion (exit code is ignored)
@@ -210,20 +185,6 @@ The sub-test's own pass/fail result is intentionally not propagated.
The interpreter and entry point used to spawn the sub-instance are picked automatically by `_testium_launch_cmd()` based on how the parent was started (AppImage → `$APPIMAGE`; Flatpak → `flatpak run`; PyInstaller → the frozen binary; source/wheel → `[sys.executable, abspath(sys.argv[0])]`). The user cannot override either via the YAML — selecting a different testium binary or Python from a sub-test was removed because it was either ill-defined (bundle modes have no separable Python) or could mismatch the parent's environment in surprising ways.
### `pytest` item
`src/testium/interpreter/test_items/test_item_pytest.py` — the pytest analogue of the `unittest` item: runs a user pytest file and surfaces every collected test as a child item (one PASS/FAIL/SKIP per test, with duration + failure message in the report).
Unlike `unittest` (which runs in-process), pytest runs in a **subprocess on the host interpreter** (`bins.python_bin()`), like `py_func`/`lua_func` — so the user's pytest install and test dependencies live on the host and the item works across every packaging channel (incl. Flatpak via the same staging used by `py_func`).
- A stdlib-only pytest plugin (`_PLUGIN_SOURCE`, written to a temp dir and loaded with `-p`) streams sentinel-prefixed lines back over the subprocess stdout: `__TESTIUM_PYTEST_COLLECTED__` (node-id list, at collection), `__TESTIUM_PYTEST_START__` / `__TESTIUM_PYTEST_RESULT__` (per test). The parent parses them live; non-sentinel lines are forwarded to the log.
- `load()` runs `pytest --collect-only` once to build the child tree; `execute()` runs the enabled node-ids once and maps results back by node-id.
- pytest is invoked with `--capture=no` (so plugin sentinels + test prints reach our pipe), `-o addopts=` (neutralise user addopts — xdist/cov would break the per-test hook parsing), `-p no:cacheprovider`. `stop_on_failure``-x`; disabled children → NORUN without running.
- Params: `test_file` (required), `test_method` (optional list of function names, matched against the node-id function segment with the parametrisation suffix stripped). Registered as `cst.TYPE_PYTEST` / `TYPE_PYTEST_STEP`, loaded via the same self-loading branch as `unittest` in `test_set.load_test_recursively`.
- `load()` raises on a collection problem (pytest not installed → a dedicated "pip install pytest" message; bad file / unknown `test_method`). That raise is handled by the **Graceful item load** path below — a warning at load and a clean FAIL at run, never a crash.
### Graceful item load
A self-loading item whose `load()` fails (a `unittest` test file importing a missing module, `pytest` not installed on the host, …) must not abort the **whole** test load. `TestSet._load_item()` wraps the `load()` call: on any exception it emits `tm.print_warn(...)` and stores the reason in `item._load_error` instead of propagating. The `@test_run` wrapper (`test_item.py`) turns a non-None `_load_error` into a clean run-time `FAILURE` (the message is printed once by `write_footer`), so the rest of the campaign still loads and runs. Scoped to the self-loading, module-loading items (`unittest`, `pytest`); structural action loading (`console`/`plot`/`json_rpc`) stays fail-fast at load.
### Report exporters & plugins
`src/testium/interpreter/test_report/test_report.py``_EXPORTER_REGISTRY` dict maps a format name (cmd key in the YAML `report.export`) to a lazy loader. Built-ins: `text`, `json`, `junit` (needs `junit_xml`), `html` (needs `lxml`). `sqlite` is the storage layer, no-op as an export.
@@ -247,93 +208,34 @@ Four distribution channels coexist, all sharing the single `src/testium/` packag
| Channel | Where | Build | Notes |
|---------|-------|-------|-------|
| Wheel (`pip install`) | `src/pyproject.toml` | `python -m build` | Vanilla Python package; entry point `testium = "testium:main"`. |
| PyInstaller binary | `package/pyinstaller/` | `build.sh` | Single ~130 MB binary. `py_func`, `runtime`, `lua_func` bundled at `_MEIPASS` root so the **host** Python can find them when launched as `python3 py_func`. `api`/`interpreter` are **not** exposed (subprocess isolation). Built windowed (`console=False`) with `package/testium.ico` as the exe icon — see "Windows frozen build". |
| Windows installer | `package/innosetup/` | `build.ps1` (Inno Setup 6) | Wraps the PyInstaller exe. Per-user, **no admin** (`PrivilegesRequired=lowest`, installs under `%LOCALAPPDATA%`). Version-scoped `AppId` + install dir so versions coexist side-by-side; one Start Menu entry per version. |
| PyInstaller binary | `package/pyinstaller/` | `build.sh` | Single ~130 MB binary. `py_func`, `runtime`, `lua_func` bundled at `_MEIPASS` root so the **host** Python can find them when launched as `python3 py_func`. `api`/`interpreter` are **not** exposed (subprocess isolation). |
| Flatpak | `package/flatpak/` | `build.sh` (uses `flatpak-builder`) | KDE 6.10 runtime. The bundled Python runs only the main process; `py_func` / `lua_func` MUST run under the **host** interpreter (no Python/Lua bundled). Produces a distributable `.flatpak` bundle. |
| AppImage | `package/appimage/` | `build.sh` (Debian Bookworm container via Podman/Docker) | Bundles Python 3.11 for the main process; `py_func` / `lua_func` MUST run under the **host** interpreter. Build runs in a container so it works on Arch / any non-Debian host. |
The `.deb` work-in-progress lives in `package/deb/`:
- `test_distro.sh debian:bookworm | debian:trixie | ubuntu:24.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)
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.
- **Flatpak**: the sandbox glibc/ABI is incompatible with arbitrary host shared libraries, so we **cannot** run host binaries inside the Flatpak runtime — `LD_LIBRARY_PATH` injection trips a `_dl_call_libc_early_init` assertion. The supported way out is `flatpak-spawn --host`, a stub on `$PATH` inside every Flatpak that proxies an `exec` over D-Bus to the host's `org.freedesktop.Flatpak` service. The manifest grants `--talk-name=org.freedesktop.Flatpak` so the call is allowed. Helpers:
- `flatpak_host_spawn(interp, args, host_cwd, extra_env=…)` builds the spawn command vector with a curated set of forwarded env vars (`HOME`, `USER`, `DISPLAY`, `DBUS_SESSION_BUS_ADDRESS`, …) plus any explicit overrides.
- `_get_host_testium_path()` returns a path to the testium package the host can read. In Flatpak the package lives under `/app/lib/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.
- `_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).
- `_python_version()` and `_lua_version()` go through `_run_probe()` which dispatches to `flatpak-spawn` in Flatpak so validation happens against the actual host interpreter.
- `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.
- The `run` item's `_testium_launch_cmd()` prefixes `flatpak run org.testium.Testium` with `flatpak-spawn --host` so the sub-instance is launched by the host's `flatpak` CLI, not by an unworkable in-sandbox `flatpak` binary.
- **AppImage**: 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.
- User overrides (`python_bin`/`lua_bin` in globdict): in Flatpak, both bare names and absolute paths go through `_which()` so they are validated on the host side (the sandbox can't see e.g. `/scratch/...`). Outside Flatpak, absolute paths are accepted as-is and bare names go through PATH discovery.
- If the host has no python3/lua, `ensure()` raises `ETUMRuntimeError` at test load with the candidate list — no silent fallback to a bundled interpreter.
- `_which(name)` probes only host bin dirs in those modes:
- Flatpak: `/run/host/usr/{local/,}bin`, `/run/host/bin` (host mounted via `--filesystem=host-os`).
- AppImage: `/usr/local/bin`, `/usr/bin`, `/bin` (we are directly on the host filesystem).
- If the host has no python3/lua, `ensure()` raises `ETUMRuntimeError` at test load with the candidate list — no silent fallback to a bundled interpreter.
- User overrides (`python_bin`/`lua_bin` in globdict): bare names are resolved through `_which()` (host-only), absolute paths are accepted as-is.
- `apply_host_libs(env)` is called by `py_process.py` / `lua_process.py` on the env passed to Popen:
- Flatpak: prepends host lib dirs to `LD_LIBRARY_PATH` so the dynamic linker finds host `.so`'s.
- 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.
- `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.
- `py_process.py` additionally pops `PYTHONUSERBASE` (set to `/var/data/python` by the Flatpak runtime, which would hide `~/.local/lib/...`).
### Windows frozen build: no console, hidden subprocess windows
The PyInstaller exe is built **windowed** (`console=False` in `testium.spec`) so a
double-click doesn't open a console. The catch: a windowed process has **no console
to inherit**, so every console subprocess it spawns (the `py_func`/`lua_func` host
Python bridges — the otherwise-permanent window — plus the `where`/`which`/`--version`
probes) opens its **own** console window. `paths.no_window_kwargs()` returns
`{"creationflags": CREATE_NO_WINDOW}` on a frozen Windows build (and `{}` everywhere
else, so the **wheel/source** keeps its console and child output stays visible). It is
applied at every spawn site: `py_process.py`, `lua_process.py`, `bins._run_probe`,
`paths.sys_app_path_win`. `termconsole.py` is intentionally exempt (it already hides
`cmd.exe` via `STARTUPINFO`).
### 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`)
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
- Test-tree search (GUI): a Ctrl+F find bar highlights + navigates matching items, with Name/Type/Doc field checkboxes. Search modifications run under `blockSignals` (else `setBackground``itemChanged``on_testChecked` storms the controller), and the search/run highlights share one flag-driven `_refresh_highlight()` (run > search > default) so overlapping layers never leave a stale colour. See "## Test-tree search (GUI)".
- `pytest` item: pytest analogue of `unittest`, but runs on the **host interpreter in a subprocess** (`bins.python_bin()`, like `py_func`) so it works across every packaging channel. A stdlib-only pytest plugin streams collected node-ids + per-test results back over stdout via sentinels; each test becomes a child item with its own PASS/FAIL/SKIP, duration and failure message. Params: `test_file`, `test_method`. Validation item: `test/validation/items/pytest/` (the validation venv now pip-installs `pytest`). See "### `pytest` item".
- Graceful item load: a self-loading item that fails to load its module/file (e.g. a `unittest` test file importing a missing module, or `pytest` not installed on the host) no longer aborts the **whole** test load. `TestSet._load_item()` wraps the item's `load()`, emits a `tm.print_warn(...)` at load time and records the reason in `item._load_error`; the `@test_run` wrapper turns a non-None `_load_error` into a clean run-time `FAILURE` (message printed once via `write_footer`). The rest of the campaign loads and runs normally. Applies to module-loading items (`unittest`, `pytest`); structural action loading stays fail-fast.
- `console` item — serial robustness + richer `read_until`: (1) a failed serial `open()` now raises a clear `ETUMRuntimeError` ("Serial device '…' does not exist." / permission hint) instead of dumping a pyserial traceback, and a console whose open failed is safely "not open" (init `_thd=None` + `isOpened` guards in `readchar`/`read_nowait`/`close`) so later reads no longer crash with `AttributeError: '_thd'`; the action handlers show a one-liner for expected (`ETUMRuntimeError`) errors and keep the full traceback for unexpected ones. (2) `read_until`'s `expected` now accepts a **list of values** (match any) and a new `regex: true` flag treats each pattern as a Python regex (`re.search` over a bounded tail — `Console.REGEX_WINDOW`; limitation: cost/memory bounded, so a match only after a very long stream or beyond the window won't fire). Flatpak manifest now grants `--device=all` so serial adapters (`/dev/ttyUSB*`, `/dev/ttyACM*`) are visible in the sandbox. Validation: new `read_until` list/regex cases in `test/validation/items/console/test.tum`.
- Parameters are expanded at **run time**, never at load: control-flow flags (`stop_on_failure`/`execute_on_stop`) resolve via properties at run, `cycle` iterator / `git` repo / `tested_references` references and `console` `telnet_port` are no longer (incorrectly) expanded or left unexpanded at load. Justified load-time exceptions: `name`, `doc`, `skipped`, and `unittest`/`pytest` `test_method`.
- Subprocess RPC startup handshake: the `py_func`/`lua_func`/`eval_proc` worker now picks its own port (`bind 0`), announces it on stdout (`__TESTIUM_RPC_PORT__=`), and the parent connects only after reading it. Fixes intermittent Windows `failed to connect : timeout` and the matching `wait_ready()` hang; removes the reserve/close/rebind race and `SO_REUSEADDR`. See "Subprocess RPC startup handshake".
- `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`.
- `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.
@@ -359,20 +261,12 @@ Both Flatpak and AppImage export `TESTIUM_VERSION` from a launcher (Flatpak: lau
- `unittest` item: renamed from `unittest_file`.
- GUI test tree: check and fold state preserved across same-file reloads.
- Licence: EUPL-1.2.
- Interpreter override timing: `apply_overrides()` extracted from `update_global()` and called by `process.py:run()` before `eval_process_init()`, so `-d python_bin=…` / GUI prefs reach `bins.python_bin()` on its first lookup. `bins._resolve()` cache is now keyed by `(name, override)` so later `param.yaml` changes are picked up by subsequently constructed engines.
## Validation tests
Located in `test/validation/`. Two entry points:
Located in `test/validation/`. Run with `-b` flag:
```
./test/validation/run.sh [clean] [--mode MODE] [extra args] # wrapper — uses a dedicated venv (see below)
./run.sh -b -- test/validation/main.tum # direct — testium's own python is used for test execution
./run.sh -b -- test/validation/main.tum
```
The same item set is reused across every packaging channel — `--mode source|wheel|pyinstaller|flatpak|appimage` selects which testium binary launches the suite (`source` is the default, invoking the project's `run.sh`). Each mode stamps its results into a distinct report file (`validation-<mode>.sqlite`, `validation-<mode>-<item>.xml`) so successive runs in different modes don't clobber each other. Prerequisites (PyInstaller binary built, Flatpak bundle installed, …) are checked before launch with a hint pointing at `build_all.sh`. On Windows only `source`, `wheel`, `pyinstaller` are supported.
The `run.sh` / `run.bat` wrappers create a dedicated **host** Python venv at `${TMPDIR:-/tmp}/testium-validation-venv` (Linux) or `%TEMP%\testium-validation-venv` (Windows), with `--system-site-packages` + `pip install junit-xml`, and run the suite with `-d python_bin=…` so every test-execution subprocess (eval_proc, py_func, cycle, post_exec) runs inside that venv. testium itself keeps running in its own environment for the chosen mode. The venv is shared across modes because every test-execution subprocess ends up on the host either directly (source/wheel/pyinstaller/appimage) or via `flatpak-spawn --host` (flatpak). `clean` as the first argument recreates the venv. `wheel` mode also creates a separate `testium-wheel-venv-<v>` to hold the installed package.
The `venv` item (`test/validation/items/venv/`) asserts that the override actually took effect: `python_bin` is set, `sys.executable` matches it, `sys.prefix == dirname(dirname(python_bin))`, and `sys.prefix != sys.base_prefix` (the last marker catches the case where `python_bin` happens to be a system interpreter, which path-equality alone would miss because the venv's `bin/python3` is a symlink to the host). Both `eval_proc` (inline `<| … |>`) and `py_func` paths are exercised.
Parallel item tests: `test/validation/items/parallel/test.tum`
## Dependencies

View File

@@ -27,27 +27,6 @@ Pre-built artifacts are published at
runnable directly, no Python installation required on the host. Lua
support still needs a system `lua` interpreter and the `lua-socket` /
`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:
```sh
@@ -62,9 +41,6 @@ Pre-built artifacts are published at
`testium` command is available in the terminal (requires `~/.local/bin` in
`PATH`, which most modern distributions provide by default).
Every channel ships the language server, so `testium lsp` (see
[Editor support](#editor-support)) works out of the box from any of them.
## Quick start
From a checkout of the repository:
@@ -106,45 +82,6 @@ python3 src/testium # GUI
python3 src/testium -b mytest.tum # batch
```
## Editor support
testium ships a Language Server Protocol (LSP) server that gives `.tum` files
completion of item types, hover documentation, and an outline view in any
LSP-capable editor:
```sh
testium lsp # speaks LSP over stdio; an editor's LSP client drives it
testium schema # dumps the item/parameter schema as JSON (what the LSP serves)
```
The server is bundled in every pre-built release (wheel, binary, Flatpak,
AppImage). For a source / wheel install, pull the language-server extra:
```sh
pip install 'testium[lsp]' # from PyPI / a wheel
pip install -e /path/to/testium/src[lsp] # from a source checkout
```
A VSCode / VSCodium client extension (`testium_assist`) wraps `testium lsp`;
the schema is built from testium itself, so new item types and parameters
appear in the editor on the next testium upgrade with no client change.
It is published on [Open VSX](https://open-vsx.org/extension/testium/testium-assist),
so in **VSCodium, Cursor, Windsurf, Theia and code-server** it installs from the
Extensions view (search `testium-assist`) or with
`codium --install-extension testium.testium-assist`.
**Microsoft VSCode** does not list Open VSX extensions, so install the `.vsix`
by hand — download it from the Open VSX page above, then *Extensions → ⋯ →
Install from VSIX…* or:
```sh
code --install-extension testium-assist-0.1.0.vsix
```
The extension runs `testium lsp`, so `testium` must be on the `PATH` (otherwise
point the `testium.serverPath` setting at the binary/AppImage).
## Troubleshooting
### `wl_proxy_marshal_flags` symbol error

View File

@@ -1,5 +1,5 @@
#!/bin/bash
# Build every distribution channel of testium:
# Build every distribution channel of testium, in order:
# 1. Manual PDF -> dist/testium-manual-<v>.pdf
# 2. Wheel -> dist/testium-<v>-py3-none-any.whl (PEP 427 name)
# 3. PyInstaller binary -> dist/testium-<v>
@@ -8,24 +8,6 @@
# release_note.txt is copied to dist/ up front (with a warning if it has no
# entry for the current version).
#
# By default, a step is skipped if its artifact already exists in dist/.
# Pass --clean to remove existing dist/ artifacts and rebuild everything.
#
# 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
# src/dist/, package/*/dist/, doc/manual/ are left in place. Wheel and AppImage
# keep their original names (which already contain the version); manual,
@@ -33,39 +15,17 @@
#
# Re-uses scripts/build_env.sh and scripts/set_env.sh — the same pair invoked
# by run.sh — so the venv at test/tmp/.venv stays the single source of Python
# dependencies. `build`, `pyinstaller`, `sphinx` and `linuxdoc` are installed
# into that venv on demand if not already there. Flatpak and AppImage build in
# their own container/sandbox; their build.sh scripts have their own toolchain
# checks.
# dependencies. `build` and `pyinstaller` are installed into that venv on
# demand if not already there. Flatpak and AppImage build in their own
# container/sandbox; their build.sh scripts have their own toolchain checks.
set -e
CLEAN=0
SERIAL=0
RAM=0
for arg in "$@"; do
case "$arg" in
--clean|-c) CLEAN=1 ;;
--serial) SERIAL=1 ;;
--ram) RAM=1 ;;
*) echo "Unknown option: $arg" >&2; exit 1 ;;
esac
done
SCRIPT_DIR=$(realpath "$(dirname "$0")")
VERSION=$(cat "$SCRIPT_DIR/src/VERSION")
DIST_DIR="$SCRIPT_DIR/dist"
mkdir -p "$DIST_DIR"
if [ "$CLEAN" -eq 1 ]; then
echo "-- clean: removing existing dist artifacts for version $VERSION"
rm -f "$DIST_DIR/testium-manual-${VERSION}.pdf"
rm -f "$DIST_DIR"/testium-${VERSION}-*.whl
rm -f "$DIST_DIR/testium-${VERSION}"
rm -f "$DIST_DIR/testium-${VERSION}.flatpak"
rm -f "$DIST_DIR"/Testium-${VERSION}-*.AppImage
fi
# Release note: copy it to dist/ and warn (but don't fail) if it has no entry
# for the current version.
RELEASE_NOTE_SRC="$SCRIPT_DIR/release_note.txt"
@@ -82,30 +42,8 @@ export REQ_PATH="$SCRIPT_DIR/src/requirements.txt"
bash "$SCRIPT_DIR/scripts/build_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
# Ensure wheel/PyInstaller toolchains are present in the venv.
python -m pip install --quiet --upgrade build pyinstaller
step() {
echo
@@ -114,186 +52,56 @@ step() {
echo "================================================================"
}
# Kill a process and its whole descendant tree (children first) — used by the
# interrupt handler so SIGINT also stops grandchildren the parallel jobs spawned
# (podman container, flatpak-builder, pyinstaller …), not just the subshells.
_kill_tree() {
local pid=$1 c
for c in $(pgrep -P "$pid" 2>/dev/null); do
_kill_tree "$c"
done
kill -TERM "$pid" 2>/dev/null || true
}
# Set as INT/TERM handler around the parallel wait. Stops every running build
# tree, then exits — the EXIT trap (set under --ram) frees the tmpfs scratch.
_interrupt() {
echo >&2
echo "-- interrupted: stopping running builds…" >&2
local pid
for pid in "${!PID2NAME[@]}"; do
_kill_tree "$pid"
done
exit 130
}
# ---------- artifact paths ----------------------------------------------------
# 1. Manual PDF
step "1/5 Manual PDF (version $VERSION)"
bash "$SCRIPT_DIR/doc/manual/sphinx/build_doc.sh"
MANUAL_SRC="$SCRIPT_DIR/doc/manual/testium_manual.pdf"
MANUAL="$DIST_DIR/testium-manual-${VERSION}.pdf"
PYI_BIN="$DIST_DIR/testium-${VERSION}"
FLATPAK_BUNDLE="$DIST_DIR/testium-${VERSION}.flatpak"
wheel_in_dist() { ls -1t "$DIST_DIR"/testium-${VERSION}-*.whl 2>/dev/null | head -1; }
appimage_in_dist() { ls -1t "$DIST_DIR"/Testium-${VERSION}-*.AppImage 2>/dev/null | head -1; }
cp -f "$MANUAL_SRC" "$MANUAL"
# ---------- per-step build functions (assume tools are installed) -------------
build_wheel() {
if [ -n "$(wheel_in_dist)" ]; then echo "wheel: already built — skipping"; return 0; fi
echo "wheel: building"
(
# 2. Wheel — PEP 427 name kept (already contains version)
step "2/5 Wheel (version $VERSION)"
(
cd "$SCRIPT_DIR/src"
rm -rf dist build *.egg-info
python -m build --wheel
)
local src; src=$(ls -1t "$SCRIPT_DIR/src/dist"/*.whl | head -1)
cp -f "$src" "$DIST_DIR/$(basename "$src")"
echo "wheel: done"
}
)
WHEEL_SRC=$(ls -1t "$SCRIPT_DIR/src/dist"/*.whl | head -1)
WHEEL="$DIST_DIR/$(basename "$WHEEL_SRC")"
cp -f "$WHEEL_SRC" "$WHEEL"
build_manual() {
if [ -f "$MANUAL" ]; then echo "manual: already built — skipping"; return 0; fi
echo "manual: building"
bash "$SCRIPT_DIR/doc/manual/sphinx/build_doc.sh"
cp -f "$SCRIPT_DIR/doc/manual/testium_manual.pdf" "$MANUAL"
echo "manual: done"
}
# 3. PyInstaller binary
step "3/5 PyInstaller binary (version $VERSION)"
bash "$SCRIPT_DIR/package/pyinstaller/build.sh"
PYI_SRC="$SCRIPT_DIR/package/pyinstaller/dist/testium"
PYI_BIN="$DIST_DIR/testium-${VERSION}"
cp -f "$PYI_SRC" "$PYI_BIN"
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"
cp -f "$SCRIPT_DIR/package/pyinstaller/dist/testium" "$PYI_BIN"
echo "pyinstaller: done"
}
build_flatpak() {
if [ -f "$FLATPAK_BUNDLE" ]; then echo "flatpak: already built — skipping"; return 0; fi
echo "flatpak: building"
(
# 4. Flatpak bundle
step "4/5 Flatpak bundle (version $VERSION)"
(
cd "$SCRIPT_DIR/package/flatpak"
bash build.sh
)
cp -f "$SCRIPT_DIR/package/flatpak/testium.flatpak" "$FLATPAK_BUNDLE"
echo "flatpak: done"
}
)
FLATPAK_SRC="$SCRIPT_DIR/package/flatpak/testium.flatpak"
FLATPAK_BUNDLE="$DIST_DIR/testium-${VERSION}.flatpak"
cp -f "$FLATPAK_SRC" "$FLATPAK_BUNDLE"
build_appimage() {
if [ -n "$(appimage_in_dist)" ]; then echo "appimage: already built — skipping"; return 0; fi
echo "appimage: building"
(
# 5. AppImage
step "5/5 AppImage (version $VERSION)"
(
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
if [ ! -f "$FLATPAK_BUNDLE" ]; then
FLATPAK_DEPS=(
"org.kde.Platform//6.10"
"org.kde.Sdk//6.10"
"io.qt.PySide.BaseApp//6.10"
)
if ! flatpak remotes --user | grep -q "^flathub"; then
echo " Adding Flathub remote"
flatpak remote-add --user --if-not-exists flathub https://dl.flathub.org/repo/flathub.flatpakrepo
fi
for dep in "${FLATPAK_DEPS[@]}"; do
if ! flatpak info --user "$dep" &>/dev/null && ! flatpak info --system "$dep" &>/dev/null; then
echo " Installing Flatpak dependency: $dep"
flatpak install --user --noninteractive flathub "$dep"
fi
done
fi
# ---------- serial: wheel (the AppImage installs it) --------------------------
step "1/5 Wheel (version $VERSION)"
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
step "2-5/5 manual + pyinstaller + flatpak + appimage (parallel)"
LOGDIR="$DIST_DIR/.build-logs"
mkdir -p "$LOGDIR"
declare -A PID2NAME
for name in "${REST[@]}"; do
log="$LOGDIR/$name.log"
echo " -> launching $name (log: $log)"
( "build_$name" ) >"$log" 2>&1 &
PID2NAME[$!]="$name"
done
# From here until all jobs are reaped, Ctrl+C stops every build tree.
trap _interrupt INT TERM
# Reap in completion order (wait -n) so each result prints the moment that
# build finishes, not when its slot comes up in the array.
FAILED=()
remaining=${#PID2NAME[@]}
while [ "$remaining" -gt 0 ]; do
if wait -n -p donepid; then rc=0; else rc=$?; fi
name="${PID2NAME[$donepid]:-}"
[ -z "$name" ] && continue
if [ "$rc" -eq 0 ]; then
echo " -> $name: OK"
else
echo " -> $name: FAILED (rc=$rc)"
FAILED+=("$name")
fi
remaining=$((remaining - 1))
done
trap - INT TERM
if [ "${#FAILED[@]}" -gt 0 ]; then
for name in "${FAILED[@]}"; do
echo
echo "===================== $name log ====================="
cat "$LOGDIR/$name.log"
done
echo >&2
echo "BUILD FAILED: ${FAILED[*]} (logs under $LOGDIR)" >&2
exit 1
fi
fi
# ---------- summary -----------------------------------------------------------
)
APPIMAGE_SRC=$(ls -1t "$SCRIPT_DIR/package/appimage"/*.AppImage 2>/dev/null | head -1)
APPIMAGE="$DIST_DIR/$(basename "$APPIMAGE_SRC")"
cp -f "$APPIMAGE_SRC" "$APPIMAGE"
chmod +x "$APPIMAGE"
step "All packages built"
printf " manual : %s\n" "$MANUAL"
printf " wheel : %s\n" "$(wheel_in_dist)"
printf " wheel : %s\n" "$WHEEL"
printf " pyinstaller : %s\n" "$PYI_BIN"
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"

View File

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

View File

@@ -121,44 +121,15 @@ writeln function is similar to the write function except that a '\n' (newline) c
The ``read_until`` action is waiting for a string pattern from the console,
its parameter are listed below
* ``expected``: the pattern(s) to wait for. It accepts either a **single
value** or a **list of values**; when a list is given the action succeeds
as soon as **any** of the values is seen.
* ``regex``: Boolean value (``True`` or ``False``, default ``False``). When
``True`` every ``expected`` entry is interpreted as a Python regular
expression (searched in the incoming stream, not anchored) instead of a
literal string.
* ``expected``: Character string to wait for
* ``timeout``: Timeout setting for the action (in seconds)
* ``no_fail``: Boolean value (``True`` or ``False``) leading to no error reported
if the expected input is not read
* ``mute``: Boolean value (``True`` or ``False``) does not log any readen data
.. code-block:: yaml
:caption: matching several values, and with a regular expression
# succeeds as soon as one of the three strings is received
- read_until:
expected: [login:, "Password:", "$ "]
timeout: 10
# regex: wait for "version X.Y.Z" with any numbers
- read_until:
expected: 'version \d+\.\d+\.\d+'
regex: True
timeout: 5
The text read by the ``read_until`` action is stored in the global
variable named ``cn_<test_name>`` (See :ref:`global variables<sec_global_variables>`
for more detail on accessing global variables from test items and scripts).
When a list of values is given, the report also records, under the
``matched`` key, which pattern actually matched.
.. note::
``regex`` matching scans a bounded tail of the received stream
(``Console.REGEX_WINDOW`` characters), so a pattern that could only match
after a very large amount of output — or across more than that window —
may not be detected. Literal matching (the default) has no such limit.
In the example above, the global variable ``$(cn_test name in GUI)``
would be created at the end of the step. It would contain the resulting

View File

@@ -9,9 +9,9 @@ This element is of the following form:
- let:
name: Let Item
values:
key1: value1
key2: value2
key3: <| $(variable)[$(loop_index)] |>
- key1: value1
- key2: value2
- key3: <| $(variable)[$(loop_index)] |>
The ``let`` element is used to set values in the global directory.

View File

@@ -1,50 +0,0 @@
**pytest** test item
============================================================
The ``pytest`` test item runs a `pytest <https://docs.pytest.org>`_ test
file and turns every collected test into a child item, each with its own
``PASS`` / ``FAIL`` / ``SKIP`` result, duration and failure message. It is the
pytest counterpart of the ``unittest`` test item.
The tum file prototype is as followed:
.. code-block:: yaml
:caption: ``pytest`` test item usage example
- pytest:
name: pytest test item
test_file: test_device.py
test_method:
- test_boot
- test_version
Attributes
------------------
Beside common test items attributes, the pytest test item has specific
attributes:
* ``test_file``: the name (and eventually path) of the pytest file to run.
* ``test_method``: optional list of test function names. When present, only
the matching tests are included in the test tree (the name is matched
against the function part of each pytest *node id*, the parametrisation
suffix being ignored). Otherwise every collected test in the file is run.
Host execution
------------------
Unlike ``unittest`` (which runs in *testium*'s own interpreter), the
``pytest`` item runs pytest in a **subprocess on the host interpreter** — the
same one used by ``py_func`` / ``lua_func`` (overridable with the
``python_bin`` global variable). As a consequence:
* ``pytest`` (and the test's own dependencies) must be installed on that host
interpreter — e.g. ``pip install pytest``. It is not bundled with *testium*.
* the tests run isolated from *testium*'s in-process API; they are meant to be
self-contained (they exercise the device/software under test, not the
*testium* tree).
The item is invoked with a controlled pytest configuration
(``--capture=no``, user ``addopts`` neutralised, cache disabled); a small
built-in pytest plugin reports the collected tests and their results back to
*testium*.

View File

@@ -51,8 +51,8 @@ The parameter file can be specified in the `.tum` file root:
:caption: configuration files definition in the main `.tum` test file
config_file:
config1.yaml
config2.yaml
- config1.yaml
- config2.yaml
main:
name: Test example
@@ -232,15 +232,6 @@ list of the main test item (and eventually of the loop test item).
TUM file ``main`` item is itself a variant of test items with a name and an
step list attributes.
.. note::
Each test item declares the parameters it accepts. When a ``.tum`` file
uses a key the item does not know, *testium* emits a warning listing the
accepted parameter names (catching typos such as ``param_filee`` for
``param_file``); a missing **required** parameter aborts loading with an
error pointing at the source ``.tum`` file. Valid existing tests are
unaffected.
.. toctree::
@@ -270,7 +261,6 @@ step list attributes.
test_items/run_test_item.rst
test_items/sleep_test_item.rst
test_items/unittest_test_item.rst
test_items/pytest_test_item.rst

Binary file not shown.

7
package/Testium.desktop Normal file
View File

@@ -0,0 +1,7 @@
[Desktop Entry]
Type=Application
Name=Testium
Exec=testium
Icon=testium
Terminal=false
Categories=Utility;Automated test

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

View File

@@ -17,20 +17,11 @@ else
fi
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.
$RUNTIME run --rm \
--privileged \
-e APPIMAGE_EXTRACT_AND_RUN=1 \
-v "$REPO_ROOT:/work" \
$APPDIR_MOUNT \
-w /work/package/appimage \
debian:bookworm bash -c "
set -e

View File

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

View File

@@ -11,20 +11,11 @@ finish-args:
- --share=ipc
- --socket=fallback-x11
- --socket=wayland
# Expose all host devices to the sandbox. testium is a hardware-in-the-loop
# test tool: the console item must reach serial adapters (/dev/ttyUSB*,
# /dev/ttyACM*, …) which are otherwise invisible in the sandbox. --device=all
# also covers the GPU (supersedes --device=dri).
- --device=all
- --device=dri
- --share=network
- --filesystem=home
- --filesystem=/tmp
- --filesystem=host-os
# Allow flatpak-spawn --host to launch host binaries (Python, Lua, git…)
# outside the sandbox. Required because the sandbox glibc/ABI is
# incompatible with arbitrary host shared libraries — we route py_func and
# lua_func through the host instead.
- --talk-name=org.freedesktop.Flatpak
build-options:
build-args:
@@ -32,23 +23,6 @@ build-options:
modules:
- 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)
# Utilisez flatpak-pip-generator pour vos autres libs (ex: pyserial, requests, etc.)
# - name: python3-requirements

View File

@@ -1,31 +0,0 @@
# Build the Testium installer from testium.iss (needs Inno Setup 6 / ISCC.exe).
# Install ISCC without admin: winget install --id JRSoftware.InnoSetup -e
$ErrorActionPreference = 'Stop'
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
# The PyInstaller exe must exist first.
$exe = Join-Path $scriptDir '..\pyinstaller\dist\testium.exe'
if (-not (Test-Path $exe)) {
throw "PyInstaller build not found: $exe`nRun package\pyinstaller\build first."
}
# Locate ISCC.exe: PATH, then the usual install dirs.
$iscc = (Get-Command ISCC.exe -ErrorAction SilentlyContinue).Source
if (-not $iscc) {
foreach ($p in @(
"$env:LOCALAPPDATA\Programs\Inno Setup 6\ISCC.exe",
"${env:ProgramFiles(x86)}\Inno Setup 6\ISCC.exe",
"$env:ProgramFiles\Inno Setup 6\ISCC.exe")) {
if (Test-Path $p) { $iscc = $p; break }
}
}
if (-not $iscc) {
throw "ISCC.exe not found. Install Inno Setup 6:`n winget install --id JRSoftware.InnoSetup -e"
}
Write-Host "Using ISCC: $iscc"
& $iscc (Join-Path $scriptDir 'testium.iss')
if ($LASTEXITCODE -ne 0) { throw "ISCC failed with exit code $LASTEXITCODE" }
Write-Host "`nInstaller built in: $(Join-Path $scriptDir 'dist')"

View File

@@ -1,127 +0,0 @@
; Inno Setup script: wraps the PyInstaller testium.exe into a per-user installer.
; Build with Inno Setup 6: ISCC.exe testium.iss (or ./build.ps1).
#define MyAppName "Testium"
#define MyAppExeName "testium.exe"
#define MyAppPublisher "Testium"
#define MyAppURL "https://github.com/"
; Read version from src/VERSION so the installer never drifts from the build.
#define VerFile FileOpen("..\..\src\VERSION")
#define MyAppVersion Trim(FileRead(VerFile))
#expr FileClose(VerFile)
#if MyAppVersion == ""
#error Could not read version from ..\..\src\VERSION
#endif
[Setup]
; Version-scoped AppId: each version is a distinct app, installable side-by-side.
AppId={{B7E6F1C2-9A4D-4E3B-8F71-7C2D5A6E0B14}_{#MyAppVersion}
AppName={#MyAppName} {#MyAppVersion}
AppVerName={#MyAppName} {#MyAppVersion}
AppVersion={#MyAppVersion}
AppPublisher={#MyAppPublisher}
AppPublisherURL={#MyAppURL}
UninstallDisplayName={#MyAppName} {#MyAppVersion}
WizardStyle=modern
; Per-version install dir so versions never overwrite each other.
DefaultDirName={autopf}\{#MyAppName}\{#MyAppVersion}
; Shared "Testium" Start Menu folder; shortcuts below are named per version.
DefaultGroupName={#MyAppName}
UninstallDisplayIcon={app}\testium.ico
DisableProgramGroupPage=yes
; Per-user install, no admin ever: installs under %LOCALAPPDATA%, no UAC prompt.
PrivilegesRequired=lowest
ArchitecturesInstallIn64BitMode=x64compatible
OutputDir=dist
OutputBaseFilename=testium-{#MyAppVersion}-setup
SetupIconFile=..\testium.ico
Compression=lzma2/max
SolidCompression=yes
; Tell Explorer to refresh the environment after a PATH change.
ChangesEnvironment=yes
[Languages]
Name: "french"; MessagesFile: "compiler:Languages\French.isl"
Name: "english"; MessagesFile: "compiler:Default.isl"
[Tasks]
Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked
; PATH off by default: the exe is windowed (console=False), so CLI shows no output.
Name: "addtopath"; Description: "Ajouter Testium au PATH (usage en ligne de commande)"; Flags: unchecked
[Files]
Source: "..\pyinstaller\dist\{#MyAppExeName}"; DestDir: "{app}"; Flags: ignoreversion
; Ship the .ico so shortcuts/uninstall reference it directly, not the embedded one.
Source: "..\testium.ico"; DestDir: "{app}"; Flags: ignoreversion
[Icons]
; Per-version names so each install shows separately in the Start Menu.
Name: "{group}\{#MyAppName} {#MyAppVersion}"; Filename: "{app}\{#MyAppExeName}"; IconFilename: "{app}\testium.ico"
Name: "{group}\{cm:UninstallProgram,{#MyAppName} {#MyAppVersion}}"; Filename: "{uninstallexe}"
Name: "{autodesktop}\{#MyAppName} {#MyAppVersion}"; Filename: "{app}\{#MyAppExeName}"; IconFilename: "{app}\testium.ico"; Tasks: desktopicon
[Run]
Filename: "{app}\{#MyAppExeName}"; Description: "{cm:LaunchProgram,{#MyAppName}}"; Flags: nowait postinstall skipifsilent
[Code]
const
EnvKey = 'Environment';
// True if Param is not already a full segment of the per-user PATH.
function NeedsAddPath(Param: string): Boolean;
var
OrigPath: string;
begin
if not RegQueryStringValue(HKEY_CURRENT_USER, EnvKey, 'Path', OrigPath) then
begin
Result := True;
exit;
end;
Result := Pos(';' + Uppercase(Param) + ';', ';' + Uppercase(OrigPath) + ';') = 0;
end;
// On install: append {app} to the per-user PATH if the task is selected.
procedure CurStepChanged(CurStep: TSetupStep);
var
Path: string;
begin
if CurStep = ssPostInstall then
begin
if WizardIsTaskSelected('addtopath') and NeedsAddPath(ExpandConstant('{app}')) then
begin
if not RegQueryStringValue(HKEY_CURRENT_USER, EnvKey, 'Path', Path) then
Path := '';
if (Path <> '') and (Copy(Path, Length(Path), 1) <> ';') then
Path := Path + ';';
Path := Path + ExpandConstant('{app}');
RegWriteStringValue(HKEY_CURRENT_USER, EnvKey, 'Path', Path);
end;
end;
end;
// On uninstall: strip {app} back out of the per-user PATH.
procedure CurUninstallStepChanged(CurUninstallStep: TUninstallStep);
var
Path, AppDir, Segment: string;
P: Integer;
begin
if CurUninstallStep = usUninstall then
begin
if RegQueryStringValue(HKEY_CURRENT_USER, EnvKey, 'Path', Path) then
begin
AppDir := ExpandConstant('{app}');
Segment := ';' + AppDir;
P := Pos(Uppercase(Segment), Uppercase(Path));
if P > 0 then
Delete(Path, P, Length(Segment))
else
begin
P := Pos(Uppercase(AppDir) + ';', Uppercase(Path));
if P = 1 then
Delete(Path, 1, Length(AppDir) + 1);
end;
RegWriteStringValue(HKEY_CURRENT_USER, EnvKey, 'Path', Path);
end;
end;
end;

View File

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

View File

@@ -1,21 +1,5 @@
# -*- mode: python ; coding: utf-8 -*-
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,
# not the frozen interpreter — so bundling it via hiddenimports alone is not
@@ -70,7 +54,7 @@ a = Analysis(
"colorama",
"matplotlib",
"junit_xml",
"lxml"] + _LSP_HIDDEN,
"lxml"],
hookspath=[],
hooksconfig={},
runtime_hooks=[],
@@ -89,16 +73,14 @@ exe = EXE(
debug=False,
bootloader_ignore_signals=False,
strip=False,
# UPX is CPU+IO heavy for a marginal size gain — build_all --ram sets
# TESTIUM_NO_UPX=1 to skip it (much faster on slow/flash storage).
upx=not os.environ.get("TESTIUM_NO_UPX"),
upx=True,
upx_exclude=[],
runtime_tmpdir=None,
console=False,
console=True,
disable_windowed_traceback=False,
argv_emulation=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None,
ico='../testium.ico'
ico='../testium.png'
)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 353 KiB

View File

@@ -1,96 +1,3 @@
version 0.3
==============
- New ``pytest`` test item: run your pytest files as a test step; each
test shows up with its own PASS / FAIL / SKIP. Requires pytest to be
installed (``pip install pytest``).
- Search the test tree in the GUI (Ctrl+F): find items by name, type or
doc; matches are highlighted and you can step through them.
- console ``read_until`` can now wait for several possible texts at once
(it matches the first one seen), and a ``regex: true`` option lets you
match with a regular expression.
- Serial console: a clear message when the device is missing, and serial
ports now work in the Flatpak version.
- If a test file cannot be loaded (for example pytest is not installed),
only that step fails — the rest of the tests still run.
- Fix: a variable (``$(...)``) used in a console ``telnet_port`` is now
correctly substituted.
version 0.2.3
=============
- Windows version now working reliably. Fix of a problem of jrpc ports
handshakes between the py and lua processes and testium.
Beneficial to linux version too.
- Windows: UTF-8 console output and a self-sufficient validation
wrapper (run.bat).
- Resolved python_bin / lua_bin are now published into the global dict,
so test scripts can read them via $(python_bin) / $(lua_bin).
- Windows: new per-user installer (no admin).
version 0.2.2
==============
- Flatpak sandbox issue fixed for term console. Now a term console is
exactly like a host console.
- Persistence fix of dialogs in case of flatpak.
version 0.2.1
==============
- Faster test loading, especially for large tests built from jinja
templates and ``!include``: compiled jinja templates are cached and
reused (a file included many times is compiled once), rendering happens
in memory instead of through a temporary file, and YAML is parsed with
the libyaml C loader when available. Typical load time is 3-6x lower on
include / template-heavy tests; behaviour is unchanged.
- Fix: a nested list holding more than one step under ``steps`` no longer
duplicates its entries while the step tree is built.
- New load-time benchmark under ``test/benchmark/`` (synthetic-tree
generator + in-process timing harness) to measure the load pipeline.
version 0.2
==============
- Test items: each item type now declares its accepted parameters
(``PARAMS = ParamSet(...)``). Typos in a ``.tum`` are surfaced as a
WARN listing the accepted names instead of being silently ignored;
missing required parameters error out at load time with the source
``.tum`` file as context. No change to valid existing tests.
- Editor support: testium now ships a language server. ``testium lsp``
gives ``.tum`` files item-type completion, hover documentation and an
outline view in any LSP-capable editor (a VSCode / VSCodium client is
provided separately). ``testium schema`` dumps the item/parameter
schema as JSON. The server works from every channel — bundled in the
binary / Flatpak / AppImage, and pulled by ``pip install testium[lsp]``
for wheel installs.
- build_all.sh: the four heavy channels now build in parallel (results
reported as each finishes; Ctrl+C stops them cleanly). New ``--ram``
option builds on a tmpfs (``/dev/shm``) and skips UPX for much faster
packaging on USB-stick / SD-card storage.
version 0.1.3
==============
- Stop interrupts engaged blocking steps (console, py_func, lua_func,
json_rpc, sleep) within ~200 ms instead of waiting for the step
to finish.
- GUI Start / Stop / Pause flow simplified.
- lua_func: a function returning nil is no longer reported as a failure.
- ``-d python_bin=...`` and the GUI ``python_bin`` preference now reach
the eval subprocess (used to be silently ignored). ``param.yaml`` can
also override ``python_bin`` for py_func / cycle / post_exec.
- Validation suite: ``test/validation/run.sh`` (and ``run.bat``)
runs the suite inside a dedicated venv in the system temp dir.
- build_all.sh: ``release_note.txt`` and the user manual copied into
``dist/``; warning if the file has no entry for the version being built.
- Flatpak: every GUI file/directory dialog (open test, save report, log
path, default report/log dirs, python/lua interpreter pickers) now
bypasses the XDG document portal — the v0.1.2 fix was only on the
"open test" dialog.
- Flatpak: py_func / lua_func / run sub-instance now execute on the host
via flatpak-spawn, lifting the previous glibc/ABI incompatibility that
prevented user-configured host Python or Lua interpreters from being
reached from the sandbox.
- Validation suite: single entry point with ``--mode source|wheel|
pyinstaller|flatpak|appimage`` to validate any packaging channel
against the same item set; reports are stamped per mode.
- GUI: the "Run tum" test item now uses the testium logo.
version 0.1.2
==============
- Flatpak: opening a test from the GUI now correctly finds its companion

View File

@@ -33,11 +33,6 @@ if [ ! -d "$PY_VENV_DIR" ]; then
python3 -m venv "$PY_VENV_DIR"
source "$PY_VENV_DIR/bin/activate"
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
# entry-points discovery end-to-end.
FAKE_EXPORTER_DIR="$(dirname "$REQ_PATH")/../test/validation/fake_exporter"

View File

@@ -1 +1 @@
0.3
0.1.2

View File

@@ -30,12 +30,6 @@ dependencies = [
]
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]
testium = "testium:main"

View File

@@ -11,40 +11,6 @@ sys.path.append(os.path.abspath(ourpath.parent))
import interpreter.utils.constants as cst
def main():
# Force UTF-8 on stdout/stderr so the runner's output survives a legacy
# console code page (Windows cp1252 can't encode box-drawing/accented
# chars). Only the stream encoders change; the locale default used for
# config files is untouched.
for _stream in (sys.stdout, sys.stderr):
try:
_stream.reconfigure(encoding="utf-8")
except (AttributeError, ValueError):
pass # no stdout (frozen GUI) or non-reconfigurable stream
# 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
# support of the test dialogs.
multiprocessing.set_start_method('spawn')

View File

@@ -2,7 +2,6 @@ from datetime import datetime
import sys
import os
import re
import errno
from queue import Queue, Empty
from time import sleep
import collections
@@ -11,8 +10,6 @@ import threading
from telnetlib3 import Telnet, DO, WILL, WONT, TTYPE, IAC, SB, SE, theNULL
from runtime.tum_except import ETUMRuntimeError
TIMEOUT_NULL = 0.000001
STOP_POLL_INTERVAL = 0.2
@@ -127,29 +124,7 @@ A {classname}.close() is missing somewhere in your code !'.format(classname=type
# c = ''
return c
# Max chars of the buffer tail scanned in regex mode (bounds cost/memory).
REGEX_WINDOW = 65536
def _feed_match(self, data, search_deques, match_deques, matches):
"""Append *data* to each window; return the first matched pattern or None."""
matched = None
for sd, md, m in zip(search_deques, match_deques, matches):
sd.append(data)
if matched is None and sd == md:
matched = m
return matched
def _search_regex(self, read_data, compiled):
"""Search the buffer tail with each regex; return the first hit's text or None."""
tail = read_data[-self.REGEX_WINDOW:]
for p in compiled:
m = p.search(tail)
if m is not None:
return m.group(0)
return None
def read_until(self, match, timeout=None, return_data=False, mute=False,
should_stop=None, regex=False):
def read_until(self, match, timeout=None, return_data=False, mute=False, should_stop=None):
"""
read until the string 'match is found
If timeout is not set (None), this function runs indefinitely
@@ -166,35 +141,15 @@ A {classname}.close() is missing somewhere in your code !'.format(classname=type
read_data = ''
status = -1
if not match:
raise ETUMRuntimeError("'expected' pattern can not be empty")
# match: a string or list of strings; succeed as soon as any is seen.
if isinstance(match, (list, tuple)):
matches = [str(m) for m in match]
else:
matches = [str(match)]
if (not matches) or any(len(m) == 0 for m in matches):
raise ETUMRuntimeError("'expected' pattern can not be empty")
raise ValueError('match parameter can not be empty')
if timeout is None:
timeout = 1000000
compiled = None
search_deques = match_deques = None
if regex:
# 'matches' are regular expressions; succeed on the first hit.
compiled = []
for m in matches:
try:
compiled.append(re.compile(m))
except re.error as e:
raise ETUMRuntimeError(
"Invalid regular expression {!r}: {}".format(m, e)) from None
else:
# One fixed-length rolling window per literal pattern.
search_deques = [collections.deque(maxlen=len(m)) for m in matches]
match_deques = [collections.deque(m) for m in matches]
self._matched = None
# Fixed-length queue that will contain the readout characters
search_deque = collections.deque(maxlen=len(match))
# convert match string into a deque for faster comparisons
match_deque = collections.deque(match)
# In case of a timeout equal to zero, it must be looped until the
# buffer is empty
@@ -212,13 +167,9 @@ A {classname}.close() is missing somewhere in your code !'.format(classname=type
self.string_buffer += data
read_data += data
if regex:
matched = self._search_regex(read_data, compiled)
else:
matched = self._feed_match(data, search_deques, match_deques, matches)
if matched is not None:
search_deque.append(data)
if search_deque == match_deque:
status = 0
self._matched = matched
if (not mute) and (data != '\n'):
self.string_buffer += '\n'
@@ -259,13 +210,9 @@ A {classname}.close() is missing somewhere in your code !'.format(classname=type
self.string_buffer += data
read_data += data
if regex:
matched = self._search_regex(read_data, compiled)
else:
matched = self._feed_match(data, search_deques, match_deques, matches)
if matched is not None:
search_deque.append(data)
if search_deque == match_deque:
status = 0
self._matched = matched
if (not mute) and (data != '\n'):
self.string_buffer += '\n'
@@ -460,35 +407,20 @@ class SerialConsole(Console):
self.stop = threading.Event()
self.port = None
self.port_id = port
self._thd = None
def open(self):
try:
self.port = serial.Serial(port=self.port_id,
baudrate=self.baudrate,
stopbits=self.stopbits,
parity=self.parity,
xonxoff=self.xonxoff,
timeout=None)
except (serial.SerialException, OSError) as e:
raise ETUMRuntimeError(self._open_error_message(e)) from None
self.isOpened = True
if self.bufferize:
self.port.timeout = 2
self._thd = threading.Thread(target=self.read_thread)
self._thd.start()
def _open_error_message(self, exc):
"""Build a short, direct message for a failed serial open."""
errno_ = getattr(exc, "errno", None)
if errno_ == errno.ENOENT:
return "Serial device '{}' does not exist.".format(self.port_id)
if errno_ == errno.EACCES:
return ("Permission denied opening serial device '{}' "
"(is your user allowed to access it, e.g. 'dialout' group?)."
.format(self.port_id))
return "Could not open serial device '{}': {}".format(self.port_id, exc)
def read_thread(self):
while not self.stop.is_set():
c = self.port.read(1)
@@ -496,7 +428,7 @@ class SerialConsole(Console):
self.rx_queue.put(c)
def close(self):
if self.bufferize and self._thd is not None:
if self.bufferize:
self.stop.set()
self._thd.join()
if self.port is not None:
@@ -508,12 +440,10 @@ class SerialConsole(Console):
self.port.timeout = timeout
def readchar(self, timeout):
if not self.isOpened:
raise ETUMRuntimeError("Serial console '{}' is not open".format(self.name))
if self.bufferize:
if not self._thd.is_alive() and not self.stop.isSet():
raise ETUMRuntimeError(
"Impossible to read the serial console, it may be already opened")
raise RuntimeError(
"Impossible to read the serial console, it may be already openned")
if timeout < TIMEOUT_NULL:
return self.rx_queue.get(block=False)
else:
@@ -525,12 +455,10 @@ class SerialConsole(Console):
self.port.flush()
def read_nowait(self, mute=False):
if not self.isOpened:
raise ETUMRuntimeError("Serial console '{}' is not open".format(self.name))
if self.bufferize:
if not self._thd.is_alive() and not self.stop.isSet():
raise ETUMRuntimeError(
"Impossible to read the serial console, it may be already opened")
raise RuntimeError(
"Impossible to read the serial console, it may be already openned")
st = self.rx_queue.getAll().decode(self.encoding, errors='replace')
if not mute:
date_str = str(datetime.now()).split('.')[0].split(' ')[1]

View File

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

View File

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

View File

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

View File

@@ -5,13 +5,6 @@ from interpreter.test_items.item_actions.action import TestItemAction
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__(
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)
self._type = item_type
self.is_container = False
self.action_classes = dict(type(self).ACTIONS)
self.action_classes = {}
self.actions_token = None
self.actions = []
try:
@@ -31,9 +24,6 @@ class TestItemActions(TestItem):
)
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():
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
import api.testium as tm
from interpreter.utils.params import TestItemParams
from interpreter.utils.param_decl import (
Param, ParamSet, LIST, BLOCK, unknown_keys, missing_required,
)
from interpreter.utils.constants import TestItemType as cst_type
from interpreter.utils.eval import eval_to_boolean, evaluate, post_evaluate
from runtime.tum_except import ETUMSyntaxError, item_load_context
@@ -16,32 +13,6 @@ LOG_TEST_STOP = '<----- step "{}" finished'
LOG_TEST_START = '-----> step "{}" started'
# Parameters accepted by every test item, regardless of its type. Subclasses
# concatenate their own ``PARAMS`` to this set; the merged result drives
# unknown-param warnings and (later) the LSP schema export.
COMMON_PARAMS = ParamSet(
Param("name", doc="Display name shown in the GUI tree and reports."),
Param("doc", doc="Free-form documentation; surfaced in tooltips."),
Param("skipped", doc="If truthy, the step is skipped (static expression, "
"evaluated at load time)."),
Param("key", doc="Report key used to classify the result "
"(typically <test>_PASS or <test>_FAIL)."),
Param("stop_on_failure", doc="If true, abort the surrounding container on failure."),
Param("execute_on_stop", doc="If true, run this step even when its container "
"is being stopped (cleanup)."),
Param("process_result", doc="Post-evaluation expression applied to the test result."),
Param("store_result", doc="Global-dict key in which to store the test result."),
Param("expected_result", doc="Expected outcome; the step is failed if it doesn't match."),
Param("no_fail", doc="If truthy, never report a FAILURE for this step."),
Param("report", doc="Per-step reporting override."),
Param("condition", doc="Optional gating expression evaluated before each "
"run; false ⇒ the step is skipped."),
Param("steps", kind=LIST, doc="Children (for container items)."),
Param("seq_filename", doc="(internal) source .tum file of this step; injected "
"by the loader."),
)
class TestItem:
pass
@@ -61,13 +32,6 @@ def test_run(f):
self.run_test_init()
# The item could not be loaded (e.g. a missing module): FAIL at run.
# run_test_end -> write_footer prints the message.
if self._load_error is not None:
self.result.set(TestValue.FAILURE, self._load_error)
self.run_test_end()
return self.result
while self._is_paused:
sleep(0.2)
if self.isStopped() :
@@ -133,11 +97,6 @@ def test_data(item: TestItem, child: dict) -> dict:
class TestItem:
# Subclasses override with their own ParamSet to opt into the declarative
# validation. While ``PARAMS`` is empty / unset, the base class skips the
# unknown-param check for this item type — keeps the migration incremental.
PARAMS = None
def __init__(
self, dict_item: dict = None, parent: TestItem = None, status_queue=None, filename = ""
):
@@ -152,17 +111,16 @@ class TestItem:
self._report_key = None
self._reported = None
self.status_queue = status_queue
self._execute_on_stop_raw = False
self._execute_on_stop = False
self._post_eval = None
self._store_result = None
self._expected_result = None
self._no_fail = None
self._is_stopped = False
self._load_error = None
self._is_running = False
self._is_breakpoint = False
self._is_paused = False
self._stop_on_failure_raw = False
self._stop_on_failure = False
self._doc = ""
self._name = ""
self.report = None
@@ -176,13 +134,6 @@ class TestItem:
# creation of the params object
self._prms = TestItemParams(dict_item, parent)
# Declarative-params validation. Only kicks in when the concrete
# subclass declares ``PARAMS`` — items not yet migrated stay
# silent. Warnings (not errors) during the migration window so
# existing .tum files don't break suddenly; will be flipped to
# errors once every item has migrated.
self._validate_declared_params(dict_item)
# getting parameters for the test item
try:
self._name = self._prms.getParam("name", default="", processed=True)
@@ -205,14 +156,13 @@ class TestItem:
self.skipped = False
self._report_key = self._prms.getParam("key", default=None)
# Kept raw: expanded at run time by the matching properties.
self._stop_on_failure_raw = self._prms.getParam(
"stop_on_failure", default=False
self._stop_on_failure = self._prms.getParam(
"stop_on_failure", default=False, processed=True
)
self._doc = self._prms.getParam("doc", default="", processed=True)
#
self._execute_on_stop_raw = self._prms.getParam(
"execute_on_stop", default=False
self._execute_on_stop = self._prms.getParam(
"execute_on_stop", default=False, processed=True
)
if "process_result" in dict_item:
@@ -240,36 +190,6 @@ class TestItem:
self.result = TestResult(self, TestValue.FAILURE, "Failure by default")
def _validate_declared_params(self, dict_item):
"""Warn on unknown / missing-required params, if PARAMS is declared.
The check is opt-in per subclass: it only runs when the concrete
class sets a non-empty ``PARAMS`` attribute. Items not yet migrated
produce no diagnostics — preserving the historical "silently accept
anything" behavior until they get their declaration.
"""
if not self.PARAMS:
return
# ``self._type`` is the parent root type at this point (subclasses set
# it after super().__init__), so use the class name as a stable label
# in diagnostics. ``self._name`` was preset to the type name by every
# subclass before super() ran, which gives a useful prefix.
label = f"{type(self).__name__} '{self._name}'"
declared = COMMON_PARAMS + self.PARAMS
unknown = unknown_keys(declared, dict_item)
if unknown:
accepted = ", ".join(sorted(declared.names()))
for k in unknown:
tm.print_warn(
f"{label}: unknown parameter '{k}'. Accepted: {accepted}."
)
missing = missing_required(declared, dict_item)
for k in missing:
raise ETUMSyntaxError(
f"{label}: required parameter '{k}' is missing.",
self._seq_filename,
)
def _filter_dict_item(self, dict_item):
# Stores the content of the step to be displayed
# in the GUI
@@ -579,20 +499,6 @@ class TestItem:
def setEnabled(self):
self.enabled = True
def _eval_flag(self, raw):
"""Run-time flag: bool as-is, otherwise expanded and coerced to bool."""
if isinstance(raw, bool):
return raw
return eval_to_boolean(self._prms.expanse(raw))
@property
def _stop_on_failure(self):
return self._eval_flag(self._stop_on_failure_raw)
@property
def _execute_on_stop(self):
return self._eval_flag(self._execute_on_stop_raw)
def executedOnStop(self):
return self._execute_on_stop

View File

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

View File

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

View File

@@ -4,13 +4,12 @@ import importlib
import traceback
import api.testium as tm
from runtime.tum_except import ETUMSyntaxError, ETUMRuntimeError
from runtime.tum_except import ETUMSyntaxError
from runtime.stdout_redirect import stdio_redir
from interpreter.test_items.test_item import test_run
from interpreter.test_items.item_actions import TestItemActions
from interpreter.test_items.item_actions.action import TestItemAction
from interpreter.utils.constants import TestItemType as cst
from interpreter.utils.param_decl import Param, ParamSet
from interpreter.test_items.test_result import TestResult, TestValue
@@ -22,38 +21,6 @@ class TestItemConsoleAction(TestItemAction):
class TestItemConsoleOpen(TestItemConsoleAction):
PARAMS = ParamSet(
Param("protocol", required=True,
doc="Transport: 'telnet', 'ssh', 'rawtcp', 'serial' or 'terminal'."),
Param("write_delay", default=0,
doc="Inter-character write delay in ms (slow devices)."),
Param("log", doc="Path to a log file capturing the console traffic."),
Param("overwrite_log", default=True,
doc="If true, truncate the log file at open; else append."),
# telnet
Param("telnet_host", doc="Hostname/IP for the telnet target."),
Param("telnet_port", default=69, doc="TCP port for telnet."),
# ssh
Param("ssh_host", doc="Hostname/IP for the SSH target."),
Param("ssh_user", doc="SSH login user."),
Param("ssh_pwd", doc="SSH password (if key-based auth is not used)."),
# rawtcp
Param("tcp_host", doc="Hostname/IP for a raw-TCP connection."),
Param("tcp_port", doc="TCP port for a raw-TCP connection."),
# serial
Param("serial_port", doc="Serial device path (e.g. /dev/ttyUSB0 or COM3)."),
Param("serial_baudrate", doc="Serial baudrate."),
Param("buffered", default=True,
doc="If true, the serial console buffers received bytes between reads."),
# terminal
Param("terminal_path",
doc="Working directory for the local terminal protocol."),
Param("shell",
doc="Shell command used for the local terminal protocol "
"(default: 'cmd.exe' on Windows, '/usr/bin/env bash' elsewhere)."),
)
def __init__(
self, action_name, dict_item, parent=None, status_queue=None, filename=""
):
@@ -88,7 +55,7 @@ class TestItemConsoleOpen(TestItemConsoleAction):
telnet_host = self._prms.getParam(
"telnet_host", required=True, processed=True
)
telnet_port = self._prms.getParam("telnet_port", default=69, processed=True)
telnet_port = self._prms.getParam("telnet_port", default=69)
elif self._protocol == "ssh":
if tm.OS() == "Windows":
@@ -225,16 +192,12 @@ class TestItemConsoleOpen(TestItemConsoleAction):
tm.add_console(cons)
cons.open()
self.result.set(TestValue.SUCCESS)
except ETUMRuntimeError as e:
# Expected console error (device missing, no permission…): one line.
msg = "Impossible to open the console '{}': {}".format(cname, e._message)
self.result.set(result=TestValue.FAILURE, message=msg)
print(msg)
except Exception as e:
# Unexpected error: keep the full traceback for diagnosis.
self.result.set(
result=TestValue.FAILURE,
message="Impossible to open the console '{}': {}".format(cname, e),
message="Impossible to open the console ({}) (exception: {})".format(
cname, e
),
)
traceback.print_exception(*sys.exc_info())
@@ -320,22 +283,6 @@ class TestItemConsoleWriteLn(TestItemConsoleAction):
class TestItemConsoleReadUntil(TestItemConsoleAction):
PARAMS = ParamSet(
Param("expected", required=True,
doc="Literal string — or a list of strings — matched against the "
"incoming console output. The read succeeds as soon as one of "
"them is seen, or fails on 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."),
Param("regex", default=False,
doc="If true, each 'expected' entry is treated as a Python "
"regular expression (searched, not anchored) instead of a "
"literal string."),
)
def __init__(
self, action_name, dict_item, parent=None, status_queue=None, filename=""
):
@@ -352,21 +299,16 @@ class TestItemConsoleReadUntil(TestItemConsoleAction):
@test_run
def execute(self):
cons = self.get_console()
# 'expected' may be a single value or a list of values (match any).
if isinstance(self._read_until, (list, tuple)):
ru = [self._prms.expanse(m) for m in self._read_until]
else:
ru = self._prms.expanse(self._read_until)
read_timeout = int(self._prms.getParam("timeout", default=-1, processed=True))
read_timeout = float(self._prms.getParam("timeout", default=-1, processed=True))
mute = self._prms.getParam("mute", default=False, processed=True)
use_regex = self._prms.getParam("regex", default=False, processed=True)
if read_timeout < 0:
read_timeout = None
try:
status, data = cons.read_until(
ru, timeout=read_timeout, return_data=True, mute=mute,
should_stop=self.isStopped, regex=bool(use_regex),
should_stop=self.isStopped,
)
if status == 0:
self.result.set(TestValue.SUCCESS)
@@ -378,21 +320,14 @@ class TestItemConsoleReadUntil(TestItemConsoleAction):
)
else:
self.result.set(result=TestValue.FAILURE, message="No matching text")
reported = {"data": "" if mute else data}
# When several patterns were given, expose which one matched.
if status == 0 and isinstance(ru, (list, tuple)):
reported["matched"] = getattr(cons, "_matched", None)
self.result.reported = reported
if mute:
self.result.reported = {"data": ""}
else:
self.result.reported = {"data": data}
# The result is put in global dir
tm.setgd("cn_" + self.parent()._name, data)
except ETUMRuntimeError as e:
# Expected console error (e.g. console not open): clear one-liner.
msg = f"Console '{self.token['console_name']}': impossible to read ({e._message})"
self.result.set(result=TestValue.FAILURE, message=msg)
print(msg)
except Exception:
# Unexpected error: keep the full traceback for diagnosis.
except:
print(traceback.format_exc())
self.result.set(
result=TestValue.FAILURE,
@@ -401,27 +336,18 @@ class TestItemConsoleReadUntil(TestItemConsoleAction):
class TestItemConsole(TestItemActions):
PARAMS = ParamSet(
Param("console_name", required=True,
doc="Identifier of the console — used by every nested action to "
"reach back the same transport. Multiple consoles can coexist "
"as long as their names differ."),
)
ACTIONS = {
"open": TestItemConsoleOpen,
"close": TestItemConsoleClose,
"write": TestItemConsoleWrite,
"writeln": TestItemConsoleWriteLn,
"read_until": TestItemConsoleReadUntil,
}
def __init__(self, dict_item, parent=None, status_queue=None, filename=""):
super().__init__(
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 = {}
global console

View File

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

View File

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

View File

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

View File

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

View File

@@ -11,7 +11,6 @@ from interpreter.test_items.item_actions.action import TestItemAction
from interpreter.utils.constants import TestItemType as cst
from interpreter.utils.eval import evaluate
from interpreter.utils.param_decl import Param, ParamSet, BLOCK
from interpreter.test_items.test_item_json_rpc.jsonrpc_adapters import (
JrpcAdapter,
@@ -77,20 +76,6 @@ class TestItemJSRPCActionClose(TestItemAction):
class TestItemJSRPCActionQuery(TestItemAction):
PARAMS = ParamSet(
Param("method", required=True,
doc="JSON-RPC method name to call."),
Param("params",
doc="Parameters payload (list, dict or scalar) sent to the method."),
Param("id", default="rand",
doc="JSON-RPC request id. 'rand' (default) ⇒ a random integer is used."),
Param("no_wait", default=False,
doc="If true, send the request without waiting for a response."),
Param("timeout", default=None,
doc="Seconds to wait for a response. None ⇒ inherits the transport "
"default."),
)
def __init__(
self, action_name, dict_item, parent=None, status_queue=None, filename=""
):
@@ -144,13 +129,6 @@ class TestItemJSRPCActionQuery(TestItemAction):
class TestItemJSRPCActionReceive(TestItemAction):
PARAMS = ParamSet(
Param("id", required=True,
doc="JSON-RPC request id whose response we expect."),
Param("timeout", default=None,
doc="Seconds to wait for the response. None ⇒ transport default."),
)
def __init__(
self, action_name, dict_item, parent=None, status_queue=None, filename=""
):
@@ -194,29 +172,6 @@ class TestItemJSON_RPC(TestItemActions):
This item TBD
"""
PARAMS = ParamSet(
Param("console", kind=BLOCK,
doc="Console-transport block: {console_name, …}. Either 'console' "
"or 'udp' must be set."),
Param("udp", kind=BLOCK,
doc="UDP-transport block: {host, port, …}. Either 'console' or "
"'udp' must be set."),
Param("version", default="1.0",
doc="JSON-RPC protocol version ('1.0' or '2.0')."),
Param("timeout", required=True,
doc="Default seconds to wait for a JSON-RPC response across all "
"child query/receive actions."),
Param("mute", default=False,
doc="If true, don't echo wire traffic to the log."),
)
ACTIONS = {
"open": TestItemJSRPCActionOpen,
"close": TestItemJSRPCActionClose,
"query": TestItemJSRPCActionQuery,
"receive": TestItemJSRPCActionReceive,
}
def __init__(
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
)
self.register_actions(
open=TestItemJSRPCActionOpen,
close=TestItemJSRPCActionClose,
query=TestItemJSRPCActionQuery,
receive=TestItemJSRPCActionReceive,
)
# Console specific params
self._console = self._prms.getParam("console", required=False)
# 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
import api.testium as tm
from interpreter.utils.constants import TestItemType as cst
from interpreter.utils.param_decl import Param, ParamSet, LIST
class TestItemLet(TestItem):
"""let item usage.
let values: {variable1: a, variable2: /dev/ttyUSB0, variable3: 115200}
let eval: {conditional_exec: "random.randint(1, 4)"}
"""
PARAMS = ParamSet(
Param("values", kind=LIST, required=True,
doc="Mapping (or list of single-pair mappings) of global-dict "
"key → value to set. Values are expanded at execution time."),
)
def __init__(self, dict_item, parent = None, status_queue=None, filename=""):
self._name = cst.TYPE_LET.item_name
super().__init__(dict_item, parent, status_queue, filename=filename)

View File

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

View File

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

View File

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

View File

@@ -6,7 +6,6 @@ from interpreter.test_items.test_item import test_run
from interpreter.test_items.test_result import TestResult, TestValue
from interpreter.utils.constants import TestItemType as cst
from interpreter.utils.eval import eval_to_boolean
from interpreter.utils.param_decl import Param, ParamSet, LIST, BLOCK, Enum
from runtime.tum_except import ETUMSyntaxError
from runtime.string_queue import StringQueue
from runtime.stdout_redirect import stdio_redir
@@ -16,12 +15,6 @@ class TestItemParallelBranch(TestItemContainer):
"""One branch of a parallel item. Runs its children sequentially,
optionally waiting for a condition before starting."""
PARAMS = ParamSet(
Param("wait_for", kind=BLOCK,
doc="Optional block {condition, timeout} that defers the branch "
"start until the condition is truthy (or the timeout elapses)."),
)
def __init__(self, dict_item, parent=None, status_queue=None, filename=""):
super().__init__(cst.TYPE_PARALLEL_BRANCH, dict_item, parent, status_queue, filename=filename)
self._wait_condition = None
@@ -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=""):
branches = dict_item.get("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.api_srv import api_request
from interpreter.utils.constants import TestItemType as cst
from interpreter.utils.param_decl import Param, ParamSet, LIST
_PY_FUNC_CONTEXTS_KEY = "_py_func_contexts"
@@ -22,21 +21,6 @@ class TestItemPyFunc(TestItem):
Optional: context_id: <id> — share a persistent process with other py_func items using the same id.
"""
PARAMS = ParamSet(
Param("file", required=True,
doc="Path to the .py file containing the function."),
Param("func_name", required=True,
doc="Name of the function to call in the file."),
Param("param", kind=LIST,
doc="Arguments passed to the function. Each entry is expanded "
"before the call. Special tokens $(loop_param) / $(loop_index) "
"resolve from the surrounding cycle."),
Param("context_id", default=None,
doc="If set, the py_func subprocess is kept alive and reused by "
"every other py_func item with the same context_id — enables "
"shared in-memory state between successive calls."),
)
def __init__(self, dict_item, parent=None, status_queue=None, filename=""):
self._name = cst.TYPE_PY_FUNCTION.item_name
super().__init__(dict_item, parent, status_queue, filename=filename)
@@ -90,7 +74,7 @@ class TestItemPyFunc(TestItem):
if not engine.is_alive():
engine.start()
if not engine.wait_ready(10):
if not engine.wait_ready():
raise ETUMRuntimeError(
f"""Impossible to start the external python execution process.
Is the python path correct ?

View File

@@ -1,397 +0,0 @@
"""``pytest`` test item.
Runs a user pytest file and surfaces every collected test as a child item
(one PASS / FAIL / SKIP per test, with duration and failure message in the
report) — the pytest analogue of the ``unittest`` item.
Unlike ``unittest`` (which runs in-process), pytest runs in a **subprocess on
the host interpreter** (``bins.python_bin()``), exactly like ``py_func`` /
``lua_func``. This keeps the user's pytest install and test dependencies on
the host (visible across every packaging channel — source, wheel, PyInstaller,
Flatpak, AppImage) instead of requiring them inside the bundled interpreter.
A tiny stdlib-only pytest plugin is shipped as a self-contained launcher
script (run directly and registered via ``pytest.main(plugins=[...])`` — no
PYTHONPATH / ``-p`` / import-by-name). It streams the collected node ids and
per-test results back over the subprocess stdout as sentinel-prefixed lines,
which the parent parses live.
"""
import os
import json
import shutil
import atexit
import tempfile
import threading
import queue
import subprocess
import api.testium as tm
from runtime.tum_except import ETUMFileError, ETUMRuntimeError
from interpreter.test_items.test_item import TestItem, test_run, test_data
from interpreter.test_items.test_result import TestResult, TestValue
from interpreter.utils.constants import TestItemType as cst
from interpreter.utils.param_decl import Param, ParamSet, LIST
from interpreter.utils.paths import no_window_kwargs
from interpreter.utils import bins
# Sentinels streamed by the in-subprocess plugin (see _PLUGIN_SOURCE). Kept in
# sync with the plugin source below.
_SENT_COLLECTED = "__TESTIUM_PYTEST_COLLECTED__"
_SENT_START = "__TESTIUM_PYTEST_START__"
_SENT_RESULT = "__TESTIUM_PYTEST_RESULT__"
# stdlib-only pytest plugin executed inside the host subprocess. It must not
# import anything from testium. It emits one sentinel line per event so the
# parent can rebuild the test tree (collection) and per-test results (run)
# without parsing pytest's human output or a JUnit XML.
_PLUGIN_SOURCE = '''\
import sys
import json
_SENT_COLLECTED = "__TESTIUM_PYTEST_COLLECTED__"
_SENT_START = "__TESTIUM_PYTEST_START__"
_SENT_RESULT = "__TESTIUM_PYTEST_RESULT__"
_reports = {}
def _emit(payload):
# Leading newline guarantees the sentinel starts its own line even if a
# test printed without a trailing newline (pytest runs with --capture=no).
sys.stdout.write("\\n" + payload + "\\n")
sys.stdout.flush()
def pytest_collection_modifyitems(session, config, items):
_emit(_SENT_COLLECTED + json.dumps([it.nodeid for it in items]))
def pytest_runtest_logstart(nodeid, location):
_emit(_SENT_START + nodeid)
def pytest_runtest_logreport(report):
_reports.setdefault(report.nodeid, {})[report.when] = report
def _skip_reason(report):
lr = report.longrepr
if isinstance(lr, tuple) and len(lr) == 3:
return str(lr[2])
return report.longreprtext or ""
def pytest_runtest_logfinish(nodeid, location):
phases = _reports.pop(nodeid, {})
setup = phases.get("setup")
call = phases.get("call")
teardown = phases.get("teardown")
duration = 0.0
for rep in (setup, call, teardown):
if rep is not None:
duration += getattr(rep, "duration", 0.0) or 0.0
outcome = "pass"
message = ""
if setup is not None and setup.failed:
outcome, message = "fail", setup.longreprtext
elif setup is not None and setup.skipped:
outcome, message = "skip", _skip_reason(setup)
elif call is not None:
if call.failed:
outcome, message = "fail", call.longreprtext
elif call.skipped:
outcome, message = "skip", _skip_reason(call)
else:
outcome = "pass"
if teardown is not None and teardown.failed and outcome == "pass":
outcome, message = "fail", teardown.longreprtext
_emit(_SENT_RESULT + json.dumps({
"nodeid": nodeid,
"outcome": outcome,
"message": message,
"duration": duration,
}))
'''
# Self-contained pytest runner: the plugin source above + a main that hands the
# module to pytest as a *plugin object* (pytest.main(plugins=[...])). Run
# directly (``python launcher.py …``), so the plugin needs no PYTHONPATH, no
# ``-p`` and no import-by-name — robust on every channel, AppImage included.
_LAUNCHER_SOURCE = _PLUGIN_SOURCE + '''
if __name__ == "__main__":
import pytest
sys.exit(pytest.main(plugins=[sys.modules[__name__]]))
'''
class TestItemPytestElement(TestItem):
"""One collected pytest test (leaf child of a pytest file item)."""
def __init__(self, name, parent=None, status_queue=None, filename=""):
super().__init__(None, parent, status_queue, filename=filename)
self.is_container = False
self._name = name
self._type = cst.TYPE_PYTEST_STEP
self.banner = ""
self.footer = ""
self._nodeid = ""
self._reported_done = False
class TestItemPytestFile(TestItem):
PARAMS = ParamSet(
Param("test_file", required=True,
doc="Path to the pytest test file."),
Param("test_method", kind=LIST,
doc="Optional list of test function names to restrict the run "
"to (matched against the function part of each node id, "
"parametrisation suffix stripped). When empty, every "
"collected test in the file is run."),
)
def __init__(self, dict_item, parent=None, status_queue=None, filename=""):
self._name = cst.TYPE_PYTEST.item_name
super().__init__(dict_item, parent, status_queue, filename=filename)
self.is_container = True
self._type = cst.TYPE_PYTEST
self._fileName = self._prms.getParam('test_file', required=True, processed=True)
self._testDir = ''
self._test_methods = self._prms.getParamAll('test_method', processed=True)
self._cwd = ""
self._launcher = ""
def setTestDir(self, dir):
self._testDir = dir
# ---- subprocess plumbing -------------------------------------------------
def _write_launcher(self):
# In Flatpak the host process can only read /tmp (shared), so stage the
# launcher there; outside Flatpak the default temp dir is fine.
d = tempfile.mkdtemp(prefix="testium_pytest_",
dir="/tmp" if bins._in_flatpak() else None)
path = os.path.join(d, "launcher.py")
with open(path, "w") as f:
f.write(_LAUNCHER_SOURCE)
atexit.register(shutil.rmtree, d, ignore_errors=True)
return path
def _pytest_popen(self, args):
pbin = bins.python_bin()
if not pbin:
raise ETUMRuntimeError("No valid Python 3 interpreter found")
env = os.environ.copy()
bins.apply_host_libs(env)
env.pop("PYTHONUSERBASE", None)
# Run the self-contained launcher directly: it registers our plugin via
# pytest.main(plugins=[...]), so no PYTHONPATH / -p / import-by-name.
cmd_args = [
self._launcher,
"--capture=no", # let plugin sentinels + test prints reach our pipe
"-o", "addopts=", # neutralise user addopts (xdist/cov break parsing)
"-p", "no:cacheprovider",
*args,
]
if bins._in_flatpak():
host_env = {"PATH": env["PATH"]} if env.get("PATH") else {}
params = bins.flatpak_host_spawn(
pbin, cmd_args, host_cwd=self._cwd, extra_env=host_env)
popen_kwargs = {}
else:
params = [pbin, *cmd_args]
popen_kwargs = {"env": env, "cwd": self._cwd}
return subprocess.Popen(
params,
stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
bufsize=1,
encoding="utf-8",
errors="replace",
restore_signals=False,
**no_window_kwargs(),
**popen_kwargs,
)
# ---- loading (collection) ------------------------------------------------
def _collect(self):
proc = self._pytest_popen(["--collect-only", "-q", self._fileName])
nodeids = []
output = []
for line in proc.stdout:
line = line.rstrip("\n")
if line.startswith(_SENT_COLLECTED):
try:
nodeids = json.loads(line[len(_SENT_COLLECTED):])
except ValueError:
pass
elif line != "":
output.append(line)
proc.wait()
return nodeids, "\n".join(output)
def _collection_error(self, output):
"""Clear reason why collection produced no test."""
if "No module named pytest" in output or "No module named 'pytest'" in output:
return ("pytest is not installed on the host interpreter used by "
"testium (python_bin). Install it, e.g. 'pip install pytest'.")
return 'No pytest test collected from "%s".\n%s' % (self._fileName, output)
def load(self):
ret = {}
if self._fileName == '':
raise ETUMFileError('A file name is expected but got "None"')
if not os.path.isabs(self._fileName):
self._fileName = os.path.normpath(os.path.join(self._testDir, self._fileName))
if not os.path.isfile(self._fileName):
raise ETUMFileError('File "%s" is not found' % (self._fileName))
self._cwd = os.path.dirname(self._fileName) or "."
self._launcher = self._write_launcher()
nodeids, output = self._collect()
if not nodeids:
raise ETUMFileError(self._collection_error(output))
if self._test_methods:
present = {nid.split("::")[-1].split("[")[0] for nid in nodeids}
for m in self._test_methods:
if m not in present:
raise ETUMFileError(
'Test function "%s" is not found in "%s"' % (m, self._fileName))
wanted = set(self._test_methods)
nodeids = [nid for nid in nodeids
if nid.split("::")[-1].split("[")[0] in wanted]
for nid in nodeids:
disp = nid.split("::", 1)[1] if "::" in nid else nid
item = TestItemPytestElement(disp, self)
item._nodeid = nid
ret.update(test_data(item, {}))
return ret
# ---- execution (run) -----------------------------------------------------
def _finish_child(self, child, value, message=""):
if child._reported_done:
return
if getattr(child, "t0", None) is None:
child.t0 = tm.timestamp()
self.status_queue.put(
{'id': child.id(), 'status': 'started', 'timestamp': child.t0})
child.duration = tm.timestamp() - child.t0
res = TestResult(child, value, message)
res.test_id = child.id()
res.sendStatus(self.status_queue)
self.status_queue.put(
{'id': child.id(), 'status': 'finished', 'duration': child.duration})
self.report.addTest(child, res)
child._reported_done = True
def _stream_results(self, proc, by_nodeid):
overall = TestValue.SUCCESS
outq = queue.Queue()
def reader():
for line in proc.stdout:
outq.put(line)
outq.put(None)
t = threading.Thread(target=reader, daemon=True)
t.start()
while True:
try:
line = outq.get(timeout=0.1)
except queue.Empty:
if self.isStopped():
try:
proc.terminate()
except Exception:
pass
break
continue
if line is None:
break
line = line.rstrip("\n")
if line.startswith(_SENT_COLLECTED):
# pytest re-collects at the start of the run; the node list was
# already consumed at load time, so drop it here.
continue
elif line.startswith(_SENT_START):
child = by_nodeid.get(line[len(_SENT_START):])
if child is not None and getattr(child, "t0", None) is None:
child.t0 = tm.timestamp()
self.status_queue.put(
{'id': child.id(), 'status': 'started', 'timestamp': child.t0})
elif line.startswith(_SENT_RESULT):
try:
rec = json.loads(line[len(_SENT_RESULT):])
except ValueError:
continue
child = by_nodeid.get(rec.get("nodeid"))
if child is None:
continue
value = {
"pass": TestValue.SUCCESS,
"fail": TestValue.FAILURE,
"skip": TestValue.NORUN,
}.get(rec.get("outcome"), TestValue.FAILURE)
self._finish_child(child, value, rec.get("message", ""))
if value == TestValue.FAILURE:
overall = TestValue.FAILURE
elif line != "":
print(line)
proc.wait()
return overall
@test_run
def execute(self):
by_nodeid = {}
enabled_nodeids = []
for i in range(self.childCount()):
c = self.child(i)
c.t0 = None
c._reported_done = False
by_nodeid[c._nodeid] = c
if c.enabled:
enabled_nodeids.append(c._nodeid)
else:
self._finish_child(c, TestValue.NORUN, "test disabled")
overall = TestValue.SUCCESS
if enabled_nodeids and not self.isStopped():
args = list(enabled_nodeids)
if self._stop_on_failure:
args.append("-x")
proc = self._pytest_popen(args)
overall = self._stream_results(proc, by_nodeid)
# Any enabled test that produced no result (crash, -x stop, user stop)
# is reported as NORUN so the tree stays consistent.
for i in range(self.childCount()):
c = self.child(i)
if c.enabled and not c._reported_done:
self._finish_child(c, TestValue.NORUN, "not executed")
if self.isStopped():
self.result.set(TestValue.NORUN, 'pytest execution aborted on user request')
else:
self.result.set(overall, 'pytest ' + str(overall))

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

View File

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

View File

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

View File

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

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

View File

@@ -2,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_item_dialog_base import TestItemDialogBase, _is_text_mode, _is_interactive
from interpreter.utils.constants import TestItemType as cst
from interpreter.utils.param_decl import Param, ParamSet, LIST
from runtime.tum_except import item_load_context
import api.testium as tm
class TestItemTestedRefsDialog(TestItemDialogBase):
PARAMS = ParamSet(
Param("question", required=True,
doc="Prompt asking the operator to enter the tested references."),
Param("reference", kind=LIST,
doc="Pre-filled list of references shown in the dialog."),
Param("auto_result", default=None,
doc="Batch-mode outcome: None ⇒ FAILURE, truthy ⇒ SUCCESS with "
"the pre-filled references."),
)
def __init__(self, dict_item, parent=None, status_queue=None, filename=""):
self._name = cst.TYPE_REFERENCE_DLG.item_name
super().__init__(dict_item, parent, status_queue, filename=filename)
@@ -26,14 +14,13 @@ class TestItemTestedRefsDialog(TestItemDialogBase):
self.is_container = False
with item_load_context(self.cmd(), self.name(), self.seqFilename()):
self._question = self._prms.getParam('question', required=True)
# Kept raw: expanded at run time in execute().
self._init_values = self._prms.getParamAll('reference', required=False)
self._init_values = self._prms.getParamAll('reference', required=False, processed=True)
self._auto_result = self._prms.getParam('auto_result', required=False, default=None)
@test_run
def execute(self):
q = self._prms.expanse(self._question)
init_values = ','.join(self._prms.expanse(v) for v in self._init_values)
init_values = ','.join(self._init_values)
if _is_text_mode():
print(f"References: {q}")
rows = init_values.split(',') if init_values else ['']

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

View File

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

View File

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

View File

@@ -14,7 +14,7 @@ class ReportExportHTML(rpe.ReportExport):
self.prepareFile()
self.create_base()
self.process_tests()
with open(self._file_name, 'w', encoding="utf-8") as f:
with open(self._file_name, 'w') as f:
f.write(lxml.html.tostring(self.root, pretty_print=True).decode())
def testsIterate(self, row):

View File

@@ -20,7 +20,7 @@ class ReportExportJUnit(rpe.ReportExport):
ts = TestSuite(repname, test_cases=self.test_cases,
hostname=tm.gd('host_ip'))
with open(self._file_name, 'w', encoding="utf-8") as f:
with open(self._file_name, 'w') as f:
TestSuite.to_file(f, [ts])
def testsIterate(self, row):

View File

@@ -29,51 +29,6 @@ def _build_item_path(item) -> str:
return " > ".join(reversed(parts))
def _flatten_actions(actions, out, parent_seq_name):
"""Expand nested lists and included ``sequence`` entries into ``out`` as a
flat list of single test-item dicts, propagating each sequence's source
filename onto its items.
Replaces the previous approach, which spliced each entry into the step
list and rebuilt the whole list every time (O(n^2) over the step list, and
a rebuild that duplicated entries when a nested list held more than one
element). This single forward pass is linear.
"""
for idx, action in enumerate(actions):
# a bare list raises its elements to the same level
if isinstance(action, (list, tuple)):
_flatten_actions(action, out, parent_seq_name)
continue
# a NoneType (e.g. pointing at an unused alias) contributes nothing
if action is None:
continue
# a 'sequence' (an included file) is spliced in, with its filename
# propagated onto each of its items
if isinstance(action, dict) and "sequence" in action:
sequence = action["sequence"]["data"]
f = action["sequence"]["filename"]
if isinstance(sequence, dict):
sequence = [{k: v} for k, v in sequence.items()]
# Case of an empty sequence
elif sequence is None:
tm.print_info(
f"An empty sequence is loaded in '{parent_seq_name}'."
)
sequence = []
elif not isinstance(sequence, list):
raise ETUMSyntaxError(
f"Syntax error in '{parent_seq_name}' step number {idx+1}. Sequence definition: '{str(action)}'",
f
)
for s in sequence:
if isinstance(s, dict) and s:
s[list(s.keys())[0]]["seq_filename"] = f
_flatten_actions(sequence, out, parent_seq_name)
continue
out.append(action)
class TestSet:
def __init__(
self,
@@ -451,20 +406,6 @@ class TestSet:
def rootItem(self):
return self._rootItem
def _load_item(self, item):
"""Run an item's self-load, deferring a failure (e.g. a missing module)
to a run-time FAILURE instead of aborting the whole test load."""
try:
return item.load()
except Exception as e:
msg = getattr(e, "_message", None) or str(e)
item._load_error = msg
tm.print_warn(
f"'{item.cmd()}' item '{item.name()}' could not be loaded: "
f"{msg} (it will FAIL at run)."
)
return {}
def load_test_recursively(self, tree_parent, parent_seq, file_name):
ret = {}
try:
@@ -493,16 +434,56 @@ class TestSet:
f"No valid list of actions in sequence {parent_seq_name}",
file_name
)
# first we merged to the same level 'sequence dict entries and list within the list
counter = 0
test_dir = tm.gd("test_directory")
la = len(parent_seq_actions)
while counter < la:
action = parent_seq_actions[counter]
# if action is a list raise up to the the same level,
# ie insert action element into the parent_seq_actions
if isinstance(action, (list, tuple)):
parent_seq_actions[counter : counter + 1] = action
parent_seq_actions = (
parent_seq_actions[:counter]
+ action
+ parent_seq_actions[counter + 1 :]
)
la = len(parent_seq_actions)
continue
# if action is a NoneType skip and continue
# (when pointing to an unused alias for instance)
if action is None:
counter += 1
continue
# if action is a sequence we insert its entry into the action list
if "sequence" in action:
sequence = action["sequence"]["data"]
f = action["sequence"]["filename"]
if isinstance(sequence, dict):
sequence = [{k: v} for k, v in sequence.items()]
# Case of an empty sequence
elif sequence is None:
tm.print_info(
f"An empty sequence is loaded in '{parent_seq_name}'."
)
sequence = []
elif not isinstance(sequence, list):
raise ETUMSyntaxError(
f"Syntax error in '{parent_seq_name}' step number {counter+1}. Sequence definition: '{str(action)}'",
f
)
for s in sequence:
s[list(s.keys())[0]]["seq_filename"] = f
parent_seq_actions = (
parent_seq_actions[:counter]
+ sequence
+ parent_seq_actions[counter + 1 :]
)
la = len(parent_seq_actions)
continue
# Flatten nested lists and included 'sequence' entries to the same level
# in one linear pass (was an in-place splice + full list rebuild per
# entry: O(n^2) over the step list).
flat_actions = []
_flatten_actions(parent_seq_actions, flat_actions, parent_seq_name)
for action in flat_actions:
# Action is now for sure a dict of length 1
# Action is now for sure a list of dict of length 1
k = list(action.keys())[0]
if action[k].get("seq_filename", None) is None:
action[k]["seq_filename"] = file_name
@@ -546,9 +527,9 @@ class TestSet:
item.is_folded = is_folded
child = {}
# case where the test item loads itself its descendants
if it in (cst_type.TYPE_UNITTEST, cst_type.TYPE_PYTEST):
if it == cst_type.TYPE_UNITTEST:
item.setTestDir(test_dir)
child = self._load_item(item)
child = item.load()
elif issubclass(it.item_class, TestItemActions):
child = item.load()
# case where the test item is an items container
@@ -565,6 +546,8 @@ class TestSet:
action[k]["seq_filename"]
)
counter += 1
return ret
def tree(self):

View File

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

View File

@@ -10,8 +10,6 @@ class TestItemEnum():
class TestItemType(Enum):
TYPE_UNITTEST = TestItemEnum("unittest", "unittest")
TYPE_UNITTEST_STEP = TestItemEnum("unittest_step", "unittest step")
TYPE_PYTEST = TestItemEnum("pytest", "pytest")
TYPE_PYTEST_STEP = TestItemEnum("pytest_step", "pytest step")
TYPE_CONSOLE = TestItemEnum("console", "Console")
TYPE_CONSOLE_ACTION = TestItemEnum("console_action", "Console action")
TYPE_CYCLE = TestItemEnum("loop", "Cycle")

View File

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

View File

@@ -1,13 +1,14 @@
import os
import sys
import subprocess
import socket
import api.testium as tm
from runtime.jrpc import JsonRpcClient
from interpreter.utils.paths import subproc_path, no_window_kwargs
from interpreter.utils.paths import subproc_path
from runtime.tum_except import ETUMRuntimeError
from interpreter.utils import bins
from interpreter.utils.proc_drain import drain_and_read_port, wait_for_port
from interpreter.utils.proc_drain import drain_to_log
class LuaProcessBase:
@@ -46,13 +47,6 @@ class LuaProcessBase:
if self._process is not None:
raise ETUMRuntimeError("The function subprocess has already been started.")
# In Flatpak the host can't see /app/lib/testium/lua_func, so use a
# staged copy under /tmp (shared between sandbox and host).
if bins._in_flatpak():
func_proc_path = os.path.join(
bins._get_host_testium_path(), "lua_func"
)
else:
func_proc_path = os.path.realpath(
os.path.join(subproc_path(), "lua_func")
)
@@ -77,56 +71,39 @@ class LuaProcessBase:
env[k] = e
else:
env[k] = e + ";" + env.get(k, "")
bins.apply_host_lua_paths(env)
# POpen params (port 0 -> the Lua server picks a free port and reports it)
cmd_args = [
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.bind(("localhost", 0))
self._port = sock.getsockname()[1]
sock.close()
# POpen params
params = [
self._lbin,
"main.lua",
"--timeout",
f"{self._timeout}",
"--host",
"127.0.0.1",
"--port",
"0",
f"{self._port}",
]
if tm.debug_enabled() and tm.gd("debug_rpc", False):
cmd_args.append("--verbose")
if bins._in_flatpak():
# Run on the host outside the sandbox: avoids glibc ABI mismatches
# between the Flatpak runtime and host shared libraries.
host_env = {
k: env[k] for k in ("LUA_PATH", "LUA_CPATH", "PATH")
if k in env and env[k]
}
params = bins.flatpak_host_spawn(
self._lbin, cmd_args, host_cwd=func_proc_path,
extra_env=host_env,
)
popen_kwargs = {}
else:
params = [self._lbin, *cmd_args]
popen_kwargs = {"env": env, "cwd": func_proc_path}
params.append("--verbose")
self._process = subprocess.Popen(
params,
params, env=env, cwd=func_proc_path,
stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
restore_signals=False,
**no_window_kwargs(),
**popen_kwargs,
)
# Forward subprocess output to the log and read the startup port sentinel.
holder = drain_and_read_port(self._process, prefix="[lua_func] ")
self._port = wait_for_port(
self._process, holder, tm.gd("proc_start_timeout", 30)
)
if self._port is None:
# Worker died before announcing its port: reset so a later start() retries clean.
self.stop()
self.join()
return
# Route subprocess stdout/stderr (lua require failures, syntax
# errors, anything written to fd 1/2 before the in-script
# remote_print is set up) into the parent's log.
drain_to_log(self._process, prefix="[lua_func] ")
self._rpc = JsonRpcClient(
"localhost", self._port, req_handler=self._req_handler

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

@@ -8,14 +8,6 @@ import subprocess
import api.testium as tm
def no_window_kwargs():
# Hide stray child consoles in the frozen Windows GUI exe (console=False has
# no console to inherit). The wheel/source keeps its console, so leave it.
if sys.platform == "win32" and getattr(sys, "frozen", False):
return {"creationflags": subprocess.CREATE_NO_WINDOW}
return {}
def testium_path():
if getattr(sys, 'frozen', False):
@@ -62,7 +54,6 @@ def sys_app_path_win(app_name):
text=True,
encoding="oem",
timeout=10,
**no_window_kwargs(),
)
data = result.stdout
except (FileNotFoundError, PermissionError, subprocess.TimeoutExpired):

View File

@@ -8,9 +8,6 @@ exceptions before the in-process redirection kicks in, lua
``require`` failures, anything written to fd 1/2 directly).
"""
import threading
from time import monotonic
from runtime.jrpc import RPC_PORT_SENTINEL
def _drain_pipe(pipe, prefix):
@@ -49,60 +46,3 @@ def drain_to_log(process, prefix=""):
t.start()
threads.append(t)
return threads
def drain_and_read_port(process, prefix=""):
"""Like :func:`drain_to_log`, but the stdout reader also watches for the
startup port sentinel. Returns a ``holder`` dict (passed to
:func:`wait_for_port`); all non-sentinel lines are still forwarded to the
log. stderr is drained as usual.
"""
holder = {"port": None, "evt": threading.Event()}
def _read_stdout(pipe):
try:
for raw in iter(pipe.readline, b""):
line = raw.decode("utf-8", errors="replace").rstrip("\r\n")
if holder["port"] is None and line.startswith(RPC_PORT_SENTINEL):
try:
holder["port"] = int(line[len(RPC_PORT_SENTINEL):].strip())
except ValueError:
continue
holder["evt"].set()
continue
if line:
print(f"{prefix}{line}" if prefix else line)
finally:
try:
pipe.close()
except Exception:
pass
holder["evt"].set() # unblock waiter on EOF even without sentinel
if process.stdout is not None:
threading.Thread(
target=_read_stdout, args=(process.stdout,), daemon=True,
).start()
if process.stderr is not None:
threading.Thread(
target=_drain_pipe, args=(process.stderr, prefix), daemon=True,
).start()
return holder
def wait_for_port(process, holder, deadline):
"""Block until the port sentinel arrives, the process dies, or *deadline*
seconds elapse. Returns the port int or ``None``.
"""
end = monotonic() + deadline
while holder["port"] is None:
remaining = end - monotonic()
if remaining <= 0:
break
holder["evt"].wait(min(remaining, 0.2))
if holder["port"] is not None:
break
if process.poll() is not None:
holder["evt"].wait(0.2) # child exited; let the reader flush a trailing line
break
return holder["port"]

View File

@@ -1,12 +1,13 @@
import os
import sys
import subprocess
import socket
from runtime.jrpc import JsonRpcClient
import api.testium as tm
from runtime.tum_except import ETUMRuntimeError
from interpreter.utils.paths import testium_path, subproc_path, no_window_kwargs
from interpreter.utils.paths import testium_path, subproc_path
from interpreter.utils import bins
from interpreter.utils.proc_drain import drain_and_read_port, wait_for_port
from interpreter.utils.proc_drain import drain_to_log
class PyProcessBase:
@@ -53,63 +54,43 @@ class PyProcessBase:
else:
env[k] = e + os.pathsep + env.get(k, "")
# In Flatpak the host can't see /app/lib/testium, so use a staged copy
# under /tmp (shared between sandbox and host) for both cwd and as the
# root in PYTHONPATH. Outside Flatpak the original paths are used.
if bins._in_flatpak():
tstium_path = bins._get_host_testium_path()
func_proc_path = tstium_path
else:
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.bind(("localhost", 0))
self._port = sock.getsockname()[1]
# Port was reserved until the sub-process is started. Now released.
if sock is not None:
sock.close()
# Add the path of the subprocess (root sources of testium)
tstium_path = os.path.realpath(testium_path())
func_proc_path = os.path.realpath(subproc_path())
env["PYTHONPATH"] = tstium_path + os.pathsep + self._ppath + os.pathsep + env.get("PYTHONPATH", "")
cmd_args = [
params = [
self._pbin,
# "-m",
"py_func",
"-p",
"0",
f"{self._port}",
"-t",
f"{self._timeout}",
]
if tm.debug_enabled() and tm.gd("debug_rpc", False):
cmd_args.append("-v")
if bins._in_flatpak():
# Run on the host outside the sandbox: avoids glibc ABI mismatches
# between the Flatpak runtime and host shared libraries.
host_env = {
k: env[k] for k in ("PYTHONPATH", "PATH")
if k in env and env[k]
}
params = bins.flatpak_host_spawn(
self._pbin, cmd_args, host_cwd=func_proc_path,
extra_env=host_env,
)
popen_kwargs = {}
else:
params = [self._pbin, *cmd_args]
popen_kwargs = {"env": env, "cwd": func_proc_path}
params.append("-v")
self._process = subprocess.Popen(
params,
params, env=env, cwd=func_proc_path,
stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
restore_signals=False,
**no_window_kwargs(),
**popen_kwargs,
)
# Forward subprocess output to the log and read the startup port sentinel.
holder = drain_and_read_port(self._process, prefix="[py_func] ")
self._port = wait_for_port(
self._process, holder, tm.gd("proc_start_timeout", 30)
)
if self._port is None:
# Worker died before announcing its port: reset so a later start() retries clean.
self.stop()
self.join()
return
# Route subprocess stdout/stderr (early-startup errors,
# unhandled exceptions, anything written to fd 1/2 before the
# in-process JSON-RPC stdio_redir kicks in) into the parent's
# log.
drain_to_log(self._process, prefix="[py_func] ")
self._rpc = JsonRpcClient(
"localhost", self._port, req_handler=self._req_handler

View File

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

View File

@@ -11,7 +11,7 @@ import api.testium as tm
import interpreter.utils.globdict as globdict
import interpreter.utils.settings as prefs
from interpreter.utils.paths import testium_path
from interpreter.utils.yaml_load import yaml_load, YAML_BASE_LOADER
from interpreter.utils.yaml_load import yaml_load
from interpreter.utils import clear_recursively
from runtime.tum_except import ETUMSyntaxError
from interpreter.utils.params import expanse, eval_func_init
@@ -25,7 +25,6 @@ from interpreter.utils.version import (
from interpreter.test_items.test_item import TestItem
from interpreter.test_items.test_item_sleep import TestItemSleep
from interpreter.test_items.test_item_unittest import TestItemUnittestFile
from interpreter.test_items.test_item_pytest import TestItemPytestFile
from interpreter.test_items.test_item_cycle import TestItemCycle
from interpreter.test_items.test_item_runtime_plot import TestItemPlot
from interpreter.test_items.test_item_group import TestItemGroup
@@ -70,7 +69,6 @@ def _constants_init():
cst.TYPE_RUN.item_class = TestItemRun
cst.TYPE_SLEEP.item_class = TestItemSleep
cst.TYPE_UNITTEST.item_class = TestItemUnittestFile
cst.TYPE_PYTEST.item_class = TestItemPytestFile
cst.TYPE_VALUE_DLG.item_class = TestItemValueDialog
cst.TYPE_PARALLEL.item_class = TestItemParallel
cst.TYPE_PARALLEL_BRANCH.item_class = TestItemParallelBranch
@@ -91,7 +89,7 @@ def locate_report_file(rep_file):
def yamltodict(param_file, silent=True):
# load of the file
with open(param_file, "r") as fd:
dp = yaml_load(fd, param_file, YAML_BASE_LOADER)
dp = yaml_load(fd, param_file, yaml.Loader)
if dp is None:
tm.print_info(f"The YAML file '{param_file}' is empty.")
@@ -167,14 +165,11 @@ def env_init():
_constants_init()
def apply_overrides(defines, gui_defaults):
"""Push GUI defaults then CLI defines into the global dict.
Extracted from update_global so it can be called *before* eval_proc
starts: interpreter overrides (python_bin, lua_bin) must be visible
to bins.python_bin() on its first lookup, which happens during
eval_process_init.
def update_global(config_files, defines, gui_defaults, silent=False):
"""Global dict updated with the content of the config file and a dict provided.
this function returns the resulting dict.
"""
# GUI preferences applied first
for k, v in gui_defaults.items():
try:
val = ast.literal_eval(v)
@@ -182,6 +177,7 @@ def apply_overrides(defines, gui_defaults):
val = v
tm.setgd(k, val)
# Then command line defines
for k, v in defines.items():
try:
val = ast.literal_eval(v)
@@ -189,14 +185,6 @@ def apply_overrides(defines, gui_defaults):
val = v
tm.setgd(k, val)
def update_global(config_files, defines, gui_defaults, silent=False):
"""Global dict updated with the content of the config file and a dict provided.
this function returns the resulting dict.
"""
# GUI preferences applied first, then command line defines
apply_overrides(defines, gui_defaults)
# Then the configuration files
# load global dic before test item
_feed_gd_with_params(config_files, silent)

View File

@@ -1,54 +1,10 @@
import atexit
import os
import stat
import sys
import tempfile
from importlib import import_module
import interpreter.utils.settings as prefs
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 = {}
def repo_rev(path):

View File

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

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

@@ -3,7 +3,7 @@
-- =========================
local config = {
host = "0.0.0.0",
port = 0, -- 0 = OS-assigned; actual port is reported on stdout
port = 9000,
timeout = 60,
verbose = false,
}
@@ -76,10 +76,6 @@ server_sock:listen(1)
local ip, port = server_sock:getsockname()
utils.log("listening on %s:%d for %.1f secs", ip, port, config.timeout)
-- Announce the actual bound port so the parent connects only once we listen.
io.stdout:write("__TESTIUM_RPC_PORT__=" .. port .. "\n")
io.stdout:flush()
server_sock:settimeout(config.timeout) -- Prevents hanging on dead connections
-- Main Server Loop

View File

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

View File

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

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 423 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 588 B

View File

@@ -42,7 +42,6 @@
<file alias="exit.png">black/exit.png</file>
<file alias="terminal.png">black/terminal.png</file>
<file alias="python.png">black/python.png</file>
<file alias="pytest.png">black/pytest.png</file>
<file alias="lua.png">black/lua.png</file>
<file alias="verif.png">black/verif.png</file>
<file alias="view-refresh.png">black/view-refresh.png</file>
@@ -92,7 +91,6 @@
<file alias="exit.png">white/exit.png</file>
<file alias="terminal.png">white/terminal.png</file>
<file alias="python.png">white/python.png</file>
<file alias="pytest.png">white/pytest.png</file>
<file alias="lua.png">white/lua.png</file>
<file alias="verif.png">white/verif.png</file>
<file alias="view-refresh.png">white/view-refresh.png</file>
@@ -142,7 +140,6 @@
<file alias="exit.png">color/exit.png</file>
<file alias="terminal.png">color/terminal.png</file>
<file alias="python.png">color/python.png</file>
<file alias="pytest.png">color/pytest.png</file>
<file alias="lua.png">color/lua.png</file>
<file alias="verif.png">color/verif.png</file>
<file alias="view-refresh.png">color/view-refresh.png</file>

View File

@@ -1,6 +1,6 @@
# Resource object code (Python 3)
# Created by: object code
# Created by: The Resource Compiler for Qt version 6.11.1
# Created by: The Resource Compiler for Qt version 6.11.0
# WARNING! All changes made in this file will be lost!
from PySide6 import QtCore
@@ -1987,33 +1987,6 @@ h\xaa\x18\xc7\xe6\xc4!\x90#au{\x90\x9b\xfc\xed\
\xc1\x83\xbb\xe4\xff\xbbn\xdf\xb3\x9e\xf5\xac\xab\xf6\x17\xad\
\x1c\x1e\xee0\x17\x1bh\x00\x00\x00\x00IEND\xae\
B`\x82\
\x00\x00\x01\x8a\
\x89\
PNG\x0d\x0a\x1a\x0a\x00\x00\x00\x0dIHDR\x00\
\x00\x00@\x00\x00\x00@\x08\x04\x00\x00\x00\x00`\xb9U\
\x00\x00\x01QIDATx\x9c\xed\x98\xb1J\x03A\
\x10\x86\xbf[\x17\xb5P\x14\xecm|\x00M\xa1\x98J\
\xf0\x09\x04\xb1\xf4u\xec|\x13!\x8dX\x8aH:\x95\
\x18,\xd4\xceF\x826\x8a\xca%\x04qE\x8bul\
\xb2\xca\x06\x87x\xf3os\xc3\xce\xfc\xfb\xdf\xfe\xc3\x1e\
\xb7E@\x17Ny}L\x803\x0b\xb0&T\x863\
\x01(\xc3'3\xda\x8c\xc7\xe7\x0d\xaei2\x17\xe3m\
Ni1\x19\xe3Mn8\x13\xb5\xebt\xf2\x05\x1c\x89\
\x9c\x17\xa0\xc9t\x8c\x1f?\xe7\xbf\x04>\xf3\xca\xa1\xa8\
\xed%\xd9)\xb4\xbf\x86~\xe0\xec\x12\xbb\xccd\xb0\xf7\
\xd8\xe1 \x91\x13\x06\x8dF\xc8\xc5\xd5@~B\xe2\x1c\
8\xcex\xfb\x1f2\x14\x89\x1eX`6c\xf9.\x97\
\x84\xdc&\xac\x8b]jQ\xb2,\xba\xfe\x82'V\x19\
\x8bq\x9b\x92\xba\xa8=\xa1\x9f\x14\x19R\xa3/\x1c]\
\x0c\x84{\x11\xaf\x05BW\xc4+a\xea[\x07\xcc'\
\xd9Q?\x09\x9d\x09\xc0,P\x863\x01(\xc3\x0f\x99\
\xaf\xa4&\xa2\xce\xdf\x0bx\xe3|\xc4,p&\x00e\
\xf8l\x86}&\xe2\xf3\x83\x86\x80\xad\x11\xb7\xc0\x99\x00\
\xcc\x82\xaa[\xe0\x7f]Q\x13\x7fBw\x1a\x02n\xf9\
_\x16\xb8\xca\x0b\xf0\xc9\x1d\xd8\x139\x1f\x97RCF\
\xa1}I\xe5\x94\xd7\xc7\x048\xb3\x00kBe8\x13\
@\xd5-x\x07\x18V\xe7\xe7\xb7ki\xfd\x00\x00\x00\
\x00IEND\xaeB`\x82\
\x00\x00\x05\xbd\
\x89\
PNG\x0d\x0a\x1a\x0a\x00\x00\x00\x0dIHDR\x00\
@@ -7159,35 +7132,6 @@ h\xaa\x18\xc7\xe6\xc4!\x90#au{\x90\x9b\xfc\xed\
\xc1\x83\xbb\xe4\xff\xbbn\xdf\xb3\x9e\xf5\xac\xab\xf6\x17\xad\
\x1c\x1e\xee0\x17\x1bh\x00\x00\x00\x00IEND\xae\
B`\x82\
\x00\x00\x01\xa7\
\x89\
PNG\x0d\x0a\x1a\x0a\x00\x00\x00\x0dIHDR\x00\
\x00\x00@\x00\x00\x00@\x08\x06\x00\x00\x00\xaaiq\xde\
\x00\x00\x01nIDATx\x9c\xed\xda1k\x14Q\
\x10\x00\xe0\xcfqQ\x0b\x83\x82\xbd\x8d? \xa60\xc4\
J\xf0\x17\x08b\xe9\xdf\xb1\xf3\x9f\x08iB\xca \xc1\
N\x83\x86\x14jg#\xa2\x8d\xa2\x92\x84 *\x07'\
\xa4\xd8g\xe1\x9e\xbe\x93\x99\x0f\x8e\x83\x1dv\xe6\xed\xdc\
{\xf7\x96\xbb\xa5\x94RJ)\xa5\x94\x92\xd1\x99\x05\xe4\
\xd8\xc7\xb9\x91\xe3w\xf0\x1aOpe$~\x1f\xcf\xf0\
\x1c\x17F\xe2w\xf1\x06{\x8d\xba\xb7\xf1n\xe2\xd8\x0d\
S\x13\xe0q#\xcf\xd7\xf9\xfb\xac\x01+#\xf1O\xa7\
\xce\x1fk\xe0\x17|\xc3N\xa3\xee\xf1\x1f\x8e\xb7,j\
\x09\x5c\xc7C\x5c\xd2\xc7l\x06<\xc0v\xa7\xfa6\xf1\
\xa3\xf3\xeb\xd5\xd4\x8b\x88\x09\xe7\xee\xeao\xb7\xf7.p\
\x0d\x97\xf5q\x84\x97\xf3\x99\xd0u\x1b\xbc\xd9\x98I\xb3\
\xed\xed\x107\x1a\xdf\xf2\x07\xf8\x8c\x0d\x9cml\xaf\x87\
\xf3\xfcc\x9e\xe2\xc4\x128i\xac\xcf\xd5y\xfcC#\
~\xeb\xd4'9\x16_\xc7\xc5\xdf\xac\xff\xab\x8b\x18|\
H.$\x17\x92\x0b\xc9\x85\xe4Br!\xb9\x90\x5cH\
n\xb0\xdcf\xb7\xc2k\x8d\xd8\xe4_\x83\xfe\x87\x06|\
\xc7\x8b\xbfY $\x17\x92\x0b\xc9\x85\xe4\x86\xde\x03\xc0\
\x16\xce\x8f\x1c\xff\x98\xa5\x01\xf7z\x16\x0f\xc9\x85\xe4B\
r!\xb9\x90\x5cH.$7\xfc\x83\x1ak\x8d\x7f~\
\xdeK\xd2\x80\xb7\x96XH.$\x17\x92\x1b\x16\x90\xe3\
Q#\xcf\xaf\x87\xa0J)\xa5\x94RJ)\xa5X2\
?\x01\x1cBc5g\xeb\xf6\x81\x00\x00\x00\x00IE\
ND\xaeB`\x82\
\x00\x00\x05]\
\x89\
PNG\x0d\x0a\x1a\x0a\x00\x00\x00\x0dIHDR\x00\
@@ -12738,45 +12682,6 @@ h\xaa\x18\xc7\xe6\xc4!\x90#au{\x90\x9b\xfc\xed\
\xc1\x83\xbb\xe4\xff\xbbn\xdf\xb3\x9e\xf5\xac\xab\xf6\x17\xad\
\x1c\x1e\xee0\x17\x1bh\x00\x00\x00\x00IEND\xae\
B`\x82\
\x00\x00\x02L\
\x89\
PNG\x0d\x0a\x1a\x0a\x00\x00\x00\x0dIHDR\x00\
\x00\x00@\x00\x00\x00@\x08\x06\x00\x00\x00\xaaiq\xde\
\x00\x00\x02\x13IDATx\x9c\xed\xdaMK\x15a\
\x14\xc0\xf1\xffsf|)\x88\x8c\xb8\x06\xb5(\x88\x08\
\x92@\x17\xa1\xb6\x0a?A\x10-[\x05\x06~\x88\x12\
\x82v\xb5\xbf\xabh\xdf\xae\x97e\xa9\x18-*LL\
\xa5\x9dX\x11itMM\xaf^\xefs\x82\xbb\xbc3\
+\x07\x99\x0b\xe7\xfc6\x03s\x98s\x0e\x87\x99y\x86\
\x99\x01\xe7\x9cs\xce9\xe7\x9cE\xa1p\x86\xa7\xdf\xe6\
\x81\xee\xcc\xfe(7\xb9{\xee\xeb\xfb\x85d\x16\xf4t\
{X\x88w\x86\xaf\xf2q\xe3~\xff\x1c\x84\xde\xf6x\
\x22\xe1\xd6\xd6\x0bY\xd9\xfb\xd7\xf8\x94\xdbx\xd0\xb1\x0b\
\xcb\xbf\x7f\x16m?-\x9a\x00e\x8a\x10\xb2y\xd2\xb8\
\xdd\xda\x06\x9dE9\xd1\x1en\x0a\x1b\xadp\xd0)\xcd\
\x19\xe0~\xa3\xb1U\xab\xff98\xa6\x957ye\xa5\
\xabQ/\xdc\xbb\xe3\xf0\x97\xc0\xc4\xc4\xc4\xa0\xaa>\x06\
N\x964\xc8z\x8c\xf1Q\xb5Z}]\xca%\xa0\xaa\
\x0f\x801J$\x22O\x80B\x03\x90\xc3\x1e\x18B\x98\
\xa1d\xaa:S\xea*0>>~1I\x92>J\
\x10c\xdc\xadV\xab\xcb\xad\xdbp\xa9\xcb\xe0\xb3\xef\xa3\
h\xc8\x9eI{a\x8e{gw\xde}\xe1\x9a\xe4\xdc\
\xe5C\x0f\x0b#\x97\xd8\xfc;yf$45i\x8f\
\xd7\x13\x9d\xafL\xae\xef\xac^\xee\x1f\xcd+\xbb\x9d\xae\
}\x18Xd\xbf\xfce0\xea4hW6s\x18\x04\
\xe6E\xe5\x15P\xc9\x1c\xb6\x1bo\x00\xd3\x1a\xf5\xad\x06\
2\xcf\x01=$\xc3K\x03\x95\xa5\xe3M\x9d\xcd+\xdb\
\xcb\xa9\xf3P[-\xda\xbe`\x9c`\x9c`\x9c`\x9c\
`\x9c`\x9c`\x9c`\x9c`\x5cJ\x07\xbb\xb2\xb8\xbe\
\xb32P\x19\xca\x8b\xd5zk\x85\xdf\x06u\xfc\x00\x02\
D\x16\xd7?\x1fe\x0d\xc18\xc18\xc18\xc1\xb8\xb4\
\xec\x06P^\x86\xa0=\x99\xfd\x07\xcd\x9a\x89\x01\xf4=\
\x5c\xbb]f}\xc18\xc18\xc18\xc18\xc18\xc1\
8\xc1\xb8\xf4\xa8\x0bh3\x0e\xc5.2_~6\xbb\
\xf9\x85\x85\x01\x5c\x1f\xe4\x07\x1dL0N0N0.\
-\x9c!\xf0\x1c\xcd\xc9\xa3\xd2\xfa\x09\xca9\xe7\x9cs\
\xce9\xe7\x1c\x9d\xe7?.\xa1\x9a\x02A^,~\x00\
\x00\x00\x00IEND\xaeB`\x82\
\x00\x00\x06\x09\
\x89\
PNG\x0d\x0a\x1a\x0a\x00\x00\x00\x0dIHDR\x00\
@@ -16492,10 +16397,6 @@ qt_resource_name = b"\
\x00s\
\x00u\x00c\x00c\x00e\x00s\x00s\x00_\x00o\x00r\x00a\x00n\x00g\x00e\x00.\x00p\x00n\
\x00g\
\x00\x0a\
\x0c\xa8V\x07\
\x00p\
\x00y\x00t\x00e\x00s\x00t\x00.\x00p\x00n\x00g\
\x00\x08\
\x04\xd2YG\
\x00i\
@@ -16591,300 +16492,294 @@ qt_resource_name = b"\
qt_resource_struct = b"\
\x00\x00\x00\x00\x00\x02\x00\x00\x00\x03\x00\x00\x00\x01\
\x00\x00\x00\x00\x00\x00\x00\x00\
\x00\x00\x00\x10\x00\x02\x00\x00\x000\x00\x00\x00d\
\x00\x00\x00\x10\x00\x02\x00\x00\x00/\x00\x00\x00b\
\x00\x00\x00\x00\x00\x00\x00\x00\
\x00\x00\x00\x00\x00\x02\x00\x00\x000\x00\x00\x004\
\x00\x00\x00\x00\x00\x02\x00\x00\x00/\x00\x00\x003\
\x00\x00\x00\x00\x00\x00\x00\x00\
\x00\x00\x00 \x00\x02\x00\x00\x000\x00\x00\x00\x04\
\x00\x00\x00 \x00\x02\x00\x00\x00/\x00\x00\x00\x04\
\x00\x00\x00\x00\x00\x00\x00\x00\
\x00\x00\x02\xee\x00\x00\x00\x00\x00\x01\x00\x00\x7f\xa5\
\x00\x00\x01\x9b\xa3\xda\x0d#\
\x00\x00\x03\x8a\x00\x00\x00\x00\x00\x01\x00\x00\xfdh\
\x00\x00\x01\x9b\xa3\xda\x0d#\
\x00\x00\x02\xd4\x00\x00\x00\x00\x00\x01\x00\x00~\x17\
\x00\x00\x01\x9b\x97*\xf4\x03\
\x00\x00\x03p\x00\x00\x00\x00\x00\x01\x00\x00\xfb\xda\
\x00\x00\x01\x9b\x97*\xf4\x05\
\x00\x00\x00^\x00\x00\x00\x00\x00\x01\x00\x00\x09\xb0\
\x00\x00\x01\x9b\xa3\xda\x0d$\
\x00\x00\x01\x9b\x97*\xf4\x06\
\x00\x00\x00\xa4\x00\x00\x00\x00\x00\x01\x00\x00\x1ds\
\x00\x00\x01\x9b\xa3\xda\x0d$\
\x00\x00\x04\x86\x00\x00\x00\x00\x00\x01\x00\x01!\x98\
\x00\x00\x01\x9b\xa3\xda\x0d#\
\x00\x00\x01\x9b\x97*\xf4\x05\
\x00\x00\x04l\x00\x00\x00\x00\x00\x01\x00\x01 \x0a\
\x00\x00\x01\x9b\x97*\xf4\x03\
\x00\x00\x01\x90\x00\x00\x00\x00\x00\x01\x00\x00O\x0d\
\x00\x00\x01\x9b\xa3\xda\x0d$\
\x00\x00\x01\x9b\x97*\xf4\x05\
\x00\x00\x02\x02\x00\x00\x00\x00\x00\x01\x00\x00`\x90\
\x00\x00\x01\x9b\xa3\xda\x0d#\
\x00\x00\x03,\x00\x00\x00\x00\x00\x01\x00\x00\x86\x81\
\x00\x00\x01\x9bi\x96\x0e\x1c\
\x00\x00\x04\xbe\x00\x00\x00\x00\x00\x01\x00\x01(\x09\
\x00\x00\x01\x9b\xa3\xda\x0d#\
\x00\x00\x02\xd8\x00\x00\x00\x00\x00\x01\x00\x00y\xe4\
\x00\x00\x01\x9b\xa3\xda\x0d#\
\x00\x00\x01L\x00\x00\x00\x00\x00\x01\x00\x00:\x88\
\x00\x00\x01\x9b\xa3\xda\x0d$\
\x00\x00\x03\x16\x00\x00\x00\x00\x00\x01\x00\x00\x82\xf0\
\x00\x00\x01\x9b\xa3\xda\x0d$\
\x00\x00\x05*\x00\x00\x00\x00\x00\x01\x00\x01=\x16\
\x00\x00\x01\x9b\xa3\xda\x0d#\
\x00\x00\x03\x9e\x00\x00\x00\x00\x00\x01\x00\x01\x03\x10\
\x00\x00\x01\x9b\xa3\xda\x0d#\
\x00\x00\x02\x16\x00\x00\x00\x00\x00\x01\x00\x00e#\
\x00\x00\x01\x9d\xed\x19\x07\x82\
\x00\x00\x01\x16\x00\x00\x00\x00\x00\x01\x00\x002\xd8\
\x00\x00\x01\x9b\xa3\xda\x0d#\
\x00\x00\x05\x12\x00\x00\x00\x00\x00\x01\x00\x0181\
\x00\x00\x01\x9b\xa3\xda\x0d#\
\x00\x00\x01\xec\x00\x00\x00\x00\x00\x01\x00\x00\x5c\x04\
\x00\x00\x01\x9b\x8ac\x97y\
\x00\x00\x01\xae\x00\x00\x00\x00\x00\x01\x00\x00T\x8d\
\x00\x00\x01\x9b\xa3\xda\x0d$\
\x00\x00\x000\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\
\x00\x00\x01\x9bi\x96\x0e\x13\
\x00\x00\x04\xfa\x00\x00\x00\x00\x00\x01\x00\x011\xbb\
\x00\x00\x01\x9b\xa3\xda\x0d#\
\x00\x00\x00F\x00\x00\x00\x00\x00\x01\x00\x00\x07&\
\x00\x00\x01\x9b\xa3\xda\x0d$\
\x00\x00\x00\x90\x00\x00\x00\x00\x00\x01\x00\x00\x15\xcd\
\x00\x00\x01\x9bi\x96\x0e\x13\
\x00\x00\x04F\x00\x00\x00\x00\x00\x01\x00\x01\x1b\x19\
\x00\x00\x01\x9b\xa3\xda\x0d#\
\x00\x00\x02|\x00\x00\x00\x00\x00\x01\x00\x00p\x93\
\x00\x00\x01\x9b\xa3\xda\x0d$\
\x00\x00\x04\x0e\x00\x00\x00\x00\x00\x01\x00\x01\x12\xb7\
\x00\x00\x01\x9b\xa3\xda\x0d#\
\x00\x00\x03R\x00\x00\x00\x00\x00\x01\x00\x00\xf7L\
\x00\x00\x01\x9b\xa3\xda\x0d$\
\x00\x00\x01f\x00\x00\x00\x00\x00\x01\x00\x00>\x99\
\x00\x00\x01\x9d\xed\x19\x07\x82\
\x00\x00\x03\xf6\x00\x00\x00\x00\x00\x01\x00\x01\x0d\xd3\
\x00\x00\x01\x9b\xa3\xda\x0d#\
\x00\x00\x00z\x00\x00\x00\x00\x00\x01\x00\x00\x0fl\
\x00\x00\x01\x9b\xa3\xe0\x22\xcf\
\x00\x00\x00\xe8\x00\x00\x00\x00\x00\x01\x00\x00+s\
\x00\x00\x01\x9b\xa3\xda\x0d#\
\x00\x00\x03l\x00\x00\x00\x00\x00\x01\x00\x00\xf9\xd8\
\x00\x00\x01\x9b\xa3\xda\x0d$\
\x00\x00\x00\xba\x00\x00\x00\x00\x00\x01\x00\x00!6\
\x00\x00\x01\x9b\xa3\xda\x0d$\
\x00\x00\x04\xa6\x00\x00\x00\x00\x00\x01\x00\x01%u\
\x00\x00\x01\x9b\x8ab\x88n\
\x00\x00\x00\xd0\x00\x00\x00\x00\x00\x01\x00\x00#\xa7\
\x00\x00\x01\x9bi\x96\x0e\x13\
\x00\x00\x04\xe2\x00\x00\x00\x00\x00\x01\x00\x01,\x15\
\x00\x00\x01\x9b\xa3\xda\x0d$\
\x00\x00\x02^\x00\x00\x00\x00\x00\x01\x00\x00l\xf5\
\x00\x00\x01\x9b\xa3\xda\x0d$\
\x00\x00\x01z\x00\x00\x00\x00\x00\x01\x00\x00H\xe3\
\x00\x00\x01\x9b\xa3\xda\x0d#\
\x00\x00\x02B\x00\x00\x00\x00\x00\x01\x00\x00f\x1c\
\x00\x00\x01\x9b\x8acl\xeb\
\x00\x00\x03\xb6\x00\x00\x00\x00\x00\x01\x00\x01\x08\x11\
\x00\x00\x01\x9d\xed\x19\x07\x82\
\x00\x00\x01\xd4\x00\x00\x00\x00\x00\x01\x00\x00Y~\
\x00\x00\x01\x9b\xa3\xda\x0d$\
\x00\x00\x01\x9b\x97*\xf4\x05\
\x00\x00\x03\x12\x00\x00\x00\x00\x00\x01\x00\x00\x84\xf3\
\x00\x00\x01\x9b\x97*\xf4\x02\
\x00\x00\x04\xa4\x00\x00\x00\x00\x00\x01\x00\x01&{\
\x00\x00\x01\x9b\x97*\xf4\x03\
\x00\x00\x02\xbe\x00\x00\x00\x00\x00\x01\x00\x00xV\
\x00\x00\x01\x9e\xc7D\xd9\x84\
\x00\x00\x03\xd4\x00\x00\x00\x00\x00\x01\x00\x01\x09\xf5\
\x00\x00\x01\x9b\xa3\xda\x0d#\
\x00\x00\x01\x9b\x97*\xf4\x04\
\x00\x00\x01L\x00\x00\x00\x00\x00\x01\x00\x00:\x88\
\x00\x00\x01\x9b\x97*\xf4\x05\
\x00\x00\x02\xfc\x00\x00\x00\x00\x00\x01\x00\x00\x81b\
\x00\x00\x01\x9b\x97*\xf4\x05\
\x00\x00\x05\x10\x00\x00\x00\x00\x00\x01\x00\x01;\x88\
\x00\x00\x01\x9b\x97*\xf4\x04\
\x00\x00\x03\x84\x00\x00\x00\x00\x00\x01\x00\x01\x01\x82\
\x00\x00\x01\x9b\x97*\xf4\x03\
\x00\x00\x02\x16\x00\x00\x00\x00\x00\x01\x00\x00e#\
\x00\x00\x01\x9d\xe0StG\
\x00\x00\x01\x16\x00\x00\x00\x00\x00\x01\x00\x002\xd8\
\x00\x00\x01\x9b\x97*\xf4\x04\
\x00\x00\x04\xf8\x00\x00\x00\x00\x00\x01\x00\x016\xa3\
\x00\x00\x01\x9b\x97*\xf4\x04\
\x00\x00\x01\xec\x00\x00\x00\x00\x00\x01\x00\x00\x5c\x04\
\x00\x00\x01\x9b\x97*\xf3\xfe\
\x00\x00\x01\xae\x00\x00\x00\x00\x00\x01\x00\x00T\x8d\
\x00\x00\x01\x9b\x97*\xf4\x06\
\x00\x00\x000\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\
\x00\x00\x01\x9b\x97*\xf3\xfe\
\x00\x00\x04\xe0\x00\x00\x00\x00\x00\x01\x00\x010-\
\x00\x00\x01\x9b\x97*\xf4\x04\
\x00\x00\x00F\x00\x00\x00\x00\x00\x01\x00\x00\x07&\
\x00\x00\x01\x9b\x97*\xf4\x06\
\x00\x00\x00\x90\x00\x00\x00\x00\x00\x01\x00\x00\x15\xcd\
\x00\x00\x01\x9b\x97*\xf3\xfe\
\x00\x00\x04,\x00\x00\x00\x00\x00\x01\x00\x01\x19\x8b\
\x00\x00\x01\x9b\x97*\xf4\x04\
\x00\x00\x02|\x00\x00\x00\x00\x00\x01\x00\x00p\x93\
\x00\x00\x01\x9b\x97*\xf4\x06\
\x00\x00\x03\xf4\x00\x00\x00\x00\x00\x01\x00\x01\x11)\
\x00\x00\x01\x9b\x97*\xf4\x03\
\x00\x00\x038\x00\x00\x00\x00\x00\x01\x00\x00\xf5\xbe\
\x00\x00\x01\x9b\x97*\xf4\x05\
\x00\x00\x01f\x00\x00\x00\x00\x00\x01\x00\x00>\x99\
\x00\x00\x01\x9d\xcf\xc3\xa3\x15\
\x00\x00\x03\xdc\x00\x00\x00\x00\x00\x01\x00\x01\x0cE\
\x00\x00\x01\x9b\x97*\xf4\x03\
\x00\x00\x00z\x00\x00\x00\x00\x00\x01\x00\x00\x0fl\
\x00\x00\x01\x9b\xc5\xbd\x83\x1b\
\x00\x00\x00\xe8\x00\x00\x00\x00\x00\x01\x00\x00+s\
\x00\x00\x01\x9b\x97*\xf4\x03\
\x00\x00\x03R\x00\x00\x00\x00\x00\x01\x00\x00\xf8J\
\x00\x00\x01\x9b\x97*\xf4\x06\
\x00\x00\x00\xba\x00\x00\x00\x00\x00\x01\x00\x00!6\
\x00\x00\x01\x9b\x97*\xf4\x06\
\x00\x00\x04\x8c\x00\x00\x00\x00\x00\x01\x00\x01#\xe7\
\x00\x00\x01\x9b\x97*\xf3\xe2\
\x00\x00\x00\xd0\x00\x00\x00\x00\x00\x01\x00\x00#\xa7\
\x00\x00\x01\x9b\x97*\xf3\xfe\
\x00\x00\x04\xc8\x00\x00\x00\x00\x00\x01\x00\x01*\x87\
\x00\x00\x01\x9b\x97*\xf4\x06\
\x00\x00\x02^\x00\x00\x00\x00\x00\x01\x00\x00l\xf5\
\x00\x00\x01\x9b\x97*\xf4\x06\
\x00\x00\x01z\x00\x00\x00\x00\x00\x01\x00\x00H\xe3\
\x00\x00\x01\x9b\x97*\xf4\x04\
\x00\x00\x02B\x00\x00\x00\x00\x00\x01\x00\x00f\x1c\
\x00\x00\x01\x9b\x97*\xf3\xfe\
\x00\x00\x03\x9c\x00\x00\x00\x00\x00\x01\x00\x01\x06\x83\
\x00\x00\x01\x9d\xe0G\xedW\
\x00\x00\x01\xd4\x00\x00\x00\x00\x00\x01\x00\x00Y~\
\x00\x00\x01\x9b\x97*\xf4\x05\
\x00\x00\x03\xba\x00\x00\x00\x00\x00\x01\x00\x01\x08g\
\x00\x00\x01\x9b\x97*\xf4\x03\
\x00\x00\x01\x02\x00\x00\x00\x00\x00\x01\x00\x00.\x86\
\x00\x00\x01\x9b\xa3\xda\x0d#\
\x00\x00\x01\x9b\x97*\xf4\x04\
\x00\x00\x016\x00\x00\x00\x00\x00\x01\x00\x006\xc0\
\x00\x00\x01\x9b\xa3\xda\x0d#\
\x00\x00\x01\x9b\x97*\xf4\x03\
\x00\x00\x02\x94\x00\x00\x00\x00\x00\x01\x00\x00s\x0e\
\x00\x00\x01\x9b\x8a`\x8cv\
\x00\x00\x04,\x00\x00\x00\x00\x00\x01\x00\x01\x15\xe2\
\x00\x00\x01\x9b\xa3\xda\x0d$\
\x00\x00\x04^\x00\x00\x00\x00\x00\x01\x00\x01\x1e&\
\x00\x00\x01\x9b\xa3\xda\x0d#\
\x00\x00\x02\xee\x00\x00\x00\x00\x00\x01\x00\x03\x13D\
\x00\x00\x01\x9b\x8a\x14\x90]\
\x00\x00\x03\x8a\x00\x00\x00\x00\x00\x01\x00\x03\x95W\
\x00\x00\x01\x9b\x8a\x15\xa3\xbc\
\x00\x00\x00^\x00\x00\x00\x00\x00\x01\x00\x02\x82\x9f\
\x00\x00\x01\x9b\x8a\x1e\x13:\
\x00\x00\x00\xa4\x00\x00\x00\x00\x00\x01\x00\x02\x99\x12\
\x00\x00\x01\x9b\x8a\x15\xeby\
\x00\x00\x04\x86\x00\x00\x00\x00\x00\x01\x00\x03\xc4~\
\x00\x00\x01\x9b\x8a\x15\x11\xa3\
\x00\x00\x01\x90\x00\x00\x00\x00\x00\x01\x00\x02\xd9\xaa\
\x00\x00\x01\x9b\x8a\x16/\xbc\
\x00\x00\x02\x02\x00\x00\x00\x00\x00\x01\x00\x02\xea\xec\
\x00\x00\x01\x9b\x8a$\x11*\
\x00\x00\x03,\x00\x00\x00\x00\x00\x01\x00\x03\x1de\
\x00\x00\x01\x9bi\x96\x0e\x1c\
\x00\x00\x04\xbe\x00\x00\x00\x00\x00\x01\x00\x03\xcc\xdc\
\x00\x00\x01\x9b\x8a\x13\xa4\xaa\
\x00\x00\x02\xd8\x00\x00\x00\x00\x00\x01\x00\x03\x0d7\
\x00\x00\x01\x9b\x8a\x12g\xa0\
\x00\x00\x01L\x00\x00\x00\x00\x00\x01\x00\x02\xb5\xe4\
\x00\x00\x01\x9b\x8a\x16Qr\
\x00\x00\x03\x16\x00\x00\x00\x00\x00\x01\x00\x03\x18\xa6\
\x00\x00\x01\x9bi\x96\x0e\x12\
\x00\x00\x05*\x00\x00\x00\x00\x00\x01\x00\x03\xe7Y\
\x00\x00\x01\x9b\x8a\x12\xf3 \
\x00\x00\x03\x9e\x00\x00\x00\x00\x00\x01\x00\x03\x98\x92\
\x00\x00\x01\x9b\x8a\x153f\
\x00\x00\x02\x16\x00\x00\x00\x00\x00\x01\x00\x02\xf4\xd4\
\x00\x00\x01\x9d\xed\x19\x07|\
\x00\x00\x01\x16\x00\x00\x00\x00\x00\x01\x00\x02\xafW\
\x00\x00\x01\x9b\x8a\x13\x1e\xe7\
\x00\x00\x05\x12\x00\x00\x00\x00\x00\x01\x00\x03\xe2\x9e\
\x00\x00\x01\x9b\x8a\x12\x93}\
\x00\x00\x01\xec\x00\x00\x00\x00\x00\x01\x00\x02\xe6`\
\x00\x00\x01\x9b\x8ac\x97y\
\x00\x00\x01\xae\x00\x00\x00\x00\x00\x01\x00\x02\xdd~\
\x00\x00\x01\x9b\x8a\x16\xf8\x01\
\x00\x00\x000\x00\x00\x00\x00\x00\x01\x00\x02w\x81\
\x00\x00\x01\x9bi\x96\x0e\x13\
\x00\x00\x04\xfa\x00\x00\x00\x00\x00\x01\x00\x03\xd91\
\x00\x00\x01\x9b\x8a\x13\x7f\x8d\
\x00\x00\x00F\x00\x00\x00\x00\x00\x01\x00\x02~\xa7\
\x00\x00\x01\x9bi\x96\x0e\x12\
\x00\x00\x00\x90\x00\x00\x00\x00\x00\x01\x00\x02\x91l\
\x00\x00\x01\x9bi\x96\x0e\x13\
\x00\x00\x04F\x00\x00\x00\x00\x00\x01\x00\x03\xbb\xfa\
\x00\x00\x01\x9b\x8a\x15|\xe4\
\x00\x00\x02|\x00\x00\x00\x00\x00\x01\x00\x03\x00\xd7\
\x00\x00\x01\x9b\x8a\x16\xd8\x95\
\x00\x00\x04\x0e\x00\x00\x00\x00\x00\x01\x00\x03\xafO\
\x00\x00\x01\x9b\x8a\x14\xb4l\
\x00\x00\x03R\x00\x00\x00\x00\x00\x01\x00\x03\x8e0\
\x00\x00\x01\x9bi\x96\x0e\x12\
\x00\x00\x01f\x00\x00\x00\x00\x00\x01\x00\x02\xbar\
\x00\x00\x01\x9d\xed\x19\x07|\
\x00\x00\x03\xf6\x00\x00\x00\x00\x00\x01\x00\x03\xa6\xd6\
\x00\x00\x01\x9b\x8a\x14\xdb\xaa\
\x00\x00\x00z\x00\x00\x00\x00\x00\x01\x00\x02\x87`\
\x00\x00\x01\x9b\x8a#\x9d\xb9\
\x00\x00\x00\xe8\x00\x00\x00\x00\x00\x01\x00\x02\xaa\xd6\
\x00\x00\x01\x9b\x8a\x13\xef\xba\
\x00\x00\x03l\x00\x00\x00\x00\x00\x01\x00\x03\x90\xa6\
\x00\x00\x01\x9b\x8a\x16\xb6\x00\
\x00\x00\x00\xba\x00\x00\x00\x00\x00\x01\x00\x02\xa0\xaf\
\x00\x00\x01\x9bi\x96\x0e\x12\
\x00\x00\x04\xa6\x00\x00\x00\x00\x00\x01\x00\x03\xcaH\
\x00\x00\x01\x9b\x8ab\x88n\
\x00\x00\x00\xd0\x00\x00\x00\x00\x00\x01\x00\x02\xa3\x0a\
\x00\x00\x01\x9bi\x96\x0e\x13\
\x00\x00\x04\xe2\x00\x00\x00\x00\x00\x01\x00\x03\xd0U\
\x00\x00\x01\x9b\x8a\x16\x95H\
\x00\x00\x02^\x00\x00\x00\x00\x00\x01\x00\x02\xfc\xea\
\x00\x00\x01\x9b\x8a\x16s\xd0\
\x00\x00\x01z\x00\x00\x00\x00\x00\x01\x00\x02\xd0k\
\x00\x00\x01\x9b\x8a\x12\xc8}\
\x00\x00\x02B\x00\x00\x00\x00\x00\x01\x00\x02\xf6\x11\
\x00\x00\x01\x9b\x8acl\xeb\
\x00\x00\x03\xb6\x00\x00\x00\x00\x00\x01\x00\x03\x9b\xc3\
\x00\x00\x01\x9d\xed\x19\x07|\
\x00\x00\x01\xd4\x00\x00\x00\x00\x00\x01\x00\x02\xe3\xed\
\x00\x00\x01\x9bi\x96\x0e\x12\
\x00\x00\x02\xbe\x00\x00\x00\x00\x00\x01\x00\x03\x0a\xe7\
\x00\x00\x01\x9e\xc7D\xd9~\
\x00\x00\x03\xd4\x00\x00\x00\x00\x00\x01\x00\x03\xa0\x7f\
\x00\x00\x01\x9b\x8a\x14@&\
\x00\x00\x01\x02\x00\x00\x00\x00\x00\x01\x00\x02\xad\x99\
\x00\x00\x01\x9b\x8a\x13M\xe4\
\x00\x00\x016\x00\x00\x00\x00\x00\x01\x00\x02\xb2\x85\
\x00\x00\x01\x9b\x8a\x14\x14\xa7\
\x00\x00\x02\x94\x00\x00\x00\x00\x00\x01\x00\x03\x05\x9f\
\x00\x00\x01\x9b\x8a`\x8cv\
\x00\x00\x04,\x00\x00\x00\x00\x00\x01\x00\x03\xb1\x88\
\x00\x00\x01\x9b\x8a#\xd7z\
\x00\x00\x04^\x00\x00\x00\x00\x00\x01\x00\x03\xbe\xe8\
\x00\x00\x01\x9b\x8a\x14h\xdc\
\x00\x00\x02\xee\x00\x00\x00\x00\x00\x01\x00\x01\xbb\xf0\
\x00\x00\x01\x9b\x8aR\xb8\x17\
\x00\x00\x03\x8a\x00\x00\x00\x00\x00\x01\x00\x028J\
\x00\x00\x01\x9b\x8aT\xb7\x8e\
\x00\x00\x00^\x00\x00\x00\x00\x00\x01\x00\x01J\xe0\
\x00\x00\x01\x9b\x8aU\xdfw\
\x00\x00\x00\xa4\x00\x00\x00\x00\x00\x01\x00\x01^0\
\x00\x00\x01\x9b\x8aUX\xd5\
\x00\x00\x04\x86\x00\x00\x00\x00\x00\x01\x00\x02Y\xa1\
\x00\x00\x01\x9b\x8aREh\
\x00\x00\x01\x90\x00\x00\x00\x00\x00\x01\x00\x01\x8d\xde\
\x00\x00\x01\x9b\x8aU\x93\xcd\
\x00\x00\x02\x02\x00\x00\x00\x00\x00\x01\x00\x01\x9eP\
\x00\x00\x01\x9b\x8aT\xdc/\
\x00\x00\x03,\x00\x00\x00\x00\x00\x01\x00\x01\xc1\xfa\
\x00\x00\x01\x9bi\x96\x0e\x1c\
\x00\x00\x04\xbe\x00\x00\x00\x00\x00\x01\x00\x02_\x9d\
\x00\x00\x01\x9b\x8aS^\xe7\
\x00\x00\x02\xd8\x00\x00\x00\x00\x00\x01\x00\x01\xb6\x8f\
\x00\x00\x01\x9b\x8aTJ\xe5\
\x00\x00\x01L\x00\x00\x00\x00\x00\x01\x00\x01x^\
\x00\x00\x01\x9b\x8aU\xc2\xd1\
\x00\x00\x03\x16\x00\x00\x00\x00\x00\x01\x00\x01\xbe\xde\
\x00\x00\x01\x9b\x8aU\x00b\
\x00\x00\x05*\x00\x00\x00\x00\x00\x01\x00\x02sC\
\x00\x00\x01\x9b\x8aS\xebC\
\x00\x00\x03\x9e\x00\x00\x00\x00\x00\x01\x00\x02=s\
\x00\x00\x01\x9b\x8aR\x1a\xcf\
\x00\x00\x02\x16\x00\x00\x00\x00\x00\x01\x00\x01\xa2`\
\x00\x00\x01\x9d\xed\x19\x07|\
\x00\x00\x01\x16\x00\x00\x00\x00\x00\x01\x00\x01q\xa0\
\x00\x00\x01\x9b\x8aS\xc7\x80\
\x00\x00\x05\x12\x00\x00\x00\x00\x00\x01\x00\x02n\xad\
\x00\x00\x01\x9b\x8aT/\x12\
\x00\x00\x01\xec\x00\x00\x00\x00\x00\x01\x00\x01\x99\xc4\
\x00\x00\x01\x9b\x8ac\x97y\
\x00\x00\x01\xae\x00\x00\x00\x00\x00\x01\x00\x01\x92\xfb\
\x00\x00\x01\x9b\x8aV\xbbi\
\x00\x00\x000\x00\x00\x00\x00\x00\x01\x00\x01A\xb2\
\x00\x00\x01\x9bi\x96\x0e\x13\
\x00\x00\x04\xfa\x00\x00\x00\x00\x00\x01\x00\x02h\x92\
\x00\x00\x01\x9b\x8aS}\xfc\
\x00\x00\x00F\x00\x00\x00\x00\x00\x01\x00\x01H\xd8\
\x00\x00\x01\x9b\x8aV:\x86\
\x00\x00\x00\x90\x00\x00\x00\x00\x00\x01\x00\x01V\x8a\
\x00\x00\x01\x9bi\x96\x0e\x13\
\x00\x00\x04F\x00\x00\x00\x00\x00\x01\x00\x02TD\
\x00\x00\x01\x9b\x8aT\x92U\
\x00\x00\x02|\x00\x00\x00\x00\x00\x01\x00\x01\xad\x9c\
\x00\x00\x01\x9b\x8aV\x99B\
\x00\x00\x04\x0e\x00\x00\x00\x00\x00\x01\x00\x02L\xb4\
\x00\x00\x01\x9b\x8aR\x903\
\x00\x00\x03R\x00\x00\x00\x00\x00\x01\x00\x022\xc5\
\x00\x00\x01\x9b\x8aU<\xbc\
\x00\x00\x01f\x00\x00\x00\x00\x00\x01\x00\x01|\x1a\
\x00\x00\x01\x9d\xed\x19\x07|\
\x00\x00\x03\xf6\x00\x00\x00\x00\x00\x01\x00\x02H\x07\
\x00\x00\x01\x9b\x8aRh\x0f\
\x00\x00\x00z\x00\x00\x00\x00\x00\x01\x00\x01PH\
\x00\x00\x01\x9b\xa3\xdek\xc5\
\x00\x00\x00\xe8\x00\x00\x00\x00\x00\x01\x00\x01k$\
\x00\x00\x01\x9b\x8aS@S\
\x00\x00\x03l\x00\x00\x00\x00\x00\x01\x00\x025\x0c\
\x00\x00\x01\x9b\x8aVw{\
\x00\x00\x00\xba\x00\x00\x00\x00\x00\x01\x00\x01ab\
\x00\x00\x01\x9b\x8aVZ\xb0\
\x00\x00\x04\xa6\x00\x00\x00\x00\x00\x01\x00\x02]\x09\
\x00\x00\x01\x9b\x8ab\x88n\
\x00\x00\x00\xd0\x00\x00\x00\x00\x00\x01\x00\x01cX\
\x00\x00\x01\x9bi\x96\x0e\x13\
\x00\x00\x04\xe2\x00\x00\x00\x00\x00\x01\x00\x02cN\
\x00\x00\x01\x9b\x8aV\x1c\x17\
\x00\x00\x02^\x00\x00\x00\x00\x00\x01\x00\x01\xaa2\
\x00\x00\x01\x9b\x8aU\xfd\x14\
\x00\x00\x01z\x00\x00\x00\x00\x00\x01\x00\x01\x87\xfd\
\x00\x00\x01\x9b\x8aT\x10\x07\
\x00\x00\x02B\x00\x00\x00\x00\x00\x01\x00\x01\xa3Y\
\x00\x00\x01\x9b\x8acl\xeb\
\x00\x00\x03\xb6\x00\x00\x00\x00\x00\x01\x00\x02B\x0e\
\x00\x00\x01\x9d\xed\x19\x07|\
\x00\x00\x01\xd4\x00\x00\x00\x00\x00\x01\x00\x01\x97\x8a\
\x00\x00\x01\x9b\x8aU\x1c\xe9\
\x00\x00\x02\xbe\x00\x00\x00\x00\x00\x01\x00\x01\xb4\xe4\
\x00\x00\x01\x9e\xc7D\xd9\x83\
\x00\x00\x03\xd4\x00\x00\x00\x00\x00\x01\x00\x02D\xa0\
\x00\x00\x01\x9b\x8aR\xfd@\
\x00\x00\x01\x02\x00\x00\x00\x00\x00\x01\x00\x01m\xc2\
\x00\x00\x01\x9b\x8aS\x9cE\
\x00\x00\x016\x00\x00\x00\x00\x00\x01\x00\x01u\x18\
\x00\x00\x01\x9b\x8aS\x1fV\
\x00\x00\x02\x94\x00\x00\x00\x00\x00\x01\x00\x01\xaf\x9c\
\x00\x00\x01\x9b\x8a`\x8cv\
\x00\x00\x04,\x00\x00\x00\x00\x00\x01\x00\x02Or\
\x00\x00\x01\x9b\x8aUv\xf5\
\x00\x00\x04^\x00\x00\x00\x00\x00\x01\x00\x02V\xab\
\x00\x00\x01\x9b\x8aR\xd9\xb9\
\x00\x00\x01\x9b\x97*\xf3\xfe\
\x00\x00\x04\x12\x00\x00\x00\x00\x00\x01\x00\x01\x14T\
\x00\x00\x01\x9b\x97*\xf4\x05\
\x00\x00\x04D\x00\x00\x00\x00\x00\x01\x00\x01\x1c\x98\
\x00\x00\x01\x9b\x97*\xf4\x03\
\x00\x00\x02\xd4\x00\x00\x00\x00\x00\x01\x00\x03\x0d\xbb\
\x00\x00\x01\x9b\x97*\xf3\xe4\
\x00\x00\x03p\x00\x00\x00\x00\x00\x01\x00\x03\x8f\xce\
\x00\x00\x01\x9b\x97*\xf3\xe4\
\x00\x00\x00^\x00\x00\x00\x00\x00\x01\x00\x02\x7ff\
\x00\x00\x01\x9b\x97*\xf3\xe5\
\x00\x00\x00\xa4\x00\x00\x00\x00\x00\x01\x00\x02\x95\xd9\
\x00\x00\x01\x9b\x97*\xf3\xe5\
\x00\x00\x04l\x00\x00\x00\x00\x00\x01\x00\x03\xbe\xf5\
\x00\x00\x01\x9b\x97*\xf3\xe4\
\x00\x00\x01\x90\x00\x00\x00\x00\x00\x01\x00\x02\xd6q\
\x00\x00\x01\x9b\x97*\xf3\xe5\
\x00\x00\x02\x02\x00\x00\x00\x00\x00\x01\x00\x02\xe7\xb3\
\x00\x00\x01\x9b\x97*\xf3\xe4\
\x00\x00\x03\x12\x00\x00\x00\x00\x00\x01\x00\x03\x17\xdc\
\x00\x00\x01\x9b\x97*\xf4\x02\
\x00\x00\x04\xa4\x00\x00\x00\x00\x00\x01\x00\x03\xc7S\
\x00\x00\x01\x9b\x97*\xf3\xe4\
\x00\x00\x02\xbe\x00\x00\x00\x00\x00\x01\x00\x03\x07\xae\
\x00\x00\x01\x9b\x97*\xf3\xe4\
\x00\x00\x01L\x00\x00\x00\x00\x00\x01\x00\x02\xb2\xab\
\x00\x00\x01\x9b\x97*\xf3\xe5\
\x00\x00\x02\xfc\x00\x00\x00\x00\x00\x01\x00\x03\x13\x1d\
\x00\x00\x01\x9b\x97*\xf3\xe4\
\x00\x00\x05\x10\x00\x00\x00\x00\x00\x01\x00\x03\xe1\xd0\
\x00\x00\x01\x9b\x97*\xf3\xe4\
\x00\x00\x03\x84\x00\x00\x00\x00\x00\x01\x00\x03\x93\x09\
\x00\x00\x01\x9b\x97*\xf3\xe3\
\x00\x00\x02\x16\x00\x00\x00\x00\x00\x01\x00\x02\xf1\x9b\
\x00\x00\x01\x9d\xe0StF\
\x00\x00\x01\x16\x00\x00\x00\x00\x00\x01\x00\x02\xac\x1e\
\x00\x00\x01\x9b\x97*\xf3\xe4\
\x00\x00\x04\xf8\x00\x00\x00\x00\x00\x01\x00\x03\xdd\x15\
\x00\x00\x01\x9b\x97*\xf3\xe4\
\x00\x00\x01\xec\x00\x00\x00\x00\x00\x01\x00\x02\xe3'\
\x00\x00\x01\x9b\x97*\xf3\xfe\
\x00\x00\x01\xae\x00\x00\x00\x00\x00\x01\x00\x02\xdaE\
\x00\x00\x01\x9b\x97*\xf3\xe5\
\x00\x00\x000\x00\x00\x00\x00\x00\x01\x00\x02tH\
\x00\x00\x01\x9b\x97*\xf3\xfe\
\x00\x00\x04\xe0\x00\x00\x00\x00\x00\x01\x00\x03\xd3\xa8\
\x00\x00\x01\x9b\x97*\xf3\xe4\
\x00\x00\x00F\x00\x00\x00\x00\x00\x01\x00\x02{n\
\x00\x00\x01\x9b\x97*\xf3\xe5\
\x00\x00\x00\x90\x00\x00\x00\x00\x00\x01\x00\x02\x8e3\
\x00\x00\x01\x9b\x97*\xf3\xfe\
\x00\x00\x04,\x00\x00\x00\x00\x00\x01\x00\x03\xb6q\
\x00\x00\x01\x9b\x97*\xf3\xe4\
\x00\x00\x02|\x00\x00\x00\x00\x00\x01\x00\x02\xfd\x9e\
\x00\x00\x01\x9b\x97*\xf3\xe5\
\x00\x00\x03\xf4\x00\x00\x00\x00\x00\x01\x00\x03\xa9\xc6\
\x00\x00\x01\x9b\x97*\xf3\xe4\
\x00\x00\x038\x00\x00\x00\x00\x00\x01\x00\x03\x88\xa7\
\x00\x00\x01\x9b\x97*\xf3\xe4\
\x00\x00\x01f\x00\x00\x00\x00\x00\x01\x00\x02\xb79\
\x00\x00\x01\x9d\xcf\xc3\xa3\x0d\
\x00\x00\x03\xdc\x00\x00\x00\x00\x00\x01\x00\x03\xa1M\
\x00\x00\x01\x9b\x97*\xf3\xe4\
\x00\x00\x00z\x00\x00\x00\x00\x00\x01\x00\x02\x84'\
\x00\x00\x01\x9b\x97*\xf3\xe4\
\x00\x00\x00\xe8\x00\x00\x00\x00\x00\x01\x00\x02\xa7\x9d\
\x00\x00\x01\x9b\x97*\xf3\xe4\
\x00\x00\x03R\x00\x00\x00\x00\x00\x01\x00\x03\x8b\x1d\
\x00\x00\x01\x9b\x97*\xf3\xe5\
\x00\x00\x00\xba\x00\x00\x00\x00\x00\x01\x00\x02\x9dv\
\x00\x00\x01\x9b\x97*\xf3\xe5\
\x00\x00\x04\x8c\x00\x00\x00\x00\x00\x01\x00\x03\xc4\xbf\
\x00\x00\x01\x9b\x97*\xf3\xe2\
\x00\x00\x00\xd0\x00\x00\x00\x00\x00\x01\x00\x02\x9f\xd1\
\x00\x00\x01\x9b\x97*\xf3\xfe\
\x00\x00\x04\xc8\x00\x00\x00\x00\x00\x01\x00\x03\xca\xcc\
\x00\x00\x01\x9b\x97*\xf3\xe5\
\x00\x00\x02^\x00\x00\x00\x00\x00\x01\x00\x02\xf9\xb1\
\x00\x00\x01\x9b\x97*\xf3\xe5\
\x00\x00\x01z\x00\x00\x00\x00\x00\x01\x00\x02\xcd2\
\x00\x00\x01\x9b\x97*\xf3\xe4\
\x00\x00\x02B\x00\x00\x00\x00\x00\x01\x00\x02\xf2\xd8\
\x00\x00\x01\x9b\x97*\xf3\xfe\
\x00\x00\x03\x9c\x00\x00\x00\x00\x00\x01\x00\x03\x96:\
\x00\x00\x01\x9d\xe0G\xedD\
\x00\x00\x01\xd4\x00\x00\x00\x00\x00\x01\x00\x02\xe0\xb4\
\x00\x00\x01\x9b\x97*\xf3\xe4\
\x00\x00\x03\xba\x00\x00\x00\x00\x00\x01\x00\x03\x9a\xf6\
\x00\x00\x01\x9b\x97*\xf3\xe4\
\x00\x00\x01\x02\x00\x00\x00\x00\x00\x01\x00\x02\xaa`\
\x00\x00\x01\x9b\x97*\xf3\xe4\
\x00\x00\x016\x00\x00\x00\x00\x00\x01\x00\x02\xafL\
\x00\x00\x01\x9b\x97*\xf3\xe4\
\x00\x00\x02\x94\x00\x00\x00\x00\x00\x01\x00\x03\x02f\
\x00\x00\x01\x9b\x97*\xf3\xfe\
\x00\x00\x04\x12\x00\x00\x00\x00\x00\x01\x00\x03\xab\xff\
\x00\x00\x01\x9b\x97*\xf3\xe5\
\x00\x00\x04D\x00\x00\x00\x00\x00\x01\x00\x03\xb9_\
\x00\x00\x01\x9b\x97*\xf3\xe4\
\x00\x00\x02\xd4\x00\x00\x00\x00\x00\x01\x00\x01\xb8\xb7\
\x00\x00\x01\x9b\x97*\xf3\xe1\
\x00\x00\x03p\x00\x00\x00\x00\x00\x01\x00\x025\x11\
\x00\x00\x01\x9b\x97*\xf3\xe1\
\x00\x00\x00^\x00\x00\x00\x00\x00\x01\x00\x01IR\
\x00\x00\x01\x9b\x97*\xf3\xe2\
\x00\x00\x00\xa4\x00\x00\x00\x00\x00\x01\x00\x01\x5c\xa2\
\x00\x00\x01\x9b\x97*\xf3\xe1\
\x00\x00\x04l\x00\x00\x00\x00\x00\x01\x00\x02Vh\
\x00\x00\x01\x9b\x97*\xf3\xe1\
\x00\x00\x01\x90\x00\x00\x00\x00\x00\x01\x00\x01\x8cP\
\x00\x00\x01\x9b\x97*\xf3\xe2\
\x00\x00\x02\x02\x00\x00\x00\x00\x00\x01\x00\x01\x9c\xc2\
\x00\x00\x01\x9b\x97*\xf3\xe1\
\x00\x00\x03\x12\x00\x00\x00\x00\x00\x01\x00\x01\xbe\xc1\
\x00\x00\x01\x9b\x97*\xf4\x02\
\x00\x00\x04\xa4\x00\x00\x00\x00\x00\x01\x00\x02\x5cd\
\x00\x00\x01\x9b\x97*\xf3\xe1\
\x00\x00\x02\xbe\x00\x00\x00\x00\x00\x01\x00\x01\xb3V\
\x00\x00\x01\x9b\x97*\xf3\xe1\
\x00\x00\x01L\x00\x00\x00\x00\x00\x01\x00\x01v\xd0\
\x00\x00\x01\x9b\x97*\xf3\xe2\
\x00\x00\x02\xfc\x00\x00\x00\x00\x00\x01\x00\x01\xbb\xa5\
\x00\x00\x01\x9b\x97*\xf3\xe1\
\x00\x00\x05\x10\x00\x00\x00\x00\x00\x01\x00\x02p\x0a\
\x00\x00\x01\x9b\x97*\xf3\xe1\
\x00\x00\x03\x84\x00\x00\x00\x00\x00\x01\x00\x02::\
\x00\x00\x01\x9b\x97*\xf3\xe0\
\x00\x00\x02\x16\x00\x00\x00\x00\x00\x01\x00\x01\xa0\xd2\
\x00\x00\x01\x9d\xe0StF\
\x00\x00\x01\x16\x00\x00\x00\x00\x00\x01\x00\x01p\x12\
\x00\x00\x01\x9b\x97*\xf3\xe1\
\x00\x00\x04\xf8\x00\x00\x00\x00\x00\x01\x00\x02kt\
\x00\x00\x01\x9b\x97*\xf3\xe1\
\x00\x00\x01\xec\x00\x00\x00\x00\x00\x01\x00\x01\x986\
\x00\x00\x01\x9b\x97*\xf3\xfe\
\x00\x00\x01\xae\x00\x00\x00\x00\x00\x01\x00\x01\x91m\
\x00\x00\x01\x9b\x97*\xf3\xe2\
\x00\x00\x000\x00\x00\x00\x00\x00\x01\x00\x01@$\
\x00\x00\x01\x9b\x97*\xf3\xfe\
\x00\x00\x04\xe0\x00\x00\x00\x00\x00\x01\x00\x02eY\
\x00\x00\x01\x9b\x97*\xf3\xe1\
\x00\x00\x00F\x00\x00\x00\x00\x00\x01\x00\x01GJ\
\x00\x00\x01\x9b\x97*\xf3\xe2\
\x00\x00\x00\x90\x00\x00\x00\x00\x00\x01\x00\x01T\xfc\
\x00\x00\x01\x9b\x97*\xf3\xfe\
\x00\x00\x04,\x00\x00\x00\x00\x00\x01\x00\x02Q\x0b\
\x00\x00\x01\x9b\x97*\xf3\xe1\
\x00\x00\x02|\x00\x00\x00\x00\x00\x01\x00\x01\xac\x0e\
\x00\x00\x01\x9b\x97*\xf3\xe2\
\x00\x00\x03\xf4\x00\x00\x00\x00\x00\x01\x00\x02I{\
\x00\x00\x01\x9b\x97*\xf3\xe1\
\x00\x00\x038\x00\x00\x00\x00\x00\x01\x00\x02/\x8c\
\x00\x00\x01\x9b\x97*\xf3\xe1\
\x00\x00\x01f\x00\x00\x00\x00\x00\x01\x00\x01z\x8c\
\x00\x00\x01\x9d\xcf\xc3\xa3\x0d\
\x00\x00\x03\xdc\x00\x00\x00\x00\x00\x01\x00\x02D\xce\
\x00\x00\x01\x9b\x97*\xf3\xe1\
\x00\x00\x00z\x00\x00\x00\x00\x00\x01\x00\x01N\xba\
\x00\x00\x01\x9b\xc5\xbd\x82\xf5\
\x00\x00\x00\xe8\x00\x00\x00\x00\x00\x01\x00\x01i\x96\
\x00\x00\x01\x9b\x97*\xf3\xe1\
\x00\x00\x03R\x00\x00\x00\x00\x00\x01\x00\x021\xd3\
\x00\x00\x01\x9b\x97*\xf3\xe2\
\x00\x00\x00\xba\x00\x00\x00\x00\x00\x01\x00\x01_\xd4\
\x00\x00\x01\x9b\x97*\xf3\xe2\
\x00\x00\x04\x8c\x00\x00\x00\x00\x00\x01\x00\x02Y\xd0\
\x00\x00\x01\x9b\x97*\xf3\xe2\
\x00\x00\x00\xd0\x00\x00\x00\x00\x00\x01\x00\x01a\xca\
\x00\x00\x01\x9b\x97*\xf3\xfe\
\x00\x00\x04\xc8\x00\x00\x00\x00\x00\x01\x00\x02`\x15\
\x00\x00\x01\x9b\x97*\xf3\xe2\
\x00\x00\x02^\x00\x00\x00\x00\x00\x01\x00\x01\xa8\xa4\
\x00\x00\x01\x9b\x97*\xf3\xe2\
\x00\x00\x01z\x00\x00\x00\x00\x00\x01\x00\x01\x86o\
\x00\x00\x01\x9b\x97*\xf3\xe1\
\x00\x00\x02B\x00\x00\x00\x00\x00\x01\x00\x01\xa1\xcb\
\x00\x00\x01\x9b\x97*\xf3\xfe\
\x00\x00\x03\x9c\x00\x00\x00\x00\x00\x01\x00\x02>\xd5\
\x00\x00\x01\x9d\xe0G\xedD\
\x00\x00\x01\xd4\x00\x00\x00\x00\x00\x01\x00\x01\x95\xfc\
\x00\x00\x01\x9b\x97*\xf3\xe1\
\x00\x00\x03\xba\x00\x00\x00\x00\x00\x01\x00\x02Ag\
\x00\x00\x01\x9b\x97*\xf3\xe1\
\x00\x00\x01\x02\x00\x00\x00\x00\x00\x01\x00\x01l4\
\x00\x00\x01\x9b\x97*\xf3\xe1\
\x00\x00\x016\x00\x00\x00\x00\x00\x01\x00\x01s\x8a\
\x00\x00\x01\x9b\x97*\xf3\xe1\
\x00\x00\x02\x94\x00\x00\x00\x00\x00\x01\x00\x01\xae\x0e\
\x00\x00\x01\x9b\x97*\xf3\xfe\
\x00\x00\x04\x12\x00\x00\x00\x00\x00\x01\x00\x02L9\
\x00\x00\x01\x9b\x97*\xf3\xe1\
\x00\x00\x04D\x00\x00\x00\x00\x00\x01\x00\x02Sr\
\x00\x00\x01\x9b\x97*\xf3\xe1\
"
def qInitResources():

Binary file not shown.

Before

Width:  |  Height:  |  Size: 394 B

View File

@@ -9,7 +9,6 @@ from PySide6.QtWidgets import QApplication, QFileDialog, QProgressDialog
from interpreter.process import TestProcess
from interpreter.utils.test_ctrl import TestSetController
from main_win.test_controller_service import TestControllerService
from main_win import file_dialog
import interpreter.utils.settings as prefs
from runtime.tum_except import ETUMFileError, ETUMRuntimeError
@@ -134,7 +133,6 @@ class TestFileManager:
QApplication.processEvents()
test_data = w.test_service.tree()
w.treeTests.clear()
w._reset_search()
QApplication.processEvents()
w.treeTests.loadTestRecursively(w.treeTests.invisibleRootItem(), test_data)
self._close_progress(progress)
@@ -214,9 +212,17 @@ class TestFileManager:
d = ""
if w.testFile is not None:
d = os.path.dirname(w.testFile)
# In Flatpak the native dialog goes through the XDG document portal,
# which returns /run/user/UID/doc/.../test.tum and only exposes the
# selected file — sibling files (param.yaml, .py, etc.) are unreachable.
# Force Qt's own dialog, which walks the real filesystem mounted via
# --filesystem=home and returns a regular path with sibling access.
options = QFileDialog.Options()
if os.path.isfile("/.flatpak-info"):
options |= QFileDialog.Option.DontUseNativeDialog
file_name, _ = QFileDialog.getOpenFileName(
w, "Open the test file", d,
"testium file (*.tum);;All Files (*)", options=file_dialog.options()
"testium file (*.tum);;All Files (*)", options=options
)
if file_name:
self.reload(file_name)

View File

@@ -163,46 +163,6 @@ class QTestTree(QTreeWidget):
def clearGlobalSuccess(self):
self._global_success = True
def _all_items(self):
"""Pre-order (visual, top-to-bottom) iteration over every tree item."""
def walk(parent):
for i in range(parent.childCount()):
child = parent.child(i)
yield child
yield from walk(child)
yield from walk(self.invisibleRootItem())
def clear_search(self):
# Block signals: setBackground -> itemChanged -> on_testChecked storm.
self.blockSignals(True)
try:
for it in self._all_items():
it.setSearchMatch(False)
finally:
self.blockSignals(False)
def search(self, text, fields):
"""Highlight items matching *text* in *fields*, expand ancestors, return matches."""
matches = []
text = (text or "").strip()
needle = text.lower()
active = bool(text and fields)
# One blocked pass: clear stale + set new matches without firing signals.
self.blockSignals(True)
try:
for it in self._all_items():
matched = active and it.matches_search(needle, fields)
it.setSearchMatch(matched)
if matched:
matches.append(it)
p = it.parent()
while p is not None:
self.expandItem(p)
p = p.parent()
finally:
self.blockSignals(False)
return matches
def __findItemByIdRecursively(self, item_id, parent):
res = None
i = 0

View File

@@ -12,8 +12,6 @@ from api.testium import print_warn
_ITEM_CONFIG = {
"unittest": {"icon": "folder.png", "icon_on": "folder-open.png", "expanded": True, "no_breakpoint": True},
"unittest step": {"icon": "document.png", "no_breakpoint": True},
"pytest": {"icon": "pytest.png", "expanded": True, "no_breakpoint": True},
"pytest step": {"icon": "pytest.png", "no_breakpoint": True},
"Console": {"icon": "terminal.png", "unfoldable": False},
"Console action": {"icon": "terminal.png"},
"Cycle": {"icon": "cycle.png", "expanded": True},
@@ -103,7 +101,7 @@ class QTestTreeItem(QTreeWidgetItem):
self.setFlags(self.flags() | Qt.ItemIsUserCheckable)
self.setCheckState(self._cols["name"]["index"], Qt.Checked)
self._is_highlighted = False
self._is_search_match = False
self._initial_brush = None
self._failure_list = None
self._no_breakpoint = False
parent.addChild(self)
@@ -180,44 +178,17 @@ class QTestTreeItem(QTreeWidgetItem):
def isBreakpoint(self):
return self._display_pause
def _refresh_highlight(self):
"""Recompute name-column colours from flags: run (green) > search (amber) > none."""
col = self._cols["name"]["index"]
if self._is_highlighted:
self.setBackground(col, QBrush(QColor(153, 255, 153)))
self.setForeground(col, QBrush())
elif self._is_search_match:
self.setBackground(col, QBrush(QColor(255, 224, 130)))
self.setForeground(col, QBrush(QColor(0, 0, 0)))
else:
self.setBackground(col, QBrush())
self.setForeground(col, QBrush())
def setHighlighted(self):
if not self._is_highlighted:
self._initial_brush = self.background(self._cols["name"]["index"])
color = QBrush(QColor(153, 255, 153))
self.setBackground(self._cols["name"]["index"], color)
self._is_highlighted = True
self._refresh_highlight()
def resetHighlighted(self):
if self._is_highlighted:
self.setBackground(self._cols["name"]["index"], self._initial_brush)
self._is_highlighted = False
self._refresh_highlight()
def matches_search(self, needle, fields):
"""True if *needle* (lowercase) is in any enabled field (name/type/doc)."""
if "name" in fields and needle in (self.name or "").lower():
return True
if "type" in fields and needle in (self.test_type or "").lower():
return True
if "doc" in fields and needle in str(self.doc or "").lower():
return True
return False
def setSearchMatch(self, on):
"""Search highlight (amber bg + black text), readable in any theme."""
if on != self._is_search_match:
self._is_search_match = on
self._refresh_highlight()
def setRowIcon(self, resource_off, resource_on=""):

View File

@@ -7,7 +7,7 @@ import shutil
# Qt
from PySide6 import QtGui
from PySide6.QtGui import QAction, QShortcut, QIcon, QPixmap, QTextCursor, QDesktopServices, QTextCursor, QKeySequence
from PySide6.QtGui import QAction, QShortcut, QIcon, QPixmap, QTextCursor, QDesktopServices, QTextCursor
from PySide6.QtCore import Slot, QUrl, Qt, QTimer
from PySide6.QtWidgets import (
@@ -16,12 +16,6 @@ from PySide6.QtWidgets import (
QDialog,
QFileDialog,
QSizePolicy,
QWidget,
QHBoxLayout,
QLineEdit,
QCheckBox,
QLabel,
QToolButton,
)
ourPath = os.path.dirname(__file__)
@@ -43,7 +37,6 @@ from interpreter.utils.icons import icon_prefix
from main_win.test_run.outlog import OutLog
from main_win.test_run.test_run import ThreadTestStatus
from main_win import file_dialog
import interpreter.utils.settings as prefs
from runtime.stdout_redirect import stdio_redir
import api.testium as tm
@@ -175,13 +168,6 @@ class MainWindow(QMainWindow, Ui_MainWindow):
activated=self.on_F1Pressed,
)
self._search_matches = []
self._search_idx = 0
self._build_search_bar()
self.shortcut_find = QShortcut(
QKeySequence.Find, self, activated=self._toggle_search
)
self.actionRefresh_test.setDisabled(True)
# Signal connections
@@ -308,135 +294,6 @@ class MainWindow(QMainWindow, Ui_MainWindow):
del self.treeTests
self.treeTests = None
# ---- test-tree search ---------------------------------------------------
def _build_search_bar(self):
"""Find bar (Ctrl+F): highlight + navigate matches; Name/Type/Doc pick fields."""
self.searchBar = QWidget(self.widget)
lay = QHBoxLayout(self.searchBar)
lay.setContentsMargins(2, 2, 2, 2)
lay.setSpacing(4)
self.searchEdit = QLineEdit(self.searchBar)
self.searchEdit.setPlaceholderText("Search the test tree…")
self.searchEdit.setClearButtonEnabled(True)
lay.addWidget(self.searchEdit, 1)
self.cbSearchName = QCheckBox("Name", self.searchBar)
self.cbSearchType = QCheckBox("Type", self.searchBar)
self.cbSearchDoc = QCheckBox("Doc", self.searchBar)
for cb in (self.cbSearchName, self.cbSearchType, self.cbSearchDoc):
cb.setChecked(True)
cb.toggled.connect(self._do_search)
lay.addWidget(cb)
self.searchCount = QLabel("", self.searchBar)
lay.addWidget(self.searchCount)
self.searchPrev = QToolButton(self.searchBar)
self.searchPrev.setArrowType(Qt.UpArrow)
self.searchPrev.setToolTip("Previous match")
self.searchPrev.clicked.connect(self._search_prev)
lay.addWidget(self.searchPrev)
self.searchNext = QToolButton(self.searchBar)
self.searchNext.setArrowType(Qt.DownArrow)
self.searchNext.setToolTip("Next match (Enter)")
self.searchNext.clicked.connect(self._search_next)
lay.addWidget(self.searchNext)
self.searchClose = QToolButton(self.searchBar)
self.searchClose.setText("")
self.searchClose.setToolTip("Close (Esc)")
self.searchClose.clicked.connect(self._close_search)
lay.addWidget(self.searchClose)
self.searchEdit.textChanged.connect(self._do_search)
self.searchEdit.returnPressed.connect(self._search_next)
QShortcut(Qt.Key_Escape, self.searchEdit,
context=Qt.WidgetShortcut, activated=self._close_search)
# Insert above the tree (index 0 is the control row from setupUi).
self.verticalLayout.insertWidget(1, self.searchBar)
self.searchBar.setVisible(False)
def _search_fields(self):
fields = set()
if self.cbSearchName.isChecked():
fields.add("name")
if self.cbSearchType.isChecked():
fields.add("type")
if self.cbSearchDoc.isChecked():
fields.add("doc")
return fields
def _toggle_search(self):
"""Ctrl+F: open the find bar, or close it (clearing the highlight)."""
if self.searchBar.isVisible():
self._close_search()
else:
self._open_search()
def _open_search(self):
self.searchBar.setVisible(True)
self.searchEdit.setFocus()
self.searchEdit.selectAll()
if self.searchEdit.text():
self._do_search()
def _do_search(self):
if self.treeTests is None:
return
self._search_matches = self.treeTests.search(
self.searchEdit.text(), self._search_fields()
)
self._search_idx = 0
if self._search_matches:
self._goto_match(0)
else:
self._update_search_count()
def _update_search_count(self):
n = len(self._search_matches)
if n == 0:
self.searchCount.setText(
"0/0" if self.searchEdit.text().strip() else ""
)
else:
self.searchCount.setText("{}/{}".format(self._search_idx + 1, n))
def _goto_match(self, idx):
if not self._search_matches:
return
self._search_idx = idx % len(self._search_matches)
it = self._search_matches[self._search_idx]
self.treeTests.scrollToItem(it)
self.treeTests.setCurrentItem(it)
self._update_search_count()
def _search_next(self):
if self._search_matches:
self._goto_match(self._search_idx + 1)
def _search_prev(self):
if self._search_matches:
self._goto_match(self._search_idx - 1)
def _close_search(self):
if self.treeTests is not None:
self.treeTests.clear_search()
self.treeTests.setFocus()
self.searchBar.setVisible(False)
self._search_matches = []
def _reset_search(self):
"""New test file loaded: drop stale matches and hide the bar."""
self._search_matches = []
self._search_idx = 0
if hasattr(self, "searchBar"):
self.searchBar.setVisible(False)
self.searchCount.setText("")
def file_loaded_at_startup(self):
modeSlider_value = prefs.settings.show_checkboxes
if modeSlider_value:
@@ -627,8 +484,7 @@ class MainWindow(QMainWindow, Ui_MainWindow):
else:
initialPath = None
fileName, _ = QFileDialog.getSaveFileName(
self, "Path to Log file", initialPath, "Log Files (*.log);;All Files (*)",
options=file_dialog.options(),
self, "Path to Log file", initialPath, "Log Files (*.log);;All Files (*)"
)
if fileName:
shutil.copy(self.logFileName, fileName)
@@ -669,8 +525,7 @@ class MainWindow(QMainWindow, Ui_MainWindow):
else:
initialPath = None
fileName, _ = QFileDialog.getSaveFileName(
self, "Path to log file", initialPath, "Log Files (*.log);;All Files (*)",
options=file_dialog.options(),
self, "Path to log file", initialPath, "Log Files (*.log);;All Files (*)"
)
if fileName:
self.editLogFilePath.setText(fileName)
@@ -820,24 +675,6 @@ def MainWin(
debug=False,
):
app = QApplication(sys.argv)
# Application identity so desktop shells (GNOME, ...) show the testium
# icon in the task bar / dock instead of a generic one. On Wayland this
# sets the surface app_id; on X11/XWayland it sets WM_CLASS, so the window
# stops inheriting the launcher's class (e.g. "python3" under the AppImage,
# which is what GNOME was keying the wrong icon off) and the window icon
# below is used as the fallback. In Flatpak the id must be the Flatpak app
# id so it matches the installed desktop file.
app.setApplicationName("Testium")
app.setApplicationDisplayName("Testium")
app.setDesktopFileName(os.environ.get("FLATPAK_ID", "testium"))
app.setWindowIcon(QIcon(u":/black/testium_logo.png"))
# On native Wayland the task-bar icon comes from an installed desktop file
# matched to the app_id, not from setWindowIcon(). Flatpak ships its own;
# for the other Linux channels drop an idempotent one under ~/.local/share.
# Windows / macOS use the window icon set above, so this is Linux-only.
if sys.platform.startswith("linux") and not os.environ.get("FLATPAK_ID"):
from main_win.desktop_integration import ensure_desktop_entry
ensure_desktop_entry()
ui = MainWindow(
test_file,
config_files,

View File

@@ -1,9 +1,7 @@
#!/usr/bin/env python
import sys
import multiprocessing
from py_func.tm import _init_api, _remote_print
from runtime.stdout_redirect import stdio_redir
from runtime.jrpc import RPC_PORT_SENTINEL
class TcpStdOut:
@@ -26,29 +24,21 @@ def main():
parser = argparse.ArgumentParser()
parser.add_argument("-i", "--ip", type=str, help="Ip address or hostname to listen to",
default="localhost")
parser.add_argument("-p", "--port", type=int, help="port to listen to (0 = OS-assigned)",
default=0)
parser.add_argument("-p", "--port", type=int, help="port to listen to",
default=9000)
parser.add_argument("-t", "--timeout", type=float, help="Timeout waiting for connection",
default=10)
parser.add_argument("-v", "--verbose", action='store_true', help="port to listen to")
args = parser.parse_args()
thrd_api = _init_api(args.ip, args.port, args.timeout)
# redirect I/O
outstream = TcpStdOut()
stdio_redir.redirect(outstream)
# debug the server
if args.verbose:
thrd_api.dbg_out = stdio_redir.ini_stdout
thrd_api.start()
# Announce the bound port on real stdout (before redirection) so the parent connects.
port = thrd_api.wait_bound(args.timeout)
if port is None:
print("py_func: failed to bind a listening port", file=sys.stderr, flush=True)
return
print(f"{RPC_PORT_SENTINEL}{port}", flush=True)
# redirect I/O
outstream = TcpStdOut()
stdio_redir.redirect(outstream)
try:
while thrd_api.is_alive():
thrd_api.join(1)

View File

@@ -12,9 +12,6 @@ except:
from runtime.tum_except import ETUMRuntimeError
# Startup handshake: subprocess prints this + its bound port on stdout once listening.
RPC_PORT_SENTINEL = "__TESTIUM_RPC_PORT__="
"""Lightweight JSON-RPC 2.0 helpers over TCP sockets.
This module implements a minimal JSON-RPC 2.0 messaging layer using
@@ -282,8 +279,6 @@ class JsonRpcBase(threading.Thread):
self._req_handler = req_handler
self._dbg_out = dbg_out
self._event_ready = threading.Event()
# Set on success AND failure so wait_ready() never hangs; outcome in _connected.
self._connected = False
def handle_request(self, method, params):
"""Override to implement server-side request handling.
@@ -319,12 +314,10 @@ class JsonRpcBase(threading.Thread):
self.name, sock, self.handle_request, dbg_out=self.dbg_out
)
self._rpc.wait_ready()
self._connected = True
self._event_ready.set()
def wait_ready(self, timeout=None):
self._event_ready.wait(timeout)
return self._connected
return self._event_ready.wait(timeout)
@property
def dbg_out(self):
@@ -355,30 +348,20 @@ class JsonRpcSrv(JsonRpcBase):
def __init__(self, host, port, req_handler=None, timeout=10):
super().__init__(host, port, req_handler, timeout)
self.name = f"JsonRpcSvr_{port}"
self._bound_port = None
self._bound_evt = threading.Event()
@property
def bound_port(self):
return self._bound_port
def wait_bound(self, timeout=None):
self._bound_evt.wait(timeout)
return self._bound_port
def run(self):
# TCP/IP socket creation
try:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
# No SO_REUSEADDR: fresh ephemeral port; on Windows it enables hijacking.
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
# Link of the socket at the configured port
sock.bind((self._host, self._port))
# Listens incoming connections
sock.listen(1)
self._bound_port = sock.getsockname()[1]
self._bound_evt.set()
self.print_info(f"listening on {self._host}:{self._bound_port}")
self.print_info(f"listening on {self._host}:{self._port}")
self.print_info(f"awaiting connection for {self._timeout} secs")
sock.settimeout(self._timeout)
@@ -399,7 +382,6 @@ class JsonRpcSrv(JsonRpcBase):
sleep(0.1)
finally:
self._bound_evt.set() # unblock wait_bound() even on failure
if self._rpc is not None:
self._rpc.stop()
self._rpc.join()
@@ -425,34 +407,35 @@ class JsonRpcClient(JsonRpcBase):
self.name = f"JsonRpcClt_{port}"
def run(self):
try:
if tm.OS() == "Windows":
self.run_win()
else:
self.run_lin()
except Exception as e:
self.print_info(f"connection failed: {e}")
finally:
self._event_ready.set() # settle wait_ready() whatever the outcome
def run_win(self):
# Server already listening (handshake); retry on refused/timeout until deadline.
deadline = monotonic() + self._timeout
# TCP/IP socket creation
tslice = 1
t = self._timeout
sock = None
try:
while True:
while t >= 0:
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(0.5)
sock.settimeout(tslice)
# Link of the socket at the configured port
try:
sock.connect((self._host, self._port))
break
except OSError as e:
except socket.timeout:
sock.close()
if monotonic() >= deadline:
t -= tslice
if t < 0:
raise ETUMRuntimeError(
f"{self.name}: failed to connect : {e}"
f"{self.name}: failed to connect : timeout"
)
sleep(0.1)
else:
sleep(tslice)
except socket.error as e:
raise ETUMRuntimeError(f"{self.name}: failed to connect : {e}")
self.print_info("Connected to server")
self.connect(sock)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -84,7 +84,18 @@
- read_until: {expected: HelloConsole, timeout: 1, mute: true}
- console:
name: Console read_until muted
name: Console read_until float timeout
console_name: term
key: $(test)_PASS
steps:
- writeln: echo "HelloConsole"
{% if os == "Windows" %}
- read_until: {expected: echo "HelloConsole", timeout: 0.2}
{% endif %}
- read_until: {expected: HelloConsole, timeout: 0.2}
- console:
name: Console read_until process result
console_name: term
key: $(test)_PASS
steps:
@@ -94,57 +105,6 @@
{% endif %}
- read_until: {expected: endOfCmd, timeout: 1, process_result: "'Hello' in r'''$(result)''' and 'PASS' in r'''$(result)''' "}
{% if os == "Linux" %}
- console:
name: Console runs on host (not the Flatpak sandbox)
doc: Regression guard for the 0.2.1 Flatpak bug (term console spawned inside the sandbox instead of on the host). /.flatpak-info exists only inside the sandbox, so the host-only marker is emitted (and matched by read_until) ONLY when the shell really runs on the host. On a broken Flatpak the marker never appears, read_until times out and the item FAILS. The marker is built at runtime ($M) so it is never present in the command line itself. Passes on every other channel.
console_name: term
key: $(test)_PASS
steps:
- writeln: 'test -e /.flatpak-info && M=SANDBOX || M=HOST; echo "console_host_check_$M"'
- read_until: {expected: console_host_check_HOST, timeout: 5}
{% endif %}
# --- read_until matching a list of values (succeeds on any) ---
- console:
name: Console read_until list match any
console_name: term
key: $(test)_PASS
steps:
- writeln: echo "list_marker_B"
- read_until: {expected: [list_marker_A, list_marker_B, list_marker_C], timeout: 5}
- console:
name: Console read_until list no match
console_name: term
key: $(test)_FAIL
steps:
- read_until: {expected: [never_marker_A, never_marker_B], timeout: 1}
# --- read_until with regular expressions ---
- console:
name: Console read_until regex
console_name: term
key: $(test)_PASS
steps:
- writeln: echo "regex_val_4242_end"
- read_until: {expected: 'regex_val_\d+_end', regex: true, timeout: 5}
- console:
name: Console read_until regex list any
console_name: term
key: $(test)_PASS
steps:
- writeln: echo "STATUS=ready"
- read_until: {expected: ['ERR:.*', 'STATUS=(ready|busy)'], regex: true, timeout: 5}
- console:
name: Console read_until regex no match
console_name: term
key: $(test)_FAIL
steps:
- read_until: {expected: 'never_\d{4}', regex: true, timeout: 1}
- console:
name: Console closure
execute_on_stop: true

Some files were not shown because too many files have changed in this diff Show More