11 Commits

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
16 changed files with 344 additions and 411 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,3 +1,21 @@
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 version 0.3.1
============== ==============
- Clearer errors when a test file fails to load. The message now names the - Clearer errors when a test file fails to load. The message now names the

View File

@@ -1 +1 @@
0.3.1 0.3.2

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

@@ -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

@@ -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,10 +1,7 @@
# 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.
#
# log_file points the sub-instance log at the throwaway report dir (gitignored)
# so a GUI run does not litter the repo with sub_*.log files.
- run: - run:
name: run PASS (valid file, passing sub-test) name: run PASS (valid file, passing sub-test)
@@ -30,3 +27,18 @@
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 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