6 Commits
v0.3 ... v0.3.1

Author SHA1 Message Date
5b5792a296 Merge branch 'main' of ssh://cahute.beafrancois.fr:8327/v-and-v/testium 2026-06-15 14:40:50 +02:00
087aa93a16 chore(release): 0.3.1
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 14:40:36 +02:00
7abd8c07a6 test(validation): negative load-error checks; keep run logs out of the repo
load_errors_check.py loads deliberately broken .tum fixtures in batch on the
build under test (like lsp_check.py) and asserts each fails with its specific
located message and without a raw traceback. Wired into run.sh just before the
main suite, so it runs for every channel.

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

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

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

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

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

View File

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

View File

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

View File

@@ -79,6 +79,42 @@ a = Analysis(
) )
pyz = PYZ(a.pure) pyz = PYZ(a.pure)
# 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( exe = EXE(
pyz, pyz,
a.scripts, a.scripts,
@@ -89,9 +125,7 @@ exe = EXE(
debug=False, debug=False,
bootloader_ignore_signals=False, bootloader_ignore_signals=False,
strip=False, strip=False,
# UPX is CPU+IO heavy for a marginal size gain — build_all --ram sets upx=_upx,
# TESTIUM_NO_UPX=1 to skip it (much faster on slow/flash storage).
upx=not os.environ.get("TESTIUM_NO_UPX"),
upx_exclude=[], upx_exclude=[],
runtime_tmpdir=None, runtime_tmpdir=None,
console=False, console=False,

View File

@@ -1,3 +1,11 @@
version 0.3.1
==============
- Clearer errors when a test file fails to load. The message now names the
exact file and item and explains the problem (unknown item or action, a
step holding two items, a missing ``steps:`` list, a misplaced value, ...)
and lists the valid names, instead of a cryptic failure. A problem inside
an ``!include``-d file points to that file.
version 0.3 version 0.3
============== ==============
- New ``pytest`` test item: run your pytest files as a test step; each - New ``pytest`` test item: run your pytest files as a test step; each

View File

@@ -1 +1 @@
0.3 0.3.1

View File

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

View File

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

View File

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

View File

@@ -2,24 +2,31 @@
# In batch mode the sub-instance runs with -b; in GUI mode with -r. # 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, # The run item result is SUCCESS if the sub-instance launched successfully,
# regardless of its own test result. # regardless of its own test result.
#
# 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)
key: $(test)_PASS key: $(test)_PASS
tum: $(test_path)$(psep)sub_pass.tum tum: $(test_path)$(psep)sub_pass.tum
log_file: $(validation_report_path)$(psep)run_sub.log
- run: - run:
name: run PASS (valid file, failing sub-test) name: run PASS (valid file, failing sub-test)
key: $(test)_PASS key: $(test)_PASS
tum: $(test_path)$(psep)sub_fail.tum tum: $(test_path)$(psep)sub_fail.tum
log_file: $(validation_report_path)$(psep)run_sub.log
- run: - run:
name: run FAIL (file not found) name: run FAIL (file not found)
key: $(test)_FAIL key: $(test)_FAIL
tum: $(test_path)$(psep)non_existent.tum tum: $(test_path)$(psep)non_existent.tum
log_file: $(validation_report_path)$(psep)run_sub.log
- run: - run:
name: run FAIL (wait_for_exec without time window) name: run FAIL (wait_for_exec without time window)
key: $(test)_FAIL key: $(test)_FAIL
tum: $(test_path)$(psep)sub_pass.tum tum: $(test_path)$(psep)sub_pass.tum
wait_for_exec: true wait_for_exec: true
log_file: $(validation_report_path)$(psep)run_sub.log

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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