17 Commits
v0.3 ... v0.3.2

Author SHA1 Message Date
9171abc3ba docs: note Flatpak host-open of log paths and F1 location under 0.3.2
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 15:14:49 +02:00
4a72fe019e feat(gui): open log line via configurable editor command (template {file}/{line})
refactor(settings): defaults carried by SettingsItem, getters/setters via _pref
refactor(pref-win): declarative Field table + _FIELD bridge + merged file pickers

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 14:44:23 +02:00
b5b8198c29 fix(gui): host-open clicked log paths in Flatpak (text_log)
fix(gui): host-open the sequence file location in Flatpak; drop double-open

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 12:15:19 +02:00
c313e1431b fix(gui): Flatpak Show Results opens the log via host xdg-open
fix(gui): keep Show Results enabled during a run

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 11:54:11 +02:00
7edfc25a1f docs: note F1 variable filter under 0.3.2
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 23:38:45 +02:00
7a732c0d04 feat(gui): filter variables in the F1 window (name, optional value)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 23:36:57 +02:00
f62ea10d24 test(validation): make immediate read_until deterministic (drop prompt race)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 23:17:14 +02:00
51068c881f chore(release): 0.3.2
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 22:53:30 +02:00
83475dd215 docs: run item capture + batch param
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 22:53:30 +02:00
4fe23518a0 test(validation): run capture via store_result
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 22:53:30 +02:00
87e62a7f2e feat(run): capture sub-instance output, add batch param
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 22:53:30 +02:00
5b5792a296 Merge branch 'main' of ssh://cahute.beafrancois.fr:8327/v-and-v/testium 2026-06-15 14:40:50 +02:00
087aa93a16 chore(release): 0.3.1
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 14:40:36 +02:00
7abd8c07a6 test(validation): negative load-error checks; keep run logs out of the repo
load_errors_check.py loads deliberately broken .tum fixtures in batch on the
build under test (like lsp_check.py) and asserts each fails with its specific
located message and without a raw traceback. Wired into run.sh just before the
main suite, so it runs for every channel.

The run validation items now point their sub-instance log at the gitignored
report dir, so a GUI run no longer litters the tree with sub_*.log files.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 14:40:24 +02:00
1ea360e5a5 fix(load): report every test-load error with file, item path and cause
A structural mistake in a .tum (unknown item or action, a step holding two
items, a missing 'steps:' list, a scalar where a mapping is expected, ...)
used to surface as a bare Python traceback. At worst the unknown-action
formatter itself crashed with "'dict_keys' object is not subscriptable"
(action.keys()[0]), masking the real cause and leaving only the generic
"test process crashed for any reason".

The load path now validates each step and funnels every failure through a
located TUM file syntax error: the file, a breadcrumb to the item, the
offending value and the list of valid names. A problem inside an !include-d
file points to that file. A last-resort net in __loadTestTree turns any
unforeseen exception into a located error too.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 14:40:15 +02:00
d5154348f6 Midified the package for windows (not a monolitic bin). And added the option to remove older versions in the setup. 2026-06-15 09:08:28 +02:00
6dc473de41 test(validation): --gui option to run the suite in the GUI
Adds `--gui` to test/validation/run.sh: drops `-b` so testium opens the GUI
with the validation suite loaded instead of running headless. The run is
started manually and the window stays open — handy to inspect the test
tree, try the Ctrl+F search, etc. Works with any --mode.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 00:25:53 +02:00
32 changed files with 816 additions and 492 deletions

View File

@@ -202,7 +202,7 @@ A find bar (Ctrl+F) over the `QTestTree` (`src/testium/main_win/test_tree.py`) h
- `QTestTreeItem._refresh_highlight()` is the single source of truth for the name-column colours: the **search** highlight (pastel amber bg + forced black text, readable in light *and* dark themes) and the green **run** highlight (`setHighlighted`) are recomputed from state flags with precedence **run > search > default**. No brush is saved/restored, so the two layers never leave a stale/permanent colour when they overlap (e.g. searching while a test runs). - `QTestTreeItem._refresh_highlight()` is the single source of truth for the name-column colours: the **search** highlight (pastel amber bg + forced black text, readable in light *and* dark themes) and the green **run** highlight (`setHighlighted`) are recomputed from state flags with precedence **run > search > default**. No brush is saved/restored, so the two layers never leave a stale/permanent colour when they overlap (e.g. searching while a test runs).
### `run` item ### `run` item
`src/testium/interpreter/test_items/test_item_run.py` — launches a `.tum` file in a new testium instance (`-b` in batch mode, `-r` in GUI mode). Result: `src/testium/interpreter/test_items/test_item_run.py` — launches a `.tum` file in a new testium instance. Child mode: `-b` in batch, `-r` (own window) in the GUI, or forced `-b` by `batch: true`. A `-b` child is **captured** (launched `-o`, no colour): its stdout/stderr stream through `proc_drain.drain_to_log()` into this test's log/report, and the full text is kept as the result value, so `store_result` pushes it to the gdict and `expected_result`/`process_result`/a py_func can post-process it. Stop kills the child via the poll loop. Result:
- **PASS** if the sub-instance launched and ran to completion (exit code is ignored) - **PASS** if the sub-instance launched and ran to completion (exit code is ignored)
- **FAIL** if the file is not found, `wait_for_exec` is set without `start_time`/`end_time`, the time window was not reached, or any other launch error - **FAIL** if the file is not found, `wait_for_exec` is set without `start_time`/`end_time`, the time window was not reached, or any other launch error
@@ -323,6 +323,8 @@ The `testium_assist` editor extension is a thin LSP client that spawns `testium
Both Flatpak and AppImage export `TESTIUM_VERSION` from a launcher (Flatpak: launcher script in `org.testium.Testium.yaml`; AppImage: `runtime.env` in `AppImageBuilder.yml`). `get_testium_version()` checks `/.flatpak-info` / `APPIMAGE` and reads `TESTIUM_VERSION` rather than relying on package metadata or repo introspection. Both Flatpak and AppImage export `TESTIUM_VERSION` from a launcher (Flatpak: launcher script in `org.testium.Testium.yaml`; AppImage: `runtime.env` in `AppImageBuilder.yml`). `get_testium_version()` checks `/.flatpak-info` / `APPIMAGE` and reads `TESTIUM_VERSION` rather than relying on package metadata or repo introspection.
## Recent fixes / notable changes ## Recent fixes / notable changes
- Open-log-at-line (GUI): double-click on a tree item opened the log via a hardcoded `code -g {file}:{line}` (broke in Flatpak where `code` is absent). Now driven by a configurable `editor_cmd` preference (placeholders `{file}`/`{line}`, default `code -g {file}:{line}`); the argv is built by `shlex.split` then per-token `.format` (paths with spaces stay one token), wrapped by `bins.host_console_command()` for Flatpak host-spawn, with a `host_open_path`/`openUrl` fallback (no line) when empty or failing. Settings/pref refactor alongside: `SettingsItem` carries its default (single source of truth), trivial getters/setters collapse to `_pref(item)` bindings, the pref window's `elements` dict becomes a `Field(key, type, widget)` table with a per-type `_FIELD` read/write bridge, and the four file-picker slots fold into `_pick_dir`/`_pick_file`. (Also fixed a latent default mismatch: `report_path` defaulted to `$(home)` in the property but `$(test_directory)` in the pref window; unified to `$(test_directory)`.)
- Show Results (GUI): the toolbar action stays enabled during a run (the log grows live, so it is useful mid-test), not just after. In Flatpak `QDesktopServices.openUrl` routes through the OpenURI portal and often opens no editor for a `.log`; `bins.host_open_path()` now spawns `xdg-open` on the host via `flatpak-spawn --host` (returns False outside Flatpak so the caller falls back to `openUrl`).
- Test-tree search (GUI): a Ctrl+F find bar highlights + navigates matching items, with Name/Type/Doc field checkboxes. Search modifications run under `blockSignals` (else `setBackground``itemChanged``on_testChecked` storms the controller), and the search/run highlights share one flag-driven `_refresh_highlight()` (run > search > default) so overlapping layers never leave a stale colour. See "## Test-tree search (GUI)". - Test-tree search (GUI): a Ctrl+F find bar highlights + navigates matching items, with Name/Type/Doc field checkboxes. Search modifications run under `blockSignals` (else `setBackground``itemChanged``on_testChecked` storms the controller), and the search/run highlights share one flag-driven `_refresh_highlight()` (run > search > default) so overlapping layers never leave a stale colour. See "## Test-tree search (GUI)".
- `pytest` item: pytest analogue of `unittest`, but runs on the **host interpreter in a subprocess** (`bins.python_bin()`, like `py_func`) so it works across every packaging channel. A stdlib-only pytest plugin streams collected node-ids + per-test results back over stdout via sentinels; each test becomes a child item with its own PASS/FAIL/SKIP, duration and failure message. Params: `test_file`, `test_method`. Validation item: `test/validation/items/pytest/` (the validation venv now pip-installs `pytest`). See "### `pytest` item". - `pytest` item: pytest analogue of `unittest`, but runs on the **host interpreter in a subprocess** (`bins.python_bin()`, like `py_func`) so it works across every packaging channel. A stdlib-only pytest plugin streams collected node-ids + per-test results back over stdout via sentinels; each test becomes a child item with its own PASS/FAIL/SKIP, duration and failure message. Params: `test_file`, `test_method`. Validation item: `test/validation/items/pytest/` (the validation venv now pip-installs `pytest`). See "### `pytest` item".
- Graceful item load: a self-loading item that fails to load its module/file (e.g. a `unittest` test file importing a missing module, or `pytest` not installed on the host) no longer aborts the **whole** test load. `TestSet._load_item()` wraps the item's `load()`, emits a `tm.print_warn(...)` at load time and records the reason in `item._load_error`; the `@test_run` wrapper turns a non-None `_load_error` into a clean run-time `FAILURE` (message printed once via `write_footer`). The rest of the campaign loads and runs normally. Applies to module-loading items (`unittest`, `pytest`); structural action loading stays fail-fast. - Graceful item load: a self-loading item that fails to load its module/file (e.g. a `unittest` test file importing a missing module, or `pytest` not installed on the host) no longer aborts the **whole** test load. `TestSet._load_item()` wraps the item's `load()`, emits a `tm.print_warn(...)` at load time and records the reason in `item._load_error`; the `@test_run` wrapper turns a non-None `_load_error` into a clean run-time `FAILURE` (message printed once via `write_footer`). The rest of the campaign loads and runs normally. Applies to module-loading items (`unittest`, `pytest`); structural action loading stays fail-fast.

View File

@@ -4,7 +4,13 @@
This test item executes a new instance of testium with the specified ``.tum`` file. This test item executes a new instance of testium with the specified ``.tum`` file.
* In **batch mode** (``-b``): the sub-instance is started with ``-b``. * In **batch mode** (``-b``): the sub-instance is started with ``-b``.
* In **GUI mode**: the sub-instance is started with ``-r`` (run and close). * In **GUI mode**: the sub-instance opens its own window with ``-r`` (run and close).
* ``batch: true`` forces the sub-instance to run headless (``-b``) even in the GUI.
A sub-instance started with ``-b`` is **captured**: its output is streamed into this
test's log and report, and kept as the item's result value, so it can be stored
with ``store_result`` and post-processed (``expected_result``, ``process_result``,
or a ``py_func`` reading the global variable).
The item result is **PASS** if the sub-instance launched and ran to completion, The item result is **PASS** if the sub-instance launched and ran to completion,
regardless of whether the sub-tests passed or failed. regardless of whether the sub-tests passed or failed.
@@ -17,7 +23,6 @@ launched, or the time window was not reached (see ``start_time`` / ``end_time``)
- run: - run:
name: Execute TUM name: Execute TUM
tum: example_cycle.tum tum: example_cycle.tum
python_bin: python3
log_file: $(home)/reports/test.log log_file: $(home)/reports/test.log
report_file: $(home)/reports/test.rep report_file: $(home)/reports/test.rep
@@ -28,9 +33,8 @@ run test item has the following specific attributes:
* ``tum``: mandatory, the path of the file to execute. Can be relative to the current execution folder. * ``tum``: mandatory, the path of the file to execute. Can be relative to the current execution folder.
* ``param_file`` (optional): the path of the parameter file to use; otherwise the default parameter file is used. * ``param_file`` (optional): the path of the parameter file to use; otherwise the default parameter file is used.
* ``python_bin`` (optional): the path of a specific Python interpreter to use. * ``batch`` (optional): ``true`` to run the sub-instance headless (``-b``) and capture its output even in GUI mode (see above).
* ``testium_path`` (optional): the path of a specific testium executable to use. * ``log_file`` (optional): the path of the log file. In GUI mode, if not provided, a file is created with a timestamp next to the ``.tum`` file. Not used in batch mode (the output is captured instead).
* ``log_file`` (optional): the path of the log file. In GUI mode, if not provided, a file is created with a timestamp next to the ``.tum`` file. Not used in batch mode.
* ``report_file`` (optional): the path of the report file to create. * ``report_file`` (optional): the path of the report file to create.
* ``start_time`` (optional): earliest time to execute the sub-instance, in ``HH:MM`` format. * ``start_time`` (optional): earliest time to execute the sub-instance, in ``HH:MM`` format.
* ``end_time`` (optional): latest time for execution within a time frame, in ``HH:MM`` format. * ``end_time`` (optional): latest time for execution within a time frame, in ``HH:MM`` format.

View File

@@ -1,16 +1,36 @@
# Build the Testium installer from testium.iss (needs Inno Setup 6 / ISCC.exe). # Build the Windows installer: PyInstaller one-folder build (fast start) + Inno Setup.
# Install ISCC without admin: winget install --id JRSoftware.InnoSetup -e # Install ISCC without admin: winget install --id JRSoftware.InnoSetup -e
$ErrorActionPreference = 'Stop' $ErrorActionPreference = 'Stop'
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path $scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$repoRoot = Resolve-Path (Join-Path $scriptDir '..\..')
$pyiDir = Join-Path $repoRoot 'package\pyinstaller'
# The PyInstaller exe must exist first. # Locate PyInstaller: PATH first, then the known project venvs.
$exe = Join-Path $scriptDir '..\pyinstaller\dist\testium.exe' $pyi = (Get-Command pyinstaller.exe -ErrorAction SilentlyContinue).Source
if (-not (Test-Path $exe)) { if (-not $pyi) {
throw "PyInstaller build not found: $exe`nRun package\pyinstaller\build first." foreach ($p in @(
(Join-Path $repoRoot 'test\tmp\testium_venv\Scripts\pyinstaller.exe'),
(Join-Path $repoRoot 'test\tmp\.venv\Scripts\pyinstaller.exe'))) {
if (Test-Path $p) { $pyi = $p; break }
}
}
if (-not $pyi) { throw "pyinstaller.exe not found (PATH or project venv)." }
# One-folder PyInstaller build => dist\testium\testium.exe + dist\testium\_internal\.
Write-Host "Building one-folder exe with: $pyi"
Remove-Item -Recurse -Force (Join-Path $pyiDir 'build'), (Join-Path $pyiDir 'dist') -ErrorAction SilentlyContinue
Push-Location $pyiDir
try {
$env:TESTIUM_ONEDIR = '1'
& $pyi 'testium.spec'
if ($LASTEXITCODE -ne 0) { throw "pyinstaller failed with exit code $LASTEXITCODE" }
} finally {
Remove-Item Env:\TESTIUM_ONEDIR -ErrorAction SilentlyContinue
Pop-Location
} }
# Locate ISCC.exe: PATH, then the usual install dirs. # Locate ISCC: PATH, then the usual install dirs.
$iscc = (Get-Command ISCC.exe -ErrorAction SilentlyContinue).Source $iscc = (Get-Command ISCC.exe -ErrorAction SilentlyContinue).Source
if (-not $iscc) { if (-not $iscc) {
foreach ($p in @( foreach ($p in @(

View File

@@ -49,9 +49,12 @@ Name: "english"; MessagesFile: "compiler:Default.isl"
Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked 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. ; 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 Name: "addtopath"; Description: "Ajouter Testium au PATH (usage en ligne de commande)"; Flags: unchecked
; Shown only if another version is already installed; unchecked => keep it.
Name: "removeold"; Description: "Désinstaller les autres versions de Testium déjà installées"; Check: OtherVersionsExist; Flags: unchecked
[Files] [Files]
Source: "..\pyinstaller\dist\{#MyAppExeName}"; DestDir: "{app}"; Flags: ignoreversion ; One-folder build: the exe plus its _internal\ tree (fast startup, no re-extract).
Source: "..\pyinstaller\dist\testium\*"; DestDir: "{app}"; Flags: recursesubdirs ignoreversion
; Ship the .ico so shortcuts/uninstall reference it directly, not the embedded one. ; Ship the .ico so shortcuts/uninstall reference it directly, not the embedded one.
Source: "..\testium.ico"; DestDir: "{app}"; Flags: ignoreversion Source: "..\testium.ico"; DestDir: "{app}"; Flags: ignoreversion
@@ -67,6 +70,54 @@ Filename: "{app}\{#MyAppExeName}"; Description: "{cm:LaunchProgram,{#MyAppName}}
[Code] [Code]
const const
EnvKey = 'Environment'; EnvKey = 'Environment';
UninstallRoot = 'Software\Microsoft\Windows\CurrentVersion\Uninstall';
AppGuid = '{B7E6F1C2-9A4D-4E3B-8F71-7C2D5A6E0B14}';
// Inno's uninstall subkey for *this* version: "{AppId}_is1".
function CurrentUninstallSubkey(): string;
begin
Result := AppGuid + '_{#MyAppVersion}_is1';
end;
// Uninstall subkeys of every installed Testium version except this one.
function OtherTestiumSubkeys(): TArrayOfString;
var
names: TArrayOfString;
i: Integer;
prefix, cur: string;
begin
SetArrayLength(Result, 0);
prefix := Uppercase(AppGuid + '_');
cur := Uppercase(CurrentUninstallSubkey());
if RegGetSubkeyNames(HKEY_CURRENT_USER, UninstallRoot, names) then
for i := 0 to GetArrayLength(names) - 1 do
if (Pos(prefix, Uppercase(names[i])) = 1) and (Uppercase(names[i]) <> cur) then
begin
SetArrayLength(Result, GetArrayLength(Result) + 1);
Result[GetArrayLength(Result) - 1] := names[i];
end;
end;
// Drives the "removeold" task: only offered when another version exists.
function OtherVersionsExist(): Boolean;
begin
Result := GetArrayLength(OtherTestiumSubkeys()) > 0;
end;
// Silently run each other version's uninstaller.
procedure RemoveOtherVersions();
var
subs: TArrayOfString;
i, rc: Integer;
cmd: string;
begin
subs := OtherTestiumSubkeys();
for i := 0 to GetArrayLength(subs) - 1 do
if RegQueryStringValue(HKEY_CURRENT_USER, UninstallRoot + '\' + subs[i],
'UninstallString', cmd) and (cmd <> '') then
Exec(RemoveQuotes(cmd), '/VERYSILENT /SUPPRESSMSGBOXES /NORESTART',
'', SW_HIDE, ewWaitUntilTerminated, rc);
end;
// True if Param is not already a full segment of the per-user PATH. // True if Param is not already a full segment of the per-user PATH.
function NeedsAddPath(Param: string): Boolean; function NeedsAddPath(Param: string): Boolean;
@@ -97,6 +148,8 @@ begin
Path := Path + ExpandConstant('{app}'); Path := Path + ExpandConstant('{app}');
RegWriteStringValue(HKEY_CURRENT_USER, EnvKey, 'Path', Path); RegWriteStringValue(HKEY_CURRENT_USER, EnvKey, 'Path', Path);
end; end;
if WizardIsTaskSelected('removeold') then
RemoveOtherVersions();
end; end;
end; end;

View File

@@ -79,26 +79,60 @@ a = Analysis(
) )
pyz = PYZ(a.pure) pyz = PYZ(a.pure)
exe = EXE( # TESTIUM_ONEDIR=1 => one-folder build (fast startup), used by the Windows
pyz, # installer; default one-file keeps the Linux build_all portable binary.
a.scripts, ONEDIR = bool(os.environ.get("TESTIUM_ONEDIR"))
a.binaries, # UPX skipped via TESTIUM_NO_UPX (build_all --ram) — slow for a marginal gain.
a.datas, _upx = not os.environ.get("TESTIUM_NO_UPX")
[],
name='testium', if ONEDIR:
debug=False, exe = EXE(
bootloader_ignore_signals=False, pyz,
strip=False, a.scripts,
# UPX is CPU+IO heavy for a marginal size gain — build_all --ram sets [],
# TESTIUM_NO_UPX=1 to skip it (much faster on slow/flash storage). exclude_binaries=True,
upx=not os.environ.get("TESTIUM_NO_UPX"), name='testium',
upx_exclude=[], debug=False,
runtime_tmpdir=None, bootloader_ignore_signals=False,
console=False, strip=False,
disable_windowed_traceback=False, upx=_upx,
argv_emulation=False, upx_exclude=[],
target_arch=None, console=False,
codesign_identity=None, disable_windowed_traceback=False,
entitlements_file=None, argv_emulation=False,
ico='../testium.ico' target_arch=None,
) codesign_identity=None,
entitlements_file=None,
ico='../testium.ico'
)
coll = COLLECT(
exe,
a.binaries,
a.datas,
strip=False,
upx=_upx,
upx_exclude=[],
name='testium',
)
else:
exe = EXE(
pyz,
a.scripts,
a.binaries,
a.datas,
[],
name='testium',
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=_upx,
upx_exclude=[],
runtime_tmpdir=None,
console=False,
disable_windowed_traceback=False,
argv_emulation=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None,
ico='../testium.ico'
)

View File

@@ -1,3 +1,29 @@
version 0.3.2
==============
- The variables window (F1) now has a filter box: type to show only the
variables whose name matches. Tick "values" to also match on the value.
- The ``run`` item now captures the output of the test it launches into your
log and report (it used to go only to the terminal and was lost from the
report). New ``batch: true`` option runs that test headless (and captured)
even when testium is in the GUI.
- The captured output of a ``run`` step can be saved with ``store_result`` and
inspected afterwards (for example with ``expected_result`` or a ``py_func``).
- "Show Results" now opens the log on Flatpak (it used to do nothing) and can
be used while a test is running, not only after it finishes.
- Other places that open a file now work on Flatpak too: clicking a file path
in the log output, and the "open location" button in the variables (F1) window.
- Double-clicking a test item to open the log now uses an editor of your choice:
a new preference holds the command (default ``code -g {file}:{line}``); set it
to your editor (for example ``kate -l {line} {file}``). Works on Flatpak too.
version 0.3.1
==============
- Clearer errors when a test file fails to load. The message now names the
exact file and item and explains the problem (unknown item or action, a
step holding two items, a missing ``steps:`` list, a misplaced value, ...)
and lists the valid names, instead of a cryptic failure. A problem inside
an ``!include``-d file points to that file.
version 0.3 version 0.3
============== ==============
- New ``pytest`` test item: run your pytest files as a test step; each - New ``pytest`` test item: run your pytest files as a test step; each

View File

@@ -1 +1 @@
0.3 0.3.2

View File

@@ -39,20 +39,36 @@ class TestItemActions(TestItem):
def load(self): def load(self):
ret = {} ret = {}
if self.dict_actions is None:
self.dict_actions = []
if not isinstance(self.dict_actions, (list, tuple)):
raise ETUMSyntaxError(
f"The '{self.cmd()}' test item named '{self.name()}' expects a "
f"list of actions under 'steps' but got "
f"{type(self.dict_actions).__name__} ({self.dict_actions!r}).",
self.seqFilename()
)
known_actions = ", ".join(sorted(self.action_classes.keys())) or "(none)"
for action in self.dict_actions: for action in self.dict_actions:
# Action should be only dict of length 1 # Each action must be a single-key mapping ``{action_name: {...}}``.
if not isinstance(action, dict) or (not len(action) == 1): if not isinstance(action, dict) or len(action) != 1:
raise ETUMSyntaxError( raise ETUMSyntaxError(
f"The '{self.cmd()}' test item named '{self.name()}' action should be only dict of length = 1.", f"The '{self.cmd()}' test item named '{self.name()}' has an "
f"invalid action: each action must be a single-key mapping "
f"('<action>: ...'), got {type(action).__name__} ({action!r}).",
self.seqFilename() self.seqFilename()
) )
action_name = list(action.keys())[0] action_name = list(action.keys())[0]
if not (action_name in self.action_classes.keys()): if action_name not in self.action_classes:
raise ETUMSyntaxError( raise ETUMSyntaxError(
f"The '{self.cmd()}' test item named '{self.name()}' has an unknown action '{action.keys()[0]}'.", f"The '{self.cmd()}' test item named '{self.name()}' has an "
f"unknown action '{action_name}'.\n"
f"Known actions: {known_actions}.",
self.seqFilename() self.seqFilename()
) )
# NB: an action body is not necessarily a mapping — several actions
# accept a scalar shorthand (e.g. ``writeln: 'echo hi'``); the action
# class validates its own body. Pass it through untouched.
item = (self.action_classes[action_name])( item = (self.action_classes[action_name])(
action_name, action_name,
action[action_name], action[action_name],

View File

@@ -11,6 +11,7 @@ from interpreter.test_items.test_result import (TestValue)
import api.testium as tm import api.testium as tm
from interpreter.utils.constants import TestItemType as cst from interpreter.utils.constants import TestItemType as cst
from interpreter.utils.param_decl import Param, ParamSet from interpreter.utils.param_decl import Param, ParamSet
from interpreter.utils.proc_drain import drain_to_log
from runtime.tum_except import ETUMSyntaxError, ETUMRuntimeError, item_load_context from runtime.tum_except import ETUMSyntaxError, ETUMRuntimeError, item_load_context
@@ -75,6 +76,9 @@ class TestItemRun(TestItem):
Param("wait_for_exec", Param("wait_for_exec",
doc="If true, block until the time window opens. Requires both " doc="If true, block until the time window opens. Requires both "
"start_time and end_time."), "start_time and end_time."),
Param("batch", default=False,
doc="Run the sub-instance headless (-b) with its output captured "
"into this test's log/report and result value, even in the GUI."),
) )
def __init__(self, dict_item, parent = None, status_queue=None, filename=""): def __init__(self, dict_item, parent = None, status_queue=None, filename=""):
@@ -90,6 +94,38 @@ class TestItemRun(TestItem):
self.start_time = self._prms.getParam('start_time') self.start_time = self._prms.getParam('start_time')
self.end_time = self._prms.getParam('end_time') self.end_time = self._prms.getParam('end_time')
self.wait_for_exec = self._prms.getParam('wait_for_exec') self.wait_for_exec = self._prms.getParam('wait_for_exec')
self.batch = self._prms.getParam('batch', default=False)
def _launch(self, cmd, capture):
"""Run the sub-instance once. When *capture*, stream its output to the
log/report, keep it as the result value, and let Stop kill the child."""
if not capture:
subprocess.run(cmd)
return
sink = []
prefix = f"[{os.path.basename(self.tum_file)}] "
proc = subprocess.Popen(
cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE
)
threads = drain_to_log(proc, prefix=prefix, sink=sink)
try:
while True:
try:
proc.wait(timeout=0.2)
break
except subprocess.TimeoutExpired:
if self.isStopped():
proc.terminate()
try:
proc.wait(timeout=2)
except subprocess.TimeoutExpired:
proc.kill()
break
finally:
for t in threads:
t.join(timeout=2)
# Captured log -> result value (store_result / expected_result).
self.result.value = "\n".join(sink)
@test_run @test_run
def execute(self): def execute(self):
@@ -104,25 +140,26 @@ class TestItemRun(TestItem):
pf = self._prms.expanse(self.param_file) pf = self._prms.expanse(self.param_file)
lp = self._prms.expanse(self.log_path) lp = self._prms.expanse(self.log_path)
rp = self._prms.expanse(self.report_path) rp = self._prms.expanse(self.report_path)
# Capture (headless -b) in batch or when `batch: true`; else open
# the child's own GUI window (-r).
capture = bool(self.batch) or tm.text_mode()
cmd = _testium_launch_cmd() cmd = _testium_launch_cmd()
if tm.text_mode(): if capture:
cmd.append("-b") cmd += ["-b", "-o"] # -o: no colour codes in the captured log
else: else:
cmd.append("-r") cmd.append("-r")
if lp == '': if lp == '':
lp = os.path.splitext(self.tum_file)[0] + "_" + \ lp = os.path.splitext(self.tum_file)[0] + "_" + \
datetime.utcnow().isoformat(timespec='seconds') + '.log' datetime.utcnow().isoformat(timespec='seconds') + '.log'
cmd.append("-l") cmd += ["-l", '"' + lp + '"']
cmd.append('"' + lp + '"')
if pf != '': if pf != '':
cmd.append("-c") cmd += ["-c", '"' + pf + '"']
cmd.append('"' + pf + '"')
if rp != '': if rp != '':
cmd.append("-p") cmd += ["-p", '"' + rp + '"']
cmd.append('"' + rp + '"')
cmd.append(self.tum_file) cmd.append(self.tum_file)
for c in cmd: print(" ".join(cmd))
print(c, end = ' ')
if self.start_time is not None: if self.start_time is not None:
self.start_time = datetime.strptime( self.start_time = datetime.strptime(
@@ -135,20 +172,24 @@ class TestItemRun(TestItem):
raise ETUMRuntimeError( raise ETUMRuntimeError(
'"wait_for_exec" set but not start_time or end_time') '"wait_for_exec" set but not start_time or end_time')
r = None ran = False
if self.wait_for_exec: if self.wait_for_exec:
while not nowInBetween(self.start_time, self.end_time): while not nowInBetween(self.start_time, self.end_time):
sleep(60) sleep(60)
r = subprocess.run(cmd) self._launch(cmd, capture)
ran = True
elif self.start_time is not None and self.end_time is not None: elif self.start_time is not None and self.end_time is not None:
if nowInBetween(self.start_time, self.end_time): if nowInBetween(self.start_time, self.end_time):
r = subprocess.run(cmd) self._launch(cmd, capture)
ran = True
elif self.start_time is not None: elif self.start_time is not None:
if self.start_time < datetime.now().time(): if self.start_time < datetime.now().time():
r = subprocess.run(cmd) self._launch(cmd, capture)
ran = True
else: else:
r = subprocess.run(cmd) self._launch(cmd, capture)
if isinstance(r, subprocess.CompletedProcess): ran = True
if ran:
self.result.set(TestValue.SUCCESS) self.result.set(TestValue.SUCCESS)
else: else:
self.result.set(TestValue.FAILURE, 'Sub-test did not execute') self.result.set(TestValue.FAILURE, 'Sub-test did not execute')

View File

@@ -3,7 +3,7 @@ import datetime
from queue import Queue from queue import Queue
from interpreter.utils.params import expanse from interpreter.utils.params import expanse
import api.testium as tm import api.testium as tm
from runtime.tum_except import ETUMSyntaxError from runtime.tum_except import ETUMSyntaxError, ETUMError
import interpreter.utils.settings as prefs import interpreter.utils.settings as prefs
from interpreter.test_report.test_report import TestReport from interpreter.test_report.test_report import TestReport
from interpreter.utils.py_func_exec import PyFuncExecEngine from interpreter.utils.py_func_exec import PyFuncExecEngine
@@ -65,9 +65,22 @@ def _flatten_actions(actions, out, parent_seq_name):
f"Syntax error in '{parent_seq_name}' step number {idx+1}. Sequence definition: '{str(action)}'", f"Syntax error in '{parent_seq_name}' step number {idx+1}. Sequence definition: '{str(action)}'",
f f
) )
if not isinstance(sequence, list):
raise ETUMSyntaxError(
f"Invalid included sequence in '{parent_seq_name}' "
f"(step {idx+1}): expected a list of steps, got "
f"{type(sequence).__name__}.",
f
)
for s in sequence: for s in sequence:
if isinstance(s, dict) and s: # Propagate the source filename onto each included step. Only a
s[list(s.keys())[0]]["seq_filename"] = f # single-key mapping with a mapping body can carry it; malformed
# entries are left untouched and reported by the loader below,
# with their real location.
if isinstance(s, dict) and len(s) == 1:
body = s[next(iter(s))]
if isinstance(body, dict):
body["seq_filename"] = f
_flatten_actions(sequence, out, parent_seq_name) _flatten_actions(sequence, out, parent_seq_name)
continue continue
@@ -390,7 +403,19 @@ class TestSet:
self._rootItem = (cst_type.TYPE_ROOT.item_class)( self._rootItem = (cst_type.TYPE_ROOT.item_class)(
dict_item=dict_main, status_queue=self.status_queue dict_item=dict_main, status_queue=self.status_queue
) )
ret = self.load_test_recursively(self._rootItem, dict_main, filename) try:
ret = self.load_test_recursively(self._rootItem, dict_main, filename)
except ETUMError:
# Already a located, user-readable testium error.
raise
except Exception as e:
# Last-resort net: turn any unforeseen failure into a located error
# rather than a bare traceback / 'crashed for any reason'.
raise ETUMSyntaxError(
f"Unexpected error while building the test tree: "
f"{type(e).__name__}: {e}",
filename
) from e
self.set_post_exec() self.set_post_exec()
return ret return ret
@@ -467,30 +492,43 @@ class TestSet:
def load_test_recursively(self, tree_parent, parent_seq, file_name): def load_test_recursively(self, tree_parent, parent_seq, file_name):
ret = {} ret = {}
path = _build_item_path(tree_parent)
if not isinstance(parent_seq, dict):
raise ETUMSyntaxError(
f"In: {path}\n"
f"The body of '{tree_parent.cmd()}' must be a mapping (with a "
f"'steps' list) but is {type(parent_seq).__name__} "
f"({parent_seq!r}).",
file_name
)
try: try:
parent_seq_name = parent_seq["name"] parent_seq_name = parent_seq["name"]
except KeyError: except KeyError:
parent_seq["name"] = "sequence" parent_seq["name"] = "sequence"
except TypeError: parent_seq_name = "sequence"
raise ETUMSyntaxError(
f"No 'name' attribute in '{tree_parent.type()}' (a child of '{tree_parent.parent().name()}')",
file_name
)
try: try:
parent_seq_actions = parent_seq["steps"] parent_seq_actions = parent_seq["steps"]
except KeyError: except KeyError:
raise ETUMSyntaxError( raise ETUMSyntaxError(
f"No step list found for '{parent_seq_name}' sequence. \n" + f"In: {path}\n"
f"Check the syntax of the 'steps' parameter of the '{tree_parent.cmd()}' test item definition.", f"No 'steps' list found for the '{tree_parent.cmd()}' item "
f"'{parent_seq_name}'.\n"
f"A container item must declare its children under 'steps:'.",
file_name file_name
) )
# if action is a dictionary , we assume it is a single action # if action is a dictionary , we assume it is a single action
# that has not been nested in a list, so do it # that has not been nested in a list, so do it
if isinstance(parent_seq_actions, (dict)): if isinstance(parent_seq_actions, (dict)):
parent_seq_actions = [parent_seq_actions] parent_seq_actions = [parent_seq_actions]
# an empty 'steps:' (None) is a valid, empty sequence
if parent_seq_actions is None:
parent_seq_actions = []
if not isinstance(parent_seq_actions, (list, tuple)): if not isinstance(parent_seq_actions, (list, tuple)):
raise ETUMSyntaxError( raise ETUMSyntaxError(
f"No valid list of actions in sequence {parent_seq_name}", f"In: {path}\n"
f"The 'steps' of '{parent_seq_name}' must be a list of test "
f"items but is {type(parent_seq_actions).__name__} "
f"({parent_seq_actions!r}).",
file_name file_name
) )
test_dir = tm.gd("test_directory") test_dir = tm.gd("test_directory")
@@ -502,10 +540,50 @@ class TestSet:
_flatten_actions(parent_seq_actions, flat_actions, parent_seq_name) _flatten_actions(parent_seq_actions, flat_actions, parent_seq_name)
for action in flat_actions: for action in flat_actions:
# Action is now for sure a dict of length 1 # After flattening, each step must be a single-key mapping
# '{item_cmd: {params...}}'. Anything else is a structural mistake
# in the .tum (a stray scalar, a missing '-' marker, an over- or
# under-indented block) — report it with its location instead of
# crashing on it below.
if not isinstance(action, dict):
raise ETUMSyntaxError(
f"In: {path}\n"
f"A step is not a valid test item: expected a "
f"'<item>: ...' mapping but got {type(action).__name__} "
f"({action!r}).\n"
f"Check the indentation and the '-' list markers of 'steps'.",
file_name
)
if len(action) != 1:
raise ETUMSyntaxError(
f"In: {path}\n"
f"A step must define exactly one test item but defines "
f"{len(action)}: {sorted(map(str, action.keys()))}.\n"
f"Each '-' step holds a single '<item>:'; the lines below it "
f"are probably its parameters and need one more indent level.",
file_name
)
k = list(action.keys())[0] k = list(action.keys())[0]
if action[k].get("seq_filename", None) is None:
action[k]["seq_filename"] = file_name # The body of an item is its parameter mapping. A bare '<item>:'
# (None) is tolerated as an empty parameter set; a scalar or list is
# a structural mistake and is reported with its location.
body = action[k]
if body is None:
body = {}
action[k] = body
if not isinstance(body, dict):
raise ETUMSyntaxError(
f"In: {path}\n"
f"The body of test item '{k}' must be a mapping of "
f"parameters but is {type(body).__name__} ({body!r}).",
file_name
)
if body.get("seq_filename", None) is None:
body["seq_filename"] = file_name
seq_filename = body["seq_filename"]
executed = False executed = False
for it in TEST_TYPE_LIST: for it in TEST_TYPE_LIST:
@@ -517,32 +595,18 @@ class TestSet:
(it.item_class is None) (it.item_class is None)
): ):
continue continue
if (it.item_cmd in action) or ( if k not in (it.item_cmd, cst.FOLDED_CHAR + it.item_cmd):
(cst.FOLDED_CHAR + it.item_cmd) in action continue
): executed = True
executed = True # A "." before the cmd name means the item is folded in the GUI
is_folded = False is_folded = k.startswith(cst.FOLDED_CHAR)
action_name = it.item_cmd try:
item = (it.item_class)(
# Check if a "." is before the cmd_name (meaning folded) body,
if (cst.FOLDED_CHAR + it.item_cmd) in action: tree_parent,
is_folded = True self.status_queue,
action_name = cst.FOLDED_CHAR + it.item_cmd filename=seq_filename
)
seq_filename = action[action_name]["seq_filename"]
try:
item = (it.item_class)(
action[action_name],
tree_parent,
self.status_queue,
filename=seq_filename
)
except ETUMSyntaxError as e:
path = _build_item_path(tree_parent)
raise ETUMSyntaxError(
f"In: {path}\n{e._message}",
e._file or seq_filename,
) from e
item.is_folded = is_folded item.is_folded = is_folded
child = {} child = {}
# case where the test item loads itself its descendants # case where the test item loads itself its descendants
@@ -554,15 +618,42 @@ class TestSet:
# case where the test item is an items container # case where the test item is an items container
elif item.is_container: elif item.is_container:
child = self.load_test_recursively( child = self.load_test_recursively(
item, action[action_name], seq_filename item, body, seq_filename
) )
except ETUMSyntaxError as e:
# Already a syntax error: prepend the breadcrumb to its
# location (unless it already carries one from a deeper level).
msg = e._message
if not msg.lstrip().startswith("In:"):
msg = f"In: {path} > {k}\n{msg}"
raise ETUMSyntaxError(msg, e._file or seq_filename) from e
except ETUMError:
# Other testium errors (missing parameter, runtime, I/O)
# already carry structured context (item type, name,
# parameter, ...): let them through unchanged.
raise
except Exception as e:
# Anything unexpected: never let a raw Python error reach the
# user as 'crashed for any reason' — locate it precisely.
raise ETUMSyntaxError(
f"In: {path} > {k}\n"
f"Unexpected error while loading this item: "
f"{type(e).__name__}: {e}",
seq_filename
) from e
ret.update(test_data(item, child)) ret.update(test_data(item, child))
if not executed: if not executed:
known = ", ".join(
t.item_cmd for t in TEST_TYPE_LIST
if t is not cst_type.TYPE_ROOT and t.item_class is not None
)
raise ETUMSyntaxError( raise ETUMSyntaxError(
f"test item '{k}' is not known.", f"In: {path}\n"
action[k]["seq_filename"] f"'{k}' is not a known test item.\n"
f"Known items: {known}.",
seq_filename
) )
return ret return ret

View File

@@ -199,6 +199,23 @@ def host_console_command(shell_cmd, cwd):
return ["flatpak-spawn", "--host", f"--directory={cwd}", *argv] return ["flatpak-spawn", "--host", f"--directory={cwd}", *argv]
def host_open_path(path):
"""Open *path* with the host default application (Flatpak only).
QDesktopServices/openUrl routes through the OpenURI portal inside Flatpak,
which often fails to open a plain editor for a log file. Spawn xdg-open on
the host so the user's real default app is used. Returns True on dispatch;
False (incl. outside Flatpak) so the caller can fall back to openUrl.
"""
if not _in_flatpak():
return False
try:
subprocess.Popen(["flatpak-spawn", "--host", "xdg-open", path])
return True
except (FileNotFoundError, PermissionError):
return False
def _which_host_flatpak(name): def _which_host_flatpak(name):
"""Resolve a binary name (or absolute path) on the host via flatpak-spawn. """Resolve a binary name (or absolute path) on the host via flatpak-spawn.

View File

@@ -13,7 +13,7 @@ from time import monotonic
from runtime.jrpc import RPC_PORT_SENTINEL from runtime.jrpc import RPC_PORT_SENTINEL
def _drain_pipe(pipe, prefix): def _drain_pipe(pipe, prefix, sink=None):
try: try:
for raw in iter(pipe.readline, b""): for raw in iter(pipe.readline, b""):
line = raw.decode("utf-8", errors="replace").rstrip("\r\n") line = raw.decode("utf-8", errors="replace").rstrip("\r\n")
@@ -23,6 +23,9 @@ def _drain_pipe(pipe, prefix):
print(f"{prefix}{line}") print(f"{prefix}{line}")
else: else:
print(line) print(line)
# sink keeps the clean (unprefixed) line for reuse as a result value
if sink is not None:
sink.append(line)
finally: finally:
try: try:
pipe.close() pipe.close()
@@ -30,21 +33,16 @@ def _drain_pipe(pipe, prefix):
pass pass
def drain_to_log(process, prefix=""): def drain_to_log(process, prefix="", sink=None):
"""Spawn daemon threads that read ``process.stdout`` and """Stream the subprocess stdout/stderr line by line through the parent's
``process.stderr`` line by line and print each line through the print pipeline (log + live output). If ``sink`` is a list, each clean line
parent's stdout (so it reaches the log + live output). is also appended to it (GIL-atomic, shared by both threads). Daemon threads."""
Each thread exits cleanly when the subprocess closes the
corresponding pipe (i.e. when it exits). Daemon flag ensures they
do not block testium exit.
"""
threads = [] threads = []
for pipe in (process.stdout, process.stderr): for pipe in (process.stdout, process.stderr):
if pipe is None: if pipe is None:
continue continue
t = threading.Thread( t = threading.Thread(
target=_drain_pipe, args=(pipe, prefix), daemon=True, target=_drain_pipe, args=(pipe, prefix, sink), daemon=True,
) )
t.start() t.start()
threads.append(t) threads.append(t)

View File

@@ -13,30 +13,59 @@ def init():
settings = TestiumSettings() settings = TestiumSettings()
_UNSET = object()
class SettingsItem(): class SettingsItem():
def __init__(self, name: str, item_type: type) -> None: def __init__(self, name: str, item_type: type, default=None) -> None:
self.name = name self.name = name
self.t = item_type self.t = item_type
self.default = default
def _pref(item):
"""Build a get/set property reading/writing *item* (default carried by the item)."""
return property(lambda self: self.value(item),
lambda self, value: self.set_value(item, value))
class TestiumSettings(): class TestiumSettings():
SettingsRecentFiles = SettingsItem('recentFileList', list) SettingsRecentFiles = SettingsItem('recentFileList', list, [])
SettingsLastLogFile = SettingsItem('lastLogFile', str) SettingsLastLogFile = SettingsItem('lastLogFile', str, '')
SettingsLogFileSaved = SettingsItem('logFileSaved', bool) SettingsLogFileSaved = SettingsItem('logFileSaved', bool, False)
SettingsHideDocPane = SettingsItem('docPaneHidden', bool) SettingsHideDocPane = SettingsItem('docPaneHidden', bool, False)
SettingsHideLogPane = SettingsItem('logPaneHidden', bool) SettingsHideLogPane = SettingsItem('logPaneHidden', bool, False)
SettingsShowCheckboxes = SettingsItem('checkBoxesShow', bool) SettingsShowCheckboxes = SettingsItem('checkBoxesShow', bool, False)
SettingsLogPath = SettingsItem('defaultLogPath', str) SettingsLogPath = SettingsItem('defaultLogPath', str, '$(test_directory)')
SettingsReportPath = SettingsItem('defaultReportPath', str) SettingsReportPath = SettingsItem('defaultReportPath', str, '$(test_directory)')
SettingsShowTimeColumn = SettingsItem('showTimeColumn', bool) SettingsShowTimeColumn = SettingsItem('showTimeColumn', bool, False)
SettingsColumnsSize = SettingsItem('columnsSize', dict) SettingsColumnsSize = SettingsItem('columnsSize', dict, {})
SettingsDblClickEnabled = SettingsItem('dblClickEnabled', bool) SettingsDblClickEnabled = SettingsItem('dblClickEnabled', bool, False)
SettingsIconsTheme = SettingsItem('iconsTheme', int) SettingsEditorCmd = SettingsItem('editorCmd', str, 'code -g {file}:{line}')
SettingsLogFont = SettingsItem('logFont', str) SettingsIconsTheme = SettingsItem('iconsTheme', int, 0)
SettingsLogFontSize = SettingsItem('logFontSize', int) SettingsLogFont = SettingsItem('logFont', str, 'Monospace')
SettingsGitSupported = SettingsItem('logGitSupported', bool) SettingsLogFontSize = SettingsItem('logFontSize', int, 8)
SettingsPythonPath = SettingsItem('pythonPath', str) SettingsGitSupported = SettingsItem('logGitSupported', bool, True)
SettingsLuaPath = SettingsItem('luaPath', str) SettingsPythonPath = SettingsItem('pythonPath', str, '')
SettingsLuaPath = SettingsItem('luaPath', str, '')
recent_files = _pref(SettingsRecentFiles)
log_file = _pref(SettingsLastLogFile)
log_file_saved = _pref(SettingsLogFileSaved)
hide_doc_pane = _pref(SettingsHideDocPane)
hide_log_pane = _pref(SettingsHideLogPane)
show_checkboxes = _pref(SettingsShowCheckboxes)
log_path = _pref(SettingsLogPath)
report_path = _pref(SettingsReportPath)
show_time_column = _pref(SettingsShowTimeColumn)
columns_size = _pref(SettingsColumnsSize)
dbl_click_enabled = _pref(SettingsDblClickEnabled)
editor_cmd = _pref(SettingsEditorCmd)
icons_theme = _pref(SettingsIconsTheme)
log_font = _pref(SettingsLogFont)
git_supported = _pref(SettingsGitSupported)
python_bin = _pref(SettingsPythonPath)
lua_bin = _pref(SettingsLuaPath)
def __init__(self): def __init__(self):
if 'windows' in platform.system().lower(): if 'windows' in platform.system().lower():
@@ -71,9 +100,11 @@ class TestiumSettings():
self.conf['Default'] = {} self.conf['Default'] = {}
self.sync() self.sync()
def value(self, key: SettingsItem, default=''): def value(self, key: SettingsItem, default=_UNSET):
if not isinstance(key, SettingsItem): if not isinstance(key, SettingsItem):
raise ETUMRuntimeError('Not a proper Settings item.') raise ETUMRuntimeError('Not a proper Settings item.')
if default is _UNSET:
default = key.default
if type(default) != key.t: if type(default) != key.t:
raise ETUMRuntimeError( raise ETUMRuntimeError(
'Types mismatch in config file. You could try to erase "{}" to solve the issue'.format(self.settings_fname)) 'Types mismatch in config file. You could try to erase "{}" to solve the issue'.format(self.settings_fname))
@@ -120,127 +151,10 @@ class TestiumSettings():
if configfile.writable(): if configfile.writable():
self.conf.write(configfile) self.conf.write(configfile)
# SettingsRecentFiles = 'recentFileList' # log_font_size keeps a custom getter: clamp non-positive sizes to 8.
@property
def recent_files(self):
return self.value(self.SettingsRecentFiles, [])
@recent_files.setter
def recent_files(self, value):
self.set_value(self.SettingsRecentFiles, value)
# SettingsLastLogFile = 'lastLogFile'
@property
def log_file(self):
return self.value(self.SettingsLastLogFile)
@log_file.setter
def log_file(self, value):
self.set_value(self.SettingsLastLogFile, value)
# SettingsLogFileSaved = 'logFileSaved'
@property
def log_file_saved(self):
return self.value(self.SettingsLogFileSaved, False)
@log_file_saved.setter
def log_file_saved(self, value):
self.set_value(self.SettingsLogFileSaved, value)
# SettingsHideDocPane = 'docPaneHidden'
@property
def hide_doc_pane(self):
return self.value(self.SettingsHideDocPane, False)
@hide_doc_pane.setter
def hide_doc_pane(self, value):
self.set_value(self.SettingsHideDocPane, value)
# SettingsHideLogPane = 'logPaneHidden'
@property
def hide_log_pane(self):
return self.value(self.SettingsHideLogPane, False)
@hide_log_pane.setter
def hide_log_pane(self, value):
self.set_value(self.SettingsHideLogPane, value)
# SettingsShowCheckboxes = 'checkBoxesShow'
@property
def show_checkboxes(self):
return self.value(self.SettingsShowCheckboxes, False)
@show_checkboxes.setter
def show_checkboxes(self, value):
self.set_value(self.SettingsShowCheckboxes, value)
# SettingsLogPath = 'defaultLogPath'
@property
def log_path(self):
return self.value(self.SettingsLogPath, '$(test_directory)')
@log_path.setter
def log_path(self, value):
self.set_value(self.SettingsLogPath, value)
# SettingsReportPath = 'defaultReportPath'
@property
def report_path(self):
return self.value(self.SettingsReportPath, '$(home)')
@report_path.setter
def report_path(self, value):
self.set_value(self.SettingsReportPath, value)
# SettingsShowTimeColumn = 'showTimeColumn'
@property
def show_time_column(self):
return self.value(self.SettingsShowTimeColumn, False)
@show_time_column.setter
def show_time_column(self, value):
self.set_value(self.SettingsShowTimeColumn, value)
# SettingsColumnsSize = 'columnsSize'
@property
def columns_size(self):
return self.value(self.SettingsColumnsSize, {})
@columns_size.setter
def columns_size(self, value):
self.set_value(self.SettingsColumnsSize, value)
# SettingsDblClickEnabled = 'dblClickEnabled'
@property
def dbl_click_enabled(self):
return self.value(self.SettingsDblClickEnabled, False)
@dbl_click_enabled.setter
def dbl_click_enabled(self, value):
self.set_value(self.SettingsDblClickEnabled, value)
# SettingsIconsTheme = 'iconsTheme'
@property
def icons_theme(self):
return self.value(self.SettingsIconsTheme, 0)
@icons_theme.setter
def icons_theme(self, value):
self.set_value(self.SettingsIconsTheme, value)
# SettingsLogFont = 'logFont'
@property
def log_font(self):
return self.value(self.SettingsLogFont, 'Monospace')
@log_font.setter
def log_font(self, value):
self.set_value(self.SettingsLogFont, value)
# SettingsLogFontSize = 'logFontSize'
@property @property
def log_font_size(self): def log_font_size(self):
v = self.value(self.SettingsLogFontSize, 8) v = self.value(self.SettingsLogFontSize)
if v <= 0: if v <= 0:
v = 8 v = 8
return v return v
@@ -248,33 +162,3 @@ class TestiumSettings():
@log_font_size.setter @log_font_size.setter
def log_font_size(self, value): def log_font_size(self, value):
self.set_value(self.SettingsLogFontSize, value) self.set_value(self.SettingsLogFontSize, value)
# SettingsGitSupported = 'gitSupported'
@property
def git_supported(self):
r = self.value(self.SettingsGitSupported, True)
return r
@git_supported.setter
def git_supported(self, value):
self.set_value(self.SettingsGitSupported, value)
# SettingsPythonPath = 'python_bin'
@property
def python_bin(self):
r = self.value(self.SettingsPythonPath, "")
return r
@python_bin.setter
def python_bin(self, value):
self.set_value(self.SettingsPythonPath, value)
# SettingsLuaPath = 'luaPath'
@property
def lua_bin(self):
r = self.value(self.SettingsLuaPath, "")
return r
@lua_bin.setter
def lua_bin(self, value):
self.set_value(self.SettingsLuaPath, value)

View File

@@ -6,13 +6,14 @@ import subprocess
import sys import sys
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QDialog, QDialogButtonBox, QHeaderView, QMenu, QMessageBox, QCheckBox, QDialog, QDialogButtonBox, QHBoxLayout, QHeaderView, QLineEdit,
QPushButton, QTextEdit, QVBoxLayout, QMenu, QMessageBox, QPushButton, QTextEdit, QVBoxLayout,
) )
from PySide6.QtGui import QSyntaxHighlighter, QTextCharFormat, QColor, QFont, QDesktopServices from PySide6.QtGui import QSyntaxHighlighter, QTextCharFormat, QColor, QFont, QDesktopServices
from PySide6.QtCore import Qt, QUrl, Slot from PySide6.QtCore import Qt, QUrl, Slot
from main_win.f1_win.f1_win_core import Ui_F1Dialog from main_win.f1_win.f1_win_core import Ui_F1Dialog
from interpreter.utils import bins
class YamlHighlighter(QSyntaxHighlighter): class YamlHighlighter(QSyntaxHighlighter):
@@ -119,6 +120,43 @@ class DialogF1(QDialog):
self.ui.addVarButton.setEnabled(False) self.ui.addVarButton.setEnabled(False)
self.ui.addVarButton.clicked.connect(self._on_add_var) self.ui.addVarButton.clicked.connect(self._on_add_var)
# Filter box above the table: hides rows whose name doesn't match.
# The optional "values" checkbox extends the match to the value column.
self._filter_text = ""
self._filter_edit = QLineEdit(self.ui.tabVariables)
self._filter_edit.setPlaceholderText("Filter variables by name")
self._filter_edit.setClearButtonEnabled(True)
self._filter_edit.textChanged.connect(self._on_filter_changed)
self._filter_values_cb = QCheckBox("values", self.ui.tabVariables)
self._filter_values_cb.setToolTip("Also match on the variable value")
self._filter_values_cb.toggled.connect(lambda _checked: self._apply_filter())
filter_row = QHBoxLayout()
filter_row.addWidget(self._filter_edit)
filter_row.addWidget(self._filter_values_cb)
self.ui.verticalLayout_tab1.insertLayout(0, filter_row)
def _on_filter_changed(self, text):
self._filter_text = text.strip().lower()
self._apply_filter()
def _apply_filter(self):
for row in range(self.ui.varsTable.rowCount()):
self._apply_filter_row(row)
def _apply_filter_row(self, row):
needle = self._filter_text
if not needle:
self.ui.varsTable.setRowHidden(row, False)
return
table = self.ui.varsTable
key_item = table.item(row, 0)
hay = key_item.text().lower() if key_item else ""
if self._filter_values_cb.isChecked():
val_item = table.item(row, 1)
if val_item is not None:
hay += "\n" + val_item.text().lower()
table.setRowHidden(row, needle not in hay)
def load_initial_vars(self, vars_dict: dict): def load_initial_vars(self, vars_dict: dict):
for key, value in vars_dict.items(): for key, value in vars_dict.items():
self.gd_var_updated(key, value) self.gd_var_updated(key, value)
@@ -149,6 +187,7 @@ class DialogF1(QDialog):
self._updating = False self._updating = False
self._key_rows[key] = row self._key_rows[key] = row
self._refresh_row(row, key, value) self._refresh_row(row, key, value)
self._apply_filter_row(self._key_rows[key])
@Slot(str) @Slot(str)
def gd_var_deleted(self, key): def gd_var_deleted(self, key):
@@ -161,6 +200,7 @@ class DialogF1(QDialog):
finally: finally:
self._updating = False self._updating = False
self._key_rows = {k: (r - 1 if r > row else r) for k, r in self._key_rows.items()} self._key_rows = {k: (r - 1 if r > row else r) for k, r in self._key_rows.items()}
self._apply_filter()
def _refresh_row(self, row, key, value): def _refresh_row(self, row, key, value):
from PySide6.QtWidgets import QTableWidgetItem from PySide6.QtWidgets import QTableWidgetItem
@@ -265,9 +305,11 @@ class DialogF1(QDialog):
def on_butlocopen_click(self): def on_butlocopen_click(self):
file = self.ui.sequenceFileNameLineEdit.text() file = self.ui.sequenceFileNameLineEdit.text()
if os.path.exists(file): if not os.path.exists(file):
if sys.platform.startswith("win"): return
subprocess.Popen(f'explorer "{file}"') if bins.host_open_path(file):
else: return
subprocess.Popen(["xdg-open", file]) if sys.platform.startswith("win"):
subprocess.Popen(f'explorer "{file}"')
else:
QDesktopServices.openUrl(QUrl.fromLocalFile(file)) QDesktopServices.openUrl(QUrl.fromLocalFile(file))

View File

@@ -1,5 +1,7 @@
from PySide6.QtCore import Slot, Qt from collections import namedtuple
from PySide6.QtWidgets import QDialog, QFileDialog
from PySide6.QtCore import Slot
from PySide6.QtWidgets import QDialog, QFileDialog, QLabel, QLineEdit
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
@@ -8,6 +10,24 @@ from main_win import file_dialog
import interpreter.utils.settings as prefs import interpreter.utils.settings as prefs
def _set_font(w, v):
f = QFont()
f.fromString(v)
w.setCurrentFont(f)
# Per-type widget <-> value bridge: (read from widget, write to widget).
_FIELD = {
"bool": (lambda w: w.isChecked(), lambda w, v: w.setChecked(v)),
"text": (lambda w: w.text(), lambda w, v: w.setText(v)),
"int": (lambda w: int(w.value()), lambda w, v: w.setValue(v)),
"combo": (lambda w: int(w.currentIndex()), lambda w, v: w.setCurrentIndex(v)),
"font": (lambda w: w.currentFont().toString(), _set_font),
}
Field = namedtuple("Field", "key type widget")
class PrefWindow(QDialog): class PrefWindow(QDialog):
def __init__(self, parent=None): def __init__(self, parent=None):
super().__init__(parent) super().__init__(parent)
@@ -17,162 +37,57 @@ class PrefWindow(QDialog):
self.ui.buttonBox.accepted.connect(self.on_buttOKPressed) self.ui.buttonBox.accepted.connect(self.on_buttOKPressed)
self.ui.buttonBox.rejected.connect(self.on_buttCancelPressed) self.ui.buttonBox.rejected.connect(self.on_buttCancelPressed)
self.finished.connect(self.on_finishedPressed) self.finished.connect(self.on_finishedPressed)
self.ui.butLogPath.triggered.connect(self.on_butLogPath_pressed)
self.ui.butReportPath.triggered.connect(self.on_butReportPath_pressed)
self.ui.butPythonPath.triggered.connect(self.on_butPythonPath_pressed)
self.ui.butLuaPath.triggered.connect(self.on_butLuaPath_pressed)
self.elements = {
prefs.settings.SettingsHideDocPane: {
"type": "bool",
"widget": self.ui.checkDocPane,
"value": prefs.settings.hide_doc_pane,
"default": False,
"changed": False,
},
prefs.settings.SettingsHideLogPane: {
"type": "bool",
"widget": self.ui.checkLogPane,
"value": prefs.settings.hide_log_pane,
"default": False,
"changed": False,
},
prefs.settings.SettingsShowCheckboxes: {
"type": "bool",
"widget": self.ui.checkBoxTest,
"value": prefs.settings.show_checkboxes,
"default": False,
"changed": False,
},
prefs.settings.SettingsShowTimeColumn: {
"type": "bool",
"widget": self.ui.checkShowTime,
"value": prefs.settings.show_time_column,
"default": False,
"changed": False,
},
prefs.settings.SettingsLogPath: {
"type": "text",
"widget": self.ui.editDefaultLogPath,
"value": prefs.settings.log_path,
"default": "$(test_directory)",
"changed": False,
},
prefs.settings.SettingsReportPath: {
"type": "text",
"widget": self.ui.editDefaultReportPath,
"value": prefs.settings.report_path,
"default": "$(test_directory)",
"changed": False,
},
prefs.settings.SettingsDblClickEnabled: {
"type": "bool",
"widget": self.ui.checkDblClick,
"value": prefs.settings.dbl_click_enabled,
"default": False,
"changed": False,
},
prefs.settings.SettingsIconsTheme: {
"type": "combo",
"widget": self.ui.choiceIconsTheme,
"value": prefs.settings.icons_theme,
"default": 0,
"changed": False,
},
prefs.settings.SettingsLogFont: {
"type": "font",
"widget": self.ui.font_choice,
"value": prefs.settings.log_font,
"default": "Monospace",
"changed": False,
},
prefs.settings.SettingsLogFontSize: {
"type": "int",
"widget": self.ui.font_size,
"value": prefs.settings.log_font_size,
"default": 8,
"changed": False,
},
prefs.settings.SettingsGitSupported: {
"type": "bool",
"widget": self.ui.checkGitSupported,
"value": prefs.settings.git_supported,
"default": True,
"changed": False,
},
prefs.settings.SettingsPythonPath: {
"type": "text",
"widget": self.ui.editPythonPath,
"value": prefs.settings.python_bin,
"default": "",
"changed": False,
},
prefs.settings.SettingsLuaPath: {
"type": "text",
"widget": self.ui.editLuaPath,
"value": prefs.settings.lua_bin,
"default": "",
"changed": False,
},
}
self.ui.butLogPath.triggered.connect(
lambda: self._pick_dir(self.ui.editDefaultLogPath, "Select the default log directory"))
self.ui.butReportPath.triggered.connect(
lambda: self._pick_dir(self.ui.editDefaultReportPath, "Select the default report directory"))
self.ui.butPythonPath.triggered.connect(
lambda: self._pick_file(self.ui.editPythonPath, "Select the python interpreter"))
self.ui.butLuaPath.triggered.connect(
lambda: self._pick_file(self.ui.editLuaPath, "Select the lua interpreter"))
# Editor command field, added in code (mirrors the F1 filter approach) so the
# generated UI stays untouched. Sits with the double-click toggle it feeds.
self.editEditorCmd = QLineEdit(self.ui.scrollAreaWidgetContents)
self.editEditorCmd.setPlaceholderText("ex: code -g {file}:{line}")
self.ui.formLayout.addRow(QLabel("Open log line in editor"), self.editEditorCmd)
s = prefs.settings
self.fields = [
Field(s.SettingsHideDocPane, "bool", self.ui.checkDocPane),
Field(s.SettingsHideLogPane, "bool", self.ui.checkLogPane),
Field(s.SettingsShowCheckboxes, "bool", self.ui.checkBoxTest),
Field(s.SettingsShowTimeColumn, "bool", self.ui.checkShowTime),
Field(s.SettingsLogPath, "text", self.ui.editDefaultLogPath),
Field(s.SettingsReportPath, "text", self.ui.editDefaultReportPath),
Field(s.SettingsDblClickEnabled, "bool", self.ui.checkDblClick),
Field(s.SettingsEditorCmd, "text", self.editEditorCmd),
Field(s.SettingsIconsTheme, "combo", self.ui.choiceIconsTheme),
Field(s.SettingsLogFont, "font", self.ui.font_choice),
Field(s.SettingsLogFontSize, "int", self.ui.font_size),
Field(s.SettingsGitSupported, "bool", self.ui.checkGitSupported),
Field(s.SettingsPythonPath, "text", self.ui.editPythonPath),
Field(s.SettingsLuaPath, "text", self.ui.editLuaPath),
]
self._changed = set()
self.restore_prefs() self.restore_prefs()
def store_prefs(self): def store_prefs(self):
for k, v in self.elements.items(): self._changed = set()
self.elements[k]["changed"] = False for f in self.fields:
if v["type"] == "bool": val = _FIELD[f.type][0](f.widget)
val = v["widget"].isChecked() if val != prefs.settings.value(f.key):
if self.elements[k]["value"] != val: prefs.settings.set_value(f.key, val)
self.elements[k]["value"] = val self._changed.add(f.key.name)
self.elements[k]["changed"] = True
if v["type"] == "text":
val = v["widget"].text()
if self.elements[k]["value"] != val:
self.elements[k]["value"] = val
self.elements[k]["changed"] = True
if v["type"] == "font":
val = v["widget"].currentFont().toString()
if self.elements[k]["value"] != val:
self.elements[k]["value"] = val
self.elements[k]["changed"] = True
if v["type"] == "int":
val = int(v["widget"].value())
if self.elements[k]["value"] != val:
self.elements[k]["value"] = val
self.elements[k]["changed"] = True
if v["type"] == "combo":
val = int(v["widget"].currentIndex())
if self.elements[k]["value"] != val:
self.elements[k]["value"] = val
self.elements[k]["changed"] = True
if self.elements[k]["changed"]:
prefs.settings.set_value(k, v["value"])
prefs.settings.sync() prefs.settings.sync()
def restore_prefs(self): def restore_prefs(self):
for k, v in self.elements.items(): for f in self.fields:
v["value"] = prefs.settings.value(k, v["default"]) _FIELD[f.type][1](f.widget, prefs.settings.value(f.key))
if v["type"] == "bool":
v["widget"].setChecked(v["value"])
elif v["type"] == "text":
v["widget"].setText(self.elements[k]["value"])
elif v["type"] == "font":
f = QFont()
f.fromString(self.elements[k]["value"])
v["widget"].setCurrentFont(f)
elif v["type"] == "int":
v["widget"].setValue(self.elements[k]["value"])
elif v["type"] == "combo":
v["widget"].setCurrentIndex(self.elements[k]["value"])
def isChanged(self, setting): def isChanged(self, setting):
return self.elements[setting]["changed"] return setting.name in self._changed
@Slot() @Slot()
def on_buttOKPressed(self): def on_buttOKPressed(self):
@@ -188,46 +103,14 @@ class PrefWindow(QDialog):
def on_finishedPressed(self): def on_finishedPressed(self):
self.restore_prefs() self.restore_prefs()
@Slot() def _pick_dir(self, edit, caption):
def on_butReportPath_pressed(self):
path = QFileDialog.getExistingDirectory( path = QFileDialog.getExistingDirectory(
self, self, caption=caption, dir=edit.text(), options=file_dialog.options())
caption="Select the default report directory",
dir=self.ui.editDefaultReportPath.text(),
options=file_dialog.options(),
)
if path: if path:
self.ui.editDefaultReportPath.setText(path) edit.setText(path)
@Slot() def _pick_file(self, edit, caption):
def on_butLogPath_pressed(self):
path = QFileDialog.getExistingDirectory(
self,
caption="Select the default log directory",
dir=self.ui.editDefaultLogPath.text(),
options=file_dialog.options(),
)
if path:
self.ui.editDefaultLogPath.setText(path)
@Slot()
def on_butPythonPath_pressed(self):
path, _ = QFileDialog.getOpenFileName( path, _ = QFileDialog.getOpenFileName(
self, self, caption=caption, dir=edit.text(), options=file_dialog.options())
caption="Select the python interpreter",
dir=self.ui.editPythonPath.text(),
options=file_dialog.options(),
)
if path: if path:
self.ui.editPythonPath.setText(path) edit.setText(path)
@Slot()
def on_butLuaPath_pressed(self):
path, _ = QFileDialog.getOpenFileName(
self,
caption="Select the lua interpreter",
dir=self.ui.editLuaPath.text(),
options=file_dialog.options(),
)
if path:
self.ui.editLuaPath.setText(path)

View File

@@ -127,7 +127,8 @@ class TestFileManager:
del w.ts_controller del w.ts_controller
w.ts_controller = None w.ts_controller = None
raise ETUMRuntimeError( raise ETUMRuntimeError(
"Test could not be loaded (test process crashed for any reason)" "Test could not be loaded. See the log above for the cause "
"(syntax error, missing file, missing module, ...)."
) )
progress.setLabelText("Building test tree…") progress.setLabelText("Building test tree…")

View File

@@ -181,7 +181,8 @@ class TestRunner:
w.actionStart_test.setText("Pause test") w.actionStart_test.setText("Pause test")
w.actionPreferences.setDisabled(True) w.actionPreferences.setDisabled(True)
w.actionRefresh_test.setDisabled(True) w.actionRefresh_test.setDisabled(True)
w.actionShow_Results.setDisabled(True) # Show Results stays available during the run (log grows live).
w.actionShow_Results.setEnabled(True)
w.actionSave_report.setDisabled(True) w.actionSave_report.setDisabled(True)
w.logSettingsBox.setDisabled(True) w.logSettingsBox.setDisabled(True)
w.actionStop_test.setEnabled(True) w.actionStop_test.setEnabled(True)

View File

@@ -1,5 +1,7 @@
import sys import sys
import os import os
import shlex
import subprocess
import webbrowser import webbrowser
from multiprocessing import Queue from multiprocessing import Queue
from threading import Thread from threading import Thread
@@ -40,6 +42,7 @@ from runtime.string_queue import StringQueue
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 interpreter.utils.icons import icon_prefix from interpreter.utils.icons import icon_prefix
from interpreter.utils import bins
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
@@ -639,7 +642,8 @@ class MainWindow(QMainWindow, Ui_MainWindow):
self.statusBar().showMessage( self.statusBar().showMessage(
"Opening the logfile (" + s + "): " + self.logFileName, 100000 "Opening the logfile (" + s + "): " + self.logFileName, 100000
) )
QDesktopServices.openUrl(QUrl.fromLocalFile(self.logFileName)) if not bins.host_open_path(self.logFileName):
QDesktopServices.openUrl(QUrl.fromLocalFile(self.logFileName))
@Slot() @Slot()
def on_actionHelp_triggered(self): def on_actionHelp_triggered(self):
@@ -743,7 +747,21 @@ class MainWindow(QMainWindow, Ui_MainWindow):
if (self.logFileName is not None) and os.access(self.logFileName, os.R_OK): if (self.logFileName is not None) and os.access(self.logFileName, os.R_OK):
ln = tm.line_number("@@{}@@".format(item.timestamp()), self.logFileName) ln = tm.line_number("@@{}@@".format(item.timestamp()), self.logFileName)
if ln > 0: if ln > 0:
os.system("{} -g {}:{} &".format("code", self.logFileName, ln + 1)) self._open_in_editor(self.logFileName, ln + 1)
def _open_in_editor(self, path, line):
"""Open path at line via the configured editor template ({file}/{line}).
Empty template or failure falls back to opening the file without line."""
tmpl = prefs.settings.editor_cmd
if tmpl:
try:
argv = [p.format(file=path, line=line) for p in shlex.split(tmpl)]
subprocess.Popen(bins.host_console_command(argv, os.path.dirname(path) or "."))
return
except (KeyError, ValueError, IndexError, OSError):
pass
if not bins.host_open_path(path):
QDesktopServices.openUrl(QUrl.fromLocalFile(path))
def on_spacePressed(self): def on_spacePressed(self):
item = self.treeTests.currentItem() item = self.treeTests.currentItem()

View File

@@ -7,6 +7,7 @@ from PySide6.QtGui import QCursor, QDesktopServices, QFont
from main_win.text_log_highlighter import TextLogHighlighter from main_win.text_log_highlighter import TextLogHighlighter
import api.testium as tm import api.testium as tm
from interpreter.utils import bins
class QTextLog(QPlainTextEdit): class QTextLog(QPlainTextEdit):
def __init__(self, parent): def __init__(self, parent):
@@ -65,7 +66,8 @@ class QTextLog(QPlainTextEdit):
self._test_dir = os.getcwd() self._test_dir = os.getcwd()
path = os.path.join(self._test_dir, path) path = os.path.join(self._test_dir, path)
if os.path.exists(path): if os.path.exists(path):
QDesktopServices.openUrl(QUrl.fromLocalFile(path)) if not bins.host_open_path(path):
QDesktopServices.openUrl(QUrl.fromLocalFile(path))
return # évite d'insérer du texte si clic return # évite d'insérer du texte si clic
super().mousePressEvent(event) super().mousePressEvent(event)

View File

@@ -47,13 +47,15 @@
{% endif %} {% endif %}
- read_until: {expected: terminal loaded, timeout: 5} - read_until: {expected: terminal loaded, timeout: 5}
# Echo two tokens on one line so both are buffered together; the immediate
# (timeout 0) reads below match buffered data with no race on the async prompt.
- console: - console:
name: Console write name: Console write
condition: <| $(conditional_exec) == 1 |> condition: <| $(conditional_exec) == 1 |>
console_name: consname console_name: consname
key: $(test)_PASS key: $(test)_PASS
steps: steps:
- writeln: echo 0 - writeln: echo ALPHA BETA
- sleep: - sleep:
name: sleep item name: sleep item
@@ -67,9 +69,9 @@
key: $(test)_PASS key: $(test)_PASS
steps: steps:
{% if os == "Windows" %} {% if os == "Windows" %}
- read_until: {expected: echo 0, timeout: 0} - read_until: {expected: echo ALPHA BETA, timeout: 0}
{% endif %} {% endif %}
- read_until: {expected: "0", timeout: 0} - read_until: {expected: ALPHA, timeout: 0}
- console: - console:
name: Console read_until immediate (2) name: Console read_until immediate (2)
@@ -77,7 +79,7 @@
console_name: consname console_name: consname
key: $(test)_PASS key: $(test)_PASS
steps: steps:
- read_until: {expected: "$(terminal_prompt)", timeout: 0} - read_until: {expected: BETA, timeout: 0}
- console: - console:
name: Console closure name: Console closure

View File

@@ -0,0 +1,9 @@
import py_func.tm as tm
def assert_captured():
"""The sub-run log stored by `run` via store_result must be in the gdict."""
log = tm.gd("captured_log", "")
assert "Test run success." in log, \
"captured sub-run log not reachable from the gdict (store_result)"
return 0

View File

@@ -1,25 +1,44 @@
# run item: launches a .tum file in a new testium instance. # run item: launches a .tum file in a new testium instance.
# In batch mode the sub-instance runs with -b; in GUI mode with -r. # Child mode: -b in batch / -r in the GUI, or forced -b (captured) by batch: true.
# The run item result is SUCCESS if the sub-instance launched successfully, # Result is SUCCESS if the sub-instance launched, regardless of its own result.
# regardless of its own test result. # log_file (GUI -r only) goes to the gitignored report dir to avoid repo litter.
- run: - run:
name: run PASS (valid file, passing sub-test) name: run PASS (valid file, passing sub-test)
key: $(test)_PASS key: $(test)_PASS
tum: $(test_path)$(psep)sub_pass.tum tum: $(test_path)$(psep)sub_pass.tum
log_file: $(validation_report_path)$(psep)run_sub.log
- run: - run:
name: run PASS (valid file, failing sub-test) name: run PASS (valid file, failing sub-test)
key: $(test)_PASS key: $(test)_PASS
tum: $(test_path)$(psep)sub_fail.tum tum: $(test_path)$(psep)sub_fail.tum
log_file: $(validation_report_path)$(psep)run_sub.log
- run: - run:
name: run FAIL (file not found) name: run FAIL (file not found)
key: $(test)_FAIL key: $(test)_FAIL
tum: $(test_path)$(psep)non_existent.tum tum: $(test_path)$(psep)non_existent.tum
log_file: $(validation_report_path)$(psep)run_sub.log
- run: - run:
name: run FAIL (wait_for_exec without time window) name: run FAIL (wait_for_exec without time window)
key: $(test)_FAIL key: $(test)_FAIL
tum: $(test_path)$(psep)sub_pass.tum tum: $(test_path)$(psep)sub_pass.tum
wait_for_exec: true wait_for_exec: true
log_file: $(validation_report_path)$(psep)run_sub.log
# batch: true forces a headless, captured sub-run even in the GUI; its log is
# kept as the result value and pushed to the gdict by store_result.
- run:
name: run batch (capture sub-run log to the gdict)
key: $(test)_PASS
tum: $(test_path)$(psep)sub_pass.tum
batch: true
store_result: captured_log
- py_func:
name: captured sub-run log is post-processable from the gdict
key: $(test)_PASS
file: $(test_path)$(psep)check_capture.py
func_name: assert_captured

View File

@@ -0,0 +1,9 @@
main:
name: root
steps:
- sleep:
name: ok
timeout: 0
# The structural error lives in the included file; the message must point
# the user at that file, not at this one.
- !include bad_include_inc.tum

View File

@@ -0,0 +1,4 @@
# Included as a bare list of steps. The unknown item below must be reported
# with THIS file as the location.
- frobnicate_in_include:
name: nope

View File

@@ -0,0 +1,6 @@
main:
name: root
steps:
# A container item (group) without its mandatory 'steps:' list.
- group:
name: g

View File

@@ -0,0 +1,5 @@
main:
name: root
steps:
# The body of an item must be a mapping of parameters, not a scalar.
- sleep: 5

View File

@@ -0,0 +1,5 @@
main:
name: root
steps:
# A step that is a bare scalar instead of a '<item>: ...' mapping.
- just some text

View File

@@ -0,0 +1,11 @@
main:
name: root
steps:
# Two items wrongly packed under a single '-' marker (a frequent indent
# mistake): the second key belongs one '-' lower.
- sleep:
name: s
timeout: 0
group:
name: g
steps: []

View File

@@ -0,0 +1,8 @@
main:
name: root
steps:
- console:
console_name: c1
steps:
- opens:
device: /dev/ttyUSB0

View File

@@ -0,0 +1,5 @@
main:
name: root
steps:
- frobnicate:
name: nope

View File

@@ -0,0 +1,87 @@
#!/usr/bin/env python3
"""Per-channel check of test-load error reporting.
Given the channel's testium invocation as argv (e.g. ``flatpak run
--command=testium org.testium.Testium``, a PyInstaller binary path, or
``python -m testium``), load each deliberately broken ``.tum`` under
``load_errors/`` in batch mode and verify that:
1. the load FAILS (non-zero exit), and
2. the output carries the *specific, located* message we expect — not a bare
Python traceback and not the generic 'crashed for any reason'.
This guards the load-time error handling in ``test_set.load_test_recursively``
and ``item_actions.load`` (a structural mistake in a ``.tum`` must always reach
the user as a readable ``TUM file syntax error`` naming the offending file,
item path and value). The historical failure mode was an unknown console
action crashing the error formatter itself with ``'dict_keys' object is not
subscriptable``.
Exits non-zero (with a diagnostic) on the first failure so the validation run
fails loudly. Used by ``run.sh`` before launching the main suite.
"""
import os
import re
import subprocess
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
FIXTURES = os.path.join(HERE, "load_errors")
# testium colourises its log; strip the ANSI escapes before matching messages.
_ANSI = re.compile(r"\x1b\[[0-9;]*m")
# fixture file -> substrings that must all appear in the load output.
CASES = [
("unknown_item.tum", ["TUM file syntax error", "is not a known test item",
"frobnicate", "Known items:"]),
("unknown_action.tum", ["unknown action", "opens", "Known actions:"]),
("two_steps.tum", ["must define exactly one test item"]),
("scalar_body.tum", ["body of test item 'sleep'", "must be a mapping"]),
("group_no_steps.tum", ["No 'steps' list found", "'group' item 'g'"]),
("step_not_mapping.tum", ["is not a valid test item"]),
# The error is inside the included file: the message must name that file.
("bad_include.tum", ["bad_include_inc.tum", "frobnicate_in_include",
"is not a known test item"]),
]
def fail(msg):
print(f"LOAD-ERROR CHECK: FAIL — {msg}", file=sys.stderr)
sys.exit(1)
def check_case(cmd, fixture, needles):
path = os.path.join(FIXTURES, fixture)
try:
out = subprocess.run(cmd + ["-b", path], capture_output=True, timeout=120)
except Exception as e: # noqa: BLE001
fail(f"`{' '.join(cmd)} -b {fixture}` could not run: {e}")
blob = _ANSI.sub("", (out.stdout + out.stderr).decode(errors="replace"))
if out.returncode == 0 or "Test run success." in blob:
fail(f"{fixture}: load was expected to fail but succeeded "
f"(exit {out.returncode}).")
# A raw Python traceback reaching the user is exactly what we are guarding
# against: every load error must be funnelled through a TUM*Error.
if "Traceback (most recent call last)" in blob:
fail(f"{fixture}: a raw Python traceback leaked to the user:\n"
f"{blob[-600:]}")
missing = [n for n in needles if n not in blob]
if missing:
fail(f"{fixture}: load message is missing {missing}.\n"
f"--- got ---\n{blob[-800:]}")
print(f"LOAD-ERROR CHECK: {fixture} OK")
def main():
cmd = sys.argv[1:]
if not cmd:
fail("usage: load_errors_check.py <testium-invocation...>")
for fixture, needles in CASES:
check_case(cmd, fixture, needles)
print("LOAD-ERROR CHECK: PASS")
if __name__ == "__main__":
main()

View File

@@ -3,11 +3,15 @@
# testium (source, wheel, pyinstaller, flatpak, appimage). # testium (source, wheel, pyinstaller, flatpak, appimage).
# #
# Usage: # Usage:
# ./test/validation/run.sh [clean] [--mode MODE] [extra testium args] # ./test/validation/run.sh [clean] [--mode MODE] [--gui] [extra testium args]
# #
# clean remove the validation venv before recreating it # clean remove the validation venv before recreating it
# (must be the first argument; useful after a Python upgrade) # (must be the first argument; useful after a Python upgrade)
# #
# --gui open the GUI with the suite loaded instead of running in
# batch; run it manually from the window, which stays open
# (handy to inspect the tree, try the Ctrl+F search, ...)
#
# --mode MODE which testium build to validate. One of: # --mode MODE which testium build to validate. One of:
# source (default) src/testium via project run.sh # source (default) src/testium via project run.sh
# wheel dist/testium-<v>-py3-none-any.whl # wheel dist/testium-<v>-py3-none-any.whl
@@ -45,6 +49,8 @@ else
fi fi
EXTRA=() EXTRA=()
RUN_FLAGS=(-b) # batch by default; --gui opens the GUI and stays open
GUI=0
while [ $# -gt 0 ]; do while [ $# -gt 0 ]; do
case "$1" in case "$1" in
--mode) --mode)
@@ -55,6 +61,11 @@ while [ $# -gt 0 ]; do
MODE="${1#--mode=}" MODE="${1#--mode=}"
shift shift
;; ;;
--gui)
GUI=1
RUN_FLAGS=() # no -b: launch the GUI with the suite loaded,
shift # run it manually; the window does not auto-close
;;
*) *)
EXTRA+=("$1") EXTRA+=("$1")
shift shift
@@ -147,7 +158,17 @@ echo "-- launch: ${CMD[*]}"
echo "-- LSP check ($MODE)" echo "-- LSP check ($MODE)"
"$VENV_PYTHON" "$SCRIPT_DIR/lsp_check.py" "${CMD[@]}" "$VENV_PYTHON" "$SCRIPT_DIR/lsp_check.py" "${CMD[@]}"
exec "${CMD[@]}" -b \ # ---------- load-error check (this exact channel) -----------------------------
# Deliberately broken .tum files must fail to load with a specific, located
# message (not a raw traceback): guards the load-time error handling.
echo "-- load-error check ($MODE)"
"$VENV_PYTHON" "$SCRIPT_DIR/load_errors_check.py" "${CMD[@]}"
if [ "$GUI" -eq 1 ]; then
echo "-- GUI mode: the suite is loaded; press Start to run. Window stays open."
fi
exec "${CMD[@]}" "${RUN_FLAGS[@]}" \
-d "python_bin=$VENV_PYTHON" \ -d "python_bin=$VENV_PYTHON" \
-d "validation_report_file=validation-$MODE" \ -d "validation_report_file=validation-$MODE" \
-- "$SCRIPT_DIR/main.tum" "${EXTRA[@]}" -- "$SCRIPT_DIR/main.tum" "${EXTRA[@]}"