Repository: beeware/briefcase

Issue:
Android build on ARM Mac without Rosetta gives error "Bad CPU type in executable"
### Describe the bug

Briefcase installs an x86 version of Java, because there's no ARM build available for Java 8 (see #827 for why we're still on that version). But the x86 build won't run unless Rosetta is installed.

### Steps to reproduce

On an ARM Mac which doesn't have Rosetta, follow the Android section of the BeeWare tutorial.



### Expected behavior

Build succeeds.

### Screenshots

_No response_

### Environment

- Operating System: macOS 12.6
- Python version: 3.11
- Software versions:
  - Briefcase: 0.3.11


### Logs

```
% briefcase create android

[java] The Java JDK was not found; downloading and installing...
Downloading OpenJDK8U-jdk_x64_mac_hotspot_8u242b08.tar.gz...
     ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 100.0% • 00:17
Installing AdoptOpenJDK... done

[android_sdk] The Android SDK was not found; downloading and installing...
Downloading commandlinetools-mac-8092744_latest.zip...
     ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 100.0% • 00:20
Installing Android SDK Command-Line Tools... done

The Android tools provided by Google have license terms that you must accept
before you may use those tools.
/Users/msmith/Library/Caches/org.beeware.briefcase/tools/android_sdk/cmdline-tools/latest/bin/sdkmanager: line 172: /Users/msmith/Library/Caches/org.beeware.briefcase/tools/java/Contents/Home/bin/java: Bad CPU type in executable
/Users/msmith/Library/Caches/org.beeware.briefcase/tools/android_sdk/cmdline-tools/latest/bin/sdkmanager: line 172: /Users/msmith/Library/Caches/org.beeware.briefcase/tools/java/Contents/Home/bin/java: Undefined error: 0

Error while reviewing Android SDK licenses. Please run this command and examine
its output for errors.

    $ /Users/msmith/Library/Caches/org.beeware.briefcase/tools/android_sdk/cmdline-tools/latest/bin/sdkmanager --licenses

Log saved to /Users/msmith/git/beeware/helloworld/logs/briefcase.2022_12_12-17_06_16.create.log
```
[briefcase.2022_12_12-17_06_16.create.log](https://github.com/beeware/briefcase/files/10210494/briefcase.2022_12_12-17_06_16.create.log)

### Additional context

As shown [here](https://apple.stackexchange.com/questions/408375), this can be fixed by running `softwareupdate --install-rosetta`. Briefcase should do this automatically.

Discussion:

Patch:
diff --git a/changes/1000.bugfix.rst b/changes/1000.bugfix.rst
new file mode 100644
index 00000000..95346fea
--- /dev/null
+++ b/changes/1000.bugfix.rst
@@ -0,0 +1,1 @@
+On macOS, Rosetta is now installed automatically if needed.
diff --git a/changes/1001.misc.rst b/changes/1001.misc.rst
deleted file mode 100644
index 83444515..00000000
--- a/changes/1001.misc.rst
+++ /dev/null
@@ -1,1 +0,0 @@
-The packaging of sdist and wheel artefacts was simplified.
diff --git a/changes/1002.misc.rst b/changes/1002.misc.rst
deleted file mode 100644
index 9b37c9f2..00000000
--- a/changes/1002.misc.rst
+++ /dev/null
@@ -1,1 +0,0 @@
-A single end-of-app sentinel is now used to report process exit status.
diff --git a/changes/1004.bugfix.rst b/changes/1004.bugfix.rst
deleted file mode 100644
index f881c8ce..00000000
--- a/changes/1004.bugfix.rst
+++ /dev/null
@@ -1,1 +0,0 @@
-Web project configuration has been updated to reflect recent changes to PyScript.
diff --git a/docs/reference/configuration.rst b/docs/reference/configuration.rst
index 8070f258..6ee34461 100644
--- a/docs/reference/configuration.rst
+++ b/docs/reference/configuration.rst
@@ -224,21 +224,6 @@ on an app with a formal name of "My App" would remove:
 3. Any ``.exe`` file in ``path`` or its subdirectories.
 4. The file ``My App/content/extra.doc``.
 
-``exit_regex``
-~~~~~~~~~~~~~~
-
-A regular expression that will be executed against the console output generated
-by an application. If/when the regular expression find match, the application
-will be terminated; the line matching the regular expression will *not* be
-output to the console. Used by Briefcase to monitor test suites; however, the
-filter will also be honored on normal ``run`` invocations.
-
-The regular expression should capture a single group named "returncode",
-capturing the integer exit status that should be reported for the process. The
-default value for this regular expression is ``^>>>>>>>>>> EXIT
-(?P<returncode>.*) <<<<<<<<<<$`` The regex will be compiled with the
-``re.MULTILINE`` flag enabled.
-
 ``formal_name``
 ~~~~~~~~~~~~~~~
 
@@ -411,6 +396,16 @@ application defines sources at the global level, application level, *and*
 platform level, the final set of sources will be the *concatenation* of test
 sources from all levels, starting from least to most specific.
 
+``test_success_regex`` / ``test_failure_regex``
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+Regular expressions that match the success and failure output of the test suite.
+The ``re.MULTILINE`` flag will be used.
+
+Briefcase defaults to regular expressions that are able to match the output of
+``unittest`` or ``pytest``; if you use a different test framework, you will need
+to customize these settings.
+
 ``url``
 ~~~~~~~
 
diff --git a/src/briefcase/commands/dev.py b/src/briefcase/commands/dev.py
index f5b26b84..2a43f64f 100644
--- a/src/briefcase/commands/dev.py
+++ b/src/briefcase/commands/dev.py
@@ -4,7 +4,6 @@ import sys
 from pathlib import Path
 from typing import Optional
 
-from briefcase.commands.run import RunAppMixin
 from briefcase.config import BaseConfig
 from briefcase.exceptions import BriefcaseCommandError
 
@@ -12,7 +11,7 @@ from .base import BaseCommand
 from .create import RequirementsInstallError, write_dist_info
 
 
-class DevCommand(RunAppMixin, BaseCommand):
+class DevCommand(BaseCommand):
     cmd_line = "briefcase dev"
     command = "dev"
     output_format = None
@@ -105,36 +104,34 @@ class DevCommand(RunAppMixin, BaseCommand):
         :param env: environment dictionary for sub command
         :param test_mode: Run the test suite, rather than the app?
         """
-        main_module = app.main_module(test_mode)
-        app_popen = self.tools.subprocess.Popen(
-            [
-                sys.executable,
-                "-u",
-                "-X",
-                "dev",
-                "-X",
-                "utf8",
-                "-c",
-                (
-                    "import runpy, sys;"
-                    "sys.path.pop(0);"
-                    f'runpy.run_module("{main_module}", run_name="__main__", alter_sys=True)'
-                ),
-            ],
-            env=env,
-            cwd=self.tools.home_path,
-            stdout=subprocess.PIPE,
-            stderr=subprocess.STDOUT,
-            bufsize=1,
-        )
-
-        # Start streaming logs for the app.
-        self._stream_app_logs(
-            app,
-            popen=app_popen,
-            test_mode=test_mode,
-            clean_output=False,
-        )
+        try:
+            main_module = app.main_module(test_mode)
+
+            # Invoke the app.
+            self.logger.info("=" * 75)
+            self.tools.subprocess.run(
+                [
+                    sys.executable,
+                    "-u",
+                    "-X",
+                    "dev",
+                    "-X",
+                    "utf8",
+                    "-c",
+                    (
+                        "import runpy, sys;"
+                        "sys.path.pop(0);"
+                        f'runpy.run_module("{main_module}", run_name="__main__", alter_sys=True)'
+                    ),
+                ],
+                env=env,
+                check=True,
+                cwd=self.tools.home_path,
+            )
+        except subprocess.CalledProcessError as e:
+            raise BriefcaseCommandError(
+                f"Problem running {'test suite' if test_mode else 'application'} {main_module!r}"
+            ) from e
 
     def get_environment(self, app, test_mode: bool):
         # Create a shell environment where PYTHONPATH points to the source
diff --git a/src/briefcase/commands/run.py b/src/briefcase/commands/run.py
index 1fdf9e4b..ffaa62b8 100644
--- a/src/briefcase/commands/run.py
+++ b/src/briefcase/commands/run.py
@@ -10,14 +10,43 @@ from .base import BaseCommand, full_options
 
 
 class LogFilter:
-    DEFAULT_EXIT_REGEX = r"^>>>>>>>>>> EXIT (?P<returncode>.*) <<<<<<<<<<$"
+    DEFAULT_SUCCESS_REGEX = (
+        # Unittest
+        # The ?P<a...> groups are to support Android logging differences.
+        # when https://github.com/chaquo/chaquopy/issues/746 is resolved,
+        # those groups can be deleted.
+        r"(^-{65,}\n(?P<a1> \n)?Ran \d+ tests in -?\d+\.\d{3}s"
+        r"\n(?P<a2> )?\n(?P<a3> \n)?OK( \(.*\))?$)"
+        # PyTest
+        r"|"
+        r"(^=+ ("
+        r"((, )?\d+ (passed|skipped|deselected|xfailed|xpassed|warnings?))*"
+        r"|(no tests ran)"
+        r") in -?\d+\.\d+s.* =+$)"
+    )
+
+    DEFAULT_FAILURE_REGEX = (
+        # Unittest
+        # The ?P<a...> groups are to support Android logging differences.
+        # when https://github.com/chaquo/chaquopy/issues/746 is resolved,
+        # those groups can be deleted.
+        r"(^-{65,}\n(?P<a1> \n)?Ran \d+ tests in -?\d+\.\d{3}s"
+        r"\n(?P<a2> )?\n(?P<a3> \n)?FAILED( \(.*\))?$)"
+        # Pytest
+        r"|"
+        r"(^=+ ("
+        r"(\d+ failed((, )?\d+ (passed|skipped|deselected|xfailed|xpassed|warnings?|errors?))*)"
+        r"|((\d+ (failed|passed|skipped|deselected|xfailed|xpassed|warnings?)(, )?)*\d+ errors?)"
+        r") in -?\d+.\d+s.* =+$)"
+    )
 
     def __init__(
         self,
         log_popen,
         clean_filter,
         clean_output,
-        exit_filter,
+        success_filter,
+        failure_filter,
     ):
         """Create a filter for a log stream.
 
@@ -26,19 +55,25 @@ class LogFilter:
             returning a "clean" line without any log system preamble.
         :param clean_output: Should the output displayed to the user be the
             "clean" output? (Default: True).
-        :param exit_filter: A function that will operate on a string containing
-            the last 10 lines of "clean" (i.e., preamble filtered) logs,
-            returning the integer exit status of the process if an exit
-            condition has been detected, or None if the log stream should
-            continue.
+        :param success_filter: A function that will operate on a string
+            containing the last 10 lines of "clean" (i.e., preamble filtered)
+            logs, returning True if a "success" condition has been detected. If
+            the success filter returns True, a success condition will be
+            recorded, and the log streamer will be told to stop streaming.
+        :param failure_filter: A function that will operate on a string
+            containing the last 10 lines of "clean" (i.e., preamble filtered)
+            logs, returning True if a "failure" condition has been detected. If
+            the failure filter returns True, a success condition will be
+            recorded, and the log streamer will be told to stop streaming.
         """
         self.log_popen = log_popen
-        self.returncode = None
+        self.success = None
         self.clean_filter = clean_filter
         self.clean_output = clean_output
 
         self.recent_history = []
-        self.exit_filter = exit_filter
+        self.success_filter = success_filter
+        self.failure_filter = failure_filter
 
     def __call__(self, line):
         """Filter a single line of a log.
@@ -75,12 +110,15 @@ class LogFilter:
             self.recent_history = self.recent_history[-10:]
             tail = "\n".join(self.recent_history)
 
-            # Look for the exit condition in the tail
-            if self.exit_filter:
-                self.returncode = self.exit_filter(tail)
-                if self.returncode is not None:
-                    raise StopStreaming()
-
+            # Look for the success/failure conditions in the tail
+            if self.failure_filter and self.failure_filter(tail):
+                self.success = False
+                yield display_line
+                raise StopStreaming()
+            elif self.success_filter and self.success_filter(tail):
+                self.success = True
+                yield display_line
+                raise StopStreaming()
         # Return the display line
         yield display_line
 
@@ -95,26 +133,49 @@ class LogFilter:
         """
 
         def filter_func(recent):
-            match = filter_func.regex.search(recent)
-            if match:
-                try:
-                    return int(match.groupdict()["returncode"])
-                except KeyError:
-                    # No returncode group in regex
-                    return -998
-                except ValueError:
-                    # Non-integer return code content
-                    return -999
-            return None
+            return filter_func.regex.search(recent) is not None
 
         # Annotate the function with the regex that will be used in the function.
         filter_func.regex = re.compile(pattern, re.MULTILINE)
         return filter_func
 
 
-class RunAppMixin:
-    """A mixin that captures the logic of starting an app and streaming the app
-    logs."""
+class RunCommand(BaseCommand):
+    command = "run"
+
+    def add_options(self, parser):
+        parser.add_argument(
+            "-a",
+            "--app",
+            dest="appname",
+            help="The app to run",
+        )
+
+        self._add_update_options(parser, context_label=" before running")
+        self._add_test_options(parser, context_label="Run")
+
+    def _prepare_app_env(self, app: BaseConfig, test_mode: bool):
+        """Prepare the environment for running an app as a log stream.
+
+        This won't be used by every backend; but it's a sufficiently common
+        default that it's been factored out.
+
+        :param app: The app to be launched
+        :param test_mode: Are we launching in test mode?
+        :returns: A dictionary of additional arguments to pass to the Popen
+        """
+        if test_mode:
+            # In test mode, set a BRIEFCASE_MAIN_MODULE environment variable
+            # to override the module at startup
+            self.logger.info("Starting test_suite...", prefix=app.app_name)
+            return {
+                "env": {
+                    "BRIEFCASE_MAIN_MODULE": app.main_module(test_mode),
+                }
+            }
+        else:
+            self.logger.info("Starting app...", prefix=app.app_name)
+            return {}
 
     def _stream_app_logs(
         self,
@@ -145,15 +206,23 @@ class RunAppMixin:
             is of the log, not the app itself.
         """
         try:
-            exit_filter = LogFilter.test_filter(
-                getattr(app, "exit_regex", LogFilter.DEFAULT_EXIT_REGEX)
-            )
+            if test_mode:
+                success_filter = LogFilter.test_filter(
+                    getattr(app, "test_success_regex", LogFilter.DEFAULT_SUCCESS_REGEX)
+                )
+                failure_filter = LogFilter.test_filter(
+                    getattr(app, "test_failure_regex", LogFilter.DEFAULT_FAILURE_REGEX)
+                )
+            else:
+                success_filter = None
+                failure_filter = None
 
             log_filter = LogFilter(
                 popen,
                 clean_filter=clean_filter,
                 clean_output=clean_output,
-                exit_filter=exit_filter,
+                success_filter=success_filter,
+                failure_filter=failure_filter,
             )
 
             # Start streaming logs for the app.
@@ -168,22 +237,17 @@ class RunAppMixin:
             # If we're in test mode, and log streaming ends,
             # check for the status of the test suite.
             if test_mode:
-                if log_filter.returncode == 0:
+                if log_filter.success:
                     self.logger.info("Test suite passed!", prefix=app.app_name)
                 else:
-                    if log_filter.returncode is None:
+                    if log_filter.success is None:
                         raise BriefcaseCommandError(
                             "Test suite didn't report a result."
                         )
                     else:
                         self.logger.error("Test suite failed!", prefix=app.app_name)
                         raise BriefcaseTestSuiteFailure()
-            elif log_stream:
-                # If we're monitoring a log stream, and the log stream reported a
-                # non-zero exit code, surface that error to the user.
-                if log_filter.returncode is not None and log_filter.returncode != 0:
-                    raise BriefcaseCommandError(f"Problem running app {app.app_name}.")
-            else:
+            elif not log_stream:
                 # If we're monitoring an actual app (not just a log stream),
                 # and the app didn't exit cleanly, surface the error to the user.
                 if popen.returncode != 0:
@@ -192,44 +256,6 @@ class RunAppMixin:
         except KeyboardInterrupt:
             pass  # Catch CTRL-C to exit normally
 
-
-class RunCommand(RunAppMixin, BaseCommand):
-    command = "run"
-
-    def add_options(self, parser):
-        parser.add_argument(
-            "-a",
-            "--app",
-            dest="appname",
-            help="The app to run",
-        )
-
-        self._add_update_options(parser, context_label=" before running")
-        self._add_test_options(parser, context_label="Run")
-
-    def _prepare_app_env(self, app: BaseConfig, test_mode: bool):
-        """Prepare the environment for running an app as a log stream.
-
-        This won't be used by every backend; but it's a sufficiently common
-        default that it's been factored out.
-
-        :param app: The app to be launched
-        :param test_mode: Are we launching in test mode?
-        :returns: A dictionary of additional arguments to pass to the Popen
-        """
-        if test_mode:
-            # In test mode, set a BRIEFCASE_MAIN_MODULE environment variable
-            # to override the module at startup
-            self.logger.info("Starting test_suite...", prefix=app.app_name)
-            return {
-                "env": {
-                    "BRIEFCASE_MAIN_MODULE": app.main_module(test_mode),
-                }
-            }
-        else:
-            self.logger.info("Starting app...", prefix=app.app_name)
-            return {}
-
     @abstractmethod
     def run_app(self, app: BaseConfig, **options):
         """Start an application.
diff --git a/src/briefcase/integrations/java.py b/src/briefcase/integrations/java.py
index f0d90c7d..f9310b43 100644
--- a/src/briefcase/integrations/java.py
+++ b/src/briefcase/integrations/java.py
@@ -69,6 +69,10 @@ class JDK(Tool):
         java_home = tools.os.environ.get("JAVA_HOME", "")
         install_message = None
 
+        if tools.host_arch == "arm64" and tools.host_os == "Darwin":
+            # Java 8 is not available for macOS on ARM64, so we will require Rosetta.
+            cls.verify_rosetta(tools)
+
         # macOS has a helpful system utility to determine JAVA_HOME. Try it.
         if not java_home and tools.host_os == "Darwin":
             try:
@@ -284,3 +288,24 @@ Delete {jdk_zip_path} and run briefcase again.
 
         self.uninstall()
         self.install()
+
+    @classmethod
+    def verify_rosetta(cls, tools):
+        try:
+            tools.subprocess.check_output(
+                ["arch", "-x86_64", "true"], stderr=subprocess.STDOUT
+            )
+        except subprocess.CalledProcessError:
+            tools.logger.info(
+                """\
+This command requires Rosetta, but it does not appear to be installed. Briefcase will
+attempt to install it now.
+"""
+            )
+            try:
+                tools.subprocess.run(
+                    ["softwareupdate", "--install-rosetta", "--agree-to-license"],
+                    check=True,
+                )
+            except subprocess.CalledProcessError as e:
+                raise BriefcaseCommandError("Failed to install Rosetta") from e
diff --git a/src/briefcase/platforms/android/gradle.py b/src/briefcase/platforms/android/gradle.py
index cc200b43..d85c1f32 100644
--- a/src/briefcase/platforms/android/gradle.py
+++ b/src/briefcase/platforms/android/gradle.py
@@ -112,9 +112,30 @@ class GradleMixin:
             / "app-release.aab"
         )
 
-    def gradlew_path(self, app):
+    def run_gradle(self, app, args):
+        # Gradle may install the emulator via the dependency chain build-tools > tools >
+        # emulator. (The `tools` package only shows up in sdkmanager if you pass
+        # `--include_obsolete`.) However, the old sdkmanager built into Android Gradle
+        # plugin 4.2 doesn't know about macOS on ARM, so it'll install an x86_64 emulator
+        # which won't work with ARM system images.
+        #
+        # Work around this by pre-installing the emulator with our own sdkmanager before
+        # running Gradle. For simplicity, we do this on all platforms, since the user will
+        # almost certainly want an emulator soon enough.
+        self.tools.android_sdk.verify_emulator()
+
         gradlew = "gradlew.bat" if self.tools.host_os == "Windows" else "gradlew"
-        return self.bundle_path(app) / gradlew
+        self.tools.subprocess.run(
+            # Windows needs the full path to `gradlew`; macOS & Linux can find it
+            # via `./gradlew`. For simplicity of implementation, we always provide
+            # the full path.
+            [self.bundle_path(app) / gradlew] + args + ["--console", "plain"],
+            env=self.tools.android_sdk.env,
+            # Set working directory so gradle can use the app bundle path as its
+            # project root, i.e., to avoid 'Task assembleDebug not found'.
+            cwd=self.bundle_path(app),
+            check=True,
+        )
 
     def verify_tools(self):
         """Verify that the Android APK tools in `briefcase` will operate on
@@ -201,17 +222,7 @@ class GradleBuildCommand(GradleMixin, BuildCommand):
         self.logger.info("Building Android APK...", prefix=app.app_name)
         with self.input.wait_bar("Building..."):
             try:
-                self.tools.subprocess.run(
-                    # Windows needs the full path to `gradlew`; macOS & Linux can find it
-                    # via `./gradlew`. For simplicity of implementation, we always provide
-                    # the full path.
-                    [self.gradlew_path(app), "assembleDebug", "--console", "plain"],
-                    env=self.tools.android_sdk.env,
-                    # Set working directory so gradle can use the app bundle path as its
-                    # project root, i.e., to avoid 'Task assembleDebug not found'.
-                    cwd=self.bundle_path(app),
-                    check=True,
-                )
+                self.run_gradle(app, ["assembleDebug"])
             except subprocess.CalledProcessError as e:
                 raise BriefcaseCommandError("Error while building project.") from e
 
@@ -389,17 +400,7 @@ class GradlePackageCommand(GradleMixin, PackageCommand):
         )
         with self.input.wait_bar("Bundling..."):
             try:
-                self.tools.subprocess.run(
-                    # Windows needs the full path to `gradlew`; macOS & Linux can find it
-                    # via `./gradlew`. For simplicity of implementation, we always provide
-                    # the full path.
-                    [self.gradlew_path(app), "bundleRelease", "--console", "plain"],
-                    env=self.tools.android_sdk.env,
-                    # Set working directory so gradle can use the app bundle path as its
-                    # project root, i.e., to avoid 'Task bundleRelease not found'.
-                    cwd=self.bundle_path(app),
-                    check=True,
-                )
+                self.run_gradle(app, ["bundleRelease"])
             except subprocess.CalledProcessError as e:
                 raise BriefcaseCommandError("Error while building project.") from e
 
diff --git a/src/briefcase/platforms/web/static.py b/src/briefcase/platforms/web/static.py
index 93576ce9..8aaef4fc 100644
--- a/src/briefcase/platforms/web/static.py
+++ b/src/briefcase/platforms/web/static.py
@@ -170,8 +170,7 @@ class StaticWebBuildCommand(StaticWebMixin, BuildCommand):
                     "name": app.formal_name,
                     "description": app.description,
                     "version": app.version,
-                    "splashscreen": {"autoclose": True},
-                    "terminal": False,
+                    "autoclose_loader": True,
                     # Ensure that we're using Unix path separators, as the content
                     # will be parsed by pyscript in the browser.
                     "packages": [
diff --git a/tox.ini b/tox.ini
index 605a09fb..1e607d14 100644
--- a/tox.ini
+++ b/tox.ini
@@ -36,5 +36,5 @@ deps =
     twine
 commands =
     check-manifest -v
-    python -m build --outdir dist/ .
+    python -m build --sdist --wheel --outdir dist/ .
     python -m twine check dist/*


Test patch:
diff --git a/tests/commands/dev/test_run_dev_app.py b/tests/commands/dev/test_run_dev_app.py
index 3e38df14..f55a5d2d 100644
--- a/tests/commands/dev/test_run_dev_app.py
+++ b/tests/commands/dev/test_run_dev_app.py
@@ -1,20 +1,18 @@
-import subprocess
 import sys
-from unittest import mock
+from subprocess import CalledProcessError
 
+import pytest
 
-def test_dev_run(dev_command, first_app, tmp_path):
-    "The app can be run in dev mode"
-    dev_command._stream_app_logs = mock.MagicMock()
-    app_popen = mock.MagicMock()
-    dev_command.tools.subprocess.Popen.return_value = app_popen
+from briefcase.exceptions import BriefcaseCommandError
 
+
+def test_subprocess_running_successfully(dev_command, first_app, tmp_path):
     dev_command.run_dev_app(
         first_app,
         env={"a": 1, "b": 2, "c": 3},
         test_mode=False,
     )
-    dev_command.tools.subprocess.Popen.assert_called_once_with(
+    dev_command.tools.subprocess.run.assert_called_once_with(
         [
             sys.executable,
             "-u",
@@ -31,30 +29,53 @@ def test_dev_run(dev_command, first_app, tmp_path):
         ],
         env={"a": 1, "b": 2, "c": 3},
         cwd=dev_command.tools.home_path,
-        stdout=subprocess.PIPE,
-        stderr=subprocess.STDOUT,
-        bufsize=1,
+        check=True,
     )
-    dev_command._stream_app_logs.assert_called_once_with(
-        first_app,
-        popen=app_popen,
-        test_mode=False,
-        clean_output=False,
+
+
+def test_subprocess_throws_error(dev_command, first_app, tmp_path):
+    dev_command.tools.subprocess.run.side_effect = CalledProcessError(
+        returncode=2, cmd="cmd"
+    )
+    with pytest.raises(
+        BriefcaseCommandError,
+        match="Problem running application 'first'",
+    ):
+        dev_command.run_dev_app(
+            first_app,
+            env={"a": 1, "b": 2, "c": 3},
+            test_mode=False,
+        )
+
+    dev_command.tools.subprocess.run.assert_called_once_with(
+        [
+            sys.executable,
+            "-u",
+            "-X",
+            "dev",
+            "-X",
+            "utf8",
+            "-c",
+            (
+                "import runpy, sys;"
+                "sys.path.pop(0);"
+                'runpy.run_module("first", run_name="__main__", alter_sys=True)'
+            ),
+        ],
+        env={"a": 1, "b": 2, "c": 3},
+        cwd=dev_command.tools.home_path,
+        check=True,
     )
 
 
-def test_dev_test_mode(dev_command, first_app, tmp_path):
+def test_subprocess_test_mode_success(dev_command, first_app, tmp_path):
     "The test suite can be run in development mode"
-    dev_command._stream_app_logs = mock.MagicMock()
-    app_popen = mock.MagicMock()
-    dev_command.tools.subprocess.Popen.return_value = app_popen
-
     dev_command.run_dev_app(
         first_app,
         env={"a": 1, "b": 2, "c": 3},
         test_mode=True,
     )
-    dev_command.tools.subprocess.Popen.assert_called_once_with(
+    dev_command.tools.subprocess.run.assert_called_once_with(
         [
             sys.executable,
             "-u",
@@ -71,13 +92,42 @@ def test_dev_test_mode(dev_command, first_app, tmp_path):
         ],
         env={"a": 1, "b": 2, "c": 3},
         cwd=dev_command.tools.home_path,
-        stdout=subprocess.PIPE,
-        stderr=subprocess.STDOUT,
-        bufsize=1,
+        check=True,
     )
-    dev_command._stream_app_logs.assert_called_once_with(
-        first_app,
-        popen=app_popen,
-        test_mode=True,
-        clean_output=False,
+
+
+def test_subprocess_test_mode_failure(dev_command, first_app, tmp_path):
+    "Failure in test mode raises a BriefcaseCommandError"
+    dev_command.tools.subprocess.run.side_effect = CalledProcessError(
+        returncode=2, cmd="cmd"
+    )
+
+    with pytest.raises(
+        BriefcaseCommandError,
+        match=r"Problem running test suite 'tests.first'",
+    ):
+        dev_command.run_dev_app(
+            first_app,
+            env={"a": 1, "b": 2, "c": 3},
+            test_mode=True,
+        )
+
+    dev_command.tools.subprocess.run.assert_called_once_with(
+        [
+            sys.executable,
+            "-u",
+            "-X",
+            "dev",
+            "-X",
+            "utf8",
+            "-c",
+            (
+                "import runpy, sys;"
+                "sys.path.pop(0);"
+                'runpy.run_module("tests.first", run_name="__main__", alter_sys=True)'
+            ),
+        ],
+        env={"a": 1, "b": 2, "c": 3},
+        cwd=dev_command.tools.home_path,
+        check=True,
     )
diff --git a/tests/commands/run/test_LogFilter.py b/tests/commands/run/test_LogFilter.py
index f86e1759..fd68b1b8 100644
--- a/tests/commands/run/test_LogFilter.py
+++ b/tests/commands/run/test_LogFilter.py
@@ -13,7 +13,8 @@ def test_default_filter():
         popen,
         clean_filter=None,
         clean_output=True,
-        exit_filter=None,
+        success_filter=None,
+        failure_filter=None,
     )
 
     for i in range(0, 10):
@@ -22,8 +23,9 @@ def test_default_filter():
         # Every line is returned verbatim
         assert [line] == list(log_filter(line))
 
-    # no return code was detected
-    assert log_filter.returncode is None
+    # no signals were sent, and no test status was triggered
+    popen.send_signal.assert_not_called()
+    assert log_filter.success is None
 
 
 def test_clean_filter():
@@ -38,7 +40,8 @@ def test_clean_filter():
         popen,
         clean_filter=clean_filter,
         clean_output=True,
-        exit_filter=None,
+        success_filter=None,
+        failure_filter=None,
     )
 
     for i in range(0, 10):
@@ -47,8 +50,9 @@ def test_clean_filter():
         # The line has the preamble stripped
         assert [line[5:]] == list(log_filter(line))
 
-    # no return code was detected
-    assert log_filter.returncode is None
+    # no signals were sent, and no test status was triggered
+    popen.send_signal.assert_not_called()
+    assert log_filter.success is None
 
 
 def test_clean_filter_unclean_output():
@@ -63,7 +67,8 @@ def test_clean_filter_unclean_output():
         popen,
         clean_filter=clean_filter,
         clean_output=False,
-        exit_filter=None,
+        success_filter=None,
+        failure_filter=None,
     )
 
     for i in range(0, 10):
@@ -72,12 +77,13 @@ def test_clean_filter_unclean_output():
         # Every line is returned verbatim
         assert [line] == list(log_filter(line))
 
-    # no return code was detected
-    assert log_filter.returncode is None
+    # no signals were sent, and no test status was triggered
+    popen.send_signal.assert_not_called()
+    assert log_filter.success is None
 
 
 @pytest.mark.parametrize(
-    "raw, expected_output, use_content_filter, clean_output, returncode",
+    "raw, expected_output, use_content_filter, clean_output, expected_success",
     [
         # Without cleaning, simple content is passed through as is.
         (
@@ -85,7 +91,7 @@ def test_clean_filter_unclean_output():
             ["1: line 1", "2: line 2", "3: line 3", "4: line 4", "5: line 5"],
             False,  # don't use content filter
             False,  # don't use clean output
-            None,  # no return code
+            None,  # no test termination
         ),
         # Cleaning can be turned on without altering output
         (
@@ -93,7 +99,7 @@ def test_clean_filter_unclean_output():
             ["1: line 1", "2: line 2", "3: line 3", "4: line 4", "5: line 5"],
             True,  # use content filter
             False,  # don't use clean output
-            None,  # no return code
+            None,  # no test termination
         ),
         # Dumped content is ignored, even if output isn't being cleaned
         (
@@ -101,7 +107,7 @@ def test_clean_filter_unclean_output():
             ["1: line 1", "2: line 2", "4: line 4", "5: line 5"],
             True,  # use content filter
             False,  # don't use clean output
-            None,  # no return code
+            None,  # no test termination
         ),
         # Output can be cleaned
         (
@@ -109,7 +115,7 @@ def test_clean_filter_unclean_output():
             ["line 1", "line 2", "line 3", "line 4", "line 5"],
             True,  # use content filter
             True,  # use clean output
-            None,  # no return code
+            None,  # no test termination
         ),
         # Dumped content won't appear in cleaned output
         (
@@ -117,86 +123,181 @@ def test_clean_filter_unclean_output():
             ["line 1", "line 2", "line 4", "line 5"],
             True,  # use content filter
             True,  # use clean output
-            None,  # no return code
+            None,  # no test termination
         ),
-        # Without cleaning, exit status won't be found
+        # Without cleaning, success criteria won't be found
         (
-            ["1: line 1", "2: line 2", "3: -----", "4: ", "5: EXIT 42", "post"],
-            ["1: line 1", "2: line 2", "3: -----", "4: ", "5: EXIT 42", "post"],
+            ["1: line 1", "2: line 2", "3: -----", "4: ", "5: SUCCESS", "post"],
+            ["1: line 1", "2: line 2", "3: -----", "4: ", "5: SUCCESS", "post"],
             False,  # don't use content filter
             False,  # don't use clean output
-            None,  # no return code due to line prefixes
+            None,  # no test termination due to line prefixes
         ),
-        # If test output is clean without filtering, exit status can be determined
+        # If test output is clean without filtering, success can be determined
         (
-            ["line 1", "line 2", "-----", "", "EXIT 42", "post"],
-            ["line 1", "line 2", "-----", ""],
+            ["line 1", "line 2", "-----", "", "SUCCESS", "post"],
+            ["line 1", "line 2", "-----", "", "SUCCESS"],
             False,  # don't use content filter
             False,  # don't use clean output
-            42,  # exit status from raw output
+            True,  # test success from raw output
         ),
-        # Exit status can be found without altering output
+        # Success criteria can be found without altering output
         # Line prefixes are all even to ensure they aren't ignored
         (
-            ["2: line 1", "4: line 2", "6: -----", "8: ", "10: EXIT 42", "post"],
-            ["2: line 1", "4: line 2", "6: -----", "8: "],
+            ["2: line 1", "4: line 2", "6: -----", "8: ", "10: SUCCESS", "post"],
+            ["2: line 1", "4: line 2", "6: -----", "8: ", "10: SUCCESS"],
             True,  # use content filter
             False,  # don't use clean output
-            42,  # exit status
+            True,  # Test success
         ),
-        # Exit status won't consider ignored content
+        # Success criteria won't consider ignored content
         # Dumped content won't be considered
         (
-            ["2: line 1", "4: -----", "DUMP: garbage", "6: ", "8: EXIT 42", "post"],
-            ["2: line 1", "4: -----", "6: "],
+            ["2: line 1", "4: -----", "DUMP: garbage", "6: ", "8: SUCCESS", "post"],
+            ["2: line 1", "4: -----", "6: ", "8: SUCCESS"],
             True,  # use content filter
             False,  # don't use clean output
-            42,  # exit status
+            True,  # Test success
         ),
-        # Exit status won't consider ignored content
+        # Success criteria won't consider ignored content
         # Line 5 will be ignored, but not dumped
         (
-            ["2: line 1", "4: -----", "5: Ignore this", "6: ", "8: EXIT 42", "post"],
-            ["2: line 1", "4: -----", "5: Ignore this", "6: "],
+            ["2: line 1", "4: -----", "5: Ignore this", "6: ", "8: SUCCESS", "post"],
+            ["2: line 1", "4: -----", "5: Ignore this", "6: ", "8: SUCCESS"],
             True,  # use content filter
             False,  # don't use clean output
-            42,  # exit status
+            True,  # Test success
         ),
-        # Exit status can be found with cleaned output
+        # Success criteria can be found with cleaned output
         # Line prefixes are all even to ensure they aren't ignored; output is clean
         (
-            ["2: line 1", "4: line 2", "6: -----", "8: ", "10: EXIT 42", "post"],
-            ["line 1", "line 2", "-----", ""],
+            ["2: line 1", "4: line 2", "6: -----", "8: ", "10: SUCCESS", "post"],
+            ["line 1", "line 2", "-----", "", "SUCCESS"],
             True,  # use content filter
             True,  # use clean output
-            42,  # Exit status
+            True,  # Test success
         ),
-        # Exit status won't consider ignored content
+        # Success criteria won't consider ignored content
         # Dumped content won't be considered; output is clean
         (
-            ["2: line 1", "4: -----", "DUMP: garbage", "6: ", "8: EXIT 42", "post"],
-            ["line 1", "-----", ""],
+            ["2: line 1", "4: -----", "DUMP: garbage", "6: ", "8: SUCCESS", "post"],
+            ["line 1", "-----", "", "SUCCESS"],
             True,  # use content filter
             True,  # use clean output
-            42,  # exit status
+            True,  # Test success
         ),
-        # Exit status won't consider ignored content
+        # Success criteria won't consider ignored content
         # Line 5 will be ignored, but not dumped; output is clean
         (
-            ["2: line 1", "4: -----", "5: Ignore this", "6: ", "8: EXIT 42", "post"],
-            ["line 1", "-----", "Ignore this", ""],
+            ["2: line 1", "4: -----", "5: Ignore this", "6: ", "8: SUCCESS", "post"],
+            ["line 1", "-----", "Ignore this", "", "SUCCESS"],
             True,  # use content filter
             True,  # use clean output
-            42,  # exit status
+            True,  # Test success
         ),
-        # Exit status won't be found if it occurs on ignored lines
+        # Success criteria won't be found if it occurs on ignored lines
         # Line 5 is the right pattern, but it will be marked as ignored by the cleaner
         (
-            ["2: line 1", "4: line 2", "5: -----", "6: ", "8: EXIT 42", "post"],
-            ["2: line 1", "4: line 2", "5: -----", "6: ", "8: EXIT 42", "post"],
+            ["2: line 1", "4: line 2", "5: -----", "6: ", "8: SUCCESS", "post"],
+            ["2: line 1", "4: line 2", "5: -----", "6: ", "8: SUCCESS", "post"],
             True,  # use content filter
             False,  # don't use clean output
-            None,  # no return code due to line 5 not matching
+            None,  # no test termination due to line 5 not matching
+        ),
+        # Without cleaning, failure criteria won't be found
+        (
+            ["1: line 1", "2: line 2", "3: -----", "4: ", "5: SUCCESS", "post"],
+            ["1: line 1", "2: line 2", "3: -----", "4: ", "5: SUCCESS", "post"],
+            False,  # don't use content filter
+            False,  # don't use clean output
+            None,  # no test termination
+        ),
+        # Without cleaning, failure criteria won't be found
+        (
+            ["1: line 1", "2: line 2", "3: -----", "4: ", "5: FAILURE", "post"],
+            ["1: line 1", "2: line 2", "3: -----", "4: ", "5: FAILURE", "post"],
+            False,  # don't use content filter
+            False,  # don't use clean output
+            None,  # no test termination due to line prefixes
+        ),
+        # If test output is clean without filtering, failure can be determined
+        (
+            ["line 1", "line 2", "-----", "", "FAILURE", "post"],
+            ["line 1", "line 2", "-----", "", "FAILURE"],
+            False,  # don't use content filter
+            False,  # don't use clean output
+            False,  # test failure from raw output
+        ),
+        # failure criteria can be found without altering output
+        # Line prefixes are all even to ensure they aren't ignored
+        (
+            ["2: line 1", "4: line 2", "6: -----", "8: ", "10: FAILURE", "post"],
+            ["2: line 1", "4: line 2", "6: -----", "8: ", "10: FAILURE"],
+            True,  # use content filter
+            False,  # don't use clean output
+            False,  # Test failure
+        ),
+        # failure criteria won't consider ignored content
+        # Dumped content won't be considered
+        (
+            ["2: line 1", "4: -----", "DUMP: garbage", "6: ", "8: FAILURE", "post"],
+            ["2: line 1", "4: -----", "6: ", "8: FAILURE"],
+            True,  # use content filter
+            False,  # don't use clean output
+            False,  # Test failure
+        ),
+        # failure criteria won't consider ignored content
+        # Line 5 will be ignored, but not dumped
+        (
+            ["2: line 1", "4: -----", "5: Ignore this", "6: ", "8: FAILURE", "post"],
+            ["2: line 1", "4: -----", "5: Ignore this", "6: ", "8: FAILURE"],
+            True,  # use content filter
+            False,  # don't use clean output
+            False,  # Test failure
+        ),
+        # failure criteria can be found with cleaned output
+        # Line prefixes are all even to ensure they aren't ignored; output is clean
+        (
+            ["2: line 1", "4: line 2", "6: -----", "8: ", "10: FAILURE", "post"],
+            ["line 1", "line 2", "-----", "", "FAILURE"],
+            True,  # use content filter
+            True,  # use clean output
+            False,  # Test failure
+        ),
+        # failure criteria won't consider ignored content
+        # Dumped content won't be considered; output is clean
+        (
+            ["2: line 1", "4: -----", "DUMP: garbage", "6: ", "8: FAILURE", "post"],
+            ["line 1", "-----", "", "FAILURE"],
+            True,  # use content filter
+            True,  # use clean output
+            False,  # Test failure
+        ),
+        # failure criteria won't consider ignored content
+        # Line 5 will be ignored, but not dumped; output is clean
+        (
+            ["2: line 1", "4: -----", "5: Ignore this", "6: ", "8: FAILURE", "post"],
+            ["line 1", "-----", "Ignore this", "", "FAILURE"],
+            True,  # use content filter
+            True,  # use clean output
+            False,  # Test failure
+        ),
+        # Without cleaning, failure criteria won't be found
+        (
+            ["1: line 1", "2: line 2", "3: -----", "4: ", "5: FAILURE", "post"],
+            ["1: line 1", "2: line 2", "3: -----", "4: ", "5: FAILURE", "post"],
+            False,  # don't use content filter
+            False,  # don't use clean output
+            None,  # no test termination
+        ),
+        # Failure criteria won't be found if it occurs on ignored lines
+        # Line 5 is the right pattern, but it will be marked as ignored by the cleaner
+        (
+            ["2: line 1", "4: line 2", "5: -----", "6: ", "8: FAILURE", "post"],
+            ["2: line 1", "4: line 2", "5: -----", "6: ", "8: FAILURE", "post"],
+            True,  # use content filter
+            False,  # don't use clean output
+            None,  # no test termination due to line 5 not matching
         ),
     ],
 )
@@ -205,7 +306,7 @@ def test_log_filter(
     expected_output,
     use_content_filter,
     clean_output,
-    returncode,
+    expected_success,
 ):
     "The log filter behaves as expected"
     # Define a clean filter that removes an index at the start of the line, only
@@ -220,8 +321,9 @@ def test_log_filter(
         except ValueError:
             return line, False
 
-    # Define a custom filter that looks for specific multiline output
-    exit_filter = LogFilter.test_filter(r"^-----\n\nEXIT (?P<returncode>\d+)$")
+    # Define a custom filters that looks for specific multiline output
+    success_filter = LogFilter.test_filter(r"^-----\n\nSUCCESS$")
+    failure_filter = LogFilter.test_filter(r"^-----\n\nFAILURE$")
 
     # Set up a log stream
     popen = mock.MagicMock()
@@ -229,7 +331,8 @@ def test_log_filter(
         popen,
         clean_filter=clean_filter if use_content_filter else None,
         clean_output=clean_output,
-        exit_filter=exit_filter,
+        success_filter=success_filter,
+        failure_filter=failure_filter,
     )
 
     # Pipe the raw output through the log filter, and capture the output
@@ -246,11 +349,11 @@ def test_log_filter(
     # Actual output is as expected
     assert output == expected_output
 
-    if returncode is None:
+    if expected_success is None:
         # No success/failure condition was set; no termination condition was processed
-        assert log_filter.returncode is None
+        assert log_filter.success is None
         assert not terminated
     else:
         # The success/failure condition was detected, and the termination condition was processed
-        assert log_filter.returncode == returncode
+        assert log_filter.success == expected_success
         assert terminated
diff --git a/tests/commands/run/test_LogFilter__test_failure_filter.py b/tests/commands/run/test_LogFilter__test_failure_filter.py
new file mode 100644
index 00000000..8581a0a5
--- /dev/null
+++ b/tests/commands/run/test_LogFilter__test_failure_filter.py
@@ -0,0 +1,279 @@
+import pytest
+
+from briefcase.commands.run import LogFilter
+
+
+@pytest.mark.parametrize(
+    "recent_history",
+    (
+        # Unittest
+        # - Failures
+        [
+            "----------------------------------------------------------------------",
+            "Ran 6 tests in 0.001s",
+            "",
+            "FAILED (failures=2)",
+        ],
+        # - Failures and skips
+        [
+            "----------------------------------------------------------------------",
+            "Ran 7 tests in 0.000s",
+            "",
+            "FAILED (failures=2, skipped=1)",
+        ],
+        # - Failures and expected failures
+        [
+            "----------------------------------------------------------------------",
+            "Ran 7 tests in 0.000s",
+            "",
+            "FAILED (failures=1, expected failures=1)",
+        ],
+        # - Failures and expected failures
+        [
+            "----------------------------------------------------------------------",
+            "Ran 6 tests in 0.001s",
+            "",
+            "FAILED (expected failures=1, unexpected successes=1)",
+        ],
+        # - Failures and expected failures
+        [
+            "----------------------------------------------------------------------",
+            "Ran 6 tests in 0.001s",
+            "",
+            "FAILED (failures=1, expected failures=3, unexpected successes=1)",
+        ],
+        # - Failures, skips, and expected failures
+        [
+            "----------------------------------------------------------------------",
+            "Ran 8 tests in 0.001s",
+            "",
+            "FAILED (failures=3, skipped=1, expected failures=1)",
+        ],
+        # - Failures, errors, skips and expected failures
+        [
+            "----------------------------------------------------------------------",
+            "Ran 7 tests in 0.000s",
+            "",
+            "FAILED (failures=1, errors=1, skipped=1, expected failures=1)",
+        ],
+        # - Failures, skips, expected failures, and unexpected successes
+        [
+            "----------------------------------------------------------------------",
+            "Ran 6 tests in 0.001s",
+            "",
+            "FAILED (failures=1, skipped=1, expected failures=2, unexpected successes=1)",
+        ],
+        # - Failures in negative time (?!)
+        [
+            "----------------------------------------------------------------------",
+            "Ran 6 tests in -12.345s",
+            "",
+            "FAILED (failures=2)",
+        ],
+        # Pytest
+        # - Only failures
+        [
+            "FAILED tests/foobar/test_other.py::test_fail1 - assert 1 == 2",
+            "FAILED tests/foobar/test_other.py::test_fail2 - assert 1 == 2",
+            "FAILED tests/foobar/test_things.py::test_fail1 - assert 1 == 2",
+            "FAILED tests/foobar/test_things.py::test_fail2 - assert 1 == 2",
+            "============================== 6 failed in 0.04s ===============================",
+        ],
+        # - Failures and skips
+        [
+            "",
+            "tests/test_base.py:10: AssertionError",
+            "=========================== short test summary info ============================",
+            "FAILED tests/test_base.py::test_fail - assert 1 == 2",
+            "========================= 1 failed, 6 skipped in 0.03s =========================",
+        ],
+        # - Failures and passes
+        [
+            "",
+            "tests/test_base.py:10: AssertionError",
+            "=========================== short test summary info ============================",
+            "FAILED tests/test_base.py::test_fail - assert 1 == 2",
+            "========================= 1 failed, 5 passed in 0.03s ==========================",
+        ],
+        # - Failures, passes and skips
+        [
+            "",
+            "tests/test_base.py:10: AssertionError",
+            "=========================== short test summary info ============================",
+            "FAILED tests/test_base.py::test_fail - assert 1 == 2",
+            "==================== 1 failed, 4 passed, 2 skipped in 0.03s ====================",
+        ],
+        # - Lots of failures, passes and skips, in a long running suite
+        [
+            "FAILED tests/test_base.py::test_fail7 - assert 1 == 2",
+            "FAILED tests/test_base.py::test_fail8 - assert 1 == 2",
+            "FAILED tests/test_base.py::test_fail9 - assert 1 == 2",
+            "FAILED tests/test_base.py::test_fail10 - assert 1 == 2",
+            "================== 10 failed, 40 passed, 20 skipped in 124.34s =================",
+        ],
+        # Failures and error
+        [
+            "FAILED tests/foobar/test_other.py::test_pass - assert 1 == 2",
+            "FAILED tests/foobar/test_other.py::test_fail - assert 1 == 2",
+            "FAILED tests/foobar/test_things.py::test_fail - assert 1 == 2",
+            "ERROR tests/foobar/test_things.py::test_pass - Exception",
+            "========================== 6 failed, 1 error in 0.04s ==========================",
+        ],
+        # Failures and errors
+        [
+            "FAILED tests/foobar/test_other.py::test_pass - assert 1 == 2",
+            "FAILED tests/foobar/test_other.py::test_fail - assert 1 == 2",
+            "ERROR tests/foobar/test_things.py::test_fail - Exception",
+            "ERROR tests/foobar/test_things.py::test_pass - Exception",
+            "========================== 6 failed, 2 errors in 0.04s ==========================",
+        ],
+        # Failures, passes, skips and error
+        [
+            "tests/test_base.py:9: AssertionError",
+            "=========================== short test summary info ============================",
+            "FAILED tests/test_base.py::test_fail - assert 1 == 2",
+            "ERROR tests/foobar/test_things.py::test_pass - Exception",
+            "=============== 1 failed, 2 passed, 3 skipped, 1 error in 0.05s ================",
+        ],
+        # Failures, skips and error
+        [
+            "FAILED tests/test_base.py::test_pass - assert 1 == 2",
+            "FAILED tests/test_base.py::test_fail - assert 1 == 2",
+            "FAILED tests/foobar/test_things.py::test_fail - assert 1 == 2",
+            "ERROR tests/foobar/test_things.py::test_pass - Exception",
+            "==================== 3 failed, 3 skipped, 1 error in 0.03s =====================",
+        ],
+        # Failures, passes and error
+        [
+            "FAILED tests/test_base.py::test_fail - assert 1 == 2",
+            "FAILED tests/test_base.py::test_skipped - assert 1 == 2",
+            "FAILED tests/foobar/test_things.py::test_fail - assert 1 == 2",
+            "ERROR tests/foobar/test_things.py::test_pass - Exception",
+            "===================== 4 failed, 2 passed, 1 error in 0.04s =====================",
+        ],
+        # Failures, passes, skips and errors
+        [
+            "tests/test_base.py:9: AssertionError",
+            "=========================== short test summary info ============================",
+            "FAILED tests/test_base.py::test_fail - assert 1 == 2",
+            "FAILED tests/foobar/test_things.py::test_fail - assert 1 == 2",
+            "ERROR tests/foobar/test_things.py::test_pass - Exception",
+            "=============== 2 failed, 2 passed, 3 skipped, 1 error in 0.05s ================",
+        ],
+        # No failures, but an error
+        [
+            "tests/test_base.py:9: AssertionError",
+            "=========================== short test summary info ============================",
+            "FAILED tests/test_base.py::test_fail - assert 1 == 2",
+            "FAILED tests/foobar/test_things.py::test_fail - assert 1 == 2",
+            "ERROR tests/foobar/test_things.py::test_pass - Exception",
+            "=================== 2 passed, 3 skipped, 1 error in 0.05s =====================",
+        ],
+        # No failures, but errors
+        [
+            "tests/test_base.py:9: AssertionError",
+            "=========================== short test summary info ============================",
+            "FAILED tests/test_base.py::test_fail - assert 1 == 2",
+            "ERROR tests/foobar/test_things.py::test_fail - Exception",
+            "ERROR tests/foobar/test_things.py::test_pass - Exception",
+            "=================== 2 passed, 3 skipped, 2 errors in 0.05s =====================",
+        ],
+        # - Error collecting test suite
+        [
+            "E   NameError: name 'pytest' is not defined",
+            "=========================== short test summary info ============================",
+            "ERROR tests/foobar/test_other.py - NameError: name 'pytest' is not defined",
+            "!!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!!",
+            "=============================== 1 error in 0.05s ===============================",
+        ],
+        # - Multiple errors collecting test suite
+        [
+            "=========================== short test summary info ============================",
+            "ERROR tests/foobar/test_other.py - NameError: name 'pytest' is not defined",
+            "ERROR tests/foobar/test_things.py - NameError: name 'pytest' is not defined",
+            "!!!!!!!!!!!!!!!!!!! Interrupted: 2 errors during collection !!!!!!!!!!!!!!!!!!!!",
+            "============================== 2 errors in 0.05s ===============================",
+        ],
+        # - Pytest with the lot
+        [
+            '    assert color.to_string() == "#0025002a0045"',
+            "",
+            "-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html",
+            "= 2 failed, 3 passed, 5 skipped, 2 deselected, 3 xfailed, 4 xpassed, 2 warnings, 4 errors in 0.68s =",
+        ],
+        # - Failures in negative time
+        [
+            "FAILED tests/foobar/test_other.py::test_fail1 - assert 1 == 2",
+            "FAILED tests/foobar/test_other.py::test_fail2 - assert 1 == 2",
+            "FAILED tests/foobar/test_things.py::test_fail1 - assert 1 == 2",
+            "FAILED tests/foobar/test_things.py::test_fail2 - assert 1 == 2",
+            "============================= 6 failed in -12.345s =============================",
+        ],
+        # - Failures with content after the time.
+        [
+            "tests/test_thirdparty.py::test_pandas PASSED                              [100%]",
+            "==================== 24 failed, 5 passed in 89.65s (0:01:29) ===================",
+        ],
+        # Until https://github.com/chaquo/chaquopy/issues/746 is resolved, Android output
+        # will contain extra line breaks because it produces a line for each call to `write`,
+        # not just on newlines. The unittest regex contains extra named groups to
+        # accommodate these discrepancies; those groups can be deleted once the Chaquopy
+        # issue is fixed.
+        [
+            " ",
+            "----------------------------------------------------------------------",
+            " ",
+            "Ran 6 tests in 0.004s",
+            " ",
+            " ",
+            "FAILED",
+            " (failures=1)",
+        ],
+        [
+            " ",
+            "----------------------------------------------------------------------",
+            " ",
+            "Ran 6 tests in 0.004s",
+            " ",
+            " ",
+            "FAILED",
+            " (failures=1, skipped=1, expected failures=2, unexpected successes=1)",
+        ],
+    ),
+)
+def test_default_failure_filter(recent_history):
+    "The default failure filter captures known test suite failure output"
+    failure_func = LogFilter.test_filter(LogFilter.DEFAULT_FAILURE_REGEX)
+    success_func = LogFilter.test_filter(LogFilter.DEFAULT_SUCCESS_REGEX)
+
+    tail = "\n".join(recent_history)
+    assert not success_func(tail)
+    assert failure_func(tail)
+
+
+@pytest.mark.parametrize(
+    "recent_history",
+    (
+        # Unittest
+        # - FAILED, but without the "ran tests" part
+        [
+            "Some other content",
+            "----------------------------------------------------------------------",
+            "",
+            "FAILED (failures=2)",
+        ],
+        # - FAILED, but without the "end of suite" separator
+        [
+            "Some other content",
+            "Ran 5 tests in 0.000s",
+            "",
+            "FAILED (failures=2)",
+        ],
+    ),
+)
+def test_default_filter_no_match(recent_history):
+    "The default failure filter *doesn't* catch content that doesn't match the regex"
+    failure_func = LogFilter.test_filter(LogFilter.DEFAULT_FAILURE_REGEX)
+
+    tail = "\n".join(recent_history)
+    assert not failure_func(tail)
diff --git a/tests/commands/run/test_LogFilter__test_filter.py b/tests/commands/run/test_LogFilter__test_filter.py
index c2b33ab7..69df0bf9 100644
--- a/tests/commands/run/test_LogFilter__test_filter.py
+++ b/tests/commands/run/test_LogFilter__test_filter.py
@@ -1,69 +1,20 @@
-import pytest
-
 from briefcase.commands.run import LogFilter
 
 
-@pytest.mark.parametrize(
-    "recent_history, returncode",
-    (
-        # Zero return code
-        [">>>>>>>>>> EXIT 0 <<<<<<<<<<", 0],
-        # positive integer return code
-        [">>>>>>>>>> EXIT 123 <<<<<<<<<<", 123],
-        # Zero return code
-        [">>>>>>>>>> EXIT -15 <<<<<<<<<<", -15],
-        # Non-alpha return code
-        [">>>>>>>>>> EXIT abc <<<<<<<<<<", -999],
-        # Mixed return code
-        [">>>>>>>>>> EXIT 42 and some more <<<<<<<<<<", -999],
-    ),
-)
-def test_default_exit_filter(recent_history, returncode):
-    "The default exit filter captures exit criteria"
-    exit_func = LogFilter.test_filter(LogFilter.DEFAULT_EXIT_REGEX)
-
-    tail = "\n".join(["line 1", "line 2"] + [recent_history])
-    assert exit_func(tail) == returncode
-
-
-@pytest.mark.parametrize(
-    "recent_history",
-    (
-        # No exit
-        "This doesn't match",
-        # Not enough chevrons
-        ">>>> EXIT 3 <<<<",
-        # Wrong text
-        ">>>>>>>>>> EXEUNT 3 <<<<<<<<<<",
-        # Extra text
-        ">>>>>>>>>> EXIT 3 <<<<<<<<<< but there's more!",
-    ),
-)
-def test_default_exit_filter_no_match(recent_history):
-    "The default exit filter *doesn't* catch content that doesn't match the regex"
-    exit_func = LogFilter.test_filter(LogFilter.DEFAULT_EXIT_REGEX)
-
-    tail = "\n".join(["line 1", "line 2"] + [recent_history])
-    assert exit_func(tail) is None
-
-
 def test_custom_filter():
-    "The user can specify a custom exit filter"
-    custom_func = LogFilter.test_filter("WIBBLE (?P<returncode>.*) WIBBLE")
-
-    # Custom filter doesn't match "normal" output
-    recent = ["line 1", "line 2", ">>>>>>>>>> EXIT 123 <<<<<<<<<<"]
-    assert custom_func("\n".join(recent)) is None
-
-    # Custom filter does match "custom" output
-    recent = ["line 1", "line 2", "WIBBLE 123 WIBBLE"]
-    assert custom_func("\n".join(recent)) == 123
-
-
-def test_bad_custom_filter():
-    "If the custom filter doesn't capture a returncode named group, any match has a known return value"
-    custom_func = LogFilter.test_filter(r"WIBBLE \d+ WIBBLE")
-
-    # Custom filter matches, but doesn't capture output
-    recent = ["line 1", "line 2", "WIBBLE 123 WIBBLE"]
-    assert custom_func("\n".join(recent)) == -998
+    "The user can specify a custom failure filter"
+    custom_func = LogFilter.test_filter("WIBBLE")
+
+    recent = [
+        "rootdir: /Users/rkm/beeware/briefcase, configfile: pyproject.toml",
+        "plugins: cov-3.0.0",
+        "collecting ... collected 0 items",
+        "",
+        "============================ no tests ran in 0.01s =============================",
+    ]
+
+    assert not custom_func("\n".join(recent))
+
+    # Add an extra line that *will* match the filter
+    recent.append("I will now say the magic word, which is WIBBLE, so we match")
+    assert custom_func("\n".join(recent))
diff --git a/tests/commands/run/test_LogFilter__test_success_filter.py b/tests/commands/run/test_LogFilter__test_success_filter.py
new file mode 100644
index 00000000..ba3cf563
--- /dev/null
+++ b/tests/commands/run/test_LogFilter__test_success_filter.py
@@ -0,0 +1,229 @@
+import pytest
+
+from briefcase.commands.run import LogFilter
+
+
+@pytest.mark.parametrize(
+    "recent_history",
+    (
+        # Unittest
+        # - Simple pass
+        [
+            "----------------------------------------------------------------------",
+            "Ran 5 tests in 0.000s",
+            "",
+            "OK",
+        ],
+        # - Pass with skips
+        [
+            "----------------------------------------------------------------------",
+            "Ran 6 tests in 0.000s",
+            "",
+            "OK (skipped=1)",
+        ],
+        # - All skips
+        [
+            "----------------------------------------------------------------------",
+            "Ran 6 tests in 0.000s",
+            "",
+            "OK (skipped=6)",
+        ],
+        # - Pass with expected failure
+        [
+            "----------------------------------------------------------------------",
+            "Ran 6 tests in 0.000s",
+            "",
+            "OK (expected failures=1)",
+        ],
+        # - All expected failures
+        [
+            "----------------------------------------------------------------------",
+            "Ran 6 tests in 0.000s",
+            "",
+            "OK (expected failures=6)",
+        ],
+        # - Pass skips and expected failure
+        [
+            "----------------------------------------------------------------------",
+            "Ran 7 tests in 0.000s",
+            "",
+            "OK (skipped=1, expected failures=1)",
+        ],
+        # - Large long running suite
+        [
+            "----------------------------------------------------------------------",
+            "Ran 70 tests in 123.456s",
+            "",
+            "OK (skipped=10, expected failures=10)",
+        ],
+        # - No tests
+        [
+            "----------------------------------------------------------------------",
+            "Ran 0 tests in 0.000s",
+            "",
+            "OK",
+        ],
+        # - Success in negative time
+        [
+            "----------------------------------------------------------------------",
+            "Ran 5 tests in -12.345s",
+            "",
+            "OK",
+        ],
+        # Pytest
+        # - Only passes
+        [
+            "tests/foobar/test_other.py::test_pass2 PASSED                             [ 71%]",
+            "tests/foobar/test_things.py::test_pass1 PASSED                            [ 85%]",
+            "tests/foobar/test_things.py::test_pass2 PASSED                            [100%]",
+            "",
+            "============================== 7 passed in 0.01s ===============================",
+        ],
+        # - Passes and skips
+        [
+            "tests/foobar/test_other.py::test_pass2 PASSED                             [ 71%]",
+            "tests/foobar/test_things.py::test_pass1 PASSED                            [ 85%]",
+            "tests/foobar/test_things.py::test_pass2 PASSED                            [100%]",
+            "",
+            "========================= 5 passed, 2 skipped in 0.02s =========================",
+        ],
+        # - Passes and warning
+        [
+            '    assert color.to_string() == "#0025002a0045"',
+            "",
+            "-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html",
+            "======================== 30 passed, 1 warning in 0.68s =========================",
+        ],
+        # - Passes and warnings
+        [
+            '    assert color.to_string() == "#0025002a0045"',
+            "",
+            "-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html",
+            "======================== 30 passed, 1 warnings in 0.68s ========================",
+        ],
+        # - Passes, skips and warning
+        [
+            '    assert color.to_string() == "#0025002a0045"',
+            "",
+            "-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html",
+            "=================== 30 passed, 5 skipped, 1 warning in 0.68s ===================",
+        ],
+        # - Passes, skips and warnings
+        [
+            '    assert color.to_string() == "#0025002a0045"',
+            "",
+            "-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html",
+            "=================== 30 passed, 5 skipped, 2 warnings in 0.68s ==================",
+        ],
+        # - Passes, xfails, xpass, and warnings
+        [
+            '    assert color.to_string() == "#0025002a0045"',
+            "",
+            "-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html",
+            "============ 30 passed, 5 xfailed, 4 xpassed, 2 warnings in 0.68s ==============",
+        ],
+        # - Pytest with the lot
+        [
+            '    assert color.to_string() == "#0025002a0045"',
+            "",
+            "-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html",
+            "= 3 passed, 5 skipped, 2 deselected, 3 xfailed, 4 xpassed, 2 warnings in 0.68s =",
+        ],
+        # - Lots of Passes and skips, in a long running suite
+        [
+            "tests/foobar/test_other.py::test_pass2 PASSED                             [ 71%]",
+            "tests/foobar/test_things.py::test_pass1 PASSED                            [ 85%]",
+            "tests/foobar/test_things.py::test_pass2 PASSED                            [100%]",
+            "",
+            "======================= 50 passed, 20 skipped in 123.45s =======================",
+        ],
+        # - only skips
+        [
+            "tests/foobar/test_things.py::test_pass SKIPPED (Not needed)              [ 85%]",
+            "tests/foobar/test_things.py::test_fail SKIPPED (Not needed)              [100%]",
+            "",
+            "============================== 7 skipped in 0.01s ==============================",
+        ],
+        # - No tests
+        [
+            "rootdir: /Users/rkm/beeware/briefcase, configfile: pyproject.toml",
+            "plugins: cov-3.0.0",
+            "collecting ... collected 0 items",
+            "",
+            "============================ no tests ran in 0.01s =============================",
+        ],
+        # - Passes in negative time
+        [
+            "tests/foobar/test_other.py::test_pass2 PASSED                             [ 71%]",
+            "tests/foobar/test_things.py::test_pass1 PASSED                            [ 85%]",
+            "tests/foobar/test_things.py::test_pass2 PASSED                            [100%]",
+            "",
+            "============================= 7 passed in 12.345s ==============================",
+        ],
+        # - Passes with content after the time.
+        [
+            "tests/test_thirdparty.py::test_pandas PASSED                              [100%]",
+            "=================== 24 passed, 5 skipped in 89.65s (0:01:29) ===================",
+        ],
+        # Until https://github.com/chaquo/chaquopy/issues/746 is resolved, Android output
+        # will contain extra line breaks because the log is written whenever the buffer is
+        # flushed, not just on newlines. The unittest regex contains extra named groups to
+        # accomodate these discrepancies; those groups can be deleted once the log output
+        # doesn't write on flush.
+        [
+            " ",
+            "----------------------------------------------------------------------",
+            " ",
+            "Ran 6 tests in 0.004s",
+            " ",
+            " ",
+            "OK",
+        ],
+        [
+            " ",
+            "----------------------------------------------------------------------",
+            " ",
+            "Ran 6 tests in 0.004s",
+            " ",
+            " ",
+            "OK",
+            " (skipped=1, expected failures=1)",
+        ],
+    ),
+)
+def test_default_success_filter(recent_history):
+    "The default success filter captures known test suite success output"
+    failure_func = LogFilter.test_filter(LogFilter.DEFAULT_FAILURE_REGEX)
+    success_func = LogFilter.test_filter(LogFilter.DEFAULT_SUCCESS_REGEX)
+
+    tail = "\n".join(recent_history)
+    assert success_func(tail)
+    assert not failure_func(tail)
+
+
+@pytest.mark.parametrize(
+    "recent_history",
+    (
+        # Unittest
+        # - OK, but without the "ran tests" part
+        [
+            "Some other content",
+            "----------------------------------------------------------------------",
+            "",
+            "OK",
+        ],
+        # - OK, but without the "end of suite" separator
+        [
+            "Some other content",
+            "Ran 5 tests in 0.000s",
+            "",
+            "OK",
+        ],
+    ),
+)
+def test_default_filter_no_match(recent_history):
+    "The default success filter *doesn't* catch content that doesn't match the regex"
+    success_func = LogFilter.test_filter(LogFilter.DEFAULT_SUCCESS_REGEX)
+
+    tail = "\n".join(recent_history)
+    assert not success_func(tail)
diff --git a/tests/commands/run/test__stream_app_logs.py b/tests/commands/run/test__stream_app_logs.py
index 4d840c59..37f6af2b 100644
--- a/tests/commands/run/test__stream_app_logs.py
+++ b/tests/commands/run/test__stream_app_logs.py
@@ -11,44 +11,6 @@ def test_run_app(run_command, first_app):
     popen = mock.MagicMock()
     popen.returncode = 0
     clean_filter = mock.MagicMock()
-
-    # Invoke the stop func as part of streaming. This is to satisfy coverage.
-    def mock_stream(label, popen_process, stop_func, filter_func):
-        stop_func()
-
-    run_command.tools.subprocess.stream_output = mock.MagicMock(side_effect=mock_stream)
-
-    # Stream the app logs
-    run_command._stream_app_logs(
-        first_app,
-        popen=popen,
-        test_mode=False,
-        clean_filter=clean_filter,
-        clean_output=False,
-    )
-
-    # The log was streamed
-    run_command.tools.subprocess.stream_output.assert_called_once_with(
-        label="first",
-        popen_process=popen,
-        filter_func=mock.ANY,
-        stop_func=mock.ANY,
-    )
-
-    # The filter function has the properties we'd expect
-    filter_func = run_command.tools.subprocess.stream_output.mock_calls[0].kwargs[
-        "filter_func"
-    ]
-    assert not filter_func.clean_output
-    assert filter_func.clean_filter == clean_filter
-    assert filter_func.exit_filter.regex.pattern == LogFilter.DEFAULT_EXIT_REGEX
-
-
-def test_run_app_custom_stop_func(run_command, first_app):
-    """An app with a custom stop function can have it's logs streamed."""
-    popen = mock.MagicMock()
-    popen.returncode = 0
-    clean_filter = mock.MagicMock()
     stop_func = mock.MagicMock()
     run_command.tools.subprocess.stream_output = mock.MagicMock()
 
@@ -76,7 +38,8 @@ def test_run_app_custom_stop_func(run_command, first_app):
     ]
     assert not filter_func.clean_output
     assert filter_func.clean_filter == clean_filter
-    assert filter_func.exit_filter.regex.pattern == LogFilter.DEFAULT_EXIT_REGEX
+    assert filter_func.success_filter is None
+    assert filter_func.failure_filter is None
 
 
 def test_test_mode_success(run_command, first_app):
@@ -89,7 +52,7 @@ def test_test_mode_success(run_command, first_app):
 
     # Mock effect of streaming the app resulting in a test suite success
     def mock_stream_output(label, popen_process, filter_func, **kwargs):
-        filter_func.returncode = 0
+        filter_func.success = True
 
     run_command.tools.subprocess.stream_output.side_effect = mock_stream_output
 
@@ -117,7 +80,8 @@ def test_test_mode_success(run_command, first_app):
     ]
     assert not filter_func.clean_output
     assert filter_func.clean_filter == clean_filter
-    assert filter_func.exit_filter.regex.pattern == LogFilter.DEFAULT_EXIT_REGEX
+    assert filter_func.success_filter.regex.pattern == LogFilter.DEFAULT_SUCCESS_REGEX
+    assert filter_func.failure_filter.regex.pattern == LogFilter.DEFAULT_FAILURE_REGEX
 
 
 def test_test_mode_failure(run_command, first_app):
@@ -130,7 +94,7 @@ def test_test_mode_failure(run_command, first_app):
 
     # Mock effect of streaming the app resulting in a test suite success
     def mock_stream_output(label, popen_process, filter_func, **kwargs):
-        filter_func.returncode = 1
+        filter_func.success = False
 
     run_command.tools.subprocess.stream_output.side_effect = mock_stream_output
 
@@ -159,7 +123,8 @@ def test_test_mode_failure(run_command, first_app):
     ]
     assert not filter_func.clean_output
     assert filter_func.clean_filter == clean_filter
-    assert filter_func.exit_filter.regex.pattern == LogFilter.DEFAULT_EXIT_REGEX
+    assert filter_func.success_filter.regex.pattern == LogFilter.DEFAULT_SUCCESS_REGEX
+    assert filter_func.failure_filter.regex.pattern == LogFilter.DEFAULT_FAILURE_REGEX
 
 
 def test_test_mode_no_result(run_command, first_app):
@@ -205,7 +170,8 @@ def test_test_mode_no_result(run_command, first_app):
     ]
     assert not filter_func.clean_output
     assert filter_func.clean_filter == clean_filter
-    assert filter_func.exit_filter.regex.pattern == LogFilter.DEFAULT_EXIT_REGEX
+    assert filter_func.success_filter.regex.pattern == LogFilter.DEFAULT_SUCCESS_REGEX
+    assert filter_func.failure_filter.regex.pattern == LogFilter.DEFAULT_FAILURE_REGEX
 
 
 def test_test_mode_custom_filters(run_command, first_app):
@@ -218,11 +184,12 @@ def test_test_mode_custom_filters(run_command, first_app):
 
     # Mock effect of streaming the app resulting in a test suite success
     def mock_stream_output(label, popen_process, filter_func, **kwargs):
-        filter_func.returncode = 0
+        filter_func.success = True
 
     run_command.tools.subprocess.stream_output.side_effect = mock_stream_output
 
-    first_app.exit_regex = "THIS IS WHAT SUCCESS LOOKS LIKE"
+    first_app.test_success_regex = "THIS IS WHAT SUCCESS LOOKS LIKE"
+    first_app.test_failure_regex = "THIS IS WHAT FAILURE LOOKS LIKE"
 
     # Stream the app logs
     run_command._stream_app_logs(
@@ -248,7 +215,8 @@ def test_test_mode_custom_filters(run_command, first_app):
     ]
     assert not filter_func.clean_output
     assert filter_func.clean_filter == clean_filter
-    assert filter_func.exit_filter.regex.pattern == "THIS IS WHAT SUCCESS LOOKS LIKE"
+    assert filter_func.success_filter.regex.pattern == "THIS IS WHAT SUCCESS LOOKS LIKE"
+    assert filter_func.failure_filter.regex.pattern == "THIS IS WHAT FAILURE LOOKS LIKE"
 
 
 def test_run_app_failure(run_command, first_app):
@@ -287,12 +255,12 @@ def test_run_app_failure(run_command, first_app):
     ]
     assert not filter_func.clean_output
     assert filter_func.clean_filter == clean_filter
-    assert filter_func.exit_filter.regex.pattern == LogFilter.DEFAULT_EXIT_REGEX
+    assert filter_func.success_filter is None
+    assert filter_func.failure_filter is None
 
 
-def test_run_app_log_stream_stream_failure(run_command, first_app):
-    """If a log stream returns an error code, the log filter requires it is
-    ignored."""
+def test_run_app_log_stream_failure(run_command, first_app):
+    """If a log stream returns an error code, it is ignored."""
     popen = mock.MagicMock()
     popen.returncode = 1
     clean_filter = mock.MagicMock()
@@ -324,138 +292,8 @@ def test_run_app_log_stream_stream_failure(run_command, first_app):
     ]
     assert not filter_func.clean_output
     assert filter_func.clean_filter == clean_filter
-    assert filter_func.exit_filter.regex.pattern == LogFilter.DEFAULT_EXIT_REGEX
-
-
-def test_run_app_log_stream_success(run_command, first_app):
-    """If a log streamed app returns successfully, no problem is surfaced."""
-    popen = mock.MagicMock()
-    popen.returncode = 0
-    clean_filter = mock.MagicMock()
-    stop_func = mock.MagicMock()
-    run_command.tools.subprocess.stream_output = mock.MagicMock()
-
-    # Mock effect of streaming the app and returning successfully
-    def mock_stream_output(label, popen_process, filter_func, **kwargs):
-        filter_func.returncode = 0
-
-    run_command.tools.subprocess.stream_output.side_effect = mock_stream_output
-
-    # Stream the app logs returns success
-    run_command._stream_app_logs(
-        first_app,
-        popen=popen,
-        test_mode=False,
-        clean_filter=clean_filter,
-        clean_output=False,
-        stop_func=stop_func,
-        log_stream=True,
-    )
-
-    # The log was streamed
-    run_command.tools.subprocess.stream_output.assert_called_once_with(
-        label="log stream",
-        popen_process=popen,
-        filter_func=mock.ANY,
-        stop_func=stop_func,
-    )
-
-    # The filter function has the properties we'd expect
-    filter_func = run_command.tools.subprocess.stream_output.mock_calls[0].kwargs[
-        "filter_func"
-    ]
-    assert not filter_func.clean_output
-    assert filter_func.clean_filter == clean_filter
-    assert filter_func.exit_filter.regex.pattern == LogFilter.DEFAULT_EXIT_REGEX
-
-
-def test_run_app_log_stream_failure(run_command, first_app):
-    """If a log streamed app exits with a failure code, the error is
-    surfaced."""
-    popen = mock.MagicMock()
-    popen.returncode = 0
-    clean_filter = mock.MagicMock()
-    stop_func = mock.MagicMock()
-    run_command.tools.subprocess.stream_output = mock.MagicMock()
-
-    # Mock effect of streaming the app resulting in a test suite success
-    def mock_stream_output(label, popen_process, filter_func, **kwargs):
-        filter_func.returncode = 1
-
-    run_command.tools.subprocess.stream_output.side_effect = mock_stream_output
-
-    # Streaming the app logs causes a test suite failure
-    with pytest.raises(
-        BriefcaseCommandError,
-        match=r"Problem running app first.",
-    ):
-        run_command._stream_app_logs(
-            first_app,
-            popen=popen,
-            test_mode=False,
-            clean_filter=clean_filter,
-            clean_output=False,
-            stop_func=stop_func,
-            log_stream=True,
-        )
-
-    # The log was streamed
-    run_command.tools.subprocess.stream_output.assert_called_once_with(
-        label="log stream",
-        popen_process=popen,
-        filter_func=mock.ANY,
-        stop_func=stop_func,
-    )
-
-    # The filter function has the properties we'd expect
-    filter_func = run_command.tools.subprocess.stream_output.mock_calls[0].kwargs[
-        "filter_func"
-    ]
-    assert not filter_func.clean_output
-    assert filter_func.clean_filter == clean_filter
-    assert filter_func.exit_filter.regex.pattern == LogFilter.DEFAULT_EXIT_REGEX
-
-
-def test_run_app_log_stream_no_result(run_command, first_app):
-    """If a log streamed app returns no result, no problem is surfaced."""
-    popen = mock.MagicMock()
-    popen.returncode = 0
-    clean_filter = mock.MagicMock()
-    stop_func = mock.MagicMock()
-    run_command.tools.subprocess.stream_output = mock.MagicMock()
-
-    # Mock effect of streaming the app but not finding a test result.
-    def mock_stream_output(label, popen_process, filter_func, **kwargs):
-        filter_func.returncode = None
-
-    run_command.tools.subprocess.stream_output.side_effect = mock_stream_output
-
-    # Streaming the app logs doesn't raise an error
-    run_command._stream_app_logs(
-        first_app,
-        popen=popen,
-        test_mode=False,
-        clean_filter=clean_filter,
-        clean_output=False,
-        stop_func=stop_func,
-        log_stream=True,
-    )
-
-    # The log was streamed
-    run_command.tools.subprocess.stream_output.assert_called_once_with(
-        label="log stream",
-        popen_process=popen,
-        filter_func=mock.ANY,
-        stop_func=stop_func,
-    )
-
-    # The filter function has the properties we'd expect
-    filter_func = run_command.tools.subprocess.stream_output.mock_calls[0].kwargs[
-        "filter_func"
-    ]
-    assert not filter_func.clean_output
-    assert filter_func.clean_filter == clean_filter
-    assert filter_func.exit_filter.regex.pattern == LogFilter.DEFAULT_EXIT_REGEX
+    assert filter_func.success_filter is None
+    assert filter_func.failure_filter is None
 
 
 def test_run_app_ctrl_c(run_command, first_app):
@@ -493,4 +331,5 @@ def test_run_app_ctrl_c(run_command, first_app):
     ]
     assert not filter_func.clean_output
     assert filter_func.clean_filter == clean_filter
-    assert filter_func.exit_filter.regex.pattern == LogFilter.DEFAULT_EXIT_REGEX
+    assert filter_func.success_filter is None
+    assert filter_func.failure_filter is None
diff --git a/tests/integrations/android_sdk/AndroidSDK/test_verify.py b/tests/integrations/android_sdk/AndroidSDK/test_verify.py
index 5ef5d321..ca0643e0 100644
--- a/tests/integrations/android_sdk/AndroidSDK/test_verify.py
+++ b/tests/integrations/android_sdk/AndroidSDK/test_verify.py
@@ -99,45 +99,6 @@ def test_succeeds_immediately_in_happy_path(mock_tools, tmp_path):
     assert sdk.root_path == android_sdk_root_path
 
 
-def test_succeeds_immediately_in_happy_path_with_debug(mock_tools, tmp_path):
-    """If debug is enabled, a verify call will display the installed
-    packages."""
-    # Increase the log level.
-    mock_tools.logger.verbosity = 2
-
-    # If `sdkmanager` exists and has the right permissions, and
-    # `android-sdk-license` exists, verify() should
-    # succeed, create no subprocesses, make no requests, and return a
-    # SDK wrapper.
-
-    # On Windows, this requires `sdkmanager.bat`; on non-Windows, it requires
-    # `sdkmanager`.
-
-    # Create `sdkmanager` and the license file.
-    android_sdk_root_path = tmp_path / "tools" / "android_sdk"
-    tools_bin = android_sdk_root_path / "cmdline-tools" / "latest" / "bin"
-    tools_bin.mkdir(parents=True, mode=0o755)
-    if platform.system() == "Windows":
-        sdk_manager = tools_bin / "sdkmanager.bat"
-        sdk_manager.touch()
-    else:
-        sdk_manager = tools_bin / "sdkmanager"
-        sdk_manager.touch(mode=0o755)
-
-    # Pre-accept the license
-    accept_license(android_sdk_root_path)()
-
-    # Expect verify() to succeed
-    sdk = AndroidSDK.verify(mock_tools)
-
-    # No calls to download or unpack anything.
-    mock_tools.download.file.assert_not_called()
-    mock_tools.shutil.unpack_archive.assert_not_called()
-
-    # The returned SDK has the expected root path.
-    assert sdk.root_path == android_sdk_root_path
-
-
 def test_user_provided_sdk(mock_tools, tmp_path):
     """If the user specifies a valid ANDROID_SDK_ROOT, it is used."""
     # Increase the log level.
@@ -173,40 +134,6 @@ def test_user_provided_sdk(mock_tools, tmp_path):
     assert sdk.root_path == existing_android_sdk_root_path
 
 
-def test_user_provided_sdk_with_debug(mock_tools, tmp_path):
-    """If the has debug with a user-specified ANDROID_SDK_ROOT, the packages
-    are listed."""
-    # Create `sdkmanager` and the license file.
-    existing_android_sdk_root_path = tmp_path / "other_sdk"
-    tools_bin = existing_android_sdk_root_path / "cmdline-tools" / "latest" / "bin"
-    tools_bin.mkdir(parents=True, mode=0o755)
-    if platform.system() == "Windows":
-        sdk_manager = tools_bin / "sdkmanager.bat"
-        sdk_manager.touch()
-    else:
-        sdk_manager = tools_bin / "sdkmanager"
-        sdk_manager.touch(mode=0o755)
-
-    # Pre-accept the license
-    accept_license(existing_android_sdk_root_path)()
-
-    # Set the environment to specify ANDROID_SDK_ROOT
-    mock_tools.os.environ = {
-        "ANDROID_SDK_ROOT": os.fsdecode(existing_android_sdk_root_path)
-    }
-
-    # Expect verify() to succeed
-    sdk = AndroidSDK.verify(mock_tools)
-
-    # No calls to download, run or unpack anything.
-    mock_tools.download.file.assert_not_called()
-    mock_tools.subprocess.run.assert_not_called()
-    mock_tools.shutil.unpack_archive.assert_not_called()
-
-    # The returned SDK has the expected root path.
-    assert sdk.root_path == existing_android_sdk_root_path
-
-
 def test_invalid_user_provided_sdk(mock_tools, tmp_path):
     """If the user specifies an invalid ANDROID_SDK_ROOT, it is ignored."""
 
diff --git a/tests/integrations/java/test_JDK__verify.py b/tests/integrations/java/test_JDK__verify.py
index 1074f961..4aecf794 100644
--- a/tests/integrations/java/test_JDK__verify.py
+++ b/tests/integrations/java/test_JDK__verify.py
@@ -2,6 +2,7 @@ import os
 import shutil
 import subprocess
 from pathlib import Path
+from subprocess import CalledProcessError
 from unittest import mock
 
 import pytest
@@ -9,6 +10,12 @@ import pytest
 from briefcase.exceptions import BriefcaseCommandError, MissingToolError, NetworkFailure
 from briefcase.integrations.java import JDK
 
+CALL_JAVA_HOME = mock.call(["/usr/libexec/java_home"], stderr=subprocess.STDOUT)
+CALL_ROSETTA_CHECK = mock.call(["arch", "-x86_64", "true"], stderr=subprocess.STDOUT)
+CALL_ROSETTA_INSTALL = mock.call(
+    ["softwareupdate", "--install-rosetta", "--agree-to-license"], check=True
+)
+
 
 def test_short_circuit(mock_tools):
     """Tool is not created if already cached."""
@@ -25,6 +32,9 @@ def test_macos_tool_java_home(mock_tools, capsys):
     # Mock being on macOS
     mock_tools.host_os = "Darwin"
 
+    # Prevent Rosetta check.
+    mock_tools.host_arch = "x86_64"
+
     # Mock 2 calls to check_output.
     mock_tools.subprocess.check_output.side_effect = [
         "/path/to/java",
@@ -37,20 +47,14 @@ def test_macos_tool_java_home(mock_tools, capsys):
     # The JDK should have the path returned by the tool
     assert mock_tools.java.java_home == Path("/path/to/java")
 
-    mock_tools.subprocess.check_output.assert_has_calls(
-        [
-            # First call is to /usr/lib/java_home
-            mock.call(
-                ["/usr/libexec/java_home"],
-                stderr=subprocess.STDOUT,
-            ),
-            # Second is a call to verify a valid Java version
-            mock.call(
-                [os.fsdecode(Path("/path/to/java/bin/javac")), "-version"],
-                stderr=subprocess.STDOUT,
-            ),
-        ]
-    )
+    assert mock_tools.subprocess.check_output.mock_calls == [
+        CALL_JAVA_HOME,
+        # Second is a call to verify a valid Java version
+        mock.call(
+            [os.fsdecode(Path("/path/to/java/bin/javac")), "-version"],
+            stderr=subprocess.STDOUT,
+        ),
+    ]
 
     # No console output
     output = capsys.readouterr()
@@ -63,6 +67,9 @@ def test_macos_tool_failure(mock_tools, tmp_path, capsys):
     # Mock being on macOS
     mock_tools.host_os = "Darwin"
 
+    # Prevent Rosetta check.
+    mock_tools.host_arch = "x86_64"
+
     # Mock a failed call on the libexec tool
     mock_tools.subprocess.check_output.side_effect = subprocess.CalledProcessError(
         returncode=1, cmd="/usr/libexec/java_home"
@@ -77,15 +84,7 @@ def test_macos_tool_failure(mock_tools, tmp_path, capsys):
     # The JDK should have the briefcase JAVA_HOME
     assert jdk.java_home == tmp_path / "tools" / "java" / "Contents" / "Home"
 
-    mock_tools.subprocess.check_output.assert_has_calls(
-        [
-            # First call is to /usr/lib/java_home
-            mock.call(
-                ["/usr/libexec/java_home"],
-                stderr=subprocess.STDOUT,
-            ),
-        ]
-    )
+    assert mock_tools.subprocess.check_output.mock_calls == [CALL_JAVA_HOME]
 
     # No console output
     output = capsys.readouterr()
@@ -98,6 +97,9 @@ def test_macos_provided_overrides_tool_java_home(mock_tools, capsys):
     # Mock being on macOS
     mock_tools.host_os = "Darwin"
 
+    # Prevent Rosetta check.
+    mock_tools.host_arch = "x86_64"
+
     # Setup explicit JAVA_HOME
     mock_tools.os.environ = {"JAVA_HOME": "/path/to/java"}
 
@@ -124,6 +126,9 @@ def test_macos_provided_overrides_tool_java_home(mock_tools, capsys):
 
 def test_valid_provided_java_home(mock_tools, capsys):
     """If a valid JAVA_HOME is provided, it is used."""
+    # Prevent Rosetta check.
+    mock_tools.host_arch = "x86_64"
+
     # Setup explicit JAVA_HOME
     mock_tools.os.environ = {"JAVA_HOME": "/path/to/java"}
 
@@ -162,6 +167,9 @@ def test_invalid_jdk_version(mock_tools, host_os, java_home, tmp_path, capsys):
     # Mock os
     mock_tools.host_os = host_os
 
+    # Prevent Rosetta check.
+    mock_tools.host_arch = "x86_64"
+
     # Setup explicit JAVA_HOME
     mock_tools.os.environ = {"JAVA_HOME": "/path/to/java"}
 
@@ -203,6 +211,9 @@ def test_no_javac(mock_tools, host_os, java_home, tmp_path, capsys):
     # Mock os
     mock_tools.host_os = host_os
 
+    # Prevent Rosetta check.
+    mock_tools.host_arch = "x86_64"
+
     # Setup explicit JAVA_HOME
     mock_tools.os.environ = {"JAVA_HOME": "/path/to/nowhere"}
 
@@ -243,6 +254,9 @@ def test_javac_error(mock_tools, host_os, java_home, tmp_path, capsys):
     # Mock os
     mock_tools.host_os = host_os
 
+    # Prevent Rosetta check.
+    mock_tools.host_arch = "x86_64"
+
     # Setup explicit JAVA_HOME
     mock_tools.os.environ = {"JAVA_HOME": "/path/to/nowhere"}
 
@@ -285,6 +299,9 @@ def test_unparseable_javac_version(mock_tools, host_os, java_home, tmp_path, cap
     # Mock os
     mock_tools.host_os = host_os
 
+    # Prevent Rosetta check.
+    mock_tools.host_arch = "x86_64"
+
     # Setup explicit JAVA_HOME
     mock_tools.os.environ = {"JAVA_HOME": "/path/to/nowhere"}
 
@@ -455,3 +472,98 @@ def test_invalid_jdk_archive(mock_tools, tmp_path):
     )
     # The original archive was not deleted
     assert archive.unlink.call_count == 0
+
+
+def test_rosetta_host_os(mock_tools, tmp_path):
+    """On an OS other than macOS, the Rosetta check does not occur."""
+    mock_tools.host_os = "Linux"
+    mock_tools.host_arch = "arm64"
+
+    # Create a mock of a previously installed Java version.
+    (tmp_path / "tools" / "java" / "bin").mkdir(parents=True)
+
+    JDK.verify(mock_tools)
+    mock_tools.subprocess.check_output.assert_not_called()
+    mock_tools.subprocess.run.assert_not_called()
+
+
+def test_rosetta_host_arch(mock_tools, tmp_path):
+    """On an architecture other than ARM64, the Rosetta check does not
+    occur."""
+    mock_tools.host_os = "Darwin"
+    mock_tools.host_arch = "x86_64"
+
+    mock_tools.subprocess.check_output.side_effect = [
+        CalledProcessError(1, "java_home")
+    ]
+
+    # Create a mock of a previously installed Java version.
+    (tmp_path / "tools" / "java" / "Contents" / "Home" / "bin").mkdir(parents=True)
+
+    JDK.verify(mock_tools)
+    assert mock_tools.subprocess.check_output.mock_calls == [CALL_JAVA_HOME]
+    mock_tools.subprocess.run.assert_not_called()
+
+
+def test_rosetta_already_installed(mock_tools, tmp_path):
+    """On an ARM Mac, the Rosetta check occurs before calling any other Java
+    commands."""
+    mock_tools.host_os = "Darwin"
+    mock_tools.host_arch = "arm64"
+
+    mock_tools.subprocess.check_output.side_effect = [
+        None,  # Rosetta check succeeds.
+        CalledProcessError(1, "java_home"),
+    ]
+
+    # Create a mock of a previously installed Java version.
+    (tmp_path / "tools" / "java" / "Contents" / "Home" / "bin").mkdir(parents=True)
+
+    JDK.verify(mock_tools)
+    assert mock_tools.subprocess.check_output.mock_calls == [
+        CALL_ROSETTA_CHECK,
+        CALL_JAVA_HOME,
+    ]
+    mock_tools.subprocess.run.assert_not_called()
+
+
+def test_rosetta_install_success(mock_tools, tmp_path):
+    """Rosetta is installed if necessary."""
+    mock_tools.host_os = "Darwin"
+    mock_tools.host_arch = "arm64"
+
+    mock_tools.subprocess.check_output.side_effect = [
+        CalledProcessError(1, "arch"),
+        CalledProcessError(1, "java_home"),
+    ]
+
+    # Create a mock of a previously installed Java version.
+    (tmp_path / "tools" / "java" / "Contents" / "Home" / "bin").mkdir(parents=True)
+
+    JDK.verify(mock_tools)
+    assert mock_tools.subprocess.check_output.mock_calls == [
+        CALL_ROSETTA_CHECK,
+        CALL_JAVA_HOME,
+    ]
+    assert mock_tools.subprocess.run.mock_calls == [CALL_ROSETTA_INSTALL]
+
+
+def test_rosetta_install_failure(mock_tools, tmp_path):
+    """If Rosetta install fails, no Java commands are called."""
+    mock_tools.host_os = "Darwin"
+    mock_tools.host_arch = "arm64"
+
+    mock_tools.subprocess.check_output.side_effect = [
+        CalledProcessError(1, "arch"),
+    ]
+    mock_tools.subprocess.run.side_effect = [
+        CalledProcessError(1, "softwareupdate"),
+    ]
+
+    # Create a mock of a previously installed Java version.
+    (tmp_path / "tools" / "java" / "Contents" / "Home" / "bin").mkdir(parents=True)
+
+    with pytest.raises(BriefcaseCommandError, match="Failed to install Rosetta"):
+        JDK.verify(mock_tools)
+    assert mock_tools.subprocess.check_output.mock_calls == [CALL_ROSETTA_CHECK]
+    assert mock_tools.subprocess.run.mock_calls == [CALL_ROSETTA_INSTALL]
diff --git a/tests/platforms/android/gradle/test_build.py b/tests/platforms/android/gradle/test_build.py
index 4a5fbb72..90870fcf 100644
--- a/tests/platforms/android/gradle/test_build.py
+++ b/tests/platforms/android/gradle/test_build.py
@@ -48,6 +48,7 @@ def test_build_app(
     # `ANDROID_SDK_ROOT`, which we expect to be overwritten.
     build_command.tools.os.environ = {"ANDROID_SDK_ROOT": "somewhere", "key": "value"}
     build_command.build_app(first_app_generated, test_mode=False)
+    build_command.tools.android_sdk.verify_emulator.assert_called_once_with()
     build_command.tools.subprocess.run.assert_called_once_with(
         [
             build_command.bundle_path(first_app_generated) / gradlew_name,
@@ -103,6 +104,7 @@ def test_build_app_test_mode(
     # `ANDROID_SDK_ROOT`, which we expect to be overwritten.
     build_command.tools.os.environ = {"ANDROID_SDK_ROOT": "somewhere", "key": "value"}
     build_command.build_app(first_app_generated, test_mode=True)
+    build_command.tools.android_sdk.verify_emulator.assert_called_once_with()
     build_command.tools.subprocess.run.assert_called_once_with(
         [
             build_command.bundle_path(first_app_generated) / gradlew_name,
diff --git a/tests/platforms/android/gradle/test_package.py b/tests/platforms/android/gradle/test_package.py
index 3265a493..adc76ed4 100644
--- a/tests/platforms/android/gradle/test_package.py
+++ b/tests/platforms/android/gradle/test_package.py
@@ -69,6 +69,7 @@ def test_execute_gradle(package_command, first_app_config, host_os, gradlew_name
     # `ANDROID_SDK_ROOT`, which we expect to be overwritten.
     package_command.tools.os.environ = {"ANDROID_SDK_ROOT": "somewhere", "key": "value"}
     package_command.package_app(first_app_config)
+    package_command.tools.android_sdk.verify_emulator.assert_called_once_with()
     package_command.tools.subprocess.run.assert_called_once_with(
         [
             package_command.bundle_path(first_app_config) / gradlew_name,
diff --git a/tests/platforms/web/static/test_build.py b/tests/platforms/web/static/test_build.py
index b08d93c9..910e08c7 100644
--- a/tests/platforms/web/static/test_build.py
+++ b/tests/platforms/web/static/test_build.py
@@ -116,8 +116,7 @@ def test_build_app(build_command, first_app_generated, tmp_path):
             "name": "First App",
             "description": "The first simple app \\ demonstration",
             "version": "0.0.1",
-            "splashscreen": {"autoclose": True},
-            "terminal": False,
+            "autoclose_loader": True,
             "packages": [
                 "/static/wheels/dependency-1.2.3-py3-none-any.whl",
                 "/static/wheels/first_app-1.2.3-py3-none-any.whl",
@@ -289,8 +288,7 @@ def test_build_app_no_requirements(build_command, first_app_generated, tmp_path)
             "name": "First App",
             "description": "The first simple app \\ demonstration",
             "version": "0.0.1",
-            "splashscreen": {"autoclose": True},
-            "terminal": False,
+            "autoclose_loader": True,
             "packages": [
                 "/static/wheels/first_app-1.2.3-py3-none-any.whl",
             ],