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>
103 lines
3.8 KiB
Python
103 lines
3.8 KiB
Python
#!/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()
|