about summary refs log tree commit diff
path: root/src/ci/docker
diff options
context:
space:
mode:
authorbors <bors@rust-lang.org>2022-11-15 06:43:28 +0000
committerbors <bors@rust-lang.org>2022-11-15 06:43:28 +0000
commitca92d90b5917e7176d5ff06607a2cd5352c088d3 (patch)
treeb34e3c368642c150f3c81bc98d45d57353480bbc /src/ci/docker
parent101e1822c3e54e63996c8aaa014d55716f3937eb (diff)
parent7c7cb7182f8b2211005d3f82126e5f45e270ad21 (diff)
downloadrust-ca92d90b5917e7176d5ff06607a2cd5352c088d3.tar.gz
rust-ca92d90b5917e7176d5ff06607a2cd5352c088d3.zip
Auto merge of #104428 - matthiaskrgr:rollup-jo3078i, r=matthiaskrgr
Rollup of 13 pull requests

Successful merges:

 - #103842 (Adding Fuchsia compiler testing script, docs)
 - #104354 (Remove leading newlines from `NonZero*` doc examples)
 - #104372 (Update compiler-builtins)
 - #104380 (rustdoc: remove unused CSS `code { opacity: 1 }`)
 - #104381 (Remove dead NoneError diagnostic handling)
 - #104383 (Remove unused symbols and diagnostic items)
 - #104391 (Deriving cleanups)
 - #104403 (Specify language of code comment to generate document)
 - #104404 (Fix missing minification for static files)
 - #104413 ([llvm-wrapper] adapt for LLVM API change)
 - #104415 (rustdoc: fix corner case in search keyboard commands)
 - #104422 (Fix suggest associated call syntax)
 - #104426 (Add test for #102154)

Failed merges:

r? `@ghost`
`@rustbot` modify labels: rollup
Diffstat (limited to 'src/ci/docker')
-rw-r--r--src/ci/docker/scripts/fuchsia-test-runner.py1041
1 files changed, 1041 insertions, 0 deletions
diff --git a/src/ci/docker/scripts/fuchsia-test-runner.py b/src/ci/docker/scripts/fuchsia-test-runner.py
new file mode 100644
index 00000000000..a2708d16947
--- /dev/null
+++ b/src/ci/docker/scripts/fuchsia-test-runner.py
@@ -0,0 +1,1041 @@
+#!/usr/bin/env python3
+
+"""
+The Rust toolchain test runner for Fuchsia.
+
+For instructions on running the compiler test suite, see
+https://doc.rust-lang.org/stable/rustc/platform-support/fuchsia.html#aarch64-fuchsia-and-x86_64-fuchsia
+"""
+
+import argparse
+from dataclasses import dataclass
+import glob
+import hashlib
+import json
+import os
+import platform
+import re
+import shutil
+import signal
+import subprocess
+import sys
+from typing import ClassVar, List
+
+
+@dataclass
+class TestEnvironment:
+    rust_dir: str
+    sdk_dir: str
+    target_arch: str
+    package_server_pid: int = None
+    emu_addr: str = None
+    libstd_name: str = None
+    libtest_name: str = None
+    verbose: bool = False
+
+    @staticmethod
+    def tmp_dir():
+        tmp_dir = os.environ.get("TEST_TOOLCHAIN_TMP_DIR")
+        if tmp_dir is not None:
+            return os.path.abspath(tmp_dir)
+        return os.path.join(os.path.dirname(__file__), "tmp~")
+
+    @classmethod
+    def env_file_path(cls):
+        return os.path.join(cls.tmp_dir(), "test_env.json")
+
+    @classmethod
+    def from_args(cls, args):
+        return cls(
+            os.path.abspath(args.rust),
+            os.path.abspath(args.sdk),
+            args.target_arch,
+            verbose=args.verbose,
+        )
+
+    @classmethod
+    def read_from_file(cls):
+        with open(cls.env_file_path(), encoding="utf-8") as f:
+            test_env = json.loads(f.read())
+            return cls(
+                test_env["rust_dir"],
+                test_env["sdk_dir"],
+                test_env["target_arch"],
+                libstd_name=test_env["libstd_name"],
+                libtest_name=test_env["libtest_name"],
+                emu_addr=test_env["emu_addr"],
+                package_server_pid=test_env["package_server_pid"],
+                verbose=test_env["verbose"],
+            )
+
+    def image_name(self):
+        if self.target_arch == "x64":
+            return "qemu-x64"
+        if self.target_arch == "arm64":
+            return "qemu-arm64"
+        raise Exception(f"Unrecognized target architecture {self.target_arch}")
+
+    def write_to_file(self):
+        with open(self.env_file_path(), "w", encoding="utf-8") as f:
+            f.write(json.dumps(self.__dict__))
+
+    def ssh_dir(self):
+        return os.path.join(self.tmp_dir(), "ssh")
+
+    def ssh_keyfile_path(self):
+        return os.path.join(self.ssh_dir(), "fuchsia_ed25519")
+
+    def ssh_authfile_path(self):
+        return os.path.join(self.ssh_dir(), "fuchsia_authorized_keys")
+
+    def vdl_output_path(self):
+        return os.path.join(self.tmp_dir(), "vdl_output")
+
+    def package_server_log_path(self):
+        return os.path.join(self.tmp_dir(), "package_server_log")
+
+    def emulator_log_path(self):
+        return os.path.join(self.tmp_dir(), "emulator_log")
+
+    def packages_dir(self):
+        return os.path.join(self.tmp_dir(), "packages")
+
+    def output_dir(self):
+        return os.path.join(self.tmp_dir(), "output")
+
+    TEST_REPO_NAME: ClassVar[str] = "rust-testing"
+
+    def repo_dir(self):
+        return os.path.join(self.tmp_dir(), self.TEST_REPO_NAME)
+
+    def rustlib_dir(self):
+        if self.target_arch == "x64":
+            return "x86_64-fuchsia"
+        if self.target_arch == "arm64":
+            return "aarch64-fuchsia"
+        raise Exception(f"Unrecognized target architecture {self.target_arch}")
+
+    def libs_dir(self):
+        return os.path.join(
+            self.rust_dir,
+            "lib",
+        )
+
+    def rustlibs_dir(self):
+        return os.path.join(
+            self.libs_dir(),
+            "rustlib",
+            self.rustlib_dir(),
+            "lib",
+        )
+
+    def sdk_arch(self):
+        machine = platform.machine()
+        if machine == "x86_64":
+            return "x64"
+        if machine == "arm":
+            return "a64"
+        raise Exception(f"Unrecognized host architecture {machine}")
+
+    def tool_path(self, tool):
+        return os.path.join(self.sdk_dir, "tools", self.sdk_arch(), tool)
+
+    def host_arch_triple(self):
+        machine = platform.machine()
+        if machine == "x86_64":
+            return "x86_64-unknown-linux-gnu"
+        if machine == "arm":
+            return "aarch64-unknown-linux-gnu"
+        raise Exception(f"Unrecognized host architecture {machine}")
+
+    def zxdb_script_path(self):
+        return os.path.join(self.tmp_dir(), "zxdb_script")
+
+    def log_info(self, msg):
+        print(msg)
+
+    def log_debug(self, msg):
+        if self.verbose:
+            print(msg)
+
+    def subprocess_output(self):
+        if self.verbose:
+            return sys.stdout
+        return subprocess.DEVNULL
+
+    def ffx_daemon_log_path(self):
+        return os.path.join(self.tmp_dir(), "ffx_daemon_log")
+
+    def ffx_isolate_dir(self):
+        return os.path.join(self.tmp_dir(), "ffx_isolate")
+
+    def ffx_home_dir(self):
+        return os.path.join(self.ffx_isolate_dir(), "user-home")
+
+    def ffx_tmp_dir(self):
+        return os.path.join(self.ffx_isolate_dir(), "tmp")
+
+    def ffx_log_dir(self):
+        return os.path.join(self.ffx_isolate_dir(), "log")
+
+    def ffx_user_config_dir(self):
+        return os.path.join(self.ffx_xdg_config_home(), "Fuchsia", "ffx", "config")
+
+    def ffx_user_config_path(self):
+        return os.path.join(self.ffx_user_config_dir(), "config.json")
+
+    def ffx_xdg_config_home(self):
+        if platform.system() == "Darwin":
+            return os.path.join(self.ffx_home_dir(), "Library", "Preferences")
+        return os.path.join(self.ffx_home_dir(), ".local", "share")
+
+    def ffx_ascendd_path(self):
+        return os.path.join(self.ffx_tmp_dir(), "ascendd")
+
+    def start_ffx_isolation(self):
+        # Most of this is translated directly from ffx's isolate library
+        os.mkdir(self.ffx_isolate_dir())
+        os.mkdir(self.ffx_home_dir())
+        os.mkdir(self.ffx_tmp_dir())
+        os.mkdir(self.ffx_log_dir())
+
+        fuchsia_dir = os.path.join(self.ffx_home_dir(), ".fuchsia")
+        os.mkdir(fuchsia_dir)
+
+        fuchsia_debug_dir = os.path.join(fuchsia_dir, "debug")
+        os.mkdir(fuchsia_debug_dir)
+
+        metrics_dir = os.path.join(fuchsia_dir, "metrics")
+        os.mkdir(metrics_dir)
+
+        analytics_path = os.path.join(metrics_dir, "analytics-status")
+        with open(analytics_path, "w", encoding="utf-8") as analytics_file:
+            print("0", file=analytics_file)
+
+        ffx_path = os.path.join(metrics_dir, "ffx")
+        with open(ffx_path, "w", encoding="utf-8") as ffx_file:
+            print("1", file=ffx_file)
+
+        os.makedirs(self.ffx_user_config_dir())
+
+        with open(
+            self.ffx_user_config_path(), "w", encoding="utf-8"
+        ) as config_json_file:
+            user_config_for_test = {
+                "log": {
+                    "enabled": True,
+                    "dir": self.ffx_log_dir(),
+                },
+                "overnet": {
+                    "socket": self.ffx_ascendd_path(),
+                },
+                "ssh": {
+                    "pub": self.ssh_authfile_path(),
+                    "priv": self.ssh_keyfile_path(),
+                },
+                "test": {
+                    "is_isolated": True,
+                    "experimental_structured_output": True,
+                },
+            }
+            print(json.dumps(user_config_for_test), file=config_json_file)
+
+        ffx_env_path = os.path.join(self.ffx_user_config_dir(), ".ffx_env")
+        with open(ffx_env_path, "w", encoding="utf-8") as ffx_env_file:
+            ffx_env_config_for_test = {
+                "user": self.ffx_user_config_path(),
+                "build": None,
+                "global": None,
+            }
+            print(json.dumps(ffx_env_config_for_test), file=ffx_env_file)
+
+        # Start ffx daemon
+        # We want this to be a long-running process that persists after the script finishes
+        # pylint: disable=consider-using-with
+        with open(
+            self.ffx_daemon_log_path(), "w", encoding="utf-8"
+        ) as ffx_daemon_log_file:
+            subprocess.Popen(
+                [
+                    self.tool_path("ffx"),
+                    "--config",
+                    self.ffx_user_config_path(),
+                    "daemon",
+                    "start",
+                ],
+                env=self.ffx_cmd_env(),
+                stdout=ffx_daemon_log_file,
+                stderr=ffx_daemon_log_file,
+            )
+
+    def ffx_cmd_env(self):
+        result = {
+            "HOME": self.ffx_home_dir(),
+            "XDG_CONFIG_HOME": self.ffx_xdg_config_home(),
+            "ASCENDD": self.ffx_ascendd_path(),
+            "FUCHSIA_SSH_KEY": self.ssh_keyfile_path(),
+            # We want to use our own specified temp directory
+            "TMP": self.tmp_dir(),
+            "TEMP": self.tmp_dir(),
+            "TMPDIR": self.tmp_dir(),
+            "TEMPDIR": self.tmp_dir(),
+        }
+
+        return result
+
+    def stop_ffx_isolation(self):
+        subprocess.check_call(
+            [
+                self.tool_path("ffx"),
+                "--config",
+                self.ffx_user_config_path(),
+                "daemon",
+                "stop",
+            ],
+            env=self.ffx_cmd_env(),
+            stdout=self.subprocess_output(),
+            stderr=self.subprocess_output(),
+        )
+
+    def start(self):
+        """Sets up the testing environment and prepares to run tests.
+
+        Args:
+            args: The command-line arguments to this command.
+
+        During setup, this function will:
+        - Locate necessary shared libraries
+        - Create a new temp directory (this is where all temporary files are stored)
+        - Start an emulator
+        - Start an update server
+        - Create a new package repo and register it with the emulator
+        - Write test environment settings to a temporary file
+        """
+
+        # Initialize temp directory
+        if not os.path.exists(self.tmp_dir()):
+            os.mkdir(self.tmp_dir())
+        elif len(os.listdir(self.tmp_dir())) != 0:
+            raise Exception(f"Temp directory is not clean (in {self.tmp_dir()})")
+
+        os.mkdir(self.ssh_dir())
+        os.mkdir(self.output_dir())
+
+        # Find libstd and libtest
+        libstd_paths = glob.glob(os.path.join(self.rustlibs_dir(), "libstd-*.so"))
+        libtest_paths = glob.glob(os.path.join(self.rustlibs_dir(), "libtest-*.so"))
+
+        if not libstd_paths:
+            raise Exception(f"Failed to locate libstd (in {self.rustlibs_dir()})")
+
+        if not libtest_paths:
+            raise Exception(f"Failed to locate libtest (in {self.rustlibs_dir()})")
+
+        self.libstd_name = os.path.basename(libstd_paths[0])
+        self.libtest_name = os.path.basename(libtest_paths[0])
+
+        # Generate SSH keys for the emulator to use
+        self.log_info("Generating SSH keys...")
+        subprocess.check_call(
+            [
+                "ssh-keygen",
+                "-N",
+                "",
+                "-t",
+                "ed25519",
+                "-f",
+                self.ssh_keyfile_path(),
+                "-C",
+                "Generated by test_toolchain.py",
+            ],
+            stdout=self.subprocess_output(),
+            stderr=self.subprocess_output(),
+        )
+        authfile_contents = subprocess.check_output(
+            [
+                "ssh-keygen",
+                "-y",
+                "-f",
+                self.ssh_keyfile_path(),
+            ],
+            stderr=self.subprocess_output(),
+        )
+        with open(self.ssh_authfile_path(), "wb") as authfile:
+            authfile.write(authfile_contents)
+
+        # Start ffx isolation
+        self.log_info("Starting ffx isolation...")
+        self.start_ffx_isolation()
+
+        # Start emulator (this will generate the vdl output)
+        self.log_info("Starting emulator...")
+        subprocess.check_call(
+            [
+                self.tool_path("fvdl"),
+                "--sdk",
+                "start",
+                "--tuntap",
+                "--headless",
+                "--nointeractive",
+                "--ssh",
+                self.ssh_dir(),
+                "--vdl-output",
+                self.vdl_output_path(),
+                "--emulator-log",
+                self.emulator_log_path(),
+                "--image-name",
+                self.image_name(),
+            ],
+            stdout=self.subprocess_output(),
+            stderr=self.subprocess_output(),
+        )
+
+        # Parse vdl output for relevant information
+        with open(self.vdl_output_path(), encoding="utf-8") as f:
+            vdl_content = f.read()
+            matches = re.search(
+                r'network_address:\s+"\[([0-9a-f]{1,4}:(:[0-9a-f]{1,4}){4}%qemu)\]"',
+                vdl_content,
+            )
+            self.emu_addr = matches.group(1)
+
+        # Create new package repo
+        self.log_info("Creating package repo...")
+        subprocess.check_call(
+            [
+                self.tool_path("pm"),
+                "newrepo",
+                "-repo",
+                self.repo_dir(),
+            ],
+            stdout=self.subprocess_output(),
+            stderr=self.subprocess_output(),
+        )
+
+        # Start package server
+        self.log_info("Starting package server...")
+        with open(
+            self.package_server_log_path(), "w", encoding="utf-8"
+        ) as package_server_log:
+            # We want this to be a long-running process that persists after the script finishes
+            # pylint: disable=consider-using-with
+            self.package_server_pid = subprocess.Popen(
+                [
+                    self.tool_path("pm"),
+                    "serve",
+                    "-vt",
+                    "-repo",
+                    self.repo_dir(),
+                    "-l",
+                    ":8084",
+                ],
+                stdout=package_server_log,
+                stderr=package_server_log,
+            ).pid
+
+        # Register package server with emulator
+        self.log_info("Registering package server...")
+        ssh_client = subprocess.check_output(
+            [
+                "ssh",
+                "-i",
+                self.ssh_keyfile_path(),
+                "-o",
+                "StrictHostKeyChecking=accept-new",
+                self.emu_addr,
+                "-f",
+                "echo $SSH_CLIENT",
+            ],
+            text=True,
+        )
+        repo_addr = ssh_client.split()[0].replace("%", "%25")
+        repo_url = f"http://[{repo_addr}]:8084/config.json"
+        subprocess.check_call(
+            [
+                "ssh",
+                "-i",
+                self.ssh_keyfile_path(),
+                "-o",
+                "StrictHostKeyChecking=accept-new",
+                self.emu_addr,
+                "-f",
+                f"pkgctl repo add url -f 1 -n {self.TEST_REPO_NAME} {repo_url}",
+            ],
+            stdout=self.subprocess_output(),
+            stderr=self.subprocess_output(),
+        )
+
+        # Write to file
+        self.write_to_file()
+
+        self.log_info("Success! Your environment is ready to run tests.")
+
+    # FIXME: shardify this
+    # `facet` statement required for TCP testing via
+    # protocol `fuchsia.posix.socket.Provider`. See
+    # https://fuchsia.dev/fuchsia-src/development/testing/components/test_runner_framework?hl=en#legacy_non-hermetic_tests
+    CML_TEMPLATE: ClassVar[
+        str
+    ] = """
+    {{
+        program: {{
+            runner: "elf_test_runner",
+            binary: "bin/{exe_name}",
+            forward_stderr_to: "log",
+            forward_stdout_to: "log",
+            environ: [{env_vars}
+            ]
+        }},
+        capabilities: [
+            {{ protocol: "fuchsia.test.Suite" }},
+        ],
+        expose: [
+            {{
+                protocol: "fuchsia.test.Suite",
+                from: "self",
+            }},
+        ],
+        use: [
+            {{ storage: "data", path: "/data" }},
+            {{ protocol: [ "fuchsia.process.Launcher" ] }},
+            {{ protocol: [ "fuchsia.posix.socket.Provider" ] }}
+        ],
+        facets: {{
+            "fuchsia.test": {{ type: "system" }},
+        }},
+    }}
+    """
+
+    MANIFEST_TEMPLATE = """
+    meta/package={package_dir}/meta/package
+    meta/{package_name}.cm={package_dir}/meta/{package_name}.cm
+    bin/{exe_name}={bin_path}
+    lib/{libstd_name}={rust_dir}/lib/rustlib/{rustlib_dir}/lib/{libstd_name}
+    lib/{libtest_name}={rust_dir}/lib/rustlib/{rustlib_dir}/lib/{libtest_name}
+    lib/ld.so.1={sdk_dir}/arch/{target_arch}/sysroot/lib/libc.so
+    lib/libzircon.so={sdk_dir}/arch/{target_arch}/sysroot/lib/libzircon.so
+    lib/libfdio.so={sdk_dir}/arch/{target_arch}/lib/libfdio.so
+    """
+
+    TEST_ENV_VARS: ClassVar[List[str]] = [
+        "TEST_EXEC_ENV",
+        "RUST_MIN_STACK",
+        "RUST_BACKTRACE",
+        "RUST_NEWRT",
+        "RUST_LOG",
+        "RUST_TEST_THREADS",
+    ]
+
+    def run(self, args):
+        """Runs the requested test in the testing environment.
+
+        Args:
+        args: The command-line arguments to this command.
+        Returns:
+        The return code of the test (0 for success, else failure).
+
+        To run a test, this function will:
+        - Create, compile, archive, and publish a test package
+        - Run the test package on the emulator
+        - Forward the test's stdout and stderr as this script's stdout and stderr
+        """
+
+        bin_path = os.path.abspath(args.bin_path)
+
+        # Build a unique, deterministic name for the test using the name of the
+        # binary and the last 6 hex digits of the hash of the full path
+        def path_checksum(path):
+            m = hashlib.sha256()
+            m.update(path.encode("utf-8"))
+            return m.hexdigest()[0:6]
+
+        base_name = os.path.basename(os.path.dirname(args.bin_path))
+        exe_name = base_name.lower().replace(".", "_")
+        package_name = f"{exe_name}_{path_checksum(bin_path)}"
+
+        package_dir = os.path.join(self.packages_dir(), package_name)
+        cml_path = os.path.join(package_dir, "meta", f"{package_name}.cml")
+        cm_path = os.path.join(package_dir, "meta", f"{package_name}.cm")
+        manifest_path = os.path.join(package_dir, f"{package_name}.manifest")
+        far_path = os.path.join(package_dir, f"{package_name}-0.far")
+
+        shared_libs = args.shared_libs[: args.n]
+        arguments = args.shared_libs[args.n :]
+
+        test_output_dir = os.path.join(self.output_dir(), package_name)
+
+        # Clean and create temporary output directory
+        if os.path.exists(test_output_dir):
+            shutil.rmtree(test_output_dir)
+
+        os.mkdir(test_output_dir)
+
+        # Open log file
+        log_path = os.path.join(test_output_dir, "log")
+        with open(log_path, "w", encoding="utf-8") as log_file:
+
+            def log(msg):
+                print(msg, file=log_file)
+                log_file.flush()
+
+            log(f"Bin path: {bin_path}")
+
+            log("Setting up package...")
+
+            # Set up package
+            subprocess.check_call(
+                [
+                    self.tool_path("pm"),
+                    "-o",
+                    package_dir,
+                    "-n",
+                    package_name,
+                    "init",
+                ],
+                stdout=log_file,
+                stderr=log_file,
+            )
+
+            log("Writing CML...")
+
+            # Write and compile CML
+            with open(cml_path, "w", encoding="utf-8") as cml:
+                # Collect environment variables
+                env_vars = ""
+                for var_name in self.TEST_ENV_VARS:
+                    var_value = os.getenv(var_name)
+                    if var_value is not None:
+                        env_vars += f'\n            "{var_name}={var_value}",'
+
+                # Default to no backtrace for test suite
+                if os.getenv("RUST_BACKTRACE") == None:
+                    env_vars += f'\n            "RUST_BACKTRACE=0",'
+
+                cml.write(
+                    self.CML_TEMPLATE.format(env_vars=env_vars, exe_name=exe_name)
+                )
+
+            log("Compiling CML...")
+
+            subprocess.check_call(
+                [
+                    self.tool_path("cmc"),
+                    "compile",
+                    cml_path,
+                    "--includepath",
+                    ".",
+                    "--output",
+                    cm_path,
+                ],
+                stdout=log_file,
+                stderr=log_file,
+            )
+
+            log("Writing manifest...")
+
+            # Write, build, and archive manifest
+            with open(manifest_path, "w", encoding="utf-8") as manifest:
+                manifest.write(
+                    self.MANIFEST_TEMPLATE.format(
+                        bin_path=bin_path,
+                        exe_name=exe_name,
+                        package_dir=package_dir,
+                        package_name=package_name,
+                        rust_dir=self.rust_dir,
+                        rustlib_dir=self.rustlib_dir(),
+                        sdk_dir=self.sdk_dir,
+                        libstd_name=self.libstd_name,
+                        libtest_name=self.libtest_name,
+                        target_arch=self.target_arch,
+                    )
+                )
+                for shared_lib in shared_libs:
+                    manifest.write(f"lib/{os.path.basename(shared_lib)}={shared_lib}\n")
+
+            log("Compiling and archiving manifest...")
+
+            subprocess.check_call(
+                [
+                    self.tool_path("pm"),
+                    "-o",
+                    package_dir,
+                    "-m",
+                    manifest_path,
+                    "build",
+                ],
+                stdout=log_file,
+                stderr=log_file,
+            )
+            subprocess.check_call(
+                [
+                    self.tool_path("pm"),
+                    "-o",
+                    package_dir,
+                    "-m",
+                    manifest_path,
+                    "archive",
+                ],
+                stdout=log_file,
+                stderr=log_file,
+            )
+
+            log("Publishing package to repo...")
+
+            # Publish package to repo
+            subprocess.check_call(
+                [
+                    self.tool_path("pm"),
+                    "publish",
+                    "-a",
+                    "-repo",
+                    self.repo_dir(),
+                    "-f",
+                    far_path,
+                ],
+                stdout=log_file,
+                stderr=log_file,
+            )
+
+            log("Running ffx test...")
+
+            # Run test on emulator
+            subprocess.run(
+                [
+                    self.tool_path("ffx"),
+                    "--config",
+                    self.ffx_user_config_path(),
+                    "test",
+                    "run",
+                    f"fuchsia-pkg://{self.TEST_REPO_NAME}/{package_name}#meta/{package_name}.cm",
+                    "--min-severity-logs",
+                    "TRACE",
+                    "--output-directory",
+                    test_output_dir,
+                    "--",
+                ]
+                + arguments,
+                env=self.ffx_cmd_env(),
+                check=False,
+                stdout=log_file,
+                stderr=log_file,
+            )
+
+            log("Reporting test suite output...")
+
+            # Read test suite output
+            run_summary_path = os.path.join(test_output_dir, "run_summary.json")
+            if os.path.exists(run_summary_path):
+                with open(run_summary_path, encoding="utf-8") as f:
+                    run_summary = json.loads(f.read())
+
+                suite = run_summary["data"]["suites"][0]
+                case = suite["cases"][0]
+
+                return_code = 0 if case["outcome"] == "PASSED" else 1
+
+                artifacts = case["artifacts"]
+                artifact_dir = case["artifact_dir"]
+                stdout_path = None
+                stderr_path = None
+
+                for path, artifact in artifacts.items():
+                    artifact_path = os.path.join(test_output_dir, artifact_dir, path)
+                    artifact_type = artifact["artifact_type"]
+
+                    if artifact_type == "STDERR":
+                        stderr_path = artifact_path
+                    elif artifact_type == "STDOUT":
+                        stdout_path = artifact_path
+
+                if stdout_path is not None and os.path.exists(stdout_path):
+                    with open(stdout_path, encoding="utf-8") as f:
+                        print(f.read(), file=sys.stdout, end="")
+
+                if stderr_path is not None and os.path.exists(stderr_path):
+                    with open(stderr_path, encoding="utf-8") as f:
+                        print(f.read(), file=sys.stderr, end="")
+            else:
+                log("Failed to open test run summary")
+                return_code = 254
+
+            log("Done!")
+
+        return return_code
+
+    def stop(self):
+        """Shuts down and cleans up the testing environment.
+
+        Args:
+        args: The command-line arguments to this command.
+        Returns:
+        The return code of the test (0 for success, else failure).
+
+        During cleanup, this function will stop the emulator, package server, and
+        update server, then delete all temporary files. If an error is encountered
+        while stopping any running processes, the temporary files will not be deleted.
+        Passing --delete-tmp will force the process to delete the files anyway.
+        """
+
+        self.log_debug("Reporting logs...")
+
+        # Print test log files
+        for test_dir in os.listdir(self.output_dir()):
+            log_path = os.path.join(self.output_dir(), test_dir, "log")
+            self.log_debug(f"\n---- Logs for test '{test_dir}' ----\n")
+            if os.path.exists(log_path):
+                with open(log_path, encoding="utf-8") as log:
+                    self.log_debug(log.read())
+            else:
+                self.log_debug("No logs found")
+
+        # Print the emulator log
+        self.log_debug("\n---- Emulator logs ----\n")
+        if os.path.exists(self.emulator_log_path()):
+            with open(self.emulator_log_path(), encoding="utf-8") as log:
+                self.log_debug(log.read())
+        else:
+            self.log_debug("No emulator logs found")
+
+        # Print the package server log
+        self.log_debug("\n---- Package server log ----\n")
+        if os.path.exists(self.package_server_log_path()):
+            with open(self.package_server_log_path(), encoding="utf-8") as log:
+                self.log_debug(log.read())
+        else:
+            self.log_debug("No package server log found")
+
+        # Print the ffx daemon log
+        self.log_debug("\n---- ffx daemon log ----\n")
+        if os.path.exists(self.ffx_daemon_log_path()):
+            with open(self.ffx_daemon_log_path(), encoding="utf-8") as log:
+                self.log_debug(log.read())
+        else:
+            self.log_debug("No ffx daemon log found")
+
+        # Stop package server
+        self.log_info("Stopping package server...")
+        os.kill(self.package_server_pid, signal.SIGTERM)
+
+        # Shut down the emulator
+        self.log_info("Stopping emulator...")
+        subprocess.check_call(
+            [
+                self.tool_path("fvdl"),
+                "--sdk",
+                "kill",
+                "--launched-proto",
+                self.vdl_output_path(),
+            ],
+            stdout=self.subprocess_output(),
+            stderr=self.subprocess_output(),
+        )
+
+        # Stop ffx isolation
+        self.log_info("Stopping ffx isolation...")
+        self.stop_ffx_isolation()
+
+    def delete_tmp(self):
+        # Remove temporary files
+        self.log_info("Deleting temporary files...")
+        shutil.rmtree(self.tmp_dir(), ignore_errors=True)
+
+    def debug(self, args):
+        command = [
+            self.tool_path("ffx"),
+            "--config",
+            self.ffx_user_config_path(),
+            "debug",
+            "connect",
+            "--",
+            "--build-id-dir",
+            os.path.join(self.sdk_dir, ".build-id"),
+            "--build-id-dir",
+            os.path.join(self.libs_dir(), ".build-id"),
+        ]
+
+        # Add rust source if it's available
+        if args.rust_src is not None:
+            command += [
+                "--build-dir",
+                args.rust_src,
+            ]
+
+        # Add fuchsia source if it's available
+        if args.fuchsia_src is not None:
+            command += [
+                "--build-dir",
+                os.path.join(args.fuchsia_src, "out", "default"),
+            ]
+
+        # Load debug symbols for the test binary and automatically attach
+        if args.test is not None:
+            if args.rust_src is None:
+                raise Exception(
+                    "A Rust source path is required with the `test` argument"
+                )
+
+            test_name = os.path.splitext(os.path.basename(args.test))[0]
+
+            build_dir = os.path.join(
+                args.rust_src,
+                "fuchsia-build",
+                self.host_arch_triple(),
+            )
+            test_dir = os.path.join(
+                build_dir,
+                "test",
+                os.path.dirname(args.test),
+                test_name,
+            )
+
+            with open(self.zxdb_script_path(), mode="w", encoding="utf-8") as f:
+                print(f"attach {test_name[:31]}", file=f)
+
+            command += [
+                "--symbol-path",
+                test_dir,
+                "-S",
+                self.zxdb_script_path(),
+            ]
+
+        # Add any other zxdb arguments the user passed
+        if args.zxdb_args is not None:
+            command += args.zxdb_args
+
+        # Connect to the running emulator with zxdb
+        subprocess.run(command, env=self.ffx_cmd_env(), check=False)
+
+
+def start(args):
+    test_env = TestEnvironment.from_args(args)
+    test_env.start()
+    return 0
+
+
+def run(args):
+    test_env = TestEnvironment.read_from_file()
+    return test_env.run(args)
+
+
+def stop(args):
+    test_env = TestEnvironment.read_from_file()
+    test_env.stop()
+    if not args.no_delete:
+        test_env.delete_tmp()
+    return 0
+
+
+def delete_tmp(args):
+    del args
+    test_env = TestEnvironment.read_from_file()
+    test_env.delete_tmp()
+    return 0
+
+
+def debug(args):
+    test_env = TestEnvironment.read_from_file()
+    test_env.debug(args)
+    return 0
+
+
+def main():
+    parser = argparse.ArgumentParser()
+
+    def print_help(args):
+        del args
+        parser.print_help()
+        return 0
+
+    parser.set_defaults(func=print_help)
+
+    subparsers = parser.add_subparsers(help="valid sub-commands")
+
+    start_parser = subparsers.add_parser(
+        "start", help="initializes the testing environment"
+    )
+    start_parser.add_argument(
+        "--rust",
+        help="the directory of the installed Rust compiler for Fuchsia",
+        required=True,
+    )
+    start_parser.add_argument(
+        "--sdk",
+        help="the directory of the fuchsia SDK",
+        required=True,
+    )
+    start_parser.add_argument(
+        "--verbose",
+        help="prints more output from executed processes",
+        action="store_true",
+    )
+    start_parser.add_argument(
+        "--target-arch",
+        help="the architecture of the image to test",
+        required=True,
+    )
+    start_parser.set_defaults(func=start)
+
+    run_parser = subparsers.add_parser(
+        "run", help="run a test in the testing environment"
+    )
+    run_parser.add_argument(
+        "n", help="the number of shared libs passed along with the executable", type=int
+    )
+    run_parser.add_argument("bin_path", help="path to the binary to run")
+    run_parser.add_argument(
+        "shared_libs",
+        help="the shared libs passed along with the binary",
+        nargs=argparse.REMAINDER,
+    )
+    run_parser.set_defaults(func=run)
+
+    stop_parser = subparsers.add_parser(
+        "stop", help="shuts down and cleans up the testing environment"
+    )
+    stop_parser.add_argument(
+        "--no-delete",
+        default=False,
+        action="store_true",
+        help="don't delete temporary files after stopping",
+    )
+    stop_parser.set_defaults(func=stop)
+
+    delete_parser = subparsers.add_parser(
+        "delete-tmp",
+        help="deletes temporary files after the testing environment has been manually cleaned up",
+    )
+    delete_parser.set_defaults(func=delete_tmp)
+
+    debug_parser = subparsers.add_parser(
+        "debug",
+        help="connect to the active testing environment with zxdb",
+    )
+    debug_parser.add_argument(
+        "--rust-src",
+        default=None,
+        help="the path to the Rust source being tested",
+    )
+    debug_parser.add_argument(
+        "--fuchsia-src",
+        default=None,
+        help="the path to the Fuchsia source",
+    )
+    debug_parser.add_argument(
+        "--test",
+        default=None,
+        help="the path to the test to debug (e.g. ui/box/new.rs)",
+    )
+    debug_parser.add_argument(
+        "zxdb_args",
+        default=None,
+        nargs=argparse.REMAINDER,
+        help="any additional arguments to pass to zxdb",
+    )
+    debug_parser.set_defaults(func=debug)
+
+    args = parser.parse_args()
+    return args.func(args)
+
+
+if __name__ == "__main__":
+    sys.exit(main())