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>
This commit is contained in:
2026-06-15 14:40:15 +02:00
parent 6dc473de41
commit 1ea360e5a5
3 changed files with 160 additions and 52 deletions

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…")