4 Commits

84 changed files with 1236 additions and 2790 deletions

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. - 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`). - 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) ### 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: User test scripts running inside a `py_func` or `lua_func` subprocess **must** use the JSON-RPC bridge to interact with testium state:
@@ -124,7 +115,7 @@ To add a new API call usable from subprocesses:
`src/testium/interpreter/utils/bins.py` — single source of truth for the paths to the external Python and Lua interpreters used by subprocesses. `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`). - `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. - `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. Engines (`PyProcessBase`, `LuaProcessBase`, `EvalExecEngine`) call `bins.python_bin()`/`bins.lua_bin()` themselves — call sites never pass an explicit binary path.
@@ -194,15 +185,8 @@ 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). 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 ### `run` item
`src/testium/interpreter/test_items/test_item_run.py` — launches a `.tum` file in a new testium instance. Child mode: `-b` in batch, `-r` (own window) in the GUI, or forced `-b` by `batch: true`. A `-b` child is **captured** (launched `-o`, no colour): its stdout/stderr stream through `proc_drain.drain_to_log()` into this test's log/report, and the full text is kept as the result value, so `store_result` pushes it to the gdict and `expected_result`/`process_result`/a py_func can post-process it. Stop kills the child via the poll loop. Result: `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) - **PASS** if the sub-instance launched and ran to completion (exit code is ignored)
- **FAIL** if the file is not found, `wait_for_exec` is set without `start_time`/`end_time`, the time window was not reached, or any other launch error - **FAIL** if the file is not found, `wait_for_exec` is set without `start_time`/`end_time`, the time window was not reached, or any other launch error
@@ -210,20 +194,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. 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 ### 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. `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,8 +217,7 @@ Four distribution channels coexist, all sharing the single `src/testium/` packag
| Channel | Where | Build | Notes | | Channel | Where | Build | Notes |
|---------|-------|-------|-------| |---------|-------|-------|-------|
| Wheel (`pip install`) | `src/pyproject.toml` | `python -m build` | Vanilla Python package; entry point `testium = "testium:main"`. | | 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". | | 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). |
| 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. |
| 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. | | 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. | | 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. |
@@ -279,19 +248,6 @@ The bundled Python (Flatpak's runtime python, AppImage's `python3.11`) is reserv
- If the host has no python3/lua, `ensure()` raises `ETUMRuntimeError` at test load with the candidate list — no silent fallback to a bundled interpreter. - If the host has no python3/lua, `ensure()` raises `ETUMRuntimeError` at test load with the candidate list — no silent fallback to a bundled interpreter.
- `py_process.py` additionally pops `PYTHONUSERBASE` (set to `/var/data/python` by the Flatpak runtime, which would hide `~/.local/lib/...`). - `py_process.py` additionally pops `PYTHONUSERBASE` (set to `/var/data/python` by the Flatpak runtime, which would hide `~/.local/lib/...`).
### 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 ### 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). 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).
@@ -323,14 +279,6 @@ The `testium_assist` editor extension is a thin LSP client that spawns `testium
Both Flatpak and AppImage export `TESTIUM_VERSION` from a launcher (Flatpak: launcher script in `org.testium.Testium.yaml`; AppImage: `runtime.env` in `AppImageBuilder.yml`). `get_testium_version()` checks `/.flatpak-info` / `APPIMAGE` and reads `TESTIUM_VERSION` rather than relying on package metadata or repo introspection. Both Flatpak and AppImage export `TESTIUM_VERSION` from a launcher (Flatpak: launcher script in `org.testium.Testium.yaml`; AppImage: `runtime.env` in `AppImageBuilder.yml`). `get_testium_version()` checks `/.flatpak-info` / `APPIMAGE` and reads `TESTIUM_VERSION` rather than relying on package metadata or repo introspection.
## Recent fixes / notable changes ## Recent fixes / notable changes
- Open-log-at-line (GUI): double-click on a tree item opened the log via a hardcoded `code -g {file}:{line}` (broke in Flatpak where `code` is absent). Now driven by a configurable `editor_cmd` preference (placeholders `{file}`/`{line}`, default `code -g {file}:{line}`); the argv is built by `shlex.split` then per-token `.format` (paths with spaces stay one token), wrapped by `bins.host_console_command()` for Flatpak host-spawn, with a `host_open_path`/`openUrl` fallback (no line) when empty or failing. Settings/pref refactor alongside: `SettingsItem` carries its default (single source of truth), trivial getters/setters collapse to `_pref(item)` bindings, the pref window's `elements` dict becomes a `Field(key, type, widget)` table with a per-type `_FIELD` read/write bridge, and the four file-picker slots fold into `_pick_dir`/`_pick_file`. (Also fixed a latent default mismatch: `report_path` defaulted to `$(home)` in the property but `$(test_directory)` in the pref window; unified to `$(test_directory)`.)
- Show Results (GUI): the toolbar action stays enabled during a run (the log grows live, so it is useful mid-test), not just after. In Flatpak `QDesktopServices.openUrl` routes through the OpenURI portal and often opens no editor for a `.log`; `bins.host_open_path()` now spawns `xdg-open` on the host via `flatpak-spawn --host` (returns False outside Flatpak so the caller falls back to `openUrl`).
- 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. - `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. - 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. - 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.

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, The ``read_until`` action is waiting for a string pattern from the console,
its parameter are listed below its parameter are listed below
* ``expected``: the pattern(s) to wait for. It accepts either a **single * ``expected``: Character string to wait for
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.
* ``timeout``: Timeout setting for the action (in seconds) * ``timeout``: Timeout setting for the action (in seconds)
* ``no_fail``: Boolean value (``True`` or ``False``) leading to no error reported * ``no_fail``: Boolean value (``True`` or ``False``) leading to no error reported
if the expected input is not read if the expected input is not read
* ``mute``: Boolean value (``True`` or ``False``) does not log any readen data * ``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 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>` variable named ``cn_<test_name>`` (See :ref:`global variables<sec_global_variables>`
for more detail on accessing global variables from test items and scripts). 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)`` 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 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: - let:
name: Let Item name: Let Item
values: values:
key1: value1 - key1: value1
key2: value2 - key2: value2
key3: <| $(variable)[$(loop_index)] |> - key3: <| $(variable)[$(loop_index)] |>
The ``let`` element is used to set values in the global directory. 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

@@ -4,13 +4,7 @@
This test item executes a new instance of testium with the specified ``.tum`` file. This test item executes a new instance of testium with the specified ``.tum`` file.
* In **batch mode** (``-b``): the sub-instance is started with ``-b``. * In **batch mode** (``-b``): the sub-instance is started with ``-b``.
* In **GUI mode**: the sub-instance opens its own window with ``-r`` (run and close). * In **GUI mode**: the sub-instance is started with ``-r`` (run and close).
* ``batch: true`` forces the sub-instance to run headless (``-b``) even in the GUI.
A sub-instance started with ``-b`` is **captured**: its output is streamed into this
test's log and report, and kept as the item's result value, so it can be stored
with ``store_result`` and post-processed (``expected_result``, ``process_result``,
or a ``py_func`` reading the global variable).
The item result is **PASS** if the sub-instance launched and ran to completion, The item result is **PASS** if the sub-instance launched and ran to completion,
regardless of whether the sub-tests passed or failed. regardless of whether the sub-tests passed or failed.
@@ -23,6 +17,7 @@ launched, or the time window was not reached (see ``start_time`` / ``end_time``)
- run: - run:
name: Execute TUM name: Execute TUM
tum: example_cycle.tum tum: example_cycle.tum
python_bin: python3
log_file: $(home)/reports/test.log log_file: $(home)/reports/test.log
report_file: $(home)/reports/test.rep report_file: $(home)/reports/test.rep
@@ -33,8 +28,9 @@ run test item has the following specific attributes:
* ``tum``: mandatory, the path of the file to execute. Can be relative to the current execution folder. * ``tum``: mandatory, the path of the file to execute. Can be relative to the current execution folder.
* ``param_file`` (optional): the path of the parameter file to use; otherwise the default parameter file is used. * ``param_file`` (optional): the path of the parameter file to use; otherwise the default parameter file is used.
* ``batch`` (optional): ``true`` to run the sub-instance headless (``-b``) and capture its output even in GUI mode (see above). * ``python_bin`` (optional): the path of a specific Python interpreter to use.
* ``log_file`` (optional): the path of the log file. In GUI mode, if not provided, a file is created with a timestamp next to the ``.tum`` file. Not used in batch mode (the output is captured instead). * ``testium_path`` (optional): the path of a specific testium executable to use.
* ``log_file`` (optional): the path of the log file. In GUI mode, if not provided, a file is created with a timestamp next to the ``.tum`` file. Not used in batch mode.
* ``report_file`` (optional): the path of the report file to create. * ``report_file`` (optional): the path of the report file to create.
* ``start_time`` (optional): earliest time to execute the sub-instance, in ``HH:MM`` format. * ``start_time`` (optional): earliest time to execute the sub-instance, in ``HH:MM`` format.
* ``end_time`` (optional): latest time for execution within a time frame, in ``HH:MM`` format. * ``end_time`` (optional): latest time for execution within a time frame, in ``HH:MM`` format.

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 :caption: configuration files definition in the main `.tum` test file
config_file: config_file:
config1.yaml - config1.yaml
config2.yaml - config2.yaml
main: main:
name: Test example name: Test example
@@ -270,7 +270,6 @@ step list attributes.
test_items/run_test_item.rst test_items/run_test_item.rst
test_items/sleep_test_item.rst test_items/sleep_test_item.rst
test_items/unittest_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

@@ -11,11 +11,7 @@ finish-args:
- --share=ipc - --share=ipc
- --socket=fallback-x11 - --socket=fallback-x11
- --socket=wayland - --socket=wayland
# Expose all host devices to the sandbox. testium is a hardware-in-the-loop - --device=dri
# 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
- --share=network - --share=network
- --filesystem=home - --filesystem=home
- --filesystem=/tmp - --filesystem=/tmp

View File

@@ -1,51 +0,0 @@
# Build the Windows installer: PyInstaller one-folder build (fast start) + Inno Setup.
# Install ISCC without admin: winget install --id JRSoftware.InnoSetup -e
$ErrorActionPreference = 'Stop'
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$repoRoot = Resolve-Path (Join-Path $scriptDir '..\..')
$pyiDir = Join-Path $repoRoot 'package\pyinstaller'
# Locate PyInstaller: PATH first, then the known project venvs.
$pyi = (Get-Command pyinstaller.exe -ErrorAction SilentlyContinue).Source
if (-not $pyi) {
foreach ($p in @(
(Join-Path $repoRoot 'test\tmp\testium_venv\Scripts\pyinstaller.exe'),
(Join-Path $repoRoot 'test\tmp\.venv\Scripts\pyinstaller.exe'))) {
if (Test-Path $p) { $pyi = $p; break }
}
}
if (-not $pyi) { throw "pyinstaller.exe not found (PATH or project venv)." }
# One-folder PyInstaller build => dist\testium\testium.exe + dist\testium\_internal\.
Write-Host "Building one-folder exe with: $pyi"
Remove-Item -Recurse -Force (Join-Path $pyiDir 'build'), (Join-Path $pyiDir 'dist') -ErrorAction SilentlyContinue
Push-Location $pyiDir
try {
$env:TESTIUM_ONEDIR = '1'
& $pyi 'testium.spec'
if ($LASTEXITCODE -ne 0) { throw "pyinstaller failed with exit code $LASTEXITCODE" }
} finally {
Remove-Item Env:\TESTIUM_ONEDIR -ErrorAction SilentlyContinue
Pop-Location
}
# Locate ISCC: 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,180 +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
; Shown only if another version is already installed; unchecked => keep it.
Name: "removeold"; Description: "Désinstaller les autres versions de Testium déjà installées"; Check: OtherVersionsExist; Flags: unchecked
[Files]
; One-folder build: the exe plus its _internal\ tree (fast startup, no re-extract).
Source: "..\pyinstaller\dist\testium\*"; DestDir: "{app}"; Flags: recursesubdirs 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';
UninstallRoot = 'Software\Microsoft\Windows\CurrentVersion\Uninstall';
AppGuid = '{B7E6F1C2-9A4D-4E3B-8F71-7C2D5A6E0B14}';
// Inno's uninstall subkey for *this* version: "{AppId}_is1".
function CurrentUninstallSubkey(): string;
begin
Result := AppGuid + '_{#MyAppVersion}_is1';
end;
// Uninstall subkeys of every installed Testium version except this one.
function OtherTestiumSubkeys(): TArrayOfString;
var
names: TArrayOfString;
i: Integer;
prefix, cur: string;
begin
SetArrayLength(Result, 0);
prefix := Uppercase(AppGuid + '_');
cur := Uppercase(CurrentUninstallSubkey());
if RegGetSubkeyNames(HKEY_CURRENT_USER, UninstallRoot, names) then
for i := 0 to GetArrayLength(names) - 1 do
if (Pos(prefix, Uppercase(names[i])) = 1) and (Uppercase(names[i]) <> cur) then
begin
SetArrayLength(Result, GetArrayLength(Result) + 1);
Result[GetArrayLength(Result) - 1] := names[i];
end;
end;
// Drives the "removeold" task: only offered when another version exists.
function OtherVersionsExist(): Boolean;
begin
Result := GetArrayLength(OtherTestiumSubkeys()) > 0;
end;
// Silently run each other version's uninstaller.
procedure RemoveOtherVersions();
var
subs: TArrayOfString;
i, rc: Integer;
cmd: string;
begin
subs := OtherTestiumSubkeys();
for i := 0 to GetArrayLength(subs) - 1 do
if RegQueryStringValue(HKEY_CURRENT_USER, UninstallRoot + '\' + subs[i],
'UninstallString', cmd) and (cmd <> '') then
Exec(RemoveQuotes(cmd), '/VERYSILENT /SUPPRESSMSGBOXES /NORESTART',
'', SW_HIDE, ewWaitUntilTerminated, rc);
end;
// 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;
if WizardIsTaskSelected('removeold') then
RemoveOtherVersions();
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

@@ -79,42 +79,6 @@ a = Analysis(
) )
pyz = PYZ(a.pure) pyz = PYZ(a.pure)
# TESTIUM_ONEDIR=1 => one-folder build (fast startup), used by the Windows
# installer; default one-file keeps the Linux build_all portable binary.
ONEDIR = bool(os.environ.get("TESTIUM_ONEDIR"))
# UPX skipped via TESTIUM_NO_UPX (build_all --ram) — slow for a marginal gain.
_upx = not os.environ.get("TESTIUM_NO_UPX")
if ONEDIR:
exe = EXE(
pyz,
a.scripts,
[],
exclude_binaries=True,
name='testium',
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=_upx,
upx_exclude=[],
console=False,
disable_windowed_traceback=False,
argv_emulation=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None,
ico='../testium.ico'
)
coll = COLLECT(
exe,
a.binaries,
a.datas,
strip=False,
upx=_upx,
upx_exclude=[],
name='testium',
)
else:
exe = EXE( exe = EXE(
pyz, pyz,
a.scripts, a.scripts,
@@ -125,14 +89,16 @@ else:
debug=False, debug=False,
bootloader_ignore_signals=False, bootloader_ignore_signals=False,
strip=False, strip=False,
upx=_upx, # UPX is CPU+IO heavy for a marginal size gain — build_all --ram sets
# TESTIUM_NO_UPX=1 to skip it (much faster on slow/flash storage).
upx=not os.environ.get("TESTIUM_NO_UPX"),
upx_exclude=[], upx_exclude=[],
runtime_tmpdir=None, runtime_tmpdir=None,
console=False, console=True,
disable_windowed_traceback=False, disable_windowed_traceback=False,
argv_emulation=False, argv_emulation=False,
target_arch=None, target_arch=None,
codesign_identity=None, codesign_identity=None,
entitlements_file=None, entitlements_file=None,
ico='../testium.ico' ico='../testium.png'
) )

Binary file not shown.

Before

Width:  |  Height:  |  Size: 353 KiB

View File

@@ -1,57 +1,3 @@
version 0.3.2
==============
- The variables window (F1) now has a filter box: type to show only the
variables whose name matches. Tick "values" to also match on the value.
- The ``run`` item now captures the output of the test it launches into your
log and report (it used to go only to the terminal and was lost from the
report). New ``batch: true`` option runs that test headless (and captured)
even when testium is in the GUI.
- The captured output of a ``run`` step can be saved with ``store_result`` and
inspected afterwards (for example with ``expected_result`` or a ``py_func``).
- "Show Results" now opens the log on Flatpak (it used to do nothing) and can
be used while a test is running, not only after it finishes.
- Other places that open a file now work on Flatpak too: clicking a file path
in the log output, and the "open location" button in the variables (F1) window.
- Double-clicking a test item to open the log now uses an editor of your choice:
a new preference holds the command (default ``code -g {file}:{line}``); set it
to your editor (for example ``kate -l {line} {file}``). Works on Flatpak too.
version 0.3.1
==============
- Clearer errors when a test file fails to load. The message now names the
exact file and item and explains the problem (unknown item or action, a
step holding two items, a missing ``steps:`` list, a misplaced value, ...)
and lists the valid names, instead of a cryptic failure. A problem inside
an ``!include``-d file points to that file.
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 version 0.2.2
============== ==============
- Flatpak sandbox issue fixed for term console. Now a term console is - Flatpak sandbox issue fixed for term console. Now a term console is

View File

@@ -1 +1 @@
0.3.2 0.2.2

View File

@@ -11,16 +11,6 @@ sys.path.append(os.path.abspath(ourpath.parent))
import interpreter.utils.constants as cst import interpreter.utils.constants as cst
def main(): def main():
# 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 # Subcommand dispatch (must run *before* argparse so neither 'schema' nor
# 'lsp' has to share the GUI/batch flag surface). The subcommands also # 'lsp' has to share the GUI/batch flag surface). The subcommands also
# skip the multiprocessing 'spawn' setup which is only meaningful for the # skip the multiprocessing 'spawn' setup which is only meaningful for the

View File

@@ -2,7 +2,6 @@ from datetime import datetime
import sys import sys
import os import os
import re import re
import errno
from queue import Queue, Empty from queue import Queue, Empty
from time import sleep from time import sleep
import collections import collections
@@ -11,8 +10,6 @@ import threading
from telnetlib3 import Telnet, DO, WILL, WONT, TTYPE, IAC, SB, SE, theNULL from telnetlib3 import Telnet, DO, WILL, WONT, TTYPE, IAC, SB, SE, theNULL
from runtime.tum_except import ETUMRuntimeError
TIMEOUT_NULL = 0.000001 TIMEOUT_NULL = 0.000001
STOP_POLL_INTERVAL = 0.2 STOP_POLL_INTERVAL = 0.2
@@ -127,29 +124,7 @@ A {classname}.close() is missing somewhere in your code !'.format(classname=type
# c = '' # c = ''
return c return c
# Max chars of the buffer tail scanned in regex mode (bounds cost/memory). def read_until(self, match, timeout=None, return_data=False, mute=False, should_stop=None):
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):
""" """
read until the string 'match is found read until the string 'match is found
If timeout is not set (None), this function runs indefinitely 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 = '' read_data = ''
status = -1 status = -1
if not match: if not match:
raise ETUMRuntimeError("'expected' pattern can not be empty") raise ValueError('match parameter 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")
if timeout is None: if timeout is None:
timeout = 1000000 timeout = 1000000
compiled = None # Fixed-length queue that will contain the readout characters
search_deques = match_deques = None search_deque = collections.deque(maxlen=len(match))
if regex: # convert match string into a deque for faster comparisons
# 'matches' are regular expressions; succeed on the first hit. match_deque = collections.deque(match)
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
# In case of a timeout equal to zero, it must be looped until the # In case of a timeout equal to zero, it must be looped until the
# buffer is empty # buffer is empty
@@ -212,13 +167,9 @@ A {classname}.close() is missing somewhere in your code !'.format(classname=type
self.string_buffer += data self.string_buffer += data
read_data += data read_data += data
if regex: search_deque.append(data)
matched = self._search_regex(read_data, compiled) if search_deque == match_deque:
else:
matched = self._feed_match(data, search_deques, match_deques, matches)
if matched is not None:
status = 0 status = 0
self._matched = matched
if (not mute) and (data != '\n'): if (not mute) and (data != '\n'):
self.string_buffer += '\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 self.string_buffer += data
read_data += data read_data += data
if regex: search_deque.append(data)
matched = self._search_regex(read_data, compiled) if search_deque == match_deque:
else:
matched = self._feed_match(data, search_deques, match_deques, matches)
if matched is not None:
status = 0 status = 0
self._matched = matched
if (not mute) and (data != '\n'): if (not mute) and (data != '\n'):
self.string_buffer += '\n' self.string_buffer += '\n'
@@ -460,35 +407,20 @@ class SerialConsole(Console):
self.stop = threading.Event() self.stop = threading.Event()
self.port = None self.port = None
self.port_id = port self.port_id = port
self._thd = None
def open(self): def open(self):
try:
self.port = serial.Serial(port=self.port_id, self.port = serial.Serial(port=self.port_id,
baudrate=self.baudrate, baudrate=self.baudrate,
stopbits=self.stopbits, stopbits=self.stopbits,
parity=self.parity, parity=self.parity,
xonxoff=self.xonxoff, xonxoff=self.xonxoff,
timeout=None) timeout=None)
except (serial.SerialException, OSError) as e:
raise ETUMRuntimeError(self._open_error_message(e)) from None
self.isOpened = True self.isOpened = True
if self.bufferize: if self.bufferize:
self.port.timeout = 2 self.port.timeout = 2
self._thd = threading.Thread(target=self.read_thread) self._thd = threading.Thread(target=self.read_thread)
self._thd.start() 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): def read_thread(self):
while not self.stop.is_set(): while not self.stop.is_set():
c = self.port.read(1) c = self.port.read(1)
@@ -496,7 +428,7 @@ class SerialConsole(Console):
self.rx_queue.put(c) self.rx_queue.put(c)
def close(self): def close(self):
if self.bufferize and self._thd is not None: if self.bufferize:
self.stop.set() self.stop.set()
self._thd.join() self._thd.join()
if self.port is not None: if self.port is not None:
@@ -508,12 +440,10 @@ class SerialConsole(Console):
self.port.timeout = timeout self.port.timeout = timeout
def readchar(self, timeout): def readchar(self, timeout):
if not self.isOpened:
raise ETUMRuntimeError("Serial console '{}' is not open".format(self.name))
if self.bufferize: if self.bufferize:
if not self._thd.is_alive() and not self.stop.isSet(): if not self._thd.is_alive() and not self.stop.isSet():
raise ETUMRuntimeError( raise RuntimeError(
"Impossible to read the serial console, it may be already opened") "Impossible to read the serial console, it may be already openned")
if timeout < TIMEOUT_NULL: if timeout < TIMEOUT_NULL:
return self.rx_queue.get(block=False) return self.rx_queue.get(block=False)
else: else:
@@ -525,12 +455,10 @@ class SerialConsole(Console):
self.port.flush() self.port.flush()
def read_nowait(self, mute=False): def read_nowait(self, mute=False):
if not self.isOpened:
raise ETUMRuntimeError("Serial console '{}' is not open".format(self.name))
if self.bufferize: if self.bufferize:
if not self._thd.is_alive() and not self.stop.isSet(): if not self._thd.is_alive() and not self.stop.isSet():
raise ETUMRuntimeError( raise RuntimeError(
"Impossible to read the serial console, it may be already opened") "Impossible to read the serial console, it may be already openned")
st = self.rx_queue.getAll().decode(self.encoding, errors='replace') st = self.rx_queue.getAll().decode(self.encoding, errors='replace')
if not mute: if not mute:
date_str = str(datetime.now()).split('.')[0].split(' ')[1] date_str = str(datetime.now()).split('.')[0].split(' ')[1]

View File

@@ -39,36 +39,20 @@ class TestItemActions(TestItem):
def load(self): def load(self):
ret = {} ret = {}
if self.dict_actions is None:
self.dict_actions = []
if not isinstance(self.dict_actions, (list, tuple)):
raise ETUMSyntaxError(
f"The '{self.cmd()}' test item named '{self.name()}' expects a "
f"list of actions under 'steps' but got "
f"{type(self.dict_actions).__name__} ({self.dict_actions!r}).",
self.seqFilename()
)
known_actions = ", ".join(sorted(self.action_classes.keys())) or "(none)"
for action in self.dict_actions: for action in self.dict_actions:
# Each action must be a single-key mapping ``{action_name: {...}}``. # Action should be only dict of length 1
if not isinstance(action, dict) or len(action) != 1: if not isinstance(action, dict) or (not len(action) == 1):
raise ETUMSyntaxError( raise ETUMSyntaxError(
f"The '{self.cmd()}' test item named '{self.name()}' has an " f"The '{self.cmd()}' test item named '{self.name()}' action should be only dict of length = 1.",
f"invalid action: each action must be a single-key mapping "
f"('<action>: ...'), got {type(action).__name__} ({action!r}).",
self.seqFilename() self.seqFilename()
) )
action_name = list(action.keys())[0] action_name = list(action.keys())[0]
if action_name not in self.action_classes: if not (action_name in self.action_classes.keys()):
raise ETUMSyntaxError( raise ETUMSyntaxError(
f"The '{self.cmd()}' test item named '{self.name()}' has an " f"The '{self.cmd()}' test item named '{self.name()}' has an unknown action '{action.keys()[0]}'.",
f"unknown action '{action_name}'.\n"
f"Known actions: {known_actions}.",
self.seqFilename() self.seqFilename()
) )
# NB: an action body is not necessarily a mapping — several actions
# accept a scalar shorthand (e.g. ``writeln: 'echo hi'``); the action
# class validates its own body. Pass it through untouched.
item = (self.action_classes[action_name])( item = (self.action_classes[action_name])(
action_name, action_name,
action[action_name], action[action_name],

View File

@@ -61,13 +61,6 @@ def test_run(f):
self.run_test_init() 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: while self._is_paused:
sleep(0.2) sleep(0.2)
if self.isStopped() : if self.isStopped() :
@@ -152,17 +145,16 @@ class TestItem:
self._report_key = None self._report_key = None
self._reported = None self._reported = None
self.status_queue = status_queue self.status_queue = status_queue
self._execute_on_stop_raw = False self._execute_on_stop = False
self._post_eval = None self._post_eval = None
self._store_result = None self._store_result = None
self._expected_result = None self._expected_result = None
self._no_fail = None self._no_fail = None
self._is_stopped = False self._is_stopped = False
self._load_error = None
self._is_running = False self._is_running = False
self._is_breakpoint = False self._is_breakpoint = False
self._is_paused = False self._is_paused = False
self._stop_on_failure_raw = False self._stop_on_failure = False
self._doc = "" self._doc = ""
self._name = "" self._name = ""
self.report = None self.report = None
@@ -205,14 +197,13 @@ class TestItem:
self.skipped = False self.skipped = False
self._report_key = self._prms.getParam("key", default=None) self._report_key = self._prms.getParam("key", default=None)
# Kept raw: expanded at run time by the matching properties. self._stop_on_failure = self._prms.getParam(
self._stop_on_failure_raw = self._prms.getParam( "stop_on_failure", default=False, processed=True
"stop_on_failure", default=False
) )
self._doc = self._prms.getParam("doc", default="", processed=True) self._doc = self._prms.getParam("doc", default="", processed=True)
# #
self._execute_on_stop_raw = self._prms.getParam( self._execute_on_stop = self._prms.getParam(
"execute_on_stop", default=False "execute_on_stop", default=False, processed=True
) )
if "process_result" in dict_item: if "process_result" in dict_item:
@@ -579,20 +570,6 @@ class TestItem:
def setEnabled(self): def setEnabled(self):
self.enabled = True 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): def executedOnStop(self):
return self._execute_on_stop return self._execute_on_stop

View File

@@ -4,7 +4,7 @@ import importlib
import traceback import traceback
import api.testium as tm 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 runtime.stdout_redirect import stdio_redir
from interpreter.test_items.test_item import test_run from interpreter.test_items.test_item import test_run
from interpreter.test_items.item_actions import TestItemActions from interpreter.test_items.item_actions import TestItemActions
@@ -88,7 +88,7 @@ class TestItemConsoleOpen(TestItemConsoleAction):
telnet_host = self._prms.getParam( telnet_host = self._prms.getParam(
"telnet_host", required=True, processed=True "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": elif self._protocol == "ssh":
if tm.OS() == "Windows": if tm.OS() == "Windows":
@@ -225,16 +225,12 @@ class TestItemConsoleOpen(TestItemConsoleAction):
tm.add_console(cons) tm.add_console(cons)
cons.open() cons.open()
self.result.set(TestValue.SUCCESS) 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: except Exception as e:
# Unexpected error: keep the full traceback for diagnosis.
self.result.set( self.result.set(
result=TestValue.FAILURE, 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()) traceback.print_exception(*sys.exc_info())
@@ -323,17 +319,12 @@ class TestItemConsoleReadUntil(TestItemConsoleAction):
PARAMS = ParamSet( PARAMS = ParamSet(
Param("expected", required=True, Param("expected", required=True,
doc="Literal string — or a list of strings — matched against the " doc="Regex matched against incoming console output until found "
"incoming console output. The read succeeds as soon as one of " "or until timeout."),
"them is seen, or fails on timeout."),
Param("timeout", default=-1, Param("timeout", default=-1,
doc="Seconds before giving up. Negative means infinite."), doc="Seconds before giving up. Negative means infinite."),
Param("mute", default=False, Param("mute", default=False,
doc="If true, don't echo received bytes to testium's stdout/log."), 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__( def __init__(
@@ -352,21 +343,16 @@ class TestItemConsoleReadUntil(TestItemConsoleAction):
@test_run @test_run
def execute(self): def execute(self):
cons = self.get_console() 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) 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) mute = self._prms.getParam("mute", default=False, processed=True)
use_regex = self._prms.getParam("regex", default=False, processed=True)
if read_timeout < 0: if read_timeout < 0:
read_timeout = None read_timeout = None
try: try:
status, data = cons.read_until( status, data = cons.read_until(
ru, timeout=read_timeout, return_data=True, mute=mute, ru, timeout=read_timeout, return_data=True, mute=mute,
should_stop=self.isStopped, regex=bool(use_regex), should_stop=self.isStopped,
) )
if status == 0: if status == 0:
self.result.set(TestValue.SUCCESS) self.result.set(TestValue.SUCCESS)
@@ -378,21 +364,14 @@ class TestItemConsoleReadUntil(TestItemConsoleAction):
) )
else: else:
self.result.set(result=TestValue.FAILURE, message="No matching text") self.result.set(result=TestValue.FAILURE, message="No matching text")
reported = {"data": "" if mute else data} if mute:
# When several patterns were given, expose which one matched. self.result.reported = {"data": ""}
if status == 0 and isinstance(ru, (list, tuple)): else:
reported["matched"] = getattr(cons, "_matched", None) self.result.reported = {"data": data}
self.result.reported = reported
# The result is put in global dir # The result is put in global dir
tm.setgd("cn_" + self.parent()._name, data) tm.setgd("cn_" + self.parent()._name, data)
except ETUMRuntimeError as e: except:
# 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.
print(traceback.format_exc()) print(traceback.format_exc())
self.result.set( self.result.set(
result=TestValue.FAILURE, result=TestValue.FAILURE,

View File

@@ -51,8 +51,11 @@ class TestItemCycle(TestItem):
self._niter = None self._niter = None
if "iterator" in dict_cycle: if "iterator" in dict_cycle:
# Kept raw: expanded at run time in execute().
self._iter = dict_cycle["iterator"] self._iter = dict_cycle["iterator"]
if isinstance(self._iter, str):
self._iter = self._prms.expanse(self._iter)
else: else:
self._iter = None self._iter = None

View File

@@ -21,8 +21,7 @@ class TestItemGit(TestItem):
super().__init__(dict_item, parent, status_queue, filename=filename) super().__init__(dict_item, parent, status_queue, filename=filename)
self._type = cst.TYPE_GIT self._type = cst.TYPE_GIT
self.is_container = False self.is_container = False
# Kept raw: each repo entry is expanded at run time in execute(). self.repo = self._prms.getParamAll('repo', processed=True, required=True)
self.repo = self._prms.getParamAll('repo', required=True)
@test_run @test_run
def execute(self): def execute(self):

View File

@@ -90,7 +90,7 @@ class TestItemPyFunc(TestItem):
if not engine.is_alive(): if not engine.is_alive():
engine.start() engine.start()
if not engine.wait_ready(10): if not engine.wait_ready():
raise ETUMRuntimeError( raise ETUMRuntimeError(
f"""Impossible to start the external python execution process. f"""Impossible to start the external python execution process.
Is the python path correct ? 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

@@ -11,7 +11,6 @@ from interpreter.test_items.test_result import (TestValue)
import api.testium as tm import api.testium as tm
from interpreter.utils.constants import TestItemType as cst from interpreter.utils.constants import TestItemType as cst
from interpreter.utils.param_decl import Param, ParamSet from interpreter.utils.param_decl import Param, ParamSet
from interpreter.utils.proc_drain import drain_to_log
from runtime.tum_except import ETUMSyntaxError, ETUMRuntimeError, item_load_context from runtime.tum_except import ETUMSyntaxError, ETUMRuntimeError, item_load_context
@@ -76,9 +75,6 @@ class TestItemRun(TestItem):
Param("wait_for_exec", Param("wait_for_exec",
doc="If true, block until the time window opens. Requires both " doc="If true, block until the time window opens. Requires both "
"start_time and end_time."), "start_time and end_time."),
Param("batch", default=False,
doc="Run the sub-instance headless (-b) with its output captured "
"into this test's log/report and result value, even in the GUI."),
) )
def __init__(self, dict_item, parent = None, status_queue=None, filename=""): def __init__(self, dict_item, parent = None, status_queue=None, filename=""):
@@ -94,38 +90,6 @@ class TestItemRun(TestItem):
self.start_time = self._prms.getParam('start_time') self.start_time = self._prms.getParam('start_time')
self.end_time = self._prms.getParam('end_time') self.end_time = self._prms.getParam('end_time')
self.wait_for_exec = self._prms.getParam('wait_for_exec') self.wait_for_exec = self._prms.getParam('wait_for_exec')
self.batch = self._prms.getParam('batch', default=False)
def _launch(self, cmd, capture):
"""Run the sub-instance once. When *capture*, stream its output to the
log/report, keep it as the result value, and let Stop kill the child."""
if not capture:
subprocess.run(cmd)
return
sink = []
prefix = f"[{os.path.basename(self.tum_file)}] "
proc = subprocess.Popen(
cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE
)
threads = drain_to_log(proc, prefix=prefix, sink=sink)
try:
while True:
try:
proc.wait(timeout=0.2)
break
except subprocess.TimeoutExpired:
if self.isStopped():
proc.terminate()
try:
proc.wait(timeout=2)
except subprocess.TimeoutExpired:
proc.kill()
break
finally:
for t in threads:
t.join(timeout=2)
# Captured log -> result value (store_result / expected_result).
self.result.value = "\n".join(sink)
@test_run @test_run
def execute(self): def execute(self):
@@ -140,26 +104,25 @@ class TestItemRun(TestItem):
pf = self._prms.expanse(self.param_file) pf = self._prms.expanse(self.param_file)
lp = self._prms.expanse(self.log_path) lp = self._prms.expanse(self.log_path)
rp = self._prms.expanse(self.report_path) rp = self._prms.expanse(self.report_path)
# Capture (headless -b) in batch or when `batch: true`; else open
# the child's own GUI window (-r).
capture = bool(self.batch) or tm.text_mode()
cmd = _testium_launch_cmd() cmd = _testium_launch_cmd()
if capture: if tm.text_mode():
cmd += ["-b", "-o"] # -o: no colour codes in the captured log cmd.append("-b")
else: else:
cmd.append("-r") cmd.append("-r")
if lp == '': if lp == '':
lp = os.path.splitext(self.tum_file)[0] + "_" + \ lp = os.path.splitext(self.tum_file)[0] + "_" + \
datetime.utcnow().isoformat(timespec='seconds') + '.log' datetime.utcnow().isoformat(timespec='seconds') + '.log'
cmd += ["-l", '"' + lp + '"'] cmd.append("-l")
cmd.append('"' + lp + '"')
if pf != '': if pf != '':
cmd += ["-c", '"' + pf + '"'] cmd.append("-c")
cmd.append('"' + pf + '"')
if rp != '': if rp != '':
cmd += ["-p", '"' + rp + '"'] cmd.append("-p")
cmd.append('"' + rp + '"')
cmd.append(self.tum_file) cmd.append(self.tum_file)
print(" ".join(cmd)) for c in cmd:
print(c, end = ' ')
if self.start_time is not None: if self.start_time is not None:
self.start_time = datetime.strptime( self.start_time = datetime.strptime(
@@ -172,24 +135,20 @@ class TestItemRun(TestItem):
raise ETUMRuntimeError( raise ETUMRuntimeError(
'"wait_for_exec" set but not start_time or end_time') '"wait_for_exec" set but not start_time or end_time')
ran = False r = None
if self.wait_for_exec: if self.wait_for_exec:
while not nowInBetween(self.start_time, self.end_time): while not nowInBetween(self.start_time, self.end_time):
sleep(60) sleep(60)
self._launch(cmd, capture) r = subprocess.run(cmd)
ran = True
elif self.start_time is not None and self.end_time is not None: elif self.start_time is not None and self.end_time is not None:
if nowInBetween(self.start_time, self.end_time): if nowInBetween(self.start_time, self.end_time):
self._launch(cmd, capture) r = subprocess.run(cmd)
ran = True
elif self.start_time is not None: elif self.start_time is not None:
if self.start_time < datetime.now().time(): if self.start_time < datetime.now().time():
self._launch(cmd, capture) r = subprocess.run(cmd)
ran = True
else: else:
self._launch(cmd, capture) r = subprocess.run(cmd)
ran = True if isinstance(r, subprocess.CompletedProcess):
if ran:
self.result.set(TestValue.SUCCESS) self.result.set(TestValue.SUCCESS)
else: else:
self.result.set(TestValue.FAILURE, 'Sub-test did not execute') self.result.set(TestValue.FAILURE, 'Sub-test did not execute')

View File

@@ -26,14 +26,13 @@ class TestItemTestedRefsDialog(TestItemDialogBase):
self.is_container = False self.is_container = False
with item_load_context(self.cmd(), self.name(), self.seqFilename()): with item_load_context(self.cmd(), self.name(), self.seqFilename()):
self._question = self._prms.getParam('question', required=True) 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, processed=True)
self._init_values = self._prms.getParamAll('reference', required=False)
self._auto_result = self._prms.getParam('auto_result', required=False, default=None) self._auto_result = self._prms.getParam('auto_result', required=False, default=None)
@test_run @test_run
def execute(self): def execute(self):
q = self._prms.expanse(self._question) 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(): if _is_text_mode():
print(f"References: {q}") print(f"References: {q}")
rows = init_values.split(',') if init_values else [''] rows = init_values.split(',') if init_values else ['']

View File

@@ -14,7 +14,7 @@ class ReportExportHTML(rpe.ReportExport):
self.prepareFile() self.prepareFile()
self.create_base() self.create_base()
self.process_tests() 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()) f.write(lxml.html.tostring(self.root, pretty_print=True).decode())
def testsIterate(self, row): def testsIterate(self, row):

View File

@@ -20,7 +20,7 @@ class ReportExportJUnit(rpe.ReportExport):
ts = TestSuite(repname, test_cases=self.test_cases, ts = TestSuite(repname, test_cases=self.test_cases,
hostname=tm.gd('host_ip')) 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]) TestSuite.to_file(f, [ts])
def testsIterate(self, row): def testsIterate(self, row):

View File

@@ -3,7 +3,7 @@ import datetime
from queue import Queue from queue import Queue
from interpreter.utils.params import expanse from interpreter.utils.params import expanse
import api.testium as tm import api.testium as tm
from runtime.tum_except import ETUMSyntaxError, ETUMError from runtime.tum_except import ETUMSyntaxError
import interpreter.utils.settings as prefs import interpreter.utils.settings as prefs
from interpreter.test_report.test_report import TestReport from interpreter.test_report.test_report import TestReport
from interpreter.utils.py_func_exec import PyFuncExecEngine from interpreter.utils.py_func_exec import PyFuncExecEngine
@@ -65,22 +65,9 @@ def _flatten_actions(actions, out, parent_seq_name):
f"Syntax error in '{parent_seq_name}' step number {idx+1}. Sequence definition: '{str(action)}'", f"Syntax error in '{parent_seq_name}' step number {idx+1}. Sequence definition: '{str(action)}'",
f f
) )
if not isinstance(sequence, list):
raise ETUMSyntaxError(
f"Invalid included sequence in '{parent_seq_name}' "
f"(step {idx+1}): expected a list of steps, got "
f"{type(sequence).__name__}.",
f
)
for s in sequence: for s in sequence:
# Propagate the source filename onto each included step. Only a if isinstance(s, dict) and s:
# single-key mapping with a mapping body can carry it; malformed s[list(s.keys())[0]]["seq_filename"] = f
# entries are left untouched and reported by the loader below,
# with their real location.
if isinstance(s, dict) and len(s) == 1:
body = s[next(iter(s))]
if isinstance(body, dict):
body["seq_filename"] = f
_flatten_actions(sequence, out, parent_seq_name) _flatten_actions(sequence, out, parent_seq_name)
continue continue
@@ -403,19 +390,7 @@ class TestSet:
self._rootItem = (cst_type.TYPE_ROOT.item_class)( self._rootItem = (cst_type.TYPE_ROOT.item_class)(
dict_item=dict_main, status_queue=self.status_queue dict_item=dict_main, status_queue=self.status_queue
) )
try:
ret = self.load_test_recursively(self._rootItem, dict_main, filename) ret = self.load_test_recursively(self._rootItem, dict_main, filename)
except ETUMError:
# Already a located, user-readable testium error.
raise
except Exception as e:
# Last-resort net: turn any unforeseen failure into a located error
# rather than a bare traceback / 'crashed for any reason'.
raise ETUMSyntaxError(
f"Unexpected error while building the test tree: "
f"{type(e).__name__}: {e}",
filename
) from e
self.set_post_exec() self.set_post_exec()
return ret return ret
@@ -476,59 +451,32 @@ class TestSet:
def rootItem(self): def rootItem(self):
return self._rootItem 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): def load_test_recursively(self, tree_parent, parent_seq, file_name):
ret = {} ret = {}
path = _build_item_path(tree_parent)
if not isinstance(parent_seq, dict):
raise ETUMSyntaxError(
f"In: {path}\n"
f"The body of '{tree_parent.cmd()}' must be a mapping (with a "
f"'steps' list) but is {type(parent_seq).__name__} "
f"({parent_seq!r}).",
file_name
)
try: try:
parent_seq_name = parent_seq["name"] parent_seq_name = parent_seq["name"]
except KeyError: except KeyError:
parent_seq["name"] = "sequence" parent_seq["name"] = "sequence"
parent_seq_name = "sequence" except TypeError:
raise ETUMSyntaxError(
f"No 'name' attribute in '{tree_parent.type()}' (a child of '{tree_parent.parent().name()}')",
file_name
)
try: try:
parent_seq_actions = parent_seq["steps"] parent_seq_actions = parent_seq["steps"]
except KeyError: except KeyError:
raise ETUMSyntaxError( raise ETUMSyntaxError(
f"In: {path}\n" f"No step list found for '{parent_seq_name}' sequence. \n" +
f"No 'steps' list found for the '{tree_parent.cmd()}' item " f"Check the syntax of the 'steps' parameter of the '{tree_parent.cmd()}' test item definition.",
f"'{parent_seq_name}'.\n"
f"A container item must declare its children under 'steps:'.",
file_name file_name
) )
# if action is a dictionary , we assume it is a single action # if action is a dictionary , we assume it is a single action
# that has not been nested in a list, so do it # that has not been nested in a list, so do it
if isinstance(parent_seq_actions, (dict)): if isinstance(parent_seq_actions, (dict)):
parent_seq_actions = [parent_seq_actions] parent_seq_actions = [parent_seq_actions]
# an empty 'steps:' (None) is a valid, empty sequence
if parent_seq_actions is None:
parent_seq_actions = []
if not isinstance(parent_seq_actions, (list, tuple)): if not isinstance(parent_seq_actions, (list, tuple)):
raise ETUMSyntaxError( raise ETUMSyntaxError(
f"In: {path}\n" f"No valid list of actions in sequence {parent_seq_name}",
f"The 'steps' of '{parent_seq_name}' must be a list of test "
f"items but is {type(parent_seq_actions).__name__} "
f"({parent_seq_actions!r}).",
file_name file_name
) )
test_dir = tm.gd("test_directory") test_dir = tm.gd("test_directory")
@@ -540,50 +488,10 @@ class TestSet:
_flatten_actions(parent_seq_actions, flat_actions, parent_seq_name) _flatten_actions(parent_seq_actions, flat_actions, parent_seq_name)
for action in flat_actions: for action in flat_actions:
# After flattening, each step must be a single-key mapping # Action is now for sure a dict of length 1
# '{item_cmd: {params...}}'. Anything else is a structural mistake
# in the .tum (a stray scalar, a missing '-' marker, an over- or
# under-indented block) — report it with its location instead of
# crashing on it below.
if not isinstance(action, dict):
raise ETUMSyntaxError(
f"In: {path}\n"
f"A step is not a valid test item: expected a "
f"'<item>: ...' mapping but got {type(action).__name__} "
f"({action!r}).\n"
f"Check the indentation and the '-' list markers of 'steps'.",
file_name
)
if len(action) != 1:
raise ETUMSyntaxError(
f"In: {path}\n"
f"A step must define exactly one test item but defines "
f"{len(action)}: {sorted(map(str, action.keys()))}.\n"
f"Each '-' step holds a single '<item>:'; the lines below it "
f"are probably its parameters and need one more indent level.",
file_name
)
k = list(action.keys())[0] k = list(action.keys())[0]
if action[k].get("seq_filename", None) is None:
# The body of an item is its parameter mapping. A bare '<item>:' action[k]["seq_filename"] = file_name
# (None) is tolerated as an empty parameter set; a scalar or list is
# a structural mistake and is reported with its location.
body = action[k]
if body is None:
body = {}
action[k] = body
if not isinstance(body, dict):
raise ETUMSyntaxError(
f"In: {path}\n"
f"The body of test item '{k}' must be a mapping of "
f"parameters but is {type(body).__name__} ({body!r}).",
file_name
)
if body.get("seq_filename", None) is None:
body["seq_filename"] = file_name
seq_filename = body["seq_filename"]
executed = False executed = False
for it in TEST_TYPE_LIST: for it in TEST_TYPE_LIST:
@@ -595,65 +503,52 @@ class TestSet:
(it.item_class is None) (it.item_class is None)
): ):
continue continue
if k not in (it.item_cmd, cst.FOLDED_CHAR + it.item_cmd): if (it.item_cmd in action) or (
continue (cst.FOLDED_CHAR + it.item_cmd) in action
):
executed = True executed = True
# A "." before the cmd name means the item is folded in the GUI is_folded = False
is_folded = k.startswith(cst.FOLDED_CHAR) action_name = it.item_cmd
# Check if a "." is before the cmd_name (meaning folded)
if (cst.FOLDED_CHAR + it.item_cmd) in action:
is_folded = True
action_name = cst.FOLDED_CHAR + it.item_cmd
seq_filename = action[action_name]["seq_filename"]
try: try:
item = (it.item_class)( item = (it.item_class)(
body, action[action_name],
tree_parent, tree_parent,
self.status_queue, self.status_queue,
filename=seq_filename filename=seq_filename
) )
except ETUMSyntaxError as e:
path = _build_item_path(tree_parent)
raise ETUMSyntaxError(
f"In: {path}\n{e._message}",
e._file or seq_filename,
) from e
item.is_folded = is_folded item.is_folded = is_folded
child = {} child = {}
# case where the test item loads itself its descendants # 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) item.setTestDir(test_dir)
child = self._load_item(item) child = item.load()
elif issubclass(it.item_class, TestItemActions): elif issubclass(it.item_class, TestItemActions):
child = item.load() child = item.load()
# case where the test item is an items container # case where the test item is an items container
elif item.is_container: elif item.is_container:
child = self.load_test_recursively( child = self.load_test_recursively(
item, body, seq_filename item, action[action_name], seq_filename
) )
except ETUMSyntaxError as e:
# Already a syntax error: prepend the breadcrumb to its
# location (unless it already carries one from a deeper level).
msg = e._message
if not msg.lstrip().startswith("In:"):
msg = f"In: {path} > {k}\n{msg}"
raise ETUMSyntaxError(msg, e._file or seq_filename) from e
except ETUMError:
# Other testium errors (missing parameter, runtime, I/O)
# already carry structured context (item type, name,
# parameter, ...): let them through unchanged.
raise
except Exception as e:
# Anything unexpected: never let a raw Python error reach the
# user as 'crashed for any reason' — locate it precisely.
raise ETUMSyntaxError(
f"In: {path} > {k}\n"
f"Unexpected error while loading this item: "
f"{type(e).__name__}: {e}",
seq_filename
) from e
ret.update(test_data(item, child)) ret.update(test_data(item, child))
if not executed: if not executed:
known = ", ".join(
t.item_cmd for t in TEST_TYPE_LIST
if t is not cst_type.TYPE_ROOT and t.item_class is not None
)
raise ETUMSyntaxError( raise ETUMSyntaxError(
f"In: {path}\n" f"test item '{k}' is not known.",
f"'{k}' is not a known test item.\n" action[k]["seq_filename"]
f"Known items: {known}.",
seq_filename
) )
return ret return ret

View File

@@ -25,7 +25,7 @@ import subprocess
import tempfile import tempfile
import api.testium as tm 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 from runtime.tum_except import ETUMRuntimeError
@@ -199,23 +199,6 @@ def host_console_command(shell_cmd, cwd):
return ["flatpak-spawn", "--host", f"--directory={cwd}", *argv] return ["flatpak-spawn", "--host", f"--directory={cwd}", *argv]
def host_open_path(path):
"""Open *path* with the host default application (Flatpak only).
QDesktopServices/openUrl routes through the OpenURI portal inside Flatpak,
which often fails to open a plain editor for a log file. Spawn xdg-open on
the host so the user's real default app is used. Returns True on dispatch;
False (incl. outside Flatpak) so the caller can fall back to openUrl.
"""
if not _in_flatpak():
return False
try:
subprocess.Popen(["flatpak-spawn", "--host", "xdg-open", path])
return True
except (FileNotFoundError, PermissionError):
return False
def _which_host_flatpak(name): def _which_host_flatpak(name):
"""Resolve a binary name (or absolute path) on the host via flatpak-spawn. """Resolve a binary name (or absolute path) on the host via flatpak-spawn.
@@ -289,7 +272,6 @@ def _run_probe(cmd):
r = subprocess.run( r = subprocess.run(
cmd, capture_output=True, text=True, cmd, capture_output=True, text=True,
encoding=tm.sys_encoding(), timeout=10, env=_probe_env(), encoding=tm.sys_encoding(), timeout=10, env=_probe_env(),
**no_window_kwargs(),
) )
except (FileNotFoundError, PermissionError, subprocess.TimeoutExpired): except (FileNotFoundError, PermissionError, subprocess.TimeoutExpired):
return None return None
@@ -406,16 +388,12 @@ def ensure(*names):
""" """
missing = [] missing = []
for n in names: for n in names:
path = _resolve(n) if not _resolve(n):
display, gd_key, candidates, _ = _SPECS[n] display, gd_key, candidates, _ = _SPECS[n]
if not path:
missing.append( missing.append(
f" - {display}: tried {candidates} on PATH, none usable. " f" - {display}: tried {candidates} on PATH, none usable. "
f"Set '{gd_key}' in the YAML config to override." 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: if missing:
raise ETUMRuntimeError( raise ETUMRuntimeError(
"Required external interpreter(s) not found:\n" + "\n".join(missing) "Required external interpreter(s) not found:\n" + "\n".join(missing)

View File

@@ -10,8 +10,6 @@ class TestItemEnum():
class TestItemType(Enum): class TestItemType(Enum):
TYPE_UNITTEST = TestItemEnum("unittest", "unittest") TYPE_UNITTEST = TestItemEnum("unittest", "unittest")
TYPE_UNITTEST_STEP = TestItemEnum("unittest_step", "unittest step") 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 = TestItemEnum("console", "Console")
TYPE_CONSOLE_ACTION = TestItemEnum("console_action", "Console action") TYPE_CONSOLE_ACTION = TestItemEnum("console_action", "Console action")
TYPE_CYCLE = TestItemEnum("loop", "Cycle") TYPE_CYCLE = TestItemEnum("loop", "Cycle")

View File

@@ -1,13 +1,14 @@
import os import os
import sys import sys
import subprocess import subprocess
import socket
import api.testium as tm import api.testium as tm
from runtime.jrpc import JsonRpcClient 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 runtime.tum_except import ETUMRuntimeError
from interpreter.utils import bins 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: class LuaProcessBase:
@@ -78,7 +79,12 @@ class LuaProcessBase:
else: else:
env[k] = e + ";" + env.get(k, "") env[k] = e + ";" + env.get(k, "")
# POpen params (port 0 -> the Lua server picks a free port and reports it) sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.bind(("localhost", 0))
self._port = sock.getsockname()[1]
sock.close()
# POpen params
cmd_args = [ cmd_args = [
"main.lua", "main.lua",
"--timeout", "--timeout",
@@ -86,7 +92,7 @@ class LuaProcessBase:
"--host", "--host",
"127.0.0.1", "127.0.0.1",
"--port", "--port",
"0", f"{self._port}",
] ]
if tm.debug_enabled() and tm.gd("debug_rpc", False): if tm.debug_enabled() and tm.gd("debug_rpc", False):
@@ -114,19 +120,12 @@ class LuaProcessBase:
stdout=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.PIPE, stderr=subprocess.PIPE,
restore_signals=False, restore_signals=False,
**no_window_kwargs(),
**popen_kwargs, **popen_kwargs,
) )
# Forward subprocess output to the log and read the startup port sentinel. # Route subprocess stdout/stderr (lua require failures, syntax
holder = drain_and_read_port(self._process, prefix="[lua_func] ") # errors, anything written to fd 1/2 before the in-script
self._port = wait_for_port( # remote_print is set up) into the parent's log.
self._process, holder, tm.gd("proc_start_timeout", 30) drain_to_log(self._process, prefix="[lua_func] ")
)
if self._port is None:
# Worker died before announcing its port: reset so a later start() retries clean.
self.stop()
self.join()
return
self._rpc = JsonRpcClient( self._rpc = JsonRpcClient(
"localhost", self._port, req_handler=self._req_handler "localhost", self._port, req_handler=self._req_handler

View File

@@ -8,14 +8,6 @@ import subprocess
import api.testium as tm 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(): def testium_path():
if getattr(sys, 'frozen', False): if getattr(sys, 'frozen', False):
@@ -62,7 +54,6 @@ def sys_app_path_win(app_name):
text=True, text=True,
encoding="oem", encoding="oem",
timeout=10, timeout=10,
**no_window_kwargs(),
) )
data = result.stdout data = result.stdout
except (FileNotFoundError, PermissionError, subprocess.TimeoutExpired): except (FileNotFoundError, PermissionError, subprocess.TimeoutExpired):

View File

@@ -8,12 +8,9 @@ exceptions before the in-process redirection kicks in, lua
``require`` failures, anything written to fd 1/2 directly). ``require`` failures, anything written to fd 1/2 directly).
""" """
import threading import threading
from time import monotonic
from runtime.jrpc import RPC_PORT_SENTINEL
def _drain_pipe(pipe, prefix, sink=None): def _drain_pipe(pipe, prefix):
try: try:
for raw in iter(pipe.readline, b""): for raw in iter(pipe.readline, b""):
line = raw.decode("utf-8", errors="replace").rstrip("\r\n") line = raw.decode("utf-8", errors="replace").rstrip("\r\n")
@@ -23,9 +20,6 @@ def _drain_pipe(pipe, prefix, sink=None):
print(f"{prefix}{line}") print(f"{prefix}{line}")
else: else:
print(line) print(line)
# sink keeps the clean (unprefixed) line for reuse as a result value
if sink is not None:
sink.append(line)
finally: finally:
try: try:
pipe.close() pipe.close()
@@ -33,74 +27,22 @@ def _drain_pipe(pipe, prefix, sink=None):
pass pass
def drain_to_log(process, prefix="", sink=None): def drain_to_log(process, prefix=""):
"""Stream the subprocess stdout/stderr line by line through the parent's """Spawn daemon threads that read ``process.stdout`` and
print pipeline (log + live output). If ``sink`` is a list, each clean line ``process.stderr`` line by line and print each line through the
is also appended to it (GIL-atomic, shared by both threads). Daemon threads.""" parent's stdout (so it reaches the log + live output).
Each thread exits cleanly when the subprocess closes the
corresponding pipe (i.e. when it exits). Daemon flag ensures they
do not block testium exit.
"""
threads = [] threads = []
for pipe in (process.stdout, process.stderr): for pipe in (process.stdout, process.stderr):
if pipe is None: if pipe is None:
continue continue
t = threading.Thread( t = threading.Thread(
target=_drain_pipe, args=(pipe, prefix, sink), daemon=True, target=_drain_pipe, args=(pipe, prefix), daemon=True,
) )
t.start() t.start()
threads.append(t) threads.append(t)
return threads 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 os
import sys import sys
import subprocess import subprocess
import socket
from runtime.jrpc import JsonRpcClient from runtime.jrpc import JsonRpcClient
import api.testium as tm import api.testium as tm
from runtime.tum_except import ETUMRuntimeError 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 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: class PyProcessBase:
@@ -53,6 +54,13 @@ class PyProcessBase:
else: else:
env[k] = e + os.pathsep + env.get(k, "") env[k] = e + os.pathsep + env.get(k, "")
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()
# In Flatpak the host can't see /app/lib/testium, so use a staged copy # 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 # under /tmp (shared between sandbox and host) for both cwd and as the
# root in PYTHONPATH. Outside Flatpak the original paths are used. # root in PYTHONPATH. Outside Flatpak the original paths are used.
@@ -67,7 +75,7 @@ class PyProcessBase:
cmd_args = [ cmd_args = [
"py_func", "py_func",
"-p", "-p",
"0", f"{self._port}",
"-t", "-t",
f"{self._timeout}", f"{self._timeout}",
] ]
@@ -97,19 +105,13 @@ class PyProcessBase:
stdout=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.PIPE, stderr=subprocess.PIPE,
restore_signals=False, restore_signals=False,
**no_window_kwargs(),
**popen_kwargs, **popen_kwargs,
) )
# Forward subprocess output to the log and read the startup port sentinel. # Route subprocess stdout/stderr (early-startup errors,
holder = drain_and_read_port(self._process, prefix="[py_func] ") # unhandled exceptions, anything written to fd 1/2 before the
self._port = wait_for_port( # in-process JSON-RPC stdio_redir kicks in) into the parent's
self._process, holder, tm.gd("proc_start_timeout", 30) # log.
) drain_to_log(self._process, prefix="[py_func] ")
if self._port is None:
# Worker died before announcing its port: reset so a later start() retries clean.
self.stop()
self.join()
return
self._rpc = JsonRpcClient( self._rpc = JsonRpcClient(
"localhost", self._port, req_handler=self._req_handler "localhost", self._port, req_handler=self._req_handler

View File

@@ -13,59 +13,30 @@ def init():
settings = TestiumSettings() settings = TestiumSettings()
_UNSET = object()
class SettingsItem(): class SettingsItem():
def __init__(self, name: str, item_type: type, default=None) -> None: def __init__(self, name: str, item_type: type) -> None:
self.name = name self.name = name
self.t = item_type self.t = item_type
self.default = default
def _pref(item):
"""Build a get/set property reading/writing *item* (default carried by the item)."""
return property(lambda self: self.value(item),
lambda self, value: self.set_value(item, value))
class TestiumSettings(): class TestiumSettings():
SettingsRecentFiles = SettingsItem('recentFileList', list, []) SettingsRecentFiles = SettingsItem('recentFileList', list)
SettingsLastLogFile = SettingsItem('lastLogFile', str, '') SettingsLastLogFile = SettingsItem('lastLogFile', str)
SettingsLogFileSaved = SettingsItem('logFileSaved', bool, False) SettingsLogFileSaved = SettingsItem('logFileSaved', bool)
SettingsHideDocPane = SettingsItem('docPaneHidden', bool, False) SettingsHideDocPane = SettingsItem('docPaneHidden', bool)
SettingsHideLogPane = SettingsItem('logPaneHidden', bool, False) SettingsHideLogPane = SettingsItem('logPaneHidden', bool)
SettingsShowCheckboxes = SettingsItem('checkBoxesShow', bool, False) SettingsShowCheckboxes = SettingsItem('checkBoxesShow', bool)
SettingsLogPath = SettingsItem('defaultLogPath', str, '$(test_directory)') SettingsLogPath = SettingsItem('defaultLogPath', str)
SettingsReportPath = SettingsItem('defaultReportPath', str, '$(test_directory)') SettingsReportPath = SettingsItem('defaultReportPath', str)
SettingsShowTimeColumn = SettingsItem('showTimeColumn', bool, False) SettingsShowTimeColumn = SettingsItem('showTimeColumn', bool)
SettingsColumnsSize = SettingsItem('columnsSize', dict, {}) SettingsColumnsSize = SettingsItem('columnsSize', dict)
SettingsDblClickEnabled = SettingsItem('dblClickEnabled', bool, False) SettingsDblClickEnabled = SettingsItem('dblClickEnabled', bool)
SettingsEditorCmd = SettingsItem('editorCmd', str, 'code -g {file}:{line}') SettingsIconsTheme = SettingsItem('iconsTheme', int)
SettingsIconsTheme = SettingsItem('iconsTheme', int, 0) SettingsLogFont = SettingsItem('logFont', str)
SettingsLogFont = SettingsItem('logFont', str, 'Monospace') SettingsLogFontSize = SettingsItem('logFontSize', int)
SettingsLogFontSize = SettingsItem('logFontSize', int, 8) SettingsGitSupported = SettingsItem('logGitSupported', bool)
SettingsGitSupported = SettingsItem('logGitSupported', bool, True) SettingsPythonPath = SettingsItem('pythonPath', str)
SettingsPythonPath = SettingsItem('pythonPath', str, '') SettingsLuaPath = SettingsItem('luaPath', str)
SettingsLuaPath = SettingsItem('luaPath', str, '')
recent_files = _pref(SettingsRecentFiles)
log_file = _pref(SettingsLastLogFile)
log_file_saved = _pref(SettingsLogFileSaved)
hide_doc_pane = _pref(SettingsHideDocPane)
hide_log_pane = _pref(SettingsHideLogPane)
show_checkboxes = _pref(SettingsShowCheckboxes)
log_path = _pref(SettingsLogPath)
report_path = _pref(SettingsReportPath)
show_time_column = _pref(SettingsShowTimeColumn)
columns_size = _pref(SettingsColumnsSize)
dbl_click_enabled = _pref(SettingsDblClickEnabled)
editor_cmd = _pref(SettingsEditorCmd)
icons_theme = _pref(SettingsIconsTheme)
log_font = _pref(SettingsLogFont)
git_supported = _pref(SettingsGitSupported)
python_bin = _pref(SettingsPythonPath)
lua_bin = _pref(SettingsLuaPath)
def __init__(self): def __init__(self):
if 'windows' in platform.system().lower(): if 'windows' in platform.system().lower():
@@ -100,11 +71,9 @@ class TestiumSettings():
self.conf['Default'] = {} self.conf['Default'] = {}
self.sync() self.sync()
def value(self, key: SettingsItem, default=_UNSET): def value(self, key: SettingsItem, default=''):
if not isinstance(key, SettingsItem): if not isinstance(key, SettingsItem):
raise ETUMRuntimeError('Not a proper Settings item.') raise ETUMRuntimeError('Not a proper Settings item.')
if default is _UNSET:
default = key.default
if type(default) != key.t: if type(default) != key.t:
raise ETUMRuntimeError( raise ETUMRuntimeError(
'Types mismatch in config file. You could try to erase "{}" to solve the issue'.format(self.settings_fname)) 'Types mismatch in config file. You could try to erase "{}" to solve the issue'.format(self.settings_fname))
@@ -151,10 +120,127 @@ class TestiumSettings():
if configfile.writable(): if configfile.writable():
self.conf.write(configfile) self.conf.write(configfile)
# log_font_size keeps a custom getter: clamp non-positive sizes to 8. # SettingsRecentFiles = 'recentFileList'
@property
def recent_files(self):
return self.value(self.SettingsRecentFiles, [])
@recent_files.setter
def recent_files(self, value):
self.set_value(self.SettingsRecentFiles, value)
# SettingsLastLogFile = 'lastLogFile'
@property
def log_file(self):
return self.value(self.SettingsLastLogFile)
@log_file.setter
def log_file(self, value):
self.set_value(self.SettingsLastLogFile, value)
# SettingsLogFileSaved = 'logFileSaved'
@property
def log_file_saved(self):
return self.value(self.SettingsLogFileSaved, False)
@log_file_saved.setter
def log_file_saved(self, value):
self.set_value(self.SettingsLogFileSaved, value)
# SettingsHideDocPane = 'docPaneHidden'
@property
def hide_doc_pane(self):
return self.value(self.SettingsHideDocPane, False)
@hide_doc_pane.setter
def hide_doc_pane(self, value):
self.set_value(self.SettingsHideDocPane, value)
# SettingsHideLogPane = 'logPaneHidden'
@property
def hide_log_pane(self):
return self.value(self.SettingsHideLogPane, False)
@hide_log_pane.setter
def hide_log_pane(self, value):
self.set_value(self.SettingsHideLogPane, value)
# SettingsShowCheckboxes = 'checkBoxesShow'
@property
def show_checkboxes(self):
return self.value(self.SettingsShowCheckboxes, False)
@show_checkboxes.setter
def show_checkboxes(self, value):
self.set_value(self.SettingsShowCheckboxes, value)
# SettingsLogPath = 'defaultLogPath'
@property
def log_path(self):
return self.value(self.SettingsLogPath, '$(test_directory)')
@log_path.setter
def log_path(self, value):
self.set_value(self.SettingsLogPath, value)
# SettingsReportPath = 'defaultReportPath'
@property
def report_path(self):
return self.value(self.SettingsReportPath, '$(home)')
@report_path.setter
def report_path(self, value):
self.set_value(self.SettingsReportPath, value)
# SettingsShowTimeColumn = 'showTimeColumn'
@property
def show_time_column(self):
return self.value(self.SettingsShowTimeColumn, False)
@show_time_column.setter
def show_time_column(self, value):
self.set_value(self.SettingsShowTimeColumn, value)
# SettingsColumnsSize = 'columnsSize'
@property
def columns_size(self):
return self.value(self.SettingsColumnsSize, {})
@columns_size.setter
def columns_size(self, value):
self.set_value(self.SettingsColumnsSize, value)
# SettingsDblClickEnabled = 'dblClickEnabled'
@property
def dbl_click_enabled(self):
return self.value(self.SettingsDblClickEnabled, False)
@dbl_click_enabled.setter
def dbl_click_enabled(self, value):
self.set_value(self.SettingsDblClickEnabled, value)
# SettingsIconsTheme = 'iconsTheme'
@property
def icons_theme(self):
return self.value(self.SettingsIconsTheme, 0)
@icons_theme.setter
def icons_theme(self, value):
self.set_value(self.SettingsIconsTheme, value)
# SettingsLogFont = 'logFont'
@property
def log_font(self):
return self.value(self.SettingsLogFont, 'Monospace')
@log_font.setter
def log_font(self, value):
self.set_value(self.SettingsLogFont, value)
# SettingsLogFontSize = 'logFontSize'
@property @property
def log_font_size(self): def log_font_size(self):
v = self.value(self.SettingsLogFontSize) v = self.value(self.SettingsLogFontSize, 8)
if v <= 0: if v <= 0:
v = 8 v = 8
return v return v
@@ -162,3 +248,33 @@ class TestiumSettings():
@log_font_size.setter @log_font_size.setter
def log_font_size(self, value): def log_font_size(self, value):
self.set_value(self.SettingsLogFontSize, value) self.set_value(self.SettingsLogFontSize, value)
# SettingsGitSupported = 'gitSupported'
@property
def git_supported(self):
r = self.value(self.SettingsGitSupported, True)
return r
@git_supported.setter
def git_supported(self, value):
self.set_value(self.SettingsGitSupported, value)
# SettingsPythonPath = 'python_bin'
@property
def python_bin(self):
r = self.value(self.SettingsPythonPath, "")
return r
@python_bin.setter
def python_bin(self, value):
self.set_value(self.SettingsPythonPath, value)
# SettingsLuaPath = 'luaPath'
@property
def lua_bin(self):
r = self.value(self.SettingsLuaPath, "")
return r
@lua_bin.setter
def lua_bin(self, value):
self.set_value(self.SettingsLuaPath, value)

View File

@@ -25,7 +25,6 @@ from interpreter.utils.version import (
from interpreter.test_items.test_item import TestItem from interpreter.test_items.test_item import TestItem
from interpreter.test_items.test_item_sleep import TestItemSleep from interpreter.test_items.test_item_sleep import TestItemSleep
from interpreter.test_items.test_item_unittest import TestItemUnittestFile 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_cycle import TestItemCycle
from interpreter.test_items.test_item_runtime_plot import TestItemPlot from interpreter.test_items.test_item_runtime_plot import TestItemPlot
from interpreter.test_items.test_item_group import TestItemGroup from interpreter.test_items.test_item_group import TestItemGroup
@@ -70,7 +69,6 @@ def _constants_init():
cst.TYPE_RUN.item_class = TestItemRun cst.TYPE_RUN.item_class = TestItemRun
cst.TYPE_SLEEP.item_class = TestItemSleep cst.TYPE_SLEEP.item_class = TestItemSleep
cst.TYPE_UNITTEST.item_class = TestItemUnittestFile cst.TYPE_UNITTEST.item_class = TestItemUnittestFile
cst.TYPE_PYTEST.item_class = TestItemPytestFile
cst.TYPE_VALUE_DLG.item_class = TestItemValueDialog cst.TYPE_VALUE_DLG.item_class = TestItemValueDialog
cst.TYPE_PARALLEL.item_class = TestItemParallel cst.TYPE_PARALLEL.item_class = TestItemParallel
cst.TYPE_PARALLEL_BRANCH.item_class = TestItemParallelBranch cst.TYPE_PARALLEL_BRANCH.item_class = TestItemParallelBranch

View File

@@ -56,9 +56,17 @@ function handle.func_call(params)
if err == nil then if err == nil then
print(string.format("Function executed from '%s'", pfile)) print(string.format("Function executed from '%s'", pfile))
utils.log("func_call function found '%s', '%s'", file, fname) utils.log("func_call function found '%s', '%s'", file, fname)
succ, ret = pcall(func, unpack(prms)) err_res = {pcall(func, unpack(prms))}
utils.log("func_call returned '%s', '%s'", tostring(succ), tostring(ret)) utils.log("func_call returned '%s', '%s'", tostring(succ), tostring(ret))
-- manage tuple ouput of a lua function
succ = table.remove(err_res, 1)
if #err_res > 1 then
ret = err_res
else
ret = unpack(err_res)
end
if succ then if succ then
res = ret res = ret
else else

View File

@@ -3,7 +3,7 @@
-- ========================= -- =========================
local config = { local config = {
host = "0.0.0.0", host = "0.0.0.0",
port = 0, -- 0 = OS-assigned; actual port is reported on stdout port = 9000,
timeout = 60, timeout = 60,
verbose = false, verbose = false,
} }
@@ -76,10 +76,6 @@ server_sock:listen(1)
local ip, port = server_sock:getsockname() local ip, port = server_sock:getsockname()
utils.log("listening on %s:%d for %.1f secs", ip, port, config.timeout) 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 server_sock:settimeout(config.timeout) -- Prevents hanging on dead connections
-- Main Server Loop -- Main Server Loop

View File

@@ -6,14 +6,13 @@ import subprocess
import sys import sys
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QCheckBox, QDialog, QDialogButtonBox, QHBoxLayout, QHeaderView, QLineEdit, QDialog, QDialogButtonBox, QHeaderView, QMenu, QMessageBox,
QMenu, QMessageBox, QPushButton, QTextEdit, QVBoxLayout, QPushButton, QTextEdit, QVBoxLayout,
) )
from PySide6.QtGui import QSyntaxHighlighter, QTextCharFormat, QColor, QFont, QDesktopServices from PySide6.QtGui import QSyntaxHighlighter, QTextCharFormat, QColor, QFont, QDesktopServices
from PySide6.QtCore import Qt, QUrl, Slot from PySide6.QtCore import Qt, QUrl, Slot
from main_win.f1_win.f1_win_core import Ui_F1Dialog from main_win.f1_win.f1_win_core import Ui_F1Dialog
from interpreter.utils import bins
class YamlHighlighter(QSyntaxHighlighter): class YamlHighlighter(QSyntaxHighlighter):
@@ -120,43 +119,6 @@ class DialogF1(QDialog):
self.ui.addVarButton.setEnabled(False) self.ui.addVarButton.setEnabled(False)
self.ui.addVarButton.clicked.connect(self._on_add_var) self.ui.addVarButton.clicked.connect(self._on_add_var)
# Filter box above the table: hides rows whose name doesn't match.
# The optional "values" checkbox extends the match to the value column.
self._filter_text = ""
self._filter_edit = QLineEdit(self.ui.tabVariables)
self._filter_edit.setPlaceholderText("Filter variables by name")
self._filter_edit.setClearButtonEnabled(True)
self._filter_edit.textChanged.connect(self._on_filter_changed)
self._filter_values_cb = QCheckBox("values", self.ui.tabVariables)
self._filter_values_cb.setToolTip("Also match on the variable value")
self._filter_values_cb.toggled.connect(lambda _checked: self._apply_filter())
filter_row = QHBoxLayout()
filter_row.addWidget(self._filter_edit)
filter_row.addWidget(self._filter_values_cb)
self.ui.verticalLayout_tab1.insertLayout(0, filter_row)
def _on_filter_changed(self, text):
self._filter_text = text.strip().lower()
self._apply_filter()
def _apply_filter(self):
for row in range(self.ui.varsTable.rowCount()):
self._apply_filter_row(row)
def _apply_filter_row(self, row):
needle = self._filter_text
if not needle:
self.ui.varsTable.setRowHidden(row, False)
return
table = self.ui.varsTable
key_item = table.item(row, 0)
hay = key_item.text().lower() if key_item else ""
if self._filter_values_cb.isChecked():
val_item = table.item(row, 1)
if val_item is not None:
hay += "\n" + val_item.text().lower()
table.setRowHidden(row, needle not in hay)
def load_initial_vars(self, vars_dict: dict): def load_initial_vars(self, vars_dict: dict):
for key, value in vars_dict.items(): for key, value in vars_dict.items():
self.gd_var_updated(key, value) self.gd_var_updated(key, value)
@@ -187,7 +149,6 @@ class DialogF1(QDialog):
self._updating = False self._updating = False
self._key_rows[key] = row self._key_rows[key] = row
self._refresh_row(row, key, value) self._refresh_row(row, key, value)
self._apply_filter_row(self._key_rows[key])
@Slot(str) @Slot(str)
def gd_var_deleted(self, key): def gd_var_deleted(self, key):
@@ -200,7 +161,6 @@ class DialogF1(QDialog):
finally: finally:
self._updating = False self._updating = False
self._key_rows = {k: (r - 1 if r > row else r) for k, r in self._key_rows.items()} self._key_rows = {k: (r - 1 if r > row else r) for k, r in self._key_rows.items()}
self._apply_filter()
def _refresh_row(self, row, key, value): def _refresh_row(self, row, key, value):
from PySide6.QtWidgets import QTableWidgetItem from PySide6.QtWidgets import QTableWidgetItem
@@ -305,11 +265,9 @@ class DialogF1(QDialog):
def on_butlocopen_click(self): def on_butlocopen_click(self):
file = self.ui.sequenceFileNameLineEdit.text() file = self.ui.sequenceFileNameLineEdit.text()
if not os.path.exists(file): if os.path.exists(file):
return
if bins.host_open_path(file):
return
if sys.platform.startswith("win"): if sys.platform.startswith("win"):
subprocess.Popen(f'explorer "{file}"') subprocess.Popen(f'explorer "{file}"')
else: else:
subprocess.Popen(["xdg-open", file])
QDesktopServices.openUrl(QUrl.fromLocalFile(file)) QDesktopServices.openUrl(QUrl.fromLocalFile(file))

View File

@@ -1,7 +1,5 @@
from collections import namedtuple from PySide6.QtCore import Slot, Qt
from PySide6.QtWidgets import QDialog, QFileDialog
from PySide6.QtCore import Slot
from PySide6.QtWidgets import QDialog, QFileDialog, QLabel, QLineEdit
from PySide6.QtGui import QFont from PySide6.QtGui import QFont
from main_win.preference_win.preference_core_win import Ui_preferenceWindow from main_win.preference_win.preference_core_win import Ui_preferenceWindow
@@ -10,24 +8,6 @@ from main_win import file_dialog
import interpreter.utils.settings as prefs import interpreter.utils.settings as prefs
def _set_font(w, v):
f = QFont()
f.fromString(v)
w.setCurrentFont(f)
# Per-type widget <-> value bridge: (read from widget, write to widget).
_FIELD = {
"bool": (lambda w: w.isChecked(), lambda w, v: w.setChecked(v)),
"text": (lambda w: w.text(), lambda w, v: w.setText(v)),
"int": (lambda w: int(w.value()), lambda w, v: w.setValue(v)),
"combo": (lambda w: int(w.currentIndex()), lambda w, v: w.setCurrentIndex(v)),
"font": (lambda w: w.currentFont().toString(), _set_font),
}
Field = namedtuple("Field", "key type widget")
class PrefWindow(QDialog): class PrefWindow(QDialog):
def __init__(self, parent=None): def __init__(self, parent=None):
super().__init__(parent) super().__init__(parent)
@@ -37,57 +17,162 @@ class PrefWindow(QDialog):
self.ui.buttonBox.accepted.connect(self.on_buttOKPressed) self.ui.buttonBox.accepted.connect(self.on_buttOKPressed)
self.ui.buttonBox.rejected.connect(self.on_buttCancelPressed) self.ui.buttonBox.rejected.connect(self.on_buttCancelPressed)
self.finished.connect(self.on_finishedPressed) self.finished.connect(self.on_finishedPressed)
self.ui.butLogPath.triggered.connect(self.on_butLogPath_pressed)
self.ui.butReportPath.triggered.connect(self.on_butReportPath_pressed)
self.ui.butPythonPath.triggered.connect(self.on_butPythonPath_pressed)
self.ui.butLuaPath.triggered.connect(self.on_butLuaPath_pressed)
self.elements = {
prefs.settings.SettingsHideDocPane: {
"type": "bool",
"widget": self.ui.checkDocPane,
"value": prefs.settings.hide_doc_pane,
"default": False,
"changed": False,
},
prefs.settings.SettingsHideLogPane: {
"type": "bool",
"widget": self.ui.checkLogPane,
"value": prefs.settings.hide_log_pane,
"default": False,
"changed": False,
},
prefs.settings.SettingsShowCheckboxes: {
"type": "bool",
"widget": self.ui.checkBoxTest,
"value": prefs.settings.show_checkboxes,
"default": False,
"changed": False,
},
prefs.settings.SettingsShowTimeColumn: {
"type": "bool",
"widget": self.ui.checkShowTime,
"value": prefs.settings.show_time_column,
"default": False,
"changed": False,
},
prefs.settings.SettingsLogPath: {
"type": "text",
"widget": self.ui.editDefaultLogPath,
"value": prefs.settings.log_path,
"default": "$(test_directory)",
"changed": False,
},
prefs.settings.SettingsReportPath: {
"type": "text",
"widget": self.ui.editDefaultReportPath,
"value": prefs.settings.report_path,
"default": "$(test_directory)",
"changed": False,
},
prefs.settings.SettingsDblClickEnabled: {
"type": "bool",
"widget": self.ui.checkDblClick,
"value": prefs.settings.dbl_click_enabled,
"default": False,
"changed": False,
},
prefs.settings.SettingsIconsTheme: {
"type": "combo",
"widget": self.ui.choiceIconsTheme,
"value": prefs.settings.icons_theme,
"default": 0,
"changed": False,
},
prefs.settings.SettingsLogFont: {
"type": "font",
"widget": self.ui.font_choice,
"value": prefs.settings.log_font,
"default": "Monospace",
"changed": False,
},
prefs.settings.SettingsLogFontSize: {
"type": "int",
"widget": self.ui.font_size,
"value": prefs.settings.log_font_size,
"default": 8,
"changed": False,
},
prefs.settings.SettingsGitSupported: {
"type": "bool",
"widget": self.ui.checkGitSupported,
"value": prefs.settings.git_supported,
"default": True,
"changed": False,
},
prefs.settings.SettingsPythonPath: {
"type": "text",
"widget": self.ui.editPythonPath,
"value": prefs.settings.python_bin,
"default": "",
"changed": False,
},
prefs.settings.SettingsLuaPath: {
"type": "text",
"widget": self.ui.editLuaPath,
"value": prefs.settings.lua_bin,
"default": "",
"changed": False,
},
}
self.ui.butLogPath.triggered.connect(
lambda: self._pick_dir(self.ui.editDefaultLogPath, "Select the default log directory"))
self.ui.butReportPath.triggered.connect(
lambda: self._pick_dir(self.ui.editDefaultReportPath, "Select the default report directory"))
self.ui.butPythonPath.triggered.connect(
lambda: self._pick_file(self.ui.editPythonPath, "Select the python interpreter"))
self.ui.butLuaPath.triggered.connect(
lambda: self._pick_file(self.ui.editLuaPath, "Select the lua interpreter"))
# Editor command field, added in code (mirrors the F1 filter approach) so the
# generated UI stays untouched. Sits with the double-click toggle it feeds.
self.editEditorCmd = QLineEdit(self.ui.scrollAreaWidgetContents)
self.editEditorCmd.setPlaceholderText("ex: code -g {file}:{line}")
self.ui.formLayout.addRow(QLabel("Open log line in editor"), self.editEditorCmd)
s = prefs.settings
self.fields = [
Field(s.SettingsHideDocPane, "bool", self.ui.checkDocPane),
Field(s.SettingsHideLogPane, "bool", self.ui.checkLogPane),
Field(s.SettingsShowCheckboxes, "bool", self.ui.checkBoxTest),
Field(s.SettingsShowTimeColumn, "bool", self.ui.checkShowTime),
Field(s.SettingsLogPath, "text", self.ui.editDefaultLogPath),
Field(s.SettingsReportPath, "text", self.ui.editDefaultReportPath),
Field(s.SettingsDblClickEnabled, "bool", self.ui.checkDblClick),
Field(s.SettingsEditorCmd, "text", self.editEditorCmd),
Field(s.SettingsIconsTheme, "combo", self.ui.choiceIconsTheme),
Field(s.SettingsLogFont, "font", self.ui.font_choice),
Field(s.SettingsLogFontSize, "int", self.ui.font_size),
Field(s.SettingsGitSupported, "bool", self.ui.checkGitSupported),
Field(s.SettingsPythonPath, "text", self.ui.editPythonPath),
Field(s.SettingsLuaPath, "text", self.ui.editLuaPath),
]
self._changed = set()
self.restore_prefs() self.restore_prefs()
def store_prefs(self): def store_prefs(self):
self._changed = set() for k, v in self.elements.items():
for f in self.fields: self.elements[k]["changed"] = False
val = _FIELD[f.type][0](f.widget) if v["type"] == "bool":
if val != prefs.settings.value(f.key): val = v["widget"].isChecked()
prefs.settings.set_value(f.key, val) if self.elements[k]["value"] != val:
self._changed.add(f.key.name) self.elements[k]["value"] = val
self.elements[k]["changed"] = True
if v["type"] == "text":
val = v["widget"].text()
if self.elements[k]["value"] != val:
self.elements[k]["value"] = val
self.elements[k]["changed"] = True
if v["type"] == "font":
val = v["widget"].currentFont().toString()
if self.elements[k]["value"] != val:
self.elements[k]["value"] = val
self.elements[k]["changed"] = True
if v["type"] == "int":
val = int(v["widget"].value())
if self.elements[k]["value"] != val:
self.elements[k]["value"] = val
self.elements[k]["changed"] = True
if v["type"] == "combo":
val = int(v["widget"].currentIndex())
if self.elements[k]["value"] != val:
self.elements[k]["value"] = val
self.elements[k]["changed"] = True
if self.elements[k]["changed"]:
prefs.settings.set_value(k, v["value"])
prefs.settings.sync() prefs.settings.sync()
def restore_prefs(self): def restore_prefs(self):
for f in self.fields: for k, v in self.elements.items():
_FIELD[f.type][1](f.widget, prefs.settings.value(f.key)) v["value"] = prefs.settings.value(k, v["default"])
if v["type"] == "bool":
v["widget"].setChecked(v["value"])
elif v["type"] == "text":
v["widget"].setText(self.elements[k]["value"])
elif v["type"] == "font":
f = QFont()
f.fromString(self.elements[k]["value"])
v["widget"].setCurrentFont(f)
elif v["type"] == "int":
v["widget"].setValue(self.elements[k]["value"])
elif v["type"] == "combo":
v["widget"].setCurrentIndex(self.elements[k]["value"])
def isChanged(self, setting): def isChanged(self, setting):
return setting.name in self._changed return self.elements[setting]["changed"]
@Slot() @Slot()
def on_buttOKPressed(self): def on_buttOKPressed(self):
@@ -103,14 +188,46 @@ class PrefWindow(QDialog):
def on_finishedPressed(self): def on_finishedPressed(self):
self.restore_prefs() self.restore_prefs()
def _pick_dir(self, edit, caption): @Slot()
def on_butReportPath_pressed(self):
path = QFileDialog.getExistingDirectory( path = QFileDialog.getExistingDirectory(
self, caption=caption, dir=edit.text(), options=file_dialog.options()) self,
caption="Select the default report directory",
dir=self.ui.editDefaultReportPath.text(),
options=file_dialog.options(),
)
if path: if path:
edit.setText(path) self.ui.editDefaultReportPath.setText(path)
def _pick_file(self, edit, caption): @Slot()
path, _ = QFileDialog.getOpenFileName( def on_butLogPath_pressed(self):
self, caption=caption, dir=edit.text(), options=file_dialog.options()) path = QFileDialog.getExistingDirectory(
self,
caption="Select the default log directory",
dir=self.ui.editDefaultLogPath.text(),
options=file_dialog.options(),
)
if path: if path:
edit.setText(path) self.ui.editDefaultLogPath.setText(path)
@Slot()
def on_butPythonPath_pressed(self):
path, _ = QFileDialog.getOpenFileName(
self,
caption="Select the python interpreter",
dir=self.ui.editPythonPath.text(),
options=file_dialog.options(),
)
if path:
self.ui.editPythonPath.setText(path)
@Slot()
def on_butLuaPath_pressed(self):
path, _ = QFileDialog.getOpenFileName(
self,
caption="Select the lua interpreter",
dir=self.ui.editLuaPath.text(),
options=file_dialog.options(),
)
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="exit.png">black/exit.png</file>
<file alias="terminal.png">black/terminal.png</file> <file alias="terminal.png">black/terminal.png</file>
<file alias="python.png">black/python.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="lua.png">black/lua.png</file>
<file alias="verif.png">black/verif.png</file> <file alias="verif.png">black/verif.png</file>
<file alias="view-refresh.png">black/view-refresh.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="exit.png">white/exit.png</file>
<file alias="terminal.png">white/terminal.png</file> <file alias="terminal.png">white/terminal.png</file>
<file alias="python.png">white/python.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="lua.png">white/lua.png</file>
<file alias="verif.png">white/verif.png</file> <file alias="verif.png">white/verif.png</file>
<file alias="view-refresh.png">white/view-refresh.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="exit.png">color/exit.png</file>
<file alias="terminal.png">color/terminal.png</file> <file alias="terminal.png">color/terminal.png</file>
<file alias="python.png">color/python.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="lua.png">color/lua.png</file>
<file alias="verif.png">color/verif.png</file> <file alias="verif.png">color/verif.png</file>
<file alias="view-refresh.png">color/view-refresh.png</file> <file alias="view-refresh.png">color/view-refresh.png</file>

View File

@@ -1,6 +1,6 @@
# Resource object code (Python 3) # Resource object code (Python 3)
# Created by: object code # 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! # WARNING! All changes made in this file will be lost!
from PySide6 import QtCore 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\ \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\ \x1c\x1e\xee0\x17\x1bh\x00\x00\x00\x00IEND\xae\
B`\x82\ 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\ \x00\x00\x05\xbd\
\x89\ \x89\
PNG\x0d\x0a\x1a\x0a\x00\x00\x00\x0dIHDR\x00\ 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\ \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\ \x1c\x1e\xee0\x17\x1bh\x00\x00\x00\x00IEND\xae\
B`\x82\ 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]\ \x00\x00\x05]\
\x89\ \x89\
PNG\x0d\x0a\x1a\x0a\x00\x00\x00\x0dIHDR\x00\ 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\ \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\ \x1c\x1e\xee0\x17\x1bh\x00\x00\x00\x00IEND\xae\
B`\x82\ 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\ \x00\x00\x06\x09\
\x89\ \x89\
PNG\x0d\x0a\x1a\x0a\x00\x00\x00\x0dIHDR\x00\ PNG\x0d\x0a\x1a\x0a\x00\x00\x00\x0dIHDR\x00\
@@ -16492,10 +16397,6 @@ qt_resource_name = b"\
\x00s\ \x00s\
\x00u\x00c\x00c\x00e\x00s\x00s\x00_\x00o\x00r\x00a\x00n\x00g\x00e\x00.\x00p\x00n\ \x00u\x00c\x00c\x00e\x00s\x00s\x00_\x00o\x00r\x00a\x00n\x00g\x00e\x00.\x00p\x00n\
\x00g\ \x00g\
\x00\x0a\
\x0c\xa8V\x07\
\x00p\
\x00y\x00t\x00e\x00s\x00t\x00.\x00p\x00n\x00g\
\x00\x08\ \x00\x08\
\x04\xd2YG\ \x04\xd2YG\
\x00i\ \x00i\
@@ -16591,300 +16492,294 @@ qt_resource_name = b"\
qt_resource_struct = b"\ qt_resource_struct = b"\
\x00\x00\x00\x00\x00\x02\x00\x00\x00\x03\x00\x00\x00\x01\ \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\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\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\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\x00\x00\x00\x00\x00\x00\
\x00\x00\x02\xee\x00\x00\x00\x00\x00\x01\x00\x00\x7f\xa5\ \x00\x00\x02\xd4\x00\x00\x00\x00\x00\x01\x00\x00~\x17\
\x00\x00\x01\x9b\xa3\xda\x0d#\ \x00\x00\x01\x9b\x97*\xf4\x03\
\x00\x00\x03\x8a\x00\x00\x00\x00\x00\x01\x00\x00\xfdh\ \x00\x00\x03p\x00\x00\x00\x00\x00\x01\x00\x00\xfb\xda\
\x00\x00\x01\x9b\xa3\xda\x0d#\ \x00\x00\x01\x9b\x97*\xf4\x05\
\x00\x00\x00^\x00\x00\x00\x00\x00\x01\x00\x00\x09\xb0\ \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\x00\xa4\x00\x00\x00\x00\x00\x01\x00\x00\x1ds\
\x00\x00\x01\x9b\xa3\xda\x0d$\ \x00\x00\x01\x9b\x97*\xf4\x05\
\x00\x00\x04\x86\x00\x00\x00\x00\x00\x01\x00\x01!\x98\ \x00\x00\x04l\x00\x00\x00\x00\x00\x01\x00\x01 \x0a\
\x00\x00\x01\x9b\xa3\xda\x0d#\ \x00\x00\x01\x9b\x97*\xf4\x03\
\x00\x00\x01\x90\x00\x00\x00\x00\x00\x01\x00\x00O\x0d\ \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\x02\x02\x00\x00\x00\x00\x00\x01\x00\x00`\x90\
\x00\x00\x01\x9b\xa3\xda\x0d#\ \x00\x00\x01\x9b\x97*\xf4\x05\
\x00\x00\x03,\x00\x00\x00\x00\x00\x01\x00\x00\x86\x81\ \x00\x00\x03\x12\x00\x00\x00\x00\x00\x01\x00\x00\x84\xf3\
\x00\x00\x01\x9bi\x96\x0e\x1c\ \x00\x00\x01\x9b\x97*\xf4\x02\
\x00\x00\x04\xbe\x00\x00\x00\x00\x00\x01\x00\x01(\x09\ \x00\x00\x04\xa4\x00\x00\x00\x00\x00\x01\x00\x01&{\
\x00\x00\x01\x9b\xa3\xda\x0d#\ \x00\x00\x01\x9b\x97*\xf4\x03\
\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\x02\xbe\x00\x00\x00\x00\x00\x01\x00\x00xV\ \x00\x00\x02\xbe\x00\x00\x00\x00\x00\x01\x00\x00xV\
\x00\x00\x01\x9e\xc7D\xd9\x84\ \x00\x00\x01\x9b\x97*\xf4\x04\
\x00\x00\x03\xd4\x00\x00\x00\x00\x00\x01\x00\x01\x09\xf5\ \x00\x00\x01L\x00\x00\x00\x00\x00\x01\x00\x00:\x88\
\x00\x00\x01\x9b\xa3\xda\x0d#\ \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\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\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\x02\x94\x00\x00\x00\x00\x00\x01\x00\x00s\x0e\
\x00\x00\x01\x9b\x8a`\x8cv\ \x00\x00\x01\x9b\x97*\xf3\xfe\
\x00\x00\x04,\x00\x00\x00\x00\x00\x01\x00\x01\x15\xe2\ \x00\x00\x04\x12\x00\x00\x00\x00\x00\x01\x00\x01\x14T\
\x00\x00\x01\x9b\xa3\xda\x0d$\ \x00\x00\x01\x9b\x97*\xf4\x05\
\x00\x00\x04^\x00\x00\x00\x00\x00\x01\x00\x01\x1e&\ \x00\x00\x04D\x00\x00\x00\x00\x00\x01\x00\x01\x1c\x98\
\x00\x00\x01\x9b\xa3\xda\x0d#\ \x00\x00\x01\x9b\x97*\xf4\x03\
\x00\x00\x02\xee\x00\x00\x00\x00\x00\x01\x00\x03\x13D\ \x00\x00\x02\xd4\x00\x00\x00\x00\x00\x01\x00\x03\x0d\xbb\
\x00\x00\x01\x9b\x8a\x14\x90]\ \x00\x00\x01\x9b\x97*\xf3\xe4\
\x00\x00\x03\x8a\x00\x00\x00\x00\x00\x01\x00\x03\x95W\ \x00\x00\x03p\x00\x00\x00\x00\x00\x01\x00\x03\x8f\xce\
\x00\x00\x01\x9b\x8a\x15\xa3\xbc\ \x00\x00\x01\x9b\x97*\xf3\xe4\
\x00\x00\x00^\x00\x00\x00\x00\x00\x01\x00\x02\x82\x9f\ \x00\x00\x00^\x00\x00\x00\x00\x00\x01\x00\x02\x7ff\
\x00\x00\x01\x9b\x8a\x1e\x13:\ \x00\x00\x01\x9b\x97*\xf3\xe5\
\x00\x00\x00\xa4\x00\x00\x00\x00\x00\x01\x00\x02\x99\x12\ \x00\x00\x00\xa4\x00\x00\x00\x00\x00\x01\x00\x02\x95\xd9\
\x00\x00\x01\x9b\x8a\x15\xeby\ \x00\x00\x01\x9b\x97*\xf3\xe5\
\x00\x00\x04\x86\x00\x00\x00\x00\x00\x01\x00\x03\xc4~\ \x00\x00\x04l\x00\x00\x00\x00\x00\x01\x00\x03\xbe\xf5\
\x00\x00\x01\x9b\x8a\x15\x11\xa3\ \x00\x00\x01\x9b\x97*\xf3\xe4\
\x00\x00\x01\x90\x00\x00\x00\x00\x00\x01\x00\x02\xd9\xaa\ \x00\x00\x01\x90\x00\x00\x00\x00\x00\x01\x00\x02\xd6q\
\x00\x00\x01\x9b\x8a\x16/\xbc\ \x00\x00\x01\x9b\x97*\xf3\xe5\
\x00\x00\x02\x02\x00\x00\x00\x00\x00\x01\x00\x02\xea\xec\ \x00\x00\x02\x02\x00\x00\x00\x00\x00\x01\x00\x02\xe7\xb3\
\x00\x00\x01\x9b\x8a$\x11*\ \x00\x00\x01\x9b\x97*\xf3\xe4\
\x00\x00\x03,\x00\x00\x00\x00\x00\x01\x00\x03\x1de\ \x00\x00\x03\x12\x00\x00\x00\x00\x00\x01\x00\x03\x17\xdc\
\x00\x00\x01\x9bi\x96\x0e\x1c\ \x00\x00\x01\x9b\x97*\xf4\x02\
\x00\x00\x04\xbe\x00\x00\x00\x00\x00\x01\x00\x03\xcc\xdc\ \x00\x00\x04\xa4\x00\x00\x00\x00\x00\x01\x00\x03\xc7S\
\x00\x00\x01\x9b\x8a\x13\xa4\xaa\ \x00\x00\x01\x9b\x97*\xf3\xe4\
\x00\x00\x02\xd8\x00\x00\x00\x00\x00\x01\x00\x03\x0d7\ \x00\x00\x02\xbe\x00\x00\x00\x00\x00\x01\x00\x03\x07\xae\
\x00\x00\x01\x9b\x8a\x12g\xa0\ \x00\x00\x01\x9b\x97*\xf3\xe4\
\x00\x00\x01L\x00\x00\x00\x00\x00\x01\x00\x02\xb5\xe4\ \x00\x00\x01L\x00\x00\x00\x00\x00\x01\x00\x02\xb2\xab\
\x00\x00\x01\x9b\x8a\x16Qr\ \x00\x00\x01\x9b\x97*\xf3\xe5\
\x00\x00\x03\x16\x00\x00\x00\x00\x00\x01\x00\x03\x18\xa6\ \x00\x00\x02\xfc\x00\x00\x00\x00\x00\x01\x00\x03\x13\x1d\
\x00\x00\x01\x9bi\x96\x0e\x12\ \x00\x00\x01\x9b\x97*\xf3\xe4\
\x00\x00\x05*\x00\x00\x00\x00\x00\x01\x00\x03\xe7Y\ \x00\x00\x05\x10\x00\x00\x00\x00\x00\x01\x00\x03\xe1\xd0\
\x00\x00\x01\x9b\x8a\x12\xf3 \ \x00\x00\x01\x9b\x97*\xf3\xe4\
\x00\x00\x03\x9e\x00\x00\x00\x00\x00\x01\x00\x03\x98\x92\ \x00\x00\x03\x84\x00\x00\x00\x00\x00\x01\x00\x03\x93\x09\
\x00\x00\x01\x9b\x8a\x153f\ \x00\x00\x01\x9b\x97*\xf3\xe3\
\x00\x00\x02\x16\x00\x00\x00\x00\x00\x01\x00\x02\xf4\xd4\ \x00\x00\x02\x16\x00\x00\x00\x00\x00\x01\x00\x02\xf1\x9b\
\x00\x00\x01\x9d\xed\x19\x07|\ \x00\x00\x01\x9d\xe0StF\
\x00\x00\x01\x16\x00\x00\x00\x00\x00\x01\x00\x02\xafW\ \x00\x00\x01\x16\x00\x00\x00\x00\x00\x01\x00\x02\xac\x1e\
\x00\x00\x01\x9b\x8a\x13\x1e\xe7\ \x00\x00\x01\x9b\x97*\xf3\xe4\
\x00\x00\x05\x12\x00\x00\x00\x00\x00\x01\x00\x03\xe2\x9e\ \x00\x00\x04\xf8\x00\x00\x00\x00\x00\x01\x00\x03\xdd\x15\
\x00\x00\x01\x9b\x8a\x12\x93}\ \x00\x00\x01\x9b\x97*\xf3\xe4\
\x00\x00\x01\xec\x00\x00\x00\x00\x00\x01\x00\x02\xe6`\ \x00\x00\x01\xec\x00\x00\x00\x00\x00\x01\x00\x02\xe3'\
\x00\x00\x01\x9b\x8ac\x97y\ \x00\x00\x01\x9b\x97*\xf3\xfe\
\x00\x00\x01\xae\x00\x00\x00\x00\x00\x01\x00\x02\xdd~\ \x00\x00\x01\xae\x00\x00\x00\x00\x00\x01\x00\x02\xdaE\
\x00\x00\x01\x9b\x8a\x16\xf8\x01\ \x00\x00\x01\x9b\x97*\xf3\xe5\
\x00\x00\x000\x00\x00\x00\x00\x00\x01\x00\x02w\x81\ \x00\x00\x000\x00\x00\x00\x00\x00\x01\x00\x02tH\
\x00\x00\x01\x9bi\x96\x0e\x13\ \x00\x00\x01\x9b\x97*\xf3\xfe\
\x00\x00\x04\xfa\x00\x00\x00\x00\x00\x01\x00\x03\xd91\ \x00\x00\x04\xe0\x00\x00\x00\x00\x00\x01\x00\x03\xd3\xa8\
\x00\x00\x01\x9b\x8a\x13\x7f\x8d\ \x00\x00\x01\x9b\x97*\xf3\xe4\
\x00\x00\x00F\x00\x00\x00\x00\x00\x01\x00\x02~\xa7\ \x00\x00\x00F\x00\x00\x00\x00\x00\x01\x00\x02{n\
\x00\x00\x01\x9bi\x96\x0e\x12\ \x00\x00\x01\x9b\x97*\xf3\xe5\
\x00\x00\x00\x90\x00\x00\x00\x00\x00\x01\x00\x02\x91l\ \x00\x00\x00\x90\x00\x00\x00\x00\x00\x01\x00\x02\x8e3\
\x00\x00\x01\x9bi\x96\x0e\x13\ \x00\x00\x01\x9b\x97*\xf3\xfe\
\x00\x00\x04F\x00\x00\x00\x00\x00\x01\x00\x03\xbb\xfa\ \x00\x00\x04,\x00\x00\x00\x00\x00\x01\x00\x03\xb6q\
\x00\x00\x01\x9b\x8a\x15|\xe4\ \x00\x00\x01\x9b\x97*\xf3\xe4\
\x00\x00\x02|\x00\x00\x00\x00\x00\x01\x00\x03\x00\xd7\ \x00\x00\x02|\x00\x00\x00\x00\x00\x01\x00\x02\xfd\x9e\
\x00\x00\x01\x9b\x8a\x16\xd8\x95\ \x00\x00\x01\x9b\x97*\xf3\xe5\
\x00\x00\x04\x0e\x00\x00\x00\x00\x00\x01\x00\x03\xafO\ \x00\x00\x03\xf4\x00\x00\x00\x00\x00\x01\x00\x03\xa9\xc6\
\x00\x00\x01\x9b\x8a\x14\xb4l\ \x00\x00\x01\x9b\x97*\xf3\xe4\
\x00\x00\x03R\x00\x00\x00\x00\x00\x01\x00\x03\x8e0\ \x00\x00\x038\x00\x00\x00\x00\x00\x01\x00\x03\x88\xa7\
\x00\x00\x01\x9bi\x96\x0e\x12\ \x00\x00\x01\x9b\x97*\xf3\xe4\
\x00\x00\x01f\x00\x00\x00\x00\x00\x01\x00\x02\xbar\ \x00\x00\x01f\x00\x00\x00\x00\x00\x01\x00\x02\xb79\
\x00\x00\x01\x9d\xed\x19\x07|\ \x00\x00\x01\x9d\xcf\xc3\xa3\x0d\
\x00\x00\x03\xf6\x00\x00\x00\x00\x00\x01\x00\x03\xa6\xd6\ \x00\x00\x03\xdc\x00\x00\x00\x00\x00\x01\x00\x03\xa1M\
\x00\x00\x01\x9b\x8a\x14\xdb\xaa\ \x00\x00\x01\x9b\x97*\xf3\xe4\
\x00\x00\x00z\x00\x00\x00\x00\x00\x01\x00\x02\x87`\ \x00\x00\x00z\x00\x00\x00\x00\x00\x01\x00\x02\x84'\
\x00\x00\x01\x9b\x8a#\x9d\xb9\ \x00\x00\x01\x9b\x97*\xf3\xe4\
\x00\x00\x00\xe8\x00\x00\x00\x00\x00\x01\x00\x02\xaa\xd6\ \x00\x00\x00\xe8\x00\x00\x00\x00\x00\x01\x00\x02\xa7\x9d\
\x00\x00\x01\x9b\x8a\x13\xef\xba\ \x00\x00\x01\x9b\x97*\xf3\xe4\
\x00\x00\x03l\x00\x00\x00\x00\x00\x01\x00\x03\x90\xa6\ \x00\x00\x03R\x00\x00\x00\x00\x00\x01\x00\x03\x8b\x1d\
\x00\x00\x01\x9b\x8a\x16\xb6\x00\ \x00\x00\x01\x9b\x97*\xf3\xe5\
\x00\x00\x00\xba\x00\x00\x00\x00\x00\x01\x00\x02\xa0\xaf\ \x00\x00\x00\xba\x00\x00\x00\x00\x00\x01\x00\x02\x9dv\
\x00\x00\x01\x9bi\x96\x0e\x12\ \x00\x00\x01\x9b\x97*\xf3\xe5\
\x00\x00\x04\xa6\x00\x00\x00\x00\x00\x01\x00\x03\xcaH\ \x00\x00\x04\x8c\x00\x00\x00\x00\x00\x01\x00\x03\xc4\xbf\
\x00\x00\x01\x9b\x8ab\x88n\ \x00\x00\x01\x9b\x97*\xf3\xe2\
\x00\x00\x00\xd0\x00\x00\x00\x00\x00\x01\x00\x02\xa3\x0a\ \x00\x00\x00\xd0\x00\x00\x00\x00\x00\x01\x00\x02\x9f\xd1\
\x00\x00\x01\x9bi\x96\x0e\x13\ \x00\x00\x01\x9b\x97*\xf3\xfe\
\x00\x00\x04\xe2\x00\x00\x00\x00\x00\x01\x00\x03\xd0U\ \x00\x00\x04\xc8\x00\x00\x00\x00\x00\x01\x00\x03\xca\xcc\
\x00\x00\x01\x9b\x8a\x16\x95H\ \x00\x00\x01\x9b\x97*\xf3\xe5\
\x00\x00\x02^\x00\x00\x00\x00\x00\x01\x00\x02\xfc\xea\ \x00\x00\x02^\x00\x00\x00\x00\x00\x01\x00\x02\xf9\xb1\
\x00\x00\x01\x9b\x8a\x16s\xd0\ \x00\x00\x01\x9b\x97*\xf3\xe5\
\x00\x00\x01z\x00\x00\x00\x00\x00\x01\x00\x02\xd0k\ \x00\x00\x01z\x00\x00\x00\x00\x00\x01\x00\x02\xcd2\
\x00\x00\x01\x9b\x8a\x12\xc8}\ \x00\x00\x01\x9b\x97*\xf3\xe4\
\x00\x00\x02B\x00\x00\x00\x00\x00\x01\x00\x02\xf6\x11\ \x00\x00\x02B\x00\x00\x00\x00\x00\x01\x00\x02\xf2\xd8\
\x00\x00\x01\x9b\x8acl\xeb\ \x00\x00\x01\x9b\x97*\xf3\xfe\
\x00\x00\x03\xb6\x00\x00\x00\x00\x00\x01\x00\x03\x9b\xc3\ \x00\x00\x03\x9c\x00\x00\x00\x00\x00\x01\x00\x03\x96:\
\x00\x00\x01\x9d\xed\x19\x07|\ \x00\x00\x01\x9d\xe0G\xedD\
\x00\x00\x01\xd4\x00\x00\x00\x00\x00\x01\x00\x02\xe3\xed\ \x00\x00\x01\xd4\x00\x00\x00\x00\x00\x01\x00\x02\xe0\xb4\
\x00\x00\x01\x9bi\x96\x0e\x12\ \x00\x00\x01\x9b\x97*\xf3\xe4\
\x00\x00\x02\xbe\x00\x00\x00\x00\x00\x01\x00\x03\x0a\xe7\ \x00\x00\x03\xba\x00\x00\x00\x00\x00\x01\x00\x03\x9a\xf6\
\x00\x00\x01\x9e\xc7D\xd9~\ \x00\x00\x01\x9b\x97*\xf3\xe4\
\x00\x00\x03\xd4\x00\x00\x00\x00\x00\x01\x00\x03\xa0\x7f\ \x00\x00\x01\x02\x00\x00\x00\x00\x00\x01\x00\x02\xaa`\
\x00\x00\x01\x9b\x8a\x14@&\ \x00\x00\x01\x9b\x97*\xf3\xe4\
\x00\x00\x01\x02\x00\x00\x00\x00\x00\x01\x00\x02\xad\x99\ \x00\x00\x016\x00\x00\x00\x00\x00\x01\x00\x02\xafL\
\x00\x00\x01\x9b\x8a\x13M\xe4\ \x00\x00\x01\x9b\x97*\xf3\xe4\
\x00\x00\x016\x00\x00\x00\x00\x00\x01\x00\x02\xb2\x85\ \x00\x00\x02\x94\x00\x00\x00\x00\x00\x01\x00\x03\x02f\
\x00\x00\x01\x9b\x8a\x14\x14\xa7\ \x00\x00\x01\x9b\x97*\xf3\xfe\
\x00\x00\x02\x94\x00\x00\x00\x00\x00\x01\x00\x03\x05\x9f\ \x00\x00\x04\x12\x00\x00\x00\x00\x00\x01\x00\x03\xab\xff\
\x00\x00\x01\x9b\x8a`\x8cv\ \x00\x00\x01\x9b\x97*\xf3\xe5\
\x00\x00\x04,\x00\x00\x00\x00\x00\x01\x00\x03\xb1\x88\ \x00\x00\x04D\x00\x00\x00\x00\x00\x01\x00\x03\xb9_\
\x00\x00\x01\x9b\x8a#\xd7z\ \x00\x00\x01\x9b\x97*\xf3\xe4\
\x00\x00\x04^\x00\x00\x00\x00\x00\x01\x00\x03\xbe\xe8\ \x00\x00\x02\xd4\x00\x00\x00\x00\x00\x01\x00\x01\xb8\xb7\
\x00\x00\x01\x9b\x8a\x14h\xdc\ \x00\x00\x01\x9b\x97*\xf3\xe1\
\x00\x00\x02\xee\x00\x00\x00\x00\x00\x01\x00\x01\xbb\xf0\ \x00\x00\x03p\x00\x00\x00\x00\x00\x01\x00\x025\x11\
\x00\x00\x01\x9b\x8aR\xb8\x17\ \x00\x00\x01\x9b\x97*\xf3\xe1\
\x00\x00\x03\x8a\x00\x00\x00\x00\x00\x01\x00\x028J\ \x00\x00\x00^\x00\x00\x00\x00\x00\x01\x00\x01IR\
\x00\x00\x01\x9b\x8aT\xb7\x8e\ \x00\x00\x01\x9b\x97*\xf3\xe2\
\x00\x00\x00^\x00\x00\x00\x00\x00\x01\x00\x01J\xe0\ \x00\x00\x00\xa4\x00\x00\x00\x00\x00\x01\x00\x01\x5c\xa2\
\x00\x00\x01\x9b\x8aU\xdfw\ \x00\x00\x01\x9b\x97*\xf3\xe1\
\x00\x00\x00\xa4\x00\x00\x00\x00\x00\x01\x00\x01^0\ \x00\x00\x04l\x00\x00\x00\x00\x00\x01\x00\x02Vh\
\x00\x00\x01\x9b\x8aUX\xd5\ \x00\x00\x01\x9b\x97*\xf3\xe1\
\x00\x00\x04\x86\x00\x00\x00\x00\x00\x01\x00\x02Y\xa1\ \x00\x00\x01\x90\x00\x00\x00\x00\x00\x01\x00\x01\x8cP\
\x00\x00\x01\x9b\x8aREh\ \x00\x00\x01\x9b\x97*\xf3\xe2\
\x00\x00\x01\x90\x00\x00\x00\x00\x00\x01\x00\x01\x8d\xde\ \x00\x00\x02\x02\x00\x00\x00\x00\x00\x01\x00\x01\x9c\xc2\
\x00\x00\x01\x9b\x8aU\x93\xcd\ \x00\x00\x01\x9b\x97*\xf3\xe1\
\x00\x00\x02\x02\x00\x00\x00\x00\x00\x01\x00\x01\x9eP\ \x00\x00\x03\x12\x00\x00\x00\x00\x00\x01\x00\x01\xbe\xc1\
\x00\x00\x01\x9b\x8aT\xdc/\ \x00\x00\x01\x9b\x97*\xf4\x02\
\x00\x00\x03,\x00\x00\x00\x00\x00\x01\x00\x01\xc1\xfa\ \x00\x00\x04\xa4\x00\x00\x00\x00\x00\x01\x00\x02\x5cd\
\x00\x00\x01\x9bi\x96\x0e\x1c\ \x00\x00\x01\x9b\x97*\xf3\xe1\
\x00\x00\x04\xbe\x00\x00\x00\x00\x00\x01\x00\x02_\x9d\ \x00\x00\x02\xbe\x00\x00\x00\x00\x00\x01\x00\x01\xb3V\
\x00\x00\x01\x9b\x8aS^\xe7\ \x00\x00\x01\x9b\x97*\xf3\xe1\
\x00\x00\x02\xd8\x00\x00\x00\x00\x00\x01\x00\x01\xb6\x8f\ \x00\x00\x01L\x00\x00\x00\x00\x00\x01\x00\x01v\xd0\
\x00\x00\x01\x9b\x8aTJ\xe5\ \x00\x00\x01\x9b\x97*\xf3\xe2\
\x00\x00\x01L\x00\x00\x00\x00\x00\x01\x00\x01x^\ \x00\x00\x02\xfc\x00\x00\x00\x00\x00\x01\x00\x01\xbb\xa5\
\x00\x00\x01\x9b\x8aU\xc2\xd1\ \x00\x00\x01\x9b\x97*\xf3\xe1\
\x00\x00\x03\x16\x00\x00\x00\x00\x00\x01\x00\x01\xbe\xde\ \x00\x00\x05\x10\x00\x00\x00\x00\x00\x01\x00\x02p\x0a\
\x00\x00\x01\x9b\x8aU\x00b\ \x00\x00\x01\x9b\x97*\xf3\xe1\
\x00\x00\x05*\x00\x00\x00\x00\x00\x01\x00\x02sC\ \x00\x00\x03\x84\x00\x00\x00\x00\x00\x01\x00\x02::\
\x00\x00\x01\x9b\x8aS\xebC\ \x00\x00\x01\x9b\x97*\xf3\xe0\
\x00\x00\x03\x9e\x00\x00\x00\x00\x00\x01\x00\x02=s\ \x00\x00\x02\x16\x00\x00\x00\x00\x00\x01\x00\x01\xa0\xd2\
\x00\x00\x01\x9b\x8aR\x1a\xcf\ \x00\x00\x01\x9d\xe0StF\
\x00\x00\x02\x16\x00\x00\x00\x00\x00\x01\x00\x01\xa2`\ \x00\x00\x01\x16\x00\x00\x00\x00\x00\x01\x00\x01p\x12\
\x00\x00\x01\x9d\xed\x19\x07|\ \x00\x00\x01\x9b\x97*\xf3\xe1\
\x00\x00\x01\x16\x00\x00\x00\x00\x00\x01\x00\x01q\xa0\ \x00\x00\x04\xf8\x00\x00\x00\x00\x00\x01\x00\x02kt\
\x00\x00\x01\x9b\x8aS\xc7\x80\ \x00\x00\x01\x9b\x97*\xf3\xe1\
\x00\x00\x05\x12\x00\x00\x00\x00\x00\x01\x00\x02n\xad\ \x00\x00\x01\xec\x00\x00\x00\x00\x00\x01\x00\x01\x986\
\x00\x00\x01\x9b\x8aT/\x12\ \x00\x00\x01\x9b\x97*\xf3\xfe\
\x00\x00\x01\xec\x00\x00\x00\x00\x00\x01\x00\x01\x99\xc4\ \x00\x00\x01\xae\x00\x00\x00\x00\x00\x01\x00\x01\x91m\
\x00\x00\x01\x9b\x8ac\x97y\ \x00\x00\x01\x9b\x97*\xf3\xe2\
\x00\x00\x01\xae\x00\x00\x00\x00\x00\x01\x00\x01\x92\xfb\ \x00\x00\x000\x00\x00\x00\x00\x00\x01\x00\x01@$\
\x00\x00\x01\x9b\x8aV\xbbi\ \x00\x00\x01\x9b\x97*\xf3\xfe\
\x00\x00\x000\x00\x00\x00\x00\x00\x01\x00\x01A\xb2\ \x00\x00\x04\xe0\x00\x00\x00\x00\x00\x01\x00\x02eY\
\x00\x00\x01\x9bi\x96\x0e\x13\ \x00\x00\x01\x9b\x97*\xf3\xe1\
\x00\x00\x04\xfa\x00\x00\x00\x00\x00\x01\x00\x02h\x92\ \x00\x00\x00F\x00\x00\x00\x00\x00\x01\x00\x01GJ\
\x00\x00\x01\x9b\x8aS}\xfc\ \x00\x00\x01\x9b\x97*\xf3\xe2\
\x00\x00\x00F\x00\x00\x00\x00\x00\x01\x00\x01H\xd8\ \x00\x00\x00\x90\x00\x00\x00\x00\x00\x01\x00\x01T\xfc\
\x00\x00\x01\x9b\x8aV:\x86\ \x00\x00\x01\x9b\x97*\xf3\xfe\
\x00\x00\x00\x90\x00\x00\x00\x00\x00\x01\x00\x01V\x8a\ \x00\x00\x04,\x00\x00\x00\x00\x00\x01\x00\x02Q\x0b\
\x00\x00\x01\x9bi\x96\x0e\x13\ \x00\x00\x01\x9b\x97*\xf3\xe1\
\x00\x00\x04F\x00\x00\x00\x00\x00\x01\x00\x02TD\ \x00\x00\x02|\x00\x00\x00\x00\x00\x01\x00\x01\xac\x0e\
\x00\x00\x01\x9b\x8aT\x92U\ \x00\x00\x01\x9b\x97*\xf3\xe2\
\x00\x00\x02|\x00\x00\x00\x00\x00\x01\x00\x01\xad\x9c\ \x00\x00\x03\xf4\x00\x00\x00\x00\x00\x01\x00\x02I{\
\x00\x00\x01\x9b\x8aV\x99B\ \x00\x00\x01\x9b\x97*\xf3\xe1\
\x00\x00\x04\x0e\x00\x00\x00\x00\x00\x01\x00\x02L\xb4\ \x00\x00\x038\x00\x00\x00\x00\x00\x01\x00\x02/\x8c\
\x00\x00\x01\x9b\x8aR\x903\ \x00\x00\x01\x9b\x97*\xf3\xe1\
\x00\x00\x03R\x00\x00\x00\x00\x00\x01\x00\x022\xc5\ \x00\x00\x01f\x00\x00\x00\x00\x00\x01\x00\x01z\x8c\
\x00\x00\x01\x9b\x8aU<\xbc\ \x00\x00\x01\x9d\xcf\xc3\xa3\x0d\
\x00\x00\x01f\x00\x00\x00\x00\x00\x01\x00\x01|\x1a\ \x00\x00\x03\xdc\x00\x00\x00\x00\x00\x01\x00\x02D\xce\
\x00\x00\x01\x9d\xed\x19\x07|\ \x00\x00\x01\x9b\x97*\xf3\xe1\
\x00\x00\x03\xf6\x00\x00\x00\x00\x00\x01\x00\x02H\x07\ \x00\x00\x00z\x00\x00\x00\x00\x00\x01\x00\x01N\xba\
\x00\x00\x01\x9b\x8aRh\x0f\ \x00\x00\x01\x9b\xc5\xbd\x82\xf5\
\x00\x00\x00z\x00\x00\x00\x00\x00\x01\x00\x01PH\ \x00\x00\x00\xe8\x00\x00\x00\x00\x00\x01\x00\x01i\x96\
\x00\x00\x01\x9b\xa3\xdek\xc5\ \x00\x00\x01\x9b\x97*\xf3\xe1\
\x00\x00\x00\xe8\x00\x00\x00\x00\x00\x01\x00\x01k$\ \x00\x00\x03R\x00\x00\x00\x00\x00\x01\x00\x021\xd3\
\x00\x00\x01\x9b\x8aS@S\ \x00\x00\x01\x9b\x97*\xf3\xe2\
\x00\x00\x03l\x00\x00\x00\x00\x00\x01\x00\x025\x0c\ \x00\x00\x00\xba\x00\x00\x00\x00\x00\x01\x00\x01_\xd4\
\x00\x00\x01\x9b\x8aVw{\ \x00\x00\x01\x9b\x97*\xf3\xe2\
\x00\x00\x00\xba\x00\x00\x00\x00\x00\x01\x00\x01ab\ \x00\x00\x04\x8c\x00\x00\x00\x00\x00\x01\x00\x02Y\xd0\
\x00\x00\x01\x9b\x8aVZ\xb0\ \x00\x00\x01\x9b\x97*\xf3\xe2\
\x00\x00\x04\xa6\x00\x00\x00\x00\x00\x01\x00\x02]\x09\ \x00\x00\x00\xd0\x00\x00\x00\x00\x00\x01\x00\x01a\xca\
\x00\x00\x01\x9b\x8ab\x88n\ \x00\x00\x01\x9b\x97*\xf3\xfe\
\x00\x00\x00\xd0\x00\x00\x00\x00\x00\x01\x00\x01cX\ \x00\x00\x04\xc8\x00\x00\x00\x00\x00\x01\x00\x02`\x15\
\x00\x00\x01\x9bi\x96\x0e\x13\ \x00\x00\x01\x9b\x97*\xf3\xe2\
\x00\x00\x04\xe2\x00\x00\x00\x00\x00\x01\x00\x02cN\ \x00\x00\x02^\x00\x00\x00\x00\x00\x01\x00\x01\xa8\xa4\
\x00\x00\x01\x9b\x8aV\x1c\x17\ \x00\x00\x01\x9b\x97*\xf3\xe2\
\x00\x00\x02^\x00\x00\x00\x00\x00\x01\x00\x01\xaa2\ \x00\x00\x01z\x00\x00\x00\x00\x00\x01\x00\x01\x86o\
\x00\x00\x01\x9b\x8aU\xfd\x14\ \x00\x00\x01\x9b\x97*\xf3\xe1\
\x00\x00\x01z\x00\x00\x00\x00\x00\x01\x00\x01\x87\xfd\ \x00\x00\x02B\x00\x00\x00\x00\x00\x01\x00\x01\xa1\xcb\
\x00\x00\x01\x9b\x8aT\x10\x07\ \x00\x00\x01\x9b\x97*\xf3\xfe\
\x00\x00\x02B\x00\x00\x00\x00\x00\x01\x00\x01\xa3Y\ \x00\x00\x03\x9c\x00\x00\x00\x00\x00\x01\x00\x02>\xd5\
\x00\x00\x01\x9b\x8acl\xeb\ \x00\x00\x01\x9d\xe0G\xedD\
\x00\x00\x03\xb6\x00\x00\x00\x00\x00\x01\x00\x02B\x0e\ \x00\x00\x01\xd4\x00\x00\x00\x00\x00\x01\x00\x01\x95\xfc\
\x00\x00\x01\x9d\xed\x19\x07|\ \x00\x00\x01\x9b\x97*\xf3\xe1\
\x00\x00\x01\xd4\x00\x00\x00\x00\x00\x01\x00\x01\x97\x8a\ \x00\x00\x03\xba\x00\x00\x00\x00\x00\x01\x00\x02Ag\
\x00\x00\x01\x9b\x8aU\x1c\xe9\ \x00\x00\x01\x9b\x97*\xf3\xe1\
\x00\x00\x02\xbe\x00\x00\x00\x00\x00\x01\x00\x01\xb4\xe4\ \x00\x00\x01\x02\x00\x00\x00\x00\x00\x01\x00\x01l4\
\x00\x00\x01\x9e\xc7D\xd9\x83\ \x00\x00\x01\x9b\x97*\xf3\xe1\
\x00\x00\x03\xd4\x00\x00\x00\x00\x00\x01\x00\x02D\xa0\ \x00\x00\x016\x00\x00\x00\x00\x00\x01\x00\x01s\x8a\
\x00\x00\x01\x9b\x8aR\xfd@\ \x00\x00\x01\x9b\x97*\xf3\xe1\
\x00\x00\x01\x02\x00\x00\x00\x00\x00\x01\x00\x01m\xc2\ \x00\x00\x02\x94\x00\x00\x00\x00\x00\x01\x00\x01\xae\x0e\
\x00\x00\x01\x9b\x8aS\x9cE\ \x00\x00\x01\x9b\x97*\xf3\xfe\
\x00\x00\x016\x00\x00\x00\x00\x00\x01\x00\x01u\x18\ \x00\x00\x04\x12\x00\x00\x00\x00\x00\x01\x00\x02L9\
\x00\x00\x01\x9b\x8aS\x1fV\ \x00\x00\x01\x9b\x97*\xf3\xe1\
\x00\x00\x02\x94\x00\x00\x00\x00\x00\x01\x00\x01\xaf\x9c\ \x00\x00\x04D\x00\x00\x00\x00\x00\x01\x00\x02Sr\
\x00\x00\x01\x9b\x8a`\x8cv\ \x00\x00\x01\x9b\x97*\xf3\xe1\
\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\
" "
def qInitResources(): def qInitResources():

Binary file not shown.

Before

Width:  |  Height:  |  Size: 394 B

View File

@@ -51,14 +51,18 @@ class TestFileManager:
w.disconnect_signals() w.disconnect_signals()
# Snapshot user-selected checkboxes and fold state so they survive a # Snapshot user-selected checkboxes and fold state so they survive a
# reload of the same file (same logic as session-restore through prefs). # reload of the same file (same logic as session-restore through prefs).
# checkList works only if show_checkboxes is True
previous_check_list = w.treeTests.getCheckList() previous_check_list = w.treeTests.getCheckList()
previous_fold_list = w.treeTests.getFoldList() previous_fold_list = w.treeTests.getFoldList()
previous_count = w.treeTests.getItemCount() previous_count = w.treeTests.getItemCount()
self.clear_process() self.clear_process()
if self.load(file_name) and w.test_service is not None: if self.load(file_name) and \
if w.treeTests.getItemCount() == previous_count: w.test_service is not None and \
w.treeTests.getItemCount() == previous_count:
if prefs.settings.show_checkboxes :
w.treeTests.restoreCheckList(previous_check_list, w.test_service) w.treeTests.restoreCheckList(previous_check_list, w.test_service)
w.treeTests.restoreFoldList(previous_fold_list) w.treeTests.restoreFoldList(previous_fold_list)
w.reconnect_signals() w.reconnect_signals()
def _make_progress(self, w): def _make_progress(self, w):
@@ -127,15 +131,13 @@ class TestFileManager:
del w.ts_controller del w.ts_controller
w.ts_controller = None w.ts_controller = None
raise ETUMRuntimeError( raise ETUMRuntimeError(
"Test could not be loaded. See the log above for the cause " "Test could not be loaded (test process crashed for any reason)"
"(syntax error, missing file, missing module, ...)."
) )
progress.setLabelText("Building test tree…") progress.setLabelText("Building test tree…")
QApplication.processEvents() QApplication.processEvents()
test_data = w.test_service.tree() test_data = w.test_service.tree()
w.treeTests.clear() w.treeTests.clear()
w._reset_search()
QApplication.processEvents() QApplication.processEvents()
w.treeTests.loadTestRecursively(w.treeTests.invisibleRootItem(), test_data) w.treeTests.loadTestRecursively(w.treeTests.invisibleRootItem(), test_data)
self._close_progress(progress) self._close_progress(progress)

View File

@@ -181,8 +181,7 @@ class TestRunner:
w.actionStart_test.setText("Pause test") w.actionStart_test.setText("Pause test")
w.actionPreferences.setDisabled(True) w.actionPreferences.setDisabled(True)
w.actionRefresh_test.setDisabled(True) w.actionRefresh_test.setDisabled(True)
# Show Results stays available during the run (log grows live). w.actionShow_Results.setDisabled(True)
w.actionShow_Results.setEnabled(True)
w.actionSave_report.setDisabled(True) w.actionSave_report.setDisabled(True)
w.logSettingsBox.setDisabled(True) w.logSettingsBox.setDisabled(True)
w.actionStop_test.setEnabled(True) w.actionStop_test.setEnabled(True)

View File

@@ -163,46 +163,6 @@ class QTestTree(QTreeWidget):
def clearGlobalSuccess(self): def clearGlobalSuccess(self):
self._global_success = True 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): def __findItemByIdRecursively(self, item_id, parent):
res = None res = None
i = 0 i = 0

View File

@@ -12,8 +12,6 @@ from api.testium import print_warn
_ITEM_CONFIG = { _ITEM_CONFIG = {
"unittest": {"icon": "folder.png", "icon_on": "folder-open.png", "expanded": True, "no_breakpoint": True}, "unittest": {"icon": "folder.png", "icon_on": "folder-open.png", "expanded": True, "no_breakpoint": True},
"unittest step": {"icon": "document.png", "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": {"icon": "terminal.png", "unfoldable": False},
"Console action": {"icon": "terminal.png"}, "Console action": {"icon": "terminal.png"},
"Cycle": {"icon": "cycle.png", "expanded": True}, "Cycle": {"icon": "cycle.png", "expanded": True},
@@ -103,7 +101,7 @@ class QTestTreeItem(QTreeWidgetItem):
self.setFlags(self.flags() | Qt.ItemIsUserCheckable) self.setFlags(self.flags() | Qt.ItemIsUserCheckable)
self.setCheckState(self._cols["name"]["index"], Qt.Checked) self.setCheckState(self._cols["name"]["index"], Qt.Checked)
self._is_highlighted = False self._is_highlighted = False
self._is_search_match = False self._initial_brush = None
self._failure_list = None self._failure_list = None
self._no_breakpoint = False self._no_breakpoint = False
parent.addChild(self) parent.addChild(self)
@@ -180,44 +178,17 @@ class QTestTreeItem(QTreeWidgetItem):
def isBreakpoint(self): def isBreakpoint(self):
return self._display_pause 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): def setHighlighted(self):
if not self._is_highlighted: 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._is_highlighted = True
self._refresh_highlight()
def resetHighlighted(self): def resetHighlighted(self):
if self._is_highlighted: if self._is_highlighted:
self.setBackground(self._cols["name"]["index"], self._initial_brush)
self._is_highlighted = False 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=""): def setRowIcon(self, resource_off, resource_on=""):

View File

@@ -1,7 +1,5 @@
import sys import sys
import os import os
import shlex
import subprocess
import webbrowser import webbrowser
from multiprocessing import Queue from multiprocessing import Queue
from threading import Thread from threading import Thread
@@ -9,7 +7,7 @@ import shutil
# Qt # Qt
from PySide6 import QtGui 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.QtCore import Slot, QUrl, Qt, QTimer
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
@@ -18,12 +16,6 @@ from PySide6.QtWidgets import (
QDialog, QDialog,
QFileDialog, QFileDialog,
QSizePolicy, QSizePolicy,
QWidget,
QHBoxLayout,
QLineEdit,
QCheckBox,
QLabel,
QToolButton,
) )
ourPath = os.path.dirname(__file__) ourPath = os.path.dirname(__file__)
@@ -42,7 +34,6 @@ from runtime.string_queue import StringQueue
from interpreter.process import TestProcess from interpreter.process import TestProcess
from interpreter.utils.test_ctrl import TestSetController from interpreter.utils.test_ctrl import TestSetController
from interpreter.utils.icons import icon_prefix from interpreter.utils.icons import icon_prefix
from interpreter.utils import bins
from main_win.test_run.outlog import OutLog from main_win.test_run.outlog import OutLog
from main_win.test_run.test_run import ThreadTestStatus from main_win.test_run.test_run import ThreadTestStatus
@@ -178,13 +169,6 @@ class MainWindow(QMainWindow, Ui_MainWindow):
activated=self.on_F1Pressed, 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) self.actionRefresh_test.setDisabled(True)
# Signal connections # Signal connections
@@ -311,135 +295,6 @@ class MainWindow(QMainWindow, Ui_MainWindow):
del self.treeTests del self.treeTests
self.treeTests = None 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): def file_loaded_at_startup(self):
modeSlider_value = prefs.settings.show_checkboxes modeSlider_value = prefs.settings.show_checkboxes
if modeSlider_value: if modeSlider_value:
@@ -642,7 +497,6 @@ class MainWindow(QMainWindow, Ui_MainWindow):
self.statusBar().showMessage( self.statusBar().showMessage(
"Opening the logfile (" + s + "): " + self.logFileName, 100000 "Opening the logfile (" + s + "): " + self.logFileName, 100000
) )
if not bins.host_open_path(self.logFileName):
QDesktopServices.openUrl(QUrl.fromLocalFile(self.logFileName)) QDesktopServices.openUrl(QUrl.fromLocalFile(self.logFileName))
@Slot() @Slot()
@@ -747,21 +601,7 @@ class MainWindow(QMainWindow, Ui_MainWindow):
if (self.logFileName is not None) and os.access(self.logFileName, os.R_OK): if (self.logFileName is not None) and os.access(self.logFileName, os.R_OK):
ln = tm.line_number("@@{}@@".format(item.timestamp()), self.logFileName) ln = tm.line_number("@@{}@@".format(item.timestamp()), self.logFileName)
if ln > 0: if ln > 0:
self._open_in_editor(self.logFileName, ln + 1) os.system("{} -g {}:{} &".format("code", self.logFileName, ln + 1))
def _open_in_editor(self, path, line):
"""Open path at line via the configured editor template ({file}/{line}).
Empty template or failure falls back to opening the file without line."""
tmpl = prefs.settings.editor_cmd
if tmpl:
try:
argv = [p.format(file=path, line=line) for p in shlex.split(tmpl)]
subprocess.Popen(bins.host_console_command(argv, os.path.dirname(path) or "."))
return
except (KeyError, ValueError, IndexError, OSError):
pass
if not bins.host_open_path(path):
QDesktopServices.openUrl(QUrl.fromLocalFile(path))
def on_spacePressed(self): def on_spacePressed(self):
item = self.treeTests.currentItem() item = self.treeTests.currentItem()

View File

@@ -7,7 +7,6 @@ from PySide6.QtGui import QCursor, QDesktopServices, QFont
from main_win.text_log_highlighter import TextLogHighlighter from main_win.text_log_highlighter import TextLogHighlighter
import api.testium as tm import api.testium as tm
from interpreter.utils import bins
class QTextLog(QPlainTextEdit): class QTextLog(QPlainTextEdit):
def __init__(self, parent): def __init__(self, parent):
@@ -66,7 +65,6 @@ class QTextLog(QPlainTextEdit):
self._test_dir = os.getcwd() self._test_dir = os.getcwd()
path = os.path.join(self._test_dir, path) path = os.path.join(self._test_dir, path)
if os.path.exists(path): if os.path.exists(path):
if not bins.host_open_path(path):
QDesktopServices.openUrl(QUrl.fromLocalFile(path)) QDesktopServices.openUrl(QUrl.fromLocalFile(path))
return # évite d'insérer du texte si clic return # évite d'insérer du texte si clic
super().mousePressEvent(event) super().mousePressEvent(event)

View File

@@ -1,9 +1,7 @@
#!/usr/bin/env python #!/usr/bin/env python
import sys
import multiprocessing import multiprocessing
from py_func.tm import _init_api, _remote_print from py_func.tm import _init_api, _remote_print
from runtime.stdout_redirect import stdio_redir from runtime.stdout_redirect import stdio_redir
from runtime.jrpc import RPC_PORT_SENTINEL
class TcpStdOut: class TcpStdOut:
@@ -26,29 +24,21 @@ def main():
parser = argparse.ArgumentParser() parser = argparse.ArgumentParser()
parser.add_argument("-i", "--ip", type=str, help="Ip address or hostname to listen to", parser.add_argument("-i", "--ip", type=str, help="Ip address or hostname to listen to",
default="localhost") default="localhost")
parser.add_argument("-p", "--port", type=int, help="port to listen to (0 = OS-assigned)", parser.add_argument("-p", "--port", type=int, help="port to listen to",
default=0) default=9000)
parser.add_argument("-t", "--timeout", type=float, help="Timeout waiting for connection", parser.add_argument("-t", "--timeout", type=float, help="Timeout waiting for connection",
default=10) default=10)
parser.add_argument("-v", "--verbose", action='store_true', help="port to listen to") parser.add_argument("-v", "--verbose", action='store_true', help="port to listen to")
args = parser.parse_args() args = parser.parse_args()
thrd_api = _init_api(args.ip, args.port, args.timeout) thrd_api = _init_api(args.ip, args.port, args.timeout)
# redirect I/O
outstream = TcpStdOut()
stdio_redir.redirect(outstream)
# debug the server # debug the server
if args.verbose: if args.verbose:
thrd_api.dbg_out = stdio_redir.ini_stdout thrd_api.dbg_out = stdio_redir.ini_stdout
thrd_api.start() 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: try:
while thrd_api.is_alive(): while thrd_api.is_alive():
thrd_api.join(1) thrd_api.join(1)

View File

@@ -12,9 +12,6 @@ except:
from runtime.tum_except import ETUMRuntimeError 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. """Lightweight JSON-RPC 2.0 helpers over TCP sockets.
This module implements a minimal JSON-RPC 2.0 messaging layer using 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._req_handler = req_handler
self._dbg_out = dbg_out self._dbg_out = dbg_out
self._event_ready = threading.Event() 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): def handle_request(self, method, params):
"""Override to implement server-side request handling. """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.name, sock, self.handle_request, dbg_out=self.dbg_out
) )
self._rpc.wait_ready() self._rpc.wait_ready()
self._connected = True
self._event_ready.set() self._event_ready.set()
def wait_ready(self, timeout=None): def wait_ready(self, timeout=None):
self._event_ready.wait(timeout) return self._event_ready.wait(timeout)
return self._connected
@property @property
def dbg_out(self): def dbg_out(self):
@@ -355,30 +348,20 @@ class JsonRpcSrv(JsonRpcBase):
def __init__(self, host, port, req_handler=None, timeout=10): def __init__(self, host, port, req_handler=None, timeout=10):
super().__init__(host, port, req_handler, timeout) super().__init__(host, port, req_handler, timeout)
self.name = f"JsonRpcSvr_{port}" 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): def run(self):
# TCP/IP socket creation # TCP/IP socket creation
try: try:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: 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)) sock.bind((self._host, self._port))
# Listens incoming connections # Listens incoming connections
sock.listen(1) sock.listen(1)
self._bound_port = sock.getsockname()[1] self.print_info(f"listening on {self._host}:{self._port}")
self._bound_evt.set()
self.print_info(f"listening on {self._host}:{self._bound_port}")
self.print_info(f"awaiting connection for {self._timeout} secs") self.print_info(f"awaiting connection for {self._timeout} secs")
sock.settimeout(self._timeout) sock.settimeout(self._timeout)
@@ -399,7 +382,6 @@ class JsonRpcSrv(JsonRpcBase):
sleep(0.1) sleep(0.1)
finally: finally:
self._bound_evt.set() # unblock wait_bound() even on failure
if self._rpc is not None: if self._rpc is not None:
self._rpc.stop() self._rpc.stop()
self._rpc.join() self._rpc.join()
@@ -425,34 +407,35 @@ class JsonRpcClient(JsonRpcBase):
self.name = f"JsonRpcClt_{port}" self.name = f"JsonRpcClt_{port}"
def run(self): def run(self):
try:
if tm.OS() == "Windows": if tm.OS() == "Windows":
self.run_win() self.run_win()
else: else:
self.run_lin() 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): def run_win(self):
# Server already listening (handshake); retry on refused/timeout until deadline. # TCP/IP socket creation
deadline = monotonic() + self._timeout tslice = 1
t = self._timeout
sock = None sock = None
try: try:
while True: while t >= 0:
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 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: try:
sock.connect((self._host, self._port)) sock.connect((self._host, self._port))
break break
except OSError as e: except socket.timeout:
sock.close() sock.close()
if monotonic() >= deadline: t -= tslice
if t < 0:
raise ETUMRuntimeError( 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.print_info("Connected to server")
self.connect(sock) self.connect(sock)

View File

@@ -47,15 +47,13 @@
{% endif %} {% endif %}
- read_until: {expected: terminal loaded, timeout: 5} - read_until: {expected: terminal loaded, timeout: 5}
# Echo two tokens on one line so both are buffered together; the immediate
# (timeout 0) reads below match buffered data with no race on the async prompt.
- console: - console:
name: Console write name: Console write
condition: <| $(conditional_exec) == 1 |> condition: <| $(conditional_exec) == 1 |>
console_name: consname console_name: consname
key: $(test)_PASS key: $(test)_PASS
steps: steps:
- writeln: echo ALPHA BETA - writeln: echo 0
- sleep: - sleep:
name: sleep item name: sleep item
@@ -69,9 +67,9 @@
key: $(test)_PASS key: $(test)_PASS
steps: steps:
{% if os == "Windows" %} {% if os == "Windows" %}
- read_until: {expected: echo ALPHA BETA, timeout: 0} - read_until: {expected: echo 0, timeout: 0}
{% endif %} {% endif %}
- read_until: {expected: ALPHA, timeout: 0} - read_until: {expected: "0", timeout: 0}
- console: - console:
name: Console read_until immediate (2) name: Console read_until immediate (2)
@@ -79,7 +77,7 @@
console_name: consname console_name: consname
key: $(test)_PASS key: $(test)_PASS
steps: steps:
- read_until: {expected: BETA, timeout: 0} - read_until: {expected: "$(terminal_prompt)", timeout: 0}
- console: - console:
name: Console closure name: Console closure

View File

@@ -84,7 +84,18 @@
- read_until: {expected: HelloConsole, timeout: 1, mute: true} - read_until: {expected: HelloConsole, timeout: 1, mute: true}
- console: - 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 console_name: term
key: $(test)_PASS key: $(test)_PASS
steps: steps:
@@ -105,46 +116,6 @@
- read_until: {expected: console_host_check_HOST, timeout: 5} - read_until: {expected: console_host_check_HOST, timeout: 5}
{% endif %} {% 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: - console:
name: Console closure name: Console closure
execute_on_stop: true execute_on_stop: true

View File

@@ -20,7 +20,7 @@
console_name: jrpces console_name: jrpces
key: $(test)_PASS key: $(test)_PASS
steps: steps:
- writeln: '"$(python_bin)" {{include_directory}}/jrpc_echo_server.py -c {{include_directory}}/jrpces.ini' - writeln: python3 {{include_directory}}/jrpc_echo_server.py -c {{include_directory}}/jrpces.ini
- read_until: {expected: ready, timeout: 5} - read_until: {expected: ready, timeout: 5}
- console: - console:

View File

@@ -11,8 +11,8 @@
- let: - let:
name: Let it be name: Let it be
values: values:
it: $(loop_param) - it: $(loop_param)
be: <| $(loop_param) == $(it) |> - be: <| $(loop_param) == $(it) |>
- loop: - loop:
name: Cycle iterating on list name: Cycle iterating on list

View File

@@ -12,12 +12,12 @@ function module.assertparam(param)
end end
function module.checkglobal(param) function module.checkglobal(param)
local res = tm.gd(param) assert(param=='test parameter')
return res return 0
end end
function module.checkglobal2(index) function module.checkglobal2(index)
return tm.gd("lua_data_to_be_returned")[index] return tm.gd("data_to_be_returned")[index+1]
end end
function module.should_not_be_called(param) function module.should_not_be_called(param)
@@ -53,7 +53,7 @@ function module.return_nothing()
-- Returns no value: ret is nil but no error. -- Returns no value: ret is nil but no error.
end end
function module.return_explicit_nil() function module.return_explicit_none()
return nil return nil
end end

View File

@@ -1,6 +1,6 @@
skipped_test_item: ['skipped_checkglobal'] skipped_test_item: ['skipped_checkglobal']
lua_data_to_be_returned: data_to_be_returned:
- 1 - 1
- {a: 1, b: 2} - {a: 1, b: 2}
- ["a", 1, 2.1, True] - ["a", 1, 2.1, True]

View File

@@ -1,7 +1,15 @@
- let: - let:
name: lua_func test constants, name: lua_func test constants,
values: values:
lua_func test parameter: test parameter lua_func - func_test_parameter: test parameter
- lua_func:
name: pass lua_func
key: $(test)_PASS
file: $(test_path)$(psep)lua_func.lua
func_name: assertparam
param:
- true
- lua_func: - lua_func:
name: fail lua_func name: fail lua_func
@@ -12,7 +20,7 @@
- false - false
- lua_func: - lua_func:
name: fail lua_func with expected result FAIL name: fail lua_func with expected result "FAIL"
key: $(test)_PASS key: $(test)_PASS
file: $(test_path)$(psep)lua_func.lua file: $(test_path)$(psep)lua_func.lua
func_name: assertparam func_name: assertparam
@@ -62,35 +70,7 @@
file: $(test_path)$(psep)lua_func.lua file: $(test_path)$(psep)lua_func.lua
func_name: checkglobal func_name: checkglobal
param: param:
- lua_func test parameter - $(func_test_parameter)
expected_result: $(lua_func test parameter)
- lua_func:
name: global param lua_func 1
key: $(test)_PASS
file: $(test_path)$(psep)lua_func.lua
func_name: checkglobal2
param:
- 1
expected_result: ($(lua_data_to_be_returned))[0]
- lua_func:
name: global param lua_func 2
key: $(test)_PASS
file: $(test_path)$(psep)lua_func.lua
func_name: checkglobal2
param:
- 2
expected_result: ($(lua_data_to_be_returned))[1]
- lua_func:
name: global param lua_func 3
key: $(test)_PASS
file: $(test_path)$(psep)lua_func.lua
func_name: checkglobal2
param:
- 3
expected_result: ($(lua_data_to_be_returned))[2]
- let: - let:
name: python2func name: python2func
@@ -98,88 +78,189 @@
values: values:
- py: $(test_path)$(psep)lua_func.lua - py: $(test_path)$(psep)lua_func.lua
- lua_func:
name: global param int
key: $(test)_PASS
file: $(test_path)$(psep)lua_func.lua
func_name: checkglobal2
param:
- 0
expected_result: ($(data_to_be_returned))[0]
- lua_func:
name: global param dict
key: $(test)_PASS
file: $(test_path)$(psep)lua_func.lua
func_name: checkglobal2
param:
- 1
expected_result: ($(data_to_be_returned))[1]
- lua_func:
name: global param list
key: $(test)_PASS
file: $(test_path)$(psep)lua_func.lua
func_name: checkglobal2
param:
- 2
expected_result: ($(data_to_be_returned))[2]
- lua_func:
name: global param lua_func
key: $(test)_PASS
file: $(py)
func_name: checkglobal
param:
- $(func_test_parameter)
- lua_func: - lua_func:
name: skipped_checkglobal name: skipped_checkglobal
key: $(test)_PASS
file: $(test_path)$(psep)lua_func.lua file: $(test_path)$(psep)lua_func.lua
func_name: should_not_be_called func_name: should_not_be_called
param: param:
- $(test parameter) - $(func_test_parameter)
- lua_func: - lua_func:
name: skipped true name: skipped true
key: $(test)_FAIL
file: $(test_path)$(psep)lua_func.lua file: $(test_path)$(psep)lua_func.lua
func_name: checkglobal func_name: echo
skipped: true skipped: true
param: param:
- $(test parameter) - "skipped"
- lua_func: - lua_func:
name: skipped 1 name: skipped 1
key: $(test)_FAIL
file: $(test_path)$(psep)lua_func.lua file: $(test_path)$(psep)lua_func.lua
func_name: checkglobal func_name: echo
skipped: 1 skipped: 1
param: param:
- $(test parameter) - "skipped"
- group: - group:
name: Function results check name: Function results check
steps: steps:
- group: - group:
name: Function result failure name: Functions result
steps: steps:
- lua_func: - lua_func:
name: int failure name: int
key: $(test)_PASS key: $(test)_PASS
file: $(test_path)$(psep)lua_func.lua file: $(test_path)$(psep)lua_func.lua
func_name: echo func_name: echo
param: [-1] param: [-1]
- lua_func: - lua_func:
name: float failure name: float
key: $(test)_PASS key: $(test)_PASS
file: $(test_path)$(psep)lua_func.lua file: $(test_path)$(psep)lua_func.lua
func_name: echo func_name: echo
param: [-1.3] param: [-20.3]
- lua_func: - lua_func:
name: String failure name: String
key: $(test)_PASS key: $(test)_PASS
file: $(test_path)$(psep)lua_func.lua file: $(test_path)$(psep)lua_func.lua
func_name: echo func_name: echo
param: [ "FAIL" ] param: [ "FAIL" ]
- lua_func: - lua_func:
name: Tuple int,str failure name: Tuple int,str
key: $(test)_PASS
file: $(test_path)$(psep)lua_func.lua
func_name: tuple_return
param: [ -1, "Got a failure" ]
- group:
name: Functions result success
steps:
- lua_func:
name: int success
key: $(test)_PASS
file: $(test_path)$(psep)lua_func.lua
func_name: echo
param: [0]
- lua_func:
name: float success
key: $(test)_PASS
file: $(test_path)$(psep)lua_func.lua
func_name: echo
param: [0.3]
- lua_func:
name: String success
key: $(test)_PASS
file: $(test_path)$(psep)lua_func.lua
func_name: echo
param: [ "Something that is not only strictly FAIL" ]
- lua_func:
name: Tuple int,str success
key: $(test)_PASS key: $(test)_PASS
file: $(test_path)$(psep)lua_func.lua file: $(test_path)$(psep)lua_func.lua
func_name: tuple_return func_name: tuple_return
param: [ 0, "OK" ] param: [ 0, "OK" ]
- group:
name: Functions result expected
steps:
- lua_func:
name: int expected
key: $(test)_PASS
file: $(test_path)$(psep)lua_func.lua
func_name: echo
param: [18]
expected_result: 18
- lua_func:
name: float expected
key: $(test)_PASS
file: $(test_path)$(psep)lua_func.lua
func_name: echo
param: [0.3]
expected_result: 0.3
- lua_func:
name: String expected
key: $(test)_PASS
file: $(test_path)$(psep)lua_func.lua
func_name: echo
param: [ "Something" ]
expected_result: Something
- lua_func:
name: Tuple int,str expected
key: $(test)_PASS
file: $(test_path)$(psep)lua_func.lua
func_name: tuple_return
param: [ 0, "OK" ]
expected_result: [0, "OK"]
- lua_func:
name: small list expected
key: $(test)_PASS
file: $(test_path)$(psep)lua_func.lua
func_name: echo
param: [ [-23] ]
expected_result: [-23]
- lua_func:
name: big list expected
key: $(test)_PASS
file: $(test_path)$(psep)lua_func.lua
func_name: echo
param: [ [-23, 17, 67] ]
expected_result: [-23, 17, 67]
- group:
name: Function result not expected
steps:
- lua_func:
name: int not expected
key: $(test)_FAIL
file: $(test_path)$(psep)lua_func.lua
func_name: echo
param: [18]
expected_result: 17
- lua_func:
name: float not expected
key: $(test)_FAIL
file: $(test_path)$(psep)lua_func.lua
func_name: echo
param: [0.3]
expected_result: 0.5
- lua_func:
name: String not expected
key: $(test)_FAIL
file: $(test_path)$(psep)lua_func.lua
func_name: echo
param: [ "Something" ]
expected_result: Nothing
- lua_func:
name: Tuple int,str not expected
key: $(test)_FAIL
file: $(test_path)$(psep)lua_func.lua
func_name: tuple_return
param: [ 0, "OK" ]
expected_result: [0, "OUPS"]
- lua_func:
name: small list not expected
key: $(test)_FAIL
file: $(test_path)$(psep)lua_func.lua
func_name: echo
param: [ [-23] ]
expected_result: [-22]
- lua_func:
name: big list not expected
key: $(test)_FAIL
file: $(test_path)$(psep)lua_func.lua
func_name: echo
param: [ [-23, 17, 67] ]
expected_result: [-23, 16, 67]
- lua_func: - lua_func:
name: delgd test name: delgd test
key: $(test)_PASS key: $(test)_PASS
@@ -193,40 +274,39 @@
func_name: return_nothing func_name: return_nothing
- lua_func: - lua_func:
name: function returning explicit nil should succeed name: function returning explicit None should succeed
key: $(test)_PASS key: $(test)_PASS
file: $(test_path)$(psep)lua_func.lua file: $(test_path)$(psep)lua_func.lua
func_name: return_explicit_nil func_name: return_explicit_none
- group: - group:
name: context_id tests name: context_id tests
steps: steps:
- lua_func: - lua_func:
name: set context value name: set serializable value
key: $(test)_PASS key: $(test)_PASS
file: $(test_path)$(psep)lua_func.lua file: $(test_path)$(psep)lua_func.lua
func_name: set_context_value func_name: set_context_value
context_id: lua_ctx_test
param: param:
- hello lua - hello context
expected_result: hello lua expected_result: hello context
- lua_func: - lua_func:
name: get context value (same context_id) name: get serializable value (same context_id)
key: $(test)_PASS key: $(test)_PASS
file: $(test_path)$(psep)lua_func.lua file: $(test_path)$(psep)lua_func.lua
func_name: get_context_value func_name: get_context_value
context_id: lua_ctx_test context_id: ctx_test
expected_result: hello lua expected_result: hello context
- lua_func: - lua_func:
name: get context value (no context_id, from main gd) name: get serializable value (no context_id, from main gd)
key: $(test)_PASS key: $(test)_PASS
file: $(test_path)$(psep)lua_func.lua file: $(test_path)$(psep)lua_func.lua
func_name: get_context_value func_name: get_context_value
expected_result: hello lua expected_result: hello context
- lua_func: - lua_func:
name: get context value (different context_id) name: get serializable value (different context_id)
key: $(test)_PASS key: $(test)_PASS
file: $(test_path)$(psep)lua_func.lua file: $(test_path)$(psep)lua_func.lua
func_name: get_context_value func_name: get_context_value
context_id: lua_ctx_other context_id: ctx_other
expected_result: hello lua expected_result: hello context

View File

@@ -1 +1,6 @@
skipped_test_item: ['skipped_checkglobal'] skipped_test_item: ['skipped_checkglobal']
data_to_be_returned:
- 1
- {a: 1, b: 2}
- ["a", 1, 2.1, True]

View File

@@ -16,8 +16,8 @@ def checkglobal(param):
assert param=='test parameter' assert param=='test parameter'
return 0 return 0
def checkglobal2(): def checkglobal2(index):
return tm.gd("py_func test parameter") return tm.gd("data_to_be_returned")[index]
def should_not_be_called(param): def should_not_be_called(param):
raise raise

View File

@@ -1,7 +1,7 @@
- let: - let:
name: py_func test constants, name: py_func test constants,
values: values:
py_func test parameter: test parameter - func_test_parameter: test parameter
- py_func: - py_func:
name: pass py_func name: pass py_func
@@ -70,7 +70,7 @@
file: $(test_path)$(psep)py_func.py file: $(test_path)$(psep)py_func.py
func_name: checkglobal func_name: checkglobal
param: param:
- $(py_func test parameter) - $(func_test_parameter)
- let: - let:
name: python2func name: python2func
@@ -79,11 +79,32 @@
- py: $(test_path)$(psep)py_func.py - py: $(test_path)$(psep)py_func.py
- py_func: - py_func:
name: global param py_func 2 name: global param int
key: $(test)_PASS key: $(test)_PASS
file: $(py) file: $(test_path)$(psep)py_func.py
func_name: checkglobal2 func_name: checkglobal2
expected_result: $(py_func test parameter) param:
- 0
expected_result: ($(data_to_be_returned))[0]
- py_func:
name: global param dict
key: $(test)_PASS
file: $(test_path)$(psep)py_func.py
func_name: checkglobal2
param:
- 1
expected_result: ($(data_to_be_returned))[1]
- py_func:
name: global param list
key: $(test)_PASS
file: $(test_path)$(psep)py_func.py
func_name: checkglobal2
param:
- 2
expected_result: ($(data_to_be_returned))[2]
- py_func: - py_func:
@@ -92,104 +113,162 @@
file: $(py) file: $(py)
func_name: checkglobal func_name: checkglobal
param: param:
- $(py_func test parameter) - $(func_test_parameter)
- py_func: - py_func:
name: skipped_checkglobal name: skipped_checkglobal
key: $(test)_PASS
file: $(test_path)$(psep)py_func.py file: $(test_path)$(psep)py_func.py
func_name: should_not_be_called func_name: should_not_be_called
param: param:
- $(py_func test parameter) - $(func_test_parameter)
- py_func: - py_func:
name: skipped true name: skipped true
key: $(test)_FAIL
file: $(test_path)$(psep)py_func.py file: $(test_path)$(psep)py_func.py
func_name: checkglobal func_name: echo
skipped: true skipped: true
param: param:
- $(py_func test parameter) - "skipped"
- py_func: - py_func:
name: skipped 1 name: skipped 1
key: $(test)_FAIL
file: $(test_path)$(psep)py_func.py file: $(test_path)$(psep)py_func.py
func_name: checkglobal func_name: echo
skipped: 1 skipped: 1
param: param:
- $(py_func test parameter) - "skipped"
- py_func: - py_func:
name: FunctionItem test name: FunctionItem test
key: $(test)_PASS
file: $(test_path)$(psep)py_func.py file: $(test_path)$(psep)py_func.py
func_name: ValidationTest func_name: ValidationTest
param: param:
- $(py_func test parameter) - $(func_test_parameter)
- group: - group:
name: Function results check name: Function results check
steps: steps:
- group: - group:
name: Function result 1 name: Functions result
steps: steps:
- py_func: - py_func:
name: int failure name: int
key: $(test)_PASS key: $(test)_PASS
file: $(test_path)$(psep)py_func.py file: $(test_path)$(psep)py_func.py
func_name: echo func_name: echo
param: [-1] param: [-1]
expected_result: -1
- py_func: - py_func:
name: float failure name: float
key: $(test)_PASS key: $(test)_PASS
file: $(test_path)$(psep)py_func.py file: $(test_path)$(psep)py_func.py
func_name: echo func_name: echo
param: [-1.3] param: [-20.3]
expected_result: -1.3
- py_func: - py_func:
name: String failure name: String
key: $(test)_PASS key: $(test)_PASS
file: $(test_path)$(psep)py_func.py file: $(test_path)$(psep)py_func.py
func_name: echo func_name: echo
param: [ "FAIL" ] param: [ "FAIL" ]
expected_result: FAIL
- py_func: - py_func:
name: Tuple int,str failure name: Tuple int,str
key: $(test)_PASS key: $(test)_PASS
file: $(test_path)$(psep)py_func.py file: $(test_path)$(psep)py_func.py
func_name: tuple_return func_name: tuple_return
param: [ -1, "Got a failure" ] param: [ 0, "OK" ]
expected_result: [-1, "Got a failure"]
- group: - group:
name: Functions result 2 name: Functions result expected
steps: steps:
- py_func: - py_func:
name: int success name: int expected
key: $(test)_PASS key: $(test)_PASS
file: $(test_path)$(psep)py_func.py file: $(test_path)$(psep)py_func.py
func_name: echo func_name: echo
param: [0] param: [18]
expected_result: 0 expected_result: 18
- py_func: - py_func:
name: float success name: float expected
key: $(test)_PASS key: $(test)_PASS
file: $(test_path)$(psep)py_func.py file: $(test_path)$(psep)py_func.py
func_name: echo func_name: echo
param: [0.3] param: [0.3]
expected_result: 0.3 expected_result: 0.3
- py_func: - py_func:
name: String success name: String expected
key: $(test)_PASS key: $(test)_PASS
file: $(test_path)$(psep)py_func.py file: $(test_path)$(psep)py_func.py
func_name: echo func_name: echo
param: [ "Something that is not only strictly FAIL" ] param: [ "Something" ]
expected_result: Something that is not only strictly FAIL expected_result: Something
- py_func: - py_func:
name: Tuple int,str success name: Tuple int,str expected
key: $(test)_PASS key: $(test)_PASS
file: $(test_path)$(psep)py_func.py file: $(test_path)$(psep)py_func.py
func_name: tuple_return func_name: tuple_return
param: [ 0, "OK" ] param: [ 0, "OK" ]
expected_result: [0, "OK"] expected_result: [0, "OK"]
- py_func:
name: small list expected
key: $(test)_PASS
file: $(test_path)$(psep)py_func.py
func_name: echo
param: [ [-23] ]
expected_result: [-23]
- py_func:
name: big list expected
key: $(test)_PASS
file: $(test_path)$(psep)py_func.py
func_name: echo
param: [ [-23, 17, 67] ]
expected_result: [-23, 17, 67]
- group:
name: Function result not expected
steps:
- py_func:
name: int not expected
key: $(test)_FAIL
file: $(test_path)$(psep)py_func.py
func_name: echo
param: [18]
expected_result: 17
- py_func:
name: float not expected
key: $(test)_FAIL
file: $(test_path)$(psep)py_func.py
func_name: echo
param: [0.3]
expected_result: 0.5
- py_func:
name: String not expected
key: $(test)_FAIL
file: $(test_path)$(psep)py_func.py
func_name: echo
param: [ "Something" ]
expected_result: Nothing
- py_func:
name: Tuple int,str not expected
key: $(test)_FAIL
file: $(test_path)$(psep)py_func.py
func_name: tuple_return
param: [ 0, "OK" ]
expected_result: [0, "OUPS"]
- py_func:
name: small list not expected
key: $(test)_FAIL
file: $(test_path)$(psep)py_func.py
func_name: echo
param: [ [-23] ]
expected_result: [-22]
- py_func:
name: big list not expected
key: $(test)_FAIL
file: $(test_path)$(psep)py_func.py
func_name: echo
param: [ [-23, 17, 67] ]
expected_result: [-23, 16, 67]
- py_func: - py_func:
name: delgd test name: delgd test
key: $(test)_PASS key: $(test)_PASS

View File

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

View File

@@ -1,17 +0,0 @@
- pytest:
name: Pytest item
test_file: {{include_directory}}/test_cases.py
key: $(test)_PASS
test_method:
- test_01_pass
- test_02_pass
- test_05_param
- pytest:
name: Pytest item
test_file: {{include_directory}}/test_cases.py
key: $(test)_FAIL
test_method:
- test_01_pass
- test_03_fail
- test_04_skip

View File

@@ -1,28 +0,0 @@
import pytest
def test_01_pass():
''' Test 01 passes '''
assert 1 + 1 == 2
def test_02_pass():
''' Test 02 passes '''
assert "a" in "abc"
def test_03_fail():
''' Test 03 fails on purpose '''
assert 1 == 2, "deliberate failure"
@pytest.mark.skip(reason="skipped on purpose")
def test_04_skip():
''' Test 04 is skipped '''
assert False
@pytest.mark.parametrize("n", [1, 2])
def test_05_param(n):
''' Test 05 is parametrised, both cases pass '''
assert n < 3

View File

@@ -1,9 +0,0 @@
import py_func.tm as tm
def assert_captured():
"""The sub-run log stored by `run` via store_result must be in the gdict."""
log = tm.gd("captured_log", "")
assert "Test run success." in log, \
"captured sub-run log not reachable from the gdict (store_result)"
return 0

View File

@@ -1,7 +0,0 @@
main:
name: run sub-test (always fail)
steps:
- check:
name: fail
values:
- false

View File

@@ -1,7 +0,0 @@
main:
name: run sub-test (always pass)
steps:
- check:
name: pass
values:
- true

View File

@@ -1,44 +1,25 @@
# run item: launches a .tum file in a new testium instance. # run item: launches a .tum file in a new testium instance.
# Child mode: -b in batch / -r in the GUI, or forced -b (captured) by batch: true. # In batch mode the sub-instance runs with -b; in GUI mode with -r.
# Result is SUCCESS if the sub-instance launched, regardless of its own result. # The run item result is SUCCESS if the sub-instance launched successfully,
# log_file (GUI -r only) goes to the gitignored report dir to avoid repo litter. # regardless of its own test result.
- run: - run:
name: run PASS (valid file, passing sub-test) name: run PASS (valid file, passing sub-test)
key: $(test)_PASS key: $(test)_PASS
tum: $(test_path)$(psep)sub_pass.tum tum: $(test_path)$(psep)sub_pass.tum
log_file: $(validation_report_path)$(psep)run_sub.log
- run: - run:
name: run PASS (valid file, failing sub-test) name: run PASS (valid file, failing sub-test)
key: $(test)_PASS key: $(test)_PASS
tum: $(test_path)$(psep)sub_fail.tum tum: $(test_path)$(psep)sub_fail.tum
log_file: $(validation_report_path)$(psep)run_sub.log
- run: - run:
name: run FAIL (file not found) name: run FAIL (file not found)
key: $(test)_FAIL key: $(test)_FAIL
tum: $(test_path)$(psep)non_existent.tum tum: $(test_path)$(psep)non_existent.tum
log_file: $(validation_report_path)$(psep)run_sub.log
- run: - run:
name: run FAIL (wait_for_exec without time window) name: run FAIL (wait_for_exec without time window)
key: $(test)_FAIL key: $(test)_FAIL
tum: $(test_path)$(psep)sub_pass.tum tum: $(test_path)$(psep)sub_pass.tum
wait_for_exec: true wait_for_exec: true
log_file: $(validation_report_path)$(psep)run_sub.log
# batch: true forces a headless, captured sub-run even in the GUI; its log is
# kept as the result value and pushed to the gdict by store_result.
- run:
name: run batch (capture sub-run log to the gdict)
key: $(test)_PASS
tum: $(test_path)$(psep)sub_pass.tum
batch: true
store_result: captured_log
- py_func:
name: captured sub-run log is post-processable from the gdict
key: $(test)_PASS
file: $(test_path)$(psep)check_capture.py
func_name: assert_captured

View File

@@ -1,9 +0,0 @@
main:
name: root
steps:
- sleep:
name: ok
timeout: 0
# The structural error lives in the included file; the message must point
# the user at that file, not at this one.
- !include bad_include_inc.tum

View File

@@ -1,4 +0,0 @@
# Included as a bare list of steps. The unknown item below must be reported
# with THIS file as the location.
- frobnicate_in_include:
name: nope

View File

@@ -1,6 +0,0 @@
main:
name: root
steps:
# A container item (group) without its mandatory 'steps:' list.
- group:
name: g

View File

@@ -1,5 +0,0 @@
main:
name: root
steps:
# The body of an item must be a mapping of parameters, not a scalar.
- sleep: 5

View File

@@ -1,5 +0,0 @@
main:
name: root
steps:
# A step that is a bare scalar instead of a '<item>: ...' mapping.
- just some text

View File

@@ -1,11 +0,0 @@
main:
name: root
steps:
# Two items wrongly packed under a single '-' marker (a frequent indent
# mistake): the second key belongs one '-' lower.
- sleep:
name: s
timeout: 0
group:
name: g
steps: []

View File

@@ -1,8 +0,0 @@
main:
name: root
steps:
- console:
console_name: c1
steps:
- opens:
device: /dev/ttyUSB0

View File

@@ -1,5 +0,0 @@
main:
name: root
steps:
- frobnicate:
name: nope

View File

@@ -1,87 +0,0 @@
#!/usr/bin/env python3
"""Per-channel check of test-load error reporting.
Given the channel's testium invocation as argv (e.g. ``flatpak run
--command=testium org.testium.Testium``, a PyInstaller binary path, or
``python -m testium``), load each deliberately broken ``.tum`` under
``load_errors/`` in batch mode and verify that:
1. the load FAILS (non-zero exit), and
2. the output carries the *specific, located* message we expect — not a bare
Python traceback and not the generic 'crashed for any reason'.
This guards the load-time error handling in ``test_set.load_test_recursively``
and ``item_actions.load`` (a structural mistake in a ``.tum`` must always reach
the user as a readable ``TUM file syntax error`` naming the offending file,
item path and value). The historical failure mode was an unknown console
action crashing the error formatter itself with ``'dict_keys' object is not
subscriptable``.
Exits non-zero (with a diagnostic) on the first failure so the validation run
fails loudly. Used by ``run.sh`` before launching the main suite.
"""
import os
import re
import subprocess
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
FIXTURES = os.path.join(HERE, "load_errors")
# testium colourises its log; strip the ANSI escapes before matching messages.
_ANSI = re.compile(r"\x1b\[[0-9;]*m")
# fixture file -> substrings that must all appear in the load output.
CASES = [
("unknown_item.tum", ["TUM file syntax error", "is not a known test item",
"frobnicate", "Known items:"]),
("unknown_action.tum", ["unknown action", "opens", "Known actions:"]),
("two_steps.tum", ["must define exactly one test item"]),
("scalar_body.tum", ["body of test item 'sleep'", "must be a mapping"]),
("group_no_steps.tum", ["No 'steps' list found", "'group' item 'g'"]),
("step_not_mapping.tum", ["is not a valid test item"]),
# The error is inside the included file: the message must name that file.
("bad_include.tum", ["bad_include_inc.tum", "frobnicate_in_include",
"is not a known test item"]),
]
def fail(msg):
print(f"LOAD-ERROR CHECK: FAIL — {msg}", file=sys.stderr)
sys.exit(1)
def check_case(cmd, fixture, needles):
path = os.path.join(FIXTURES, fixture)
try:
out = subprocess.run(cmd + ["-b", path], capture_output=True, timeout=120)
except Exception as e: # noqa: BLE001
fail(f"`{' '.join(cmd)} -b {fixture}` could not run: {e}")
blob = _ANSI.sub("", (out.stdout + out.stderr).decode(errors="replace"))
if out.returncode == 0 or "Test run success." in blob:
fail(f"{fixture}: load was expected to fail but succeeded "
f"(exit {out.returncode}).")
# A raw Python traceback reaching the user is exactly what we are guarding
# against: every load error must be funnelled through a TUM*Error.
if "Traceback (most recent call last)" in blob:
fail(f"{fixture}: a raw Python traceback leaked to the user:\n"
f"{blob[-600:]}")
missing = [n for n in needles if n not in blob]
if missing:
fail(f"{fixture}: load message is missing {missing}.\n"
f"--- got ---\n{blob[-800:]}")
print(f"LOAD-ERROR CHECK: {fixture} OK")
def main():
cmd = sys.argv[1:]
if not cmd:
fail("usage: load_errors_check.py <testium-invocation...>")
for fixture, needles in CASES:
check_case(cmd, fixture, needles)
print("LOAD-ERROR CHECK: PASS")
if __name__ == "__main__":
main()

View File

@@ -31,7 +31,11 @@ main:
{% for item in items %} {% for item in items %}
# item test # item test
- let: {name: {{ item }} test constants, values: {test: {{ item }}, test_path: items/$(test)}} - let:
name: {{ item }} test constants
values:
- test: {{ item }}
- test_path: items/$(test)
- group: - group:
name: {{ item }} test name: {{ item }} test
steps: steps:

View File

@@ -89,7 +89,7 @@ def exec():
junit_report = report.replace(".sqlite", f"-{test}.xml") junit_report = report.replace(".sqlite", f"-{test}.xml")
print(junit_report) print(junit_report)
_prepare_file_to_save(junit_report) _prepare_file_to_save(junit_report)
with open(junit_report, "w", encoding="utf-8") as f: with open(junit_report, "w") as f:
f.write(TestSuite.to_xml_string([ts])) f.write(TestSuite.to_xml_string([ts]))
# cleanup # cleanup

View File

@@ -89,13 +89,6 @@ REM Reports are stamped with the mode so successive runs don't clobber each othe
SET "TAIL=-b -d "python_bin=%VENV_PYTHON%" -d "validation_report_file=validation-%MODE%" -- "%SCRIPT_DIR%\main.tum"%EXTRA%" SET "TAIL=-b -d "python_bin=%VENV_PYTHON%" -d "validation_report_file=validation-%MODE%" -- "%SCRIPT_DIR%\main.tum"%EXTRA%"
REM The report-exporter plugin (items\report_plugin) is a pip entry-point
REM package. It must live in the *testium* environment, so it is installed into
REM the source/wheel venvs below. A frozen PyInstaller binary cannot see
REM externally-installed plugins, so report_plugin is expected to be skipped
REM there (same as Linux pyinstaller mode).
SET "FAKE_EXPORTER=%SCRIPT_DIR%\fake_exporter"
REM ---------- per-mode launcher ---------------------------------------------- REM ---------- per-mode launcher ----------------------------------------------
echo -- validation mode: %MODE% echo -- validation mode: %MODE%
@@ -107,25 +100,8 @@ echo ERROR: unknown --mode '%MODE%'. Expected: source ^| wheel ^| pyinstaller.
exit /b 1 exit /b 1
:MODE_SOURCE :MODE_SOURCE
REM Run testium from src\ in a dedicated venv set up here. We do NOT delegate to call "%PROJECT_DIR%\run.bat" %TAIL%
REM the project's run.bat: that one launches the GUI and does not forward its exit /b %ERRORLEVEL%
REM arguments, so the suite would never run head-less.
SET "TESTIUM_VENV=%PROJECT_DIR%\test\tmp\testium_venv"
IF NOT EXIST "%TESTIUM_VENV%" (
echo Creating testium venv at %TESTIUM_VENV%
%PYTHON_EXE% -m venv "%TESTIUM_VENV%"
IF !ERRORLEVEL! NEQ 0 (
echo ERROR while creating the testium venv.
exit /b 1
)
call "%TESTIUM_VENV%\Scripts\pip" install --quiet --upgrade pip
call "%TESTIUM_VENV%\Scripts\pip" install --quiet -r "%PROJECT_DIR%\src\requirements.txt"
REM language-server extra so `testium lsp` works from source (lsp_check.py)
call "%TESTIUM_VENV%\Scripts\pip" install --quiet "pygls>=1.3"
)
call "%TESTIUM_VENV%\Scripts\pip" install --quiet -e "%FAKE_EXPORTER%"
SET CMD="%TESTIUM_VENV%\Scripts\python.exe" "%PROJECT_DIR%\src\testium"
GOTO LAUNCH
:MODE_WHEEL :MODE_WHEEL
SET "WHEEL=%PROJECT_DIR%\dist\testium-%VERSION%-py3-none-any.whl" SET "WHEEL=%PROJECT_DIR%\dist\testium-%VERSION%-py3-none-any.whl"
@@ -139,13 +115,10 @@ IF NOT EXIST "%WHEEL_VENV%" (
echo Creating wheel venv at %WHEEL_VENV% echo Creating wheel venv at %WHEEL_VENV%
%PYTHON_EXE% -m venv --system-site-packages "%WHEEL_VENV%" %PYTHON_EXE% -m venv --system-site-packages "%WHEEL_VENV%"
call "%WHEEL_VENV%\Scripts\pip" install --quiet --upgrade pip call "%WHEEL_VENV%\Scripts\pip" install --quiet --upgrade pip
REM install with the [lsp] extra so the wheel channel is validated in its call "%WHEEL_VENV%\Scripts\pip" install --quiet "%WHEEL%"
REM language-server-capable form (pulls pygls), matching `pip install testium[lsp]`.
call "%WHEEL_VENV%\Scripts\pip" install --quiet "%WHEEL%[lsp]"
) )
call "%WHEEL_VENV%\Scripts\pip" install --quiet -e "%FAKE_EXPORTER%" "%WHEEL_VENV%\Scripts\python.exe" -m testium %TAIL%
SET CMD="%WHEEL_VENV%\Scripts\python.exe" -m testium exit /b %ERRORLEVEL%
GOTO LAUNCH
:MODE_PYI :MODE_PYI
SET "PYI_BIN=%PROJECT_DIR%\dist\testium-%VERSION%.exe" SET "PYI_BIN=%PROJECT_DIR%\dist\testium-%VERSION%.exe"
@@ -154,22 +127,5 @@ IF NOT EXIST "%PYI_BIN%" (
echo ERROR: PyInstaller binary not found in %PROJECT_DIR%\dist -- run build_all.sh first. echo ERROR: PyInstaller binary not found in %PROJECT_DIR%\dist -- run build_all.sh first.
exit /b 1 exit /b 1
) )
SET CMD="%PYI_BIN%" "%PYI_BIN%" %TAIL%
GOTO LAUNCH
REM ---------- launch ----------------------------------------------------------
:LAUNCH
echo -- launch: %CMD%
REM LSP check (this exact channel): `schema` must keep its nested actions and
REM `lsp` must answer initialize. Mirrors run.sh; aborts the run on failure.
echo -- LSP check (%MODE%)
"%VENV_PYTHON%" "%SCRIPT_DIR%\lsp_check.py" %CMD%
IF !ERRORLEVEL! NEQ 0 (
echo ERROR: LSP check failed for mode %MODE%.
exit /b 1
)
%CMD% %TAIL%
exit /b %ERRORLEVEL% exit /b %ERRORLEVEL%

View File

@@ -3,15 +3,11 @@
# testium (source, wheel, pyinstaller, flatpak, appimage). # testium (source, wheel, pyinstaller, flatpak, appimage).
# #
# Usage: # Usage:
# ./test/validation/run.sh [clean] [--mode MODE] [--gui] [extra testium args] # ./test/validation/run.sh [clean] [--mode MODE] [extra testium args]
# #
# clean remove the validation venv before recreating it # clean remove the validation venv before recreating it
# (must be the first argument; useful after a Python upgrade) # (must be the first argument; useful after a Python upgrade)
# #
# --gui open the GUI with the suite loaded instead of running in
# batch; run it manually from the window, which stays open
# (handy to inspect the tree, try the Ctrl+F search, ...)
#
# --mode MODE which testium build to validate. One of: # --mode MODE which testium build to validate. One of:
# source (default) src/testium via project run.sh # source (default) src/testium via project run.sh
# wheel dist/testium-<v>-py3-none-any.whl # wheel dist/testium-<v>-py3-none-any.whl
@@ -25,7 +21,7 @@
# even Flatpak reaches it via flatpak-spawn --host. The validation venv # even Flatpak reaches it via flatpak-spawn --host. The validation venv
# is created with --system-site-packages so existing system packages # is created with --system-site-packages so existing system packages
# (PySide6, lxml, ...) stay visible, then junit-xml is pip-installed # (PySide6, lxml, ...) stay visible, then junit-xml is pip-installed
# for post_execution.py and pytest for the `pytest` item. # for post_execution.py.
# #
# The report file is suffixed with the mode (e.g. validation-flatpak.sqlite) # The report file is suffixed with the mode (e.g. validation-flatpak.sqlite)
# so consecutive runs in different modes don't overwrite each other. # so consecutive runs in different modes don't overwrite each other.
@@ -49,8 +45,6 @@ else
fi fi
EXTRA=() EXTRA=()
RUN_FLAGS=(-b) # batch by default; --gui opens the GUI and stays open
GUI=0
while [ $# -gt 0 ]; do while [ $# -gt 0 ]; do
case "$1" in case "$1" in
--mode) --mode)
@@ -61,11 +55,6 @@ while [ $# -gt 0 ]; do
MODE="${1#--mode=}" MODE="${1#--mode=}"
shift shift
;; ;;
--gui)
GUI=1
RUN_FLAGS=() # no -b: launch the GUI with the suite loaded,
shift # run it manually; the window does not auto-close
;;
*) *)
EXTRA+=("$1") EXTRA+=("$1")
shift shift
@@ -84,7 +73,7 @@ if [ ! -d "$VENV_DIR" ]; then
echo "Creating validation venv at $VENV_DIR" echo "Creating validation venv at $VENV_DIR"
python3 -m venv --system-site-packages "$VENV_DIR" python3 -m venv --system-site-packages "$VENV_DIR"
"$VENV_DIR/bin/pip" install --quiet --upgrade pip "$VENV_DIR/bin/pip" install --quiet --upgrade pip
"$VENV_DIR/bin/pip" install --quiet junit-xml pytest "$VENV_DIR/bin/pip" install --quiet junit-xml
fi fi
VENV_PYTHON="$VENV_DIR/bin/python3" VENV_PYTHON="$VENV_DIR/bin/python3"
@@ -158,17 +147,7 @@ echo "-- launch: ${CMD[*]}"
echo "-- LSP check ($MODE)" echo "-- LSP check ($MODE)"
"$VENV_PYTHON" "$SCRIPT_DIR/lsp_check.py" "${CMD[@]}" "$VENV_PYTHON" "$SCRIPT_DIR/lsp_check.py" "${CMD[@]}"
# ---------- load-error check (this exact channel) ----------------------------- exec "${CMD[@]}" -b \
# Deliberately broken .tum files must fail to load with a specific, located
# message (not a raw traceback): guards the load-time error handling.
echo "-- load-error check ($MODE)"
"$VENV_PYTHON" "$SCRIPT_DIR/load_errors_check.py" "${CMD[@]}"
if [ "$GUI" -eq 1 ]; then
echo "-- GUI mode: the suite is loaded; press Start to run. Window stays open."
fi
exec "${CMD[@]}" "${RUN_FLAGS[@]}" \
-d "python_bin=$VENV_PYTHON" \ -d "python_bin=$VENV_PYTHON" \
-d "validation_report_file=validation-$MODE" \ -d "validation_report_file=validation-$MODE" \
-- "$SCRIPT_DIR/main.tum" "${EXTRA[@]}" -- "$SCRIPT_DIR/main.tum" "${EXTRA[@]}"