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