Compare commits
4 Commits
tum_schema
...
v0.1.3
| Author | SHA1 | Date | |
|---|---|---|---|
| d4889c2a2e | |||
| a260e2a56c | |||
| dd584c9064 | |||
| 4d8cafb5a0 |
21
DESIGN.md
21
DESIGN.md
@@ -114,11 +114,20 @@ To add a new API call usable from subprocesses:
|
|||||||
### External interpreter resolution (`bins.py`)
|
### External interpreter resolution (`bins.py`)
|
||||||
`src/testium/interpreter/utils/bins.py` — single source of truth for the paths to the external Python and Lua interpreters used by 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 once, cache in memory. User can override via the `python_bin` / `lua_bin` global dict keys (typically populated from the YAML config). Falls back to discovery on PATH (candidates: `python3`/`python` and `lua`/`lua5.5`/`lua5.4`/`lua5.3`/`lua5.2`/`lua5.1`).
|
- `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.
|
||||||
|
|
||||||
Engines (`PyProcessBase`, `LuaProcessBase`, `EvalExecEngine`) call `bins.python_bin()`/`bins.lua_bin()` themselves — call sites never pass an explicit binary path.
|
Engines (`PyProcessBase`, `LuaProcessBase`, `EvalExecEngine`) call `bins.python_bin()`/`bins.lua_bin()` themselves — call sites never pass an explicit binary path.
|
||||||
|
|
||||||
|
#### Override-timing contract (`apply_overrides`)
|
||||||
|
`bins.python_bin()` is called for the **first** time inside `eval_process_init()` (the long-lived inline-`<| … |>` subprocess), which happens **before** the YAML param files are loaded. To make `-d python_bin=…` and the GUI `python_bin` preference take effect for `eval_proc` itself, `process.py:run()` applies them to gd **before** `eval_process_init()` via the `apply_overrides()` helper extracted from `update_global()`. The post-load `update_global()` call then re-applies the same overrides (after `prepare_global()` clears gd), keeping the gd value in sync with the cached resolution.
|
||||||
|
|
||||||
|
| Override source | `eval_proc` | `py_func` / `cycle` / `post_exec` |
|
||||||
|
|---|---|---|
|
||||||
|
| `-d python_bin=…` (CLI) | ✅ | ✅ |
|
||||||
|
| GUI `python_bin` preference | ✅ | ✅ |
|
||||||
|
| `python_bin: …` in `param.yaml` | ❌ (eval_proc already started) | ✅ (cache re-resolves on key change) |
|
||||||
|
|
||||||
## Key files
|
## Key files
|
||||||
|
|
||||||
| Path | Role |
|
| Path | Role |
|
||||||
@@ -261,12 +270,18 @@ Both Flatpak and AppImage export `TESTIUM_VERSION` from a launcher (Flatpak: lau
|
|||||||
- `unittest` item: renamed from `unittest_file`.
|
- `unittest` item: renamed from `unittest_file`.
|
||||||
- GUI test tree: check and fold state preserved across same-file reloads.
|
- GUI test tree: check and fold state preserved across same-file reloads.
|
||||||
- Licence: EUPL-1.2.
|
- Licence: EUPL-1.2.
|
||||||
|
- Interpreter override timing: `apply_overrides()` extracted from `update_global()` and called by `process.py:run()` before `eval_process_init()`, so `-d python_bin=…` / GUI prefs reach `bins.python_bin()` on its first lookup. `bins._resolve()` cache is now keyed by `(name, override)` so later `param.yaml` changes are picked up by subsequently constructed engines.
|
||||||
|
|
||||||
## Validation tests
|
## Validation tests
|
||||||
Located in `test/validation/`. Run with `-b` flag:
|
Located in `test/validation/`. Two entry points:
|
||||||
```
|
```
|
||||||
./run.sh -b -- test/validation/main.tum
|
./test/validation/run.sh # wrapper — uses a dedicated venv (see below)
|
||||||
|
./run.sh -b -- test/validation/main.tum # direct — testium's own python is used for test execution
|
||||||
```
|
```
|
||||||
|
The `run.sh` / `run.bat` wrappers create a dedicated Python venv at `${TMPDIR:-/tmp}/testium-validation-venv` (Linux) or `%TEMP%\testium-validation-venv` (Windows), with `--system-site-packages` + `pip install junit-xml`, and run the suite with `-d python_bin=…` so every test-execution subprocess (eval_proc, py_func, cycle, post_exec) runs inside the venv. testium itself keeps running in the project's own environment. `clean` as the first argument recreates the venv.
|
||||||
|
|
||||||
|
The `venv` item (`test/validation/items/venv/`) asserts that the override actually took effect: `python_bin` is set, `sys.executable` matches it, `sys.prefix == dirname(dirname(python_bin))`, and `sys.prefix != sys.base_prefix` (the last marker catches the case where `python_bin` happens to be a system interpreter, which path-equality alone would miss because the venv's `bin/python3` is a symlink to the host). Both `eval_proc` (inline `<| … |>`) and `py_func` paths are exercised.
|
||||||
|
|
||||||
Parallel item tests: `test/validation/items/parallel/test.tum`
|
Parallel item tests: `test/validation/items/parallel/test.tum`
|
||||||
|
|
||||||
## Dependencies
|
## Dependencies
|
||||||
|
|||||||
131
build_all.sh
131
build_all.sh
@@ -8,6 +8,9 @@
|
|||||||
# release_note.txt is copied to dist/ up front (with a warning if it has no
|
# release_note.txt is copied to dist/ up front (with a warning if it has no
|
||||||
# entry for the current version).
|
# entry for the current version).
|
||||||
#
|
#
|
||||||
|
# By default, a step is skipped if its artifact already exists in dist/.
|
||||||
|
# Pass --clean to remove existing dist/ artifacts and rebuild everything.
|
||||||
|
#
|
||||||
# All artifacts are collected (copied) under <repo>/dist/. Original outputs in
|
# All artifacts are collected (copied) under <repo>/dist/. Original outputs in
|
||||||
# src/dist/, package/*/dist/, doc/manual/ are left in place. Wheel and AppImage
|
# src/dist/, package/*/dist/, doc/manual/ are left in place. Wheel and AppImage
|
||||||
# keep their original names (which already contain the version); manual,
|
# keep their original names (which already contain the version); manual,
|
||||||
@@ -15,17 +18,35 @@
|
|||||||
#
|
#
|
||||||
# Re-uses scripts/build_env.sh and scripts/set_env.sh — the same pair invoked
|
# Re-uses scripts/build_env.sh and scripts/set_env.sh — the same pair invoked
|
||||||
# by run.sh — so the venv at test/tmp/.venv stays the single source of Python
|
# by run.sh — so the venv at test/tmp/.venv stays the single source of Python
|
||||||
# dependencies. `build` and `pyinstaller` are installed into that venv on
|
# dependencies. `build`, `pyinstaller`, `sphinx` and `linuxdoc` are installed
|
||||||
# demand if not already there. Flatpak and AppImage build in their own
|
# into that venv on demand if not already there. Flatpak and AppImage build in
|
||||||
# container/sandbox; their build.sh scripts have their own toolchain checks.
|
# their own container/sandbox; their build.sh scripts have their own toolchain
|
||||||
|
# checks.
|
||||||
|
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
|
CLEAN=0
|
||||||
|
for arg in "$@"; do
|
||||||
|
case "$arg" in
|
||||||
|
--clean|-c) CLEAN=1 ;;
|
||||||
|
*) echo "Unknown option: $arg" >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
SCRIPT_DIR=$(realpath "$(dirname "$0")")
|
SCRIPT_DIR=$(realpath "$(dirname "$0")")
|
||||||
VERSION=$(cat "$SCRIPT_DIR/src/VERSION")
|
VERSION=$(cat "$SCRIPT_DIR/src/VERSION")
|
||||||
DIST_DIR="$SCRIPT_DIR/dist"
|
DIST_DIR="$SCRIPT_DIR/dist"
|
||||||
mkdir -p "$DIST_DIR"
|
mkdir -p "$DIST_DIR"
|
||||||
|
|
||||||
|
if [ "$CLEAN" -eq 1 ]; then
|
||||||
|
echo "-- clean: removing existing dist artifacts for version $VERSION"
|
||||||
|
rm -f "$DIST_DIR/testium-manual-${VERSION}.pdf"
|
||||||
|
rm -f "$DIST_DIR"/testium-${VERSION}-*.whl
|
||||||
|
rm -f "$DIST_DIR/testium-${VERSION}"
|
||||||
|
rm -f "$DIST_DIR/testium-${VERSION}.flatpak"
|
||||||
|
rm -f "$DIST_DIR"/Testium-${VERSION}-*.AppImage
|
||||||
|
fi
|
||||||
|
|
||||||
# Release note: copy it to dist/ and warn (but don't fail) if it has no entry
|
# Release note: copy it to dist/ and warn (but don't fail) if it has no entry
|
||||||
# for the current version.
|
# for the current version.
|
||||||
RELEASE_NOTE_SRC="$SCRIPT_DIR/release_note.txt"
|
RELEASE_NOTE_SRC="$SCRIPT_DIR/release_note.txt"
|
||||||
@@ -42,9 +63,6 @@ export REQ_PATH="$SCRIPT_DIR/src/requirements.txt"
|
|||||||
bash "$SCRIPT_DIR/scripts/build_env.sh"
|
bash "$SCRIPT_DIR/scripts/build_env.sh"
|
||||||
source "$SCRIPT_DIR/scripts/set_env.sh"
|
source "$SCRIPT_DIR/scripts/set_env.sh"
|
||||||
|
|
||||||
# Ensure wheel/PyInstaller toolchains are present in the venv.
|
|
||||||
python -m pip install --quiet --upgrade build pyinstaller
|
|
||||||
|
|
||||||
step() {
|
step() {
|
||||||
echo
|
echo
|
||||||
echo "================================================================"
|
echo "================================================================"
|
||||||
@@ -52,51 +70,90 @@ step() {
|
|||||||
echo "================================================================"
|
echo "================================================================"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
skip() { echo " (already built — skipping)"; }
|
||||||
|
|
||||||
# 1. Manual PDF
|
# 1. Manual PDF
|
||||||
step "1/5 Manual PDF (version $VERSION)"
|
|
||||||
bash "$SCRIPT_DIR/doc/manual/sphinx/build_doc.sh"
|
|
||||||
MANUAL_SRC="$SCRIPT_DIR/doc/manual/testium_manual.pdf"
|
|
||||||
MANUAL="$DIST_DIR/testium-manual-${VERSION}.pdf"
|
MANUAL="$DIST_DIR/testium-manual-${VERSION}.pdf"
|
||||||
cp -f "$MANUAL_SRC" "$MANUAL"
|
step "1/5 Manual PDF (version $VERSION)"
|
||||||
|
if [ ! -f "$MANUAL" ]; then
|
||||||
|
python -m pip install --quiet --upgrade sphinx linuxdoc
|
||||||
|
bash "$SCRIPT_DIR/doc/manual/sphinx/build_doc.sh"
|
||||||
|
cp -f "$SCRIPT_DIR/doc/manual/testium_manual.pdf" "$MANUAL"
|
||||||
|
else
|
||||||
|
skip
|
||||||
|
fi
|
||||||
|
|
||||||
# 2. Wheel — PEP 427 name kept (already contains version)
|
# 2. Wheel — PEP 427 name kept (already contains version)
|
||||||
step "2/5 Wheel (version $VERSION)"
|
step "2/5 Wheel (version $VERSION)"
|
||||||
(
|
WHEEL=$(ls -1t "$DIST_DIR"/testium-${VERSION}-*.whl 2>/dev/null | head -1)
|
||||||
cd "$SCRIPT_DIR/src"
|
if [ -z "$WHEEL" ]; then
|
||||||
rm -rf dist build *.egg-info
|
python -m pip install --quiet --upgrade build
|
||||||
python -m build --wheel
|
(
|
||||||
)
|
cd "$SCRIPT_DIR/src"
|
||||||
WHEEL_SRC=$(ls -1t "$SCRIPT_DIR/src/dist"/*.whl | head -1)
|
rm -rf dist build *.egg-info
|
||||||
WHEEL="$DIST_DIR/$(basename "$WHEEL_SRC")"
|
python -m build --wheel
|
||||||
cp -f "$WHEEL_SRC" "$WHEEL"
|
)
|
||||||
|
WHEEL_SRC=$(ls -1t "$SCRIPT_DIR/src/dist"/*.whl | head -1)
|
||||||
|
WHEEL="$DIST_DIR/$(basename "$WHEEL_SRC")"
|
||||||
|
cp -f "$WHEEL_SRC" "$WHEEL"
|
||||||
|
else
|
||||||
|
skip
|
||||||
|
fi
|
||||||
|
|
||||||
# 3. PyInstaller binary
|
# 3. PyInstaller binary
|
||||||
step "3/5 PyInstaller binary (version $VERSION)"
|
|
||||||
bash "$SCRIPT_DIR/package/pyinstaller/build.sh"
|
|
||||||
PYI_SRC="$SCRIPT_DIR/package/pyinstaller/dist/testium"
|
|
||||||
PYI_BIN="$DIST_DIR/testium-${VERSION}"
|
PYI_BIN="$DIST_DIR/testium-${VERSION}"
|
||||||
cp -f "$PYI_SRC" "$PYI_BIN"
|
step "3/5 PyInstaller binary (version $VERSION)"
|
||||||
|
if [ ! -f "$PYI_BIN" ]; then
|
||||||
|
python -m pip install --quiet --upgrade pyinstaller
|
||||||
|
bash "$SCRIPT_DIR/package/pyinstaller/build.sh"
|
||||||
|
cp -f "$SCRIPT_DIR/package/pyinstaller/dist/testium" "$PYI_BIN"
|
||||||
|
else
|
||||||
|
skip
|
||||||
|
fi
|
||||||
|
|
||||||
# 4. Flatpak bundle
|
# 4. Flatpak bundle
|
||||||
step "4/5 Flatpak bundle (version $VERSION)"
|
|
||||||
(
|
|
||||||
cd "$SCRIPT_DIR/package/flatpak"
|
|
||||||
bash build.sh
|
|
||||||
)
|
|
||||||
FLATPAK_SRC="$SCRIPT_DIR/package/flatpak/testium.flatpak"
|
|
||||||
FLATPAK_BUNDLE="$DIST_DIR/testium-${VERSION}.flatpak"
|
FLATPAK_BUNDLE="$DIST_DIR/testium-${VERSION}.flatpak"
|
||||||
cp -f "$FLATPAK_SRC" "$FLATPAK_BUNDLE"
|
step "4/5 Flatpak bundle (version $VERSION)"
|
||||||
|
if [ ! -f "$FLATPAK_BUNDLE" ]; then
|
||||||
|
FLATPAK_DEPS=(
|
||||||
|
"org.kde.Platform//6.10"
|
||||||
|
"org.kde.Sdk//6.10"
|
||||||
|
"io.qt.PySide.BaseApp//6.10"
|
||||||
|
)
|
||||||
|
if ! flatpak remotes --user | grep -q "^flathub"; then
|
||||||
|
echo " Adding Flathub remote"
|
||||||
|
flatpak remote-add --user --if-not-exists flathub https://dl.flathub.org/repo/flathub.flatpakrepo
|
||||||
|
fi
|
||||||
|
for dep in "${FLATPAK_DEPS[@]}"; do
|
||||||
|
if ! flatpak info --user "$dep" &>/dev/null && ! flatpak info --system "$dep" &>/dev/null; then
|
||||||
|
echo " Installing Flatpak dependency: $dep"
|
||||||
|
flatpak install --user --noninteractive flathub "$dep"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
(
|
||||||
|
cd "$SCRIPT_DIR/package/flatpak"
|
||||||
|
bash build.sh
|
||||||
|
)
|
||||||
|
cp -f "$SCRIPT_DIR/package/flatpak/testium.flatpak" "$FLATPAK_BUNDLE"
|
||||||
|
else
|
||||||
|
skip
|
||||||
|
fi
|
||||||
|
|
||||||
# 5. AppImage
|
# 5. AppImage
|
||||||
step "5/5 AppImage (version $VERSION)"
|
step "5/5 AppImage (version $VERSION)"
|
||||||
(
|
APPIMAGE=$(ls -1t "$DIST_DIR"/Testium-${VERSION}-*.AppImage 2>/dev/null | head -1)
|
||||||
cd "$SCRIPT_DIR/package/appimage"
|
if [ -z "$APPIMAGE" ]; then
|
||||||
bash build.sh
|
(
|
||||||
)
|
cd "$SCRIPT_DIR/package/appimage"
|
||||||
APPIMAGE_SRC=$(ls -1t "$SCRIPT_DIR/package/appimage"/*.AppImage 2>/dev/null | head -1)
|
bash build.sh
|
||||||
APPIMAGE="$DIST_DIR/$(basename "$APPIMAGE_SRC")"
|
)
|
||||||
cp -f "$APPIMAGE_SRC" "$APPIMAGE"
|
APPIMAGE_SRC=$(ls -1t "$SCRIPT_DIR/package/appimage"/*.AppImage 2>/dev/null | head -1)
|
||||||
chmod +x "$APPIMAGE"
|
APPIMAGE="$DIST_DIR/$(basename "$APPIMAGE_SRC")"
|
||||||
|
cp -f "$APPIMAGE_SRC" "$APPIMAGE"
|
||||||
|
chmod +x "$APPIMAGE"
|
||||||
|
else
|
||||||
|
skip
|
||||||
|
fi
|
||||||
|
|
||||||
step "All packages built"
|
step "All packages built"
|
||||||
printf " manual : %s\n" "$MANUAL"
|
printf " manual : %s\n" "$MANUAL"
|
||||||
|
|||||||
Binary file not shown.
@@ -16,6 +16,11 @@ finish-args:
|
|||||||
- --filesystem=home
|
- --filesystem=home
|
||||||
- --filesystem=/tmp
|
- --filesystem=/tmp
|
||||||
- --filesystem=host-os
|
- --filesystem=host-os
|
||||||
|
# Allow flatpak-spawn --host to launch host binaries (Python, Lua, git…)
|
||||||
|
# outside the sandbox. Required because the sandbox glibc/ABI is
|
||||||
|
# incompatible with arbitrary host shared libraries — we route py_func and
|
||||||
|
# lua_func through the host instead.
|
||||||
|
- --talk-name=org.freedesktop.Flatpak
|
||||||
|
|
||||||
build-options:
|
build-options:
|
||||||
build-args:
|
build-args:
|
||||||
|
|||||||
@@ -1,3 +1,30 @@
|
|||||||
|
version 0.1.3
|
||||||
|
==============
|
||||||
|
- Stop interrupts engaged blocking steps (console, py_func, lua_func,
|
||||||
|
json_rpc, sleep) within ~200 ms instead of waiting for the step
|
||||||
|
to finish.
|
||||||
|
- GUI Start / Stop / Pause flow simplified.
|
||||||
|
- lua_func: a function returning nil is no longer reported as a failure.
|
||||||
|
- ``-d python_bin=...`` and the GUI ``python_bin`` preference now reach
|
||||||
|
the eval subprocess (used to be silently ignored). ``param.yaml`` can
|
||||||
|
also override ``python_bin`` for py_func / cycle / post_exec.
|
||||||
|
- Validation suite: ``test/validation/run.sh`` (and ``run.bat``)
|
||||||
|
runs the suite inside a dedicated venv in the system temp dir.
|
||||||
|
- build_all.sh: ``release_note.txt`` and the user manual copied into
|
||||||
|
``dist/``; warning if the file has no entry for the version being built.
|
||||||
|
- Flatpak: every GUI file/directory dialog (open test, save report, log
|
||||||
|
path, default report/log dirs, python/lua interpreter pickers) now
|
||||||
|
bypasses the XDG document portal — the v0.1.2 fix was only on the
|
||||||
|
"open test" dialog.
|
||||||
|
- Flatpak: py_func / lua_func / run sub-instance now execute on the host
|
||||||
|
via flatpak-spawn, lifting the previous glibc/ABI incompatibility that
|
||||||
|
prevented user-configured host Python or Lua interpreters from being
|
||||||
|
reached from the sandbox.
|
||||||
|
- Validation suite: single entry point with ``--mode source|wheel|
|
||||||
|
pyinstaller|flatpak|appimage`` to validate any packaging channel
|
||||||
|
against the same item set; reports are stamped per mode.
|
||||||
|
- GUI: the "Run tum" test item now uses the testium logo.
|
||||||
|
|
||||||
version 0.1.2
|
version 0.1.2
|
||||||
==============
|
==============
|
||||||
- Flatpak: opening a test from the GUI now correctly finds its companion
|
- Flatpak: opening a test from the GUI now correctly finds its companion
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
0.1.2
|
0.1.3
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ from interpreter.utils.test_init import (
|
|||||||
env_init,
|
env_init,
|
||||||
prepare_global,
|
prepare_global,
|
||||||
update_global,
|
update_global,
|
||||||
|
apply_overrides,
|
||||||
set_standard_gd_keys,
|
set_standard_gd_keys,
|
||||||
test_run_init,
|
test_run_init,
|
||||||
test_run_header,
|
test_run_header,
|
||||||
@@ -210,6 +211,19 @@ class TestProcess(Process):
|
|||||||
|
|
||||||
env_init()
|
env_init()
|
||||||
|
|
||||||
|
# Apply GUI defaults and CLI defines to the global dict
|
||||||
|
# *before* eval_proc starts: bins.python_bin() reads
|
||||||
|
# ``python_bin`` from gd on its very first call (during
|
||||||
|
# eval_process_init) and caches the result. Without this,
|
||||||
|
# ``-d python_bin=...`` and the GUI ``python_bin`` preference
|
||||||
|
# would only take effect for items spawned *after* the cache
|
||||||
|
# was already populated with the auto-discovered interpreter,
|
||||||
|
# i.e. they would silently be ignored for eval_proc itself.
|
||||||
|
# _load_initial_params re-applies the same overrides after
|
||||||
|
# ``prepare_global()`` clears gd, so the gd value stays in
|
||||||
|
# sync with the cached path.
|
||||||
|
apply_overrides(self.__defs, self.__gui_defaults)
|
||||||
|
|
||||||
# Creation of the python evaluation process for loading of the complete test
|
# Creation of the python evaluation process for loading of the complete test
|
||||||
eval_proc = eval_process_init(api_request, 10, test_dir)
|
eval_proc = eval_process_init(api_request, 10, test_dir)
|
||||||
eval_proc.start()
|
eval_proc.start()
|
||||||
|
|||||||
@@ -24,9 +24,15 @@ def _testium_launch_cmd():
|
|||||||
appimage = os.environ.get("APPIMAGE")
|
appimage = os.environ.get("APPIMAGE")
|
||||||
if appimage:
|
if appimage:
|
||||||
return [appimage]
|
return [appimage]
|
||||||
# Flatpak: re-launch via the Flatpak app id.
|
# Flatpak: re-launch via the Flatpak app id, but on the host side —
|
||||||
|
# the `flatpak` CLI cannot run inside our sandbox (no D-Bus access to the
|
||||||
|
# host Flatpak service, and the host binary would need host libs that are
|
||||||
|
# ABI-incompatible with the sandbox runtime). flatpak-spawn proxies the
|
||||||
|
# call to the host via org.freedesktop.Flatpak (allowed by --talk-name in
|
||||||
|
# the manifest).
|
||||||
if os.path.isfile("/.flatpak-info"):
|
if os.path.isfile("/.flatpak-info"):
|
||||||
return ["flatpak", "run", "org.testium.Testium"]
|
return ["flatpak-spawn", "--host",
|
||||||
|
"flatpak", "run", "org.testium.Testium"]
|
||||||
# PyInstaller frozen exe: sys.executable is the binary itself.
|
# PyInstaller frozen exe: sys.executable is the binary itself.
|
||||||
if getattr(sys, "frozen", False):
|
if getattr(sys, "frozen", False):
|
||||||
return [sys.executable]
|
return [sys.executable]
|
||||||
|
|||||||
@@ -17,8 +17,11 @@ Public API
|
|||||||
``reset()`` : clear the cache (mostly useful for tests)
|
``reset()`` : clear the cache (mostly useful for tests)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import atexit
|
||||||
import os
|
import os
|
||||||
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
|
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
|
||||||
@@ -30,20 +33,6 @@ from runtime.tum_except import ETUMRuntimeError
|
|||||||
_PYTHON_CANDIDATES = ["python3", "python"]
|
_PYTHON_CANDIDATES = ["python3", "python"]
|
||||||
_LUA_CANDIDATES = ["lua", "lua5.5", "lua5.4", "lua5.3", "lua5.2", "lua5.1"]
|
_LUA_CANDIDATES = ["lua", "lua5.5", "lua5.4", "lua5.3", "lua5.2", "lua5.1"]
|
||||||
|
|
||||||
# When running inside a Flatpak, --filesystem=host-os mounts the host at
|
|
||||||
# /run/host (read-only). Binaries and libraries from the host are not on the
|
|
||||||
# sandbox PATH/LD_LIBRARY_PATH, so we probe and inject them explicitly.
|
|
||||||
_FLATPAK_HOST_DIRS = [
|
|
||||||
"/run/host/usr/local/bin",
|
|
||||||
"/run/host/usr/bin",
|
|
||||||
"/run/host/bin",
|
|
||||||
]
|
|
||||||
_FLATPAK_HOST_LIB_DIRS = [
|
|
||||||
"/run/host/usr/lib",
|
|
||||||
"/run/host/usr/lib64",
|
|
||||||
"/run/host/usr/local/lib",
|
|
||||||
]
|
|
||||||
|
|
||||||
# Inside an AppImage, AppRun prepends $APPDIR/usr/bin to PATH and exports a
|
# Inside an AppImage, AppRun prepends $APPDIR/usr/bin to PATH and exports a
|
||||||
# bundle-local PYTHONHOME / PYTHONPATH / LD_LIBRARY_PATH. We want py_func and
|
# bundle-local PYTHONHOME / PYTHONPATH / LD_LIBRARY_PATH. We want py_func and
|
||||||
# lua_func to run under the *host* interpreter (not the bundled one), so we
|
# lua_func to run under the *host* interpreter (not the bundled one), so we
|
||||||
@@ -64,78 +53,162 @@ def _in_appimage():
|
|||||||
return "APPIMAGE" in os.environ
|
return "APPIMAGE" in os.environ
|
||||||
|
|
||||||
|
|
||||||
def apply_host_lua_paths(env):
|
|
||||||
"""Prepend host Lua module dirs to LUA_PATH / LUA_CPATH (Flatpak only).
|
|
||||||
|
|
||||||
Must be called after user-defined lua_env overrides are applied, so host
|
|
||||||
paths are always first regardless of user config. User-defined paths remain
|
|
||||||
in the variable but after the host ones.
|
|
||||||
"""
|
|
||||||
if not _in_flatpak():
|
|
||||||
return
|
|
||||||
_LUA_VERSIONS = ["5.5", "5.4", "5.3", "5.2", "5.1"]
|
|
||||||
_HOST = "/run/host/usr"
|
|
||||||
cpath_dirs, lpath_dirs = [], []
|
|
||||||
for v in _LUA_VERSIONS:
|
|
||||||
for base in [f"{_HOST}/lib/lua/{v}",
|
|
||||||
f"{_HOST}/lib64/lua/{v}",
|
|
||||||
f"{_HOST}/lib/x86_64-linux-gnu/lua/{v}"]:
|
|
||||||
cpath_dirs.append(f"{base}/?.so")
|
|
||||||
lpath_dirs.append(f"{_HOST}/share/lua/{v}/?.lua")
|
|
||||||
lpath_dirs.append(f"{_HOST}/share/lua/{v}/?/init.lua")
|
|
||||||
sep = ";"
|
|
||||||
host_cpath = sep.join(cpath_dirs)
|
|
||||||
host_lpath = sep.join(lpath_dirs)
|
|
||||||
# ;; keeps Lua's compiled-in defaults at the end as last resort
|
|
||||||
env["LUA_CPATH"] = host_cpath + sep + env.get("LUA_CPATH", ";;")
|
|
||||||
env["LUA_PATH"] = host_lpath + sep + env.get("LUA_PATH", ";;")
|
|
||||||
|
|
||||||
|
|
||||||
def apply_host_libs(env):
|
def apply_host_libs(env):
|
||||||
"""Prepare *env* for launching a host binary from inside our bundle.
|
"""Strip bundle-local entries from *env* so a host binary can run cleanly.
|
||||||
|
|
||||||
- Flatpak: prepend host library dirs to LD_LIBRARY_PATH so the dynamic
|
Only meaningful for AppImage: removes $APPDIR-prefixed entries from
|
||||||
linker can find host .so files mounted under /run/host.
|
LD_LIBRARY_PATH / PYTHONPATH / PATH and drops PYTHONHOME, so the host
|
||||||
- AppImage: strip $APPDIR-prefixed entries from LD_LIBRARY_PATH and
|
interpreter doesn't try to load the bundled (incompatible) Python
|
||||||
PYTHONPATH and drop PYTHONHOME, so the host interpreter doesn't try
|
lib/site-packages. Flatpak is handled via flatpak-spawn --host instead
|
||||||
to load the bundled (incompatible) Python lib/site-packages.
|
(see flatpak_host_spawn), so the sandbox env is irrelevant there.
|
||||||
- Otherwise: no-op.
|
|
||||||
"""
|
"""
|
||||||
if _in_flatpak():
|
if not _in_appimage():
|
||||||
dirs = ":".join(d for d in _FLATPAK_HOST_LIB_DIRS if os.path.isdir(d))
|
|
||||||
if dirs:
|
|
||||||
existing = env.get("LD_LIBRARY_PATH", "")
|
|
||||||
env["LD_LIBRARY_PATH"] = dirs + (":" + existing if existing else "")
|
|
||||||
return
|
return
|
||||||
if _in_appimage():
|
appdir = os.environ.get("APPDIR", "")
|
||||||
appdir = os.environ.get("APPDIR", "")
|
if appdir:
|
||||||
if appdir:
|
for var, sep in (("LD_LIBRARY_PATH", ":"),
|
||||||
for var, sep in (("LD_LIBRARY_PATH", ":"),
|
("PYTHONPATH", os.pathsep),
|
||||||
("PYTHONPATH", os.pathsep),
|
("PATH", os.pathsep)):
|
||||||
("PATH", os.pathsep)):
|
cur = env.get(var, "")
|
||||||
cur = env.get(var, "")
|
if not cur:
|
||||||
if not cur:
|
continue
|
||||||
continue
|
cleaned = sep.join(
|
||||||
cleaned = sep.join(
|
p for p in cur.split(sep)
|
||||||
p for p in cur.split(sep)
|
if p and not p.startswith(appdir)
|
||||||
if p and not p.startswith(appdir)
|
)
|
||||||
)
|
if cleaned:
|
||||||
if cleaned:
|
env[var] = cleaned
|
||||||
env[var] = cleaned
|
else:
|
||||||
else:
|
env.pop(var, None)
|
||||||
env.pop(var, None)
|
env.pop("PYTHONHOME", None)
|
||||||
env.pop("PYTHONHOME", None)
|
|
||||||
|
|
||||||
|
# ---------- Flatpak: spawn on host outside the sandbox -----------------------
|
||||||
|
#
|
||||||
|
# Inside a Flatpak the sandbox glibc is incompatible with host shared libraries,
|
||||||
|
# so we can't run host Python/Lua under the sandbox runtime — `LD_LIBRARY_PATH`
|
||||||
|
# tricks hit a `_dl_call_libc_early_init` assertion. The supported way out is
|
||||||
|
# `flatpak-spawn --host`, which talks to the session-bus Flatpak D-Bus service
|
||||||
|
# (org.freedesktop.Flatpak.Development) and asks it to spawn a process in the
|
||||||
|
# host execution environment instead of inside our sandbox. The manifest must
|
||||||
|
# grant `--talk-name=org.freedesktop.Flatpak` for the D-Bus call to be allowed.
|
||||||
|
#
|
||||||
|
# The host process can't see our /app/ contents (sandbox-only), so when we
|
||||||
|
# spawn host Python/Lua to run `py_func` / `lua_func`, the cwd must be a
|
||||||
|
# directory both sides can reach. /tmp is shared (--filesystem=/tmp), so we
|
||||||
|
# stage the testium package there once per process and reuse it for every
|
||||||
|
# spawn. In source mode (testium under $HOME) the host already sees the
|
||||||
|
# original path, so we skip the copy.
|
||||||
|
|
||||||
|
_staged_testium_path = None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_host_testium_path():
|
||||||
|
"""Return a path to the testium package that the host can read.
|
||||||
|
|
||||||
|
- Source / wheel / PyInstaller install under $HOME → return testium_path()
|
||||||
|
as-is (host sees the same path via --filesystem=home).
|
||||||
|
- Flatpak bundle (testium under /app/) → stage a copy under /tmp on first
|
||||||
|
call and reuse it for the rest of the process.
|
||||||
|
"""
|
||||||
|
global _staged_testium_path
|
||||||
|
if _staged_testium_path is not None:
|
||||||
|
return _staged_testium_path
|
||||||
|
|
||||||
|
# Imported lazily to avoid a circular import (paths.py -> api.testium).
|
||||||
|
from interpreter.utils.paths import testium_path
|
||||||
|
tp = testium_path()
|
||||||
|
|
||||||
|
if not tp.startswith("/app/"):
|
||||||
|
_staged_testium_path = tp
|
||||||
|
return tp
|
||||||
|
|
||||||
|
staged = tempfile.mkdtemp(prefix="testium_host_", dir="/tmp")
|
||||||
|
# copytree refuses to write into an existing dir unless dirs_exist_ok=True.
|
||||||
|
# mkdtemp creates the dir, so we copy *into* it.
|
||||||
|
for entry in os.listdir(tp):
|
||||||
|
src = os.path.join(tp, entry)
|
||||||
|
dst = os.path.join(staged, entry)
|
||||||
|
if os.path.isdir(src):
|
||||||
|
shutil.copytree(src, dst, symlinks=True)
|
||||||
|
else:
|
||||||
|
shutil.copy2(src, dst, follow_symlinks=False)
|
||||||
|
_staged_testium_path = staged
|
||||||
|
atexit.register(shutil.rmtree, staged, ignore_errors=True)
|
||||||
|
return staged
|
||||||
|
|
||||||
|
|
||||||
|
_FORWARDED_ENV_KEYS = (
|
||||||
|
"HOME", "USER", "LOGNAME", "TMPDIR",
|
||||||
|
"XDG_RUNTIME_DIR", "XDG_DATA_HOME", "XDG_CONFIG_HOME", "XDG_CACHE_HOME",
|
||||||
|
"DBUS_SESSION_BUS_ADDRESS", "DISPLAY", "WAYLAND_DISPLAY",
|
||||||
|
"LANG", "LC_ALL",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def flatpak_host_spawn(interp_bin, cmd_args, host_cwd, extra_env=None):
|
||||||
|
"""Build a flatpak-spawn --host command vector.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
interp_bin: absolute path to the host interpreter (e.g. /usr/bin/python3).
|
||||||
|
cmd_args: list of arguments passed to the interpreter.
|
||||||
|
host_cwd: working directory on the host (must be reachable from host).
|
||||||
|
extra_env: optional {name: value} of env vars to set on the host side
|
||||||
|
in addition to the default forwarded set. Values of ""
|
||||||
|
unset the variable on the host.
|
||||||
|
|
||||||
|
Returns a list suitable for subprocess.Popen.
|
||||||
|
"""
|
||||||
|
spawn = ["flatpak-spawn", "--host", f"--directory={host_cwd}"]
|
||||||
|
forwarded = {}
|
||||||
|
for key in _FORWARDED_ENV_KEYS:
|
||||||
|
val = os.environ.get(key)
|
||||||
|
if val:
|
||||||
|
forwarded[key] = val
|
||||||
|
if extra_env:
|
||||||
|
forwarded.update(extra_env)
|
||||||
|
for k, v in forwarded.items():
|
||||||
|
if v == "":
|
||||||
|
spawn.append(f"--unset-env={k}")
|
||||||
|
else:
|
||||||
|
spawn.append(f"--env={k}={v}")
|
||||||
|
spawn.append(interp_bin)
|
||||||
|
spawn.extend(cmd_args)
|
||||||
|
return spawn
|
||||||
|
|
||||||
|
|
||||||
|
def _which_host_flatpak(name):
|
||||||
|
"""Resolve a binary name (or absolute path) on the host via flatpak-spawn.
|
||||||
|
|
||||||
|
We can't probe /run/host/... because (a) only host-os is mounted there,
|
||||||
|
not arbitrary paths like /scratch, and (b) returning a /run/host path
|
||||||
|
would be useless — the host-side spawn sees a different filesystem and
|
||||||
|
needs the host-native path anyway.
|
||||||
|
"""
|
||||||
|
if os.path.isabs(name):
|
||||||
|
cmd = flatpak_host_spawn("/bin/sh", ["-c", f'test -x "{name}"'],
|
||||||
|
host_cwd="/tmp")
|
||||||
|
try:
|
||||||
|
r = subprocess.run(cmd, capture_output=True, timeout=10)
|
||||||
|
except (FileNotFoundError, PermissionError, subprocess.TimeoutExpired):
|
||||||
|
return ""
|
||||||
|
return name if r.returncode == 0 else ""
|
||||||
|
cmd = flatpak_host_spawn("/bin/sh", ["-c", f'command -v "{name}"'],
|
||||||
|
host_cwd="/tmp")
|
||||||
|
try:
|
||||||
|
r = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
|
||||||
|
except (FileNotFoundError, PermissionError, subprocess.TimeoutExpired):
|
||||||
|
return ""
|
||||||
|
if r.returncode != 0:
|
||||||
|
return ""
|
||||||
|
return r.stdout.strip()
|
||||||
|
|
||||||
|
|
||||||
def _which(name):
|
def _which(name):
|
||||||
if tm.OS() == "Windows":
|
if tm.OS() == "Windows":
|
||||||
return sys_app_path_win(name)
|
return sys_app_path_win(name)
|
||||||
if _in_flatpak():
|
if _in_flatpak():
|
||||||
for d in _FLATPAK_HOST_DIRS:
|
return _which_host_flatpak(name)
|
||||||
p = os.path.join(d, name)
|
|
||||||
if os.path.isfile(p) and os.access(p, os.X_OK):
|
|
||||||
return p
|
|
||||||
return ""
|
|
||||||
if _in_appimage():
|
if _in_appimage():
|
||||||
for d in _APPIMAGE_HOST_DIRS:
|
for d in _APPIMAGE_HOST_DIRS:
|
||||||
p = os.path.join(d, name)
|
p = os.path.join(d, name)
|
||||||
@@ -146,14 +219,33 @@ def _which(name):
|
|||||||
|
|
||||||
|
|
||||||
def _probe_env():
|
def _probe_env():
|
||||||
"""Subprocess env for probing host binaries (adds host libs in Flatpak)."""
|
"""Subprocess env for probing host binaries.
|
||||||
|
|
||||||
|
In AppImage we still need to scrub APPDIR-prefixed entries; in Flatpak we
|
||||||
|
delegate execution to the host via flatpak-spawn so the sandbox env doesn't
|
||||||
|
matter, but apply_host_libs is a no-op cost.
|
||||||
|
"""
|
||||||
env = os.environ.copy()
|
env = os.environ.copy()
|
||||||
apply_host_libs(env)
|
apply_host_libs(env)
|
||||||
return env
|
return env
|
||||||
|
|
||||||
|
|
||||||
def _python_version(path):
|
def _run_probe(cmd):
|
||||||
cmd = [path, "-c", "import sys; print(sys.version_info[:3])"]
|
"""Run a probe command, dispatching through flatpak-spawn --host in Flatpak.
|
||||||
|
|
||||||
|
Returns (stdout, stderr) as str, or None on failure.
|
||||||
|
"""
|
||||||
|
if _in_flatpak():
|
||||||
|
spawn = flatpak_host_spawn(cmd[0], cmd[1:], host_cwd="/tmp")
|
||||||
|
try:
|
||||||
|
r = subprocess.run(
|
||||||
|
spawn, capture_output=True, text=True, timeout=10,
|
||||||
|
)
|
||||||
|
except (FileNotFoundError, PermissionError, subprocess.TimeoutExpired):
|
||||||
|
return None
|
||||||
|
if r.returncode != 0:
|
||||||
|
return None
|
||||||
|
return r.stdout, r.stderr
|
||||||
try:
|
try:
|
||||||
r = subprocess.run(
|
r = subprocess.run(
|
||||||
cmd, capture_output=True, text=True,
|
cmd, capture_output=True, text=True,
|
||||||
@@ -161,8 +253,15 @@ def _python_version(path):
|
|||||||
)
|
)
|
||||||
except (FileNotFoundError, PermissionError, subprocess.TimeoutExpired):
|
except (FileNotFoundError, PermissionError, subprocess.TimeoutExpired):
|
||||||
return None
|
return None
|
||||||
|
return r.stdout, r.stderr
|
||||||
|
|
||||||
|
|
||||||
|
def _python_version(path):
|
||||||
|
out = _run_probe([path, "-c", "import sys; print(sys.version_info[:3])"])
|
||||||
|
if out is None:
|
||||||
|
return None
|
||||||
try:
|
try:
|
||||||
return eval(r.stdout)
|
return eval(out[0])
|
||||||
except Exception:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -173,15 +272,11 @@ def _is_python3(path):
|
|||||||
|
|
||||||
|
|
||||||
def _lua_version(path):
|
def _lua_version(path):
|
||||||
try:
|
out = _run_probe([path, "-v"])
|
||||||
r = subprocess.run(
|
if out is None:
|
||||||
[path, "-v"], capture_output=True, text=True, timeout=10,
|
|
||||||
env=_probe_env(),
|
|
||||||
)
|
|
||||||
except (FileNotFoundError, PermissionError, subprocess.TimeoutExpired):
|
|
||||||
return None
|
return None
|
||||||
# On Windows the version banner goes to stderr.
|
# On Windows the version banner goes to stderr.
|
||||||
line = r.stdout or r.stderr
|
line = out[0] or out[1]
|
||||||
try:
|
try:
|
||||||
major, minor, _patch = line.split(" ")[1].split(".")
|
major, minor, _patch = line.split(" ")[1].split(".")
|
||||||
return (int(major), int(minor))
|
return (int(major), int(minor))
|
||||||
@@ -202,22 +297,33 @@ _SPECS = {
|
|||||||
"lua": ("Lua 5.1+", "lua_bin", _LUA_CANDIDATES, _is_lua51),
|
"lua": ("Lua 5.1+", "lua_bin", _LUA_CANDIDATES, _is_lua51),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Cached per (name, override) so that runtime changes to gd[gd_key] —
|
||||||
|
# e.g. ``python_bin`` set from a YAML config file loaded *after*
|
||||||
|
# eval_proc has already resolved its own interpreter — are picked up by
|
||||||
|
# the next lookup instead of returning the stale, auto-discovered path.
|
||||||
|
# Long-lived subprocesses (eval_proc) keep whatever they captured at
|
||||||
|
# construction time, but every new PyProcessBase / FuncExecEngine spawned
|
||||||
|
# afterwards sees the current override.
|
||||||
_resolved = {}
|
_resolved = {}
|
||||||
|
|
||||||
|
|
||||||
def _resolve(name):
|
def _resolve(name):
|
||||||
if name in _resolved:
|
|
||||||
return _resolved[name]
|
|
||||||
|
|
||||||
display, gd_key, candidates, validator = _SPECS[name]
|
display, gd_key, candidates, validator = _SPECS[name]
|
||||||
override = tm.gd(gd_key, "") or ""
|
override = tm.gd(gd_key, "") or ""
|
||||||
|
|
||||||
|
cached = _resolved.get(name)
|
||||||
|
if cached is not None and cached[0] == override:
|
||||||
|
return cached[1]
|
||||||
|
|
||||||
path = ""
|
path = ""
|
||||||
if override:
|
if override:
|
||||||
# Absolute path: accept as-is (user knows exactly what they want).
|
# Absolute path: accept as-is (user knows exactly what they want).
|
||||||
# Bare name: resolve via _which() so the override stays host-only in
|
# Bare name: resolve via _which() so the override stays host-only in
|
||||||
# Flatpak/AppImage instead of silently picking the bundled interpreter.
|
# Flatpak/AppImage instead of silently picking the bundled interpreter.
|
||||||
if os.path.isabs(override):
|
# In Flatpak we always defer to _which() so even absolute paths are
|
||||||
|
# checked from the host's perspective (the sandbox can't see e.g.
|
||||||
|
# /scratch/... paths that the user may have configured).
|
||||||
|
if os.path.isabs(override) and not _in_flatpak():
|
||||||
resolved = override if (os.path.isfile(override)
|
resolved = override if (os.path.isfile(override)
|
||||||
and os.access(override, os.X_OK)) else ""
|
and os.access(override, os.X_OK)) else ""
|
||||||
else:
|
else:
|
||||||
@@ -239,7 +345,7 @@ def _resolve(name):
|
|||||||
path = p
|
path = p
|
||||||
break
|
break
|
||||||
|
|
||||||
_resolved[name] = path
|
_resolved[name] = (override, path)
|
||||||
return path
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -47,9 +47,16 @@ class LuaProcessBase:
|
|||||||
if self._process is not None:
|
if self._process is not None:
|
||||||
raise ETUMRuntimeError("The function subprocess has already been started.")
|
raise ETUMRuntimeError("The function subprocess has already been started.")
|
||||||
|
|
||||||
func_proc_path = os.path.realpath(
|
# In Flatpak the host can't see /app/lib/testium/lua_func, so use a
|
||||||
os.path.join(subproc_path(), "lua_func")
|
# staged copy under /tmp (shared between sandbox and host).
|
||||||
)
|
if bins._in_flatpak():
|
||||||
|
func_proc_path = os.path.join(
|
||||||
|
bins._get_host_testium_path(), "lua_func"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
func_proc_path = os.path.realpath(
|
||||||
|
os.path.join(subproc_path(), "lua_func")
|
||||||
|
)
|
||||||
|
|
||||||
# POpen config
|
# POpen config
|
||||||
CUST_ENV = {
|
CUST_ENV = {
|
||||||
@@ -71,7 +78,6 @@ class LuaProcessBase:
|
|||||||
env[k] = e
|
env[k] = e
|
||||||
else:
|
else:
|
||||||
env[k] = e + ";" + env.get(k, "")
|
env[k] = e + ";" + env.get(k, "")
|
||||||
bins.apply_host_lua_paths(env)
|
|
||||||
|
|
||||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||||
sock.bind(("localhost", 0))
|
sock.bind(("localhost", 0))
|
||||||
@@ -79,8 +85,7 @@ class LuaProcessBase:
|
|||||||
sock.close()
|
sock.close()
|
||||||
|
|
||||||
# POpen params
|
# POpen params
|
||||||
params = [
|
cmd_args = [
|
||||||
self._lbin,
|
|
||||||
"main.lua",
|
"main.lua",
|
||||||
"--timeout",
|
"--timeout",
|
||||||
f"{self._timeout}",
|
f"{self._timeout}",
|
||||||
@@ -91,14 +96,31 @@ class LuaProcessBase:
|
|||||||
]
|
]
|
||||||
|
|
||||||
if tm.debug_enabled() and tm.gd("debug_rpc", False):
|
if tm.debug_enabled() and tm.gd("debug_rpc", False):
|
||||||
params.append("--verbose")
|
cmd_args.append("--verbose")
|
||||||
|
|
||||||
|
if bins._in_flatpak():
|
||||||
|
# Run on the host outside the sandbox: avoids glibc ABI mismatches
|
||||||
|
# between the Flatpak runtime and host shared libraries.
|
||||||
|
host_env = {
|
||||||
|
k: env[k] for k in ("LUA_PATH", "LUA_CPATH", "PATH")
|
||||||
|
if k in env and env[k]
|
||||||
|
}
|
||||||
|
params = bins.flatpak_host_spawn(
|
||||||
|
self._lbin, cmd_args, host_cwd=func_proc_path,
|
||||||
|
extra_env=host_env,
|
||||||
|
)
|
||||||
|
popen_kwargs = {}
|
||||||
|
else:
|
||||||
|
params = [self._lbin, *cmd_args]
|
||||||
|
popen_kwargs = {"env": env, "cwd": func_proc_path}
|
||||||
|
|
||||||
self._process = subprocess.Popen(
|
self._process = subprocess.Popen(
|
||||||
params, env=env, cwd=func_proc_path,
|
params,
|
||||||
stdin=subprocess.DEVNULL,
|
stdin=subprocess.DEVNULL,
|
||||||
stdout=subprocess.PIPE,
|
stdout=subprocess.PIPE,
|
||||||
stderr=subprocess.PIPE,
|
stderr=subprocess.PIPE,
|
||||||
restore_signals=False,
|
restore_signals=False,
|
||||||
|
**popen_kwargs,
|
||||||
)
|
)
|
||||||
# Route subprocess stdout/stderr (lua require failures, syntax
|
# Route subprocess stdout/stderr (lua require failures, syntax
|
||||||
# errors, anything written to fd 1/2 before the in-script
|
# errors, anything written to fd 1/2 before the in-script
|
||||||
|
|||||||
@@ -61,14 +61,18 @@ class PyProcessBase:
|
|||||||
if sock is not None:
|
if sock is not None:
|
||||||
sock.close()
|
sock.close()
|
||||||
|
|
||||||
# Add the path of the subprocess (root sources of testium)
|
# In Flatpak the host can't see /app/lib/testium, so use a staged copy
|
||||||
tstium_path = os.path.realpath(testium_path())
|
# under /tmp (shared between sandbox and host) for both cwd and as the
|
||||||
func_proc_path = os.path.realpath(subproc_path())
|
# root in PYTHONPATH. Outside Flatpak the original paths are used.
|
||||||
|
if bins._in_flatpak():
|
||||||
|
tstium_path = bins._get_host_testium_path()
|
||||||
|
func_proc_path = tstium_path
|
||||||
|
else:
|
||||||
|
tstium_path = os.path.realpath(testium_path())
|
||||||
|
func_proc_path = os.path.realpath(subproc_path())
|
||||||
env["PYTHONPATH"] = tstium_path + os.pathsep + self._ppath + os.pathsep + env.get("PYTHONPATH", "")
|
env["PYTHONPATH"] = tstium_path + os.pathsep + self._ppath + os.pathsep + env.get("PYTHONPATH", "")
|
||||||
|
|
||||||
params = [
|
cmd_args = [
|
||||||
self._pbin,
|
|
||||||
# "-m",
|
|
||||||
"py_func",
|
"py_func",
|
||||||
"-p",
|
"-p",
|
||||||
f"{self._port}",
|
f"{self._port}",
|
||||||
@@ -77,14 +81,31 @@ class PyProcessBase:
|
|||||||
]
|
]
|
||||||
|
|
||||||
if tm.debug_enabled() and tm.gd("debug_rpc", False):
|
if tm.debug_enabled() and tm.gd("debug_rpc", False):
|
||||||
params.append("-v")
|
cmd_args.append("-v")
|
||||||
|
|
||||||
|
if bins._in_flatpak():
|
||||||
|
# Run on the host outside the sandbox: avoids glibc ABI mismatches
|
||||||
|
# between the Flatpak runtime and host shared libraries.
|
||||||
|
host_env = {
|
||||||
|
k: env[k] for k in ("PYTHONPATH", "PATH")
|
||||||
|
if k in env and env[k]
|
||||||
|
}
|
||||||
|
params = bins.flatpak_host_spawn(
|
||||||
|
self._pbin, cmd_args, host_cwd=func_proc_path,
|
||||||
|
extra_env=host_env,
|
||||||
|
)
|
||||||
|
popen_kwargs = {}
|
||||||
|
else:
|
||||||
|
params = [self._pbin, *cmd_args]
|
||||||
|
popen_kwargs = {"env": env, "cwd": func_proc_path}
|
||||||
|
|
||||||
self._process = subprocess.Popen(
|
self._process = subprocess.Popen(
|
||||||
params, env=env, cwd=func_proc_path,
|
params,
|
||||||
stdin=subprocess.DEVNULL,
|
stdin=subprocess.DEVNULL,
|
||||||
stdout=subprocess.PIPE,
|
stdout=subprocess.PIPE,
|
||||||
stderr=subprocess.PIPE,
|
stderr=subprocess.PIPE,
|
||||||
restore_signals=False,
|
restore_signals=False,
|
||||||
|
**popen_kwargs,
|
||||||
)
|
)
|
||||||
# Route subprocess stdout/stderr (early-startup errors,
|
# Route subprocess stdout/stderr (early-startup errors,
|
||||||
# unhandled exceptions, anything written to fd 1/2 before the
|
# unhandled exceptions, anything written to fd 1/2 before the
|
||||||
|
|||||||
@@ -165,11 +165,14 @@ def env_init():
|
|||||||
_constants_init()
|
_constants_init()
|
||||||
|
|
||||||
|
|
||||||
def update_global(config_files, defines, gui_defaults, silent=False):
|
def apply_overrides(defines, gui_defaults):
|
||||||
"""Global dict updated with the content of the config file and a dict provided.
|
"""Push GUI defaults then CLI defines into the global dict.
|
||||||
this function returns the resulting dict.
|
|
||||||
|
Extracted from update_global so it can be called *before* eval_proc
|
||||||
|
starts: interpreter overrides (python_bin, lua_bin) must be visible
|
||||||
|
to bins.python_bin() on its first lookup, which happens during
|
||||||
|
eval_process_init.
|
||||||
"""
|
"""
|
||||||
# GUI preferences applied first
|
|
||||||
for k, v in gui_defaults.items():
|
for k, v in gui_defaults.items():
|
||||||
try:
|
try:
|
||||||
val = ast.literal_eval(v)
|
val = ast.literal_eval(v)
|
||||||
@@ -177,7 +180,6 @@ def update_global(config_files, defines, gui_defaults, silent=False):
|
|||||||
val = v
|
val = v
|
||||||
tm.setgd(k, val)
|
tm.setgd(k, val)
|
||||||
|
|
||||||
# Then command line defines
|
|
||||||
for k, v in defines.items():
|
for k, v in defines.items():
|
||||||
try:
|
try:
|
||||||
val = ast.literal_eval(v)
|
val = ast.literal_eval(v)
|
||||||
@@ -185,6 +187,14 @@ def update_global(config_files, defines, gui_defaults, silent=False):
|
|||||||
val = v
|
val = v
|
||||||
tm.setgd(k, val)
|
tm.setgd(k, val)
|
||||||
|
|
||||||
|
|
||||||
|
def update_global(config_files, defines, gui_defaults, silent=False):
|
||||||
|
"""Global dict updated with the content of the config file and a dict provided.
|
||||||
|
this function returns the resulting dict.
|
||||||
|
"""
|
||||||
|
# GUI preferences applied first, then command line defines
|
||||||
|
apply_overrides(defines, gui_defaults)
|
||||||
|
|
||||||
# Then the configuration files
|
# Then the configuration files
|
||||||
# load global dic before test item
|
# load global dic before test item
|
||||||
_feed_gd_with_params(config_files, silent)
|
_feed_gd_with_params(config_files, silent)
|
||||||
|
|||||||
21
src/testium/main_win/file_dialog.py
Normal file
21
src/testium/main_win/file_dialog.py
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
"""Helpers for Qt file/directory dialogs.
|
||||||
|
|
||||||
|
In Flatpak the native QFileDialog goes through the XDG document portal,
|
||||||
|
which returns ``/run/user/UID/doc/.../<file>`` and only exposes the
|
||||||
|
selected file — sibling files (param.yaml, scripts, recent paths in
|
||||||
|
preferences, ...) are unreachable. Forcing Qt's own non-native dialog
|
||||||
|
makes it walk the real filesystem mounted via ``--filesystem=home``
|
||||||
|
and return a regular path.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
from PySide6.QtWidgets import QFileDialog
|
||||||
|
|
||||||
|
|
||||||
|
def options():
|
||||||
|
"""Default ``QFileDialog`` options for the current runtime."""
|
||||||
|
opts = QFileDialog.Options()
|
||||||
|
if os.path.isfile("/.flatpak-info"):
|
||||||
|
opts |= QFileDialog.Option.DontUseNativeDialog
|
||||||
|
return opts
|
||||||
@@ -3,6 +3,7 @@ from PySide6.QtWidgets import QDialog, QFileDialog
|
|||||||
from PySide6.QtGui import QFont
|
from PySide6.QtGui import QFont
|
||||||
|
|
||||||
from main_win.preference_win.preference_core_win import Ui_preferenceWindow
|
from main_win.preference_win.preference_core_win import Ui_preferenceWindow
|
||||||
|
from main_win import file_dialog
|
||||||
|
|
||||||
import interpreter.utils.settings as prefs
|
import interpreter.utils.settings as prefs
|
||||||
|
|
||||||
@@ -193,6 +194,7 @@ class PrefWindow(QDialog):
|
|||||||
self,
|
self,
|
||||||
caption="Select the default report directory",
|
caption="Select the default report directory",
|
||||||
dir=self.ui.editDefaultReportPath.text(),
|
dir=self.ui.editDefaultReportPath.text(),
|
||||||
|
options=file_dialog.options(),
|
||||||
)
|
)
|
||||||
if path:
|
if path:
|
||||||
self.ui.editDefaultReportPath.setText(path)
|
self.ui.editDefaultReportPath.setText(path)
|
||||||
@@ -203,6 +205,7 @@ class PrefWindow(QDialog):
|
|||||||
self,
|
self,
|
||||||
caption="Select the default log directory",
|
caption="Select the default log directory",
|
||||||
dir=self.ui.editDefaultLogPath.text(),
|
dir=self.ui.editDefaultLogPath.text(),
|
||||||
|
options=file_dialog.options(),
|
||||||
)
|
)
|
||||||
if path:
|
if path:
|
||||||
self.ui.editDefaultLogPath.setText(path)
|
self.ui.editDefaultLogPath.setText(path)
|
||||||
@@ -213,6 +216,7 @@ class PrefWindow(QDialog):
|
|||||||
self,
|
self,
|
||||||
caption="Select the python interpreter",
|
caption="Select the python interpreter",
|
||||||
dir=self.ui.editPythonPath.text(),
|
dir=self.ui.editPythonPath.text(),
|
||||||
|
options=file_dialog.options(),
|
||||||
)
|
)
|
||||||
if path:
|
if path:
|
||||||
self.ui.editPythonPath.setText(path)
|
self.ui.editPythonPath.setText(path)
|
||||||
@@ -220,7 +224,10 @@ class PrefWindow(QDialog):
|
|||||||
@Slot()
|
@Slot()
|
||||||
def on_butLuaPath_pressed(self):
|
def on_butLuaPath_pressed(self):
|
||||||
path, _ = QFileDialog.getOpenFileName(
|
path, _ = QFileDialog.getOpenFileName(
|
||||||
self, caption="Select the lua interpreter", dir=self.ui.editLuaPath.text()
|
self,
|
||||||
|
caption="Select the lua interpreter",
|
||||||
|
dir=self.ui.editLuaPath.text(),
|
||||||
|
options=file_dialog.options(),
|
||||||
)
|
)
|
||||||
if path:
|
if path:
|
||||||
self.ui.editLuaPath.setText(path)
|
self.ui.editLuaPath.setText(path)
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from PySide6.QtWidgets import QApplication, QFileDialog, QProgressDialog
|
|||||||
from interpreter.process import TestProcess
|
from interpreter.process import TestProcess
|
||||||
from interpreter.utils.test_ctrl import TestSetController
|
from interpreter.utils.test_ctrl import TestSetController
|
||||||
from main_win.test_controller_service import TestControllerService
|
from main_win.test_controller_service import TestControllerService
|
||||||
|
from main_win import file_dialog
|
||||||
import interpreter.utils.settings as prefs
|
import interpreter.utils.settings as prefs
|
||||||
from runtime.tum_except import ETUMFileError, ETUMRuntimeError
|
from runtime.tum_except import ETUMFileError, ETUMRuntimeError
|
||||||
|
|
||||||
@@ -212,17 +213,9 @@ class TestFileManager:
|
|||||||
d = ""
|
d = ""
|
||||||
if w.testFile is not None:
|
if w.testFile is not None:
|
||||||
d = os.path.dirname(w.testFile)
|
d = os.path.dirname(w.testFile)
|
||||||
# In Flatpak the native dialog goes through the XDG document portal,
|
|
||||||
# which returns /run/user/UID/doc/.../test.tum and only exposes the
|
|
||||||
# selected file — sibling files (param.yaml, .py, etc.) are unreachable.
|
|
||||||
# Force Qt's own dialog, which walks the real filesystem mounted via
|
|
||||||
# --filesystem=home and returns a regular path with sibling access.
|
|
||||||
options = QFileDialog.Options()
|
|
||||||
if os.path.isfile("/.flatpak-info"):
|
|
||||||
options |= QFileDialog.Option.DontUseNativeDialog
|
|
||||||
file_name, _ = QFileDialog.getOpenFileName(
|
file_name, _ = QFileDialog.getOpenFileName(
|
||||||
w, "Open the test file", d,
|
w, "Open the test file", d,
|
||||||
"testium file (*.tum);;All Files (*)", options=options
|
"testium file (*.tum);;All Files (*)", options=file_dialog.options()
|
||||||
)
|
)
|
||||||
if file_name:
|
if file_name:
|
||||||
self.reload(file_name)
|
self.reload(file_name)
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ from interpreter.utils.icons import icon_prefix
|
|||||||
|
|
||||||
from main_win.test_run.outlog import OutLog
|
from main_win.test_run.outlog import OutLog
|
||||||
from main_win.test_run.test_run import ThreadTestStatus
|
from main_win.test_run.test_run import ThreadTestStatus
|
||||||
|
from main_win import file_dialog
|
||||||
import interpreter.utils.settings as prefs
|
import interpreter.utils.settings as prefs
|
||||||
from runtime.stdout_redirect import stdio_redir
|
from runtime.stdout_redirect import stdio_redir
|
||||||
import api.testium as tm
|
import api.testium as tm
|
||||||
@@ -484,7 +485,8 @@ class MainWindow(QMainWindow, Ui_MainWindow):
|
|||||||
else:
|
else:
|
||||||
initialPath = None
|
initialPath = None
|
||||||
fileName, _ = QFileDialog.getSaveFileName(
|
fileName, _ = QFileDialog.getSaveFileName(
|
||||||
self, "Path to Log file", initialPath, "Log Files (*.log);;All Files (*)"
|
self, "Path to Log file", initialPath, "Log Files (*.log);;All Files (*)",
|
||||||
|
options=file_dialog.options(),
|
||||||
)
|
)
|
||||||
if fileName:
|
if fileName:
|
||||||
shutil.copy(self.logFileName, fileName)
|
shutil.copy(self.logFileName, fileName)
|
||||||
@@ -525,7 +527,8 @@ class MainWindow(QMainWindow, Ui_MainWindow):
|
|||||||
else:
|
else:
|
||||||
initialPath = None
|
initialPath = None
|
||||||
fileName, _ = QFileDialog.getSaveFileName(
|
fileName, _ = QFileDialog.getSaveFileName(
|
||||||
self, "Path to log file", initialPath, "Log Files (*.log);;All Files (*)"
|
self, "Path to log file", initialPath, "Log Files (*.log);;All Files (*)",
|
||||||
|
options=file_dialog.options(),
|
||||||
)
|
)
|
||||||
if fileName:
|
if fileName:
|
||||||
self.editLogFilePath.setText(fileName)
|
self.editLogFilePath.setText(fileName)
|
||||||
|
|||||||
@@ -1,10 +1,76 @@
|
|||||||
# Validation
|
# Validation
|
||||||
|
|
||||||
This directory contains the necessary material to run the testium validation.
|
This directory contains the testium validation suite. A single set of
|
||||||
|
items (`items/`), fixtures and post-processing (`post_execution.py`) is
|
||||||
|
re-used across every packaging channel.
|
||||||
|
|
||||||
Here is the documentation on how to configure the validation, run it and check that the
|
## Running the suite
|
||||||
results are correct.
|
|
||||||
|
|
||||||
# Tests
|
```sh
|
||||||
|
./test/validation/run.sh # default mode = source
|
||||||
|
./test/validation/run.sh --mode wheel
|
||||||
|
./test/validation/run.sh --mode pyinstaller
|
||||||
|
./test/validation/run.sh --mode flatpak
|
||||||
|
./test/validation/run.sh --mode appimage
|
||||||
|
```
|
||||||
|
|
||||||
TBD
|
On Windows (only `source`, `wheel`, `pyinstaller` are supported):
|
||||||
|
|
||||||
|
```bat
|
||||||
|
test\validation\run.bat --mode pyinstaller
|
||||||
|
```
|
||||||
|
|
||||||
|
Pass `clean` as the **first** argument to recreate the validation venv
|
||||||
|
from scratch (useful after a system Python upgrade):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./test/validation/run.sh clean --mode flatpak
|
||||||
|
```
|
||||||
|
|
||||||
|
Any extra arguments after the mode flag are forwarded to testium.
|
||||||
|
|
||||||
|
## Modes
|
||||||
|
|
||||||
|
| Mode | What it launches | Prerequisite |
|
||||||
|
|---------------|-------------------------------------------------------------|------------------------------------------------------------------|
|
||||||
|
| `source` | `python3 src/testium` via the project's `run.sh` | none — works straight out of the repo |
|
||||||
|
| `wheel` | `python -m testium` inside a dedicated wheel venv | `./build_all.sh` produced `dist/testium-<v>-py3-none-any.whl` |
|
||||||
|
| `pyinstaller` | `dist/testium-<v>` (frozen binary) | `./build_all.sh` produced the PyInstaller binary |
|
||||||
|
| `flatpak` | `flatpak run --command=testium org.testium.Testium` | the Flatpak bundle is installed (`flatpak install --user dist/testium-<v>.flatpak`) |
|
||||||
|
| `appimage` | `dist/Testium-<v>-x86_64.AppImage` | `./build_all.sh` produced the AppImage |
|
||||||
|
|
||||||
|
Each mode writes its results to a distinct report file
|
||||||
|
(`validation-<mode>.sqlite` / `validation-<mode>-<item>.xml`), so you
|
||||||
|
can run several modes in a row without clobbering previous reports.
|
||||||
|
|
||||||
|
## How `python_bin` is pinned
|
||||||
|
|
||||||
|
Every test-execution subprocess (inline `<| ... |>` evaluation,
|
||||||
|
`py_func`, `cycle`, `post_execution`, …) is routed through a dedicated
|
||||||
|
venv at `${TMPDIR:-/tmp}/testium-validation-venv`. The venv is created
|
||||||
|
with `--system-site-packages` so existing system packages stay visible,
|
||||||
|
then `junit-xml` is pip-installed for `post_execution.py`.
|
||||||
|
|
||||||
|
This is a **host** venv. In every mode (including Flatpak) the
|
||||||
|
test-execution subprocesses end up running on the host — directly for
|
||||||
|
source/wheel/pyinstaller/appimage, and via `flatpak-spawn --host` for
|
||||||
|
Flatpak — so the same venv works across modes. The wheel mode
|
||||||
|
additionally creates a separate `testium-wheel-venv-<v>` to hold the
|
||||||
|
installed wheel; that one is only used to launch testium itself.
|
||||||
|
|
||||||
|
## What is checked
|
||||||
|
|
||||||
|
The `venv` item under `items/venv/` asserts that the validation venv is
|
||||||
|
actually being used:
|
||||||
|
|
||||||
|
* `python_bin` is set in the global dict.
|
||||||
|
* The eval subprocess (used for `<| ... |>` expressions) has
|
||||||
|
`sys.executable == python_bin`, `sys.prefix == dirname(dirname(python_bin))`,
|
||||||
|
and `sys.prefix != sys.base_prefix` (i.e. is actually inside a venv).
|
||||||
|
* A `py_func` subprocess passes the same three checks.
|
||||||
|
|
||||||
|
These checks use `abspath`/`normpath` rather than `realpath` on
|
||||||
|
purpose: the venv's `bin/python3` is a symlink to the host interpreter,
|
||||||
|
so `realpath` would map both venv and non-venv interpreters to the same
|
||||||
|
target. `sys.prefix != sys.base_prefix` is the venv-specific marker
|
||||||
|
that distinguishes the two cases.
|
||||||
|
|||||||
1
test/validation/items/venv/param.yaml
Normal file
1
test/validation/items/venv/param.yaml
Normal file
@@ -0,0 +1 @@
|
|||||||
|
no_param: Null
|
||||||
53
test/validation/items/venv/test.tum
Normal file
53
test/validation/items/venv/test.tum
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
# venv test: assert that the dedicated validation venv is the python
|
||||||
|
# being used for every test-execution subprocess (eval_proc / py_func /
|
||||||
|
# cycle / ...). The venv path is pinned by ``-d python_bin=...`` in
|
||||||
|
# test/validation/run.sh (or run.bat).
|
||||||
|
#
|
||||||
|
# We use ``abspath``/``normpath`` rather than ``realpath`` on purpose:
|
||||||
|
# the venv's ``bin/python3`` is a symlink to the host python, so
|
||||||
|
# realpath would map every venv interpreter to the same system path and
|
||||||
|
# the comparison would silently pass even *without* a venv.
|
||||||
|
# ``sys.prefix != sys.base_prefix`` is the venv-specific marker that
|
||||||
|
# catches that case.
|
||||||
|
|
||||||
|
- check:
|
||||||
|
name: python_bin is set in the global dict
|
||||||
|
key: $(test)_PASS
|
||||||
|
values:
|
||||||
|
- <| bool(r"$(python_bin)") |>
|
||||||
|
|
||||||
|
- check:
|
||||||
|
name: eval_proc subprocess runs in the validation venv
|
||||||
|
key: $(test)_PASS
|
||||||
|
values:
|
||||||
|
- <| os.path.normpath(os.path.abspath(sys.executable)) == os.path.normpath(os.path.abspath(r"$(python_bin)")) |>
|
||||||
|
|
||||||
|
- check:
|
||||||
|
name: eval_proc sys.prefix matches python_bin venv root
|
||||||
|
key: $(test)_PASS
|
||||||
|
values:
|
||||||
|
- <| os.path.normpath(os.path.abspath(sys.prefix)) == os.path.dirname(os.path.dirname(os.path.normpath(os.path.abspath(r"$(python_bin)")))) |>
|
||||||
|
|
||||||
|
- check:
|
||||||
|
name: eval_proc is actually inside a venv (sys.prefix != sys.base_prefix)
|
||||||
|
key: $(test)_PASS
|
||||||
|
values:
|
||||||
|
- <| os.path.normpath(os.path.abspath(sys.prefix)) != os.path.normpath(os.path.abspath(sys.base_prefix)) |>
|
||||||
|
|
||||||
|
- py_func:
|
||||||
|
name: py_func subprocess runs in the validation venv
|
||||||
|
key: $(test)_PASS
|
||||||
|
file: $(test_path)$(psep)verify_venv.py
|
||||||
|
func_name: check_sys_executable
|
||||||
|
|
||||||
|
- py_func:
|
||||||
|
name: py_func sys.prefix matches python_bin venv root
|
||||||
|
key: $(test)_PASS
|
||||||
|
file: $(test_path)$(psep)verify_venv.py
|
||||||
|
func_name: check_sys_prefix_in_venv
|
||||||
|
|
||||||
|
- py_func:
|
||||||
|
name: py_func is actually inside a venv
|
||||||
|
key: $(test)_PASS
|
||||||
|
file: $(test_path)$(psep)verify_venv.py
|
||||||
|
func_name: check_is_venv
|
||||||
62
test/validation/items/venv/verify_venv.py
Normal file
62
test/validation/items/venv/verify_venv.py
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import py_func.tm as tm
|
||||||
|
|
||||||
|
|
||||||
|
def _norm(p):
|
||||||
|
# normpath + normcase, without resolving symlinks. realpath() would
|
||||||
|
# follow the venv's ``python3`` symlink to ``/usr/bin/python3.X`` and
|
||||||
|
# defeat the comparison.
|
||||||
|
return os.path.normcase(os.path.normpath(os.path.abspath(p)))
|
||||||
|
|
||||||
|
|
||||||
|
def _venv_dir():
|
||||||
|
# python_bin is at ``<venv>/(bin|Scripts)/python*`` so the venv root
|
||||||
|
# is two levels above the executable.
|
||||||
|
exe = tm.gd("python_bin", "")
|
||||||
|
if not exe:
|
||||||
|
return ""
|
||||||
|
return os.path.dirname(os.path.dirname(_norm(exe)))
|
||||||
|
|
||||||
|
|
||||||
|
def check_sys_executable():
|
||||||
|
"""py_func subprocess: sys.executable must match the configured python_bin."""
|
||||||
|
expected = _norm(tm.gd("python_bin", ""))
|
||||||
|
actual = _norm(sys.executable)
|
||||||
|
if expected and actual == expected:
|
||||||
|
return True
|
||||||
|
return (
|
||||||
|
-1,
|
||||||
|
f"sys.executable={actual!r} differs from python_bin={expected!r}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def check_sys_prefix_in_venv():
|
||||||
|
"""py_func subprocess: sys.prefix must match the venv root derived
|
||||||
|
from python_bin (two levels up from the executable)."""
|
||||||
|
venv = _venv_dir()
|
||||||
|
if not venv:
|
||||||
|
return (-1, "python_bin is not set in the global dict")
|
||||||
|
actual = _norm(sys.prefix)
|
||||||
|
if actual == venv:
|
||||||
|
return True
|
||||||
|
return (
|
||||||
|
-1,
|
||||||
|
f"sys.prefix={actual!r} is not the validation venv {venv!r}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def check_is_venv():
|
||||||
|
"""py_func subprocess: confirm we are inside a venv, i.e. sys.prefix
|
||||||
|
differs from sys.base_prefix. This catches the case where python_bin
|
||||||
|
happens to be a system interpreter and the path-based check would
|
||||||
|
pass trivially."""
|
||||||
|
actual = _norm(sys.prefix)
|
||||||
|
base = _norm(sys.base_prefix)
|
||||||
|
if actual != base:
|
||||||
|
return True
|
||||||
|
return (
|
||||||
|
-1,
|
||||||
|
f"sys.prefix == sys.base_prefix == {actual!r}: not running in a venv",
|
||||||
|
)
|
||||||
131
test/validation/run.bat
Normal file
131
test/validation/run.bat
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
@echo off
|
||||||
|
SETLOCAL EnableExtensions EnableDelayedExpansion
|
||||||
|
|
||||||
|
REM Runs the testium validation suite against any installable channel of
|
||||||
|
REM testium on Windows (source, wheel, pyinstaller).
|
||||||
|
REM
|
||||||
|
REM Usage:
|
||||||
|
REM test\validation\run.bat [clean] [--mode MODE] [extra testium args]
|
||||||
|
REM
|
||||||
|
REM clean remove the validation venv before recreating it
|
||||||
|
REM (must be the first argument; useful after a Python upgrade)
|
||||||
|
REM
|
||||||
|
REM --mode MODE which testium build to validate. One of:
|
||||||
|
REM source (default) project's run.bat (src\testium)
|
||||||
|
REM wheel dist\testium-<v>-py3-none-any.whl
|
||||||
|
REM pyinstaller dist\testium-<v>.exe (or dist\testium-<v>)
|
||||||
|
REM
|
||||||
|
REM Every test-execution subprocess runs in a dedicated host venv under
|
||||||
|
REM %TEMP%\testium-validation-venv (created with --system-site-packages,
|
||||||
|
REM then junit-xml is pip-installed for post_execution.py).
|
||||||
|
REM
|
||||||
|
REM The report file is suffixed with the mode so consecutive runs in
|
||||||
|
REM different modes don't overwrite each other.
|
||||||
|
|
||||||
|
SET "SCRIPT_DIR=%~dp0"
|
||||||
|
IF "%SCRIPT_DIR:~-1%"=="\" SET "SCRIPT_DIR=%SCRIPT_DIR:~0,-1%"
|
||||||
|
SET "PROJECT_DIR=%SCRIPT_DIR%\..\.."
|
||||||
|
SET /P VERSION=<"%PROJECT_DIR%\src\VERSION"
|
||||||
|
|
||||||
|
REM ---------- arg parsing ----------------------------------------------------
|
||||||
|
|
||||||
|
SET "MODE=source"
|
||||||
|
SET "CLEAN=0"
|
||||||
|
IF /I "%~1"=="clean" (
|
||||||
|
SET "CLEAN=1"
|
||||||
|
SHIFT
|
||||||
|
)
|
||||||
|
|
||||||
|
SET "EXTRA="
|
||||||
|
:PARSE_ARGS
|
||||||
|
IF "%~1"=="" GOTO ARGS_DONE
|
||||||
|
IF /I "%~1"=="--mode" (
|
||||||
|
SET "MODE=%~2"
|
||||||
|
SHIFT
|
||||||
|
SHIFT
|
||||||
|
GOTO PARSE_ARGS
|
||||||
|
)
|
||||||
|
SET "EXTRA=!EXTRA! "%~1""
|
||||||
|
SHIFT
|
||||||
|
GOTO PARSE_ARGS
|
||||||
|
:ARGS_DONE
|
||||||
|
|
||||||
|
REM ---------- locate host python ---------------------------------------------
|
||||||
|
|
||||||
|
SET "PYTHON_EXE="
|
||||||
|
py --version >nul 2>&1
|
||||||
|
IF %ERRORLEVEL% EQU 0 (
|
||||||
|
SET "PYTHON_EXE=py"
|
||||||
|
GOTO PYTHON_FOUND
|
||||||
|
)
|
||||||
|
python --version >nul 2>&1
|
||||||
|
IF %ERRORLEVEL% EQU 0 (
|
||||||
|
SET "PYTHON_EXE=python"
|
||||||
|
GOTO PYTHON_FOUND
|
||||||
|
)
|
||||||
|
echo ERROR: Python could not be found on this system.
|
||||||
|
exit /b 1
|
||||||
|
:PYTHON_FOUND
|
||||||
|
|
||||||
|
REM ---------- validation venv -------------------------------------------------
|
||||||
|
|
||||||
|
SET "VENV_DIR=%TEMP%\testium-validation-venv"
|
||||||
|
IF "%CLEAN%"=="1" IF EXIST "%VENV_DIR%" rmdir /s /q "%VENV_DIR%"
|
||||||
|
|
||||||
|
IF NOT EXIST "%VENV_DIR%" (
|
||||||
|
echo Creating validation venv at %VENV_DIR%
|
||||||
|
%PYTHON_EXE% -m venv --system-site-packages "%VENV_DIR%"
|
||||||
|
IF !ERRORLEVEL! NEQ 0 (
|
||||||
|
echo ERROR while creating the validation venv.
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
call "%VENV_DIR%\Scripts\pip" install --quiet --upgrade pip
|
||||||
|
call "%VENV_DIR%\Scripts\pip" install --quiet junit-xml
|
||||||
|
)
|
||||||
|
SET "VENV_PYTHON=%VENV_DIR%\Scripts\python.exe"
|
||||||
|
|
||||||
|
REM ---------- shared "tail" forwarded to every launcher -----------------------
|
||||||
|
REM Reports are stamped with the mode so successive runs don't clobber each other.
|
||||||
|
|
||||||
|
SET "TAIL=-b -d "python_bin=%VENV_PYTHON%" -d "validation_report_file=validation-%MODE%" -- "%SCRIPT_DIR%\main.tum"%EXTRA%"
|
||||||
|
|
||||||
|
REM ---------- per-mode launcher ----------------------------------------------
|
||||||
|
|
||||||
|
echo -- validation mode: %MODE%
|
||||||
|
|
||||||
|
IF /I "%MODE%"=="source" GOTO MODE_SOURCE
|
||||||
|
IF /I "%MODE%"=="wheel" GOTO MODE_WHEEL
|
||||||
|
IF /I "%MODE%"=="pyinstaller" GOTO MODE_PYI
|
||||||
|
echo ERROR: unknown --mode '%MODE%'. Expected: source ^| wheel ^| pyinstaller.
|
||||||
|
exit /b 1
|
||||||
|
|
||||||
|
:MODE_SOURCE
|
||||||
|
call "%PROJECT_DIR%\run.bat" %TAIL%
|
||||||
|
exit /b %ERRORLEVEL%
|
||||||
|
|
||||||
|
:MODE_WHEEL
|
||||||
|
SET "WHEEL=%PROJECT_DIR%\dist\testium-%VERSION%-py3-none-any.whl"
|
||||||
|
IF NOT EXIST "%WHEEL%" (
|
||||||
|
echo ERROR: wheel not found at %WHEEL% -- run build_all.sh first.
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
SET "WHEEL_VENV=%TEMP%\testium-wheel-venv-%VERSION%"
|
||||||
|
IF "%CLEAN%"=="1" IF EXIST "%WHEEL_VENV%" rmdir /s /q "%WHEEL_VENV%"
|
||||||
|
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%"
|
||||||
|
)
|
||||||
|
"%WHEEL_VENV%\Scripts\python.exe" -m testium %TAIL%
|
||||||
|
exit /b %ERRORLEVEL%
|
||||||
|
|
||||||
|
:MODE_PYI
|
||||||
|
SET "PYI_BIN=%PROJECT_DIR%\dist\testium-%VERSION%.exe"
|
||||||
|
IF NOT EXIST "%PYI_BIN%" SET "PYI_BIN=%PROJECT_DIR%\dist\testium-%VERSION%"
|
||||||
|
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%
|
||||||
|
exit /b %ERRORLEVEL%
|
||||||
143
test/validation/run.sh
Executable file
143
test/validation/run.sh
Executable file
@@ -0,0 +1,143 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Runs the testium validation suite against any installable channel of
|
||||||
|
# testium (source, wheel, pyinstaller, flatpak, appimage).
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# ./test/validation/run.sh [clean] [--mode MODE] [extra testium args]
|
||||||
|
#
|
||||||
|
# clean remove the validation venv before recreating it
|
||||||
|
# (must be the first argument; useful after a Python upgrade)
|
||||||
|
#
|
||||||
|
# --mode MODE which testium build to validate. One of:
|
||||||
|
# source (default) src/testium via project run.sh
|
||||||
|
# wheel dist/testium-<v>-py3-none-any.whl
|
||||||
|
# pyinstaller dist/testium-<v>
|
||||||
|
# flatpak installed org.testium.Testium
|
||||||
|
# appimage dist/Testium-<v>-*.AppImage
|
||||||
|
#
|
||||||
|
# Every test-execution subprocess (inline <| ... |>, py_func, cycle,
|
||||||
|
# post_execution, ...) runs in a dedicated host venv under
|
||||||
|
# /tmp/testium-validation-venv. That venv is shared across modes —
|
||||||
|
# even Flatpak reaches it via flatpak-spawn --host. The validation venv
|
||||||
|
# is created with --system-site-packages so existing system packages
|
||||||
|
# (PySide6, lxml, ...) stay visible, then junit-xml is pip-installed
|
||||||
|
# for post_execution.py.
|
||||||
|
#
|
||||||
|
# The report file is suffixed with the mode (e.g. validation-flatpak.sqlite)
|
||||||
|
# so consecutive runs in different modes don't overwrite each other.
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
SCRIPT_PATH="$(readlink -f "$0")"
|
||||||
|
SCRIPT_DIR="$(realpath "$(dirname "$SCRIPT_PATH")")"
|
||||||
|
PROJECT_DIR="$(realpath "$SCRIPT_DIR/../..")"
|
||||||
|
VERSION="$(cat "$PROJECT_DIR/src/VERSION")"
|
||||||
|
|
||||||
|
# ---------- arg parsing -------------------------------------------------------
|
||||||
|
|
||||||
|
MODE=source
|
||||||
|
|
||||||
|
if [ "${1:-}" = "clean" ]; then
|
||||||
|
CLEAN=1
|
||||||
|
shift
|
||||||
|
else
|
||||||
|
CLEAN=0
|
||||||
|
fi
|
||||||
|
|
||||||
|
EXTRA=()
|
||||||
|
while [ $# -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
|
--mode)
|
||||||
|
MODE="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--mode=*)
|
||||||
|
MODE="${1#--mode=}"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
EXTRA+=("$1")
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# ---------- validation venv ---------------------------------------------------
|
||||||
|
|
||||||
|
VENV_DIR="${TMPDIR:-/tmp}/testium-validation-venv"
|
||||||
|
if [ "$CLEAN" -eq 1 ]; then
|
||||||
|
rm -rf "$VENV_DIR"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -d "$VENV_DIR" ]; then
|
||||||
|
echo "Creating validation venv at $VENV_DIR"
|
||||||
|
python3 -m venv --system-site-packages "$VENV_DIR"
|
||||||
|
"$VENV_DIR/bin/pip" install --quiet --upgrade pip
|
||||||
|
"$VENV_DIR/bin/pip" install --quiet junit-xml
|
||||||
|
fi
|
||||||
|
VENV_PYTHON="$VENV_DIR/bin/python3"
|
||||||
|
|
||||||
|
# ---------- per-mode launcher -------------------------------------------------
|
||||||
|
|
||||||
|
case "$MODE" in
|
||||||
|
source)
|
||||||
|
CMD=("$PROJECT_DIR/run.sh")
|
||||||
|
;;
|
||||||
|
wheel)
|
||||||
|
WHEEL="$PROJECT_DIR/dist/testium-${VERSION}-py3-none-any.whl"
|
||||||
|
if [ ! -f "$WHEEL" ]; then
|
||||||
|
echo "ERROR: wheel not found at $WHEEL — run ./build_all.sh first." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
WHEEL_VENV="${TMPDIR:-/tmp}/testium-wheel-venv-${VERSION}"
|
||||||
|
if [ "$CLEAN" -eq 1 ]; then
|
||||||
|
rm -rf "$WHEEL_VENV"
|
||||||
|
fi
|
||||||
|
if [ ! -d "$WHEEL_VENV" ]; then
|
||||||
|
echo "Creating wheel venv at $WHEEL_VENV"
|
||||||
|
python3 -m venv --system-site-packages "$WHEEL_VENV"
|
||||||
|
"$WHEEL_VENV/bin/pip" install --quiet --upgrade pip
|
||||||
|
"$WHEEL_VENV/bin/pip" install --quiet "$WHEEL"
|
||||||
|
fi
|
||||||
|
CMD=("$WHEEL_VENV/bin/python" -m testium)
|
||||||
|
;;
|
||||||
|
pyinstaller)
|
||||||
|
PYI_BIN="$PROJECT_DIR/dist/testium-${VERSION}"
|
||||||
|
if [ ! -x "$PYI_BIN" ]; then
|
||||||
|
echo "ERROR: PyInstaller binary not found at $PYI_BIN — run ./build_all.sh first." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
CMD=("$PYI_BIN")
|
||||||
|
;;
|
||||||
|
flatpak)
|
||||||
|
if ! flatpak info --user org.testium.Testium &>/dev/null \
|
||||||
|
&& ! flatpak info --system org.testium.Testium &>/dev/null; then
|
||||||
|
echo "ERROR: org.testium.Testium is not installed." >&2
|
||||||
|
echo " flatpak install --user $PROJECT_DIR/dist/testium-${VERSION}.flatpak" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
CMD=(flatpak run --command=testium org.testium.Testium)
|
||||||
|
;;
|
||||||
|
appimage)
|
||||||
|
APPIMAGE=$(ls -1t "$PROJECT_DIR/dist"/Testium-"${VERSION}"-*.AppImage 2>/dev/null | head -1)
|
||||||
|
if [ -z "$APPIMAGE" ] || [ ! -x "$APPIMAGE" ]; then
|
||||||
|
echo "ERROR: no AppImage for version $VERSION under $PROJECT_DIR/dist — run ./build_all.sh first." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
CMD=("$APPIMAGE")
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "ERROR: unknown --mode '$MODE'. Expected: source|wheel|pyinstaller|flatpak|appimage." >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# ---------- launch ------------------------------------------------------------
|
||||||
|
|
||||||
|
echo "-- validation mode: $MODE"
|
||||||
|
echo "-- launch: ${CMD[*]}"
|
||||||
|
|
||||||
|
exec "${CMD[@]}" -b \
|
||||||
|
-d "python_bin=$VENV_PYTHON" \
|
||||||
|
-d "validation_report_file=validation-$MODE" \
|
||||||
|
-- "$SCRIPT_DIR/main.tum" "${EXTRA[@]}"
|
||||||
Reference in New Issue
Block a user