lsp: declarative action registry + cross-channel language server

Make `testium lsp` (and the testium_assist editor extension that spawns it)
work from every distribution channel: source, wheel, PyInstaller, Flatpak,
AppImage.

Two enablers:

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

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-05-29 23:17:59 +02:00
parent a01268cd0e
commit 8ab53f470d
12 changed files with 204 additions and 58 deletions

View File

@@ -254,13 +254,25 @@ Validation is **opt-in per subclass**: while a subclass keeps `PARAMS = None` (t
Diagnostics are currently **warnings** for unknown params so an out-of-tree `.tum` with a pre-existing typo doesn't suddenly fail. The flip to a hard error is a one-line change in `_validate_declared_params()` once the user is comfortable. Diagnostics are currently **warnings** for unknown params so an out-of-tree `.tum` with a pre-existing typo doesn't suddenly fail. The flip to a hard error is a one-line change in `_validate_declared_params()` once the user is comfortable.
The schema is also designed to be the source of truth for a future LSP server and for auto-generated manual sections: `ParamSet.to_schema()` returns the JSON-Schema-shaped representation that those consumers will read. Action items follow the same declarative principle. A `TestItemActions` parent (`console`, `plot`, `json_rpc`) declares its nested actions as a class attribute `ACTIONS = {yaml_key: action_class}` (e.g. `{"open": TestItemConsoleOpen, "write": …}`), mirroring `PARAMS`. The base `TestItemActions.__init__` seeds `self.action_classes` from `type(self).ACTIONS`; the imperative `register_actions(**…)` method is retained only as an escape hatch for actions that can't be known at class-definition time (none today). Because the action classes are always defined above their parent in the module, the class-level dict resolves without forward-reference gymnastics.
The schema is the realized source of truth for the LSP server (`testium lsp`), the `testium schema` CLI dump, and future auto-generated manual sections: `ParamSet.to_schema()` returns the JSON-Schema-shaped representation, and `lsp/schema.py` reads both `PARAMS` and `ACTIONS` **purely from class attributes** — no `inspect.getsource`/AST parsing. This is what lets the full schema (including nested actions) survive a frozen PyInstaller build where the `.py` source isn't on disk.
### Language server (`testium lsp`) across channels
The `testium_assist` editor extension is a thin LSP client that spawns `testium lsp` and talks JSON-RPC over stdio, so the language server must work from *every* distribution channel. Two requirements:
1. **`pygls` (+ `lsprotocol`, `cattrs`, `attrs`, `typing_extensions`) must be bundled.** It is the pyproject `[lsp]` extra (kept optional so a plain `pip install testium` stays lean), wired into each full-app channel: `build_env.sh` installs it into the shared `test/tmp/.venv` (covers **source run** and the **PyInstaller** build env); the **AppImage** installs the wheel as `…whl[lsp]`; the **Flatpak** adds a `python3-lsp` pip module (network-at-build, consistent with the manifest's global `--share=network`); the **PyInstaller** `.spec` force-collects the submodules via `collect_submodules` + explicit `hiddenimports` (including the lazily-imported `lsp`, `lsp.server`, `lsp.schema`).
2. **The schema must build without source** — handled by the declarative `PARAMS`/`ACTIONS` above; PyInstaller is the only channel that strips `.py` source, and it no longer matters.
`test/validation/lsp_smoke.py` enforces both per channel: `run.sh` calls it before launching the suite, asserting that `<channel> schema` returns JSON whose `console`/`plot`/`json_rpc` items still carry their actions, and that `<channel> lsp` answers an `initialize` request with capabilities (and never reports the pygls dependency missing). So `./test/validation/run.sh --mode flatpak|pyinstaller|appimage` now fails loudly if a channel ships a broken or pygls-less language server.
### Version reporting (`interpreter/utils/version.py`) ### Version reporting (`interpreter/utils/version.py`)
Both Flatpak and AppImage export `TESTIUM_VERSION` from a launcher (Flatpak: launcher script in `org.testium.Testium.yaml`; AppImage: `runtime.env` in `AppImageBuilder.yml`). `get_testium_version()` checks `/.flatpak-info` / `APPIMAGE` and reads `TESTIUM_VERSION` rather than relying on package metadata or repo introspection. Both Flatpak and AppImage export `TESTIUM_VERSION` from a launcher (Flatpak: launcher script in `org.testium.Testium.yaml`; AppImage: `runtime.env` in `AppImageBuilder.yml`). `get_testium_version()` checks `/.flatpak-info` / `APPIMAGE` and reads `TESTIUM_VERSION` rather than relying on package metadata or repo introspection.
## Recent fixes / notable changes ## Recent fixes / notable changes
- 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_smoke.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.
- Flatpak: `py_func` / `lua_func` / `run` sub-instance now execute on the host via `flatpak-spawn --host`. The previous attempt to inject host lib dirs into the sandbox's `LD_LIBRARY_PATH` was abandoned — host shared libs are ABI-incompatible with the Flatpak runtime's glibc and would trip `_dl_call_libc_early_init`. The manifest gained `--talk-name=org.freedesktop.Flatpak` so the spawn proxy call is allowed. The testium package is staged once per process under `/tmp` (shared with the host) so the host interpreter can locate `py_func` / `lua_func`. - Flatpak: `py_func` / `lua_func` / `run` sub-instance now execute on the host via `flatpak-spawn --host`. The previous attempt to inject host lib dirs into the sandbox's `LD_LIBRARY_PATH` was abandoned — host shared libs are ABI-incompatible with the Flatpak runtime's glibc and would trip `_dl_call_libc_early_init`. The manifest gained `--talk-name=org.freedesktop.Flatpak` so the spawn proxy call is allowed. The testium package is staged once per process under `/tmp` (shared with the host) so the host interpreter can locate `py_func` / `lua_func`.
- Validation suite: single entry point with `--mode source|wheel|pyinstaller|flatpak|appimage` to validate every packaging channel against the same items. Per-mode report filenames prevent clobbering. - Validation suite: single entry point with `--mode source|wheel|pyinstaller|flatpak|appimage` to validate every packaging channel against the same items. Per-mode report filenames prevent clobbering.

View File

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

View File

@@ -28,6 +28,20 @@ build-options:
modules: modules:
- python3-requirements.json - python3-requirements.json
# Language-server deps for `testium lsp` (pygls + lsprotocol + cattrs + attrs
# + typing_extensions). Installed from PyPI at build time — the build already
# runs with --share=network (see build-options). The core runtime deps stay
# offline-pinned in python3-requirements.json; these are pure-python wheels,
# hence --only-binary=:all: (no compilation, deterministic).
- name: python3-lsp
buildsystem: simple
build-options:
build-args:
- --share=network
build-commands:
- pip3 install --prefix=${FLATPAK_DEST} --only-binary=:all: "pygls>=1.3"
# 1. Dépendances Python tierces (HORS PySide6) # 1. Dépendances Python tierces (HORS PySide6)
# Utilisez flatpak-pip-generator pour vos autres libs (ex: pyserial, requests, etc.) # Utilisez flatpak-pip-generator pour vos autres libs (ex: pyserial, requests, etc.)
# - name: python3-requirements # - name: python3-requirements

View File

@@ -1,5 +1,21 @@
# -*- mode: python ; coding: utf-8 -*- # -*- mode: python ; coding: utf-8 -*-
import os import os
from PyInstaller.utils.hooks import collect_submodules
# Language-server dependencies for `testium lsp`. pygls/lsprotocol register
# converters and features dynamically, so we collect their submodules wholesale
# and force-import their pure-python deps (cattrs/attrs/typing_extensions).
# The testium lsp modules are imported lazily by the CLI dispatch
# (`from lsp.server import serve`), which PyInstaller's static analysis misses —
# hence the explicit names. No source files need bundling: the schema export is
# now fully declarative (PARAMS + ACTIONS class attributes), so it no longer
# reads .py source via inspect.getsource (which fails in a frozen build).
_LSP_HIDDEN = (
collect_submodules("pygls")
+ collect_submodules("lsprotocol")
+ ["cattrs", "attr", "attrs", "typing_extensions",
"lsp", "lsp.server", "lsp.schema"]
)
# junit_xml is imported by post_exec scripts running under the *host* Python, # junit_xml is imported by post_exec scripts running under the *host* Python,
# not the frozen interpreter — so bundling it via hiddenimports alone is not # not the frozen interpreter — so bundling it via hiddenimports alone is not
@@ -54,7 +70,7 @@ a = Analysis(
"colorama", "colorama",
"matplotlib", "matplotlib",
"junit_xml", "junit_xml",
"lxml"], "lxml"] + _LSP_HIDDEN,
hookspath=[], hookspath=[],
hooksconfig={}, hooksconfig={},
runtime_hooks=[], runtime_hooks=[],

View File

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

View File

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

View File

@@ -388,18 +388,19 @@ class TestItemConsole(TestItemActions):
"as long as their names differ."), "as long as their names differ."),
) )
ACTIONS = {
"open": TestItemConsoleOpen,
"close": TestItemConsoleClose,
"write": TestItemConsoleWrite,
"writeln": TestItemConsoleWriteLn,
"read_until": TestItemConsoleReadUntil,
}
def __init__(self, dict_item, parent=None, status_queue=None, filename=""): def __init__(self, dict_item, parent=None, status_queue=None, filename=""):
super().__init__( super().__init__(
cst.TYPE_CONSOLE, dict_item, parent, status_queue, filename=filename cst.TYPE_CONSOLE, dict_item, parent, status_queue, filename=filename
) )
self.register_actions(
open=TestItemConsoleOpen,
close=TestItemConsoleClose,
write=TestItemConsoleWrite,
writeln=TestItemConsoleWriteLn,
read_until=TestItemConsoleReadUntil,
)
self.actions_token = {} self.actions_token = {}
global console global console

View File

@@ -210,6 +210,13 @@ class TestItemJSON_RPC(TestItemActions):
doc="If true, don't echo wire traffic to the log."), doc="If true, don't echo wire traffic to the log."),
) )
ACTIONS = {
"open": TestItemJSRPCActionOpen,
"close": TestItemJSRPCActionClose,
"query": TestItemJSRPCActionQuery,
"receive": TestItemJSRPCActionReceive,
}
def __init__( def __init__(
self, dict_item: dict, parent: TestItem = None, status_queue=None, filename="" self, dict_item: dict, parent: TestItem = None, status_queue=None, filename=""
): ):
@@ -217,13 +224,6 @@ class TestItemJSON_RPC(TestItemActions):
cst.TYPE_JSON_RPC, dict_item, parent, status_queue, filename=filename cst.TYPE_JSON_RPC, dict_item, parent, status_queue, filename=filename
) )
self.register_actions(
open=TestItemJSRPCActionOpen,
close=TestItemJSRPCActionClose,
query=TestItemJSRPCActionQuery,
receive=TestItemJSRPCActionReceive,
)
# Console specific params # Console specific params
self._console = self._prms.getParam("console", required=False) self._console = self._prms.getParam("console", required=False)
# UDP specific params # UDP specific params

View File

@@ -263,18 +263,18 @@ class TestItemPlot(TestItemActions):
"action and by $(plv_<plot_name>) for last-values output."), "action and by $(plv_<plot_name>) for last-values output."),
) )
ACTIONS = {
"open": TestItemPlotActionOpen,
"close": TestItemPlotActionClose,
"periodic": TestItemPlotActionPeriodic,
"add": TestItemPlotActionAdd,
"last_value": TestItemPlotActionLastValues,
"export": TestItemPlotActionExport,
}
def __init__(self, dict_item, parent=None, status_queue=None, filename=""): def __init__(self, dict_item, parent=None, status_queue=None, filename=""):
super().__init__( super().__init__(
cst.TYPE_GRAPH, dict_item, parent, status_queue, filename=filename cst.TYPE_GRAPH, dict_item, parent, status_queue, filename=filename
) )
self.register_actions(
open=TestItemPlotActionOpen,
close=TestItemPlotActionClose,
periodic=TestItemPlotActionPeriodic,
add=TestItemPlotActionAdd,
last_value=TestItemPlotActionLastValues,
export=TestItemPlotActionExport,
)
self.actions_token = self._prms.getParam("plot_name", required=True) self.actions_token = self._prms.getParam("plot_name", required=True)

View File

@@ -24,41 +24,17 @@ from interpreter.utils.test_init import _constants_init
# Action class -> parent cmd (the action's parent in the YAML). Action classes # Action class -> parent cmd (the action's parent in the YAML). Action classes
# aren't first-class TestItemType entries (TYPE_*_ACTION is one generic bucket), # aren't first-class TestItemType entries (TYPE_*_ACTION is one generic bucket),
# so we resolve their YAML key by looking at how each parent registers them. # so we resolve their YAML key from the parent's declarative ``ACTIONS`` map.
def _collect_action_classes(parent_class): def _collect_action_classes(parent_class):
"""Return {action_yaml_key: action_class} for a TestItemActions parent. """Return {action_yaml_key: action_class} for a TestItemActions parent.
Each parent's ``__init__`` calls ``self.register_actions(name=class, ...)`` Each parent declares its actions as a class-level ``ACTIONS = {key: class}``
*during* construction, so we can't read the registry without instantiating attribute (see ``item_actions/TestItemActions``). We read it directly — no
one. We work around it by parsing the source for the registration call — instantiation, no source inspection — so this works identically whether the
cheap, no side effects, and the schema export is a CLI command anyway. package runs from source, a wheel, or a frozen (PyInstaller) build where the
``.py`` source isn't on disk.
""" """
import ast return dict(getattr(parent_class, "ACTIONS", None) or {})
import inspect
try:
src = inspect.getsource(parent_class)
except (OSError, TypeError):
return {}
actions = {}
tree = ast.parse(src)
for node in ast.walk(tree):
if not isinstance(node, ast.Call):
continue
fn = node.func
if not (isinstance(fn, ast.Attribute) and fn.attr == "register_actions"):
continue
for kw in node.keywords:
if kw.arg is None or not isinstance(kw.value, ast.Name):
continue
# ast.Name gives us only the bare identifier; resolve it through
# the parent class's defining module.
mod = __import__(parent_class.__module__, fromlist=[kw.value.id])
cls = getattr(mod, kw.value.id, None)
if cls is not None:
actions[kw.arg] = cls
return actions
def _params_to_schema(item_class, common_params): def _params_to_schema(item_class, common_params):

View File

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

View File

@@ -137,6 +137,13 @@ esac
echo "-- validation mode: $MODE" echo "-- validation mode: $MODE"
echo "-- launch: ${CMD[*]}" echo "-- launch: ${CMD[*]}"
# ---------- LSP smoke check (this exact channel) ------------------------------
# Verify `testium lsp` / `testium schema` work in the build under test before
# running the suite: schema must keep its nested actions (declarative ACTIONS,
# survives frozen builds) and the language server must start (pygls bundled).
echo "-- LSP smoke check ($MODE)"
"$VENV_PYTHON" "$SCRIPT_DIR/lsp_smoke.py" "${CMD[@]}"
exec "${CMD[@]}" -b \ exec "${CMD[@]}" -b \
-d "python_bin=$VENV_PYTHON" \ -d "python_bin=$VENV_PYTHON" \
-d "validation_report_file=validation-$MODE" \ -d "validation_report_file=validation-$MODE" \