diff --git a/DESIGN.md b/DESIGN.md index 1d66d17..ae8ec58 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -226,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. | @@ -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. - `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). diff --git a/package/innosetup/build.ps1 b/package/innosetup/build.ps1 new file mode 100644 index 0000000..f652774 --- /dev/null +++ b/package/innosetup/build.ps1 @@ -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')" diff --git a/package/innosetup/testium.iss b/package/innosetup/testium.iss new file mode 100644 index 0000000..4ee6e8a --- /dev/null +++ b/package/innosetup/testium.iss @@ -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; diff --git a/package/pyinstaller/testium.spec b/package/pyinstaller/testium.spec index 6d2de79..b7b476d 100644 --- a/package/pyinstaller/testium.spec +++ b/package/pyinstaller/testium.spec @@ -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' ) diff --git a/package/testium.ico b/package/testium.ico new file mode 100644 index 0000000..28752b2 Binary files /dev/null and b/package/testium.ico differ diff --git a/release_note.txt b/release_note.txt index af077c5..d69ce43 100644 --- a/release_note.txt +++ b/release_note.txt @@ -7,6 +7,7 @@ version 0.2.3 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 ============== diff --git a/src/testium/interpreter/utils/bins.py b/src/testium/interpreter/utils/bins.py index 796bf77..78af2f7 100644 --- a/src/testium/interpreter/utils/bins.py +++ b/src/testium/interpreter/utils/bins.py @@ -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 diff --git a/src/testium/interpreter/utils/lua_process.py b/src/testium/interpreter/utils/lua_process.py index 59185f0..78d2e65 100644 --- a/src/testium/interpreter/utils/lua_process.py +++ b/src/testium/interpreter/utils/lua_process.py @@ -4,7 +4,7 @@ import subprocess 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_and_read_port, wait_for_port @@ -114,6 +114,7 @@ class LuaProcessBase: stdout=subprocess.PIPE, stderr=subprocess.PIPE, restore_signals=False, + **no_window_kwargs(), **popen_kwargs, ) # Forward subprocess output to the log and read the startup port sentinel. diff --git a/src/testium/interpreter/utils/paths.py b/src/testium/interpreter/utils/paths.py index 719788a..e76e7ea 100644 --- a/src/testium/interpreter/utils/paths.py +++ b/src/testium/interpreter/utils/paths.py @@ -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): diff --git a/src/testium/interpreter/utils/py_process.py b/src/testium/interpreter/utils/py_process.py index 2991f10..b673bd5 100644 --- a/src/testium/interpreter/utils/py_process.py +++ b/src/testium/interpreter/utils/py_process.py @@ -4,7 +4,7 @@ import subprocess 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_and_read_port, wait_for_port @@ -97,6 +97,7 @@ class PyProcessBase: stdout=subprocess.PIPE, stderr=subprocess.PIPE, restore_signals=False, + **no_window_kwargs(), **popen_kwargs, ) # Forward subprocess output to the log and read the startup port sentinel.