1# pylint: disable=C0301,C0103,C0111
2from __future__ import print_function
3from sys import platform
4import os
5import sys
6import socket
7import fnmatch
8import subprocess
9import psutil
10import shutil
11import tempfile
12import uuid
13import re
14from collections import OrderedDict, defaultdict
15from time import monotonic, sleep
16from typing import List, Dict, Tuple, Set
17from argparse import Namespace
18
19import robot, robot.result, robot.running
20from robot.libraries.BuiltIn import BuiltIn
21from robot.libraries.DateTime import Time
22
23import xml.etree.ElementTree as ET
24
25from tests_engine import TestResult
26
27this_path = os.path.abspath(os.path.dirname(__file__))
28
29
30class Timeout:
31    def __init__(self, value: str):
32        self.seconds = Time(value).seconds
33        self.value = value
34
35    def __repr__(self):
36        return f"{self.value} ({self.seconds}s)"
37
38
39def install_cli_arguments(parser):
40    group = parser.add_mutually_exclusive_group()
41
42    group.add_argument("--robot-framework-remote-server-full-directory",
43                       dest="remote_server_full_directory",
44                       action="store",
45                       help="Full location of robot framework remote server binary.")
46
47    group.add_argument("--robot-framework-remote-server-directory-prefix",
48                       dest="remote_server_directory_prefix",
49                       action="store",
50                       default=os.path.join(this_path, '../output/bin'),
51                       help="Directory of robot framework remote server binary. This is concatenated with current configuration to create full path.")
52
53    parser.add_argument("--robot-framework-remote-server-name",
54                        dest="remote_server_name",
55                        action="store",
56                        default="Renode.exe",
57                        help="Name of robot framework remote server binary.")
58
59    parser.add_argument("--robot-framework-remote-server-port", "-P",
60                        dest="remote_server_port",
61                        action="store",
62                        default=0,
63                        type=int,
64                        help="Port of robot framework remote server binary. Use '0' to automatically select any unused private port.")
65
66    parser.add_argument("--enable-xwt",
67                        dest="enable_xwt",
68                        action="store_true",
69                        default=False,
70                        help="Enables support for XWT.")
71
72    parser.add_argument("--show-log",
73                        dest="show_log",
74                        action="store_true",
75                        default=False,
76                        help="Display log messages in console (might corrupt robot summary output).")
77
78    parser.add_argument("--keep-renode-output",
79                        dest="keep_renode_output",
80                        action="store_true",
81                        default=False,
82                        help=" ".join([
83                            "Redirect Renode stdout and stderr to log files.",
84                            "Only non-empty log files are kept (i.e. up to 2 per suite).",
85                            "This is separate from the usual logs generated by RobotFramework.",
86                            "Implies --show-log (output is redirected and does not appear in console).",
87                        ]))
88
89    parser.add_argument("--verbose",
90                        dest="verbose",
91                        action="store_true",
92                        default=False,
93                        help="Print verbose info from Robot Framework.")
94
95    parser.add_argument("--hot-spot",
96                        dest="hotspot",
97                        action="store",
98                        default=None,
99                        help="Test given hot spot action.")
100
101    parser.add_argument("--variable",
102                        dest="variables",
103                        action="append",
104                        default=None,
105                        help="Variable to pass to Robot.")
106
107    parser.add_argument("--css-file",
108                        dest="css_file",
109                        action="store",
110                        default=os.path.join(this_path, '../lib/resources/styles/robot.css'),
111                        help="Custom CSS style for the result files.")
112
113    parser.add_argument("--debug-on-error",
114                        dest="debug_on_error",
115                        action="store_true",
116                        default=False,
117                        help="Enables the Renode User Interface when test fails.")
118
119    parser.add_argument("--cleanup-timeout",
120                        dest="cleanup_timeout",
121                        action="store",
122                        default=3,
123                        type=int,
124                        help="Robot frontend process cleanup timeout.")
125
126    parser.add_argument("--listener",
127                        action="append",
128                        help="Path to additional progress listener (can be provided many times).")
129
130    parser.add_argument("--renode-config",
131                        dest="renode_config",
132                        action="store",
133                        default=None,
134                        help="Path to the Renode config file.")
135
136    parser.add_argument("--kill-stale-renode-instances",
137                        dest="autokill_renode",
138                        action="store_true",
139                        default=False,
140                        help="Automatically kill stale Renode instances without asking.")
141
142    parser.add_argument("--gather-execution-metrics",
143                        dest="execution_metrics",
144                        action="store_true",
145                        default=False,
146                        help="Gather execution metrics for each suite.")
147
148    parser.add_argument("--test-timeout",
149                        dest="timeout",
150                        action="store",
151                        default=None,
152                        type=Timeout,
153                        help=" ".join([
154                            "Default test case timeout after which Renode keywords will be interrupted.",
155                            "It's parsed by Robot Framework's DateTime library so all its time formats are supported.",
156                        ]))
157
158
159def verify_cli_arguments(options):
160    # port is not available on Windows
161    if platform != "win32":
162        if options.port == str(options.remote_server_port):
163            print('Port {} is reserved for Robot Framework remote server and cannot be used for remote debugging.'.format(options.remote_server_port))
164            sys.exit(1)
165        if options.port is not None and options.jobs != 1:
166            print("Debug port cannot be used in parallel runs")
167            sys.exit(1)
168
169    if options.css_file:
170        if not os.path.isabs(options.css_file):
171            options.css_file = os.path.join(this_path, options.css_file)
172
173        if not os.path.isfile(options.css_file):
174            print("Unable to find provided CSS file: {0}.".format(options.css_file))
175            sys.exit(1)
176
177    if options.remote_server_port != 0 and options.jobs != 1:
178        print("Parallel execution and fixed Robot port number options cannot be used together")
179        sys.exit(1)
180
181
182def is_process_running(pid):
183    if not psutil.pid_exists(pid):
184        return False
185    proc = psutil.Process(pid)
186    # docs note: is_running() will return True also if the process is a zombie (p.status() == psutil.STATUS_ZOMBIE)
187    return proc.is_running() and proc.status() != psutil.STATUS_ZOMBIE
188
189
190def is_port_available(port, autokill):
191    port_handle = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
192    available = False
193    try:
194        port_handle.bind(("localhost", port))
195        port_handle.close()
196        available = True
197    except:
198        available = can_be_freed_by_killing_other_job(port, autokill)
199    return available
200
201
202def can_be_freed_by_killing_other_job(port, autokill):
203    if not sys.stdin.isatty():
204        return
205    try:
206        for proc in [psutil.Process(pid) for pid in psutil.pids()]:
207            if '--robot-server-port' in proc.cmdline() and str(port) in proc.cmdline():
208                if not is_process_running(proc.pid):
209                    # process is zombie
210                    continue
211
212                if autokill:
213                    result = 'y'
214                else:
215                    print('It seems that Renode process (pid {}, name {}) is currently running on port {}'.format(proc.pid, proc.name(), port))
216                    result = input('Do you want me to kill it? [y/N] ')
217
218                if result in ['Y', 'y']:
219                    proc.kill()
220                    return True
221                break
222    except Exception:
223        # do nothing here
224        pass
225    return False
226
227
228class KeywordsFinder(robot.model.SuiteVisitor):
229    def __init__(self, keyword):
230        self.keyword = keyword
231        self.occurences = 0
232        self.arguments = []
233
234
235    def visit_keyword(self, keyword):
236        if keyword.name == self.keyword:
237            self.occurences += 1
238            arguments = keyword.args
239            self.arguments.append(arguments)
240
241
242    def got_results(self):
243        return self.occurences > 0
244
245
246class TestsFinder(robot.model.SuiteVisitor):
247    def __init__(self, keyword):
248        self.keyword = keyword
249        self.tests_matching = []
250        self.tests_not_matching = []
251
252
253    def isMatching(self, test):
254        finder = KeywordsFinder(self.keyword)
255        test.visit(finder)
256        return finder.got_results()
257
258
259    def visit_test(self, test):
260        if self.isMatching(test):
261            self.tests_matching.append(test)
262        else:
263            self.tests_not_matching.append(test)
264
265
266class RobotTestSuite(object):
267    after_timeout_message_suffix = ' '.join([
268        "Failed on Renode restarted after timeout,",
269        "will be retried if `-N/--retry` option was used."
270    ])
271    instances_count = 0
272    robot_frontend_process = None
273    renode_pid = -1  # It's not always robot_frontend_process.pid, e.g., with `--run-gdb` option.
274    hotspot_action = ['None', 'Pause', 'Serialize']
275    # Used to share the port between all suites when running sequentially
276    remote_server_port = -1
277    retry_test_regex = re.compile(r"\[RETRY\] (PASS|FAIL) on (\d+)\. retry\.")
278    retry_suite_regex = re.compile(r"|".join((
279            r"\[Errno \d+\] Connection refused",
280            r"Connection to remote server broken: \[WinError \d+\]",
281            r"Connecting remote server at [^ ]+ failed",
282            "Getting keyword names from library 'Remote' failed",
283            after_timeout_message_suffix,
284    )))
285    timeout_expected_tag = 'timeout_expected'
286
287    def __init__(self, path):
288        self.path = path
289        self._dependencies_met = set()
290        self.remote_server_directory = None
291        # Subset of RobotTestSuite.log_files which are "owned" by the running instance
292        self.suite_log_files = None
293
294        self.tests_with_hotspots = []
295        self.tests_without_hotspots = []
296
297
298    def check(self, options, number_of_runs):
299        # Checking if there are no other jobs is moved to `prepare` as it is now possible to skip used ports
300        pass
301
302
303    def get_output_dir(self, options, iteration_index, suite_retry_index):
304        return os.path.join(
305            options.results_directory,
306            f"iteration{iteration_index}" if options.iteration_count > 1 else "",
307            f"retry{suite_retry_index}" if options.retry_count > 1 else "",
308        )
309
310
311    def prepare(self, options):
312        RobotTestSuite.instances_count += 1
313
314        hotSpotTestFinder = TestsFinder(keyword="Handle Hot Spot")
315        suiteBuilder = robot.running.builder.TestSuiteBuilder()
316        suite = suiteBuilder.build(self.path)
317        suite.visit(hotSpotTestFinder)
318
319        self.tests_with_hotspots = [test.name for test in hotSpotTestFinder.tests_matching]
320        self.tests_without_hotspots = [test.name for test in hotSpotTestFinder.tests_not_matching]
321
322        # In parallel runs, Renode is started for each suite.
323        # The same is done in sequential runs with --keep-renode-output.
324        # see: run
325        if options.jobs == 1 and not options.keep_renode_output:
326            if not RobotTestSuite._is_frontend_running():
327                RobotTestSuite.robot_frontend_process = self._run_remote_server(options)
328                # Save port to reuse when running sequentially
329                RobotTestSuite.remote_server_port = self.remote_server_port
330            else:
331                # Restore port allocated by a previous suite
332                self.remote_server_port = RobotTestSuite.remote_server_port
333
334
335    @classmethod
336    def _is_frontend_running(cls):
337        return cls.robot_frontend_process is not None and is_process_running(cls.robot_frontend_process.pid)
338
339
340    def _run_remote_server(self, options, iteration_index=1, suite_retry_index=0, remote_server_port=None):
341        # Let's reset PID and check it's set before returning to prevent keeping old PID.
342        self.renode_pid = -1
343
344        if options.runner == 'dotnet':
345            remote_server_name = "Renode.dll"
346        else:
347            remote_server_name = options.remote_server_name
348
349        self.remote_server_directory = options.remote_server_full_directory
350        remote_server_binary = os.path.join(self.remote_server_directory, remote_server_name)
351
352        if not os.path.isfile(remote_server_binary):
353            print("Robot framework remote server binary not found: '{}'! Did you forget to build?".format(remote_server_binary))
354            sys.exit(1)
355
356        if remote_server_port is None:
357            remote_server_port = options.remote_server_port
358
359        if remote_server_port != 0 and not is_port_available(remote_server_port, options.autokill_renode):
360            print("The selected port {} is not available".format(remote_server_port))
361            sys.exit(1)
362
363        command = [remote_server_binary, '--robot-server-port', str(remote_server_port)]
364        if not options.show_log and not options.keep_renode_output:
365            command.append('--hide-log')
366        if not options.enable_xwt:
367            command.append('--disable-gui')
368        if options.debug_on_error:
369            command.append('--robot-debug-on-error')
370        if options.keep_temps:
371            command.append('--keep-temporary-files')
372        if options.renode_config:
373            command.append('--config')
374            command.append(options.renode_config)
375
376        if options.runner == 'mono':
377            command.insert(0, 'mono')
378            if options.port is not None:
379                if options.suspend:
380                    print('Waiting for a debugger at port: {}'.format(options.port))
381                command.insert(1, '--debug')
382                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))
383            elif options.debug_mode:
384                command.insert(1, '--debug')
385            options.exclude.append('skip_mono')
386        elif options.runner == 'dotnet':
387            command.insert(0, 'dotnet')
388            options.exclude.append('skip_dotnet')
389
390        renode_command = command
391
392        # if we started GDB, wait for the user to start Renode as a child process
393        if options.run_gdb:
394            command = ['gdb', '-nx', '-ex', 'handle SIGXCPU SIG33 SIG35 SIG36 SIGPWR nostop noprint', '--args'] + command
395            p = psutil.Popen(command, cwd=self.remote_server_directory, bufsize=1)
396
397            if options.keep_renode_output:
398                print("Note: --keep-renode-output is not supported when using --run-gdb")
399
400            print("Waiting for Renode process to start")
401            while True:
402                # We strip argv[0] because if we pass just `mono` to GDB it will resolve
403                # it to a full path to mono on the PATH, for example /bin/mono
404                renode_child = next((c for c in p.children() if c.cmdline()[1:] == renode_command[1:]), None)
405                if renode_child:
406                    break
407                sleep(0.5)
408            self.renode_pid = renode_child.pid
409        elif options.perf_output_path:
410            pid_file_uuid = uuid.uuid4()
411            pid_filename = f'pid_file_{pid_file_uuid}'
412
413            command = ['perf', 'record', '-q', '-g', '-F', 'max'] + command + ['--pid-file', pid_filename]
414
415            perf_stdout_stderr_file_name = "perf_stdout_stderr"
416
417            if options.keep_renode_output:
418                print("Note: --keep-renode-output is not supported when using --perf-output-path")
419
420            print(f"WARNING: perf stdout and stderr is being redirected to {perf_stdout_stderr_file_name}")
421
422            perf_stdout_stderr_file = open(perf_stdout_stderr_file_name, "w")
423            p = subprocess.Popen(command, cwd=self.remote_server_directory, bufsize=1, stdout=perf_stdout_stderr_file, stderr=perf_stdout_stderr_file)
424
425            pid_file_path = os.path.join(self.remote_server_directory, pid_filename)
426            perf_renode_timeout = 10
427
428            while not os.path.exists(pid_file_path) and perf_renode_timeout > 0:
429                sleep(0.5)
430                perf_renode_timeout -= 1
431
432            if perf_renode_timeout <= 0:
433                raise RuntimeError("Renode pid file could not be found, can't attach perf")
434
435            with open(pid_file_path, 'r') as pid_file:
436                self.renode_pid = pid_file.read()
437        else:
438            # Start Renode
439            if options.keep_renode_output:
440                output_dir = self.get_output_dir(options, iteration_index, suite_retry_index)
441                logs_dir = os.path.join(output_dir, 'logs')
442                os.makedirs(logs_dir, exist_ok=True)
443                file_name = os.path.splitext(os.path.basename(self.path))[0]
444                suite_name = RobotTestSuite._create_suite_name(file_name, None)
445                fout = open(os.path.join(logs_dir, f"{suite_name}.renode_stdout.log"), "wb", buffering=0)
446                ferr = open(os.path.join(logs_dir, f"{suite_name}.renode_stderr.log"), "wb", buffering=0)
447                p = subprocess.Popen(command, cwd=self.remote_server_directory, bufsize=1, stdout=fout, stderr=ferr)
448                self.renode_pid = p.pid
449            else:
450                p = subprocess.Popen(command, cwd=self.remote_server_directory, bufsize=1)
451                self.renode_pid = p.pid
452
453        timeout_s = 180
454        countdown = float(timeout_s)
455        temp_dir = tempfile.gettempdir()
456        renode_port_file = os.path.join(temp_dir, f'renode-{self.renode_pid}', 'robot_port')
457        while countdown > 0:
458            try:
459                with open(renode_port_file) as f:
460                    port_num = f.readline()
461                    if port_num == '':
462                        continue
463                    self.remote_server_port = int(port_num)
464                break
465            except:
466                sleep(0.5)
467                countdown -= 0.5
468        else:
469            self._close_remote_server(p, options)
470            raise TimeoutError(f"Couldn't access port file for Renode instance pid {self.renode_pid}; timed out after {timeout_s}s")
471
472        # If a certain port was expected, let's make sure Renode uses it.
473        if remote_server_port and remote_server_port != self.remote_server_port:
474            self._close_remote_server(p, options)
475            raise RuntimeError(f"Renode was expected to use port {remote_server_port} but {self.remote_server_port} port is used instead!")
476
477        assert self.renode_pid != -1, "Renode PID has to be set before returning"
478        return p
479
480    def __move_perf_data(self, options):
481        perf_data_path = os.path.join(self.remote_server_directory, "perf.data")
482
483        if not perf_data_path:
484            raise RuntimeError("perf.data file was not generated succesfully")
485
486        if not os.path.isdir(options.perf_output_path):
487            raise RuntimeError(f"{options.perf_output_path} is not a valid directory path")
488
489        shutil.move(perf_data_path, options.perf_output_path)
490
491    def _close_remote_server(self, proc, options, cleanup_timeout_override=None, silent=False):
492        if proc:
493            if not silent:
494                print('Closing Renode pid {}'.format(proc.pid))
495
496            # Let's prevent using these after the server is closed.
497            self.robot_frontend_process = None
498            self.renode_pid = -1
499
500            try:
501                process = psutil.Process(proc.pid)
502                os.kill(proc.pid, 2)
503                if cleanup_timeout_override is not None:
504                    cleanup_timeout = cleanup_timeout_override
505                else:
506                    cleanup_timeout = options.cleanup_timeout
507                process.wait(timeout=cleanup_timeout)
508
509                if options.perf_output_path:
510                    self.__move_perf_data(options)
511            except psutil.TimeoutExpired:
512                process.kill()
513                process.wait()
514            except psutil.NoSuchProcess:
515                #evidently closed by other means
516                pass
517
518            if options.perf_output_path and proc.stdout:
519                proc.stdout.close()
520
521            # None of the previously provided states are available after closing the server.
522            self._dependencies_met = set()
523
524    @classmethod
525    def _has_renode_crashed(cls, test: ET.Element) -> bool:
526        # only finds immediate children - required because `status`
527        # nodes are also present lower in the tree for example
528        # for every keyword but we only need the status of the test
529        status: ET.Element = test.find('status')
530        if status.text is not None and cls.retry_suite_regex.search(status.text):
531            return True
532        else:
533            return any(cls.retry_suite_regex.search(msg.text) for msg in test.iter("msg"))
534
535
536    def run(self, options, run_id=0, iteration_index=1, suite_retry_index=0):
537        if self.path.endswith('renode-keywords.robot'):
538            print('Ignoring helper file: {}'.format(self.path))
539            return True
540
541        # The list is cleared only on the first run attempt in each iteration so
542        # that tests that time out aren't retried in the given iteration but are
543        # started as usual in subsequent iterations.
544        if suite_retry_index == 0:
545            self.tests_with_unexpected_timeouts = []
546
547        # in non-parallel runs there is only one Renode process for all runs,
548        # unless --keep-renode-output is enabled, in which case a new process
549        # is spawned for every suite to ensure logs are separate files.
550        # see: prepare
551        if options.jobs != 1 or options.keep_renode_output:
552            # Parallel groups run in separate processes so these aren't really
553            # shared, they're only needed to restart Renode in timeout handler.
554            RobotTestSuite.robot_frontend_process = self._run_remote_server(options, iteration_index, suite_retry_index)
555            RobotTestSuite.remote_server_port = self.remote_server_port
556
557        print(f'Running suite on Renode pid {self.renode_pid} using port {self.remote_server_port}: {self.path}')
558
559        result = None
560        def get_result():
561            return result if result is not None else TestResult(True, None)
562
563        start_timestamp = monotonic()
564
565        if any(self.tests_without_hotspots):
566            result = get_result().ok and self._run_inner(options.fixture,
567                                                         None,
568                                                         self.tests_without_hotspots,
569                                                         options,
570                                                         iteration_index,
571                                                         suite_retry_index)
572        if any(self.tests_with_hotspots):
573            for hotspot in RobotTestSuite.hotspot_action:
574                if options.hotspot and options.hotspot != hotspot:
575                    continue
576                result = get_result().ok and self._run_inner(options.fixture,
577                                                             hotspot,
578                                                             self.tests_with_hotspots,
579                                                             options,
580                                                             iteration_index,
581                                                             suite_retry_index)
582
583        end_timestamp = monotonic()
584
585        if result is None:
586            print(f'No tests executed for suite {self.path}', flush=True)
587        else:
588            status = 'finished successfully' if result.ok else 'failed'
589            exec_time = round(end_timestamp - start_timestamp, 2)
590            print(f'Suite {self.path} {status} in {exec_time} seconds.', flush=True)
591
592        if options.jobs != 1 or options.keep_renode_output:
593            self._close_remote_server(RobotTestSuite.robot_frontend_process, options)
594
595        # make sure renode is still alive when a non-parallel run depends on it
596        if options.jobs == 1 and not options.keep_renode_output:
597            if not self._is_frontend_running():
598                print("Renode has unexpectedly died when running sequentially! Trying to respawn before continuing...")
599                RobotTestSuite.robot_frontend_process = self._run_remote_server(options, iteration_index, suite_retry_index)
600                # Save port to reuse when running sequentially
601                RobotTestSuite.remote_server_port = self.remote_server_port
602
603        return get_result()
604
605
606    def _get_dependencies(self, test_case):
607
608        suiteBuilder = robot.running.builder.TestSuiteBuilder()
609        suite = suiteBuilder.build(self.path)
610        test = next(t for t in suite.tests if hasattr(t, 'name') and t.name == test_case)
611        requirements = [s.args[0] for s in test.body if hasattr(s, 'name') and s.name == 'Requires']
612        if len(requirements) == 0:
613            return set()
614        if len(requirements) > 1:
615            raise Exception('Too many requirements for a single test. At most one is allowed.')
616        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)]
617        if len(providers) > 1:
618            raise Exception('Too many providers for state {0} found: {1}'.format(requirements[0], ', '.join(providers.name)))
619        if len(providers) == 0:
620            raise Exception('No provider for state {0} found'.format(requirements[0]))
621        res = self._get_dependencies(providers[0].name)
622        res.add(providers[0].name)
623        return res
624
625
626    def cleanup(self, options):
627        assert hasattr(RobotTestSuite, "log_files"), "tests_engine.py did not assign RobotTestSuite.log_files"
628        RobotTestSuite.instances_count -= 1
629        if RobotTestSuite.instances_count == 0:
630            self._close_remote_server(RobotTestSuite.robot_frontend_process, options)
631            print("Aggregating all robot results")
632            grouped_log_files = self.group_log_paths(RobotTestSuite.log_files)
633            for iteration in range(1, options.iteration_count + 1):
634                for retry in range(options.retry_count):
635                    output_dir = self.get_output_dir(options, iteration, retry)
636                    log_files = grouped_log_files[(iteration, retry)]
637
638                    # An output_dir can be missing for suite retries that were never "used"
639                    if not os.path.isdir(output_dir) or not log_files:
640                        continue
641
642                    robot.rebot(
643                        *log_files,
644                        processemptysuite=True,
645                        name='Test Suite',
646                        loglevel="TRACE:INFO",
647                        outputdir=output_dir,
648                        output='robot_output.xml'
649                    )
650                    for file in set(log_files):
651                        os.remove(file)
652                    if options.css_file:
653                        with open(options.css_file) as style:
654                            style_content = style.read()
655                            for report_name in ("report.html", "log.html"):
656                                with open(os.path.join(output_dir, report_name), "a") as report:
657                                    report.write("<style media=\"all\" type=\"text/css\">")
658                                    report.write(style_content)
659                                    report.write("</style>")
660
661
662            if options.keep_renode_output:
663                logs_pattern = re.compile(r"(?P<suite_name>.*)\.renode_std(out|err)\.log")
664                for dirpath, _, fnames in os.walk(options.results_directory):
665                    if os.path.basename(dirpath.rstrip("/")) != "logs":
666                        continue
667
668                    failed_suites = self.find_suites_with_fails(os.path.dirname(dirpath))
669                    for fname in fnames:
670                        fpath = os.path.join(dirpath, fname)
671                        m = logs_pattern.match(fname)
672                        if m:
673                            # Remove empty logs
674                            if os.path.getsize(fpath) == 0:
675                                os.remove(fpath)
676                                continue
677
678                            if options.save_logs == "onfail":
679                                # Remove logs which weren't failures
680                                suite_name = m.group("suite_name")
681                                if suite_name not in failed_suites:
682                                    os.remove(fpath)
683
684                    # If the logs directory is empty, delete it
685                    try:
686                        os.rmdir(dirpath)
687                    except OSError:
688                        pass
689
690
691    def should_retry_suite(self, options, iteration_index, suite_retry_index):
692        tree = None
693        assert self.suite_log_files is not None, "The suite has not yet been run."
694        output_dir = self.get_output_dir(options, iteration_index, suite_retry_index)
695        for log_file in self.suite_log_files:
696            try:
697                tree = ET.parse(os.path.join(output_dir, log_file))
698            except FileNotFoundError as e:
699                raise e
700
701            root = tree.getroot()
702            for suite in root.iter('suite'):
703                if not suite.get('source', False):
704                    continue # it is a tag used to group other suites without meaning on its own
705
706                # Always retry if our Setup failed.
707                for kw in suite.iter('kw'):
708                    if kw.get('name') == 'Setup' and kw.get('library') == 'renode-keywords':
709                        if kw.find('status').get('status') != 'PASS':
710                            print('Renode Setup failure detected!')
711                            return True
712                        else:
713                            break
714
715                # Look for regular expressions signifying a crash.
716                # Suite Setup and Suite Teardown aren't checked here cause they're in the `kw` tags.
717                for test in suite.iter('test'):
718                    if self._has_renode_crashed(test):
719                        return True
720
721        return False
722
723
724    @staticmethod
725    def _create_suite_name(test_name, hotspot):
726        return test_name + (' [HotSpot action: {0}]'.format(hotspot) if hotspot else '')
727
728
729    def _run_dependencies(self, test_cases_names, options, iteration_index=1, suite_retry_index=0):
730        test_cases_names.difference_update(self._dependencies_met)
731        if not any(test_cases_names):
732            return True
733        self._dependencies_met.update(test_cases_names)
734        return self._run_inner(None, None, test_cases_names, options, iteration_index, suite_retry_index)
735
736
737    def _run_inner(self, fixture, hotspot, test_cases_names, options, iteration_index=1, suite_retry_index=0):
738        file_name = os.path.splitext(os.path.basename(self.path))[0]
739        suite_name = RobotTestSuite._create_suite_name(file_name, hotspot)
740
741        output_dir = self.get_output_dir(options, iteration_index, suite_retry_index)
742        variables = [
743            'SKIP_RUNNING_SERVER:True',
744            'DIRECTORY:{}'.format(self.remote_server_directory),
745            'PORT_NUMBER:{}'.format(self.remote_server_port),
746            'RESULTS_DIRECTORY:{}'.format(output_dir),
747        ]
748        if hotspot:
749            variables.append('HOTSPOT_ACTION:' + hotspot)
750        if options.debug_mode:
751            variables.append('CONFIGURATION:Debug')
752        if options.debug_on_error:
753            variables.append('HOLD_ON_ERROR:True')
754        if options.execution_metrics:
755            variables.append('CREATE_EXECUTION_METRICS:True')
756        if options.save_logs == "always":
757            variables.append('SAVE_LOGS_WHEN:Always')
758        if options.runner == 'dotnet':
759            variables.append('BINARY_NAME:Renode.dll')
760            variables.append('RENODE_PID:{}'.format(self.renode_pid))
761            variables.append('NET_PLATFORM:True')
762        else:
763            options.exclude.append('profiling')
764
765        if options.variables:
766            variables += options.variables
767
768        test_cases = [(test_name, '{0}.{1}'.format(suite_name, test_name)) for test_name in test_cases_names]
769        if fixture:
770            test_cases = [x for x in test_cases if fnmatch.fnmatch(x[1], '*' + fixture + '*')]
771            if len(test_cases) == 0:
772                return None
773            deps = set()
774            for test_name in (t[0] for t in test_cases):
775                deps.update(self._get_dependencies(test_name))
776            if not self._run_dependencies(deps, options, iteration_index, suite_retry_index):
777                return False
778
779        # Listeners are called in the exact order as in `listeners` list for both `start_test` and `end_test`.
780        output_formatter = 'robot_output_formatter_verbose.py' if options.verbose else 'robot_output_formatter.py'
781        listeners = [
782            os.path.join(this_path, f'retry_and_timeout_listener.py:{options.retry_count}'),
783            # Has to be the last one to print final state, message etc. after all the changes made by other listeners.
784            os.path.join(this_path, output_formatter),
785        ]
786        if options.listener:
787            listeners += options.listener
788
789        metadata = {"HotSpot_Action": hotspot if hotspot else '-'}
790        log_file = os.path.join(output_dir, 'results-{0}{1}.robot.xml'.format(file_name, '_' + hotspot if hotspot else ''))
791
792        keywords_path = os.path.abspath(os.path.join(this_path, "renode-keywords.robot"))
793        keywords_path = keywords_path.replace(os.path.sep, "/")  # Robot wants forward slashes even on Windows
794        # This variable is provided for compatibility with Robot files that use Resource ${RENODEKEYWORDS}
795        variables.append('RENODEKEYWORDS:{}'.format(keywords_path))
796        tools_path = os.path.join(os.path.dirname(this_path), "tools")
797        tools_path = tools_path.replace(os.path.sep, "/")
798        variables.append('RENODETOOLS:{}'.format(tools_path))
799        suite_builder = robot.running.builder.TestSuiteBuilder()
800        suite = suite_builder.build(self.path)
801        suite.resource.imports.create(type="Resource", name=keywords_path)
802
803        suite.configure(include_tags=options.include, exclude_tags=options.exclude,
804                        include_tests=[t[1] for t in test_cases], metadata=metadata,
805                        name=suite_name, empty_suite_ok=True)
806
807        # Provide default values for {Suite,Test}{Setup,Teardown}
808        if not suite.setup:
809            suite.setup.config(name="Setup")
810        if not suite.teardown:
811            suite.teardown.config(name="Teardown")
812
813        for test in suite.tests:
814            if not test.setup:
815                test.setup.config(name="Reset Emulation")
816            if not test.teardown:
817                test.teardown.config(name="Test Teardown")
818
819            # Let's just fail tests which previously unexpectedly timed out.
820            if test.name in self.tests_with_unexpected_timeouts:
821                test.config(setup=None, teardown=None)
822                test.body.clear()
823                test.body.create_keyword('Fail', ["Test timed out in a previous run and won't be retried."])
824
825                # This tag tells `RetryFailed` max retries for this test.
826                if 'test:retry' in test.tags:
827                    test.tags.remove('test:retry')
828                test.tags.add('test:retry(0)')
829
830            # Timeout tests with `self.timeout_expected_tag` will be set as passed in the listener
831            # during timeout handling. Their timeout won't be influenced by the global timeout option.
832            if self.timeout_expected_tag in test.tags:
833                if not test.timeout:
834                    print(f"!!!!! Test with a `{self.timeout_expected_tag}` tag must have `[Timeout]` set: {test.longname}")
835                    sys.exit(1)
836            elif options.timeout:
837                # Timeout from tags is used if it's shorter than the global timeout.
838                if not test.timeout or Time(test.timeout).seconds >= options.timeout.seconds:
839                    test.timeout = options.timeout.value
840
841        # Timeout handler is used in `retry_and_timeout_listener.py` and, to be able to call it,
842        # `self` is smuggled in suite's `parent` which is typically None for the main suite.
843        # Listener grabs it on suite start and resets to original value.
844        suite.parent = (self, suite.parent)
845        self.timeout_handler = self._create_timeout_handler(options, iteration_index, suite_retry_index)
846
847        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'])
848
849        self.suite_log_files = []
850        file_name = os.path.splitext(os.path.basename(self.path))[0]
851        output_dir = self.get_output_dir(options, iteration_index, suite_retry_index)
852        if any(self.tests_without_hotspots):
853            log_file = os.path.join(output_dir, 'results-{0}.robot.xml'.format(file_name))
854            if os.path.isfile(log_file):
855                self.suite_log_files.append(log_file)
856        if any(self.tests_with_hotspots):
857            for hotspot in RobotTestSuite.hotspot_action:
858                if options.hotspot and options.hotspot != hotspot:
859                    continue
860                log_file = os.path.join(output_dir, 'results-{0}{1}.robot.xml'.format(file_name, '_' + hotspot if hotspot else ''))
861                if os.path.isfile(log_file):
862                    self.suite_log_files.append(log_file)
863
864        if options.runner == "mono":
865            self.copy_mono_logs(options, iteration_index, suite_retry_index)
866
867        return TestResult(result.return_code == 0, self.suite_log_files)
868
869    def _create_timeout_handler(self, options, iteration_index, suite_retry_index):
870        def _timeout_handler(test: robot.running.TestCase, result: robot.result.TestCase):
871            if self.timeout_expected_tag in test.tags:
872                message_start = '----- Test timed out, as expected,'
873            else:
874                # Let's make the first message stand out if the timeout wasn't expected.
875                message_start = '!!!!! Test timed out'
876
877                # Tests with unexpected timeouts won't be retried.
878                self.tests_with_unexpected_timeouts = test.name
879            print(f"{message_start} after {Time(test.timeout).seconds}s: {test.parent.name}.{test.name}")
880            print(f"----- Skipped flushing emulation log and saving state due to the timeout, restarting Renode...")
881
882            self._close_remote_server(RobotTestSuite.robot_frontend_process, options, cleanup_timeout_override=0, silent=True)
883            RobotTestSuite.robot_frontend_process = self._run_remote_server(options, iteration_index, suite_retry_index, self.remote_server_port)
884
885            # It's typically used in suite setup (renode-keywords.robot:Setup) but we don't need to
886            # call full setup which imports library etc. We only need to resend settings to Renode.
887            BuiltIn().run_keyword("Setup Renode")
888            print(f"----- ...done, running remaining tests on Renode pid {self.renode_pid} using the same port {self.remote_server_port}")
889        return _timeout_handler
890
891
892    def copy_mono_logs(self, options: Namespace, iteration_index: int, suite_retry_index: int) -> None:
893        """Copies 'mono_crash.*.json' files into the suite's logs directory.
894
895        These files are occasionally created when mono crashes. There are also 'mono_crash.*.blob'
896        files, but they contain heavier memory dumps and have questionable usefulness."""
897        output_dir = self.get_output_dir(options, iteration_index, suite_retry_index)
898        logs_dir = os.path.join(output_dir, "logs")
899        for dirpath, dirnames, fnames in os.walk(os.getcwd()):
900            # Do not descend into "logs" directories, to prevent later invocations from
901            # stealing files already moved by earlier invocations
902            logs_indices = [x for x in range(len(dirnames)) if dirnames[x] == "logs"]
903            logs_indices.sort(reverse=True)
904            for logs_idx in logs_indices:
905                del dirnames[logs_idx]
906
907            for fname in filter(lambda x: x.startswith("mono_crash.") and x.endswith(".json"), fnames):
908                os.makedirs(logs_dir, exist_ok=True)
909                src_fpath = os.path.join(dirpath, fname)
910                dest_fpath = os.path.join(logs_dir, fname)
911                print(f"Moving mono_crash file: '{src_fpath}' -> '{dest_fpath}'")
912                os.rename(src_fpath, dest_fpath)
913
914
915    def tests_failed_due_to_renode_crash(self) -> bool:
916        # Return false if the test has not yet run
917        if self.suite_log_files is None:
918            return
919        for file in self.suite_log_files:
920            try:
921                tree = ET.parse(file)
922            except FileNotFoundError:
923                continue
924
925            root = tree.getroot()
926
927            for suite in root.iter('suite'):
928                if not suite.get('source', False):
929                    continue # it is a tag used to group other suites without meaning on its own
930                for test in suite.iter('test'):
931                    # do not check skipped tests
932                    if test.find("./tags/[tag='skipped']"):
933                        continue
934
935                    # only finds immediate children - required because `status`
936                    # nodes are also present lower in the tree for example
937                    # for every keyword but we only need the status of the test
938                    status = test.find('status')
939                    if status.attrib["status"] != "FAIL":
940                        continue # passed tests should not be checked for crashes
941
942                    # check whether renode crashed during this test
943                    if self._has_renode_crashed(test):
944                        return True
945
946        return False
947
948
949    @staticmethod
950    def find_failed_tests(path, file="robot_output.xml"):
951        ret = {'mandatory': set(), 'non_critical': set()}
952
953        # Aggregate failed tests from all report files (can be multiple if iterations or retries were used)
954        for dirpath, _, fnames in os.walk(path):
955            for fname in filter(lambda x: x == file, fnames):
956                tree = ET.parse(os.path.join(dirpath, fname))
957                root = tree.getroot()
958                for suite in root.iter('suite'):
959                    if not suite.get('source', False):
960                        continue # it is a tag used to group other suites without meaning on its own
961                    for test in suite.iter('test'):
962                        status = test.find('status') # only finds immediate children - important requirement
963                        if status.attrib['status'] == 'FAIL':
964                            test_name = test.attrib['name']
965                            suite_name = suite.attrib['name']
966                            if suite_name == "Test Suite":
967                                # If rebot is invoked with only 1 suite, it renames that suite to Test Suite
968                                # instead of wrapping in a new top-level Test Suite. A workaround is to extract
969                                # the suite name from the *.robot file name.
970                                suite_name = os.path.basename(suite.attrib["source"]).rsplit(".", 1)[0]
971                            if test.find("./tags/[tag='skipped']"):
972                                continue # skipped test should not be classified as fail
973                            if test.find("./tags/[tag='non_critical']"):
974                                ret['non_critical'].add(f"{suite_name}.{test_name}")
975                            else:
976                                ret['mandatory'].add(f"{suite_name}.{test_name}")
977
978        if not ret['mandatory'] and not ret['non_critical']:
979            return None
980        return ret
981
982
983    @classmethod
984    def find_suites_with_fails(cls, path, file="robot_output.xml"):
985        """Finds suites which contain at least one test case failure.
986
987        A suite may be successful and still contain failures, e.g. if the --retry option
988        was used and a test passed on a later attempt."""
989        ret = set()
990
991        for dirpath, _, fnames in os.walk(path):
992            for fname in filter(lambda x: x == file, fnames):
993                tree = ET.parse(os.path.join(dirpath, fname))
994                root = tree.getroot()
995                for suite in root.iter('suite'):
996                    if not suite.get('source', False):
997                        continue  # it is a tag used to group other suites without meaning on its own
998
999                    suite_name = suite.attrib['name']
1000                    if suite_name == "Test Suite":
1001                        # If rebot is invoked with only 1 suite, it renames that suite to Test Suite
1002                        # instead of wrapping in a new top-level Test Suite. A workaround is to extract
1003                        # the suite name from the *.robot file name.
1004                        suite_name = os.path.basename(suite.attrib["source"]).rsplit(".", 1)[0]
1005
1006                    for test in suite.iter('test'):
1007                        if test.find("./tags/[tag='skipped']"):
1008                            continue  # skipped test should not be classified as fail
1009                        status = test.find('status')  # only finds immediate children - important requirement
1010                        if status.attrib["status"] == "FAIL":
1011                            ret.add(suite_name)
1012                            break
1013                        if status.text is not None and cls.retry_test_regex.search(status.text):
1014                            # Retried test cases still count as fails
1015                            ret.add(suite_name)
1016                            break
1017
1018        return ret
1019
1020
1021    @staticmethod
1022    def group_log_paths(paths: List[str]) -> Dict[Tuple[int, int], Set[str]]:
1023        """Breaks a list of log paths into subsets grouped by (iteration, suite_retry) pairs."""
1024        re_path_indices_patterns = (
1025            re.compile(r"\biteration(?P<iteration>\d+)/retry(?P<suite_retry>\d+)/"),
1026            re.compile(r"\bretry(?P<suite_retry>\d+)/"),
1027            re.compile(r"\biteration(?P<iteration>\d+)/"),
1028        )
1029        ret = defaultdict(lambda: set())
1030        for path in paths:
1031            iteration = 1
1032            suite_retry = 0
1033            for pattern in re_path_indices_patterns:
1034                match = pattern.search(path)
1035                if match is None:
1036                    continue
1037                try:
1038                    iteration = int(match.group("iteration"))
1039                except IndexError:
1040                    pass
1041                try:
1042                    suite_retry = int(match.group("suite_retry"))
1043                except IndexError:
1044                    pass
1045            ret[(iteration, suite_retry)].add(path)
1046        return ret
1047
1048
1049    @classmethod
1050    def find_rerun_tests(cls, path):
1051
1052        def analyze_xml(label, retry_dir, file="robot_output.xml"):
1053            try:
1054                tree = ET.parse(os.path.join(retry_dir, file))
1055            except FileNotFoundError:
1056                return
1057            root = tree.getroot()
1058            for suite in root.iter('suite'):
1059                if not suite.get('source', False):
1060                    continue # it is a tag used to group other suites without meaning on its own
1061                suite_name = suite.attrib['name']
1062                if suite_name == "Test Suite":
1063                    # If rebot is invoked with only 1 suite, it renames that suite to Test Suite
1064                    # instead of wrapping in a new top-level Test Suite. A workaround is to extract
1065                    # the suite name from the *.robot file name.
1066                    suite_name = os.path.basename(suite.attrib["source"]).rsplit(".", 1)[0]
1067                for test in suite.iter('test'):
1068                    test_name = test.attrib['name']
1069                    tags = []
1070                    if test.find("./tags/[tag='skipped']"):
1071                        continue # skipped test should not be classified as fail
1072                    if test.find("./tags/[tag='non_critical']"):
1073                        tags.append("non_critical")
1074                    status = test.find('status') # only finds immediate children - important requirement
1075                    m = cls.retry_test_regex.search(status.text) if status.text is not None else None
1076
1077                    # Check whether renode crashed during this test
1078                    has_renode_crashed = cls._has_renode_crashed(test)
1079
1080                    status_str = status.attrib["status"]
1081                    nth = (1 + int(m.group(2))) if m else 1
1082                    key = f"{suite_name}.{test_name}"
1083                    if key not in data:
1084                        data[key] = []
1085                    data[key].append({
1086                        "label": label,        # e.g. "retry0", "retry1", "iteration1/retry2", ...
1087                        "status": status_str,  # e.g. "PASS", "FAIL", "SKIP", ...
1088                        "nth": nth,            # The number of test case attempts that led to the above status
1089                        "tags": tags,          # e.g. ["non_critical"], [], ...
1090                        "crash": has_renode_crashed,
1091                    })
1092
1093        def analyze_iteration(iteration_dir):
1094            iteration_dirname = os.path.basename(iteration_dir)
1095            report_fpath = os.path.join(iteration_dir, "robot_output.xml")
1096            if os.path.isfile(report_fpath):
1097                analyze_xml(iteration_dirname, iteration_dir)
1098                return
1099            i = -1
1100            while True:
1101                i += 1
1102                retry_dirpath = os.path.join(iteration_dir, f"retry{i}")
1103                if os.path.isdir(retry_dirpath):
1104                    analyze_xml(os.path.join(iteration_dirname, f"retry{i}"), retry_dirpath)
1105                    continue
1106                break
1107
1108        data = OrderedDict()
1109        i = -1
1110        while True:
1111            i += 1
1112            iteration_dirpath = os.path.join(path, f"iteration{i + 1}")
1113            retry_dirpath = os.path.join(path, f"retry{i}")
1114            if os.path.isdir(iteration_dirpath):
1115                analyze_iteration(iteration_dirpath)
1116                continue
1117            elif os.path.isdir(retry_dirpath):
1118                analyze_xml(f"retry{i}", retry_dirpath)
1119                continue
1120            break
1121
1122        return data
1123