9 Commits

Author SHA1 Message Date
8c4e1b56b5 feat(windows): icon, windowed exe, no-admin installer
- PyInstaller exe built windowed (console=False) with package/testium.ico
  as the embedded icon (BMP entries for shell compatibility).
- Suppress stray subprocess console windows in the frozen Windows build via
  paths.no_window_kwargs() (CREATE_NO_WINDOW); wheel/source unchanged.
  Applied to py_process, lua_process, bins probes, sys_app_path_win.
- New per-user Inno Setup installer (package/innosetup/): no admin,
  version-scoped AppId/dir so versions install side-by-side, one Start
  Menu entry per version, .ico shipped for shortcut/uninstall icons.
- DESIGN.md + release_note.txt updated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 13:57:48 +02:00
e0802a9a72 release 0.2.3 preparation 2026-06-10 12:30:18 +02:00
fe1766c1fc cosmetics 2026-06-10 12:26:54 +02:00
3c1a736294 release note updated with new rev. 2026-06-07 19:52:57 +02:00
c3346c6bb7 chore: bump version to 0.2.3 2026-06-07 19:48:40 +02:00
b2f85591ce style: shorten code comments to one line 2026-06-07 18:47:44 +02:00
3d96e5060f fix: publish resolved python_bin/lua_bin into the global dict
bins.ensure() now stores the resolved interpreter path under
python_bin / lua_bin when the key is unset, so test scripts can use
$(python_bin) / $(lua_bin) in GUI mode (no -d override). Restores the
behaviour lost when bins.py centralised resolution. A user-provided
value is left untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 18:42:43 +02:00
2241dfb8c7 fix(windows): RPC port handshake for py/lua subprocesses
The subprocess now binds port 0, prints the bound port on stdout after
listen(), and the parent connects only once it reads that port. Removes
the reserve/close/rebind race and SO_REUSEADDR, and the connect-before-
ready timing guess that failed intermittently on Windows. wait_ready()
no longer hangs when a connection attempt fails.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 18:29:04 +02:00
9dae210f7f fix(windows): UTF-8 console + self-sufficient validation wrapper
Make the suite run cleanly on Windows.

Product code:
- __init__.py: force UTF-8 on stdout/stderr. The Windows console code
  page (cp1252) cannot encode the box-drawing/accented characters the
  runner prints, which crashed the parent capture_stdout thread. Only
  the stream encoders are reconfigured; the locale default used to read
  cp1252 config files is left untouched.
- report_export_junit/html: open the report file with encoding="utf-8"
  (XML/HTML are UTF-8) instead of the platform default, matching the
  txt/json exporters.

Validation:
- run.bat: source mode now sets up its own venv and runs testium from
  src\ directly instead of delegating to the project run.bat (which
  launches the GUI and drops its arguments). Installs the fake_exporter
  entry-point plugin (report_plugin) and the [lsp] extra, and runs the
  same lsp_check.py pre-flight as run.sh.
- jsonrpc/test.tum: launch the echo server via "$(python_bin)" instead
  of "python3" (the Microsoft Store stub on Windows).
- post_execution.py: write the JUnit XML with encoding="utf-8".
- restore items/run/sub_pass.tum and sub_fail.tum, deleted by mistake in
  d97d00c "removed test logs".
2026-06-06 21:39:36 +02:00
33 changed files with 458 additions and 112 deletions

View File

@@ -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.
@@ -217,7 +226,8 @@ Four distribution channels coexist, all sharing the single `src/testium/` packag
| Channel | Where | Build | Notes |
|---------|-------|-------|-------|
| Wheel (`pip install`) | `src/pyproject.toml` | `python -m build` | Vanilla Python package; entry point `testium = "testium:main"`. |
| PyInstaller binary | `package/pyinstaller/` | `build.sh` | Single ~130 MB binary. `py_func`, `runtime`, `lua_func` bundled at `_MEIPASS` root so the **host** Python can find them when launched as `python3 py_func`. `api`/`interpreter` are **not** exposed (subprocess isolation). |
| PyInstaller binary | `package/pyinstaller/` | `build.sh` | Single ~130 MB binary. `py_func`, `runtime`, `lua_func` bundled at `_MEIPASS` root so the **host** Python can find them when launched as `python3 py_func`. `api`/`interpreter` are **not** exposed (subprocess isolation). Built windowed (`console=False`) with `package/testium.ico` as the exe icon — see "Windows frozen build". |
| Windows installer | `package/innosetup/` | `build.ps1` (Inno Setup 6) | Wraps the PyInstaller exe. Per-user, **no admin** (`PrivilegesRequired=lowest`, installs under `%LOCALAPPDATA%`). Version-scoped `AppId` + install dir so versions coexist side-by-side; one Start Menu entry per version. |
| 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. |
@@ -248,6 +258,19 @@ 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.
- `py_process.py` additionally pops `PYTHONUSERBASE` (set to `/var/data/python` by the Flatpak runtime, which would hide `~/.local/lib/...`).
### Windows frozen build: no console, hidden subprocess windows
The PyInstaller exe is built **windowed** (`console=False` in `testium.spec`) so a
double-click doesn't open a console. The catch: a windowed process has **no console
to inherit**, so every console subprocess it spawns (the `py_func`/`lua_func` host
Python bridges — the otherwise-permanent window — plus the `where`/`which`/`--version`
probes) opens its **own** console window. `paths.no_window_kwargs()` returns
`{"creationflags": CREATE_NO_WINDOW}` on a frozen Windows build (and `{}` everywhere
else, so the **wheel/source** keeps its console and child output stays visible). It is
applied at every spawn site: `py_process.py`, `lua_process.py`, `bins._run_probe`,
`paths.sys_app_path_win`. `termconsole.py` is intentionally exempt (it already hides
`cmd.exe` via `STARTUPINFO`).
### Declarative test item parameters
Each `TestItem` subclass declares its accepted parameters as a class attribute `PARAMS = ParamSet(Param(...), ...)` (`interpreter/utils/param_decl.py`). The descriptor carries the parameter name, *kind* (`SCALAR` — the default and may be omitted; `LIST`; `BLOCK`; `Enum("a", "b", ...)`), `required` flag, `default`, and free-form `doc`. There is **no Python type** in the descriptor on purpose: most parameter values are expressions (`$(...)` / `<| ... |>`) whose effective type is only known after expansion, so a static type would be misleading. Post-expansion `validate=lambda v: ...` callbacks are available as an opt-in for the rare cases where a runtime check is warranted (e.g. a specific format).
@@ -279,6 +302,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.

View File

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

View File

@@ -51,8 +51,8 @@ The parameter file can be specified in the `.tum` file root:
:caption: configuration files definition in the main `.tum` test file
config_file:
- config1.yaml
- config2.yaml
config1.yaml
config2.yaml
main:
name: Test example

View File

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

View File

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

View File

@@ -94,11 +94,11 @@ exe = EXE(
upx=not os.environ.get("TESTIUM_NO_UPX"),
upx_exclude=[],
runtime_tmpdir=None,
console=True,
console=False,
disable_windowed_traceback=False,
argv_emulation=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None,
ico='../testium.png'
ico='../testium.ico'
)

BIN
package/testium.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 353 KiB

View File

@@ -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.
Beneficial to linux version too.
- Windows: UTF-8 console output and a self-sufficient validation
wrapper (run.bat).
- Resolved python_bin / lua_bin are now published into the global dict,
so test scripts can read them via $(python_bin) / $(lua_bin).
- Windows: new per-user installer (no admin).
version 0.2.2
==============
- Flatpak sandbox issue fixed for term console. Now a term console is

View File

@@ -1 +1 @@
0.2.2
0.2.3

View File

@@ -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

View File

@@ -344,7 +344,7 @@ class TestItemConsoleReadUntil(TestItemConsoleAction):
def execute(self):
cons = self.get_console()
ru = self._prms.expanse(self._read_until)
read_timeout = float(self._prms.getParam("timeout", default=-1, processed=True))
read_timeout = int(self._prms.getParam("timeout", default=-1, processed=True))
mute = self._prms.getParam("mute", default=False, processed=True)
if read_timeout < 0:
read_timeout = None

View File

@@ -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 ?

View File

@@ -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):

View File

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

View File

@@ -25,7 +25,7 @@ import subprocess
import tempfile
import api.testium as tm
from interpreter.utils.paths import sys_app_path_lin, sys_app_path_win
from interpreter.utils.paths import sys_app_path_lin, sys_app_path_win, no_window_kwargs
from runtime.tum_except import ETUMRuntimeError
@@ -272,6 +272,7 @@ def _run_probe(cmd):
r = subprocess.run(
cmd, capture_output=True, text=True,
encoding=tm.sys_encoding(), timeout=10, env=_probe_env(),
**no_window_kwargs(),
)
except (FileNotFoundError, PermissionError, subprocess.TimeoutExpired):
return None
@@ -388,12 +389,16 @@ def ensure(*names):
"""
missing = []
for n in names:
if not _resolve(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)

View File

@@ -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 interpreter.utils.paths import subproc_path, no_window_kwargs
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):
@@ -120,12 +114,19 @@ class LuaProcessBase:
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
restore_signals=False,
**no_window_kwargs(),
**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

View File

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

View File

@@ -8,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"]

View File

@@ -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.paths import testium_path, subproc_path, no_window_kwargs
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}",
]
@@ -105,13 +97,19 @@ class PyProcessBase:
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
restore_signals=False,
**no_window_kwargs(),
**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

View File

@@ -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

View File

@@ -51,18 +51,14 @@ class TestFileManager:
w.disconnect_signals()
# Snapshot user-selected checkboxes and fold state so they survive a
# 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_fold_list = w.treeTests.getFoldList()
previous_count = w.treeTests.getItemCount()
self.clear_process()
if self.load(file_name) and \
w.test_service is not None and \
w.treeTests.getItemCount() == previous_count:
if prefs.settings.show_checkboxes :
if self.load(file_name) and w.test_service is not None:
if w.treeTests.getItemCount() == previous_count:
w.treeTests.restoreCheckList(previous_check_list, w.test_service)
w.treeTests.restoreFoldList(previous_fold_list)
w.reconnect_signals()
def _make_progress(self, w):

View File

@@ -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)

View File

@@ -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):
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)

View File

@@ -84,18 +84,7 @@
- read_until: {expected: HelloConsole, timeout: 1, mute: true}
- console:
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
name: Console read_until muted
console_name: term
key: $(test)_PASS
steps:

View File

@@ -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:

View File

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

View File

@@ -1,7 +1,7 @@
- let:
name: lua_func test constants,
values:
- lua_func test parameter: test parameter lua_func
lua_func test parameter: test parameter lua_func
- lua_func:
name: fail lua_func

View File

@@ -1,7 +1,7 @@
- let:
name: py_func test constants,
values:
- py_func test parameter: test parameter
py_func test parameter: test parameter
- py_func:
name: pass py_func

View File

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

View File

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

View File

@@ -31,11 +31,7 @@ main:
{% for item in items %}
# 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:
name: {{ item }} test
steps:

View File

@@ -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

View File

@@ -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%