scripts: Support composite command in test maps

Some tests are side-effectful and must therefore be excecuted in
a particular order. Add a composite command element to group such
tests. Composite tests can contain port tests and and reboot
commands. The latter should no longer occur outside composite
tests.

Bug: 236898842
Change-Id: I28569a3ae4a819b3cdf2c39fea4c9125e33551ea
diff --git a/scripts/run_tests.py b/scripts/run_tests.py
index bc0f028..e8242aa 100755
--- a/scripts/run_tests.py
+++ b/scripts/run_tests.py
@@ -31,8 +31,12 @@
 import subprocess
 import sys
 import time
+from typing import Optional
 
-import trusty_build_config
+from trusty_build_config import TrustyTest, TrustyCompositeTest
+from trusty_build_config import TrustyRebootCommand, TrustyHostTest
+from trusty_build_config import TrustyBuildConfig
+
 
 class TestResults(object):
     """Stores test results.
@@ -144,7 +148,7 @@
 
         return run
 
-    def run_test(test):
+    def run_test(test, parent_test: Optional[TrustyCompositeTest] = None) -> int:
         """Execute a single test and print out helpful information"""
         cmd = test.command[1:]
         disable_rpmb = True if "--disable_rpmb" in cmd else None
@@ -154,14 +158,21 @@
         test_start_time = time.time()
 
         match test:
-            case trusty_build_config.TrustyHostTest():
+            case TrustyHostTest():
                 # append nice and expand path to command
                 cmd = ["nice", f"{project_root}/{test.command[0]}"] + cmd
                 print("Command line:",
                       " ".join([s.replace(" ", "\\ ") for s in cmd]),
                       flush=True)
                 status = subprocess.call(cmd)
-            case trusty_build_config.TrustyTest():
+            case TrustyCompositeTest():
+                status = 0
+                for subtest in test.sequence:
+                    if status := run_test(subtest, test):
+                        # fail the composite test with the same status code as
+                        # the first failing subtest
+                        break
+            case TrustyTest():
                 if hasattr(test, "shell_command"):
                     print("Command line:",
                           test.shell_command.replace(" ", "\\ "),
@@ -181,16 +192,21 @@
                 finally:
                     if test_env:
                         test_env.shutdown(test_runner)
-            case trusty_build_config.TrustyRebootCommand():
+            case TrustyRebootCommand() if parent_test:
+                assert isinstance(parent_test, TrustyCompositeTest)
                 # ignore reboot commands since we are not (yet) batching
                 # tests such that they can share an emulator instance.
-                return
+                return 0
+            case TrustyRebootCommand():
+                raise RuntimeError(
+                    "Reboot may only be used inside compositetest")
             case _:
                 raise NotImplementedError(f"Don't know how to run {test.name}")
 
         elapsed = time.time() - test_start_time
         print(f"{test.name:s} returned {status:d} after {elapsed:.3f} seconds")
         test_results.add_result(test.name, status == 0)
+        return status
 
     for test in project_config.tests:
         if not test.enabled and not run_disabled_tests:
@@ -211,7 +227,7 @@
                         help="Project to test.")
     args = parser.parse_args()
 
-    build_config = trusty_build_config.TrustyBuildConfig()
+    build_config = TrustyBuildConfig()
     test_results = run_tests(build_config, args.root, args.project)
     test_results.print_results()
     if not test_results.passed:
diff --git a/scripts/test-map b/scripts/test-map
index 34b2356..7873049 100644
--- a/scripts/test-map
+++ b/scripts/test-map
@@ -41,13 +41,27 @@
 #         tests=[
 #             # Run a program on the host
 #             hosttest("some_host_binary"),
-#             # Use test-runner to activate a Trusty IPC port
-#             boottest("port.under.test"),
-#             # Use Android to activate a Trusty IPC port
-#             androidport("port.under.test"),
+#             # Run test on device or in emulator. Porttests run in one of two
+#             # contexts:
+#             # 1. In a minimal
+#             #    bootloader environment (when porttest is nested within
+#             #    in a boottests element), or
+#             # 2. with a full Android userspace present (when nested within an
+#             #    androidporttests element).
+#             porttest("port.under.test"),
 #             # Run a shell command inside Android
 #             androidtest(name="test_name", command="command to run"),
-#             ...
+#             # Run a sequence of test and commands in the given order
+#             # Ensure that test environment is rebooted before second port test
+#             compositetest(name="testname", sequence=[
+#                 hosttest("some_host_binary"),
+#                 porttest("port.under.test"),
+#                 # a reboot may only be requested inside composite tests
+#                 reboot(),
+#                 porttest("another.port.under.test"),
+#                 ...
+#              ]
+#            ...
 #         ],
 #     ),
 #     ...
diff --git a/scripts/trusty_build_config.py b/scripts/trusty_build_config.py
index dd7e2f5..ab7d04c 100755
--- a/scripts/trusty_build_config.py
+++ b/scripts/trusty_build_config.py
@@ -24,6 +24,7 @@
 import argparse
 import os
 import re
+from typing import List
 
 script_dir = os.path.dirname(os.path.abspath(__file__))
 
@@ -173,6 +174,37 @@
         super().__init__("reboot command")
 
 
+class TrustyCompositeTest(TrustyTest):
+    """Stores a sequence of tests that must execute in order"""
+
+    def __init__(self, name: str,
+                 sequence: List[TrustyPortTest | TrustyCommand],
+                 enabled=True):
+        super().__init__(name, [], enabled)
+        self.sequence = sequence
+        flags = set()
+        for subtest in sequence:
+            flags.update(subtest.need.flags)
+        self.need = TrustyPortTestFlags(**{flag: True for flag in flags})
+
+    def needs(self, **need):
+        self.need.set(**need)
+        return self
+
+    def into_androidporttest(self, **kwargs):
+        # because the needs of the composite test is the union of the needs of
+        # its subtests, we do not need to filter out any subtests; all needs met
+        self.sequence = [subtest.into_androidporttest(**kwargs)
+                         for subtest in self.sequence]
+        return self
+
+    def into_bootporttest(self):
+        # similarly to into_androidporttest, we do not need to filter out tests
+        self.sequence = [subtest.into_bootporttest()
+                         for subtest in self.sequence]
+        return self
+
+
 class TrustyBuildConfig(object):
     """Trusty build and test configuration file parser."""
 
@@ -309,6 +341,7 @@
             "testmap": testmap,
             "hosttest": hosttest,
             "porttest": TrustyPortTest,
+            "compositetest": TrustyCompositeTest,
             "porttestflags": TrustyPortTestFlags,
             "hosttests": hosttests,
             "boottests": boottests,
@@ -482,6 +515,32 @@
     print("get_projects test passed")
 
     reboot_seen = False
+
+    def check_test(i, test):
+        match test:
+            case TrustyTest():
+                host_m = re.match(r"host-test:self_test.*\.(\d+)",
+                                  test.name)
+                unit_m = re.match(r"boot-test:self_test.*\.(\d+)",
+                                  test.name)
+                if args.debug:
+                    print(project, i, test.name)
+                m = host_m or unit_m
+                assert m
+                assert m.group(1) == str(i + 1)
+            case TrustyRebootCommand():
+                assert False, "Reboot outside composite command"
+            case _:
+                assert False, "Unexpected test type"
+
+    def check_subtest(i, test):
+        nonlocal reboot_seen
+        match test:
+            case TrustyRebootCommand():
+                reboot_seen = True
+            case _:
+                check_test(i, test)
+
     for project_name in config.get_projects():
         project = config.get_project(project_name)
         if args.debug:
@@ -506,18 +565,15 @@
 
         for i, test in enumerate(project.tests):
             match test:
-                case TrustyRebootCommand():
-                    reboot_seen = True
-                case TrustyTest():
-                    host_m = re.match(r"host-test:self_test.*\.(\d+)",
-                                      test.name)
-                    unit_m = re.match(r"boot-test:self_test.*\.(\d+)",
-                                      test.name)
-                    if args.debug:
-                        print(project, i, test.name)
-                    m = host_m or unit_m
-                    assert m
-                    assert m.group(1) == str(i + 1)
+                case TrustyCompositeTest():
+                    # because one of its subtest needs storage_boot,
+                    # the composite test should similarly need it
+                    assert "storage_boot" in test.need.flags
+                    for subtest in test.sequence:
+                        check_subtest(i, subtest)
+                case _:
+                    check_test(i, test)
+
     assert reboot_seen
 
     print("get_tests test passed")
diff --git a/scripts/trusty_build_config_self_test_include1 b/scripts/trusty_build_config_self_test_include1
index c9bb0b8..55914c8 100644
--- a/scripts/trusty_build_config_self_test_include1
+++ b/scripts/trusty_build_config_self_test_include1
@@ -49,8 +49,14 @@
                 porttest("self_test.c.1"),
                 porttest("self_test.a.2"),
                 porttest("self_test.b.3"),
-                hosttest("self_test.d.4"),
-                reboot(),
+                hosttest("self_test.d.5"),
+                compositetest(
+                    name="self_test.composite.a",
+                    sequence=[
+                        porttest("self_test.a.4").needs(storage_boot=True),
+                        reboot(),
+                    ],
+                ),
             ]),
         ],
     ),