Compare commits
2 Commits
fix/window
...
v0.2.3
| Author | SHA1 | Date | |
|---|---|---|---|
| 8c4e1b56b5 | |||
| e0802a9a72 |
16
DESIGN.md
16
DESIGN.md
@@ -226,7 +226,8 @@ Four distribution channels coexist, all sharing the single `src/testium/` packag
|
|||||||
| Channel | Where | Build | Notes |
|
| Channel | Where | Build | Notes |
|
||||||
|---------|-------|-------|-------|
|
|---------|-------|-------|-------|
|
||||||
| Wheel (`pip install`) | `src/pyproject.toml` | `python -m build` | Vanilla Python package; entry point `testium = "testium:main"`. |
|
| Wheel (`pip install`) | `src/pyproject.toml` | `python -m build` | Vanilla Python package; entry point `testium = "testium:main"`. |
|
||||||
| PyInstaller binary | `package/pyinstaller/` | `build.sh` | Single ~130 MB binary. `py_func`, `runtime`, `lua_func` bundled at `_MEIPASS` root so the **host** Python can find them when launched as `python3 py_func`. `api`/`interpreter` are **not** exposed (subprocess isolation). |
|
| 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. |
|
| Flatpak | `package/flatpak/` | `build.sh` (uses `flatpak-builder`) | KDE 6.10 runtime. The bundled Python runs only the main process; `py_func` / `lua_func` MUST run under the **host** interpreter (no Python/Lua bundled). Produces a distributable `.flatpak` bundle. |
|
||||||
| AppImage | `package/appimage/` | `build.sh` (Debian Bookworm container via Podman/Docker) | Bundles Python 3.11 for the main process; `py_func` / `lua_func` MUST run under the **host** interpreter. Build runs in a container so it works on Arch / any non-Debian host. |
|
| AppImage | `package/appimage/` | `build.sh` (Debian Bookworm container via Podman/Docker) | Bundles Python 3.11 for the main process; `py_func` / `lua_func` MUST run under the **host** interpreter. Build runs in a container so it works on Arch / any non-Debian host. |
|
||||||
|
|
||||||
@@ -257,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.
|
- If the host has no python3/lua, `ensure()` raises `ETUMRuntimeError` at test load with the candidate list — no silent fallback to a bundled interpreter.
|
||||||
- `py_process.py` additionally pops `PYTHONUSERBASE` (set to `/var/data/python` by the Flatpak runtime, which would hide `~/.local/lib/...`).
|
- `py_process.py` additionally pops `PYTHONUSERBASE` (set to `/var/data/python` by the Flatpak runtime, which would hide `~/.local/lib/...`).
|
||||||
|
|
||||||
|
### Windows frozen build: no console, hidden subprocess windows
|
||||||
|
|
||||||
|
The PyInstaller exe is built **windowed** (`console=False` in `testium.spec`) so a
|
||||||
|
double-click doesn't open a console. The catch: a windowed process has **no console
|
||||||
|
to inherit**, so every console subprocess it spawns (the `py_func`/`lua_func` host
|
||||||
|
Python bridges — the otherwise-permanent window — plus the `where`/`which`/`--version`
|
||||||
|
probes) opens its **own** console window. `paths.no_window_kwargs()` returns
|
||||||
|
`{"creationflags": CREATE_NO_WINDOW}` on a frozen Windows build (and `{}` everywhere
|
||||||
|
else, so the **wheel/source** keeps its console and child output stays visible). It is
|
||||||
|
applied at every spawn site: `py_process.py`, `lua_process.py`, `bins._run_probe`,
|
||||||
|
`paths.sys_app_path_win`. `termconsole.py` is intentionally exempt (it already hides
|
||||||
|
`cmd.exe` via `STARTUPINFO`).
|
||||||
|
|
||||||
### Declarative test item parameters
|
### Declarative test item parameters
|
||||||
|
|
||||||
Each `TestItem` subclass declares its accepted parameters as a class attribute `PARAMS = ParamSet(Param(...), ...)` (`interpreter/utils/param_decl.py`). The descriptor carries the parameter name, *kind* (`SCALAR` — the default and may be omitted; `LIST`; `BLOCK`; `Enum("a", "b", ...)`), `required` flag, `default`, and free-form `doc`. There is **no Python type** in the descriptor on purpose: most parameter values are expressions (`$(...)` / `<| ... |>`) whose effective type is only known after expansion, so a static type would be misleading. Post-expansion `validate=lambda v: ...` callbacks are available as an opt-in for the rare cases where a runtime check is warranted (e.g. a specific format).
|
Each `TestItem` subclass declares its accepted parameters as a class attribute `PARAMS = ParamSet(Param(...), ...)` (`interpreter/utils/param_decl.py`). The descriptor carries the parameter name, *kind* (`SCALAR` — the default and may be omitted; `LIST`; `BLOCK`; `Enum("a", "b", ...)`), `required` flag, `default`, and free-form `doc`. There is **no Python type** in the descriptor on purpose: most parameter values are expressions (`$(...)` / `<| ... |>`) whose effective type is only known after expansion, so a static type would be misleading. Post-expansion `validate=lambda v: ...` callbacks are available as an opt-in for the rare cases where a runtime check is warranted (e.g. a specific format).
|
||||||
|
|||||||
31
package/innosetup/build.ps1
Normal file
31
package/innosetup/build.ps1
Normal 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')"
|
||||||
127
package/innosetup/testium.iss
Normal file
127
package/innosetup/testium.iss
Normal 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;
|
||||||
@@ -94,11 +94,11 @@ exe = EXE(
|
|||||||
upx=not os.environ.get("TESTIUM_NO_UPX"),
|
upx=not os.environ.get("TESTIUM_NO_UPX"),
|
||||||
upx_exclude=[],
|
upx_exclude=[],
|
||||||
runtime_tmpdir=None,
|
runtime_tmpdir=None,
|
||||||
console=True,
|
console=False,
|
||||||
disable_windowed_traceback=False,
|
disable_windowed_traceback=False,
|
||||||
argv_emulation=False,
|
argv_emulation=False,
|
||||||
target_arch=None,
|
target_arch=None,
|
||||||
codesign_identity=None,
|
codesign_identity=None,
|
||||||
entitlements_file=None,
|
entitlements_file=None,
|
||||||
ico='../testium.png'
|
ico='../testium.ico'
|
||||||
)
|
)
|
||||||
|
|||||||
BIN
package/testium.ico
Normal file
BIN
package/testium.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 353 KiB |
@@ -1,7 +1,13 @@
|
|||||||
version 0.2.3
|
version 0.2.3
|
||||||
=============
|
=============
|
||||||
- Windows version now working reliably. Fix of a problem of jrpc ports
|
- Windows version now working reliably. Fix of a problem of jrpc ports
|
||||||
handshakes between the py and lua processes and testium
|
handshakes between the py and lua processes and testium.
|
||||||
|
Beneficial to linux version too.
|
||||||
|
- Windows: UTF-8 console output and a self-sufficient validation
|
||||||
|
wrapper (run.bat).
|
||||||
|
- Resolved python_bin / lua_bin are now published into the global dict,
|
||||||
|
so test scripts can read them via $(python_bin) / $(lua_bin).
|
||||||
|
- Windows: new per-user installer (no admin).
|
||||||
|
|
||||||
version 0.2.2
|
version 0.2.2
|
||||||
==============
|
==============
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ import subprocess
|
|||||||
import tempfile
|
import tempfile
|
||||||
|
|
||||||
import api.testium as tm
|
import api.testium as tm
|
||||||
from interpreter.utils.paths import sys_app_path_lin, sys_app_path_win
|
from interpreter.utils.paths import sys_app_path_lin, sys_app_path_win, no_window_kwargs
|
||||||
from runtime.tum_except import ETUMRuntimeError
|
from runtime.tum_except import ETUMRuntimeError
|
||||||
|
|
||||||
|
|
||||||
@@ -272,6 +272,7 @@ def _run_probe(cmd):
|
|||||||
r = subprocess.run(
|
r = subprocess.run(
|
||||||
cmd, capture_output=True, text=True,
|
cmd, capture_output=True, text=True,
|
||||||
encoding=tm.sys_encoding(), timeout=10, env=_probe_env(),
|
encoding=tm.sys_encoding(), timeout=10, env=_probe_env(),
|
||||||
|
**no_window_kwargs(),
|
||||||
)
|
)
|
||||||
except (FileNotFoundError, PermissionError, subprocess.TimeoutExpired):
|
except (FileNotFoundError, PermissionError, subprocess.TimeoutExpired):
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import subprocess
|
|||||||
|
|
||||||
import api.testium as tm
|
import api.testium as tm
|
||||||
from runtime.jrpc import JsonRpcClient
|
from runtime.jrpc import JsonRpcClient
|
||||||
from interpreter.utils.paths import subproc_path
|
from interpreter.utils.paths import subproc_path, no_window_kwargs
|
||||||
from runtime.tum_except import ETUMRuntimeError
|
from runtime.tum_except import ETUMRuntimeError
|
||||||
from interpreter.utils import bins
|
from interpreter.utils import bins
|
||||||
from interpreter.utils.proc_drain import drain_and_read_port, wait_for_port
|
from interpreter.utils.proc_drain import drain_and_read_port, wait_for_port
|
||||||
@@ -114,6 +114,7 @@ class LuaProcessBase:
|
|||||||
stdout=subprocess.PIPE,
|
stdout=subprocess.PIPE,
|
||||||
stderr=subprocess.PIPE,
|
stderr=subprocess.PIPE,
|
||||||
restore_signals=False,
|
restore_signals=False,
|
||||||
|
**no_window_kwargs(),
|
||||||
**popen_kwargs,
|
**popen_kwargs,
|
||||||
)
|
)
|
||||||
# Forward subprocess output to the log and read the startup port sentinel.
|
# Forward subprocess output to the log and read the startup port sentinel.
|
||||||
|
|||||||
@@ -8,6 +8,14 @@ import subprocess
|
|||||||
import api.testium as tm
|
import api.testium as tm
|
||||||
|
|
||||||
|
|
||||||
|
def no_window_kwargs():
|
||||||
|
# Hide stray child consoles in the frozen Windows GUI exe (console=False has
|
||||||
|
# no console to inherit). The wheel/source keeps its console, so leave it.
|
||||||
|
if sys.platform == "win32" and getattr(sys, "frozen", False):
|
||||||
|
return {"creationflags": subprocess.CREATE_NO_WINDOW}
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
def testium_path():
|
def testium_path():
|
||||||
|
|
||||||
if getattr(sys, 'frozen', False):
|
if getattr(sys, 'frozen', False):
|
||||||
@@ -54,6 +62,7 @@ def sys_app_path_win(app_name):
|
|||||||
text=True,
|
text=True,
|
||||||
encoding="oem",
|
encoding="oem",
|
||||||
timeout=10,
|
timeout=10,
|
||||||
|
**no_window_kwargs(),
|
||||||
)
|
)
|
||||||
data = result.stdout
|
data = result.stdout
|
||||||
except (FileNotFoundError, PermissionError, subprocess.TimeoutExpired):
|
except (FileNotFoundError, PermissionError, subprocess.TimeoutExpired):
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import subprocess
|
|||||||
from runtime.jrpc import JsonRpcClient
|
from runtime.jrpc import JsonRpcClient
|
||||||
import api.testium as tm
|
import api.testium as tm
|
||||||
from runtime.tum_except import ETUMRuntimeError
|
from runtime.tum_except import ETUMRuntimeError
|
||||||
from interpreter.utils.paths import testium_path, subproc_path
|
from interpreter.utils.paths import testium_path, subproc_path, no_window_kwargs
|
||||||
from interpreter.utils import bins
|
from interpreter.utils import bins
|
||||||
from interpreter.utils.proc_drain import drain_and_read_port, wait_for_port
|
from interpreter.utils.proc_drain import drain_and_read_port, wait_for_port
|
||||||
|
|
||||||
@@ -97,6 +97,7 @@ class PyProcessBase:
|
|||||||
stdout=subprocess.PIPE,
|
stdout=subprocess.PIPE,
|
||||||
stderr=subprocess.PIPE,
|
stderr=subprocess.PIPE,
|
||||||
restore_signals=False,
|
restore_signals=False,
|
||||||
|
**no_window_kwargs(),
|
||||||
**popen_kwargs,
|
**popen_kwargs,
|
||||||
)
|
)
|
||||||
# Forward subprocess output to the log and read the startup port sentinel.
|
# Forward subprocess output to the log and read the startup port sentinel.
|
||||||
|
|||||||
Reference in New Issue
Block a user