Compare commits
13 Commits
perf/load-
...
fix/window
| Author | SHA1 | Date | |
|---|---|---|---|
| fe1766c1fc | |||
| 3c1a736294 | |||
| c3346c6bb7 | |||
| b2f85591ce | |||
| 3d96e5060f | |||
| 2241dfb8c7 | |||
| 9dae210f7f | |||
| d97d00c593 | |||
| 2b0c4b5ee0 | |||
| 59e63e1338 | |||
| de32a524da | |||
| 2515213b14 | |||
| 0376b77494 |
12
DESIGN.md
12
DESIGN.md
@@ -97,6 +97,15 @@ All dialog items (`dialog_image`, `dialog_question`, `dialog_references`, `dialo
|
||||
- For the live stream (terminal in batch / GUI panel), prefixes every line emitted from a branch's thread with `[<branch_name>] ` so concurrent branches stay readable.
|
||||
- Exposes `write` / `writeln` / `flush` (Python 3.14's `unittest` calls `stream.writeln()` directly without `_WritelnDecorator`).
|
||||
|
||||
### Subprocess RPC startup handshake (py_func / lua_func / eval_proc)
|
||||
|
||||
The parent ↔ subprocess JSON-RPC link runs over a localhost TCP socket. The **subprocess** owns the port: it binds `port 0` (OS-assigned), `listen()`s, then prints `__TESTIUM_RPC_PORT__=<port>` on stdout (constant `RPC_PORT_SENTINEL` in `runtime/jrpc.py`). The parent reads that line (`proc_drain.drain_and_read_port` + `wait_for_port`, deadline `gd("proc_start_timeout", 30)`) and only *then* connects — the server is guaranteed to be listening, so the connect succeeds on the first attempt.
|
||||
|
||||
This replaced the previous fragile scheme (parent reserved a port via `bind(0)`+close, child re-bound the same port, parent connected on a timing guess) which broke intermittently on Windows: cold-start/antivirus variance pushed the worker past the connect deadline, and `connect()` to a not-yet-listening localhost port *times out* (≈1 s) instead of refusing, exhausting the retry budget. Notes:
|
||||
- The server no longer sets `SO_REUSEADDR` (a fresh ephemeral port needs no TIME_WAIT override; on Windows it would enable port hijacking).
|
||||
- `JsonRpcBase.wait_ready()` always settles (event set on success **and** failure) and returns the actual connection outcome — a connect failure no longer hangs a `wait_ready()` caller.
|
||||
- Non-sentinel subprocess stdout/stderr is still forwarded to the parent log (early-startup errors stay visible).
|
||||
|
||||
### Subprocess API contract (py_func / lua_func)
|
||||
|
||||
User test scripts running inside a `py_func` or `lua_func` subprocess **must** use the JSON-RPC bridge to interact with testium state:
|
||||
@@ -115,7 +124,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.
|
||||
|
||||
- `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.
|
||||
- `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.
|
||||
|
||||
Engines (`PyProcessBase`, `LuaProcessBase`, `EvalExecEngine`) call `bins.python_bin()`/`bins.lua_bin()` themselves — call sites never pass an explicit binary path.
|
||||
|
||||
@@ -279,6 +288,7 @@ 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.
|
||||
|
||||
## Recent fixes / notable changes
|
||||
- Subprocess RPC startup handshake: the `py_func`/`lua_func`/`eval_proc` worker now picks its own port (`bind 0`), announces it on stdout (`__TESTIUM_RPC_PORT__=`), and the parent connects only after reading it. Fixes intermittent Windows `failed to connect : timeout` and the matching `wait_ready()` hang; removes the reserve/close/rebind race and `SO_REUSEADDR`. See "Subprocess RPC startup handshake".
|
||||
- `build_all.sh`: builds the four heavy channels in parallel (serial prep for the shared venv + wheel), results in completion order, Ctrl+C kills the whole job tree; `--ram` puts the build scratch on tmpfs (`/dev/shm`) + skips UPX for fast builds on USB/SD storage (Flatpak excluded — rofiles-fuse can't mount tmpfs). See the "Building all channels" section.
|
||||
- LSP across packaging channels: `testium lsp` (and the `testium_assist` editor extension that spawns it) now works from source, wheel, PyInstaller, Flatpak and AppImage. Two enablers — (1) action items declare a class-level `ACTIONS = {key: class}` registry (like `PARAMS`), so `lsp/schema.py` builds the full schema from class attributes with no `inspect.getsource`/AST (which broke under frozen PyInstaller); (2) the `[lsp]` extra (pygls) is wired into every full-app channel. `test/validation/lsp_check.py`, run by `run.sh` before the suite, asserts per-channel that `schema` keeps its actions and `lsp` answers `initialize`. See the matching architecture sections.
|
||||
- Declarative test item parameters (v0.2): each `TestItem` subclass exposes a `PARAMS = ParamSet(...)` class attribute consumed by the base `__init__`. Catches unknown YAML keys (typo warnings listing the accepted names) and missing required params (load-time errors with `.tum` context). Lays the schema foundation for a future LSP server and auto-generated manual sections. See the matching architecture section.
|
||||
|
||||
16
README.md
16
README.md
@@ -129,6 +129,22 @@ A VSCode / VSCodium client extension (`testium_assist`) wraps `testium lsp`;
|
||||
the schema is built from testium itself, so new item types and parameters
|
||||
appear in the editor on the next testium upgrade with no client change.
|
||||
|
||||
It is published on [Open VSX](https://open-vsx.org/extension/testium/testium-assist),
|
||||
so in **VSCodium, Cursor, Windsurf, Theia and code-server** it installs from the
|
||||
Extensions view (search `testium-assist`) or with
|
||||
`codium --install-extension testium.testium-assist`.
|
||||
|
||||
**Microsoft VSCode** does not list Open VSX extensions, so install the `.vsix`
|
||||
by hand — download it from the Open VSX page above, then *Extensions → ⋯ →
|
||||
Install from VSIX…* or:
|
||||
|
||||
```sh
|
||||
code --install-extension testium-assist-0.1.0.vsix
|
||||
```
|
||||
|
||||
The extension runs `testium lsp`, so `testium` must be on the `PATH` (otherwise
|
||||
point the `testium.serverPath` setting at the binary/AppImage).
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### `wl_proxy_marshal_flags` symbol error
|
||||
|
||||
@@ -67,3 +67,36 @@ dependencies:
|
||||
:caption: enable the language server for a wheel / source install
|
||||
|
||||
pip install 'testium[lsp]'
|
||||
|
||||
Installing the VSCode / VSCodium extension
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
|
||||
The *testium_assist* client extension is published on `Open VSX
|
||||
<https://open-vsx.org/extension/testium/testium-assist>`_, the registry used by
|
||||
VSCodium, Cursor, Windsurf, Eclipse Theia and code-server. In those editors,
|
||||
open the Extensions view and search ``testium-assist``, or install it from the
|
||||
command line:
|
||||
|
||||
.. code-block:: text
|
||||
:caption: install in VSCodium and other Open VSX editors
|
||||
|
||||
codium --install-extension testium.testium-assist
|
||||
|
||||
Microsoft *VSCode* uses a different marketplace that does not list Open VSX
|
||||
extensions, so install the packaged ``.vsix`` by hand. Download it from the
|
||||
Open VSX page linked above, then either choose *Extensions* → *⋯* →
|
||||
*Install from VSIX…* in the UI, or run:
|
||||
|
||||
.. code-block:: text
|
||||
:caption: install the .vsix in Microsoft VSCode
|
||||
|
||||
code --install-extension testium-assist-0.1.0.vsix
|
||||
|
||||
The extension launches ``testium lsp``, so the ``testium`` command must be on
|
||||
the ``PATH``. If *testium* is installed elsewhere — a specific binary or an
|
||||
AppImage — point the ``testium.serverPath`` setting at it instead.
|
||||
|
||||
Once installed, open a ``.tum`` file: completion of item types, hover
|
||||
documentation and the outline view become available. If nothing happens, check
|
||||
that no ``files.associations`` entry forces ``*.tum`` to another language (it
|
||||
must stay the ``tum`` language the extension provides).
|
||||
|
||||
Binary file not shown.
@@ -1,3 +1,14 @@
|
||||
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
|
||||
|
||||
version 0.2.2
|
||||
==============
|
||||
- Flatpak sandbox issue fixed for term console. Now a term console is
|
||||
exactly like a host console.
|
||||
- Persistence fix of dialogs in case of flatpak.
|
||||
|
||||
version 0.2.1
|
||||
==============
|
||||
- Faster test loading, especially for large tests built from jinja
|
||||
|
||||
@@ -1 +1 @@
|
||||
0.2
|
||||
0.2.3
|
||||
|
||||
@@ -11,6 +11,16 @@ sys.path.append(os.path.abspath(ourpath.parent))
|
||||
import interpreter.utils.constants as cst
|
||||
|
||||
def main():
|
||||
# Force UTF-8 on stdout/stderr so the runner's output survives a legacy
|
||||
# console code page (Windows cp1252 can't encode box-drawing/accented
|
||||
# chars). Only the stream encoders change; the locale default used for
|
||||
# config files is untouched.
|
||||
for _stream in (sys.stdout, sys.stderr):
|
||||
try:
|
||||
_stream.reconfigure(encoding="utf-8")
|
||||
except (AttributeError, ValueError):
|
||||
pass # no stdout (frozen GUI) or non-reconfigurable stream
|
||||
|
||||
# Subcommand dispatch (must run *before* argparse so neither 'schema' nor
|
||||
# 'lsp' has to share the GUI/batch flag surface). The subcommands also
|
||||
# skip the multiprocessing 'spawn' setup which is only meaningful for the
|
||||
|
||||
@@ -81,9 +81,13 @@ class TermConsole(Console):
|
||||
bufsize=0)
|
||||
|
||||
else:
|
||||
self.term = pexpect.spawn( shell_cmd,
|
||||
echo=False,
|
||||
cwd=self.ppath)
|
||||
# In Flatpak this returns a `flatpak-spawn --host` wrapper so the
|
||||
# console behaves like a host shell (matching py_func / lua_func /
|
||||
# run); elsewhere it's the chosen command unchanged.
|
||||
from interpreter.utils import bins
|
||||
argv = bins.host_console_command(shell_cmd, self.ppath)
|
||||
self.term = pexpect.spawn(argv[0], args=argv[1:],
|
||||
echo=False, cwd=self.ppath)
|
||||
|
||||
self.q = BytesStore()
|
||||
self.t = threading.Thread(target=self.enqueue_output)
|
||||
|
||||
@@ -221,6 +221,11 @@ def main(args, conn=None):
|
||||
|
||||
if conn:
|
||||
settings.setValue(SettingsLastChoices, result)
|
||||
# Flush before sending: the parent terminates this subprocess as soon
|
||||
# as it reads the result, so the QSettings destructor never runs and
|
||||
# the write would race the kill (lost under Flatpak — see the
|
||||
# tested-references dialog for the full rationale).
|
||||
settings.sync()
|
||||
conn.send([result, success])
|
||||
conn.close()
|
||||
else:
|
||||
|
||||
@@ -90,7 +90,7 @@ class TestItemPyFunc(TestItem):
|
||||
|
||||
if not engine.is_alive():
|
||||
engine.start()
|
||||
if not engine.wait_ready():
|
||||
if not engine.wait_ready(10):
|
||||
raise ETUMRuntimeError(
|
||||
f"""Impossible to start the external python execution process.
|
||||
Is the python path correct ?
|
||||
|
||||
@@ -76,6 +76,12 @@ def main(args, conn=None):
|
||||
|
||||
if conn:
|
||||
settings.setValue(SettingsLastReference, result)
|
||||
# Flush to disk *before* handing the result back: as soon as the parent
|
||||
# receives it on the pipe it terminates this subprocess (SIGTERM, no
|
||||
# handler), so the QSettings destructor never runs. Without sync() the
|
||||
# write races the kill and is lost — reliably so under Flatpak, where
|
||||
# the .conf is atomically renamed on the slower ~/.var/app overlay.
|
||||
settings.sync()
|
||||
conn.send([result, success])
|
||||
conn.close()
|
||||
else:
|
||||
|
||||
@@ -14,7 +14,7 @@ class ReportExportHTML(rpe.ReportExport):
|
||||
self.prepareFile()
|
||||
self.create_base()
|
||||
self.process_tests()
|
||||
with open(self._file_name, 'w') as f:
|
||||
with open(self._file_name, 'w', encoding="utf-8") as f:
|
||||
f.write(lxml.html.tostring(self.root, pretty_print=True).decode())
|
||||
|
||||
def testsIterate(self, row):
|
||||
|
||||
@@ -20,7 +20,7 @@ class ReportExportJUnit(rpe.ReportExport):
|
||||
|
||||
ts = TestSuite(repname, test_cases=self.test_cases,
|
||||
hostname=tm.gd('host_ip'))
|
||||
with open(self._file_name, 'w') as f:
|
||||
with open(self._file_name, 'w', encoding="utf-8") as f:
|
||||
TestSuite.to_file(f, [ts])
|
||||
|
||||
def testsIterate(self, row):
|
||||
|
||||
@@ -19,6 +19,7 @@ Public API
|
||||
|
||||
import atexit
|
||||
import os
|
||||
import shlex
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
@@ -177,6 +178,27 @@ def flatpak_host_spawn(interp_bin, cmd_args, host_cwd, extra_env=None):
|
||||
return spawn
|
||||
|
||||
|
||||
def host_console_command(shell_cmd, cwd):
|
||||
"""Build the argv to start *shell_cmd* as an ordinary interactive console.
|
||||
|
||||
*shell_cmd* is the command the caller chose (a string — shell-split — or
|
||||
an argv list); the choice is preserved verbatim.
|
||||
|
||||
Outside Flatpak the command is returned unchanged. Inside Flatpak a bare
|
||||
spawn would run in the sandbox under the runtime python3, so a host venv
|
||||
(``/path/venv/bin/python3 -m mod``) can't see its pip deps. We simply run
|
||||
it on the host with ``flatpak-spawn --host`` so it behaves like any other
|
||||
terminal: flatpak-spawn passes the current environment through unchanged
|
||||
and the shell (sourced venv, profile, …) sets things up as the user wants.
|
||||
No env forwarding or scrubbing — the launcher's leaked PYTHONPATH points at
|
||||
/app paths absent on the host, so it's inert there.
|
||||
"""
|
||||
argv = shlex.split(shell_cmd) if isinstance(shell_cmd, str) else list(shell_cmd)
|
||||
if not _in_flatpak():
|
||||
return argv
|
||||
return ["flatpak-spawn", "--host", f"--directory={cwd}", *argv]
|
||||
|
||||
|
||||
def _which_host_flatpak(name):
|
||||
"""Resolve a binary name (or absolute path) on the host via flatpak-spawn.
|
||||
|
||||
@@ -366,12 +388,16 @@ def ensure(*names):
|
||||
"""
|
||||
missing = []
|
||||
for n in names:
|
||||
if not _resolve(n):
|
||||
display, gd_key, candidates, _ = _SPECS[n]
|
||||
path = _resolve(n)
|
||||
display, gd_key, candidates, _ = _SPECS[n]
|
||||
if not path:
|
||||
missing.append(
|
||||
f" - {display}: tried {candidates} on PATH, none usable. "
|
||||
f"Set '{gd_key}' in the YAML config to override."
|
||||
)
|
||||
elif not tm.gd(gd_key):
|
||||
# Publish resolved path so test scripts can use $(python_bin)/$(lua_bin).
|
||||
tm.setgd(gd_key, path)
|
||||
if missing:
|
||||
raise ETUMRuntimeError(
|
||||
"Required external interpreter(s) not found:\n" + "\n".join(missing)
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import os
|
||||
import sys
|
||||
import subprocess
|
||||
import socket
|
||||
|
||||
import api.testium as tm
|
||||
from runtime.jrpc import JsonRpcClient
|
||||
from interpreter.utils.paths import subproc_path
|
||||
from runtime.tum_except import ETUMRuntimeError
|
||||
from interpreter.utils import bins
|
||||
from interpreter.utils.proc_drain import drain_to_log
|
||||
from interpreter.utils.proc_drain import drain_and_read_port, wait_for_port
|
||||
|
||||
|
||||
class LuaProcessBase:
|
||||
@@ -79,12 +78,7 @@ class LuaProcessBase:
|
||||
else:
|
||||
env[k] = e + ";" + env.get(k, "")
|
||||
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
sock.bind(("localhost", 0))
|
||||
self._port = sock.getsockname()[1]
|
||||
sock.close()
|
||||
|
||||
# POpen params
|
||||
# POpen params (port 0 -> the Lua server picks a free port and reports it)
|
||||
cmd_args = [
|
||||
"main.lua",
|
||||
"--timeout",
|
||||
@@ -92,7 +86,7 @@ class LuaProcessBase:
|
||||
"--host",
|
||||
"127.0.0.1",
|
||||
"--port",
|
||||
f"{self._port}",
|
||||
"0",
|
||||
]
|
||||
|
||||
if tm.debug_enabled() and tm.gd("debug_rpc", False):
|
||||
@@ -122,10 +116,16 @@ class LuaProcessBase:
|
||||
restore_signals=False,
|
||||
**popen_kwargs,
|
||||
)
|
||||
# Route subprocess stdout/stderr (lua require failures, syntax
|
||||
# errors, anything written to fd 1/2 before the in-script
|
||||
# remote_print is set up) into the parent's log.
|
||||
drain_to_log(self._process, prefix="[lua_func] ")
|
||||
# Forward subprocess output to the log and read the startup port sentinel.
|
||||
holder = drain_and_read_port(self._process, prefix="[lua_func] ")
|
||||
self._port = wait_for_port(
|
||||
self._process, holder, tm.gd("proc_start_timeout", 30)
|
||||
)
|
||||
if self._port is None:
|
||||
# Worker died before announcing its port: reset so a later start() retries clean.
|
||||
self.stop()
|
||||
self.join()
|
||||
return
|
||||
|
||||
self._rpc = JsonRpcClient(
|
||||
"localhost", self._port, req_handler=self._req_handler
|
||||
|
||||
@@ -8,6 +8,9 @@ exceptions before the in-process redirection kicks in, lua
|
||||
``require`` failures, anything written to fd 1/2 directly).
|
||||
"""
|
||||
import threading
|
||||
from time import monotonic
|
||||
|
||||
from runtime.jrpc import RPC_PORT_SENTINEL
|
||||
|
||||
|
||||
def _drain_pipe(pipe, prefix):
|
||||
@@ -46,3 +49,60 @@ def drain_to_log(process, prefix=""):
|
||||
t.start()
|
||||
threads.append(t)
|
||||
return threads
|
||||
|
||||
|
||||
def drain_and_read_port(process, prefix=""):
|
||||
"""Like :func:`drain_to_log`, but the stdout reader also watches for the
|
||||
startup port sentinel. Returns a ``holder`` dict (passed to
|
||||
:func:`wait_for_port`); all non-sentinel lines are still forwarded to the
|
||||
log. stderr is drained as usual.
|
||||
"""
|
||||
holder = {"port": None, "evt": threading.Event()}
|
||||
|
||||
def _read_stdout(pipe):
|
||||
try:
|
||||
for raw in iter(pipe.readline, b""):
|
||||
line = raw.decode("utf-8", errors="replace").rstrip("\r\n")
|
||||
if holder["port"] is None and line.startswith(RPC_PORT_SENTINEL):
|
||||
try:
|
||||
holder["port"] = int(line[len(RPC_PORT_SENTINEL):].strip())
|
||||
except ValueError:
|
||||
continue
|
||||
holder["evt"].set()
|
||||
continue
|
||||
if line:
|
||||
print(f"{prefix}{line}" if prefix else line)
|
||||
finally:
|
||||
try:
|
||||
pipe.close()
|
||||
except Exception:
|
||||
pass
|
||||
holder["evt"].set() # unblock waiter on EOF even without sentinel
|
||||
|
||||
if process.stdout is not None:
|
||||
threading.Thread(
|
||||
target=_read_stdout, args=(process.stdout,), daemon=True,
|
||||
).start()
|
||||
if process.stderr is not None:
|
||||
threading.Thread(
|
||||
target=_drain_pipe, args=(process.stderr, prefix), daemon=True,
|
||||
).start()
|
||||
return holder
|
||||
|
||||
|
||||
def wait_for_port(process, holder, deadline):
|
||||
"""Block until the port sentinel arrives, the process dies, or *deadline*
|
||||
seconds elapse. Returns the port int or ``None``.
|
||||
"""
|
||||
end = monotonic() + deadline
|
||||
while holder["port"] is None:
|
||||
remaining = end - monotonic()
|
||||
if remaining <= 0:
|
||||
break
|
||||
holder["evt"].wait(min(remaining, 0.2))
|
||||
if holder["port"] is not None:
|
||||
break
|
||||
if process.poll() is not None:
|
||||
holder["evt"].wait(0.2) # child exited; let the reader flush a trailing line
|
||||
break
|
||||
return holder["port"]
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import os
|
||||
import sys
|
||||
import subprocess
|
||||
import socket
|
||||
from runtime.jrpc import JsonRpcClient
|
||||
import api.testium as tm
|
||||
from runtime.tum_except import ETUMRuntimeError
|
||||
from interpreter.utils.paths import testium_path, subproc_path
|
||||
from interpreter.utils import bins
|
||||
from interpreter.utils.proc_drain import drain_to_log
|
||||
from interpreter.utils.proc_drain import drain_and_read_port, wait_for_port
|
||||
|
||||
|
||||
class PyProcessBase:
|
||||
@@ -54,13 +53,6 @@ class PyProcessBase:
|
||||
else:
|
||||
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
|
||||
# under /tmp (shared between sandbox and host) for both cwd and as the
|
||||
# root in PYTHONPATH. Outside Flatpak the original paths are used.
|
||||
@@ -75,7 +67,7 @@ class PyProcessBase:
|
||||
cmd_args = [
|
||||
"py_func",
|
||||
"-p",
|
||||
f"{self._port}",
|
||||
"0",
|
||||
"-t",
|
||||
f"{self._timeout}",
|
||||
]
|
||||
@@ -107,11 +99,16 @@ class PyProcessBase:
|
||||
restore_signals=False,
|
||||
**popen_kwargs,
|
||||
)
|
||||
# Route subprocess stdout/stderr (early-startup errors,
|
||||
# unhandled exceptions, anything written to fd 1/2 before the
|
||||
# in-process JSON-RPC stdio_redir kicks in) into the parent's
|
||||
# log.
|
||||
drain_to_log(self._process, prefix="[py_func] ")
|
||||
# Forward subprocess output to the log and read the startup port sentinel.
|
||||
holder = drain_and_read_port(self._process, prefix="[py_func] ")
|
||||
self._port = wait_for_port(
|
||||
self._process, holder, tm.gd("proc_start_timeout", 30)
|
||||
)
|
||||
if self._port is None:
|
||||
# Worker died before announcing its port: reset so a later start() retries clean.
|
||||
self.stop()
|
||||
self.join()
|
||||
return
|
||||
|
||||
self._rpc = JsonRpcClient(
|
||||
"localhost", self._port, req_handler=self._req_handler
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
-- =========================
|
||||
local config = {
|
||||
host = "0.0.0.0",
|
||||
port = 9000,
|
||||
port = 0, -- 0 = OS-assigned; actual port is reported on stdout
|
||||
timeout = 60,
|
||||
verbose = false,
|
||||
}
|
||||
@@ -76,6 +76,10 @@ server_sock:listen(1)
|
||||
local ip, port = server_sock:getsockname()
|
||||
utils.log("listening on %s:%d for %.1f secs", ip, port, config.timeout)
|
||||
|
||||
-- Announce the actual bound port so the parent connects only once we listen.
|
||||
io.stdout:write("__TESTIUM_RPC_PORT__=" .. port .. "\n")
|
||||
io.stdout:flush()
|
||||
|
||||
server_sock:settimeout(config.timeout) -- Prevents hanging on dead connections
|
||||
|
||||
-- Main Server Loop
|
||||
|
||||
95
src/testium/main_win/desktop_integration.py
Normal file
95
src/testium/main_win/desktop_integration.py
Normal file
@@ -0,0 +1,95 @@
|
||||
"""Install a desktop entry + icon under the user's data dir so desktop shells
|
||||
show the testium icon in the task bar / dock.
|
||||
|
||||
On a native Wayland session GNOME takes a window's task-bar icon from the
|
||||
``.desktop`` file whose name (or ``StartupWMClass``) matches the window
|
||||
``app_id`` — ``QGuiApplication.setWindowIcon`` is ignored there. The portable
|
||||
channels (source checkout, PyInstaller binary, AppImage) install no system
|
||||
desktop file, so we drop an idempotent one in ``~/.local/share``. The window
|
||||
``app_id`` is set to ``testium`` (see ``QApplication.setDesktopFileName`` in
|
||||
``testium_win``), which is exactly this file's base name.
|
||||
|
||||
Flatpak ships its own ``org.testium.Testium.desktop`` and keeps its own app id,
|
||||
so the caller skips this integration there.
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtGui import QPixmap
|
||||
|
||||
# Must match QApplication.setDesktopFileName(...) for the GUI, and is used as
|
||||
# both the desktop-file base name and the StartupWMClass.
|
||||
APP_ID = "testium"
|
||||
|
||||
|
||||
def _launch_command():
|
||||
"""Best-effort Exec= for the menu entry. Not needed for icon matching, but
|
||||
makes the entry actually launchable when possible."""
|
||||
appimage = os.environ.get("APPIMAGE")
|
||||
if appimage:
|
||||
return f'"{appimage}"'
|
||||
if getattr(sys, "frozen", False):
|
||||
return f'"{os.path.abspath(sys.executable)}"'
|
||||
argv0 = os.path.abspath(sys.argv[0]) if sys.argv and sys.argv[0] else ""
|
||||
if argv0 and os.path.exists(argv0):
|
||||
return f'"{os.path.abspath(sys.executable)}" "{argv0}"'
|
||||
return f'"{os.path.abspath(sys.executable)}" -m testium'
|
||||
|
||||
|
||||
def ensure_desktop_entry():
|
||||
"""Create (or refresh) ~/.local/share icon + desktop entry. Best-effort:
|
||||
any failure is swallowed so it can never take the GUI down.
|
||||
|
||||
Freedesktop-only: a no-op off Linux (Windows / macOS use the window icon)."""
|
||||
if not sys.platform.startswith("linux"):
|
||||
return
|
||||
try:
|
||||
data_home = os.environ.get("XDG_DATA_HOME") or os.path.join(
|
||||
os.path.expanduser("~"), ".local", "share"
|
||||
)
|
||||
icon_dir = os.path.join(data_home, "icons", "hicolor", "256x256", "apps")
|
||||
app_dir = os.path.join(data_home, "applications")
|
||||
icon_path = os.path.join(icon_dir, f"{APP_ID}.png")
|
||||
desktop_path = os.path.join(app_dir, f"{APP_ID}.desktop")
|
||||
|
||||
os.makedirs(icon_dir, exist_ok=True)
|
||||
os.makedirs(app_dir, exist_ok=True)
|
||||
|
||||
# Icon: render the bundled Qt resource to a PNG once. Requires a live
|
||||
# QGuiApplication (the caller creates it before calling us).
|
||||
if not os.path.isfile(icon_path):
|
||||
pixmap = QPixmap(u":/black/testium_logo.png")
|
||||
if not pixmap.isNull():
|
||||
pixmap = pixmap.scaled(
|
||||
256, 256,
|
||||
Qt.AspectRatioMode.KeepAspectRatio,
|
||||
Qt.TransformationMode.SmoothTransformation,
|
||||
)
|
||||
pixmap.save(icon_path, "PNG")
|
||||
|
||||
# Absolute Icon= path so the shell resolves it without an icon-cache
|
||||
# refresh; StartupWMClass lets X11 / XWayland match too.
|
||||
desktop = (
|
||||
"[Desktop Entry]\n"
|
||||
"Type=Application\n"
|
||||
"Name=Testium\n"
|
||||
"Comment=Test sequencer\n"
|
||||
f"Icon={icon_path}\n"
|
||||
f"Exec={_launch_command()} %f\n"
|
||||
"Terminal=false\n"
|
||||
f"StartupWMClass={APP_ID}\n"
|
||||
"Categories=Utility;Development;\n"
|
||||
)
|
||||
|
||||
# Write only when missing or changed, to avoid needless menu churn.
|
||||
current = None
|
||||
if os.path.isfile(desktop_path):
|
||||
with open(desktop_path, "r") as fh:
|
||||
current = fh.read()
|
||||
if current != desktop:
|
||||
with open(desktop_path, "w") as fh:
|
||||
fh.write(desktop)
|
||||
except Exception:
|
||||
# Desktop integration is a nicety, never a hard requirement.
|
||||
pass
|
||||
@@ -678,6 +678,24 @@ def MainWin(
|
||||
debug=False,
|
||||
):
|
||||
app = QApplication(sys.argv)
|
||||
# Application identity so desktop shells (GNOME, ...) show the testium
|
||||
# icon in the task bar / dock instead of a generic one. On Wayland this
|
||||
# sets the surface app_id; on X11/XWayland it sets WM_CLASS, so the window
|
||||
# stops inheriting the launcher's class (e.g. "python3" under the AppImage,
|
||||
# which is what GNOME was keying the wrong icon off) and the window icon
|
||||
# below is used as the fallback. In Flatpak the id must be the Flatpak app
|
||||
# id so it matches the installed desktop file.
|
||||
app.setApplicationName("Testium")
|
||||
app.setApplicationDisplayName("Testium")
|
||||
app.setDesktopFileName(os.environ.get("FLATPAK_ID", "testium"))
|
||||
app.setWindowIcon(QIcon(u":/black/testium_logo.png"))
|
||||
# On native Wayland the task-bar icon comes from an installed desktop file
|
||||
# matched to the app_id, not from setWindowIcon(). Flatpak ships its own;
|
||||
# for the other Linux channels drop an idempotent one under ~/.local/share.
|
||||
# Windows / macOS use the window icon set above, so this is Linux-only.
|
||||
if sys.platform.startswith("linux") and not os.environ.get("FLATPAK_ID"):
|
||||
from main_win.desktop_integration import ensure_desktop_entry
|
||||
ensure_desktop_entry()
|
||||
ui = MainWindow(
|
||||
test_file,
|
||||
config_files,
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
#!/usr/bin/env python
|
||||
import sys
|
||||
import multiprocessing
|
||||
from py_func.tm import _init_api, _remote_print
|
||||
from runtime.stdout_redirect import stdio_redir
|
||||
from runtime.jrpc import RPC_PORT_SENTINEL
|
||||
|
||||
|
||||
class TcpStdOut:
|
||||
@@ -24,21 +26,29 @@ def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("-i", "--ip", type=str, help="Ip address or hostname to listen to",
|
||||
default="localhost")
|
||||
parser.add_argument("-p", "--port", type=int, help="port to listen to",
|
||||
default=9000)
|
||||
parser.add_argument("-p", "--port", type=int, help="port to listen to (0 = OS-assigned)",
|
||||
default=0)
|
||||
parser.add_argument("-t", "--timeout", type=float, help="Timeout waiting for connection",
|
||||
default=10)
|
||||
parser.add_argument("-v", "--verbose", action='store_true', help="port to listen to")
|
||||
args = parser.parse_args()
|
||||
|
||||
thrd_api = _init_api(args.ip, args.port, args.timeout)
|
||||
# redirect I/O
|
||||
outstream = TcpStdOut()
|
||||
stdio_redir.redirect(outstream)
|
||||
# debug the server
|
||||
if args.verbose:
|
||||
thrd_api.dbg_out = stdio_redir.ini_stdout
|
||||
thrd_api.start()
|
||||
|
||||
# Announce the bound port on real stdout (before redirection) so the parent connects.
|
||||
port = thrd_api.wait_bound(args.timeout)
|
||||
if port is None:
|
||||
print("py_func: failed to bind a listening port", file=sys.stderr, flush=True)
|
||||
return
|
||||
print(f"{RPC_PORT_SENTINEL}{port}", flush=True)
|
||||
|
||||
# redirect I/O
|
||||
outstream = TcpStdOut()
|
||||
stdio_redir.redirect(outstream)
|
||||
try:
|
||||
while thrd_api.is_alive():
|
||||
thrd_api.join(1)
|
||||
|
||||
@@ -12,6 +12,9 @@ except:
|
||||
|
||||
from runtime.tum_except import ETUMRuntimeError
|
||||
|
||||
# Startup handshake: subprocess prints this + its bound port on stdout once listening.
|
||||
RPC_PORT_SENTINEL = "__TESTIUM_RPC_PORT__="
|
||||
|
||||
"""Lightweight JSON-RPC 2.0 helpers over TCP sockets.
|
||||
|
||||
This module implements a minimal JSON-RPC 2.0 messaging layer using
|
||||
@@ -279,6 +282,8 @@ class JsonRpcBase(threading.Thread):
|
||||
self._req_handler = req_handler
|
||||
self._dbg_out = dbg_out
|
||||
self._event_ready = threading.Event()
|
||||
# Set on success AND failure so wait_ready() never hangs; outcome in _connected.
|
||||
self._connected = False
|
||||
|
||||
def handle_request(self, method, params):
|
||||
"""Override to implement server-side request handling.
|
||||
@@ -314,10 +319,12 @@ class JsonRpcBase(threading.Thread):
|
||||
self.name, sock, self.handle_request, dbg_out=self.dbg_out
|
||||
)
|
||||
self._rpc.wait_ready()
|
||||
self._connected = True
|
||||
self._event_ready.set()
|
||||
|
||||
def wait_ready(self, timeout=None):
|
||||
return self._event_ready.wait(timeout)
|
||||
self._event_ready.wait(timeout)
|
||||
return self._connected
|
||||
|
||||
@property
|
||||
def dbg_out(self):
|
||||
@@ -348,20 +355,30 @@ class JsonRpcSrv(JsonRpcBase):
|
||||
def __init__(self, host, port, req_handler=None, timeout=10):
|
||||
super().__init__(host, port, req_handler, timeout)
|
||||
self.name = f"JsonRpcSvr_{port}"
|
||||
self._bound_port = None
|
||||
self._bound_evt = threading.Event()
|
||||
|
||||
@property
|
||||
def bound_port(self):
|
||||
return self._bound_port
|
||||
|
||||
def wait_bound(self, timeout=None):
|
||||
self._bound_evt.wait(timeout)
|
||||
return self._bound_port
|
||||
|
||||
def run(self):
|
||||
# TCP/IP socket creation
|
||||
try:
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
||||
|
||||
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
|
||||
# Link of the socket at the configured port
|
||||
# No SO_REUSEADDR: fresh ephemeral port; on Windows it enables hijacking.
|
||||
sock.bind((self._host, self._port))
|
||||
|
||||
# Listens incoming connections
|
||||
sock.listen(1)
|
||||
self.print_info(f"listening on {self._host}:{self._port}")
|
||||
self._bound_port = sock.getsockname()[1]
|
||||
self._bound_evt.set()
|
||||
self.print_info(f"listening on {self._host}:{self._bound_port}")
|
||||
|
||||
self.print_info(f"awaiting connection for {self._timeout} secs")
|
||||
sock.settimeout(self._timeout)
|
||||
@@ -382,6 +399,7 @@ class JsonRpcSrv(JsonRpcBase):
|
||||
sleep(0.1)
|
||||
|
||||
finally:
|
||||
self._bound_evt.set() # unblock wait_bound() even on failure
|
||||
if self._rpc is not None:
|
||||
self._rpc.stop()
|
||||
self._rpc.join()
|
||||
@@ -407,35 +425,34 @@ class JsonRpcClient(JsonRpcBase):
|
||||
self.name = f"JsonRpcClt_{port}"
|
||||
|
||||
def run(self):
|
||||
if tm.OS() == "Windows":
|
||||
self.run_win()
|
||||
else:
|
||||
self.run_lin()
|
||||
try:
|
||||
if tm.OS() == "Windows":
|
||||
self.run_win()
|
||||
else:
|
||||
self.run_lin()
|
||||
except Exception as e:
|
||||
self.print_info(f"connection failed: {e}")
|
||||
finally:
|
||||
self._event_ready.set() # settle wait_ready() whatever the outcome
|
||||
|
||||
def run_win(self):
|
||||
# TCP/IP socket creation
|
||||
tslice = 1
|
||||
t = self._timeout
|
||||
# Server already listening (handshake); retry on refused/timeout until deadline.
|
||||
deadline = monotonic() + self._timeout
|
||||
sock = None
|
||||
try:
|
||||
while t >= 0:
|
||||
while True:
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
sock.settimeout(tslice)
|
||||
# Link of the socket at the configured port
|
||||
sock.settimeout(0.5)
|
||||
try:
|
||||
sock.connect((self._host, self._port))
|
||||
break
|
||||
except socket.timeout:
|
||||
except OSError as e:
|
||||
sock.close()
|
||||
t -= tslice
|
||||
if t < 0:
|
||||
if monotonic() >= deadline:
|
||||
raise ETUMRuntimeError(
|
||||
f"{self.name}: failed to connect : timeout"
|
||||
f"{self.name}: failed to connect : {e}"
|
||||
)
|
||||
else:
|
||||
sleep(tslice)
|
||||
except socket.error as e:
|
||||
raise ETUMRuntimeError(f"{self.name}: failed to connect : {e}")
|
||||
sleep(0.1)
|
||||
|
||||
self.print_info("Connected to server")
|
||||
self.connect(sock)
|
||||
|
||||
@@ -94,6 +94,17 @@
|
||||
{% endif %}
|
||||
- read_until: {expected: endOfCmd, timeout: 1, process_result: "'Hello' in r'''$(result)''' and 'PASS' in r'''$(result)''' "}
|
||||
|
||||
{% if os == "Linux" %}
|
||||
- console:
|
||||
name: Console runs on host (not the Flatpak sandbox)
|
||||
doc: Regression guard for the 0.2.1 Flatpak bug (term console spawned inside the sandbox instead of on the host). /.flatpak-info exists only inside the sandbox, so the host-only marker is emitted (and matched by read_until) ONLY when the shell really runs on the host. On a broken Flatpak the marker never appears, read_until times out and the item FAILS. The marker is built at runtime ($M) so it is never present in the command line itself. Passes on every other channel.
|
||||
console_name: term
|
||||
key: $(test)_PASS
|
||||
steps:
|
||||
- writeln: 'test -e /.flatpak-info && M=SANDBOX || M=HOST; echo "console_host_check_$M"'
|
||||
- read_until: {expected: console_host_check_HOST, timeout: 5}
|
||||
{% endif %}
|
||||
|
||||
- console:
|
||||
name: Console closure
|
||||
execute_on_stop: true
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
console_name: jrpces
|
||||
key: $(test)_PASS
|
||||
steps:
|
||||
- writeln: python3 {{include_directory}}/jrpc_echo_server.py -c {{include_directory}}/jrpces.ini
|
||||
- writeln: '"$(python_bin)" {{include_directory}}/jrpc_echo_server.py -c {{include_directory}}/jrpces.ini'
|
||||
- read_until: {expected: ready, timeout: 5}
|
||||
|
||||
- console:
|
||||
|
||||
@@ -89,7 +89,7 @@ def exec():
|
||||
junit_report = report.replace(".sqlite", f"-{test}.xml")
|
||||
print(junit_report)
|
||||
_prepare_file_to_save(junit_report)
|
||||
with open(junit_report, "w") as f:
|
||||
with open(junit_report, "w", encoding="utf-8") as f:
|
||||
f.write(TestSuite.to_xml_string([ts]))
|
||||
|
||||
# cleanup
|
||||
|
||||
@@ -89,6 +89,13 @@ 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%"
|
||||
|
||||
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 ----------------------------------------------
|
||||
|
||||
echo -- validation mode: %MODE%
|
||||
@@ -100,8 +107,25 @@ echo ERROR: unknown --mode '%MODE%'. Expected: source ^| wheel ^| pyinstaller.
|
||||
exit /b 1
|
||||
|
||||
:MODE_SOURCE
|
||||
call "%PROJECT_DIR%\run.bat" %TAIL%
|
||||
exit /b %ERRORLEVEL%
|
||||
REM Run testium from src\ in a dedicated venv set up here. We do NOT delegate to
|
||||
REM the project's run.bat: that one launches the GUI and does not forward its
|
||||
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
|
||||
SET "WHEEL=%PROJECT_DIR%\dist\testium-%VERSION%-py3-none-any.whl"
|
||||
@@ -115,10 +139,13 @@ IF NOT EXIST "%WHEEL_VENV%" (
|
||||
echo Creating wheel venv at %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 "%WHEEL%"
|
||||
REM install with the [lsp] extra so the wheel channel is validated in its
|
||||
REM language-server-capable form (pulls pygls), matching `pip install testium[lsp]`.
|
||||
call "%WHEEL_VENV%\Scripts\pip" install --quiet "%WHEEL%[lsp]"
|
||||
)
|
||||
"%WHEEL_VENV%\Scripts\python.exe" -m testium %TAIL%
|
||||
exit /b %ERRORLEVEL%
|
||||
call "%WHEEL_VENV%\Scripts\pip" install --quiet -e "%FAKE_EXPORTER%"
|
||||
SET CMD="%WHEEL_VENV%\Scripts\python.exe" -m testium
|
||||
GOTO LAUNCH
|
||||
|
||||
:MODE_PYI
|
||||
SET "PYI_BIN=%PROJECT_DIR%\dist\testium-%VERSION%.exe"
|
||||
@@ -127,5 +154,22 @@ IF NOT EXIST "%PYI_BIN%" (
|
||||
echo ERROR: PyInstaller binary not found in %PROJECT_DIR%\dist -- run build_all.sh first.
|
||||
exit /b 1
|
||||
)
|
||||
"%PYI_BIN%" %TAIL%
|
||||
SET CMD="%PYI_BIN%"
|
||||
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%
|
||||
|
||||
Reference in New Issue
Block a user