Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9171abc3ba | |||
| 4a72fe019e | |||
| b5b8198c29 | |||
| c313e1431b | |||
| 7edfc25a1f | |||
| 7a732c0d04 | |||
| f62ea10d24 | |||
| 51068c881f | |||
| 83475dd215 | |||
| 4fe23518a0 | |||
| 87e62a7f2e | |||
| 5b5792a296 | |||
| 087aa93a16 | |||
| 7abd8c07a6 | |||
| 1ea360e5a5 | |||
| d5154348f6 | |||
| 6dc473de41 |
@@ -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).
|
||||
|
||||
### `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)
|
||||
- **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.
|
||||
|
||||
## 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)".
|
||||
- `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.
|
||||
|
||||
@@ -4,7 +4,13 @@
|
||||
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 **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,
|
||||
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:
|
||||
name: Execute TUM
|
||||
tum: example_cycle.tum
|
||||
python_bin: python3
|
||||
log_file: $(home)/reports/test.log
|
||||
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.
|
||||
* ``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.
|
||||
* ``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.
|
||||
* ``batch`` (optional): ``true`` to run the sub-instance headless (``-b``) and capture its output even in GUI mode (see above).
|
||||
* ``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).
|
||||
* ``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.
|
||||
* ``end_time`` (optional): latest time for execution within a time frame, in ``HH:MM`` format.
|
||||
|
||||
@@ -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
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$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.
|
||||
$exe = Join-Path $scriptDir '..\pyinstaller\dist\testium.exe'
|
||||
if (-not (Test-Path $exe)) {
|
||||
throw "PyInstaller build not found: $exe`nRun package\pyinstaller\build first."
|
||||
# Locate PyInstaller: PATH first, then the known project venvs.
|
||||
$pyi = (Get-Command pyinstaller.exe -ErrorAction SilentlyContinue).Source
|
||||
if (-not $pyi) {
|
||||
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
|
||||
if (-not $iscc) {
|
||||
foreach ($p in @(
|
||||
|
||||
@@ -49,9 +49,12 @@ Name: "english"; MessagesFile: "compiler:Default.isl"
|
||||
Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked
|
||||
; PATH off by default: the exe is windowed (console=False), so CLI shows no output.
|
||||
Name: "addtopath"; Description: "Ajouter Testium au PATH (usage en ligne de commande)"; Flags: unchecked
|
||||
; 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]
|
||||
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.
|
||||
Source: "..\testium.ico"; DestDir: "{app}"; Flags: ignoreversion
|
||||
|
||||
@@ -67,6 +70,54 @@ Filename: "{app}\{#MyAppExeName}"; Description: "{cm:LaunchProgram,{#MyAppName}}
|
||||
[Code]
|
||||
const
|
||||
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.
|
||||
function NeedsAddPath(Param: string): Boolean;
|
||||
@@ -97,6 +148,8 @@ begin
|
||||
Path := Path + ExpandConstant('{app}');
|
||||
RegWriteStringValue(HKEY_CURRENT_USER, EnvKey, 'Path', Path);
|
||||
end;
|
||||
if WizardIsTaskSelected('removeold') then
|
||||
RemoveOtherVersions();
|
||||
end;
|
||||
end;
|
||||
|
||||
|
||||
@@ -79,26 +79,60 @@ a = Analysis(
|
||||
)
|
||||
pyz = PYZ(a.pure)
|
||||
|
||||
exe = EXE(
|
||||
pyz,
|
||||
a.scripts,
|
||||
a.binaries,
|
||||
a.datas,
|
||||
[],
|
||||
name='testium',
|
||||
debug=False,
|
||||
bootloader_ignore_signals=False,
|
||||
strip=False,
|
||||
# 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).
|
||||
upx=not os.environ.get("TESTIUM_NO_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'
|
||||
)
|
||||
# TESTIUM_ONEDIR=1 => one-folder build (fast startup), used by the Windows
|
||||
# installer; default one-file keeps the Linux build_all portable binary.
|
||||
ONEDIR = bool(os.environ.get("TESTIUM_ONEDIR"))
|
||||
# UPX skipped via TESTIUM_NO_UPX (build_all --ram) — slow for a marginal gain.
|
||||
_upx = not os.environ.get("TESTIUM_NO_UPX")
|
||||
|
||||
if ONEDIR:
|
||||
exe = EXE(
|
||||
pyz,
|
||||
a.scripts,
|
||||
[],
|
||||
exclude_binaries=True,
|
||||
name='testium',
|
||||
debug=False,
|
||||
bootloader_ignore_signals=False,
|
||||
strip=False,
|
||||
upx=_upx,
|
||||
upx_exclude=[],
|
||||
console=False,
|
||||
disable_windowed_traceback=False,
|
||||
argv_emulation=False,
|
||||
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'
|
||||
)
|
||||
|
||||
@@ -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
|
||||
==============
|
||||
- New ``pytest`` test item: run your pytest files as a test step; each
|
||||
|
||||
@@ -1 +1 @@
|
||||
0.3
|
||||
0.3.2
|
||||
|
||||
@@ -39,20 +39,36 @@ class TestItemActions(TestItem):
|
||||
|
||||
def load(self):
|
||||
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:
|
||||
# Action should be only dict of length 1
|
||||
if not isinstance(action, dict) or (not len(action) == 1):
|
||||
# Each action must be a single-key mapping ``{action_name: {...}}``.
|
||||
if not isinstance(action, dict) or len(action) != 1:
|
||||
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()
|
||||
)
|
||||
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(
|
||||
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()
|
||||
)
|
||||
|
||||
# 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])(
|
||||
action_name,
|
||||
action[action_name],
|
||||
|
||||
@@ -11,6 +11,7 @@ from interpreter.test_items.test_result import (TestValue)
|
||||
import api.testium as tm
|
||||
from interpreter.utils.constants import TestItemType as cst
|
||||
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
|
||||
|
||||
|
||||
@@ -75,6 +76,9 @@ class TestItemRun(TestItem):
|
||||
Param("wait_for_exec",
|
||||
doc="If true, block until the time window opens. Requires both "
|
||||
"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=""):
|
||||
@@ -90,6 +94,38 @@ class TestItemRun(TestItem):
|
||||
self.start_time = self._prms.getParam('start_time')
|
||||
self.end_time = self._prms.getParam('end_time')
|
||||
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
|
||||
def execute(self):
|
||||
@@ -104,25 +140,26 @@ class TestItemRun(TestItem):
|
||||
pf = self._prms.expanse(self.param_file)
|
||||
lp = self._prms.expanse(self.log_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()
|
||||
if tm.text_mode():
|
||||
cmd.append("-b")
|
||||
if capture:
|
||||
cmd += ["-b", "-o"] # -o: no colour codes in the captured log
|
||||
else:
|
||||
cmd.append("-r")
|
||||
if lp == '':
|
||||
lp = os.path.splitext(self.tum_file)[0] + "_" + \
|
||||
datetime.utcnow().isoformat(timespec='seconds') + '.log'
|
||||
cmd.append("-l")
|
||||
cmd.append('"' + lp + '"')
|
||||
cmd += ["-l", '"' + lp + '"']
|
||||
if pf != '':
|
||||
cmd.append("-c")
|
||||
cmd.append('"' + pf + '"')
|
||||
cmd += ["-c", '"' + pf + '"']
|
||||
if rp != '':
|
||||
cmd.append("-p")
|
||||
cmd.append('"' + rp + '"')
|
||||
cmd += ["-p", '"' + rp + '"']
|
||||
cmd.append(self.tum_file)
|
||||
for c in cmd:
|
||||
print(c, end = ' ')
|
||||
print(" ".join(cmd))
|
||||
|
||||
if self.start_time is not None:
|
||||
self.start_time = datetime.strptime(
|
||||
@@ -135,20 +172,24 @@ class TestItemRun(TestItem):
|
||||
raise ETUMRuntimeError(
|
||||
'"wait_for_exec" set but not start_time or end_time')
|
||||
|
||||
r = None
|
||||
ran = False
|
||||
if self.wait_for_exec:
|
||||
while not nowInBetween(self.start_time, self.end_time):
|
||||
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:
|
||||
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:
|
||||
if self.start_time < datetime.now().time():
|
||||
r = subprocess.run(cmd)
|
||||
self._launch(cmd, capture)
|
||||
ran = True
|
||||
else:
|
||||
r = subprocess.run(cmd)
|
||||
if isinstance(r, subprocess.CompletedProcess):
|
||||
self._launch(cmd, capture)
|
||||
ran = True
|
||||
if ran:
|
||||
self.result.set(TestValue.SUCCESS)
|
||||
else:
|
||||
self.result.set(TestValue.FAILURE, 'Sub-test did not execute')
|
||||
|
||||
@@ -3,7 +3,7 @@ import datetime
|
||||
from queue import Queue
|
||||
from interpreter.utils.params import expanse
|
||||
import api.testium as tm
|
||||
from runtime.tum_except import ETUMSyntaxError
|
||||
from runtime.tum_except import ETUMSyntaxError, ETUMError
|
||||
import interpreter.utils.settings as prefs
|
||||
from interpreter.test_report.test_report import TestReport
|
||||
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
|
||||
)
|
||||
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:
|
||||
if isinstance(s, dict) and s:
|
||||
s[list(s.keys())[0]]["seq_filename"] = f
|
||||
# Propagate the source filename onto each included step. Only a
|
||||
# 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)
|
||||
continue
|
||||
|
||||
@@ -390,7 +403,19 @@ class TestSet:
|
||||
self._rootItem = (cst_type.TYPE_ROOT.item_class)(
|
||||
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()
|
||||
return ret
|
||||
|
||||
@@ -467,30 +492,43 @@ class TestSet:
|
||||
|
||||
def load_test_recursively(self, tree_parent, parent_seq, file_name):
|
||||
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:
|
||||
parent_seq_name = parent_seq["name"]
|
||||
except KeyError:
|
||||
parent_seq["name"] = "sequence"
|
||||
except TypeError:
|
||||
raise ETUMSyntaxError(
|
||||
f"No 'name' attribute in '{tree_parent.type()}' (a child of '{tree_parent.parent().name()}')",
|
||||
file_name
|
||||
)
|
||||
parent_seq_name = "sequence"
|
||||
try:
|
||||
parent_seq_actions = parent_seq["steps"]
|
||||
except KeyError:
|
||||
raise ETUMSyntaxError(
|
||||
f"No step list found for '{parent_seq_name}' sequence. \n" +
|
||||
f"Check the syntax of the 'steps' parameter of the '{tree_parent.cmd()}' test item definition.",
|
||||
f"In: {path}\n"
|
||||
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
|
||||
)
|
||||
# if action is a dictionary , we assume it is a single action
|
||||
# that has not been nested in a list, so do it
|
||||
if isinstance(parent_seq_actions, (dict)):
|
||||
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)):
|
||||
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
|
||||
)
|
||||
test_dir = tm.gd("test_directory")
|
||||
@@ -502,10 +540,50 @@ class TestSet:
|
||||
_flatten_actions(parent_seq_actions, flat_actions, parent_seq_name)
|
||||
|
||||
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]
|
||||
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
|
||||
for it in TEST_TYPE_LIST:
|
||||
@@ -517,32 +595,18 @@ class TestSet:
|
||||
(it.item_class is None)
|
||||
):
|
||||
continue
|
||||
if (it.item_cmd in action) or (
|
||||
(cst.FOLDED_CHAR + it.item_cmd) in action
|
||||
):
|
||||
executed = True
|
||||
is_folded = False
|
||||
action_name = it.item_cmd
|
||||
|
||||
# Check if a "." is before the cmd_name (meaning folded)
|
||||
if (cst.FOLDED_CHAR + it.item_cmd) in action:
|
||||
is_folded = True
|
||||
action_name = cst.FOLDED_CHAR + it.item_cmd
|
||||
|
||||
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
|
||||
if k not in (it.item_cmd, cst.FOLDED_CHAR + it.item_cmd):
|
||||
continue
|
||||
executed = True
|
||||
# A "." before the cmd name means the item is folded in the GUI
|
||||
is_folded = k.startswith(cst.FOLDED_CHAR)
|
||||
try:
|
||||
item = (it.item_class)(
|
||||
body,
|
||||
tree_parent,
|
||||
self.status_queue,
|
||||
filename=seq_filename
|
||||
)
|
||||
item.is_folded = is_folded
|
||||
child = {}
|
||||
# case where the test item loads itself its descendants
|
||||
@@ -554,15 +618,42 @@ class TestSet:
|
||||
# case where the test item is an items container
|
||||
elif item.is_container:
|
||||
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:
|
||||
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(
|
||||
f"test item '{k}' is not known.",
|
||||
action[k]["seq_filename"]
|
||||
f"In: {path}\n"
|
||||
f"'{k}' is not a known test item.\n"
|
||||
f"Known items: {known}.",
|
||||
seq_filename
|
||||
)
|
||||
|
||||
return ret
|
||||
|
||||
@@ -199,6 +199,23 @@ def host_console_command(shell_cmd, cwd):
|
||||
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):
|
||||
"""Resolve a binary name (or absolute path) on the host via flatpak-spawn.
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ from time import monotonic
|
||||
from runtime.jrpc import RPC_PORT_SENTINEL
|
||||
|
||||
|
||||
def _drain_pipe(pipe, prefix):
|
||||
def _drain_pipe(pipe, prefix, sink=None):
|
||||
try:
|
||||
for raw in iter(pipe.readline, b""):
|
||||
line = raw.decode("utf-8", errors="replace").rstrip("\r\n")
|
||||
@@ -23,6 +23,9 @@ def _drain_pipe(pipe, prefix):
|
||||
print(f"{prefix}{line}")
|
||||
else:
|
||||
print(line)
|
||||
# sink keeps the clean (unprefixed) line for reuse as a result value
|
||||
if sink is not None:
|
||||
sink.append(line)
|
||||
finally:
|
||||
try:
|
||||
pipe.close()
|
||||
@@ -30,21 +33,16 @@ def _drain_pipe(pipe, prefix):
|
||||
pass
|
||||
|
||||
|
||||
def drain_to_log(process, prefix=""):
|
||||
"""Spawn daemon threads that read ``process.stdout`` and
|
||||
``process.stderr`` line by line and print each line through the
|
||||
parent's stdout (so it reaches the log + live output).
|
||||
|
||||
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.
|
||||
"""
|
||||
def drain_to_log(process, prefix="", sink=None):
|
||||
"""Stream the subprocess stdout/stderr line by line through the parent's
|
||||
print pipeline (log + live output). If ``sink`` is a list, each clean line
|
||||
is also appended to it (GIL-atomic, shared by both threads). Daemon threads."""
|
||||
threads = []
|
||||
for pipe in (process.stdout, process.stderr):
|
||||
if pipe is None:
|
||||
continue
|
||||
t = threading.Thread(
|
||||
target=_drain_pipe, args=(pipe, prefix), daemon=True,
|
||||
target=_drain_pipe, args=(pipe, prefix, sink), daemon=True,
|
||||
)
|
||||
t.start()
|
||||
threads.append(t)
|
||||
|
||||
@@ -13,30 +13,59 @@ def init():
|
||||
settings = TestiumSettings()
|
||||
|
||||
|
||||
_UNSET = object()
|
||||
|
||||
|
||||
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.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():
|
||||
SettingsRecentFiles = SettingsItem('recentFileList', list)
|
||||
SettingsLastLogFile = SettingsItem('lastLogFile', str)
|
||||
SettingsLogFileSaved = SettingsItem('logFileSaved', bool)
|
||||
SettingsHideDocPane = SettingsItem('docPaneHidden', bool)
|
||||
SettingsHideLogPane = SettingsItem('logPaneHidden', bool)
|
||||
SettingsShowCheckboxes = SettingsItem('checkBoxesShow', bool)
|
||||
SettingsLogPath = SettingsItem('defaultLogPath', str)
|
||||
SettingsReportPath = SettingsItem('defaultReportPath', str)
|
||||
SettingsShowTimeColumn = SettingsItem('showTimeColumn', bool)
|
||||
SettingsColumnsSize = SettingsItem('columnsSize', dict)
|
||||
SettingsDblClickEnabled = SettingsItem('dblClickEnabled', bool)
|
||||
SettingsIconsTheme = SettingsItem('iconsTheme', int)
|
||||
SettingsLogFont = SettingsItem('logFont', str)
|
||||
SettingsLogFontSize = SettingsItem('logFontSize', int)
|
||||
SettingsGitSupported = SettingsItem('logGitSupported', bool)
|
||||
SettingsPythonPath = SettingsItem('pythonPath', str)
|
||||
SettingsLuaPath = SettingsItem('luaPath', str)
|
||||
SettingsRecentFiles = SettingsItem('recentFileList', list, [])
|
||||
SettingsLastLogFile = SettingsItem('lastLogFile', str, '')
|
||||
SettingsLogFileSaved = SettingsItem('logFileSaved', bool, False)
|
||||
SettingsHideDocPane = SettingsItem('docPaneHidden', bool, False)
|
||||
SettingsHideLogPane = SettingsItem('logPaneHidden', bool, False)
|
||||
SettingsShowCheckboxes = SettingsItem('checkBoxesShow', bool, False)
|
||||
SettingsLogPath = SettingsItem('defaultLogPath', str, '$(test_directory)')
|
||||
SettingsReportPath = SettingsItem('defaultReportPath', str, '$(test_directory)')
|
||||
SettingsShowTimeColumn = SettingsItem('showTimeColumn', bool, False)
|
||||
SettingsColumnsSize = SettingsItem('columnsSize', dict, {})
|
||||
SettingsDblClickEnabled = SettingsItem('dblClickEnabled', bool, False)
|
||||
SettingsEditorCmd = SettingsItem('editorCmd', str, 'code -g {file}:{line}')
|
||||
SettingsIconsTheme = SettingsItem('iconsTheme', int, 0)
|
||||
SettingsLogFont = SettingsItem('logFont', str, 'Monospace')
|
||||
SettingsLogFontSize = SettingsItem('logFontSize', int, 8)
|
||||
SettingsGitSupported = SettingsItem('logGitSupported', bool, True)
|
||||
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):
|
||||
if 'windows' in platform.system().lower():
|
||||
@@ -71,9 +100,11 @@ class TestiumSettings():
|
||||
self.conf['Default'] = {}
|
||||
self.sync()
|
||||
|
||||
def value(self, key: SettingsItem, default=''):
|
||||
def value(self, key: SettingsItem, default=_UNSET):
|
||||
if not isinstance(key, SettingsItem):
|
||||
raise ETUMRuntimeError('Not a proper Settings item.')
|
||||
if default is _UNSET:
|
||||
default = key.default
|
||||
if type(default) != key.t:
|
||||
raise ETUMRuntimeError(
|
||||
'Types mismatch in config file. You could try to erase "{}" to solve the issue'.format(self.settings_fname))
|
||||
@@ -120,161 +151,14 @@ class TestiumSettings():
|
||||
if configfile.writable():
|
||||
self.conf.write(configfile)
|
||||
|
||||
# SettingsRecentFiles = 'recentFileList'
|
||||
@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'
|
||||
# log_font_size keeps a custom getter: clamp non-positive sizes to 8.
|
||||
@property
|
||||
def log_font_size(self):
|
||||
v = self.value(self.SettingsLogFontSize, 8)
|
||||
v = self.value(self.SettingsLogFontSize)
|
||||
if v <= 0:
|
||||
v = 8
|
||||
return v
|
||||
|
||||
@log_font_size.setter
|
||||
def log_font_size(self, 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)
|
||||
self.set_value(self.SettingsLogFontSize, value)
|
||||
@@ -6,13 +6,14 @@ import subprocess
|
||||
import sys
|
||||
|
||||
from PySide6.QtWidgets import (
|
||||
QDialog, QDialogButtonBox, QHeaderView, QMenu, QMessageBox,
|
||||
QPushButton, QTextEdit, QVBoxLayout,
|
||||
QCheckBox, QDialog, QDialogButtonBox, QHBoxLayout, QHeaderView, QLineEdit,
|
||||
QMenu, QMessageBox, QPushButton, QTextEdit, QVBoxLayout,
|
||||
)
|
||||
from PySide6.QtGui import QSyntaxHighlighter, QTextCharFormat, QColor, QFont, QDesktopServices
|
||||
from PySide6.QtCore import Qt, QUrl, Slot
|
||||
|
||||
from main_win.f1_win.f1_win_core import Ui_F1Dialog
|
||||
from interpreter.utils import bins
|
||||
|
||||
|
||||
class YamlHighlighter(QSyntaxHighlighter):
|
||||
@@ -119,6 +120,43 @@ class DialogF1(QDialog):
|
||||
self.ui.addVarButton.setEnabled(False)
|
||||
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):
|
||||
for key, value in vars_dict.items():
|
||||
self.gd_var_updated(key, value)
|
||||
@@ -149,6 +187,7 @@ class DialogF1(QDialog):
|
||||
self._updating = False
|
||||
self._key_rows[key] = row
|
||||
self._refresh_row(row, key, value)
|
||||
self._apply_filter_row(self._key_rows[key])
|
||||
|
||||
@Slot(str)
|
||||
def gd_var_deleted(self, key):
|
||||
@@ -161,6 +200,7 @@ class DialogF1(QDialog):
|
||||
finally:
|
||||
self._updating = False
|
||||
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):
|
||||
from PySide6.QtWidgets import QTableWidgetItem
|
||||
@@ -265,9 +305,11 @@ class DialogF1(QDialog):
|
||||
|
||||
def on_butlocopen_click(self):
|
||||
file = self.ui.sequenceFileNameLineEdit.text()
|
||||
if os.path.exists(file):
|
||||
if sys.platform.startswith("win"):
|
||||
subprocess.Popen(f'explorer "{file}"')
|
||||
else:
|
||||
subprocess.Popen(["xdg-open", file])
|
||||
if not os.path.exists(file):
|
||||
return
|
||||
if bins.host_open_path(file):
|
||||
return
|
||||
if sys.platform.startswith("win"):
|
||||
subprocess.Popen(f'explorer "{file}"')
|
||||
else:
|
||||
QDesktopServices.openUrl(QUrl.fromLocalFile(file))
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from PySide6.QtCore import Slot, Qt
|
||||
from PySide6.QtWidgets import QDialog, QFileDialog
|
||||
from collections import namedtuple
|
||||
|
||||
from PySide6.QtCore import Slot
|
||||
from PySide6.QtWidgets import QDialog, QFileDialog, QLabel, QLineEdit
|
||||
from PySide6.QtGui import QFont
|
||||
|
||||
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
|
||||
|
||||
|
||||
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):
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
@@ -17,162 +37,57 @@ class PrefWindow(QDialog):
|
||||
self.ui.buttonBox.accepted.connect(self.on_buttOKPressed)
|
||||
self.ui.buttonBox.rejected.connect(self.on_buttCancelPressed)
|
||||
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()
|
||||
|
||||
def store_prefs(self):
|
||||
for k, v in self.elements.items():
|
||||
self.elements[k]["changed"] = False
|
||||
if v["type"] == "bool":
|
||||
val = v["widget"].isChecked()
|
||||
if self.elements[k]["value"] != val:
|
||||
self.elements[k]["value"] = val
|
||||
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"])
|
||||
|
||||
self._changed = set()
|
||||
for f in self.fields:
|
||||
val = _FIELD[f.type][0](f.widget)
|
||||
if val != prefs.settings.value(f.key):
|
||||
prefs.settings.set_value(f.key, val)
|
||||
self._changed.add(f.key.name)
|
||||
prefs.settings.sync()
|
||||
|
||||
def restore_prefs(self):
|
||||
for k, v in self.elements.items():
|
||||
v["value"] = prefs.settings.value(k, v["default"])
|
||||
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"])
|
||||
for f in self.fields:
|
||||
_FIELD[f.type][1](f.widget, prefs.settings.value(f.key))
|
||||
|
||||
def isChanged(self, setting):
|
||||
return self.elements[setting]["changed"]
|
||||
return setting.name in self._changed
|
||||
|
||||
@Slot()
|
||||
def on_buttOKPressed(self):
|
||||
@@ -188,46 +103,14 @@ class PrefWindow(QDialog):
|
||||
def on_finishedPressed(self):
|
||||
self.restore_prefs()
|
||||
|
||||
@Slot()
|
||||
def on_butReportPath_pressed(self):
|
||||
def _pick_dir(self, edit, caption):
|
||||
path = QFileDialog.getExistingDirectory(
|
||||
self,
|
||||
caption="Select the default report directory",
|
||||
dir=self.ui.editDefaultReportPath.text(),
|
||||
options=file_dialog.options(),
|
||||
)
|
||||
self, caption=caption, dir=edit.text(), options=file_dialog.options())
|
||||
if path:
|
||||
self.ui.editDefaultReportPath.setText(path)
|
||||
edit.setText(path)
|
||||
|
||||
@Slot()
|
||||
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):
|
||||
def _pick_file(self, edit, caption):
|
||||
path, _ = QFileDialog.getOpenFileName(
|
||||
self,
|
||||
caption="Select the python interpreter",
|
||||
dir=self.ui.editPythonPath.text(),
|
||||
options=file_dialog.options(),
|
||||
)
|
||||
self, caption=caption, dir=edit.text(), options=file_dialog.options())
|
||||
if path:
|
||||
self.ui.editPythonPath.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)
|
||||
edit.setText(path)
|
||||
|
||||
@@ -127,7 +127,8 @@ class TestFileManager:
|
||||
del w.ts_controller
|
||||
w.ts_controller = None
|
||||
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…")
|
||||
|
||||
@@ -181,7 +181,8 @@ class TestRunner:
|
||||
w.actionStart_test.setText("Pause test")
|
||||
w.actionPreferences.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.logSettingsBox.setDisabled(True)
|
||||
w.actionStop_test.setEnabled(True)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import sys
|
||||
import os
|
||||
import shlex
|
||||
import subprocess
|
||||
import webbrowser
|
||||
from multiprocessing import Queue
|
||||
from threading import Thread
|
||||
@@ -40,6 +42,7 @@ from runtime.string_queue import StringQueue
|
||||
from interpreter.process import TestProcess
|
||||
from interpreter.utils.test_ctrl import TestSetController
|
||||
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.test_run import ThreadTestStatus
|
||||
@@ -639,7 +642,8 @@ class MainWindow(QMainWindow, Ui_MainWindow):
|
||||
self.statusBar().showMessage(
|
||||
"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()
|
||||
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):
|
||||
ln = tm.line_number("@@{}@@".format(item.timestamp()), self.logFileName)
|
||||
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):
|
||||
item = self.treeTests.currentItem()
|
||||
|
||||
@@ -7,6 +7,7 @@ from PySide6.QtGui import QCursor, QDesktopServices, QFont
|
||||
from main_win.text_log_highlighter import TextLogHighlighter
|
||||
|
||||
import api.testium as tm
|
||||
from interpreter.utils import bins
|
||||
|
||||
class QTextLog(QPlainTextEdit):
|
||||
def __init__(self, parent):
|
||||
@@ -65,7 +66,8 @@ class QTextLog(QPlainTextEdit):
|
||||
self._test_dir = os.getcwd()
|
||||
path = os.path.join(self._test_dir, 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
|
||||
super().mousePressEvent(event)
|
||||
|
||||
|
||||
@@ -47,13 +47,15 @@
|
||||
{% endif %}
|
||||
- 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:
|
||||
name: Console write
|
||||
condition: <| $(conditional_exec) == 1 |>
|
||||
console_name: consname
|
||||
key: $(test)_PASS
|
||||
steps:
|
||||
- writeln: echo 0
|
||||
- writeln: echo ALPHA BETA
|
||||
|
||||
- sleep:
|
||||
name: sleep item
|
||||
@@ -67,9 +69,9 @@
|
||||
key: $(test)_PASS
|
||||
steps:
|
||||
{% if os == "Windows" %}
|
||||
- read_until: {expected: echo 0, timeout: 0}
|
||||
- read_until: {expected: echo ALPHA BETA, timeout: 0}
|
||||
{% endif %}
|
||||
- read_until: {expected: "0", timeout: 0}
|
||||
- read_until: {expected: ALPHA, timeout: 0}
|
||||
|
||||
- console:
|
||||
name: Console read_until immediate (2)
|
||||
@@ -77,7 +79,7 @@
|
||||
console_name: consname
|
||||
key: $(test)_PASS
|
||||
steps:
|
||||
- read_until: {expected: "$(terminal_prompt)", timeout: 0}
|
||||
- read_until: {expected: BETA, timeout: 0}
|
||||
|
||||
- console:
|
||||
name: Console closure
|
||||
|
||||
9
test/validation/items/run/check_capture.py
Normal file
9
test/validation/items/run/check_capture.py
Normal 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
|
||||
@@ -1,25 +1,44 @@
|
||||
# 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.
|
||||
# The run item result is SUCCESS if the sub-instance launched successfully,
|
||||
# regardless of its own test result.
|
||||
# Child mode: -b in batch / -r in the GUI, or forced -b (captured) by batch: true.
|
||||
# Result is SUCCESS if the sub-instance launched, regardless of its own result.
|
||||
# log_file (GUI -r only) goes to the gitignored report dir to avoid repo litter.
|
||||
|
||||
- run:
|
||||
name: run PASS (valid file, passing sub-test)
|
||||
key: $(test)_PASS
|
||||
tum: $(test_path)$(psep)sub_pass.tum
|
||||
log_file: $(validation_report_path)$(psep)run_sub.log
|
||||
|
||||
- run:
|
||||
name: run PASS (valid file, failing sub-test)
|
||||
key: $(test)_PASS
|
||||
tum: $(test_path)$(psep)sub_fail.tum
|
||||
log_file: $(validation_report_path)$(psep)run_sub.log
|
||||
|
||||
- run:
|
||||
name: run FAIL (file not found)
|
||||
key: $(test)_FAIL
|
||||
tum: $(test_path)$(psep)non_existent.tum
|
||||
log_file: $(validation_report_path)$(psep)run_sub.log
|
||||
|
||||
- run:
|
||||
name: run FAIL (wait_for_exec without time window)
|
||||
key: $(test)_FAIL
|
||||
tum: $(test_path)$(psep)sub_pass.tum
|
||||
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
|
||||
|
||||
9
test/validation/load_errors/bad_include.tum
Normal file
9
test/validation/load_errors/bad_include.tum
Normal 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
|
||||
4
test/validation/load_errors/bad_include_inc.tum
Normal file
4
test/validation/load_errors/bad_include_inc.tum
Normal 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
|
||||
6
test/validation/load_errors/group_no_steps.tum
Normal file
6
test/validation/load_errors/group_no_steps.tum
Normal file
@@ -0,0 +1,6 @@
|
||||
main:
|
||||
name: root
|
||||
steps:
|
||||
# A container item (group) without its mandatory 'steps:' list.
|
||||
- group:
|
||||
name: g
|
||||
5
test/validation/load_errors/scalar_body.tum
Normal file
5
test/validation/load_errors/scalar_body.tum
Normal 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
|
||||
5
test/validation/load_errors/step_not_mapping.tum
Normal file
5
test/validation/load_errors/step_not_mapping.tum
Normal file
@@ -0,0 +1,5 @@
|
||||
main:
|
||||
name: root
|
||||
steps:
|
||||
# A step that is a bare scalar instead of a '<item>: ...' mapping.
|
||||
- just some text
|
||||
11
test/validation/load_errors/two_steps.tum
Normal file
11
test/validation/load_errors/two_steps.tum
Normal 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: []
|
||||
8
test/validation/load_errors/unknown_action.tum
Normal file
8
test/validation/load_errors/unknown_action.tum
Normal file
@@ -0,0 +1,8 @@
|
||||
main:
|
||||
name: root
|
||||
steps:
|
||||
- console:
|
||||
console_name: c1
|
||||
steps:
|
||||
- opens:
|
||||
device: /dev/ttyUSB0
|
||||
5
test/validation/load_errors/unknown_item.tum
Normal file
5
test/validation/load_errors/unknown_item.tum
Normal file
@@ -0,0 +1,5 @@
|
||||
main:
|
||||
name: root
|
||||
steps:
|
||||
- frobnicate:
|
||||
name: nope
|
||||
87
test/validation/load_errors_check.py
Normal file
87
test/validation/load_errors_check.py
Normal 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()
|
||||
@@ -3,11 +3,15 @@
|
||||
# testium (source, wheel, pyinstaller, flatpak, appimage).
|
||||
#
|
||||
# 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
|
||||
# (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:
|
||||
# source (default) src/testium via project run.sh
|
||||
# wheel dist/testium-<v>-py3-none-any.whl
|
||||
@@ -45,6 +49,8 @@ else
|
||||
fi
|
||||
|
||||
EXTRA=()
|
||||
RUN_FLAGS=(-b) # batch by default; --gui opens the GUI and stays open
|
||||
GUI=0
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--mode)
|
||||
@@ -55,6 +61,11 @@ while [ $# -gt 0 ]; do
|
||||
MODE="${1#--mode=}"
|
||||
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")
|
||||
shift
|
||||
@@ -147,7 +158,17 @@ echo "-- launch: ${CMD[*]}"
|
||||
echo "-- LSP check ($MODE)"
|
||||
"$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 "validation_report_file=validation-$MODE" \
|
||||
-- "$SCRIPT_DIR/main.tum" "${EXTRA[@]}"
|
||||
|
||||
Reference in New Issue
Block a user