Refactor hftest.py

Changes will be required to hftest.py to support DT overlays, driving
tests over serial port, etc. Refactor into classes with well-defined
roles to make this easier.

Change-Id: Ib4855281456d27627b9a5c6c7642a6cd5519b64f
diff --git a/test/hftest/hftest.py b/test/hftest/hftest.py
index 9a9019c..99699b7 100755
--- a/test/hftest/hftest.py
+++ b/test/hftest/hftest.py
@@ -14,9 +14,8 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
-"""Run tests.
-
-Runs tests on QEMU.
+"""Script which drives invocation of tests and parsing their output to produce
+a results report.
 """
 
 from __future__ import print_function
@@ -24,6 +23,7 @@
 import xml.etree.ElementTree as ET
 
 import argparse
+import collections
 import datetime
 import json
 import os
@@ -35,141 +35,390 @@
 HFTEST_LOG_FAILURE_PREFIX = "Failure:"
 HFTEST_LOG_FINISHED = "FINISHED"
 
-HF_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
+HF_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(
+    os.path.abspath(__file__))))
 DTC_SCRIPT = os.path.join(HF_ROOT, "build", "image", "dtc.py")
+FVP_BINARY = os.path.join(
+    os.path.dirname(HF_ROOT), "fvp", "Base_RevC_AEMv8A_pkg", "models",
+    "Linux64_GCC-4.9", "FVP_Base_RevC-2xAEMv8A")
+FVP_PREBUILT_DTS = os.path.join(
+    HF_ROOT, "prebuilts", "linux-aarch64", "arm-trusted-firmware",
+    "fvp-base-gicv3-psci-1t.dts")
 
-def log_timeout_returncode(f, returncode):
-    if returncode == 0:
-        return
-    elif returncode == 124:
-        f.write("\r\n{}{} timed out\r\n".format(HFTEST_LOG_PREFIX,
-                                                HFTEST_LOG_FAILURE_PREFIX))
-    else:
-        f.write("\r\n{}{} process return code {}\r\n".format(
-            HFTEST_LOG_PREFIX, HFTEST_LOG_FAILURE_PREFIX, returncode))
+def read_file(path):
+    with open(path, "r") as f:
+        return f.read()
 
-def qemu(image, initrd, args, log):
-    qemu_args = [
-        "timeout", "--foreground", "10s",
-        "./prebuilts/linux-x64/qemu/qemu-system-aarch64", "-M", "virt,gic_version=3",
-        "-cpu", "cortex-a57", "-smp", "4", "-m", "64M", "-machine", "virtualization=true",
-        "-nographic", "-nodefaults", "-serial", "stdio", "-kernel", image,
-    ]
-    if initrd:
-        qemu_args += ["-initrd", initrd]
-    if args:
-        qemu_args += ["-append", args]
-    # Save the log to a file.
-    with open(log, "w") as f:
-        f.write("$ {}\r\n".format(" ".join(qemu_args)))
-        f.flush()
+def write_file(path, to_write, append=False):
+    with open(path, "a" if append else "w") as f:
+        f.write(to_write)
+
+def append_file(path, to_write):
+    write_file(path, to_write, append=True)
+
+def join_if_not_None(*args):
+    return " ".join(filter(lambda x: x, args))
+
+class ArtifactsManager:
+    """Class which manages folder with test artifacts."""
+
+    def __init__(self, log_dir):
+        self.created_files = []
+        self.log_dir = log_dir
+
+        # Create directory.
         try:
-            subprocess.check_call(qemu_args, stdout=f, stderr=f)
-        except subprocess.CalledProcessError as e:
-            log_timeout_returncode(f, e.returncode)
-    # Return that log for processing.
-    with open(log, "r") as f:
-        return f.read()
+            os.makedirs(self.log_dir)
+        except OSError:
+            if not os.path.isdir(self.log_dir):
+                raise
+        print("Logs saved under", log_dir)
+
+        # Create files expected by the Sponge test result parser.
+        self.sponge_log_path = self.create_file("sponge_log", ".log")
+        self.sponge_xml_path = self.create_file("sponge_log", ".xml")
+
+    def create_file(self, basename, extension):
+        """Create and touch a new file in the log folder. Ensure that no other
+        file of the same name was created by this instance of ArtifactsManager.
+        """
+        # Determine the path of the file.
+        path = os.path.join(self.log_dir, basename + extension)
+
+        # Check that the path is unique.
+        assert(path not in self.created_files)
+        self.created_files += [ path ]
+
+        # Touch file.
+        with open(path, "w") as f:
+            pass
+
+        return path
 
 
-def fvp(image, initrd, args, log):
-    uart0_log = log + ".uart0"
-    uart1_log = log + ".uart1"
-    fdt = log + ".dtb"
-    dtc_args = [ DTC_SCRIPT, "-o", fdt ]
-    fvp_args = [
-        "timeout", "--foreground", "40s",
-        "../fvp/Base_RevC_AEMv8A_pkg/models/Linux64_GCC-4.9/FVP_Base_RevC-2xAEMv8A",
-        "-C", "pctl.startup=0.0.0.0",
-        "-C", "bp.secure_memory=0",
-        "-C", "cluster0.NUM_CORES=4",
-        "-C", "cluster1.NUM_CORES=4",
-        "-C", "cache_state_modelled=0",
-        "-C", "bp.vis.disable_visualisation=true",
-        "-C", "bp.vis.rate_limit-enable=false",
-        "-C", "bp.terminal_0.start_telnet=false",
-        "-C", "bp.terminal_1.start_telnet=false",
-        "-C", "bp.terminal_2.start_telnet=false",
-        "-C", "bp.terminal_3.start_telnet=false",
-        "-C", "bp.pl011_uart0.untimed_fifos=1",
-        "-C", "bp.pl011_uart0.unbuffered_output=1",
-        "-C", "bp.pl011_uart0.out_file=" + uart0_log,
-        "-C", "bp.pl011_uart1.out_file=" + uart1_log,
-        "-C", "cluster0.cpu0.RVBAR=0x04020000",
-        "-C", "cluster0.cpu1.RVBAR=0x04020000",
-        "-C", "cluster0.cpu2.RVBAR=0x04020000",
-        "-C", "cluster0.cpu3.RVBAR=0x04020000",
-        "-C", "cluster1.cpu0.RVBAR=0x04020000",
-        "-C", "cluster1.cpu1.RVBAR=0x04020000",
-        "-C", "cluster1.cpu2.RVBAR=0x04020000",
-        "-C", "cluster1.cpu3.RVBAR=0x04020000",
-        "--data", "cluster0.cpu0=prebuilts/linux-aarch64/arm-trusted-firmware/bl31.bin@0x04020000",
-        "--data", "cluster0.cpu0=" + fdt + "@0x82000000",
-        "--data", "cluster0.cpu0=" + image + "@0x80000000",
-        "-C", "bp.ve_sysregs.mmbSiteDefault=0",
-        "-C", "bp.ve_sysregs.exit_on_shutdown=1",
-    ]
-    initrd_start = 0x84000000
-    initrd_end = 0x85000000  # Default value
-    if initrd:
-        fvp_args += ["--data", "cluster0.cpu0={}@{}".format(initrd, hex(initrd_start))]
-        initrd_end = initrd_start + os.path.getsize(initrd)
+# Tuple holding the arguments common to all driver constructors.
+# This is to avoid having to pass arguments from subclasses to superclasses.
+DriverArgs = collections.namedtuple("DriverArgs", [
+        "artifacts",
+        "kernel",
+        "initrd",
+        "vm_args",
+    ])
 
 
-    with open(log, "w") as f:
-        f.write("$ {}\r\n".format(" ".join(dtc_args)))
-        f.flush()
-        dtc = subprocess.Popen(
-            dtc_args, stdout=f, stderr=f, stdin=subprocess.PIPE)
-        with open(
-                "prebuilts/linux-aarch64/arm-trusted-firmware/fvp-base-gicv3-psci-1t.dts",
-                "r") as base_dts:
-            dtc.stdin.write(base_dts.read())
-        dtc.stdin.write("/ {\n")
-        dtc.stdin.write("        chosen {\n")
-        dtc.stdin.write("                bootargs = \"" + args + "\";\n")
-        dtc.stdin.write("                stdout-path = \"serial0:115200n8\";\n")
-        dtc.stdin.write("                linux,initrd-start = <{}>;\n".format(initrd_start))
-        dtc.stdin.write("                linux,initrd-end = <{}>;\n".format(initrd_end))
-        dtc.stdin.write("        };\n")
-        dtc.stdin.write("};\n")
-        dtc.stdin.close()
-        dtc.wait()
-
-        f.write("$ {}\r\n".format(" ".join(fvp_args)))
-        f.flush()
-        returncode = subprocess.call(fvp_args, stdout=f, stderr=f)
-        with open(uart0_log, "r") as g:
-            f.write(g.read())
-        log_timeout_returncode(f, returncode)
-
-    with open(log, "r") as f:
-        return f.read()
+# State shared between the common Driver class and its subclasses during
+# a single invocation of the target platform.
+DriverRunState = collections.namedtuple("DriverRunState", [
+        "log_path",
+    ])
 
 
-def emulator(use_fvp, image, initrd, args, log):
-    if use_fvp:
-        return fvp(image, initrd, args, log)
-    else:
-        return qemu(image, initrd, args, log)
+class Driver:
+    """Parent class of drivers for all testable platforms."""
+
+    def __init__(self, args):
+        self.args = args
+
+    def start_run(self, run_name):
+        """Hook called by Driver subclasses before they invoke the target
+        platform."""
+        return DriverRunState(self.args.artifacts.create_file(run_name, ".log"))
+
+    def exec_logged(self, run_state, exec_args):
+        """Run a subprocess on behalf of a Driver subclass and append its
+        stdout and stderr to the main log."""
+        with open(run_state.log_path, "a") as f:
+            f.write("$ {}\r\n".format(" ".join(exec_args)))
+            f.flush()
+            return subprocess.call(exec_args, stdout=f, stderr=f)
+
+    def finish_run(self, run_state, ret_code):
+        """Hook called by Driver subclasses after they finished running the
+        target platform. `ret_code` argument is the return code of the main
+        command run by the driver. A corresponding log message is printed."""
+        # Decode return code and add a message to the log.
+        with open(run_state.log_path, "a") as f:
+            if ret_code == 124:
+                f.write("\r\n{}{} timed out\r\n".format(
+                    HFTEST_LOG_PREFIX, HFTEST_LOG_FAILURE_PREFIX))
+            elif ret_code != 0:
+                f.write("\r\n{}{} process return code {}\r\n".format(
+                    HFTEST_LOG_PREFIX, HFTEST_LOG_FAILURE_PREFIX, ret_code))
+
+        # Append log of this run to full test log.
+        log_content = read_file(run_state.log_path)
+        append_file(
+            self.args.artifacts.sponge_log_path,
+            log_content + "\r\n\r\n")
+        return log_content
 
 
-def ensure_dir(path):
-    try:
-        os.makedirs(path)
-    except OSError:
-        if not os.path.isdir(path):
-            raise
+class QemuDriver(Driver):
+    """Driver which runs tests in QEMU."""
+
+    def __init__(self, args):
+        Driver.__init__(self, args)
+
+    def gen_exec_args(self, test_args):
+        """Generate command line arguments for QEMU."""
+        exec_args = [
+            "timeout", "--foreground", "10s",
+            "./prebuilts/linux-x64/qemu/qemu-system-aarch64",
+            "-M", "virt,gic_version=3",
+            "-cpu", "cortex-a57", "-smp", "4", "-m", "64M",
+            "-machine", "virtualization=true",
+            "-nographic", "-nodefaults", "-serial", "stdio",
+            "-kernel", self.args.kernel,
+        ]
+
+        if self.args.initrd:
+            exec_args += ["-initrd", self.args.initrd]
+
+        vm_args = join_if_not_None(self.args.vm_args, test_args)
+        if vm_args:
+            exec_args += ["-append", vm_args]
+
+        return exec_args
+
+    def run(self, run_name, test_args):
+        """Run test given by `test_args` in QEMU."""
+        run_state = self.start_run(run_name)
+        ret_code = self.exec_logged(run_state, self.gen_exec_args(test_args))
+        return self.finish_run(run_state, ret_code)
 
 
-def hftest_lines(raw):
-    lines = []
-    for line in raw.splitlines():
-        if line.startswith("VM "):
-            line = line[len("VM 0: "):]
-        if line.startswith(HFTEST_LOG_PREFIX):
-            lines.append(line[len(HFTEST_LOG_PREFIX):])
-    return lines
+class FvpDriver(Driver):
+    """Driver which runs tests in ARM FVP emulator."""
+
+    def __init__(self, args):
+        Driver.__init__(self, args)
+
+    def gen_dts(self, dts_path, test_args, initrd_start, initrd_end):
+        """Create a DeviceTree source which will be compiled into a DTB and
+        passed to FVP for a test run."""
+        vm_args = join_if_not_None(self.args.vm_args, test_args)
+        write_file(dts_path, read_file(FVP_PREBUILT_DTS))
+        append_file(dts_path, """
+                / {{
+                    chosen {{
+                        bootargs = "{}";
+                        stdout-path = "serial0:115200n8";
+                        linux,initrd-start = <{}>;
+                        linux,initrd-end = <{}>;
+                    }};
+                }};
+            """.format(vm_args, initrd_start, initrd_end))
+
+    def gen_fvp_args(
+            self, initrd_start, uart0_log_path, uart1_log_path, dtb_path):
+        """Generate command line arguments for FVP."""
+        fvp_args = [
+            "timeout", "--foreground", "40s",
+            FVP_BINARY,
+            "-C", "pctl.startup=0.0.0.0",
+            "-C", "bp.secure_memory=0",
+            "-C", "cluster0.NUM_CORES=4",
+            "-C", "cluster1.NUM_CORES=4",
+            "-C", "cache_state_modelled=0",
+            "-C", "bp.vis.disable_visualisation=true",
+            "-C", "bp.vis.rate_limit-enable=false",
+            "-C", "bp.terminal_0.start_telnet=false",
+            "-C", "bp.terminal_1.start_telnet=false",
+            "-C", "bp.terminal_2.start_telnet=false",
+            "-C", "bp.terminal_3.start_telnet=false",
+            "-C", "bp.pl011_uart0.untimed_fifos=1",
+            "-C", "bp.pl011_uart0.unbuffered_output=1",
+            "-C", "bp.pl011_uart0.out_file=" + uart0_log_path,
+            "-C", "bp.pl011_uart1.out_file=" + uart1_log_path,
+            "-C", "cluster0.cpu0.RVBAR=0x04020000",
+            "-C", "cluster0.cpu1.RVBAR=0x04020000",
+            "-C", "cluster0.cpu2.RVBAR=0x04020000",
+            "-C", "cluster0.cpu3.RVBAR=0x04020000",
+            "-C", "cluster1.cpu0.RVBAR=0x04020000",
+            "-C", "cluster1.cpu1.RVBAR=0x04020000",
+            "-C", "cluster1.cpu2.RVBAR=0x04020000",
+            "-C", "cluster1.cpu3.RVBAR=0x04020000",
+            "--data", "cluster0.cpu0=prebuilts/linux-aarch64/arm-trusted-firmware/bl31.bin@0x04020000",
+            "--data", "cluster0.cpu0=" + dtb_path + "@0x82000000",
+            "--data", "cluster0.cpu0=" + self.args.kernel + "@0x80000000",
+            "-C", "bp.ve_sysregs.mmbSiteDefault=0",
+            "-C", "bp.ve_sysregs.exit_on_shutdown=1",
+        ]
+
+        if self.args.initrd:
+            fvp_args += [
+                "--data",
+                "cluster0.cpu0={}@{}".format(
+                    self.args.initrd, hex(initrd_start))
+            ]
+
+        return fvp_args
+
+    def run(self, run_name, test_args):
+        run_state = self.start_run(run_name)
+
+        dts_path = self.args.artifacts.create_file(run_name, ".dts")
+        dtb_path = self.args.artifacts.create_file(run_name, ".dtb")
+        uart0_log_path = self.args.artifacts.create_file(run_name, ".uart0.log")
+        uart1_log_path = self.args.artifacts.create_file(run_name, ".uart1.log")
+
+        initrd_start = 0x84000000
+        if self.args.initrd:
+            initrd_end = initrd_start + os.path.getsize(self.args.initrd)
+        else:
+            initrd_end = 0x85000000  # Default value
+
+        # Create a FDT to pass to FVP.
+        self.gen_dts(dts_path, test_args, initrd_start, initrd_end)
+        dtc_args = [ DTC_SCRIPT, "-i", dts_path, "-o", dtb_path ]
+        self.exec_logged(run_state, dtc_args)
+
+        # Run FVP.
+        fvp_args = self.gen_fvp_args(
+            initrd_start, uart0_log_path, uart1_log_path, dtb_path)
+        ret_code = self.exec_logged(run_state, fvp_args)
+
+        # Append UART0 output to main log.
+        append_file(run_state.log_path, read_file(uart0_log_path))
+
+        return self.finish_run(run_state, ret_code)
+
+
+# Tuple used to return information about the results of running a set of tests.
+TestRunnerResult = collections.namedtuple("TestRunnerResult", [
+        "tests_run",
+        "tests_failed",
+    ])
+
+
+class TestRunner:
+    """Class which communicates with a test platform to obtain a list of
+    available tests and driving their execution."""
+
+    def __init__(self, artifacts, driver, image_name, suite_regex, test_regex):
+        self.artifacts = artifacts
+        self.driver = driver
+        self.image_name = image_name
+
+        self.suite_re = re.compile(suite_regex or ".*")
+        self.test_re = re.compile(test_regex or ".*")
+
+    def extract_hftest_lines(self, raw):
+        """Extract hftest-specific lines from a raw output from an invocation
+        of the test platform."""
+        lines = []
+        for line in raw.splitlines():
+            if line.startswith("VM "):
+                line = line[len("VM 0: "):]
+            if line.startswith(HFTEST_LOG_PREFIX):
+                lines.append(line[len(HFTEST_LOG_PREFIX):])
+        return lines
+
+    def get_test_json(self):
+        """Invoke the test platform and request a JSON of available test and
+        test suites."""
+        out = self.driver.run("json", "json")
+        hf_out = "\n".join(self.extract_hftest_lines(out))
+        try:
+            return json.loads(hf_out)
+        except ValueError as e:
+            print(out)
+            raise e
+
+    def collect_results(self, fn, it, xml_node):
+        """Run `fn` on every entry in `it` and collect their TestRunnerResults.
+        Insert "tests" and "failures" nodes to `xml_node`."""
+        tests_run = 0
+        tests_failed = 0
+        for i in it:
+            sub_result = fn(i)
+            assert(sub_result.tests_run >= sub_result.tests_failed)
+            tests_run += sub_result.tests_run
+            tests_failed += sub_result.tests_failed
+
+        xml_node.set("tests", str(tests_run))
+        xml_node.set("failures", str(tests_failed))
+        return TestRunnerResult(tests_run, tests_failed)
+
+    def is_passed_test(self, test_out):
+        """Parse the output of a test and return True if it passed."""
+        return \
+            len(test_out) > 0 and \
+            test_out[-1] == HFTEST_LOG_FINISHED and \
+            not any(l.startswith(HFTEST_LOG_FAILURE_PREFIX) for l in test_out)
+
+    def run_test(self, suite, test, suite_xml):
+        """Invoke the test platform and request to run a given `test` in given
+        `suite`. Create a new XML node with results under `suite_xml`.
+        Test only invoked if it matches the regex given to constructor."""
+        if not self.test_re.match(test):
+            return TestRunnerResult(tests_run=0, tests_failed=0)
+
+        print("      RUN", test)
+        log_name = suite["name"] + "." + test
+
+        test_xml = ET.SubElement(suite_xml, "testcase")
+        test_xml.set("name", test)
+        test_xml.set("classname", suite['name'])
+        test_xml.set("status", "run")
+
+        out = self.extract_hftest_lines(self.driver.run(
+            log_name, "run {} {}".format(suite["name"], test)))
+
+        if self.is_passed_test(out):
+            print("        PASS")
+            return TestRunnerResult(tests_run=1, tests_failed=0)
+        else:
+            print("[x]     FAIL --", self.driver.file_path(log_name))
+            failure_xml = ET.SubElement(test_xml, "failure")
+            # TODO: set a meaningful message and put log in CDATA
+            failure_xml.set("message", "Test failed")
+            return TestRunnerResult(tests_run=1, tests_failed=1)
+
+    def run_suite(self, suite, xml):
+        """Invoke the test platform and request to run all matching tests in
+        `suite`. Create new XML nodes with results under `xml`.
+        Suite skipped if it does not match the regex given to constructor."""
+        if not self.suite_re.match(suite["name"]):
+            return TestRunnerResult(tests_run=0, tests_failed=0)
+
+        print("    SUITE", suite["name"])
+        suite_xml = ET.SubElement(xml, "testsuite")
+        suite_xml.set("name", suite["name"])
+
+        return self.collect_results(
+            lambda test: self.run_test(suite, test, suite_xml),
+            suite["tests"],
+            suite_xml)
+
+    def run_tests(self):
+        """Run all suites and tests matching regexes given to constructor.
+        Write results to sponge log XML. Return the number of run and failed
+        tests."""
+
+        test_spec = self.get_test_json()
+        timestamp = datetime.datetime.now().replace(microsecond=0).isoformat()
+
+        xml = ET.Element("testsuites")
+        xml.set("name", self.image_name)
+        xml.set("timestamp", timestamp)
+
+        result = self.collect_results(
+            lambda suite: self.run_suite(suite, xml),
+            test_spec["suites"],
+            xml)
+
+        # Write XML to file.
+        with open(self.artifacts.sponge_xml_path, "w") as f:
+            ET.ElementTree(xml).write(f, encoding='utf-8', xml_declaration=True)
+
+        if result.tests_failed > 0:
+            print("[x] FAIL:", result.tests_failed, "of", result.tests_run,
+                    "tests failed")
+        elif result.tests_run > 0:
+            print("    PASS: all", result.tests_run, "tests passed")
+
+        return result
 
 
 def Main():
@@ -184,100 +433,41 @@
     parser.add_argument("--vm_args")
     parser.add_argument("--fvp", type=bool)
     args = parser.parse_args()
+
     # Resolve some paths.
     image = os.path.join(args.out, args.image + ".bin")
     initrd = None
-    suite = args.image
+    image_name = args.image
     if args.initrd:
         initrd = os.path.join(args.out_initrd, "obj", args.initrd, "initrd.img")
-        suite += "_" + args.initrd
+        image_name += "_" + args.initrd
     vm_args = args.vm_args or ""
-    log = os.path.join(args.log, suite)
-    ensure_dir(log)
-    print("Logs saved under", log)
-    log_file = os.path.join(log, "sponge_log.log")
-    with open(log_file, "w") as full_log:
-        # Query the tests in the image.
-        out = emulator(args.fvp, image, initrd, vm_args + " json",
-                       os.path.join(log, "json.log"))
-        full_log.write(out)
-        full_log.write("\r\n\r\n")
-        hftest_json = "\n".join(hftest_lines(out))
-        try:
-            tests = json.loads(hftest_json)
-        except ValueError:
-            print(hftest_json)
-            return 2
-        # Run the selected tests.
-        tests_run = 0
-        failures = 0
-        suite_re = re.compile(args.suite or ".*")
-        test_re = re.compile(args.test or ".*")
-        suites_xml = ET.Element("testsuites")
-        suites_xml.set("name", suite)
-        suites_xml.set(
-            "timestamp",
-            datetime.datetime.now().replace(microsecond=0).isoformat())
-        for suite in tests["suites"]:
-            if not suite_re.match(suite["name"]):
-                continue
-            tests_run_from_suite = 0
-            failures_from_suite = 0
-            suite_xml = ET.SubElement(suites_xml, "testsuite")
-            suite_xml.set("name", suite["name"])
-            for test in suite["tests"]:
-                if not test_re.match(test):
-                    continue
-                test_xml = ET.SubElement(suite_xml, "testcase")
-                test_xml.set("name", test)
-                test_xml.set("classname", suite['name'])
-                test_xml.set("status", "run")
-                tests_run_from_suite += 1
-                if tests_run_from_suite == 1:
-                    print("    SUITE", suite["name"])
-                print("      RUN", test)
-                test_log = os.path.join(log,
-                                        suite["name"] + "." + test + ".log")
-                out = emulator(
-                    args.fvp, image, initrd,
-                    vm_args + " run {} {}".format(suite["name"], test),
-                    test_log)
-                full_log.write(out)
-                full_log.write("\r\n\r\n")
-                hftest_out = hftest_lines(out)
-                if len(
-                        hftest_out
-                ) > 0 and hftest_out[-1] == HFTEST_LOG_FINISHED and not any(
-                        l.startswith(HFTEST_LOG_FAILURE_PREFIX)
-                        for l in hftest_out):
-                    print("        PASS")
-                else:
-                    failures_from_suite += 1
-                    failure_xml = ET.SubElement(test_xml, "failure")
-                    # TODO: set a meaningful message and put log in CDATA
-                    failure_xml.set("message", "Test failed")
-                    print("[x]     FAIL --", test_log)
-            tests_run += tests_run_from_suite
-            failures += failures_from_suite
-            suite_xml.set("tests", str(tests_run_from_suite))
-            suite_xml.set("failures", str(failures_from_suite))
-    suites_xml.set("tests", str(tests_run))
-    suites_xml.set("failures", str(failures))
-    with open(os.path.join(log, "sponge_log.xml"), "w") as f:
-        ET.ElementTree(suites_xml).write(
-            f, encoding='utf-8', xml_declaration=True)
-    # If none were run, this is probably a mistake.
-    if tests_run == 0:
+
+    # Create class which will manage all test artifacts.
+    artifacts = ArtifactsManager(os.path.join(args.log, image_name))
+
+    # Create a driver for the platform we want to test on.
+    driver_args = DriverArgs(artifacts, image, initrd, vm_args)
+    if args.fvp:
+        driver = FvpDriver(driver_args)
+    else:
+        driver = QemuDriver(driver_args)
+
+    # Create class which will drive test execution.
+    runner = TestRunner(artifacts, driver, image_name, args.suite, args.test)
+
+    # Run tests.
+    runner_result = runner.run_tests()
+
+    # Print error message if no tests were run as this is probably unexpected.
+    # Return suitable error code.
+    if runner_result.tests_run == 0:
         print("Error: no tests match")
         return 10
-    # Exit with 0 on success and 1 if any test failed.
-    if failures:
-        print("[x] FAIL:", failures, "of", tests_run, "tests failed")
+    elif runner_result.tests_failed > 0:
         return 1
     else:
-        print("    PASS: all", tests_run, "tests passed")
-    return 0
-
+        return 0
 
 if __name__ == "__main__":
     sys.exit(Main())