| # pylint: disable=C0301,C0103,C0111 |
| from __future__ import print_function |
| from sys import platform |
| import os |
| import sys |
| import socket |
| import fnmatch |
| import subprocess |
| import psutil |
| import shutil |
| import tempfile |
| import uuid |
| import re |
| from collections import OrderedDict, defaultdict |
| from time import monotonic, sleep |
| from typing import List, Dict, Tuple, Set |
| from argparse import Namespace |
| |
| import robot |
| import xml.etree.ElementTree as ET |
| |
| from tests_engine import TestResult |
| |
| this_path = os.path.abspath(os.path.dirname(__file__)) |
| |
| |
| def install_cli_arguments(parser): |
| group = parser.add_mutually_exclusive_group() |
| |
| group.add_argument("--robot-framework-remote-server-full-directory", |
| dest="remote_server_full_directory", |
| action="store", |
| help="Full location of robot framework remote server binary.") |
| |
| group.add_argument("--robot-framework-remote-server-directory-prefix", |
| dest="remote_server_directory_prefix", |
| action="store", |
| default=os.path.join(this_path, '../output/bin'), |
| help="Directory of robot framework remote server binary. This is concatenated with current configuration to create full path.") |
| |
| parser.add_argument("--robot-framework-remote-server-name", |
| dest="remote_server_name", |
| action="store", |
| default="Renode.exe", |
| help="Name of robot framework remote server binary.") |
| |
| parser.add_argument("--robot-framework-remote-server-port", "-P", |
| dest="remote_server_port", |
| action="store", |
| default=0, |
| type=int, |
| help="Port of robot framework remote server binary. Use '0' to automatically select any unused private port.") |
| |
| parser.add_argument("--enable-xwt", |
| dest="enable_xwt", |
| action="store_true", |
| default=False, |
| help="Enables support for XWT.") |
| |
| parser.add_argument("--show-log", |
| dest="show_log", |
| action="store_true", |
| default=False, |
| help="Display log messages in console (might corrupt robot summary output).") |
| |
| parser.add_argument("--keep-renode-output", |
| dest="keep_renode_output", |
| action="store_true", |
| default=False, |
| help=" ".join([ |
| "Redirect Renode stdout and stderr to log files.", |
| "Only non-empty log files are kept (i.e. up to 2 per suite).", |
| "This is separate from the usual logs generated by RobotFramework.", |
| "Implies --show-log (output is redirected and does not appear in console).", |
| ])) |
| |
| parser.add_argument("--verbose", |
| dest="verbose", |
| action="store_true", |
| default=False, |
| help="Print verbose info from Robot Framework.") |
| |
| parser.add_argument("--hot-spot", |
| dest="hotspot", |
| action="store", |
| default=None, |
| help="Test given hot spot action.") |
| |
| parser.add_argument("--variable", |
| dest="variables", |
| action="append", |
| default=None, |
| help="Variable to pass to Robot.") |
| |
| parser.add_argument("--css-file", |
| dest="css_file", |
| action="store", |
| default=os.path.join(this_path, '../lib/resources/styles/robot.css'), |
| help="Custom CSS style for the result files.") |
| |
| parser.add_argument("--runner", |
| dest="runner", |
| action="store", |
| default="mono" if platform.startswith("linux") or platform == "darwin" else "none", |
| help=".NET runner.") |
| |
| parser.add_argument("--net", |
| dest="runner", |
| action="store_const", |
| const="dotnet", |
| help="Use .NET Core runner (alias for --runner=dotnet).") |
| |
| parser.add_argument("--debug-on-error", |
| dest="debug_on_error", |
| action="store_true", |
| default=False, |
| help="Enables the Renode User Interface when test fails.") |
| |
| parser.add_argument("--cleanup-timeout", |
| dest="cleanup_timeout", |
| action="store", |
| default=3, |
| type=int, |
| help="Robot frontend process cleanup timeout.") |
| |
| parser.add_argument("--listener", |
| action="append", |
| help="Path to additional progress listener (can be provided many times).") |
| |
| parser.add_argument("--renode-config", |
| dest="renode_config", |
| action="store", |
| default=None, |
| help="Path to the Renode config file.") |
| |
| parser.add_argument("--kill-stale-renode-instances", |
| dest="autokill_renode", |
| action="store_true", |
| default=False, |
| help="Automatically kill stale Renode instances without asking.") |
| |
| parser.add_argument("--gather-execution-metrics", |
| dest="execution_metrics", |
| action="store_true", |
| default=False, |
| help="Gather execution metrics for each suite.") |
| |
| |
| def verify_cli_arguments(options): |
| # port is not available on Windows |
| if platform != "win32": |
| if options.port == str(options.remote_server_port): |
| print('Port {} is reserved for Robot Framework remote server and cannot be used for remote debugging.'.format(options.remote_server_port)) |
| sys.exit(1) |
| if options.port is not None and options.jobs != 1: |
| print("Debug port cannot be used in parallel runs") |
| sys.exit(1) |
| |
| if options.css_file: |
| if not os.path.isabs(options.css_file): |
| options.css_file = os.path.join(this_path, options.css_file) |
| |
| if not os.path.isfile(options.css_file): |
| print("Unable to find provided CSS file: {0}.".format(options.css_file)) |
| sys.exit(1) |
| |
| if options.remote_server_port != 0 and options.jobs != 1: |
| print("Parallel execution and fixed Robot port number options cannot be used together") |
| sys.exit(1) |
| |
| |
| def is_process_running(pid): |
| if not psutil.pid_exists(pid): |
| return False |
| proc = psutil.Process(pid) |
| # docs note: is_running() will return True also if the process is a zombie (p.status() == psutil.STATUS_ZOMBIE) |
| return proc.is_running() and proc.status() != psutil.STATUS_ZOMBIE |
| |
| |
| def is_port_available(port, autokill): |
| port_handle = socket.socket(socket.AF_INET, socket.SOCK_STREAM) |
| available = False |
| try: |
| port_handle.bind(("localhost", port)) |
| port_handle.close() |
| available = True |
| except: |
| available = can_be_freed_by_killing_other_job(port, autokill) |
| return available |
| |
| |
| def can_be_freed_by_killing_other_job(port, autokill): |
| if not sys.stdin.isatty(): |
| return |
| try: |
| for proc in [psutil.Process(pid) for pid in psutil.pids()]: |
| if '--robot-server-port' in proc.cmdline() and str(port) in proc.cmdline(): |
| if not is_process_running(proc.pid): |
| # process is zombie |
| continue |
| |
| if autokill: |
| result = 'y' |
| else: |
| print('It seems that Renode process (pid {}, name {}) is currently running on port {}'.format(proc.pid, proc.name(), port)) |
| result = input('Do you want me to kill it? [y/N] ') |
| |
| if result in ['Y', 'y']: |
| proc.kill() |
| return True |
| break |
| except Exception: |
| # do nothing here |
| pass |
| return False |
| |
| |
| class KeywordsFinder(robot.model.SuiteVisitor): |
| def __init__(self, keyword): |
| self.keyword = keyword |
| self.occurences = 0 |
| self.arguments = [] |
| |
| |
| def visit_keyword(self, keyword): |
| if keyword.name == self.keyword: |
| self.occurences += 1 |
| arguments = keyword.args |
| self.arguments.append(arguments) |
| |
| |
| def got_results(self): |
| return self.occurences > 0 |
| |
| |
| class TestsFinder(robot.model.SuiteVisitor): |
| def __init__(self, keyword): |
| self.keyword = keyword |
| self.tests_matching = [] |
| self.tests_not_matching = [] |
| |
| |
| def isMatching(self, test): |
| finder = KeywordsFinder(self.keyword) |
| test.visit(finder) |
| return finder.got_results() |
| |
| |
| def visit_test(self, test): |
| if self.isMatching(test): |
| self.tests_matching.append(test) |
| else: |
| self.tests_not_matching.append(test) |
| |
| |
| class RobotTestSuite(object): |
| instances_count = 0 |
| robot_frontend_process = None |
| hotspot_action = ['None', 'Pause', 'Serialize'] |
| # Used to share the port between all suites when running sequentially |
| remote_server_port = -1 |
| retry_test_regex = re.compile(r"\[RETRY\] (PASS|FAIL) on (\d+)\. retry\.") |
| retry_suite_regex = re.compile(r"|".join(( |
| r"\[Errno \d+\] Connection refused", |
| r"Connection to remote server broken: \[WinError \d+\]", |
| r"Connecting remote server at [^ ]+ failed", |
| "Getting keyword names from library 'Remote' failed", |
| ))) |
| |
| def __init__(self, path): |
| self.path = path |
| self._dependencies_met = set() |
| self.remote_server_directory = None |
| self.renode_pid = -1 |
| self.remote_server_port = -1 |
| # Subset of RobotTestSuite.log_files which are "owned" by the running instance |
| self.suite_log_files = None |
| |
| self.tests_with_hotspots = [] |
| self.tests_without_hotspots = [] |
| |
| |
| def check(self, options, number_of_runs): |
| # Checking if there are no other jobs is moved to `prepare` as it is now possible to skip used ports |
| pass |
| |
| |
| def get_output_dir(self, options, iteration_index, suite_retry_index): |
| return os.path.join( |
| options.results_directory, |
| f"iteration{iteration_index}" if options.iteration_count > 1 else "", |
| f"retry{suite_retry_index}" if options.retry_count > 1 else "", |
| ) |
| |
| |
| def prepare(self, options): |
| RobotTestSuite.instances_count += 1 |
| |
| hotSpotTestFinder = TestsFinder(keyword="Handle Hot Spot") |
| suiteBuilder = robot.running.builder.TestSuiteBuilder() |
| suite = suiteBuilder.build(self.path) |
| suite.visit(hotSpotTestFinder) |
| |
| self.tests_with_hotspots = [test.name for test in hotSpotTestFinder.tests_matching] |
| self.tests_without_hotspots = [test.name for test in hotSpotTestFinder.tests_not_matching] |
| |
| # In parallel runs, each parallel group starts its own Renode process. |
| # The same is done in sequential runs with --keep-renode-output. |
| # see: run |
| if options.jobs == 1 and not options.keep_renode_output: |
| if not RobotTestSuite._is_frontend_running(): |
| RobotTestSuite.robot_frontend_process = self._run_remote_server(options) |
| # Save port to reuse when running sequentially |
| RobotTestSuite.remote_server_port = self.remote_server_port |
| else: |
| # Restore port allocated by a previous suite |
| self.remote_server_port = RobotTestSuite.remote_server_port |
| |
| |
| @classmethod |
| def _is_frontend_running(cls): |
| return cls.robot_frontend_process is not None and is_process_running(cls.robot_frontend_process.pid) |
| |
| |
| def _run_remote_server(self, options, iteration_index=1, suite_retry_index=0): |
| if options.runner == 'dotnet': |
| remote_server_name = "Renode.dll" |
| if platform == "win32": |
| tfm = "net6.0-windows10.0.17763.0" |
| else: |
| tfm = "net6.0" |
| configuration = os.path.join(options.configuration, tfm) |
| else: |
| remote_server_name = options.remote_server_name |
| configuration = options.configuration |
| |
| if options.remote_server_full_directory is not None: |
| if not os.path.isabs(options.remote_server_full_directory): |
| options.remote_server_full_directory = os.path.join(this_path, options.remote_server_full_directory) |
| |
| self.remote_server_directory = options.remote_server_full_directory |
| else: |
| self.remote_server_directory = os.path.join(options.remote_server_directory_prefix, configuration) |
| |
| remote_server_binary = os.path.join(self.remote_server_directory, remote_server_name) |
| |
| if not os.path.isfile(remote_server_binary): |
| print("Robot framework remote server binary not found: '{}'! Did you forget to build?".format(remote_server_binary)) |
| sys.exit(1) |
| |
| if options.remote_server_port != 0 and not is_port_available(options.remote_server_port, options.autokill_renode): |
| print("The selected port {} is not available".format(options.remote_server_port)) |
| sys.exit(1) |
| |
| command = [remote_server_binary, '--robot-server-port', str(options.remote_server_port)] |
| if not options.show_log and not options.keep_renode_output: |
| command.append('--hide-log') |
| if not options.enable_xwt: |
| command.append('--disable-gui') |
| if options.debug_on_error: |
| command.append('--robot-debug-on-error') |
| if options.keep_temps: |
| command.append('--keep-temporary-files') |
| if options.renode_config: |
| command.append('--config') |
| command.append(options.renode_config) |
| |
| if options.runner == 'mono': |
| command.insert(0, 'mono') |
| if options.port is not None: |
| if options.suspend: |
| print('Waiting for a debugger at port: {}'.format(options.port)) |
| command.insert(1, '--debug') |
| command.insert(2, '--debugger-agent=transport=dt_socket,server=y,suspend={0},address=127.0.0.1:{1}'.format('y' if options.suspend else 'n', options.port)) |
| elif options.debug_mode: |
| command.insert(1, '--debug') |
| options.exclude.append('skip_mono') |
| elif options.runner == 'dotnet': |
| command.insert(0, 'dotnet') |
| options.exclude.append('skip_dotnet') |
| |
| renode_command = command |
| |
| # if we started GDB, wait for the user to start Renode as a child process |
| if options.run_gdb: |
| command = ['gdb', '-nx', '-ex', 'handle SIGXCPU SIG33 SIG35 SIG36 SIGPWR nostop noprint', '--args'] + command |
| p = psutil.Popen(command, cwd=self.remote_server_directory, bufsize=1) |
| |
| if options.keep_renode_output: |
| print("Note: --keep-renode-output is not supported when using --run-gdb") |
| |
| print("Waiting for Renode process to start") |
| while True: |
| # We strip argv[0] because if we pass just `mono` to GDB it will resolve |
| # it to a full path to mono on the PATH, for example /bin/mono |
| renode_child = next((c for c in p.children() if c.cmdline()[1:] == renode_command[1:]), None) |
| if renode_child: |
| break |
| sleep(0.5) |
| self.renode_pid = renode_child.pid |
| elif options.perf_output_path: |
| pid_file_uuid = uuid.uuid4() |
| pid_filename = f'pid_file_{pid_file_uuid}' |
| |
| command = ['perf', 'record', '-q', '-g', '-F', 'max'] + command + ['--pid-file', pid_filename] |
| |
| perf_stdout_stderr_file_name = "perf_stdout_stderr" |
| |
| if options.keep_renode_output: |
| print("Note: --keep-renode-output is not supported when using --run-gdb") |
| |
| print(f"WARNING: perf stdout and stderr is being redirected to {perf_stdout_stderr_file_name}") |
| |
| perf_stdout_stderr_file = open(perf_stdout_stderr_file_name, "w") |
| p = subprocess.Popen(command, cwd=self.remote_server_directory, bufsize=1, stdout=perf_stdout_stderr_file, stderr=perf_stdout_stderr_file) |
| |
| pid_file_path = os.path.join(self.remote_server_directory, pid_filename) |
| perf_renode_timeout = 10 |
| |
| while not os.path.exists(pid_file_path) and perf_renode_timeout > 0: |
| sleep(0.5) |
| perf_renode_timeout -= 1 |
| |
| if perf_renode_timeout <= 0: |
| raise RuntimeError("Renode pid file could not be found, can't attach perf") |
| |
| with open(pid_file_path, 'r') as pid_file: |
| self.renode_pid = pid_file.read() |
| else: |
| # Start Renode |
| if options.keep_renode_output: |
| output_dir = self.get_output_dir(options, iteration_index, suite_retry_index) |
| logs_dir = os.path.join(output_dir, 'logs') |
| os.makedirs(logs_dir, exist_ok=True) |
| file_name = os.path.splitext(os.path.basename(self.path))[0] |
| suite_name = RobotTestSuite._create_suite_name(file_name, None) |
| fout = open(os.path.join(logs_dir, f"{suite_name}.renode_stdout.log"), "wb", buffering=0) |
| ferr = open(os.path.join(logs_dir, f"{suite_name}.renode_stderr.log"), "wb", buffering=0) |
| p = subprocess.Popen(command, cwd=self.remote_server_directory, bufsize=1, stdout=fout, stderr=ferr) |
| self.renode_pid = p.pid |
| else: |
| p = subprocess.Popen(command, cwd=self.remote_server_directory, bufsize=1) |
| self.renode_pid = p.pid |
| |
| countdown = 120 |
| temp_dir = tempfile.gettempdir() |
| renode_port_file = os.path.join(temp_dir, f'renode-{self.renode_pid}', 'robot_port') |
| while countdown > 0: |
| try: |
| with open(renode_port_file) as f: |
| port_num = f.readline() |
| if port_num == '': |
| continue |
| self.remote_server_port = int(port_num) |
| break |
| except: |
| sleep(0.5) |
| countdown -= 1 |
| |
| if countdown == 0: |
| print("Couldn't access port file for Renode instance pid {}".format(self.renode_pid)) |
| self._close_remote_server(p, options) |
| return None |
| |
| print('Started Renode instance on port {}; pid {}'.format(self.remote_server_port, self.renode_pid)) |
| return p |
| |
| def __move_perf_data(self, options): |
| perf_data_path = os.path.join(self.remote_server_directory, "perf.data") |
| |
| if not perf_data_path: |
| raise RuntimeError("perf.data file was not generated succesfully") |
| |
| if not os.path.isdir(options.perf_output_path): |
| raise RuntimeError(f"{options.perf_output_path} is not a valid directory path") |
| |
| shutil.move(perf_data_path, options.perf_output_path) |
| |
| def _close_remote_server(self, proc, options): |
| if proc: |
| print('Closing Renode pid {}'.format(proc.pid)) |
| try: |
| process = psutil.Process(proc.pid) |
| os.kill(proc.pid, 2) |
| process.wait(timeout=options.cleanup_timeout) |
| |
| if options.perf_output_path: |
| self.__move_perf_data(options) |
| except psutil.TimeoutExpired: |
| process.kill() |
| process.wait() |
| except psutil.NoSuchProcess: |
| #evidently closed by other means |
| pass |
| |
| if options.perf_output_path and proc.stdout: |
| proc.stdout.close() |
| |
| |
| def run(self, options, run_id=0, iteration_index=1, suite_retry_index=0): |
| if self.path.endswith('renode-keywords.robot'): |
| print('Ignoring helper file: {}'.format(self.path)) |
| return True |
| |
| print('Running ' + self.path) |
| result = None |
| |
| # in non-parallel runs there is only one Renode process for all runs, |
| # unless --keep-renode-output is enabled, in which case a new process |
| # is spawned for every suite to ensure logs are separate files. |
| # see: prepare |
| if options.jobs != 1 or options.keep_renode_output: |
| proc = self._run_remote_server(options, iteration_index, suite_retry_index) |
| else: |
| proc = None |
| |
| def get_result(): |
| return result if result is not None else TestResult(True, None) |
| |
| start_timestamp = monotonic() |
| |
| if any(self.tests_without_hotspots): |
| result = get_result().ok and self._run_inner(options.fixture, |
| None, |
| self.tests_without_hotspots, |
| options, |
| iteration_index, |
| suite_retry_index) |
| if any(self.tests_with_hotspots): |
| for hotspot in RobotTestSuite.hotspot_action: |
| if options.hotspot and options.hotspot != hotspot: |
| continue |
| result = get_result().ok and self._run_inner(options.fixture, |
| hotspot, |
| self.tests_with_hotspots, |
| options, |
| iteration_index, |
| suite_retry_index) |
| |
| end_timestamp = monotonic() |
| |
| if result is None: |
| print(f'No tests executed for suite {self.path}', flush=True) |
| else: |
| status = 'finished successfully' if result.ok else 'failed' |
| exec_time = round(end_timestamp - start_timestamp, 2) |
| print(f'Suite {self.path} {status} in {exec_time} seconds.', flush=True) |
| |
| self._close_remote_server(proc, options) |
| |
| # make sure renode is still alive when a non-parallel run depends on it |
| if options.jobs == 1 and not options.keep_renode_output: |
| if not self._is_frontend_running(): |
| print("Renode has unexpectedly died when running sequentially! Trying to respawn before continuing...") |
| RobotTestSuite.robot_frontend_process = self._run_remote_server(options, iteration_index, suite_retry_index) |
| # Save port to reuse when running sequentially |
| RobotTestSuite.remote_server_port = self.remote_server_port |
| |
| return get_result() |
| |
| |
| def _get_dependencies(self, test_case): |
| |
| suiteBuilder = robot.running.builder.TestSuiteBuilder() |
| suite = suiteBuilder.build(self.path) |
| test = next(t for t in suite.tests if hasattr(t, 'name') and t.name == test_case) |
| requirements = [s.args[0] for s in test.body if hasattr(s, 'name') and s.name == 'Requires'] |
| if len(requirements) == 0: |
| return set() |
| if len(requirements) > 1: |
| raise Exception('Too many requirements for a single test. At most one is allowed.') |
| providers = [t for t in suite.tests if any(hasattr(s, 'name') and s.name == 'Provides' and s.args[0] == requirements[0] for s in t.body)] |
| if len(providers) > 1: |
| raise Exception('Too many providers for state {0} found: {1}'.format(requirements[0], ', '.join(providers.name))) |
| if len(providers) == 0: |
| raise Exception('No provider for state {0} found'.format(requirements[0])) |
| res = self._get_dependencies(providers[0].name) |
| res.add(providers[0].name) |
| return res |
| |
| |
| def cleanup(self, options): |
| assert hasattr(RobotTestSuite, "log_files"), "tests_engine.py did not assign RobotTestSuite.log_files" |
| RobotTestSuite.instances_count -= 1 |
| if RobotTestSuite.instances_count == 0: |
| self._close_remote_server(RobotTestSuite.robot_frontend_process, options) |
| print("Aggregating all robot results") |
| grouped_log_files = self.group_log_paths(RobotTestSuite.log_files) |
| for iteration in range(1, options.iteration_count + 1): |
| for retry in range(options.retry_count): |
| output_dir = self.get_output_dir(options, iteration, retry) |
| log_files = grouped_log_files[(iteration, retry)] |
| |
| # An output_dir can be missing for suite retries that were never "used" |
| if not os.path.isdir(output_dir) or not log_files: |
| continue |
| |
| robot.rebot( |
| *log_files, |
| processemptysuite=True, |
| name='Test Suite', |
| loglevel="TRACE:INFO", |
| outputdir=output_dir, |
| output='robot_output.xml' |
| ) |
| for file in set(log_files): |
| os.remove(file) |
| if options.css_file: |
| with open(options.css_file) as style: |
| style_content = style.read() |
| for report_name in ("report.html", "log.html"): |
| with open(os.path.join(output_dir, report_name), "a") as report: |
| report.write("<style media=\"all\" type=\"text/css\">") |
| report.write(style_content) |
| report.write("</style>") |
| |
| |
| if options.keep_renode_output: |
| logs_pattern = re.compile(r"(?P<suite_name>.*)\.renode_std(out|err)\.log") |
| for dirpath, _, fnames in os.walk(options.results_directory): |
| if os.path.basename(dirpath.rstrip("/")) != "logs": |
| continue |
| |
| failed_suites = self.find_suites_with_fails(os.path.dirname(dirpath)) |
| for fname in fnames: |
| fpath = os.path.join(dirpath, fname) |
| m = logs_pattern.match(fname) |
| if m: |
| # Remove empty logs |
| if os.path.getsize(fpath) == 0: |
| os.remove(fpath) |
| continue |
| |
| if options.save_logs == "onfail": |
| # Remove logs which weren't failures |
| suite_name = m.group("suite_name") |
| if suite_name not in failed_suites: |
| os.remove(fpath) |
| |
| # If the logs directory is empty, delete it |
| try: |
| os.rmdir(dirpath) |
| except OSError: |
| pass |
| |
| |
| def should_retry_suite(self, options, iteration_index, suite_retry_index): |
| tree = None |
| assert self.suite_log_files is not None, "The suite has not yet been run." |
| output_dir = self.get_output_dir(options, iteration_index, suite_retry_index) |
| for log_file in self.suite_log_files: |
| try: |
| tree = ET.parse(os.path.join(output_dir, log_file)) |
| except FileNotFoundError as e: |
| raise e |
| |
| root = tree.getroot() |
| for suite in root.iter('suite'): |
| if not suite.get('source', False): |
| continue # it is a tag used to group other suites without meaning on its own |
| |
| # Always retry if our Setup failed. |
| for kw in suite.iter('kw'): |
| if kw.get('name') == 'Setup' and kw.get('library') == 'renode-keywords': |
| if kw.find('status').get('status') != 'PASS': |
| print('Renode Setup failure detected!') |
| return True |
| else: |
| break |
| |
| # Look for regular expressions signifying a crash. |
| # Suite Setup and Suite Teardown aren't checked here cause they're in the `kw` tags. |
| for test in suite.iter('test'): |
| status = test.find('status') # only finds immediate children - important requirement |
| if status.text is not None and self.retry_suite_regex.search(status.text): |
| return True |
| for msg in test.iter("msg"): |
| if self.retry_suite_regex.search(msg.text): |
| return True |
| return False |
| |
| |
| @staticmethod |
| def _create_suite_name(test_name, hotspot): |
| return test_name + (' [HotSpot action: {0}]'.format(hotspot) if hotspot else '') |
| |
| |
| def _run_dependencies(self, test_cases_names, options, iteration_index=1, suite_retry_index=0): |
| test_cases_names.difference_update(self._dependencies_met) |
| if not any(test_cases_names): |
| return True |
| self._dependencies_met.update(test_cases_names) |
| return self._run_inner(None, None, test_cases_names, options, iteration_index, suite_retry_index) |
| |
| |
| def _run_inner(self, fixture, hotspot, test_cases_names, options, iteration_index=1, suite_retry_index=0): |
| file_name = os.path.splitext(os.path.basename(self.path))[0] |
| suite_name = RobotTestSuite._create_suite_name(file_name, hotspot) |
| |
| output_dir = self.get_output_dir(options, iteration_index, suite_retry_index) |
| variables = [ |
| 'SKIP_RUNNING_SERVER:True', |
| 'DIRECTORY:{}'.format(self.remote_server_directory), |
| 'PORT_NUMBER:{}'.format(self.remote_server_port), |
| 'RESULTS_DIRECTORY:{}'.format(output_dir), |
| ] |
| if hotspot: |
| variables.append('HOTSPOT_ACTION:' + hotspot) |
| if options.debug_mode: |
| variables.append('CONFIGURATION:Debug') |
| if options.debug_on_error: |
| variables.append('HOLD_ON_ERROR:True') |
| if options.execution_metrics: |
| variables.append('CREATE_EXECUTION_METRICS:True') |
| if options.save_logs == "always": |
| variables.append('SAVE_LOGS_WHEN:Always') |
| if options.runner == 'dotnet': |
| variables.append('BINARY_NAME:Renode.dll') |
| variables.append('RENODE_PID:{}'.format(self.renode_pid)) |
| variables.append('NET_PLATFORM:True') |
| else: |
| options.exclude.append('profiling') |
| |
| if options.variables: |
| variables += options.variables |
| |
| test_cases = [(test_name, '{0}.{1}'.format(suite_name, test_name)) for test_name in test_cases_names] |
| if fixture: |
| test_cases = [x for x in test_cases if fnmatch.fnmatch(x[1], '*' + fixture + '*')] |
| if len(test_cases) == 0: |
| return None |
| deps = set() |
| for test_name in (t[0] for t in test_cases): |
| deps.update(self._get_dependencies(test_name)) |
| if not self._run_dependencies(deps, options, iteration_index, suite_retry_index): |
| return False |
| |
| output_formatter = 'robot_output_formatter_verbose.py' if options.verbose else 'robot_output_formatter.py' |
| listeners = [os.path.join(this_path, output_formatter)] |
| if options.listener: |
| listeners += options.listener |
| if options.retry_count > 1: |
| listeners += [f'RetryFailed:{options.retry_count - 1}'] |
| |
| metadata = {"HotSpot_Action": hotspot if hotspot else '-'} |
| log_file = os.path.join(output_dir, 'results-{0}{1}.robot.xml'.format(file_name, '_' + hotspot if hotspot else '')) |
| |
| keywords_path = os.path.abspath(os.path.join(this_path, "renode-keywords.robot")) |
| keywords_path = keywords_path.replace(os.path.sep, "/") # Robot wants forward slashes even on Windows |
| # This variable is provided for compatibility with Robot files that use Resource ${RENODEKEYWORDS} |
| variables.append('RENODEKEYWORDS:{}'.format(keywords_path)) |
| tools_path = os.path.join(os.path.dirname(this_path), "tools") |
| tools_path = tools_path.replace(os.path.sep, "/") |
| variables.append('RENODETOOLS:{}'.format(tools_path)) |
| suite_builder = robot.running.builder.TestSuiteBuilder() |
| suite = suite_builder.build(self.path) |
| suite.resource.imports.create(type="Resource", name=keywords_path) |
| |
| suite.configure(include_tags=options.include, exclude_tags=options.exclude, |
| include_tests=[t[1] for t in test_cases], metadata=metadata, |
| name=suite_name, empty_suite_ok=True) |
| |
| # Provide default values for {Suite,Test}{Setup,Teardown} |
| if not suite.setup: |
| suite.setup.config(name="Setup") |
| if not suite.teardown: |
| suite.teardown.config(name="Teardown") |
| |
| for test in suite.tests: |
| if not test.setup: |
| test.setup.config(name="Reset Emulation") |
| if not test.teardown: |
| test.teardown.config(name="Test Teardown") |
| |
| result = suite.run(console='none', listener=listeners, exitonfailure=options.stop_on_error, output=log_file, log=None, loglevel='TRACE', report=None, variable=variables, skiponfailure=['non_critical', 'skipped']) |
| |
| self.suite_log_files = [] |
| file_name = os.path.splitext(os.path.basename(self.path))[0] |
| output_dir = self.get_output_dir(options, iteration_index, suite_retry_index) |
| if any(self.tests_without_hotspots): |
| log_file = os.path.join(output_dir, 'results-{0}.robot.xml'.format(file_name)) |
| if os.path.isfile(log_file): |
| self.suite_log_files.append(log_file) |
| if any(self.tests_with_hotspots): |
| for hotspot in RobotTestSuite.hotspot_action: |
| if options.hotspot and options.hotspot != hotspot: |
| continue |
| log_file = os.path.join(output_dir, 'results-{0}{1}.robot.xml'.format(file_name, '_' + hotspot if hotspot else '')) |
| if os.path.isfile(log_file): |
| self.suite_log_files.append(log_file) |
| |
| if options.runner == "mono": |
| self.copy_mono_logs(options, iteration_index, suite_retry_index) |
| |
| return TestResult(result.return_code == 0, self.suite_log_files) |
| |
| |
| def copy_mono_logs(self, options: Namespace, iteration_index: int, suite_retry_index: int) -> None: |
| """Copies 'mono_crash.*.json' files into the suite's logs directory. |
| |
| These files are occasionally created when mono crashes. There are also 'mono_crash.*.blob' |
| files, but they contain heavier memory dumps and have questionable usefulness.""" |
| output_dir = self.get_output_dir(options, iteration_index, suite_retry_index) |
| logs_dir = os.path.join(output_dir, "logs") |
| for dirpath, dirnames, fnames in os.walk(os.getcwd()): |
| # Do not descend into "logs" directories, to prevent later invocations from |
| # stealing files already moved by earlier invocations |
| logs_indices = [x for x in range(len(dirnames)) if dirnames[x] == "logs"] |
| logs_indices.sort(reverse=True) |
| for logs_idx in logs_indices: |
| del dirnames[logs_idx] |
| |
| for fname in filter(lambda x: x.startswith("mono_crash.") and x.endswith(".json"), fnames): |
| os.makedirs(logs_dir, exist_ok=True) |
| src_fpath = os.path.join(dirpath, fname) |
| dest_fpath = os.path.join(logs_dir, fname) |
| print(f"Moving mono_crash file: '{src_fpath}' -> '{dest_fpath}'") |
| os.rename(src_fpath, dest_fpath) |
| |
| |
| @staticmethod |
| def find_failed_tests(path, file="robot_output.xml"): |
| ret = {'mandatory': set(), 'non_critical': set()} |
| |
| # Aggregate failed tests from all report files (can be multiple if iterations or retries were used) |
| for dirpath, _, fnames in os.walk(path): |
| for fname in filter(lambda x: x == file, fnames): |
| tree = ET.parse(os.path.join(dirpath, fname)) |
| root = tree.getroot() |
| for suite in root.iter('suite'): |
| if not suite.get('source', False): |
| continue # it is a tag used to group other suites without meaning on its own |
| for test in suite.iter('test'): |
| status = test.find('status') # only finds immediate children - important requirement |
| if status.attrib['status'] == 'FAIL': |
| test_name = test.attrib['name'] |
| suite_name = suite.attrib['name'] |
| if suite_name == "Test Suite": |
| # If rebot is invoked with only 1 suite, it renames that suite to Test Suite |
| # instead of wrapping in a new top-level Test Suite. A workaround is to extract |
| # the suite name from the *.robot file name. |
| suite_name = os.path.basename(suite.attrib["source"]).rsplit(".", 1)[0] |
| if test.find("./tags/[tag='skipped']"): |
| continue # skipped test should not be classified as fail |
| if test.find("./tags/[tag='non_critical']"): |
| ret['non_critical'].add(f"{suite_name}.{test_name}") |
| else: |
| ret['mandatory'].add(f"{suite_name}.{test_name}") |
| |
| if not ret['mandatory'] and not ret['non_critical']: |
| return None |
| return ret |
| |
| |
| @classmethod |
| def find_suites_with_fails(cls, path, file="robot_output.xml"): |
| """Finds suites which contain at least one test case failure. |
| |
| A suite may be successful and still contain failures, e.g. if the --retry option |
| was used and a test passed on a later attempt.""" |
| ret = set() |
| |
| for dirpath, _, fnames in os.walk(path): |
| for fname in filter(lambda x: x == file, fnames): |
| tree = ET.parse(os.path.join(dirpath, fname)) |
| root = tree.getroot() |
| for suite in root.iter('suite'): |
| if not suite.get('source', False): |
| continue # it is a tag used to group other suites without meaning on its own |
| |
| suite_name = suite.attrib['name'] |
| if suite_name == "Test Suite": |
| # If rebot is invoked with only 1 suite, it renames that suite to Test Suite |
| # instead of wrapping in a new top-level Test Suite. A workaround is to extract |
| # the suite name from the *.robot file name. |
| suite_name = os.path.basename(suite.attrib["source"]).rsplit(".", 1)[0] |
| |
| for test in suite.iter('test'): |
| if test.find("./tags/[tag='skipped']"): |
| continue # skipped test should not be classified as fail |
| status = test.find('status') # only finds immediate children - important requirement |
| if status.attrib["status"] == "FAIL": |
| ret.add(suite_name) |
| break |
| if status.text is not None and cls.retry_test_regex.search(status.text): |
| # Retried test cases still count as fails |
| ret.add(suite_name) |
| break |
| |
| return ret |
| |
| |
| @staticmethod |
| def group_log_paths(paths: List[str]) -> Dict[Tuple[int, int], Set[str]]: |
| """Breaks a list of log paths into subsets grouped by (iteration, suite_retry) pairs.""" |
| re_path_indices_patterns = ( |
| re.compile(r"\biteration(?P<iteration>\d+)/retry(?P<suite_retry>\d+)/"), |
| re.compile(r"\bretry(?P<suite_retry>\d+)/"), |
| re.compile(r"\biteration(?P<iteration>\d+)/"), |
| ) |
| ret = defaultdict(lambda: set()) |
| for path in paths: |
| iteration = 1 |
| suite_retry = 0 |
| for pattern in re_path_indices_patterns: |
| match = pattern.search(path) |
| if match is None: |
| continue |
| try: |
| iteration = int(match.group("iteration")) |
| except IndexError: |
| pass |
| try: |
| suite_retry = int(match.group("suite_retry")) |
| except IndexError: |
| pass |
| ret[(iteration, suite_retry)].add(path) |
| return ret |
| |
| |
| @classmethod |
| def find_rerun_tests(cls, path): |
| |
| def analyze_xml(label, retry_dir, file="robot_output.xml"): |
| try: |
| tree = ET.parse(os.path.join(retry_dir, file)) |
| except FileNotFoundError: |
| return |
| root = tree.getroot() |
| for suite in root.iter('suite'): |
| if not suite.get('source', False): |
| continue # it is a tag used to group other suites without meaning on its own |
| suite_name = suite.attrib['name'] |
| if suite_name == "Test Suite": |
| # If rebot is invoked with only 1 suite, it renames that suite to Test Suite |
| # instead of wrapping in a new top-level Test Suite. A workaround is to extract |
| # the suite name from the *.robot file name. |
| suite_name = os.path.basename(suite.attrib["source"]).rsplit(".", 1)[0] |
| for test in suite.iter('test'): |
| test_name = test.attrib['name'] |
| tags = [] |
| if test.find("./tags/[tag='skipped']"): |
| continue # skipped test should not be classified as fail |
| if test.find("./tags/[tag='non_critical']"): |
| tags.append("non_critical") |
| status = test.find('status') # only finds immediate children - important requirement |
| m = cls.retry_test_regex.search(status.text) if status.text is not None else None |
| |
| # Check whether renode crashed during this test |
| has_renode_crashed = False |
| if status.text is not None and cls.retry_suite_regex.search(status.text): |
| has_renode_crashed = True |
| else: |
| has_renode_crashed = any(cls.retry_suite_regex.search(msg.text) for msg in test.iter("msg")) |
| |
| status_str = status.attrib["status"] |
| nth = (1 + int(m.group(2))) if m else 1 |
| key = f"{suite_name}.{test_name}" |
| if key not in data: |
| data[key] = [] |
| data[key].append({ |
| "label": label, # e.g. "retry0", "retry1", "iteration1/retry2", ... |
| "status": status_str, # e.g. "PASS", "FAIL", "SKIP", ... |
| "nth": nth, # The number of test case attempts that led to the above status |
| "tags": tags, # e.g. ["non_critical"], [], ... |
| "crash": has_renode_crashed, |
| }) |
| |
| def analyze_iteration(iteration_dir): |
| iteration_dirname = os.path.basename(iteration_dir) |
| report_fpath = os.path.join(iteration_dir, "robot_output.xml") |
| if os.path.isfile(report_fpath): |
| analyze_xml(iteration_dirname, iteration_dir) |
| return |
| i = -1 |
| while True: |
| i += 1 |
| retry_dirpath = os.path.join(iteration_dir, f"retry{i}") |
| if os.path.isdir(retry_dirpath): |
| analyze_xml(os.path.join(iteration_dirname, f"retry{i}"), retry_dirpath) |
| continue |
| break |
| |
| data = OrderedDict() |
| i = -1 |
| while True: |
| i += 1 |
| iteration_dirpath = os.path.join(path, f"iteration{i + 1}") |
| retry_dirpath = os.path.join(path, f"retry{i}") |
| if os.path.isdir(iteration_dirpath): |
| analyze_iteration(iteration_dirpath) |
| continue |
| elif os.path.isdir(retry_dirpath): |
| analyze_xml(f"retry{i}", retry_dirpath) |
| continue |
| break |
| |
| return data |