Repository: beeware/briefcase
Pull request #1649 (2024-02-14 18:03:41)

Print message when Wait Bar is started non-interactively
## Changes
- When Briefcase runs non-interactively, such as in CI, the Wait Bar is not rendered; therefore, it may not be clear which task is currently running. For instance, starting the Android emulator requires several Wait Bars but there is only output when the Wait Bar finishes.
- This prints the Wait Bar's message when the Wait Bar starts if Briefcase is running non-interactively.
- This new Wait Bar start message as well as the outcome message is always included in the log file now.
- Additionally, a new keep-alive spinner is available as a return from the Wait Bar.
  - This spinner was be used to periodically print `... still waiting` while a background runs that doesn't include any console output.
  - The is currently implemented for the start up process of the Android emulator.

This is an example of non-interactive output:
```
[helloworld] Starting emulator beePhone...
Starting emulator... started
Starting emulator... done

[helloworld] Starting app on https://github.com/beephone (running emulator) (device ID emulator-5554)

[helloworld] Installing app...
Stopping old versions of the app... started
Stopping old versions of the app... done

Installing new app version... started
Installing new app version... done

Launching app... started
Launching app... done

[helloworld] Following device log output (type CTRL-C to stop log)...
===========================================================================
```

This is akin to what `pip` does when run non-interactively:
```
❯ python -m pip install pygobject --no-cache | cat
Collecting pygobject
  Downloading PyGObject-3.46.0.tar.gz (723 kB)
     ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 723.4/723.4 kB 3.7 MB/s eta 0:00:00
  Installing build dependencies: started
  Installing build dependencies: finished with status 'done'
  Getting requirements to build wheel: started
  Getting requirements to build wheel: finished with status 'done'
  Preparing metadata (pyproject.toml): started
  Preparing metadata (pyproject.toml): finished with status 'done'
Collecting pycairo>=1.16.0 (from pygobject)
  Downloading pycairo-1.26.0.tar.gz (346 kB)
     ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 346.9/346.9 kB 39.3 MB/s eta 0:00:00
  Installing build dependencies: started
  Installing build dependencies: finished with status 'done'
  Getting requirements to build wheel: started
  Getting requirements to build wheel: finished with status 'done'
  Installing backend dependencies: started
  Installing backend dependencies: finished with status 'done'
  Preparing metadata (pyproject.toml): started
  Preparing metadata (pyproject.toml): finished with status 'done'
Building wheels for collected packages: pygobject, pycairo
  Building wheel for pygobject (pyproject.toml): started
  Building wheel for pygobject (pyproject.toml): finished with status 'done'
  Created wheel for pygobject: filename=PyGObject-3.46.0-cp310-cp310-linux_x86_64.whl size=780934 sha256=adaf291713960cd7c5020defbb5294742c95a2cc03240157053fd801b60eefc3
  Stored in directory: /tmp/pip-ephem-wheel-cache-8d96807b/wheels/2b/d7/f7/1f4c5631b6d31c5a0f377754294700bf3d7df1ceee00617404
  Building wheel for pycairo (pyproject.toml): started
  Building wheel for pycairo (pyproject.toml): finished with status 'done'
  Created wheel for pycairo: filename=pycairo-1.26.0-cp310-cp310-linux_x86_64.whl size=320989 sha256=07a26626db22ee48e253c5669be0d2227091599db2abe5f695391a697033e90d
  Stored in directory: /tmp/pip-ephem-wheel-cache-8d96807b/wheels/e3/46/83/453eb7915b034ce1a9fee5a6023def2030633f6a73dc6d2de8
Successfully built pygobject pycairo
Installing collected packages: pycairo, pygobject
Successfully installed pycairo-1.26.0 pygobject-3.46.0
```

<!--- Describe your changes in detail -->
<!--- What problem does this change solve? -->
<!--- If this PR relates to an issue, include Refs #XXX or Fixes #XXX -->

## PR Checklist:
<!--- Go over all the following points, and put an `x` in all the boxes that apply. -->
- [x] All new features have been tested
- [x] All new features have been documented
- [X] I have read the **CONTRIBUTING.md** file
- [X] I will abide by the code of conduct


rmartin16: If we're amenable to this change, I'll have to fix a lot of tests. I was also thinking it may be a good idea to augment the initial message with `started` or something.

For instance:
```
[helloworld] Starting emulator beePhone...
Starting emulator... started
Starting emulator... done
```
freakboy3742: Definitely amenable to the broad concept being described here. A user in a non-interactive session shouldn't have *less* information about what is going on. 

The `starting` message might be a good addition (although I think I'd rather *not* see that in interactive mode); another would be to replace the "throbber" with a periodic (say, every 10 seconds) "... still working" message on a line by its own. 
rmartin16: > another would be to replace the "throbber" with a periodic (say, every 10 seconds) "... still working" message on a line by its own

hmm...I like this idea as well....but my initial thought is this would require setting up a thread to print while Briefcase continues on? ...hmm

Looks like `pip` [accomplishes](https://github.com/pypa/pip/blob/51de88ca6459fdd5213f86a54b021a80884572f9/src/pip/_internal/cli/spinners.py#L75) this with a context manager....but that requires a lot of cooperation with the callers.
rmartin16: I implemented support and tests for printing static messages in lieu of the Wait Bar in non-interactive environments. I also found that we were evaluating "interactivity" based on Rich's `FileProxy` for `stdout`; so, I updated the check to use `os.__stdout__` instead.

Additionally, I updated the static message that's printed after a Wait Bar in the event of an exception. In this error condition, the status is now `aborted` for `KeyboardInterrupt` and `errored` otherwise. I think this is an improvement over the Wait Bar message just becoming a static message with any sort of status.

Finally, I adjusted how these static messages are printed so they are _always_ contained in the log file.

I'm still debating the periodic "...still working" messages; I'm somewhat concerned of the depth of the rabbit hole to implement a "threaded non-interactive wait bar"... Also, thinking about the user experience, it might be a bit disruptive; for instance, some long Wait Bars have a lot of output from the underlying task....so, injecting a "...still working" message might actually be confusing... I wonder if there's even another case where we'd want this message that isn't starting the Android emulator..?


rmartin16: I implemented a cooperative keep alive that callers of the Wait Bar can use to signify that Briefcase is still alive and working via a `... still waiting` message. I've only implemented it for the start up of the Android emulator.

Seen in CI: https://github.com/beeware/briefcase/actions/runs/7944249382/job/21689654386?pr=1649#step:25:677

There's probably room here to debate if 10 seconds is too short and whether `... still waiting` is the right message. I didn't parameterize these since it kinda felt like YAGNI.
rmartin16: As a note, unlike the new Wait Bar's "started" message, the new `... still waiting` message prints even in an interactive session where the Wait Bar is being rendered.

Let me know what you think about this. I looked at how the startup for Apple's iOS simulator is implemented but it would need to be re-worked to leverage the keep-alive messages....probably using `Popen()` instead of `run()`.
rmartin16: I've been looking at the iOS simulator startup:
1) call to boot simulator
2) open window for simulator
3) uninstall app
4) install app
5) launch app

Steps 1 and 2 are mostly asynchronous, in that they defer control elsewhere and just return.

So then, we're stuck at step 3 to uninstall the app which blocks until the simulator is ready to start accepting commands. And on my virtualized macOS, this takes some time.

I was hoping to find a way to wait on the simulator to be ready without waiting on the command to uninstall the app.

So far, I found `xcrun simctl bootstatus <UUID>`....but this command is semi-undocumented. It isn't in the list of commands for `xcrun simctl`....but it has help text via `xcrun simctl help bootstatus`. I've also found an [SO answer](https://stackoverflow.com/questions/52438954/mac-simulator-xcode-wait-for-startup-command) from 2019 mentioning it....so, its been around for a minute.

Otherwise, maybe there's innocuous command that'll block like the uninstall command until the simulator is ready.
rmartin16: Maybe an easier approach is just to lie about what Briefcase is doing. In that, while the command to uninstall the app is waiting for the simulator to be ready, we just have the Wait Bar say `Booting...` and if the uninstall fails, we can error as such. Otherwise, the Wait Bar moves on to `Installing app...`.
rmartin16: The simulator's operation and status is just too opaque; so, i simply implemented the keep alive spinner for uninstalling and installing the app. All the other calls are basically async and if they are blocking, then there's bigger problems.

https://github.com/beeware/briefcase/actions/runs/8009151207/job/21877475631?pr=1649#step:26:128
rmartin16: One solution for the CI failures has been implemented in #1661...but there is at least one other option; I outlined them in https://github.com/beeware/briefcase/pull/1661#discussion_r1499771138.
freakboy3742: > One solution for the CI failures has been implemented in #1661...but there is at least one other option; I outlined them in [#1661 (comment)](https://github.com/beeware/briefcase/pull/1661#discussion_r1499771138).

My apologies - I missed that comment in my overnight catchup. I guess we resolve/merge that one, then re-run CI here. Marking the PR accepted on the basis there's no code changes needed.

Patch:
diff --git a/changes/1649.feature.rst b/changes/1649.feature.rst
new file mode 100644
index 00000000..8550da86
--- /dev/null
+++ b/changes/1649.feature.rst
@@ -0,0 +1,1 @@
+In non-interactive environments, such as CI, a message is now printed to signify a task has begun where an animated bar would be displayed in interactive console sessions.
diff --git a/src/briefcase/console.py b/src/briefcase/console.py
index 4ee5e331..37e945c1 100644
--- a/src/briefcase/console.py
+++ b/src/briefcase/console.py
@@ -6,11 +6,13 @@ import os
 import platform
 import re
 import sys
+import time
 import traceback
 from contextlib import contextmanager
 from datetime import datetime
 from enum import IntEnum
 from pathlib import Path
+from typing import Callable, Iterable
 
 from rich.console import Console as RichConsole
 from rich.control import strip_control_codes
@@ -23,7 +25,7 @@ from rich.progress import (
     TextColumn,
     TimeRemainingColumn,
 )
-from rich.traceback import Traceback
+from rich.traceback import Trace, Traceback
 
 from briefcase import __version__
 
@@ -65,20 +67,19 @@ class RichConsoleHighlighter(RegexHighlighter):
     """
 
     base_style = "repr."
-    highlights = [r"(?P<url>(file|https|http|ws|wss)://[-0-9a-zA-Z$_+!`(),.?/;:&=%#]*)"]
+    highlights = [
+        r"(?P<url>(file|https|http|ws|wss)://[-0-9a-zA-Z$_+!`(),.?/;:&=%#~]*)"
+    ]
 
 
 class Printer:
-    """Interface for printing and managing output to the console and/or log."""
-
     def __init__(self, log_width=180):
-        """Create an interface for printing and managing output to the console and/or
-        log.
+        """Interface for printing and managing output to the console and/or log.
 
         The default width is wide enough to render the output of ``sdkmanager
-        --list_installed`` without line wrapping.
+        --list_installed`` in the log file without line wrapping.
 
-        :param width: The width at which content should be wrapped.
+        :param log_width: The width at which content should be wrapped in the log file
         """
         self.log_width = log_width
 
@@ -91,8 +92,7 @@ class Printer:
 
         # Rich only records what's being logged if it is actually written somewhere;
         # writing to /dev/null allows Rich to do so without needing to print the
-        # logs in the console or save them to file before it is known a file is
-        # wanted.
+        # logs in the console or save them to file before it is known a file is wanted.
         self.dev_null = open(os.devnull, "w", encoding="utf-8", errors="ignore")
         self.log = RichConsole(
             file=self.dev_null,
@@ -176,9 +176,13 @@ class Log:
     # subdirectory of command.base_path to store log files
     LOG_DIR = "logs"
 
-    def __init__(self, printer=None, verbosity: LogLevel = LogLevel.INFO):
+    def __init__(
+        self,
+        printer: Printer | None = None,
+        verbosity: LogLevel = LogLevel.INFO,
+    ):
         self.print = Printer() if printer is None else printer
-        # --verbosity flag: 0 for info, 1 for debug, 2 for deep debug
+        # --verbosity flag: see LogLevel for valid values
         self.verbosity = verbosity
         # --log flag to force logfile creation
         self.save_log = False
@@ -186,14 +190,14 @@ class Log:
         self.skip_log = False
         # Rich stacktraces of exceptions for logging to file.
         # A list of tuples containing a label for the thread context, and the Trace object
-        self.stacktraces = []
+        self.stacktraces: list[tuple[str, Trace]] = []
         # functions to run for additional logging if creating a logfile
-        self.log_file_extras = []
+        self.log_file_extras: list[Callable[[], object]] = []
         # The current context for the log
         self._context = ""
 
     @contextmanager
-    def context(self, context):
+    def context(self, context: str):
         """Wrap a collection of output in a logging context.
 
         A logging context is a prefix on every logging line. It is used when a set of
@@ -272,7 +276,7 @@ class Log:
         )
 
     def verbose(self, message="", *, prefix="", markup=False):
-        """Log message at verbose level if debug enabled; included if verbosity >= 1."""
+        """Log message at verbose level; included if verbosity >= 1."""
         self._log(prefix=prefix, message=message, show=self.is_verbose, markup=markup)
 
     def info(self, message="", *, prefix="", markup=False):
@@ -359,7 +363,7 @@ class Log:
 
         self.stacktraces.append((label, Traceback.extract(*exc_info, show_locals=True)))
 
-    def add_log_file_extra(self, func):
+    def add_log_file_extra(self, func: Callable[[], object]):
         """Register a function to be called in the event that a log file is written.
 
         This can be used to provide additional debugging information which is too
@@ -460,13 +464,41 @@ class Log:
         )
 
 
+class NotDeadYet:
+    # I’m getting better! No you’re not, you’ll be stone dead in a minute.
+
+    def __init__(self, printer: Printer):
+        """A keep-alive spinner for long-running processes without console output.
+
+        Returned by the Wait Bar's context manager but can be used independently. Use in
+        a loop that calls update() each iteration. A keep-alive message will be printed
+        every 10 seconds.
+        """
+        self.printer: Printer = printer
+        self.interval_sec: int = 10
+        self.message: str = "... still waiting"
+        self.ready_time: float = 0.0
+
+        self.reset()  # initialize
+
+    def update(self):
+        """Write keep-alive message if the periodic interval has elapsed."""
+        if self.ready_time < time.time():
+            self.printer(self.message)
+            self.reset()
+
+    def reset(self):
+        """Initialize periodic interval to now; next message prints after interval."""
+        self.ready_time = time.time() + self.interval_sec
+
+
 class Console:
-    def __init__(self, printer=None, enabled=True):
+    def __init__(self, printer: Printer | None = None, enabled: bool = True):
         self.enabled = enabled
         self.print = Printer() if printer is None else printer
         # Use Rich's input() to read from user
         self.input = self.print.console.input
-        self._wait_bar: Progress = None
+        self._wait_bar: Progress | None = None
         # Signal that Rich is dynamically controlling the console output. Therefore,
         # all output must be printed to the screen by Rich to prevent corruption of
         # dynamic elements like the Wait Bar.
@@ -484,7 +516,8 @@ class Console:
         can dramatically compromise the quality of logged output. So, dynamic elements
         should be specifically disabled in non-interactive sessions.
         """
-        return sys.stdout.isatty()
+        # `sys.__stdout__` is used because Rich captures and redirects `sys.stdout`
+        return sys.__stdout__.isatty()
 
     def progress_bar(self):
         """Returns a progress bar as a context manager."""
@@ -502,12 +535,12 @@ class Console:
     @contextmanager
     def wait_bar(
         self,
-        message="",
-        done_message="done",
+        message: str = "",
+        done_message: str = "done",
         *,
-        transient=False,
-        markup=False,
-    ):
+        transient: bool = False,
+        markup: bool = False,
+    ) -> NotDeadYet:
         """Activates the Wait Bar as a context manager.
 
         If the Wait Bar is already active, then its message is updated for the new
@@ -520,35 +553,47 @@ class Console:
             if False (default), the message will remain on the screen without pulsing bar.
         :param markup: whether to interpret Rich styling markup in the message; if True,
             the message must already be escaped; defaults False.
+        :returns:  Keep-alive spinner to notify user Briefcase is still waiting
         """
+        is_wait_bar_disabled = not self.is_interactive
+        show_outcome_message = message and (is_wait_bar_disabled or not transient)
+
         if self._wait_bar is None:
             self._wait_bar = Progress(
                 TextColumn("    "),
                 BarColumn(bar_width=20, style="black", pulse_style="white"),
                 TextColumn("{task.fields[message]}"),
                 transient=True,
-                disable=not self.is_interactive,
+                disable=is_wait_bar_disabled,
                 console=self.print.console,
             )
             # start=False causes the progress bar to "pulse"
             # message=None is a sentinel the Wait Bar should be inactive
             self._wait_bar.add_task("", start=False, message=None)
 
+        self.print(
+            f"{message} started", markup=markup, show=message and is_wait_bar_disabled
+        )
+
         self.is_console_controlled = True
         wait_bar_task = self._wait_bar.tasks[0]
         previous_message = wait_bar_task.fields["message"]
         self._wait_bar.update(wait_bar_task.id, message=message)
+
         try:
             self._wait_bar.start()
-            yield
-        except BaseException:
-            # ensure the message is left on the screen even if user sends CTRL+C
-            if message and not transient:
-                self.print(message, markup=markup)
+            yield NotDeadYet(printer=self.print)
+        except BaseException as e:
+            # capture BaseException so message is left on the screen even if user sends CTRL+C
+            error_message = "aborted" if isinstance(e, KeyboardInterrupt) else "errored"
+            self.print(
+                f"{message} {error_message}", markup=markup, show=show_outcome_message
+            )
             raise
         else:
-            if message and not transient:
-                self.print(f"{message} {done_message}", markup=markup)
+            self.print(
+                f"{message} {done_message}", markup=markup, show=show_outcome_message
+            )
         finally:
             self._wait_bar.update(wait_bar_task.id, message=previous_message)
             # Deactivate the Wait Bar if returning to its initial state
@@ -584,7 +629,7 @@ class Console:
                 self._wait_bar.start()
 
     def prompt(self, *values, markup=False, **kwargs):
-        """Print to the screen for soliciting user interaction.
+        """Print to the screen for soliciting user interaction if input enabled.
 
         :param values: strings to print as the user prompt
         :param markup: True if prompt contains Rich markup
@@ -592,7 +637,7 @@ class Console:
         if self.enabled:
             self.print(*values, markup=markup, stack_offset=4, **kwargs)
 
-    def boolean_input(self, question, default=False):
+    def boolean_input(self, question: str, default: bool = False) -> bool:
         """Get a boolean input from user, in the form of y/n.
 
         The user might press "y" for true or "n" for false. If input is disabled,
@@ -630,12 +675,12 @@ class Console:
 
     def selection_input(
         self,
-        prompt,
-        choices,
-        default=None,
-        error_message="Invalid Selection",
-        transform=None,
-    ):
+        prompt: str,
+        choices: Iterable[str],
+        default: str | None = None,
+        error_message: str = "Invalid Selection",
+        transform: Callable[[str], str] | None = None,
+    ) -> str:
         """Prompt the user to select an option from a list of choices.
 
         :param prompt: The text prompt to display
@@ -657,7 +702,7 @@ class Console:
             self.prompt()
             self.prompt(error_message)
 
-    def text_input(self, prompt, default=None):
+    def text_input(self, prompt: str, default: str | None = None) -> str:
         """Prompt the user for text input.
 
         If no default is specified, the input will be returned as entered.
@@ -680,8 +725,8 @@ class Console:
 
         return user_input
 
-    def __call__(self, prompt, *, markup=False):
-        """Present input() interface."""
+    def __call__(self, prompt: str, *, markup: bool = False):
+        """Present input() interface; prompt should be bold if markup is included."""
         if not self.enabled:
             raise InputDisabled()
 
diff --git a/src/briefcase/integrations/android_sdk.py b/src/briefcase/integrations/android_sdk.py
index 993e29cf..cf27bf9b 100644
--- a/src/briefcase/integrations/android_sdk.py
+++ b/src/briefcase/integrations/android_sdk.py
@@ -1337,7 +1337,7 @@ You can also start the emulator manually by running:
         # Phase 1: Wait for the device to appear so we can get an
         # ADB instance for the new device.
         try:
-            with self.tools.input.wait_bar("Starting emulator..."):
+            with self.tools.input.wait_bar("Starting emulator...") as keep_alive:
                 adb = None
                 known_devices = set()
                 while adb is None:
@@ -1366,11 +1366,12 @@ You can also start the emulator manually by running:
 
                     # If we haven't found a device, try again in 2 seconds...
                     if adb is None:
+                        keep_alive.update()
                         self.sleep(2)
 
             # Phase 2: Wait for the boot process to complete
             if not adb.has_booted():
-                with self.tools.input.wait_bar("Booting emulator..."):
+                with self.tools.input.wait_bar("Booting emulator...") as keep_alive:
                     while not adb.has_booted():
                         if emulator_popen.poll() is not None:
                             raise BriefcaseCommandError(
@@ -1380,6 +1381,7 @@ You can also start the emulator manually by running:
                             )
 
                         # Try again in 2 seconds...
+                        keep_alive.update()
                         self.sleep(2)
         except BaseException as e:
             self.tools.logger.warning(
diff --git a/src/briefcase/platforms/iOS/xcode.py b/src/briefcase/platforms/iOS/xcode.py
index ae88dc9f..453c036a 100644
--- a/src/briefcase/platforms/iOS/xcode.py
+++ b/src/briefcase/platforms/iOS/xcode.py
@@ -427,9 +427,8 @@ class iOSXcodeRunCommand(iOSXcodeMixin, RunCommand):
         super().__init__(*args, **kwargs)
 
         # External service APIs.
-        # These are abstracted to enable testing without patching.
+        # This is abstracted to enable testing without patching.
         self.get_device_state = get_device_state
-        self.sleep = time.sleep
 
     def run_app(
         self,
@@ -471,7 +470,7 @@ class iOSXcodeRunCommand(iOSXcodeMixin, RunCommand):
         if device_state not in {DeviceState.SHUTDOWN, DeviceState.BOOTED}:
             with self.input.wait_bar("Waiting for simulator shutdown..."):
                 while device_state not in {DeviceState.SHUTDOWN, DeviceState.BOOTED}:
-                    self.sleep(2)
+                    time.sleep(2)
                     device_state = self.get_device_state(self.tools, udid)
 
         # We now know the simulator is either shut down or booted;
@@ -512,28 +511,33 @@ class iOSXcodeRunCommand(iOSXcodeMixin, RunCommand):
         # Try to uninstall the app first. If the app hasn't been installed
         # before, this will still succeed.
         self.logger.info(f"Installing {label}...", prefix=app.app_name)
-        try:
-            with self.input.wait_bar("Uninstalling any existing app version..."):
-                self.tools.subprocess.run(
-                    ["xcrun", "simctl", "uninstall", udid, app.bundle_identifier],
-                    check=True,
-                )
-        except subprocess.CalledProcessError as e:
+        with self.input.wait_bar(
+            "Uninstalling any existing app version..."
+        ) as keep_alive, self.tools.subprocess.Popen(
+            ["xcrun", "simctl", "uninstall", udid, app.bundle_identifier]
+        ) as uninstall_popen:
+            while (ret_code := uninstall_popen.poll()) is None:
+                keep_alive.update()
+                time.sleep(0.25)
+        if ret_code != 0:
+            self.logger.error(f"{ret_code=}")
             raise BriefcaseCommandError(
                 f"Unable to uninstall old version of app {app.app_name}."
-            ) from e
+            )
 
         # Install the app.
-        try:
-            with self.input.wait_bar(f"Installing new {label} version..."):
-                self.tools.subprocess.run(
-                    ["xcrun", "simctl", "install", udid, self.binary_path(app)],
-                    check=True,
-                )
-        except subprocess.CalledProcessError as e:
+        with self.input.wait_bar(
+            f"Installing new {label} version..."
+        ) as keep_alive, self.tools.subprocess.Popen(
+            ["xcrun", "simctl", "install", udid, self.binary_path(app)]
+        ) as install_popen:
+            while (ret_code := install_popen.poll()) is None:
+                keep_alive.update()
+                time.sleep(0.25)
+        if ret_code != 0:
             raise BriefcaseCommandError(
                 f"Unable to install new version of app {app.app_name}."
-            ) from e
+            )
 
         # Start log stream for the app.
         # The following sets up a log stream filter that looks for:
@@ -571,7 +575,7 @@ class iOSXcodeRunCommand(iOSXcodeMixin, RunCommand):
         )
 
         # Wait for the log stream start up
-        self.sleep(0.25)
+        time.sleep(0.25)
 
         try:
             self.logger.info(f"Starting {label}...", prefix=app.app_name)


Test patch:
diff --git a/tests/commands/create/test_cleanup_app_content.py b/tests/commands/create/test_cleanup_app_content.py
index bd87e4f6..67f993e1 100644
--- a/tests/commands/create/test_cleanup_app_content.py
+++ b/tests/commands/create/test_cleanup_app_content.py
@@ -42,7 +42,7 @@ def test_no_cleanup(create_command, myapp_unrolled, support_path, debug, capsys)
     # whether debug is enabled.
     output = capsys.readouterr().out.split("\n")
     assert output[-3] == "Removing unneeded app bundle content... done"
-    assert len(output) == (4 if debug else 3)
+    assert len(output) == (5 if debug else 4)
 
 
 @pytest.mark.parametrize("debug", [True, False])
@@ -65,7 +65,7 @@ def test_dir_cleanup(create_command, myapp_unrolled, support_path, debug, capsys
     # whether debug is enabled.
     output = capsys.readouterr().out.split("\n")
     assert output[-3] == "Removing unneeded app bundle content... done"
-    assert len(output) == (4 if debug else 3)
+    assert len(output) == (5 if debug else 4)
 
 
 @pytest.mark.parametrize("debug", [True, False])
@@ -91,7 +91,7 @@ def test_file_cleanup(create_command, myapp_unrolled, support_path, debug, capsy
     # whether debug is enabled.
     output = capsys.readouterr().out.split("\n")
     assert output[-3] == "Removing unneeded app bundle content... done"
-    assert len(output) == (5 if debug else 3)
+    assert len(output) == (6 if debug else 4)
 
 
 @pytest.mark.parametrize("debug", [True, False])
@@ -121,7 +121,7 @@ def test_all_files_in_dir_cleanup(
     # whether debug is enabled.
     output = capsys.readouterr().out.split("\n")
     assert output[-3] == "Removing unneeded app bundle content... done"
-    assert len(output) == (7 if debug else 3)
+    assert len(output) == (8 if debug else 4)
 
 
 @pytest.mark.parametrize("debug", [True, False])
@@ -144,7 +144,7 @@ def test_dir_glob_cleanup(create_command, myapp_unrolled, support_path, debug, c
     # whether debug is enabled.
     output = capsys.readouterr().out.split("\n")
     assert output[-3] == "Removing unneeded app bundle content... done"
-    assert len(output) == (5 if debug else 3)
+    assert len(output) == (6 if debug else 4)
 
 
 @pytest.mark.parametrize("debug", [True, False])
@@ -170,7 +170,7 @@ def test_file_glob_cleanup(create_command, myapp_unrolled, support_path, debug,
     # whether debug is enabled.
     output = capsys.readouterr().out.split("\n")
     assert output[-3] == "Removing unneeded app bundle content... done"
-    assert len(output) == (6 if debug else 3)
+    assert len(output) == (7 if debug else 4)
 
 
 @pytest.mark.parametrize("debug", [True, False])
@@ -197,7 +197,7 @@ def test_deep_glob_cleanup(create_command, myapp_unrolled, support_path, debug,
     # whether debug is enabled.
     output = capsys.readouterr().out.split("\n")
     assert output[-3] == "Removing unneeded app bundle content... done"
-    assert len(output) == (7 if debug else 3)
+    assert len(output) == (8 if debug else 4)
 
 
 @pytest.mark.parametrize("debug", [True, False])
@@ -232,7 +232,7 @@ def test_template_glob_cleanup(
     # whether debug is enabled.
     output = capsys.readouterr().out.split("\n")
     assert output[-3] == "Removing unneeded app bundle content... done"
-    assert len(output) == (7 if debug else 3)
+    assert len(output) == (8 if debug else 4)
 
 
 @pytest.mark.parametrize("debug", [True, False])
@@ -272,4 +272,4 @@ def test_non_existent_cleanup(
     # whether debug is enabled.
     output = capsys.readouterr().out.split("\n")
     assert output[-3] == "Removing unneeded app bundle content... done"
-    assert len(output) == (5 if debug else 3)
+    assert len(output) == (6 if debug else 4)
diff --git a/tests/commands/create/test_install_image.py b/tests/commands/create/test_install_image.py
index 0b88a824..006a25dd 100644
--- a/tests/commands/create/test_install_image.py
+++ b/tests/commands/create/test_install_image.py
@@ -60,7 +60,10 @@ def test_no_requested_size(create_command, tmp_path, capsys):
     )
 
     # The right message was written to output
-    expected = "Installing input/original.png as sample image... done\n\n"
+    expected = (
+        "Installing input/original.png as sample image... started\n"
+        "Installing input/original.png as sample image... done\n\n"
+    )
     assert capsys.readouterr().out == expected
 
     # The file was copied into position
@@ -114,7 +117,10 @@ def test_requested_size(create_command, tmp_path, capsys):
     )
 
     # The right message was written to output
-    expected = "Installing input/original-3742.png as 3742px sample image... done\n\n"
+    expected = (
+        "Installing input/original-3742.png as 3742px sample image... started\n"
+        "Installing input/original-3742.png as 3742px sample image... done\n\n"
+    )
     assert capsys.readouterr().out == expected
 
     # The file was copied into position
@@ -170,7 +176,10 @@ def test_variant_with_no_requested_size(create_command, tmp_path, capsys):
     )
 
     # The right message was written to output
-    expected = "Installing input/original.png as round sample image... done\n\n"
+    expected = (
+        "Installing input/original.png as round sample image... started\n"
+        "Installing input/original.png as round sample image... done\n\n"
+    )
     assert capsys.readouterr().out == expected
 
     # The file was copied into position
@@ -265,6 +274,7 @@ def test_variant_with_size(create_command, tmp_path, capsys):
 
     # The right message was written to output
     expected = (
+        "Installing input/original-3742.png as 3742px round sample image... started\n"
         "Installing input/original-3742.png as 3742px round sample image... done\n\n"
     )
     assert capsys.readouterr().out == expected
@@ -361,7 +371,10 @@ def test_unsized_variant(create_command, tmp_path, capsys):
     )
 
     # The right message was written to output
-    expected = "Installing input/original.png as round sample image... done\n\n"
+    expected = (
+        "Installing input/original.png as round sample image... started\n"
+        "Installing input/original.png as round sample image... done\n\n"
+    )
     assert capsys.readouterr().out == expected
 
     # The file was copied into position
diff --git a/tests/conftest.py b/tests/conftest.py
index d36ad503..31e4203f 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -2,7 +2,7 @@ import inspect
 import os
 import subprocess
 import time
-from unittest.mock import ANY
+from unittest.mock import ANY, MagicMock
 
 import pytest
 
@@ -50,7 +50,7 @@ def no_print(monkeypatch):
 @pytest.fixture
 def sleep_zero(monkeypatch):
     """Replace all calls to ``time.sleep(x)`` with ``time.sleep(0)``."""
-    monkeypatch.setattr(time, "sleep", lambda x: _sleep(0))
+    monkeypatch.setattr(time, "sleep", MagicMock(wraps=lambda x: _sleep(0)))
 
 
 @pytest.fixture
diff --git a/tests/console/Console/test_progress_bar.py b/tests/console/Console/test_progress_bar.py
index 6ca7a92e..3e6f8353 100644
--- a/tests/console/Console/test_progress_bar.py
+++ b/tests/console/Console/test_progress_bar.py
@@ -1,6 +1,6 @@
-def test_wait_bar_always_interactive(interactive_console):
+def test_wait_bar_always_interactive(console):
     """Progress Bar is not disabled when console is interactive."""
-    with interactive_console.progress_bar() as bar:
+    with console.progress_bar() as bar:
         assert bar.disable is False
 
 
diff --git a/tests/console/Console/test_properties.py b/tests/console/Console/test_properties.py
index 11afc6a3..962d7bb8 100644
--- a/tests/console/Console/test_properties.py
+++ b/tests/console/Console/test_properties.py
@@ -1,6 +1,3 @@
-import os
-import sys
-
 from briefcase.console import Console
 
 
@@ -28,24 +25,17 @@ def test_enable(disabled_console):
 def test_disable():
     """A disabled console can be enabled."""
     console = Console()
-
     assert console.enabled
 
     console.enabled = False
-
     assert not console.enabled
 
 
-def test_is_interactive_is_tty(console):
-    """Interactivity should match whether a tty is attached to stdout."""
-    assert console.is_interactive is os.isatty(sys.stdout.fileno())
-
-
 def test_is_interactive_non_interactive(non_interactive_console):
     """Console is non-interactive when stdout has no tty."""
     assert non_interactive_console.is_interactive is False
 
 
-def test_is_interactive_always_interactive(interactive_console):
+def test_is_interactive_always_interactive(console):
     """Console is interactive when stdout has a tty."""
-    assert interactive_console.is_interactive is True
+    assert console.is_interactive is True
diff --git a/tests/console/Console/test_release_console_control.py b/tests/console/Console/test_release_console_control.py
index df20dc56..643a64cb 100644
--- a/tests/console/Console/test_release_console_control.py
+++ b/tests/console/Console/test_release_console_control.py
@@ -27,11 +27,8 @@ def test_console_is_not_controlled():
     assert console.is_console_controlled is False
 
 
-def test_wait_bar_is_active(interactive_console):
+def test_wait_bar_is_active(console):
     """An active Wait Bar is stopped."""
-    # Always run in interactive mode; otherwise, is_started is will never be True
-    console = interactive_console
-
     with console.wait_bar("Testing..."):
         # Wrap the Wait Bar stop and start methods
         console._wait_bar.stop = Mock(wraps=console._wait_bar.stop)
diff --git a/tests/console/Console/test_wait_bar.py b/tests/console/Console/test_wait_bar.py
index e45edbe8..69c9cb97 100644
--- a/tests/console/Console/test_wait_bar.py
+++ b/tests/console/Console/test_wait_bar.py
@@ -1,7 +1,11 @@
+from unittest.mock import MagicMock
+
 import pytest
 
+import briefcase.console
+
 
-def test_wait_bar_done_message(console, capsys):
+def test_wait_bar_done_message_interactive(console, capsys):
     """Custom done_message is printed when wait bar normally exits."""
     with console.wait_bar("Wait message...", done_message="finished"):
         pass
@@ -9,13 +13,40 @@ def test_wait_bar_done_message(console, capsys):
     assert capsys.readouterr().out == "Wait message... finished\n\n"
 
 
-def test_wait_bar_done_message_nested(console, capsys):
+def test_wait_bar_done_message_non_interactive(non_interactive_console, capsys):
+    """Custom done_message is printed when wait bar normally exits."""
+    with non_interactive_console.wait_bar("Wait message...", done_message="finished"):
+        pass
+
+    assert capsys.readouterr().out == (
+        "Wait message... started\n" "Wait message... finished\n\n"
+    )
+
+
+def test_wait_bar_done_message_nested_interactive(console, capsys):
     """Custom done_message is printed when wait bar normally exits."""
     with console.wait_bar("Wait message 1...", done_message="finished"):
         with console.wait_bar("Wait message 2...", done_message="finished"):
             pass
 
-    expected = "Wait message 2... finished\nWait message 1... finished\n\n"
+    expected = "Wait message 2... finished\n" "Wait message 1... finished\n\n"
+    assert capsys.readouterr().out == expected
+
+
+def test_wait_bar_done_message_nested_non_interactive(non_interactive_console, capsys):
+    """Custom done_message is printed when wait bar normally exits."""
+    with non_interactive_console.wait_bar("Wait message 1...", done_message="finished"):
+        with non_interactive_console.wait_bar(
+            "Wait message 2...", done_message="finished"
+        ):
+            pass
+
+    expected = (
+        "Wait message 1... started\n"
+        "Wait message 2... started\n"
+        "Wait message 2... finished\n"
+        "Wait message 1... finished\n\n"
+    )
     assert capsys.readouterr().out == expected
 
 
@@ -28,7 +59,7 @@ def test_wait_bar_done_message_nested(console, capsys):
         ("", True, "\n"),
     ),
 )
-def test_wait_bar_transient(console, message, transient, output, capsys):
+def test_wait_bar_transient_interactive(console, message, transient, output, capsys):
     """Output is present or absent based on presence of message and transient value."""
     with console.wait_bar(message, transient=transient):
         pass
@@ -36,6 +67,29 @@ def test_wait_bar_transient(console, message, transient, output, capsys):
     assert capsys.readouterr().out == output
 
 
+@pytest.mark.parametrize(
+    ("message", "transient", "output"),
+    (
+        ("Wait message...", False, "Wait message... started\nWait message... done\n\n"),
+        ("", False, "\n"),
+        ("Wait message...", True, "Wait message... started\nWait message... done\n\n"),
+        ("", True, "\n"),
+    ),
+)
+def test_wait_bar_transient_non_interactive(
+    non_interactive_console,
+    message,
+    transient,
+    output,
+    capsys,
+):
+    """Output is present or absent based on presence of message and transient value."""
+    with non_interactive_console.wait_bar(message, transient=transient):
+        pass
+
+    assert capsys.readouterr().out == output
+
+
 @pytest.mark.parametrize(
     ("message_one", "message_two", "transient", "output"),
     (
@@ -50,7 +104,7 @@ def test_wait_bar_transient(console, message, transient, output, capsys):
         ("", "", True, "\n"),
     ),
 )
-def test_wait_bar_transient_nested(
+def test_wait_bar_transient_nested_interactive(
     console,
     message_one,
     message_two,
@@ -66,16 +120,67 @@ def test_wait_bar_transient_nested(
     assert capsys.readouterr().out == output
 
 
+@pytest.mark.parametrize(
+    ("message_one", "message_two", "transient", "output"),
+    (
+        (
+            "Wait message 1...",
+            "Wait message 2...",
+            False,
+            (
+                "Wait message 1... started\n"
+                "Wait message 2... started\n"
+                "Wait message 2... done\n"
+                "Wait message 1... done\n\n"
+            ),
+        ),
+        ("", "", False, "\n"),
+        (
+            "Wait message 1...",
+            "Wait message 2...",
+            True,
+            (
+                "Wait message 1... started\n"
+                "Wait message 2... started\n"
+                "Wait message 2... done\n"
+                "Wait message 1... done\n\n"
+            ),
+        ),
+        ("", "", True, "\n"),
+    ),
+)
+def test_wait_bar_transient_nested_non_interactive(
+    non_interactive_console,
+    message_one,
+    message_two,
+    transient,
+    output,
+    capsys,
+):
+    """Output is present or absent based on presence of message and transient value."""
+    with non_interactive_console.wait_bar(message_one, transient=transient):
+        with non_interactive_console.wait_bar(message_two, transient=transient):
+            pass
+
+    assert capsys.readouterr().out == output
+
+
 @pytest.mark.parametrize(
     ("message", "transient", "output"),
     (
-        ("Wait message...", False, "Wait message...\n\n"),
+        ("Wait message...", False, "Wait message... aborted\n\n"),
         ("", False, "\n"),
         ("Wait Message...", True, "\n"),
         ("", True, "\n"),
     ),
 )
-def test_wait_bar_keyboard_interrupt(console, message, transient, output, capsys):
+def test_wait_bar_keyboard_interrupt_interactive(
+    console,
+    message,
+    transient,
+    output,
+    capsys,
+):
     """If the wait bar is interrupted, output is present or absent based on presence of
     message and transient value."""
     with pytest.raises(KeyboardInterrupt):
@@ -85,6 +190,39 @@ def test_wait_bar_keyboard_interrupt(console, message, transient, output, capsys
     assert capsys.readouterr().out == output
 
 
+@pytest.mark.parametrize(
+    ("message", "transient", "output"),
+    (
+        (
+            "Wait message...",
+            False,
+            "Wait message... started\nWait message... aborted\n\n",
+        ),
+        ("", False, "\n"),
+        (
+            "Wait message...",
+            True,
+            "Wait message... started\nWait message... aborted\n\n",
+        ),
+        ("", True, "\n"),
+    ),
+)
+def test_wait_bar_keyboard_interrupt_non_interactive(
+    non_interactive_console,
+    message,
+    transient,
+    output,
+    capsys,
+):
+    """If the wait bar is interrupted, output is present or absent based on presence of
+    message and transient value."""
+    with pytest.raises(KeyboardInterrupt):
+        with non_interactive_console.wait_bar(message, transient=transient):
+            raise KeyboardInterrupt
+
+    assert capsys.readouterr().out == output
+
+
 @pytest.mark.parametrize(
     ("message_one", "message_two", "transient", "output"),
     (
@@ -92,14 +230,14 @@ def test_wait_bar_keyboard_interrupt(console, message, transient, output, capsys
             "Wait message 1...",
             "Wait message 2...",
             False,
-            "Wait message 2...\nWait message 1...\n\n",
+            "Wait message 2... aborted\nWait message 1... aborted\n\n",
         ),
         ("", "", False, "\n"),
         ("Wait message 1...", "Wait message 2...", True, "\n"),
         ("", "", True, "\n"),
     ),
 )
-def test_wait_bar_keyboard_interrupt_nested(
+def test_wait_bar_keyboard_interrupt_nested_interactive(
     console,
     message_one,
     message_two,
@@ -117,12 +255,59 @@ def test_wait_bar_keyboard_interrupt_nested(
     assert capsys.readouterr().out == output
 
 
-def test_wait_bar_always_interactive(interactive_console):
+@pytest.mark.parametrize(
+    ("message_one", "message_two", "transient", "output"),
+    (
+        (
+            "Wait message 1...",
+            "Wait message 2...",
+            False,
+            (
+                "Wait message 1... started\n"
+                "Wait message 2... started\n"
+                "Wait message 2... aborted\n"
+                "Wait message 1... aborted\n\n"
+            ),
+        ),
+        ("", "", False, "\n"),
+        (
+            "Wait message 1...",
+            "Wait message 2...",
+            True,
+            (
+                "Wait message 1... started\n"
+                "Wait message 2... started\n"
+                "Wait message 2... aborted\n"
+                "Wait message 1... aborted\n\n"
+            ),
+        ),
+        ("", "", True, "\n"),
+    ),
+)
+def test_wait_bar_keyboard_interrupt_nested_non_interactive(
+    non_interactive_console,
+    message_one,
+    message_two,
+    transient,
+    output,
+    capsys,
+):
+    """If the wait bar is interrupted, output is present or absent based on presence of
+    message and transient value."""
+    with pytest.raises(KeyboardInterrupt):
+        with non_interactive_console.wait_bar(message_one, transient=transient):
+            with non_interactive_console.wait_bar(message_two, transient=transient):
+                raise KeyboardInterrupt
+
+    assert capsys.readouterr().out == output
+
+
+def test_wait_bar_always_interactive(console):
     """Wait Bar is not disabled when console is interactive."""
-    with interactive_console.wait_bar():
-        assert interactive_console._wait_bar.disable is False
-        with interactive_console.wait_bar():
-            assert interactive_console._wait_bar.disable is False
+    with console.wait_bar():
+        assert console._wait_bar.disable is False
+        with console.wait_bar():
+            assert console._wait_bar.disable is False
 
 
 def test_wait_bar_non_interactive(non_interactive_console):
@@ -131,3 +316,31 @@ def test_wait_bar_non_interactive(non_interactive_console):
         assert non_interactive_console._wait_bar.disable is True
         with non_interactive_console.wait_bar():
             assert non_interactive_console._wait_bar.disable is True
+
+
+def test_wait_bar_alive_messages_interactive(
+    console,
+    non_interactive_console,
+    capsys,
+    monkeypatch,
+):
+    """Wait Bar keep_alive prints keep alive messages."""
+    for test_console in [console, non_interactive_console]:
+
+        # initialization will set interval to a small number
+        # update() will see time at a large number and print the message
+        # then interval is reset back to a small number
+        monkeypatch.setattr(
+            briefcase.console.time,
+            "time",
+            MagicMock(side_effect=[0] + [1e42, 0] * 2),
+        )
+
+        with test_console.wait_bar("task") as keep_alive:
+            for _ in range(2):
+                keep_alive.update()
+
+        assert capsys.readouterr().out.endswith(
+            "... still waiting\n... still waiting\ntask done\n\n"
+        )
+        assert capsys.readouterr().out == ""
diff --git a/tests/console/conftest.py b/tests/console/conftest.py
index e197a998..c1ec1294 100644
--- a/tests/console/conftest.py
+++ b/tests/console/conftest.py
@@ -7,9 +7,11 @@ from briefcase.console import Console
 
 
 @pytest.fixture
-def console() -> Console:
+def console(monkeypatch) -> Console:
     console = Console()
     console.input = mock.MagicMock(spec_set=input)
+    # default console is always interactive
+    monkeypatch.setattr(sys.__stdout__, "isatty", lambda: True)
     return console
 
 
@@ -22,11 +24,5 @@ def disabled_console() -> Console:
 
 @pytest.fixture
 def non_interactive_console(console, monkeypatch) -> Console:
-    monkeypatch.setattr(sys.stdout, "isatty", lambda: False)
-    yield console
-
-
-@pytest.fixture
-def interactive_console(console, monkeypatch) -> Console:
-    monkeypatch.setattr(sys.stdout, "isatty", lambda: True)
+    monkeypatch.setattr(sys.__stdout__, "isatty", lambda: False)
     yield console
diff --git a/tests/console/test_Log.py b/tests/console/test_Log.py
index abb962ad..3515c137 100644
--- a/tests/console/test_Log.py
+++ b/tests/console/test_Log.py
@@ -1,3 +1,4 @@
+import contextlib
 import datetime
 import logging
 from io import TextIOBase
@@ -8,7 +9,7 @@ from rich.traceback import Trace
 
 import briefcase
 from briefcase.commands.dev import DevCommand
-from briefcase.console import Console, Log, LogLevel, RichLoggingHandler
+from briefcase.console import Console, Log, LogLevel, Printer, RichLoggingHandler
 from briefcase.exceptions import BriefcaseError
 
 TRACEBACK_HEADER = "Traceback (most recent call last)"
@@ -169,8 +170,11 @@ def test_save_log_to_file_no_exception(mock_now, command, tmp_path):
         "ANDROID_HOME": "/androidsdk",
     }
 
-    logger = Log(verbosity=LogLevel.DEBUG)
+    printer = Printer()
+    command.tools.input = Console(printer=printer)
+    logger = command.tools.logger = Log(printer=printer, verbosity=LogLevel.DEBUG)
     logger.save_log = True
+
     logger.debug("this is debug output")
     logger.info("this is info output")
     logger.warning("this is warning output")
@@ -190,6 +194,15 @@ def test_save_log_to_file_no_exception(mock_now, command, tmp_path):
         prefix="wibble",
         markup=True,
     )
+
+    with command.tools.input.wait_bar("transient message...", transient=True):
+        pass
+    with command.tools.input.wait_bar("wait message..."):
+        pass
+    with contextlib.suppress(KeyboardInterrupt):
+        with command.tools.input.wait_bar("abort message..."):
+            raise KeyboardInterrupt
+
     logger.save_log_to_file(command=command)
 
     log_filepath = tmp_path / "logs/briefcase.2022_06_25-16_12_29.dev.log"
@@ -215,6 +228,13 @@ def test_save_log_to_file_no_exception(mock_now, command, tmp_path):
     assert "GITHUB_KEY=super-secret-key" not in log_contents
     # Environment variables are sorted
     assert log_contents.index("ANDROID_HOME") < log_contents.index("GITHUB_KEY")
+    # wait bar status messages are included
+    assert "transient message... started" in log_contents
+    assert "transient message... done" in log_contents
+    assert "wait message... started" in log_contents
+    assert "wait message... done" in log_contents
+    assert "abort message... started" in log_contents
+    assert "abort message... aborted" in log_contents
 
     assert TRACEBACK_HEADER not in log_contents
     assert EXTRA_HEADER not in log_contents
diff --git a/tests/console/test_NotDeadYet.py b/tests/console/test_NotDeadYet.py
new file mode 100644
index 00000000..2a545d97
--- /dev/null
+++ b/tests/console/test_NotDeadYet.py
@@ -0,0 +1,46 @@
+from unittest.mock import MagicMock
+
+import briefcase.console
+from briefcase.console import NotDeadYet, Printer
+
+
+def test_update(capsys, monkeypatch):
+    """The message is only printed once for each interval."""
+    # initialization will set interval to 0 + 10
+    # update() will see time either at 5 or 15 and print the message accordingly
+    # then interval is reset back to 0 + 10
+    monkeypatch.setattr(
+        briefcase.console.time,
+        "time",
+        MagicMock(side_effect=[0] + [10 - 5, 10 + 5, 0] * 3),
+    )
+
+    keep_alive = NotDeadYet(printer=Printer())
+
+    for _ in range(3):
+        keep_alive.update()
+        keep_alive.update()
+
+    assert capsys.readouterr().out == (
+        "... still waiting\n... still waiting\n... still waiting\n"
+    )
+
+
+def test_reset(capsys, monkeypatch):
+    """Calling reset always puts the interval in the future and nothing prints."""
+    # initialization will set interval to 0 + 10
+    # the reset updates the interval to 10 + 10
+    # the update sees a time of 15 and doesn't print or reset
+    monkeypatch.setattr(
+        briefcase.console.time,
+        "time",
+        MagicMock(side_effect=[0] + [10, 15] * 10),
+    )
+
+    keep_alive = NotDeadYet(printer=Printer())
+
+    for _ in range(10):
+        keep_alive.reset()
+        keep_alive.update()
+
+    assert capsys.readouterr().out == ""
diff --git a/tests/platforms/iOS/xcode/test_run.py b/tests/platforms/iOS/xcode/test_run.py
index bb789676..eba1b90f 100644
--- a/tests/platforms/iOS/xcode/test_run.py
+++ b/tests/platforms/iOS/xcode/test_run.py
@@ -24,9 +24,6 @@ def run_command(tmp_path):
     command.tools.subprocess = mock.MagicMock(spec_set=Subprocess)
     command._stream_app_logs = mock.MagicMock()
 
-    # Disable sleeps
-    command.sleep = mock.MagicMock(side_effect=lambda x: time.sleep(0))
-
     # To satisfy coverage, the stop function must be invoked
     # at least once when streaming app logs.
     def mock_stream_app_logs(app, stop_func, **kwargs):
@@ -78,6 +75,7 @@ def test_run_multiple_devices_input_disabled(run_command, first_app_config):
         run_command.run_app(first_app_config, test_mode=False, passthrough=[])
 
 
+@pytest.mark.usefixtures("sleep_zero")
 def test_run_app_simulator_booted(run_command, first_app_config, tmp_path):
     """An iOS App can be started when the simulator is already booted."""
     # A valid target device will be selected.
@@ -93,9 +91,19 @@ def test_run_app_simulator_booted(run_command, first_app_config, tmp_path):
         "com.example.first-app: 1234\n"
     )
 
-    # Mock the log stream
+    # Mock the uninstall, install, and log stream Popen processes
+    uninstall_popen = mock.MagicMock(spec_set=subprocess.Popen)
+    uninstall_popen.__enter__.return_value = uninstall_popen
+    uninstall_popen.poll.side_effect = [None, None, 0]
+    install_popen = mock.MagicMock(spec_set=subprocess.Popen)
+    install_popen.__enter__.return_value = install_popen
+    install_popen.poll.side_effect = [None, None, 0]
     log_stream_process = mock.MagicMock(spec_set=subprocess.Popen)
-    run_command.tools.subprocess.Popen.return_value = log_stream_process
+    run_command.tools.subprocess.Popen.side_effect = [
+        uninstall_popen,
+        install_popen,
+        log_stream_process,
+    ]
 
     # Run the app
     run_command.run_app(first_app_config, test_mode=False, passthrough=[])
@@ -115,6 +123,25 @@ def test_run_app_simulator_booted(run_command, first_app_config, tmp_path):
                 ],
                 check=True,
             ),
+        ]
+    )
+    # Launch the new app
+    run_command.tools.subprocess.check_output.assert_called_once_with(
+        [
+            "xcrun",
+            "simctl",
+            "launch",
+            "2D3503A3-6EB9-4B37-9B17-C7EFEF2FA32D",
+            "com.example.first-app",
+        ],
+    )
+
+    # slept 4 times for uninstall/install and 1 time for log stream start
+    assert time.sleep.call_count == 4 + 1
+
+    # Start the uninstall, install, and  log stream
+    run_command.tools.subprocess.Popen.assert_has_calls(
+        [
             # Uninstall the old app
             mock.call(
                 [
@@ -124,7 +151,6 @@ def test_run_app_simulator_booted(run_command, first_app_config, tmp_path):
                     "2D3503A3-6EB9-4B37-9B17-C7EFEF2FA32D",
                     "com.example.first-app",
                 ],
-                check=True,
             ),
             # Install the new app
             mock.call(
@@ -134,51 +160,31 @@ def test_run_app_simulator_booted(run_command, first_app_config, tmp_path):
                     "install",
                     "2D3503A3-6EB9-4B37-9B17-C7EFEF2FA32D",
                     tmp_path
-                    / "base_path"
-                    / "build"
-                    / "first-app"
-                    / "ios"
-                    / "xcode"
-                    / "build"
-                    / "Debug-iphonesimulator"
-                    / "First App.app",
+                    / "base_path/build/first-app/ios/xcode/build/Debug-iphonesimulator/First App.app",
                 ],
-                check=True,
+            ),
+            mock.call(
+                [
+                    "xcrun",
+                    "simctl",
+                    "spawn",
+                    "2D3503A3-6EB9-4B37-9B17-C7EFEF2FA32D",
+                    "log",
+                    "stream",
+                    "--style",
+                    "compact",
+                    "--predicate",
+                    'senderImagePath ENDSWITH "/First App"'
+                    ' OR (processImagePath ENDSWITH "/First App"'
+                    ' AND (senderImagePath ENDSWITH "-iphonesimulator.so"'
+                    ' OR senderImagePath ENDSWITH "-iphonesimulator.dylib"))',
+                ],
+                stdout=subprocess.PIPE,
+                stderr=subprocess.STDOUT,
+                bufsize=1,
             ),
         ]
     )
-    # Launch the new app
-    run_command.tools.subprocess.check_output.assert_called_once_with(
-        [
-            "xcrun",
-            "simctl",
-            "launch",
-            "2D3503A3-6EB9-4B37-9B17-C7EFEF2FA32D",
-            "com.example.first-app",
-        ],
-    )
-
-    # Start the log stream
-    run_command.tools.subprocess.Popen.assert_called_once_with(
-        [
-            "xcrun",
-            "simctl",
-            "spawn",
-            "2D3503A3-6EB9-4B37-9B17-C7EFEF2FA32D",
-            "log",
-            "stream",
-            "--style",
-            "compact",
-            "--predicate",
-            'senderImagePath ENDSWITH "/First App"'
-            ' OR (processImagePath ENDSWITH "/First App"'
-            ' AND (senderImagePath ENDSWITH "-iphonesimulator.so"'
-            ' OR senderImagePath ENDSWITH "-iphonesimulator.dylib"))',
-        ],
-        stdout=subprocess.PIPE,
-        stderr=subprocess.STDOUT,
-        bufsize=1,
-    )
 
     # Log stream monitoring was started
     run_command._stream_app_logs.assert_called_with(
@@ -192,6 +198,7 @@ def test_run_app_simulator_booted(run_command, first_app_config, tmp_path):
     )
 
 
+@pytest.mark.usefixtures("sleep_zero")
 def test_run_app_simulator_booted_underscore(
     run_command,
     underscore_app_config,
@@ -215,13 +222,26 @@ def test_run_app_simulator_booted_underscore(
         "com.example.first-app: 1234\n"
     )
 
-    # Mock the log stream
+    # Mock the uninstall, install, and log stream Popen processes
+    uninstall_popen = mock.MagicMock(spec_set=subprocess.Popen)
+    uninstall_popen.__enter__.return_value = uninstall_popen
+    uninstall_popen.poll.side_effect = [None, None, 0]
+    install_popen = mock.MagicMock(spec_set=subprocess.Popen)
+    install_popen.__enter__.return_value = install_popen
+    install_popen.poll.side_effect = [None, None, 0]
     log_stream_process = mock.MagicMock(spec_set=subprocess.Popen)
-    run_command.tools.subprocess.Popen.return_value = log_stream_process
+    run_command.tools.subprocess.Popen.side_effect = [
+        uninstall_popen,
+        install_popen,
+        log_stream_process,
+    ]
 
     # Run the app
     run_command.run_app(underscore_app_config, test_mode=False, passthrough=[])
 
+    # slept 4 times for uninstall/install and 1 time for log stream start
+    assert time.sleep.call_count == 4 + 1
+
     # The correct sequence of commands was issued.
     run_command.tools.subprocess.run.assert_has_calls(
         [
@@ -237,6 +257,22 @@ def test_run_app_simulator_booted_underscore(
                 ],
                 check=True,
             ),
+        ]
+    )
+    # Launch the new app
+    run_command.tools.subprocess.check_output.assert_called_once_with(
+        [
+            "xcrun",
+            "simctl",
+            "launch",
+            "2D3503A3-6EB9-4B37-9B17-C7EFEF2FA32D",
+            "com.example.first-app",
+        ],
+    )
+
+    # Start the uninstall, install, and  log stream
+    run_command.tools.subprocess.Popen.assert_has_calls(
+        [
             # Uninstall the old app
             mock.call(
                 [
@@ -246,7 +282,6 @@ def test_run_app_simulator_booted_underscore(
                     "2D3503A3-6EB9-4B37-9B17-C7EFEF2FA32D",
                     "com.example.first-app",
                 ],
-                check=True,
             ),
             # Install the new app
             mock.call(
@@ -256,51 +291,31 @@ def test_run_app_simulator_booted_underscore(
                     "install",
                     "2D3503A3-6EB9-4B37-9B17-C7EFEF2FA32D",
                     tmp_path
-                    / "base_path"
-                    / "build"
-                    / "first_app"
-                    / "ios"
-                    / "xcode"
-                    / "build"
-                    / "Debug-iphonesimulator"
-                    / "First App.app",
+                    / "base_path/build/first_app/ios/xcode/build/Debug-iphonesimulator/First App.app",
                 ],
-                check=True,
+            ),
+            mock.call(
+                [
+                    "xcrun",
+                    "simctl",
+                    "spawn",
+                    "2D3503A3-6EB9-4B37-9B17-C7EFEF2FA32D",
+                    "log",
+                    "stream",
+                    "--style",
+                    "compact",
+                    "--predicate",
+                    'senderImagePath ENDSWITH "/First App"'
+                    ' OR (processImagePath ENDSWITH "/First App"'
+                    ' AND (senderImagePath ENDSWITH "-iphonesimulator.so"'
+                    ' OR senderImagePath ENDSWITH "-iphonesimulator.dylib"))',
+                ],
+                stdout=subprocess.PIPE,
+                stderr=subprocess.STDOUT,
+                bufsize=1,
             ),
         ]
     )
-    # Launch the new app
-    run_command.tools.subprocess.check_output.assert_called_once_with(
-        [
-            "xcrun",
-            "simctl",
-            "launch",
-            "2D3503A3-6EB9-4B37-9B17-C7EFEF2FA32D",
-            "com.example.first-app",
-        ],
-    )
-
-    # Start the log stream
-    run_command.tools.subprocess.Popen.assert_called_once_with(
-        [
-            "xcrun",
-            "simctl",
-            "spawn",
-            "2D3503A3-6EB9-4B37-9B17-C7EFEF2FA32D",
-            "log",
-            "stream",
-            "--style",
-            "compact",
-            "--predicate",
-            'senderImagePath ENDSWITH "/First App"'
-            ' OR (processImagePath ENDSWITH "/First App"'
-            ' AND (senderImagePath ENDSWITH "-iphonesimulator.so"'
-            ' OR senderImagePath ENDSWITH "-iphonesimulator.dylib"))',
-        ],
-        stdout=subprocess.PIPE,
-        stderr=subprocess.STDOUT,
-        bufsize=1,
-    )
 
     # Log stream monitoring was started
     run_command._stream_app_logs.assert_called_with(
@@ -314,6 +329,7 @@ def test_run_app_simulator_booted_underscore(
     )
 
 
+@pytest.mark.usefixtures("sleep_zero")
 def test_run_app_with_passthrough(run_command, first_app_config, tmp_path):
     """An iOS App can be started with passthrough args."""
     # A valid target device will be selected.
@@ -329,9 +345,19 @@ def test_run_app_with_passthrough(run_command, first_app_config, tmp_path):
         "com.example.first-app: 1234\n"
     )
 
-    # Mock the log stream
+    # Mock the uninstall, install, and log stream Popen processes
+    uninstall_popen = mock.MagicMock(spec_set=subprocess.Popen)
+    uninstall_popen.__enter__.return_value = uninstall_popen
+    uninstall_popen.poll.side_effect = [None, None, 0]
+    install_popen = mock.MagicMock(spec_set=subprocess.Popen)
+    install_popen.__enter__.return_value = install_popen
+    install_popen.poll.side_effect = [None, None, 0]
     log_stream_process = mock.MagicMock(spec_set=subprocess.Popen)
-    run_command.tools.subprocess.Popen.return_value = log_stream_process
+    run_command.tools.subprocess.Popen.side_effect = [
+        uninstall_popen,
+        install_popen,
+        log_stream_process,
+    ]
 
     # Run the app with passthrough args.
     run_command.run_app(
@@ -340,6 +366,9 @@ def test_run_app_with_passthrough(run_command, first_app_config, tmp_path):
         passthrough=["foo", "--bar"],
     )
 
+    # slept 4 times for uninstall/install and 1 time for log stream start
+    assert time.sleep.call_count == 4 + 1
+
     # The correct sequence of commands was issued.
     run_command.tools.subprocess.run.assert_has_calls(
         [
@@ -355,6 +384,24 @@ def test_run_app_with_passthrough(run_command, first_app_config, tmp_path):
                 ],
                 check=True,
             ),
+        ]
+    )
+    # Launch the new app
+    run_command.tools.subprocess.check_output.assert_called_once_with(
+        [
+            "xcrun",
+            "simctl",
+            "launch",
+            "2D3503A3-6EB9-4B37-9B17-C7EFEF2FA32D",
+            "com.example.first-app",
+            "foo",
+            "--bar",
+        ],
+    )
+
+    # Start the uninstall, install, and  log stream
+    run_command.tools.subprocess.Popen.assert_has_calls(
+        [
             # Uninstall the old app
             mock.call(
                 [
@@ -364,7 +411,6 @@ def test_run_app_with_passthrough(run_command, first_app_config, tmp_path):
                     "2D3503A3-6EB9-4B37-9B17-C7EFEF2FA32D",
                     "com.example.first-app",
                 ],
-                check=True,
             ),
             # Install the new app
             mock.call(
@@ -374,53 +420,31 @@ def test_run_app_with_passthrough(run_command, first_app_config, tmp_path):
                     "install",
                     "2D3503A3-6EB9-4B37-9B17-C7EFEF2FA32D",
                     tmp_path
-                    / "base_path"
-                    / "build"
-                    / "first-app"
-                    / "ios"
-                    / "xcode"
-                    / "build"
-                    / "Debug-iphonesimulator"
-                    / "First App.app",
+                    / "base_path/build/first-app/ios/xcode/build/Debug-iphonesimulator/First App.app",
                 ],
-                check=True,
+            ),
+            mock.call(
+                [
+                    "xcrun",
+                    "simctl",
+                    "spawn",
+                    "2D3503A3-6EB9-4B37-9B17-C7EFEF2FA32D",
+                    "log",
+                    "stream",
+                    "--style",
+                    "compact",
+                    "--predicate",
+                    'senderImagePath ENDSWITH "/First App"'
+                    ' OR (processImagePath ENDSWITH "/First App"'
+                    ' AND (senderImagePath ENDSWITH "-iphonesimulator.so"'
+                    ' OR senderImagePath ENDSWITH "-iphonesimulator.dylib"))',
+                ],
+                stdout=subprocess.PIPE,
+                stderr=subprocess.STDOUT,
+                bufsize=1,
             ),
         ]
     )
-    # Launch the new app
-    run_command.tools.subprocess.check_output.assert_called_once_with(
-        [
-            "xcrun",
-            "simctl",
-            "launch",
-            "2D3503A3-6EB9-4B37-9B17-C7EFEF2FA32D",
-            "com.example.first-app",
-            "foo",
-            "--bar",
-        ],
-    )
-
-    # Start the log stream
-    run_command.tools.subprocess.Popen.assert_called_once_with(
-        [
-            "xcrun",
-            "simctl",
-            "spawn",
-            "2D3503A3-6EB9-4B37-9B17-C7EFEF2FA32D",
-            "log",
-            "stream",
-            "--style",
-            "compact",
-            "--predicate",
-            'senderImagePath ENDSWITH "/First App"'
-            ' OR (processImagePath ENDSWITH "/First App"'
-            ' AND (senderImagePath ENDSWITH "-iphonesimulator.so"'
-            ' OR senderImagePath ENDSWITH "-iphonesimulator.dylib"))',
-        ],
-        stdout=subprocess.PIPE,
-        stderr=subprocess.STDOUT,
-        bufsize=1,
-    )
 
     # Log stream monitoring was started
     run_command._stream_app_logs.assert_called_with(
@@ -434,7 +458,12 @@ def test_run_app_with_passthrough(run_command, first_app_config, tmp_path):
     )
 
 
-def test_run_app_simulator_shut_down(run_command, first_app_config, tmp_path):
+@pytest.mark.usefixtures("sleep_zero")
+def test_run_app_simulator_shut_down(
+    run_command,
+    first_app_config,
+    tmp_path,
+):
     """An iOS App can be started when the simulator is shut down."""
     # A valid target device will be selected.
     run_command.select_target_device = mock.MagicMock(
@@ -451,13 +480,26 @@ def test_run_app_simulator_shut_down(run_command, first_app_config, tmp_path):
         "com.example.first-app: 1234\n"
     )
 
-    # Mock the log stream
+    # Mock the uninstall, install, and log stream Popen processes
+    uninstall_popen = mock.MagicMock(spec_set=subprocess.Popen)
+    uninstall_popen.__enter__.return_value = uninstall_popen
+    uninstall_popen.poll.side_effect = [None, None, 0]
+    install_popen = mock.MagicMock(spec_set=subprocess.Popen)
+    install_popen.__enter__.return_value = install_popen
+    install_popen.poll.side_effect = [None, None, 0]
     log_stream_process = mock.MagicMock(spec_set=subprocess.Popen)
-    run_command.tools.subprocess.Popen.return_value = log_stream_process
+    run_command.tools.subprocess.Popen.side_effect = [
+        uninstall_popen,
+        install_popen,
+        log_stream_process,
+    ]
 
     # Run the app
     run_command.run_app(first_app_config, test_mode=False, passthrough=[])
 
+    # slept 4 times for uninstall/install and 1 time for log stream start
+    assert time.sleep.call_count == 4 + 1
+
     # The correct sequence of commands was issued.
     run_command.tools.subprocess.run.assert_has_calls(
         [
@@ -478,6 +520,23 @@ def test_run_app_simulator_shut_down(run_command, first_app_config, tmp_path):
                 ],
                 check=True,
             ),
+        ]
+    )
+
+    # Launch the new app
+    run_command.tools.subprocess.check_output.assert_called_once_with(
+        [
+            "xcrun",
+            "simctl",
+            "launch",
+            "2D3503A3-6EB9-4B37-9B17-C7EFEF2FA32D",
+            "com.example.first-app",
+        ],
+    )
+
+    # Start the uninstall, install, and  log stream
+    run_command.tools.subprocess.Popen.assert_has_calls(
+        [
             # Uninstall the old app
             mock.call(
                 [
@@ -487,7 +546,6 @@ def test_run_app_simulator_shut_down(run_command, first_app_config, tmp_path):
                     "2D3503A3-6EB9-4B37-9B17-C7EFEF2FA32D",
                     "com.example.first-app",
                 ],
-                check=True,
             ),
             # Install the new app
             mock.call(
@@ -497,53 +555,32 @@ def test_run_app_simulator_shut_down(run_command, first_app_config, tmp_path):
                     "install",
                     "2D3503A3-6EB9-4B37-9B17-C7EFEF2FA32D",
                     tmp_path
-                    / "base_path"
-                    / "build"
-                    / "first-app"
-                    / "ios"
-                    / "xcode"
-                    / "build"
-                    / "Debug-iphonesimulator"
-                    / "First App.app",
+                    / "base_path/build/first-app/ios/xcode/build/Debug-iphonesimulator/First App.app",
                 ],
-                check=True,
+            ),
+            mock.call(
+                [
+                    "xcrun",
+                    "simctl",
+                    "spawn",
+                    "2D3503A3-6EB9-4B37-9B17-C7EFEF2FA32D",
+                    "log",
+                    "stream",
+                    "--style",
+                    "compact",
+                    "--predicate",
+                    'senderImagePath ENDSWITH "/First App"'
+                    ' OR (processImagePath ENDSWITH "/First App"'
+                    ' AND (senderImagePath ENDSWITH "-iphonesimulator.so"'
+                    ' OR senderImagePath ENDSWITH "-iphonesimulator.dylib"))',
+                ],
+                stdout=subprocess.PIPE,
+                stderr=subprocess.STDOUT,
+                bufsize=1,
             ),
         ]
     )
 
-    # Launch the new app
-    run_command.tools.subprocess.check_output.assert_called_once_with(
-        [
-            "xcrun",
-            "simctl",
-            "launch",
-            "2D3503A3-6EB9-4B37-9B17-C7EFEF2FA32D",
-            "com.example.first-app",
-        ],
-    )
-
-    # Start the log stream
-    run_command.tools.subprocess.Popen.assert_called_once_with(
-        [
-            "xcrun",
-            "simctl",
-            "spawn",
-            "2D3503A3-6EB9-4B37-9B17-C7EFEF2FA32D",
-            "log",
-            "stream",
-            "--style",
-            "compact",
-            "--predicate",
-            'senderImagePath ENDSWITH "/First App"'
-            ' OR (processImagePath ENDSWITH "/First App"'
-            ' AND (senderImagePath ENDSWITH "-iphonesimulator.so"'
-            ' OR senderImagePath ENDSWITH "-iphonesimulator.dylib"))',
-        ],
-        stdout=subprocess.PIPE,
-        stderr=subprocess.STDOUT,
-        bufsize=1,
-    )
-
     # Log stream monitoring was started
     run_command._stream_app_logs.assert_called_with(
         first_app_config,
@@ -556,6 +593,7 @@ def test_run_app_simulator_shut_down(run_command, first_app_config, tmp_path):
     )
 
 
+@pytest.mark.usefixtures("sleep_zero")
 def test_run_app_simulator_shutting_down(run_command, first_app_config, tmp_path):
     """An iOS App can be started when the simulator is shutting down."""
     # A valid target device will be selected.
@@ -583,15 +621,25 @@ def test_run_app_simulator_shutting_down(run_command, first_app_config, tmp_path
         "com.example.first-app: 1234\n"
     )
 
-    # Mock the log stream
+    # Mock the uninstall, install, and log stream Popen processes
+    uninstall_popen = mock.MagicMock(spec_set=subprocess.Popen)
+    uninstall_popen.__enter__.return_value = uninstall_popen
+    uninstall_popen.poll.side_effect = [None, None, 0]
+    install_popen = mock.MagicMock(spec_set=subprocess.Popen)
+    install_popen.__enter__.return_value = install_popen
+    install_popen.poll.side_effect = [None, None, 0]
     log_stream_process = mock.MagicMock(spec_set=subprocess.Popen)
-    run_command.tools.subprocess.Popen.return_value = log_stream_process
+    run_command.tools.subprocess.Popen.side_effect = [
+        uninstall_popen,
+        install_popen,
+        log_stream_process,
+    ]
 
     # Run the app
     run_command.run_app(first_app_config, test_mode=False, passthrough=[])
 
-    # We should have slept 4 times
-    assert run_command.sleep.call_count == 4
+    # We should have slept 4 times for shutting down and 4 time for uninstall/install
+    assert time.sleep.call_count == 4 + 4
 
     # The correct sequence of commands was issued.
     run_command.tools.subprocess.run.assert_has_calls(
@@ -613,6 +661,23 @@ def test_run_app_simulator_shutting_down(run_command, first_app_config, tmp_path
                 ],
                 check=True,
             ),
+        ]
+    )
+
+    # Launch the new app
+    run_command.tools.subprocess.check_output.assert_called_once_with(
+        [
+            "xcrun",
+            "simctl",
+            "launch",
+            "2D3503A3-6EB9-4B37-9B17-C7EFEF2FA32D",
+            "com.example.first-app",
+        ],
+    )
+
+    # Start the uninstall, install, and  log stream
+    run_command.tools.subprocess.Popen.assert_has_calls(
+        [
             # Uninstall the old app
             mock.call(
                 [
@@ -622,7 +687,6 @@ def test_run_app_simulator_shutting_down(run_command, first_app_config, tmp_path
                     "2D3503A3-6EB9-4B37-9B17-C7EFEF2FA32D",
                     "com.example.first-app",
                 ],
-                check=True,
             ),
             # Install the new app
             mock.call(
@@ -632,53 +696,32 @@ def test_run_app_simulator_shutting_down(run_command, first_app_config, tmp_path
                     "install",
                     "2D3503A3-6EB9-4B37-9B17-C7EFEF2FA32D",
                     tmp_path
-                    / "base_path"
-                    / "build"
-                    / "first-app"
-                    / "ios"
-                    / "xcode"
-                    / "build"
-                    / "Debug-iphonesimulator"
-                    / "First App.app",
+                    / "base_path/build/first-app/ios/xcode/build/Debug-iphonesimulator/First App.app",
                 ],
-                check=True,
+            ),
+            mock.call(
+                [
+                    "xcrun",
+                    "simctl",
+                    "spawn",
+                    "2D3503A3-6EB9-4B37-9B17-C7EFEF2FA32D",
+                    "log",
+                    "stream",
+                    "--style",
+                    "compact",
+                    "--predicate",
+                    'senderImagePath ENDSWITH "/First App"'
+                    ' OR (processImagePath ENDSWITH "/First App"'
+                    ' AND (senderImagePath ENDSWITH "-iphonesimulator.so"'
+                    ' OR senderImagePath ENDSWITH "-iphonesimulator.dylib"))',
+                ],
+                stdout=subprocess.PIPE,
+                stderr=subprocess.STDOUT,
+                bufsize=1,
             ),
         ]
     )
 
-    # Launch the new app
-    run_command.tools.subprocess.check_output.assert_called_once_with(
-        [
-            "xcrun",
-            "simctl",
-            "launch",
-            "2D3503A3-6EB9-4B37-9B17-C7EFEF2FA32D",
-            "com.example.first-app",
-        ],
-    )
-
-    # Start the log stream
-    run_command.tools.subprocess.Popen.assert_called_once_with(
-        [
-            "xcrun",
-            "simctl",
-            "spawn",
-            "2D3503A3-6EB9-4B37-9B17-C7EFEF2FA32D",
-            "log",
-            "stream",
-            "--style",
-            "compact",
-            "--predicate",
-            'senderImagePath ENDSWITH "/First App"'
-            ' OR (processImagePath ENDSWITH "/First App"'
-            ' AND (senderImagePath ENDSWITH "-iphonesimulator.so"'
-            ' OR senderImagePath ENDSWITH "-iphonesimulator.dylib"))',
-        ],
-        stdout=subprocess.PIPE,
-        stderr=subprocess.STDOUT,
-        bufsize=1,
-    )
-
     # Log stream monitoring was started
     run_command._stream_app_logs.assert_called_with(
         first_app_config,
@@ -691,6 +734,7 @@ def test_run_app_simulator_shutting_down(run_command, first_app_config, tmp_path
     )
 
 
+@pytest.mark.usefixtures("sleep_zero")
 def test_run_app_simulator_boot_failure(run_command, first_app_config):
     """If the simulator fails to boot, raise an error."""
     # A valid target device will be selected.
@@ -710,6 +754,9 @@ def test_run_app_simulator_boot_failure(run_command, first_app_config):
     with pytest.raises(BriefcaseCommandError):
         run_command.run_app(first_app_config, test_mode=False, passthrough=[])
 
+    # No sleeps
+    assert time.sleep.call_count == 0
+
     # The correct sequence of commands was issued.
     run_command.tools.subprocess.run.assert_has_calls(
         [
@@ -726,6 +773,7 @@ def test_run_app_simulator_boot_failure(run_command, first_app_config):
     run_command._stream_app_logs.assert_not_called()
 
 
+@pytest.mark.usefixtures("sleep_zero")
 def test_run_app_simulator_open_failure(run_command, first_app_config):
     """If the simulator can't be opened, raise an error."""
     # A valid target device will be selected.
@@ -750,6 +798,9 @@ def test_run_app_simulator_open_failure(run_command, first_app_config):
     with pytest.raises(BriefcaseCommandError):
         run_command.run_app(first_app_config, test_mode=False, passthrough=[])
 
+    # No sleeps
+    assert time.sleep.call_count == 0
+
     # The correct sequence of commands was issued.
     run_command.tools.subprocess.run.assert_has_calls(
         [
@@ -777,6 +828,7 @@ def test_run_app_simulator_open_failure(run_command, first_app_config):
     run_command._stream_app_logs.assert_not_called()
 
 
+@pytest.mark.usefixtures("sleep_zero")
 def test_run_app_simulator_uninstall_failure(run_command, first_app_config):
     """If the old app can't be uninstalled, raise an error."""
     # A valid target device will be selected.
@@ -787,20 +839,19 @@ def test_run_app_simulator_uninstall_failure(run_command, first_app_config):
     # Simulator is shut down
     run_command.get_device_state = mock.MagicMock(return_value=DeviceState.SHUTDOWN)
 
-    # Call to boot and open simulator succeed, but uninstall fails.
-    run_command.tools.subprocess = mock.MagicMock(spec_set=Subprocess)
-    run_command.tools.subprocess.run.side_effect = [
-        0,
-        0,
-        subprocess.CalledProcessError(
-            cmd=["xcrun", "simctl", "uninstall", "..."], returncode=1
-        ),
-    ]
+    # Call uninstall fails
+    uninstall_popen = mock.MagicMock(spec_set=subprocess.Popen)
+    uninstall_popen.__enter__.return_value = uninstall_popen
+    uninstall_popen.poll.side_effect = [None, None, 1]
+    run_command.tools.subprocess.Popen.side_effect = [uninstall_popen]
 
     # Run the app
     with pytest.raises(BriefcaseCommandError):
         run_command.run_app(first_app_config, test_mode=False, passthrough=[])
 
+    # Sleep twice for uninstall failure
+    assert time.sleep.call_count == 2
+
     # The correct sequence of commands was issued.
     run_command.tools.subprocess.run.assert_has_calls(
         [
@@ -821,6 +872,11 @@ def test_run_app_simulator_uninstall_failure(run_command, first_app_config):
                 ],
                 check=True,
             ),
+        ]
+    )
+    # Start the uninstall
+    run_command.tools.subprocess.Popen.assert_has_calls(
+        [
             # Uninstall the old app
             mock.call(
                 [
@@ -830,15 +886,15 @@ def test_run_app_simulator_uninstall_failure(run_command, first_app_config):
                     "2D3503A3-6EB9-4B37-9B17-C7EFEF2FA32D",
                     "com.example.first-app",
                 ],
-                check=True,
             ),
         ]
     )
+
     # The log will not be tailed
-    run_command.tools.subprocess.Popen.assert_not_called()
     run_command._stream_app_logs.assert_not_called()
 
 
+@pytest.mark.usefixtures("sleep_zero")
 def test_run_app_simulator_install_failure(run_command, first_app_config, tmp_path):
     """If the app fails to install in the simulator, raise an error."""
     # A valid target device will be selected.
@@ -849,21 +905,25 @@ def test_run_app_simulator_install_failure(run_command, first_app_config, tmp_pa
     # Simulator is shut down
     run_command.get_device_state = mock.MagicMock(return_value=DeviceState.SHUTDOWN)
 
-    # Call to boot and open simulator, and uninstall succeed, but install fails.
-    run_command.tools.subprocess = mock.MagicMock(spec_set=Subprocess)
-    run_command.tools.subprocess.run.side_effect = [
-        0,
-        0,
-        0,
-        subprocess.CalledProcessError(
-            cmd=["xcrun", "simctl", "uninstall", "..."], returncode=1
-        ),
+    # Call to install fails
+    uninstall_popen = mock.MagicMock(spec_set=subprocess.Popen)
+    uninstall_popen.__enter__.return_value = uninstall_popen
+    uninstall_popen.poll.side_effect = [None, None, 0]
+    install_popen = mock.MagicMock(spec_set=subprocess.Popen)
+    install_popen.__enter__.return_value = install_popen
+    install_popen.poll.side_effect = [None, None, 1]
+    run_command.tools.subprocess.Popen.side_effect = [
+        uninstall_popen,
+        install_popen,
     ]
 
     # Run the app
     with pytest.raises(BriefcaseCommandError):
         run_command.run_app(first_app_config, test_mode=False, passthrough=[])
 
+    # Sleep twice for uninstall and twice for install failure
+    assert time.sleep.call_count == 4
+
     # The correct sequence of commands was issued.
     run_command.tools.subprocess.run.assert_has_calls(
         [
@@ -884,6 +944,12 @@ def test_run_app_simulator_install_failure(run_command, first_app_config, tmp_pa
                 ],
                 check=True,
             ),
+        ]
+    )
+
+    # Start the uninstall and install
+    run_command.tools.subprocess.Popen.assert_has_calls(
+        [
             # Uninstall the old app
             mock.call(
                 [
@@ -893,7 +959,6 @@ def test_run_app_simulator_install_failure(run_command, first_app_config, tmp_pa
                     "2D3503A3-6EB9-4B37-9B17-C7EFEF2FA32D",
                     "com.example.first-app",
                 ],
-                check=True,
             ),
             # Install the new app
             mock.call(
@@ -903,24 +968,17 @@ def test_run_app_simulator_install_failure(run_command, first_app_config, tmp_pa
                     "install",
                     "2D3503A3-6EB9-4B37-9B17-C7EFEF2FA32D",
                     tmp_path
-                    / "base_path"
-                    / "build"
-                    / "first-app"
-                    / "ios"
-                    / "xcode"
-                    / "build"
-                    / "Debug-iphonesimulator"
-                    / "First App.app",
+                    / "base_path/build/first-app/ios/xcode/build/Debug-iphonesimulator/First App.app",
                 ],
-                check=True,
             ),
         ]
     )
+
     # The log will not be tailed
-    run_command.tools.subprocess.Popen.assert_not_called()
     run_command._stream_app_logs.assert_not_called()
 
 
+@pytest.mark.usefixtures("sleep_zero")
 def test_run_app_simulator_launch_failure(run_command, first_app_config, tmp_path):
     """If the app fails to launch, raise an error."""
     # A valid target device will be selected.
@@ -931,15 +989,6 @@ def test_run_app_simulator_launch_failure(run_command, first_app_config, tmp_pat
     # Simulator is shut down
     run_command.get_device_state = mock.MagicMock(return_value=DeviceState.SHUTDOWN)
 
-    # Call to boot and open simulator, uninstall and install succeed.
-    run_command.tools.subprocess = mock.MagicMock(spec_set=Subprocess)
-    run_command.tools.subprocess.run.side_effect = [
-        0,
-        0,
-        0,
-        0,
-    ]
-
     # Mock a process ID for the app
     run_command.tools.subprocess.check_output.side_effect = (
         subprocess.CalledProcessError(
@@ -947,14 +996,27 @@ def test_run_app_simulator_launch_failure(run_command, first_app_config, tmp_pat
         )
     )
 
-    # Mock the log stream
-    log_stream_process = mock.MagicMock()
-    run_command.tools.subprocess.Popen.return_value = log_stream_process
+    # Mock the uninstall, install, and log stream Popen processes
+    uninstall_popen = mock.MagicMock(spec_set=subprocess.Popen)
+    uninstall_popen.__enter__.return_value = uninstall_popen
+    uninstall_popen.poll.side_effect = [None, None, 0]
+    install_popen = mock.MagicMock(spec_set=subprocess.Popen)
+    install_popen.__enter__.return_value = install_popen
+    install_popen.poll.side_effect = [None, None, 0]
+    log_stream_process = mock.MagicMock(spec_set=subprocess.Popen)
+    run_command.tools.subprocess.Popen.side_effect = [
+        uninstall_popen,
+        install_popen,
+        log_stream_process,
+    ]
 
     # Run the app
     with pytest.raises(BriefcaseCommandError):
         run_command.run_app(first_app_config, test_mode=False, passthrough=[])
 
+    # Sleep four times for uninstall/install and once for log stream start
+    assert time.sleep.call_count == 4 + 1
+
     # The correct sequence of commands was issued.
     run_command.tools.subprocess.run.assert_has_calls(
         [
@@ -975,6 +1037,23 @@ def test_run_app_simulator_launch_failure(run_command, first_app_config, tmp_pat
                 ],
                 check=True,
             ),
+        ]
+    )
+
+    # Launch the new app
+    run_command.tools.subprocess.check_output.assert_called_once_with(
+        [
+            "xcrun",
+            "simctl",
+            "launch",
+            "2D3503A3-6EB9-4B37-9B17-C7EFEF2FA32D",
+            "com.example.first-app",
+        ],
+    )
+
+    # Start the uninstall, install, and  log stream
+    run_command.tools.subprocess.Popen.assert_has_calls(
+        [
             # Uninstall the old app
             mock.call(
                 [
@@ -984,7 +1063,6 @@ def test_run_app_simulator_launch_failure(run_command, first_app_config, tmp_pat
                     "2D3503A3-6EB9-4B37-9B17-C7EFEF2FA32D",
                     "com.example.first-app",
                 ],
-                check=True,
             ),
             # Install the new app
             mock.call(
@@ -994,57 +1072,37 @@ def test_run_app_simulator_launch_failure(run_command, first_app_config, tmp_pat
                     "install",
                     "2D3503A3-6EB9-4B37-9B17-C7EFEF2FA32D",
                     tmp_path
-                    / "base_path"
-                    / "build"
-                    / "first-app"
-                    / "ios"
-                    / "xcode"
-                    / "build"
-                    / "Debug-iphonesimulator"
-                    / "First App.app",
+                    / "base_path/build/first-app/ios/xcode/build/Debug-iphonesimulator/First App.app",
                 ],
-                check=True,
+            ),
+            mock.call(
+                [
+                    "xcrun",
+                    "simctl",
+                    "spawn",
+                    "2D3503A3-6EB9-4B37-9B17-C7EFEF2FA32D",
+                    "log",
+                    "stream",
+                    "--style",
+                    "compact",
+                    "--predicate",
+                    'senderImagePath ENDSWITH "/First App"'
+                    ' OR (processImagePath ENDSWITH "/First App"'
+                    ' AND (senderImagePath ENDSWITH "-iphonesimulator.so"'
+                    ' OR senderImagePath ENDSWITH "-iphonesimulator.dylib"))',
+                ],
+                stdout=subprocess.PIPE,
+                stderr=subprocess.STDOUT,
+                bufsize=1,
             ),
         ]
     )
 
-    # Launch the new app
-    run_command.tools.subprocess.check_output.assert_called_once_with(
-        [
-            "xcrun",
-            "simctl",
-            "launch",
-            "2D3503A3-6EB9-4B37-9B17-C7EFEF2FA32D",
-            "com.example.first-app",
-        ],
-    )
-
-    # Start the log stream
-    run_command.tools.subprocess.Popen.assert_called_once_with(
-        [
-            "xcrun",
-            "simctl",
-            "spawn",
-            "2D3503A3-6EB9-4B37-9B17-C7EFEF2FA32D",
-            "log",
-            "stream",
-            "--style",
-            "compact",
-            "--predicate",
-            'senderImagePath ENDSWITH "/First App"'
-            ' OR (processImagePath ENDSWITH "/First App"'
-            ' AND (senderImagePath ENDSWITH "-iphonesimulator.so"'
-            ' OR senderImagePath ENDSWITH "-iphonesimulator.dylib"))',
-        ],
-        stdout=subprocess.PIPE,
-        stderr=subprocess.STDOUT,
-        bufsize=1,
-    )
-
     # Log stream failed, so it won't be monitored
     run_command._stream_app_logs.assert_not_called()
 
 
+@pytest.mark.usefixtures("sleep_zero")
 def test_run_app_simulator_no_pid(run_command, first_app_config, tmp_path):
     """If the app fails to provide a meaningful PID on launch, raise an error."""
     # A valid target device will be selected.
@@ -1055,26 +1113,30 @@ def test_run_app_simulator_no_pid(run_command, first_app_config, tmp_path):
     # Simulator is shut down
     run_command.get_device_state = mock.MagicMock(return_value=DeviceState.SHUTDOWN)
 
-    # Call to boot and open simulator, uninstall and install succeed.
-    run_command.tools.subprocess = mock.MagicMock(spec_set=Subprocess)
-    run_command.tools.subprocess.run.side_effect = [
-        0,
-        0,
-        0,
-        0,
-    ]
-
     # Mock a bad return value for the PID
     run_command.tools.subprocess.check_output.return_value = "No PID returned"
 
-    # Mock the log stream
-    log_stream_process = mock.MagicMock()
-    run_command.tools.subprocess.Popen.return_value = log_stream_process
+    # Mock the uninstall, install, and log stream Popen processes
+    uninstall_popen = mock.MagicMock(spec_set=subprocess.Popen)
+    uninstall_popen.__enter__.return_value = uninstall_popen
+    uninstall_popen.poll.side_effect = [None, None, 0]
+    install_popen = mock.MagicMock(spec_set=subprocess.Popen)
+    install_popen.__enter__.return_value = install_popen
+    install_popen.poll.side_effect = [None, None, 0]
+    log_stream_process = mock.MagicMock(spec_set=subprocess.Popen)
+    run_command.tools.subprocess.Popen.side_effect = [
+        uninstall_popen,
+        install_popen,
+        log_stream_process,
+    ]
 
     # Run the app
     with pytest.raises(BriefcaseCommandError):
         run_command.run_app(first_app_config, test_mode=False, passthrough=[])
 
+    # Sleep four times for uninstall/install and once for log stream start
+    assert time.sleep.call_count == 4 + 1
+
     # The correct sequence of commands was issued.
     run_command.tools.subprocess.run.assert_has_calls(
         [
@@ -1095,6 +1157,23 @@ def test_run_app_simulator_no_pid(run_command, first_app_config, tmp_path):
                 ],
                 check=True,
             ),
+        ]
+    )
+
+    # Launch the new app
+    run_command.tools.subprocess.check_output.assert_called_once_with(
+        [
+            "xcrun",
+            "simctl",
+            "launch",
+            "2D3503A3-6EB9-4B37-9B17-C7EFEF2FA32D",
+            "com.example.first-app",
+        ],
+    )
+
+    # Start the uninstall, install, and  log stream
+    run_command.tools.subprocess.Popen.assert_has_calls(
+        [
             # Uninstall the old app
             mock.call(
                 [
@@ -1104,7 +1183,6 @@ def test_run_app_simulator_no_pid(run_command, first_app_config, tmp_path):
                     "2D3503A3-6EB9-4B37-9B17-C7EFEF2FA32D",
                     "com.example.first-app",
                 ],
-                check=True,
             ),
             # Install the new app
             mock.call(
@@ -1114,57 +1192,37 @@ def test_run_app_simulator_no_pid(run_command, first_app_config, tmp_path):
                     "install",
                     "2D3503A3-6EB9-4B37-9B17-C7EFEF2FA32D",
                     tmp_path
-                    / "base_path"
-                    / "build"
-                    / "first-app"
-                    / "ios"
-                    / "xcode"
-                    / "build"
-                    / "Debug-iphonesimulator"
-                    / "First App.app",
+                    / "base_path/build/first-app/ios/xcode/build/Debug-iphonesimulator/First App.app",
                 ],
-                check=True,
+            ),
+            mock.call(
+                [
+                    "xcrun",
+                    "simctl",
+                    "spawn",
+                    "2D3503A3-6EB9-4B37-9B17-C7EFEF2FA32D",
+                    "log",
+                    "stream",
+                    "--style",
+                    "compact",
+                    "--predicate",
+                    'senderImagePath ENDSWITH "/First App"'
+                    ' OR (processImagePath ENDSWITH "/First App"'
+                    ' AND (senderImagePath ENDSWITH "-iphonesimulator.so"'
+                    ' OR senderImagePath ENDSWITH "-iphonesimulator.dylib"))',
+                ],
+                stdout=subprocess.PIPE,
+                stderr=subprocess.STDOUT,
+                bufsize=1,
             ),
         ]
     )
 
-    # Launch the new app
-    run_command.tools.subprocess.check_output.assert_called_once_with(
-        [
-            "xcrun",
-            "simctl",
-            "launch",
-            "2D3503A3-6EB9-4B37-9B17-C7EFEF2FA32D",
-            "com.example.first-app",
-        ],
-    )
-
-    # Start the log stream
-    run_command.tools.subprocess.Popen.assert_called_once_with(
-        [
-            "xcrun",
-            "simctl",
-            "spawn",
-            "2D3503A3-6EB9-4B37-9B17-C7EFEF2FA32D",
-            "log",
-            "stream",
-            "--style",
-            "compact",
-            "--predicate",
-            'senderImagePath ENDSWITH "/First App"'
-            ' OR (processImagePath ENDSWITH "/First App"'
-            ' AND (senderImagePath ENDSWITH "-iphonesimulator.so"'
-            ' OR senderImagePath ENDSWITH "-iphonesimulator.dylib"))',
-        ],
-        stdout=subprocess.PIPE,
-        stderr=subprocess.STDOUT,
-        bufsize=1,
-    )
-
     # PID detection failed, so it won't be monitored
     run_command._stream_app_logs.assert_not_called()
 
 
+@pytest.mark.usefixtures("sleep_zero")
 def test_run_app_simulator_non_integer_pid(run_command, first_app_config, tmp_path):
     """If the PID returned isn't an integer, raise an error."""
     # A valid target device will be selected.
@@ -1175,28 +1233,32 @@ def test_run_app_simulator_non_integer_pid(run_command, first_app_config, tmp_pa
     # Simulator is shut down
     run_command.get_device_state = mock.MagicMock(return_value=DeviceState.SHUTDOWN)
 
-    # Call to boot and open simulator, uninstall and install succeed.
-    run_command.tools.subprocess = mock.MagicMock(spec_set=Subprocess)
-    run_command.tools.subprocess.run.side_effect = [
-        0,
-        0,
-        0,
-        0,
-    ]
-
     # Mock a bad process ID for the app
     run_command.tools.subprocess.check_output.return_value = (
         "com.example.first-app: NOT A PID\n"
     )
 
-    # Mock the log stream
-    log_stream_process = mock.MagicMock()
-    run_command.tools.subprocess.Popen.return_value = log_stream_process
+    # Mock the uninstall, install, and log stream Popen processes
+    uninstall_popen = mock.MagicMock(spec_set=subprocess.Popen)
+    uninstall_popen.__enter__.return_value = uninstall_popen
+    uninstall_popen.poll.side_effect = [None, None, 0]
+    install_popen = mock.MagicMock(spec_set=subprocess.Popen)
+    install_popen.__enter__.return_value = install_popen
+    install_popen.poll.side_effect = [None, None, 0]
+    log_stream_process = mock.MagicMock(spec_set=subprocess.Popen)
+    run_command.tools.subprocess.Popen.side_effect = [
+        uninstall_popen,
+        install_popen,
+        log_stream_process,
+    ]
 
     # Run the app
     with pytest.raises(BriefcaseCommandError):
         run_command.run_app(first_app_config, test_mode=False, passthrough=[])
 
+    # Sleep four times for uninstall/install and once for log stream start
+    assert time.sleep.call_count == 4 + 1
+
     # The correct sequence of commands was issued.
     run_command.tools.subprocess.run.assert_has_calls(
         [
@@ -1217,6 +1279,23 @@ def test_run_app_simulator_non_integer_pid(run_command, first_app_config, tmp_pa
                 ],
                 check=True,
             ),
+        ]
+    )
+
+    # Launch the new app
+    run_command.tools.subprocess.check_output.assert_called_once_with(
+        [
+            "xcrun",
+            "simctl",
+            "launch",
+            "2D3503A3-6EB9-4B37-9B17-C7EFEF2FA32D",
+            "com.example.first-app",
+        ],
+    )
+
+    # Start the uninstall, install, and  log stream
+    run_command.tools.subprocess.Popen.assert_has_calls(
+        [
             # Uninstall the old app
             mock.call(
                 [
@@ -1226,7 +1305,6 @@ def test_run_app_simulator_non_integer_pid(run_command, first_app_config, tmp_pa
                     "2D3503A3-6EB9-4B37-9B17-C7EFEF2FA32D",
                     "com.example.first-app",
                 ],
-                check=True,
             ),
             # Install the new app
             mock.call(
@@ -1236,57 +1314,37 @@ def test_run_app_simulator_non_integer_pid(run_command, first_app_config, tmp_pa
                     "install",
                     "2D3503A3-6EB9-4B37-9B17-C7EFEF2FA32D",
                     tmp_path
-                    / "base_path"
-                    / "build"
-                    / "first-app"
-                    / "ios"
-                    / "xcode"
-                    / "build"
-                    / "Debug-iphonesimulator"
-                    / "First App.app",
+                    / "base_path/build/first-app/ios/xcode/build/Debug-iphonesimulator/First App.app",
                 ],
-                check=True,
+            ),
+            mock.call(
+                [
+                    "xcrun",
+                    "simctl",
+                    "spawn",
+                    "2D3503A3-6EB9-4B37-9B17-C7EFEF2FA32D",
+                    "log",
+                    "stream",
+                    "--style",
+                    "compact",
+                    "--predicate",
+                    'senderImagePath ENDSWITH "/First App"'
+                    ' OR (processImagePath ENDSWITH "/First App"'
+                    ' AND (senderImagePath ENDSWITH "-iphonesimulator.so"'
+                    ' OR senderImagePath ENDSWITH "-iphonesimulator.dylib"))',
+                ],
+                stdout=subprocess.PIPE,
+                stderr=subprocess.STDOUT,
+                bufsize=1,
             ),
         ]
     )
 
-    # Launch the new app
-    run_command.tools.subprocess.check_output.assert_called_once_with(
-        [
-            "xcrun",
-            "simctl",
-            "launch",
-            "2D3503A3-6EB9-4B37-9B17-C7EFEF2FA32D",
-            "com.example.first-app",
-        ],
-    )
-
-    # Start the log stream
-    run_command.tools.subprocess.Popen.assert_called_once_with(
-        [
-            "xcrun",
-            "simctl",
-            "spawn",
-            "2D3503A3-6EB9-4B37-9B17-C7EFEF2FA32D",
-            "log",
-            "stream",
-            "--style",
-            "compact",
-            "--predicate",
-            'senderImagePath ENDSWITH "/First App"'
-            ' OR (processImagePath ENDSWITH "/First App"'
-            ' AND (senderImagePath ENDSWITH "-iphonesimulator.so"'
-            ' OR senderImagePath ENDSWITH "-iphonesimulator.dylib"))',
-        ],
-        stdout=subprocess.PIPE,
-        stderr=subprocess.STDOUT,
-        bufsize=1,
-    )
-
     # PID detection failed, so it won't be called
     run_command._stream_app_logs.assert_not_called()
 
 
+@pytest.mark.usefixtures("sleep_zero")
 def test_run_app_test_mode(run_command, first_app_config, tmp_path):
     """An iOS App can be started in test mode."""
     # A valid target device will be selected.
@@ -1302,17 +1360,43 @@ def test_run_app_test_mode(run_command, first_app_config, tmp_path):
         "com.example.first-app: 1234\n"
     )
 
-    # Mock the log stream
+    # Mock the uninstall, install, and log stream Popen processes
+    uninstall_popen = mock.MagicMock(spec_set=subprocess.Popen)
+    uninstall_popen.__enter__.return_value = uninstall_popen
+    uninstall_popen.poll.side_effect = [None, None, 0]
+    install_popen = mock.MagicMock(spec_set=subprocess.Popen)
+    install_popen.__enter__.return_value = install_popen
+    install_popen.poll.side_effect = [None, None, 0]
     log_stream_process = mock.MagicMock(spec_set=subprocess.Popen)
-    run_command.tools.subprocess.Popen.return_value = log_stream_process
+    run_command.tools.subprocess.Popen.side_effect = [
+        uninstall_popen,
+        install_popen,
+        log_stream_process,
+    ]
 
     # Run the app
     run_command.run_app(first_app_config, test_mode=True, passthrough=[])
 
-    # The correct sequence of commands was issued.
-    run_command.tools.subprocess.run.assert_has_calls(
+    # Sleep four times for uninstall/install and once for log stream start
+    assert time.sleep.call_count == 4 + 1
+
+    # Open command was not called
+    run_command.tools.subprocess.run.assert_not_called()
+
+    # Launch the new app
+    run_command.tools.subprocess.check_output.assert_called_once_with(
+        [
+            "xcrun",
+            "simctl",
+            "launch",
+            "2D3503A3-6EB9-4B37-9B17-C7EFEF2FA32D",
+            "com.example.first-app",
+        ],
+    )
+
+    # Start the uninstall, install, and  log stream
+    run_command.tools.subprocess.Popen.assert_has_calls(
         [
-            # Simulator doesn't need to be opened.
             # Uninstall the old app
             mock.call(
                 [
@@ -1322,7 +1406,6 @@ def test_run_app_test_mode(run_command, first_app_config, tmp_path):
                     "2D3503A3-6EB9-4B37-9B17-C7EFEF2FA32D",
                     "com.example.first-app",
                 ],
-                check=True,
             ),
             # Install the new app
             mock.call(
@@ -1332,53 +1415,32 @@ def test_run_app_test_mode(run_command, first_app_config, tmp_path):
                     "install",
                     "2D3503A3-6EB9-4B37-9B17-C7EFEF2FA32D",
                     tmp_path
-                    / "base_path"
-                    / "build"
-                    / "first-app"
-                    / "ios"
-                    / "xcode"
-                    / "build"
-                    / "Debug-iphonesimulator"
-                    / "First App.app",
+                    / "base_path/build/first-app/ios/xcode/build/Debug-iphonesimulator/First App.app",
                 ],
-                check=True,
+            ),
+            mock.call(
+                [
+                    "xcrun",
+                    "simctl",
+                    "spawn",
+                    "2D3503A3-6EB9-4B37-9B17-C7EFEF2FA32D",
+                    "log",
+                    "stream",
+                    "--style",
+                    "compact",
+                    "--predicate",
+                    'senderImagePath ENDSWITH "/First App"'
+                    ' OR (processImagePath ENDSWITH "/First App"'
+                    ' AND (senderImagePath ENDSWITH "-iphonesimulator.so"'
+                    ' OR senderImagePath ENDSWITH "-iphonesimulator.dylib"))',
+                ],
+                stdout=subprocess.PIPE,
+                stderr=subprocess.STDOUT,
+                bufsize=1,
             ),
         ]
     )
 
-    # Launch the new app
-    run_command.tools.subprocess.check_output.assert_called_once_with(
-        [
-            "xcrun",
-            "simctl",
-            "launch",
-            "2D3503A3-6EB9-4B37-9B17-C7EFEF2FA32D",
-            "com.example.first-app",
-        ],
-    )
-
-    # Start the log stream
-    run_command.tools.subprocess.Popen.assert_called_once_with(
-        [
-            "xcrun",
-            "simctl",
-            "spawn",
-            "2D3503A3-6EB9-4B37-9B17-C7EFEF2FA32D",
-            "log",
-            "stream",
-            "--style",
-            "compact",
-            "--predicate",
-            'senderImagePath ENDSWITH "/First App"'
-            ' OR (processImagePath ENDSWITH "/First App"'
-            ' AND (senderImagePath ENDSWITH "-iphonesimulator.so"'
-            ' OR senderImagePath ENDSWITH "-iphonesimulator.dylib"))',
-        ],
-        stdout=subprocess.PIPE,
-        stderr=subprocess.STDOUT,
-        bufsize=1,
-    )
-
     # Log stream monitoring was started
     run_command._stream_app_logs.assert_called_with(
         first_app_config,
@@ -1391,6 +1453,7 @@ def test_run_app_test_mode(run_command, first_app_config, tmp_path):
     )
 
 
+@pytest.mark.usefixtures("sleep_zero")
 def test_run_app_test_mode_with_passthrough(run_command, first_app_config, tmp_path):
     """An iOS App can be started in test mode with passthrough args."""
     # A valid target device will be selected.
@@ -1406,9 +1469,19 @@ def test_run_app_test_mode_with_passthrough(run_command, first_app_config, tmp_p
         "com.example.first-app: 1234\n"
     )
 
-    # Mock the log stream
+    # Mock the uninstall, install, and log stream Popen processes
+    uninstall_popen = mock.MagicMock(spec_set=subprocess.Popen)
+    uninstall_popen.__enter__.return_value = uninstall_popen
+    uninstall_popen.poll.side_effect = [None, None, 0]
+    install_popen = mock.MagicMock(spec_set=subprocess.Popen)
+    install_popen.__enter__.return_value = install_popen
+    install_popen.poll.side_effect = [None, None, 0]
     log_stream_process = mock.MagicMock(spec_set=subprocess.Popen)
-    run_command.tools.subprocess.Popen.return_value = log_stream_process
+    run_command.tools.subprocess.Popen.side_effect = [
+        uninstall_popen,
+        install_popen,
+        log_stream_process,
+    ]
 
     # Run the app with args.
     run_command.run_app(
@@ -1417,10 +1490,28 @@ def test_run_app_test_mode_with_passthrough(run_command, first_app_config, tmp_p
         passthrough=["foo", "--bar"],
     )
 
-    # The correct sequence of commands was issued.
-    run_command.tools.subprocess.run.assert_has_calls(
+    # Sleep four times for uninstall/install and once for log stream start
+    assert time.sleep.call_count == 4 + 1
+
+    # Open command was not called
+    run_command.tools.subprocess.run.assert_not_called()
+
+    # Launch the new app
+    run_command.tools.subprocess.check_output.assert_called_once_with(
+        [
+            "xcrun",
+            "simctl",
+            "launch",
+            "2D3503A3-6EB9-4B37-9B17-C7EFEF2FA32D",
+            "com.example.first-app",
+            "foo",
+            "--bar",
+        ],
+    )
+
+    # Start the uninstall, install, and  log stream
+    run_command.tools.subprocess.Popen.assert_has_calls(
         [
-            # Simulator doesn't need to be opened.
             # Uninstall the old app
             mock.call(
                 [
@@ -1430,7 +1521,6 @@ def test_run_app_test_mode_with_passthrough(run_command, first_app_config, tmp_p
                     "2D3503A3-6EB9-4B37-9B17-C7EFEF2FA32D",
                     "com.example.first-app",
                 ],
-                check=True,
             ),
             # Install the new app
             mock.call(
@@ -1440,55 +1530,32 @@ def test_run_app_test_mode_with_passthrough(run_command, first_app_config, tmp_p
                     "install",
                     "2D3503A3-6EB9-4B37-9B17-C7EFEF2FA32D",
                     tmp_path
-                    / "base_path"
-                    / "build"
-                    / "first-app"
-                    / "ios"
-                    / "xcode"
-                    / "build"
-                    / "Debug-iphonesimulator"
-                    / "First App.app",
+                    / "base_path/build/first-app/ios/xcode/build/Debug-iphonesimulator/First App.app",
                 ],
-                check=True,
+            ),
+            mock.call(
+                [
+                    "xcrun",
+                    "simctl",
+                    "spawn",
+                    "2D3503A3-6EB9-4B37-9B17-C7EFEF2FA32D",
+                    "log",
+                    "stream",
+                    "--style",
+                    "compact",
+                    "--predicate",
+                    'senderImagePath ENDSWITH "/First App"'
+                    ' OR (processImagePath ENDSWITH "/First App"'
+                    ' AND (senderImagePath ENDSWITH "-iphonesimulator.so"'
+                    ' OR senderImagePath ENDSWITH "-iphonesimulator.dylib"))',
+                ],
+                stdout=subprocess.PIPE,
+                stderr=subprocess.STDOUT,
+                bufsize=1,
             ),
         ]
     )
 
-    # Launch the new app
-    run_command.tools.subprocess.check_output.assert_called_once_with(
-        [
-            "xcrun",
-            "simctl",
-            "launch",
-            "2D3503A3-6EB9-4B37-9B17-C7EFEF2FA32D",
-            "com.example.first-app",
-            "foo",
-            "--bar",
-        ],
-    )
-
-    # Start the log stream
-    run_command.tools.subprocess.Popen.assert_called_once_with(
-        [
-            "xcrun",
-            "simctl",
-            "spawn",
-            "2D3503A3-6EB9-4B37-9B17-C7EFEF2FA32D",
-            "log",
-            "stream",
-            "--style",
-            "compact",
-            "--predicate",
-            'senderImagePath ENDSWITH "/First App"'
-            ' OR (processImagePath ENDSWITH "/First App"'
-            ' AND (senderImagePath ENDSWITH "-iphonesimulator.so"'
-            ' OR senderImagePath ENDSWITH "-iphonesimulator.dylib"))',
-        ],
-        stdout=subprocess.PIPE,
-        stderr=subprocess.STDOUT,
-        bufsize=1,
-    )
-
     # Log stream monitoring was started
     run_command._stream_app_logs.assert_called_with(
         first_app_config,