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