Repository: beeware/briefcase

Issue:
Disabling the Wait Bar for Batch Scripts Prevents Including Script Output in the Logfile
**Describe the bug**
#877 disabled the Wait Bar for batch scripts. Since an active Wait Bar is the signal to stream output for the command, the output for batch scripts is not streamed and not included in the logfile.

For example, building an Android APK is not captured in the logfile.

**To Reproduce**
Run `briefcase run android --log` and see the build output is absent in the logfile.

**Expected behavior**
Batch script output should be captured in the logfile.

Passing `stream_output=True` for calls to `Subprocess.run` is probably the needed change.

Alternatively, defaulting to `stream_output=True` and requiring devs to intentionally set `stream_output=False` would help prevent this broader case of issues. For instance, the output for flatpak builds cannot be streamed.

**Environment:**
 - Operating System: Windows 10
 - Python version: 3.10.7
 - Software versions:
   - Briefcase: `0.3.11rc6.dev3+g0a0f3a36.d20221013`

Discussion:

Patch:
diff --git a/changes/917.bugfix.rst b/changes/917.bugfix.rst
new file mode 100644
index 00000000..bea10b5a
--- /dev/null
+++ b/changes/917.bugfix.rst
@@ -0,0 +1,1 @@
+The console output for Windows batch scripts in now captured in the Briefcase log.
diff --git a/src/briefcase/commands/dev.py b/src/briefcase/commands/dev.py
index 46ae2133..7a5aeb97 100644
--- a/src/briefcase/commands/dev.py
+++ b/src/briefcase/commands/dev.py
@@ -101,7 +101,6 @@ class DevCommand(BaseCommand):
                 env=env,
                 check=True,
                 cwd=self.tools.home_path,
-                stream_output=True,
             )
         except subprocess.CalledProcessError as e:
             raise BriefcaseCommandError(
diff --git a/src/briefcase/integrations/android_sdk.py b/src/briefcase/integrations/android_sdk.py
index 4f440064..faa4f6e8 100644
--- a/src/briefcase/integrations/android_sdk.py
+++ b/src/briefcase/integrations/android_sdk.py
@@ -312,6 +312,7 @@ Delete {cmdline_tools_zip_path} and run briefcase again.
                 [os.fsdecode(self.sdkmanager_path), "--update"],
                 env=self.env,
                 check=True,
+                stream_output=False,
             )
         except subprocess.CalledProcessError as e:
             raise BriefcaseCommandError(
@@ -330,6 +331,7 @@ its output for errors.
             self.tools.subprocess.check_output(
                 [os.fsdecode(self.sdkmanager_path), "--list_installed"],
                 env=self.env,
+                stderr=subprocess.STDOUT,
             )
         except subprocess.CalledProcessError as e:
             raise BriefcaseCommandError(
@@ -367,6 +369,7 @@ before you may use those tools.
                 [os.fsdecode(self.sdkmanager_path), "--licenses"],
                 env=self.env,
                 check=True,
+                stream_output=False,
             )
         except subprocess.CalledProcessError as e:
             raise BriefcaseCommandError(
@@ -419,6 +422,7 @@ connection.
                 ],
                 env=self.env,
                 check=True,
+                stream_output=False,
             )
         except subprocess.CalledProcessError as e:
             raise BriefcaseCommandError(
@@ -550,6 +554,7 @@ connection.
                 ],
                 env=self.env,
                 check=True,
+                stream_output=False,
             )
         except subprocess.CalledProcessError as e:
             raise BriefcaseCommandError(
@@ -1263,7 +1268,6 @@ Activity class not found while starting app.
                 + [f"{tag}:S" for tag in ["EGL_emulation"]],
                 env=self.tools.android_sdk.env,
                 check=True,
-                stream_output=True,
             )
         except subprocess.CalledProcessError as e:
             # If the user sends CTRL+C:
diff --git a/src/briefcase/integrations/flatpak.py b/src/briefcase/integrations/flatpak.py
index 367a83d8..491dbffd 100644
--- a/src/briefcase/integrations/flatpak.py
+++ b/src/briefcase/integrations/flatpak.py
@@ -181,6 +181,8 @@ You must install both flatpak and flatpak-builder.
                     f"{sdk}/{self.tools.host_arch}/{runtime_version}",
                 ],
                 check=True,
+                # flatpak install uses many animations that cannot be disabled
+                stream_output=False,
             )
         except subprocess.CalledProcessError as e:
             raise BriefcaseCommandError(
@@ -250,7 +252,6 @@ flatpak run {bundle}.{app_name}
                     f"{bundle}.{app_name}",
                 ],
                 check=True,
-                stream_output=True,
             )
         except subprocess.CalledProcessError as e:
             raise BriefcaseCommandError(f"Unable to start app {app_name}.") from e
diff --git a/src/briefcase/integrations/subprocess.py b/src/briefcase/integrations/subprocess.py
index 1f05e71a..7e470ded 100644
--- a/src/briefcase/integrations/subprocess.py
+++ b/src/briefcase/integrations/subprocess.py
@@ -256,22 +256,114 @@ class Subprocess:
         return tools.subprocess
 
     @ensure_console_is_safe
-    def run(self, args, stream_output=False, **kwargs):
+    def run(self, args, stream_output=True, **kwargs):
         """A wrapper for subprocess.run().
 
-        :param args: args for subprocess.run()
-        :param stream_output: simulate run() while streaming the output to the console
-        :param kwargs: keywords args for subprocess.run()
+        This method implements the behavior of subprocess.run() with some
+        notable differences:
+
+          - The command's output is streamed if ``stream_output=True`` or if
+            dynamic console content such as the Wait Bar is active and I/O is
+            not redirected.
+          - If dynamic content is not active, ``stream_output=False`` will
+            disable streaming.
+          - If the ``env`` argument is provided, the current system environment
+            will be copied, and the contents of env overwritten into that
+            environment.
+          - The ``text`` argument is defaulted to True so all output is returned
+            as strings instead of bytes.
+
+        Output Streaming Primer
+        ~~~~~~~~~~~~~~~~~~~~~~~
+
+        What is output streaming?
+        -------------------------
+
+        When a command is invoked via ``subprocess.run()``, by default, its
+        stdout and stderr are connected directly to the console and the output
+        is presented to the user as though the command was invoked natively.
+        When output streaming is active, Briefcase reads the command's output
+        and prints it in to the console on behalf of the command.
+
+        Why is output streaming necessary?
+        ----------------------------------
+
+        Allowing commands to print directly to the console can provide superior
+        UX since it allows tools to provide their native experience to users.
+
+        However, a primary mechanism for providing support for Briefcase is
+        asking users to provide the log file when errors occur. Often, these
+        errors are raised by invoked commands; streaming the output of commands
+        allows Briefcase to capture that output and include it in the log file
+        as well as the console.
+
+        Additionally, the content of the console can become corrupted if other
+        processes print to the console while Rich's dynamic console elements
+        such as a Progress Bar are active. Streaming such output allows Rich to
+        maintain integrity of all the console content.
+
+        How does output streaming work?
+        -------------------------------
+
+        Streaming is activated when:
+
+          1. dynamic content such as the Wait Bar is active and I/O is not
+             redirected
+
+          2. the ``stream_output`` argument is ``True`` (as it is by default)
+
+        I/O is considered redirected when both ``stdout`` and ``stderr`` are
+        specified (e.g., when ``stdout`` and ``stderr`` are set
+        to``subprocess.PIPE`` so the output is available to the caller).
+
+        To facilitate streaming the output, ``stdout`` is set to
+        ``subprocess.PIPE``. If ``stderr`` is not specified by calling code,
+        then it is set to ``subprocess.STDOUT`` so it is interlaced with stdout
+        output.
+
+        The process is created for the command with ``Popen``, and a separate
+        thread is started for continuously reading and printing the stdout to
+        the console from the command process. It is necessary to read from
+        stdout in a thread because the read is uninterruptible; so, a user may
+        not be able to abort with CTRL-C.
+
+        When the process ends (or the user sends CTRL-C), a call is made to
+        ensure the command process terminates; this, in turn, ensures the thread
+        for streaming can properly exit its otherwise infinite loop. The calling
+        code is either returned a ``CompletedProcess`` object or
+        ``CalledProcessError`` could be raised.
+
+        Output streaming ultimately strives to simulate calling
+        ``subprocess.run()`` while proxying the command's output to the console.
+
+        When *not* to use output streaming?
+        -----------------------------------
+
+        When streaming is disabled, the command's output **WILL NOT** be
+        included the Briefcase log file. This makes troubleshooting difficult
+        and should be avoided. However, there are some situations where disabling
+        streaming is unavoidable.
+
+        Some commands use their own dynamic console content such as progress
+        bars, spinners, and other animations. When such output is streamed, its
+        quality can be significantly compromised and look terrible in the
+        console for users. If it is not possible to disable such output with
+        e.g. a flag for the command, then disabling streaming may be necessary.
+        As an example, the ``flatpak install`` command uses many animations.
+
+        If a command requires user input, do not use output streaming. The
+        output streaming implementation doesn't provide any facilities to send
+        input to the process and doesn't provide any special handling of stdin.
+        Additionally, when the command process prompts the user for input, that
+        doesn't always trigger ``readline()`` to return; so, the console may
+        actually end up waiting for user input before displaying a prompt to the
+        user. As an example, accepting Android SDK licenses requires user input.
 
-        The behavior of this method is identical to subprocess.run(),
-        except for:
-         - If a Wait Bar is active and IO is not redirected, subprocess.run()
-           will be simulated so the output can be proxied via stream_output.
-         - If the `env` argument is provided, the current system environment
-           will be copied, and the contents of env overwritten into that
-           environment.
-         - The `text` argument is defaulted to True so all output is returned
-           as strings instead of bytes.
+        :param args: args for subprocess.run()
+        :param stream_output: simulate run() while streaming the output to the
+            console. Set to False if the command requires user interaction.
+        :param kwargs: keyword args for subprocess.run()
+        :return: `CompletedProcess` for invoked process
         """
         # If `stream_output` or dynamic screen content (e.g. the Wait Bar) is
         # active and output is not redirected, use run with output streaming.
diff --git a/src/briefcase/integrations/xcode.py b/src/briefcase/integrations/xcode.py
index e5955671..0b0adc61 100644
--- a/src/briefcase/integrations/xcode.py
+++ b/src/briefcase/integrations/xcode.py
@@ -310,6 +310,7 @@ to enter your password (Briefcase will not store this password anywhere).
                 tools.subprocess.run(
                     ["sudo", "xcodebuild", "-license"],
                     check=True,
+                    stream_output=False,
                 )
             except subprocess.CalledProcessError as e:
                 # status code 1 - sudo fail
diff --git a/src/briefcase/platforms/linux/appimage.py b/src/briefcase/platforms/linux/appimage.py
index 40d4818a..baf4168d 100644
--- a/src/briefcase/platforms/linux/appimage.py
+++ b/src/briefcase/platforms/linux/appimage.py
@@ -265,7 +265,6 @@ class LinuxAppImageRunCommand(LinuxAppImagePassiveMixin, RunCommand):
                 [os.fsdecode(self.binary_path(app))],
                 check=True,
                 cwd=self.tools.home_path,
-                stream_output=True,
             )
         except KeyboardInterrupt:
             pass  # Catch CTRL-C to exit normally
diff --git a/src/briefcase/platforms/windows/__init__.py b/src/briefcase/platforms/windows/__init__.py
index 04bf84ab..b13b5737 100644
--- a/src/briefcase/platforms/windows/__init__.py
+++ b/src/briefcase/platforms/windows/__init__.py
@@ -87,7 +87,6 @@ class WindowsRunCommand(RunCommand):
                 [os.fsdecode(self.binary_path(app))],
                 cwd=self.tools.home_path,
                 check=True,
-                stream_output=True,
             )
         except KeyboardInterrupt:
             pass  # Catch CTRL-C to exit normally


Test patch:
diff --git a/tests/commands/dev/test_run_dev_app.py b/tests/commands/dev/test_run_dev_app.py
index a09d1346..b25c4535 100644
--- a/tests/commands/dev/test_run_dev_app.py
+++ b/tests/commands/dev/test_run_dev_app.py
@@ -23,7 +23,6 @@ def test_subprocess_running_successfully(dev_command, first_app, tmp_path):
         env=env,
         cwd=dev_command.tools.home_path,
         check=True,
-        stream_output=True,
     )
 
 
@@ -50,5 +49,4 @@ def test_subprocess_throws_error(dev_command, first_app, tmp_path):
         env=env,
         cwd=dev_command.tools.home_path,
         check=True,
-        stream_output=True,
     )
diff --git a/tests/integrations/android_sdk/ADB/test_logcat.py b/tests/integrations/android_sdk/ADB/test_logcat.py
index 48300ce5..7151f503 100644
--- a/tests/integrations/android_sdk/ADB/test_logcat.py
+++ b/tests/integrations/android_sdk/ADB/test_logcat.py
@@ -29,7 +29,6 @@ def test_logcat(mock_tools):
         ],
         env=mock_tools.android_sdk.env,
         check=True,
-        stream_output=True,
     )
 
 
diff --git a/tests/integrations/android_sdk/AndroidSDK/test_list_packages.py b/tests/integrations/android_sdk/AndroidSDK/test_list_packages.py
index 10e6733b..9c7cdfeb 100644
--- a/tests/integrations/android_sdk/AndroidSDK/test_list_packages.py
+++ b/tests/integrations/android_sdk/AndroidSDK/test_list_packages.py
@@ -14,6 +14,7 @@ def test_list_packages(mock_tools, android_sdk):
     mock_tools.subprocess.check_output.assert_called_once_with(
         [os.fsdecode(android_sdk.sdkmanager_path), "--list_installed"],
         env=android_sdk.env,
+        stderr=subprocess.STDOUT,
     )
 
 
@@ -28,4 +29,5 @@ def test_list_packages_failure(mock_tools, android_sdk):
     mock_tools.subprocess.check_output.assert_called_once_with(
         [os.fsdecode(android_sdk.sdkmanager_path), "--list_installed"],
         env=android_sdk.env,
+        stderr=subprocess.STDOUT,
     )
diff --git a/tests/integrations/android_sdk/AndroidSDK/test_upgrade.py b/tests/integrations/android_sdk/AndroidSDK/test_upgrade.py
index f32bc1f0..95c90bd1 100644
--- a/tests/integrations/android_sdk/AndroidSDK/test_upgrade.py
+++ b/tests/integrations/android_sdk/AndroidSDK/test_upgrade.py
@@ -14,6 +14,7 @@ def test_upgrade(mock_tools, android_sdk):
         [os.fsdecode(android_sdk.sdkmanager_path), "--update"],
         env=android_sdk.env,
         check=True,
+        stream_output=False,
     )
 
 
@@ -27,4 +28,5 @@ def test_upgrade_failure(mock_tools, android_sdk):
         [os.fsdecode(android_sdk.sdkmanager_path), "--update"],
         env=android_sdk.env,
         check=True,
+        stream_output=False,
     )
diff --git a/tests/integrations/android_sdk/AndroidSDK/test_verify_emulator.py b/tests/integrations/android_sdk/AndroidSDK/test_verify_emulator.py
index 9540dcea..601a1f20 100644
--- a/tests/integrations/android_sdk/AndroidSDK/test_verify_emulator.py
+++ b/tests/integrations/android_sdk/AndroidSDK/test_verify_emulator.py
@@ -69,6 +69,7 @@ def test_installs_android_emulator(mock_tools, android_sdk):
         ],
         env=android_sdk.env,
         check=True,
+        stream_output=False,
     )
 
 
@@ -91,6 +92,7 @@ def test_partial_android_emulator_install(mock_tools, android_sdk):
         ],
         env=android_sdk.env,
         check=True,
+        stream_output=False,
     )
 
 
diff --git a/tests/integrations/android_sdk/AndroidSDK/test_verify_license.py b/tests/integrations/android_sdk/AndroidSDK/test_verify_license.py
index 1044d27e..08e31a5e 100644
--- a/tests/integrations/android_sdk/AndroidSDK/test_verify_license.py
+++ b/tests/integrations/android_sdk/AndroidSDK/test_verify_license.py
@@ -39,6 +39,7 @@ def test_verify_license_prompts_for_licenses_and_exits_if_you_agree(
         [os.fsdecode(android_sdk.sdkmanager_path), "--licenses"],
         env=android_sdk.env,
         check=True,
+        stream_output=False,
     )
 
 
@@ -53,6 +54,7 @@ def test_verify_license_handles_sdkmanager_crash(mock_tools, android_sdk):
         [os.fsdecode(android_sdk.sdkmanager_path), "--licenses"],
         env=android_sdk.env,
         check=True,
+        stream_output=False,
     )
 
 
@@ -68,4 +70,5 @@ def test_verify_license_insists_on_agreement(mock_tools, android_sdk):
         [os.fsdecode(android_sdk.sdkmanager_path), "--licenses"],
         env=android_sdk.env,
         check=True,
+        stream_output=False,
     )
diff --git a/tests/integrations/android_sdk/AndroidSDK/test_verify_system_image.py b/tests/integrations/android_sdk/AndroidSDK/test_verify_system_image.py
index 8769a922..38683ebc 100644
--- a/tests/integrations/android_sdk/AndroidSDK/test_verify_system_image.py
+++ b/tests/integrations/android_sdk/AndroidSDK/test_verify_system_image.py
@@ -72,6 +72,7 @@ def test_incompatible_abi(mock_tools, android_sdk, capsys):
         ],
         env=android_sdk.env,
         check=True,
+        stream_output=False,
     )
 
 
@@ -109,6 +110,7 @@ def test_new_system_image(mock_tools, android_sdk):
         ],
         env=android_sdk.env,
         check=True,
+        stream_output=False,
     )
 
 
@@ -139,4 +141,5 @@ def test_problem_downloading_system_image(mock_tools, android_sdk):
         ],
         env=android_sdk.env,
         check=True,
+        stream_output=False,
     )
diff --git a/tests/integrations/docker/test_DockerAppContext__run.py b/tests/integrations/docker/test_DockerAppContext__run.py
index e44575ff..885cce95 100644
--- a/tests/integrations/docker/test_DockerAppContext__run.py
+++ b/tests/integrations/docker/test_DockerAppContext__run.py
@@ -1,4 +1,5 @@
 import os
+import subprocess
 import sys
 from pathlib import Path
 from unittest.mock import ANY
@@ -11,7 +12,7 @@ def test_simple_call(mock_docker_app_context, tmp_path, capsys):
 
     mock_docker_app_context.run(["hello", "world"])
 
-    mock_docker_app_context.tools.subprocess._subprocess.run.assert_called_with(
+    mock_docker_app_context.tools.subprocess._subprocess.Popen.assert_called_with(
         [
             "docker",
             "run",
@@ -26,6 +27,9 @@ def test_simple_call(mock_docker_app_context, tmp_path, capsys):
         ],
         text=True,
         encoding=ANY,
+        bufsize=1,
+        stdout=subprocess.PIPE,
+        stderr=subprocess.STDOUT,
     )
     assert capsys.readouterr().out == (
         "\n"
@@ -48,7 +52,7 @@ def test_call_with_arg_and_env(mock_docker_app_context, tmp_path, capsys):
         universal_newlines=True,
     )
 
-    mock_docker_app_context.tools.subprocess._subprocess.run.assert_called_with(
+    mock_docker_app_context.tools.subprocess._subprocess.Popen.assert_called_with(
         [
             "docker",
             "run",
@@ -67,6 +71,9 @@ def test_call_with_arg_and_env(mock_docker_app_context, tmp_path, capsys):
         ],
         universal_newlines=True,
         encoding=ANY,
+        bufsize=1,
+        stdout=subprocess.PIPE,
+        stderr=subprocess.STDOUT,
     )
     assert capsys.readouterr().out == (
         "\n"
@@ -92,7 +99,7 @@ def test_call_with_path_arg_and_env(mock_docker_app_context, tmp_path, capsys):
         cwd=tmp_path / "cwd",
     )
 
-    mock_docker_app_context.tools.subprocess._subprocess.run.assert_called_with(
+    mock_docker_app_context.tools.subprocess._subprocess.Popen.assert_called_with(
         [
             "docker",
             "run",
@@ -112,6 +119,9 @@ def test_call_with_path_arg_and_env(mock_docker_app_context, tmp_path, capsys):
         cwd=os.fsdecode(tmp_path / "cwd"),
         text=True,
         encoding=ANY,
+        bufsize=1,
+        stdout=subprocess.PIPE,
+        stderr=subprocess.STDOUT,
     )
     assert capsys.readouterr().out == (
         "\n"
@@ -130,7 +140,7 @@ def test_simple_verbose_call(mock_docker_app_context, tmp_path, capsys):
 
     mock_docker_app_context.run(["hello", "world"])
 
-    mock_docker_app_context.tools.subprocess._subprocess.run.assert_called_with(
+    mock_docker_app_context.tools.subprocess._subprocess.Popen.assert_called_with(
         [
             "docker",
             "run",
@@ -145,6 +155,9 @@ def test_simple_verbose_call(mock_docker_app_context, tmp_path, capsys):
         ],
         text=True,
         encoding=ANY,
+        bufsize=1,
+        stdout=subprocess.PIPE,
+        stderr=subprocess.STDOUT,
     )
     assert capsys.readouterr().out == (
         "\n"
@@ -159,7 +172,7 @@ def test_simple_verbose_call(mock_docker_app_context, tmp_path, capsys):
         "hello world\n"
         ">>> Working Directory:\n"
         f">>>     {Path.cwd()}\n"
-        ">>> Return code: 3\n"
+        ">>> Return code: 0\n"
         "\n"
         "[myapp] Leaving Docker context\n"
     )
diff --git a/tests/integrations/flatpak/test_Flatpak__run.py b/tests/integrations/flatpak/test_Flatpak__run.py
index dddbe607..4a90dcbc 100644
--- a/tests/integrations/flatpak/test_Flatpak__run.py
+++ b/tests/integrations/flatpak/test_Flatpak__run.py
@@ -21,7 +21,6 @@ def test_run(flatpak):
             "com.example.my-app",
         ],
         check=True,
-        stream_output=True,
     )
 
 
diff --git a/tests/integrations/flatpak/test_Flatpak__verify_runtime.py b/tests/integrations/flatpak/test_Flatpak__verify_runtime.py
index b9ec53c5..e3a12de4 100644
--- a/tests/integrations/flatpak/test_Flatpak__verify_runtime.py
+++ b/tests/integrations/flatpak/test_Flatpak__verify_runtime.py
@@ -27,6 +27,7 @@ def test_verify_runtime(flatpak):
             "org.beeware.flatpak.SDK/gothic/37.42",
         ],
         check=True,
+        stream_output=False,
     )
 
 
diff --git a/tests/integrations/subprocess/conftest.py b/tests/integrations/subprocess/conftest.py
index 3b678527..e0bb7095 100644
--- a/tests/integrations/subprocess/conftest.py
+++ b/tests/integrations/subprocess/conftest.py
@@ -12,7 +12,31 @@ CREATE_NEW_PROCESS_GROUP = 0x200
 
 
 @pytest.fixture
-def mock_sub(mock_tools):
+def streaming_process():
+    """Mock process returned for Popen context manager in
+    Subprocess._run_and_stream_output."""
+    process = MagicMock()
+
+    # Mock the readline values of an actual process. The final return value is "",
+    # indicating that the process has exited; however, we insert a short sleep
+    # to ensure that any other threads will have a chance to run before this
+    # thread actually terminates.
+    def mock_readline():
+        yield from [
+            "output line 1\n",
+            "\n",
+            "output line 3\n",
+        ]
+        time.sleep(0.1)
+        yield ""
+
+    process.stdout.readline.side_effect = mock_readline()
+    process.poll.return_value = -3
+    return process
+
+
+@pytest.fixture
+def mock_sub(mock_tools, streaming_process):
     mock_tools.os.environ = {
         "VAR1": "Value 1",
         "PS1": "\nLine 2\n\nLine 4",
@@ -31,26 +55,7 @@ def mock_sub(mock_tools):
     sub._subprocess.CREATE_NO_WINDOW = CREATE_NO_WINDOW
     sub._subprocess.CREATE_NEW_PROCESS_GROUP = CREATE_NEW_PROCESS_GROUP
 
-    return sub
+    # Return the streaming process for output streaming
+    sub._subprocess.Popen.return_value.__enter__.return_value = streaming_process
 
-
-@pytest.fixture
-def popen_process():
-    process = MagicMock()
-
-    # Mock the readline values of an actual process. The final return value is "",
-    # indicating that the process has exited; however, we insert a short sleep
-    # to ensure that any other threads will have a chance to run before this
-    # thread actually terminates.
-    def mock_readline():
-        yield from [
-            "output line 1\n",
-            "\n",
-            "output line 3\n",
-        ]
-        time.sleep(0.1)
-        yield ""
-
-    process.stdout.readline.side_effect = mock_readline()
-    process.poll.return_value = -3
-    return process
+    return sub
diff --git a/tests/integrations/subprocess/test_Subprocess__run_in_wait_bar.py b/tests/integrations/subprocess/test_Subprocess__run__controlled_console.py
similarity index 73%
rename from tests/integrations/subprocess/test_Subprocess__run_in_wait_bar.py
rename to tests/integrations/subprocess/test_Subprocess__run__controlled_console.py
index 572043d2..e277f958 100644
--- a/tests/integrations/subprocess/test_Subprocess__run_in_wait_bar.py
+++ b/tests/integrations/subprocess/test_Subprocess__run__controlled_console.py
@@ -5,19 +5,17 @@ from unittest.mock import ANY
 
 import pytest
 
-
-@pytest.fixture
-def mock_sub(mock_sub, popen_process):
-    # ensure Popen returns the mock process when used as a context manager
-    mock_sub._subprocess.Popen.return_value.__enter__.return_value = popen_process
-    return mock_sub
+#####
+# These tests are run with stream_output=False to ensure it is the
+# Wait Bar that influences the control flow of Subprocess.run().
+#####
 
 
 def test_call(mock_sub, capsys):
     """A simple call will be invoked."""
 
     with mock_sub.tools.input.wait_bar():
-        mock_sub.run(["hello", "world"])
+        mock_sub.run(["hello", "world"], stream_output=False)
 
     mock_sub._subprocess.Popen.assert_called_with(
         ["hello", "world"],
@@ -27,7 +25,6 @@ def test_call(mock_sub, capsys):
         text=True,
         encoding=ANY,
     )
-
     # fmt: off
     expected_output = (
         "output line 1\n"
@@ -43,7 +40,7 @@ def test_call_with_arg(mock_sub, capsys):
     """Any extra keyword arguments are passed through as-is."""
 
     with mock_sub.tools.input.wait_bar():
-        mock_sub.run(["hello", "world"], universal_newlines=True)
+        mock_sub.run(["hello", "world"], universal_newlines=True, stream_output=False)
 
     mock_sub._subprocess.Popen.assert_called_with(
         ["hello", "world"],
@@ -69,7 +66,7 @@ def test_debug_call(mock_sub, capsys):
     mock_sub.tools.logger.verbosity = 2
 
     with mock_sub.tools.input.wait_bar():
-        mock_sub.run(["hello", "world"])
+        mock_sub.run(["hello", "world"], stream_output=False)
 
     mock_sub._subprocess.Popen.assert_called_with(
         ["hello", "world"],
@@ -101,7 +98,12 @@ def test_debug_call_with_env(mock_sub, capsys, tmp_path):
 
     env = {"NewVar": "NewVarValue"}
     with mock_sub.tools.input.wait_bar():
-        mock_sub.run(["hello", "world"], env=env, cwd=tmp_path / "cwd")
+        mock_sub.run(
+            ["hello", "world"],
+            env=env,
+            cwd=tmp_path / "cwd",
+            stream_output=False,
+        )
 
     merged_env = mock_sub.tools.os.environ.copy()
     merged_env.update(env)
@@ -151,7 +153,8 @@ def test_text_eq_true_default_overriding(mock_sub, in_kwargs, kwargs):
     """if text or universal_newlines is explicitly provided, those should
     override text=true default."""
     with mock_sub.tools.input.wait_bar():
-        mock_sub.run(["hello", "world"], **in_kwargs)
+        mock_sub.run(["hello", "world"], **in_kwargs, stream_output=False)
+
     mock_sub._subprocess.Popen.assert_called_with(
         ["hello", "world"],
         stdout=subprocess.PIPE,
@@ -160,13 +163,17 @@ def test_text_eq_true_default_overriding(mock_sub, in_kwargs, kwargs):
     )
 
 
-def test_stderr_is_redirected(mock_sub, popen_process, capsys):
+def test_stderr_is_redirected(mock_sub, streaming_process, capsys):
     """When stderr is redirected, it should be included in the result."""
     stderr_output = "stderr output\nline 2"
-    popen_process.stderr.read.return_value = stderr_output
+    streaming_process.stderr.read.return_value = stderr_output
 
     with mock_sub.tools.input.wait_bar():
-        run_result = mock_sub.run(["hello", "world"], stderr=subprocess.PIPE)
+        run_result = mock_sub.run(
+            ["hello", "world"],
+            stderr=subprocess.PIPE,
+            stream_output=False,
+        )
 
     mock_sub._subprocess.Popen.assert_called_with(
         ["hello", "world"],
@@ -176,7 +183,6 @@ def test_stderr_is_redirected(mock_sub, popen_process, capsys):
         text=True,
         encoding=ANY,
     )
-
     # fmt: off
     expected_output = (
         "output line 1\n"
@@ -189,12 +195,16 @@ def test_stderr_is_redirected(mock_sub, popen_process, capsys):
     assert run_result.stderr == stderr_output
 
 
-def test_stderr_dev_null(mock_sub, popen_process, capsys):
+def test_stderr_dev_null(mock_sub, streaming_process, capsys):
     """When stderr is discarded, it should be None in the result."""
-    popen_process.stderr = None
+    streaming_process.stderr = None
 
     with mock_sub.tools.input.wait_bar():
-        run_result = mock_sub.run(["hello", "world"], stderr=subprocess.DEVNULL)
+        run_result = mock_sub.run(
+            ["hello", "world"],
+            stderr=subprocess.DEVNULL,
+            stream_output=False,
+        )
 
     mock_sub._subprocess.Popen.assert_called_with(
         ["hello", "world"],
@@ -204,7 +214,6 @@ def test_stderr_dev_null(mock_sub, popen_process, capsys):
         text=True,
         encoding=ANY,
     )
-
     # fmt: off
     expected_output = (
         "output line 1\n"
@@ -217,15 +226,20 @@ def test_stderr_dev_null(mock_sub, popen_process, capsys):
     assert run_result.stderr is None
 
 
-def test_calledprocesserror(mock_sub, popen_process, capsys):
+def test_calledprocesserror(mock_sub, streaming_process, capsys):
     """CalledProcessError is raised with check=True and non-zero return
     value."""
     stderr_output = "stderr output\nline 2"
-    popen_process.stderr.read.return_value = stderr_output
+    streaming_process.stderr.read.return_value = stderr_output
 
     with pytest.raises(subprocess.CalledProcessError) as exc:
         with mock_sub.tools.input.wait_bar():
-            mock_sub.run(["hello", "world"], check=True, stderr=subprocess.PIPE)
+            mock_sub.run(
+                ["hello", "world"],
+                check=True,
+                stderr=subprocess.PIPE,
+                stream_output=False,
+            )
 
     # fmt: off
     expected_output = (
@@ -245,9 +259,34 @@ def test_invalid_invocations(mock_sub):
     """Ensure run cannot be used in a Wait Bar with invalid arguments."""
     with pytest.raises(AssertionError):
         with mock_sub.tools.input.wait_bar():
-            mock_sub.run(["hello", "world"], stdout=subprocess.PIPE)
+            mock_sub.run(
+                ["hello", "world"],
+                stdout=subprocess.PIPE,
+                stream_output=False,
+            )
 
     for invalid_arg in ("timeout", "input"):
         with pytest.raises(AssertionError):
             with mock_sub.tools.input.wait_bar():
                 mock_sub.run(["hello", "world"], **{invalid_arg: "value"})
+
+
+@pytest.mark.parametrize(
+    "redirected_kwargs",
+    [
+        dict(capture_output=True),
+        dict(stdout=subprocess.PIPE, stderr=subprocess.PIPE),
+        dict(stdout=subprocess.PIPE, stderr=subprocess.STDOUT),
+        dict(stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL),
+        dict(stdout=-1, stderr=-1),
+    ],
+)
+def test_redirected_output(mock_sub, redirected_kwargs, capsys):
+    """Output is not streamed if it is redirected."""
+    with mock_sub.tools.input.wait_bar():
+        mock_sub.run(["hello", "world"], stream_output=False, **redirected_kwargs)
+
+    mock_sub._subprocess.run.assert_called_with(
+        ["hello", "world"], text=True, encoding=ANY, **redirected_kwargs
+    )
+    assert capsys.readouterr().out == "\n"
diff --git a/tests/integrations/subprocess/test_Subprocess__run.py b/tests/integrations/subprocess/test_Subprocess__run__stream_output__False.py
similarity index 86%
rename from tests/integrations/subprocess/test_Subprocess__run.py
rename to tests/integrations/subprocess/test_Subprocess__run__stream_output__False.py
index a30cd1d7..4a098639 100644
--- a/tests/integrations/subprocess/test_Subprocess__run.py
+++ b/tests/integrations/subprocess/test_Subprocess__run__stream_output__False.py
@@ -13,7 +13,7 @@ def test_call(mock_sub, capsys, platform):
     """A simple call will be invoked."""
 
     mock_sub.tools.sys.platform = platform
-    mock_sub.run(["hello", "world"])
+    mock_sub.run(["hello", "world"], stream_output=False)
 
     mock_sub._subprocess.run.assert_called_with(
         ["hello", "world"],
@@ -26,7 +26,7 @@ def test_call(mock_sub, capsys, platform):
 def test_call_with_arg(mock_sub, capsys):
     """Any extra keyword arguments are passed through as-is."""
 
-    mock_sub.run(["hello", "world"], universal_newlines=True)
+    mock_sub.run(["hello", "world"], universal_newlines=True, stream_output=False)
 
     mock_sub._subprocess.run.assert_called_with(
         ["hello", "world"],
@@ -39,7 +39,11 @@ def test_call_with_arg(mock_sub, capsys):
 def test_call_with_path_arg(mock_sub, capsys, tmp_path):
     """Path-based arguments are converted to strings and passed in as-is."""
 
-    mock_sub.run(["hello", tmp_path / "location"], cwd=tmp_path / "cwd")
+    mock_sub.run(
+        ["hello", tmp_path / "location"],
+        cwd=tmp_path / "cwd",
+        stream_output=False,
+    )
 
     mock_sub._subprocess.run.assert_called_with(
         ["hello", os.fsdecode(tmp_path / "location")],
@@ -79,7 +83,11 @@ def test_call_with_start_new_session(
     Windows."""
 
     mock_sub.tools.host_os = platform
-    mock_sub.run(["hello", "world"], start_new_session=start_new_session)
+    mock_sub.run(
+        ["hello", "world"],
+        start_new_session=start_new_session,
+        stream_output=False,
+    )
 
     if platform == "Windows":
         mock_sub._subprocess.run.assert_called_with(
@@ -124,7 +132,10 @@ def test_call_windows_with_start_new_session_and_creationflags(
         AssertionError, match="Subprocess called with creationflags set"
     ):
         mock_sub.run(
-            ["hello", "world"], start_new_session=True, creationflags=creationflags
+            ["hello", "world"],
+            start_new_session=True,
+            creationflags=creationflags,
+            stream_output=False,
         )
 
 
@@ -132,7 +143,7 @@ def test_debug_call(mock_sub, capsys):
     """If verbosity is turned up, there is output."""
     mock_sub.tools.logger.verbosity = 2
 
-    mock_sub.run(["hello", "world"])
+    mock_sub.run(["hello", "world"], stream_output=False)
 
     mock_sub._subprocess.run.assert_called_with(
         ["hello", "world"],
@@ -158,7 +169,7 @@ def test_debug_call_with_env(mock_sub, capsys, tmp_path):
     mock_sub.tools.logger.verbosity = 2
 
     env = {"NewVar": "NewVarValue"}
-    mock_sub.run(["hello", "world"], env=env, cwd=tmp_path / "cwd")
+    mock_sub.run(["hello", "world"], env=env, cwd=tmp_path / "cwd", stream_output=False)
 
     merged_env = mock_sub.tools.os.environ.copy()
     merged_env.update(env)
@@ -186,16 +197,15 @@ def test_debug_call_with_env(mock_sub, capsys, tmp_path):
 def test_calledprocesserror_exception_logging(mock_sub, capsys):
     mock_sub.tools.logger.verbosity = 2
 
-    called_process_error = CalledProcessError(
+    mock_sub._subprocess.run.side_effect = CalledProcessError(
         returncode=-1,
         cmd="hello world",
         output="output line 1\noutput line 2",
         stderr="error line 1\nerror line 2",
     )
-    mock_sub._subprocess.run.side_effect = called_process_error
 
     with pytest.raises(CalledProcessError):
-        mock_sub.run(["hello", "world"])
+        mock_sub.run(["hello", "world"], stream_output=False)
 
     # fmt: off
     expected_output = (
@@ -207,7 +217,6 @@ def test_calledprocesserror_exception_logging(mock_sub, capsys):
         ">>> Return code: -1\n"
     )
     # fmt: on
-
     assert capsys.readouterr().out == expected_output
 
 
@@ -222,8 +231,8 @@ def test_calledprocesserror_exception_logging(mock_sub, capsys):
     ],
 )
 def test_text_eq_true_default_overriding(mock_sub, in_kwargs, kwargs):
-    """if text or universal_newlines is explicitly provided, those should
+    """If text or universal_newlines is explicitly provided, those should
     override text=true default."""
+    mock_sub.run(["hello", "world"], stream_output=False, **in_kwargs)
 
-    mock_sub.run(["hello", "world"], **in_kwargs)
     mock_sub._subprocess.run.assert_called_with(["hello", "world"], **kwargs)
diff --git a/tests/integrations/subprocess/test_Subprocess__run__stream_output__True.py b/tests/integrations/subprocess/test_Subprocess__run__stream_output__True.py
new file mode 100644
index 00000000..c7e44bfd
--- /dev/null
+++ b/tests/integrations/subprocess/test_Subprocess__run__stream_output__True.py
@@ -0,0 +1,288 @@
+import os
+import subprocess
+from pathlib import Path
+from subprocess import CalledProcessError
+from unittest.mock import ANY
+
+import pytest
+
+from .conftest import CREATE_NEW_PROCESS_GROUP, CREATE_NO_WINDOW
+
+
+@pytest.mark.parametrize("platform", ["Linux", "Darwin", "Windows"])
+def test_call(mock_sub, capsys, platform):
+    """A simple call will be invoked."""
+
+    mock_sub.tools.sys.platform = platform
+    mock_sub.run(["hello", "world"])
+
+    mock_sub._subprocess.Popen.assert_called_with(
+        ["hello", "world"],
+        text=True,
+        encoding=ANY,
+        stdout=subprocess.PIPE,
+        stderr=subprocess.STDOUT,
+        bufsize=1,
+    )
+    # fmt: off
+    expected_output = (
+        "output line 1\n"
+        "\n"
+        "output line 3\n"
+    )
+    # fmt: on
+    assert capsys.readouterr().out == expected_output
+
+
+def test_call_with_arg(mock_sub, capsys):
+    """Any extra keyword arguments are passed through as-is."""
+
+    mock_sub.run(["hello", "world"], universal_newlines=True)
+
+    mock_sub._subprocess.Popen.assert_called_with(
+        ["hello", "world"],
+        universal_newlines=True,
+        encoding=ANY,
+        stdout=subprocess.PIPE,
+        stderr=subprocess.STDOUT,
+        bufsize=1,
+    )
+    # fmt: off
+    expected_output = (
+        "output line 1\n"
+        "\n"
+        "output line 3\n"
+    )
+    # fmt: on
+    assert capsys.readouterr().out == expected_output
+
+
+def test_call_with_path_arg(mock_sub, capsys, tmp_path):
+    """Path-based arguments are converted to strings and passed in as-is."""
+
+    mock_sub.run(["hello", tmp_path / "location"], cwd=tmp_path / "cwd")
+
+    mock_sub._subprocess.Popen.assert_called_with(
+        ["hello", os.fsdecode(tmp_path / "location")],
+        cwd=os.fsdecode(tmp_path / "cwd"),
+        text=True,
+        encoding=ANY,
+        stdout=subprocess.PIPE,
+        stderr=subprocess.STDOUT,
+        bufsize=1,
+    )
+    # fmt: off
+    expected_output = (
+        "output line 1\n"
+        "\n"
+        "output line 3\n"
+    )
+    # fmt: on
+    assert capsys.readouterr().out == expected_output
+
+
+@pytest.mark.parametrize(
+    ("platform", "start_new_session", "run_kwargs"),
+    [
+        ("Linux", None, {}),
+        ("Linux", True, {}),
+        ("Linux", False, {}),
+        ("Darwin", None, {}),
+        ("Darwin", True, {}),
+        ("Darwin", False, {}),
+        ("Windows", None, {}),
+        (
+            "Windows",
+            True,
+            {"creationflags": CREATE_NEW_PROCESS_GROUP | CREATE_NO_WINDOW},
+        ),
+        ("Windows", False, {}),
+    ],
+)
+def test_call_with_start_new_session(
+    mock_sub,
+    capsys,
+    platform,
+    start_new_session,
+    run_kwargs,
+):
+    """start_new_session is passed thru on Linux and macOS but converted for
+    Windows."""
+
+    mock_sub.tools.host_os = platform
+    mock_sub.run(["hello", "world"], start_new_session=start_new_session)
+
+    if platform == "Windows":
+        mock_sub._subprocess.Popen.assert_called_with(
+            ["hello", "world"],
+            text=True,
+            encoding=ANY,
+            stdout=subprocess.PIPE,
+            stderr=subprocess.STDOUT,
+            bufsize=1,
+            **run_kwargs,
+        )
+    else:
+        mock_sub._subprocess.Popen.assert_called_with(
+            ["hello", "world"],
+            start_new_session=start_new_session,
+            text=True,
+            encoding=ANY,
+            stdout=subprocess.PIPE,
+            stderr=subprocess.STDOUT,
+            bufsize=1,
+            **run_kwargs,
+        )
+
+    # fmt: off
+    expected_output = (
+        "output line 1\n"
+        "\n"
+        "output line 3\n"
+    )
+    # fmt: on
+    assert capsys.readouterr().out == expected_output
+
+
+@pytest.mark.parametrize(
+    ("creationflags", "final_creationflags"),
+    [
+        (0x1, CREATE_NEW_PROCESS_GROUP | CREATE_NO_WINDOW | 1),
+        (CREATE_NEW_PROCESS_GROUP, CREATE_NEW_PROCESS_GROUP | CREATE_NO_WINDOW),
+        (0, CREATE_NEW_PROCESS_GROUP | CREATE_NO_WINDOW),
+    ],
+)
+def test_call_windows_with_start_new_session_and_creationflags(
+    mock_sub,
+    capsys,
+    creationflags,
+    final_creationflags,
+):
+    """creationflags used to simulate start_new_session=True should be merged
+    with any existing flags."""
+
+    mock_sub.tools.host_os = "Windows"
+
+    # use commented test below when merging creationflags is allowed
+    with pytest.raises(
+        AssertionError,
+        match="Subprocess called with creationflags set",
+    ):
+        mock_sub.run(
+            ["hello", "world"],
+            start_new_session=True,
+            creationflags=creationflags,
+        )
+
+
+def test_debug_call(mock_sub, capsys):
+    """If verbosity is turned up, there is output."""
+    mock_sub.tools.logger.verbosity = 2
+
+    mock_sub.run(["hello", "world"])
+
+    mock_sub._subprocess.Popen.assert_called_with(
+        ["hello", "world"],
+        text=True,
+        encoding=ANY,
+        stdout=subprocess.PIPE,
+        stderr=subprocess.STDOUT,
+        bufsize=1,
+    )
+    # fmt: off
+    expected_output = (
+        "\n"
+        ">>> Running Command:\n"
+        ">>>     hello world\n"
+        ">>> Working Directory:\n"
+        f">>>     {Path.cwd()}\n"
+        "output line 1\n"
+        "\n"
+        "output line 3\n"
+        ">>> Return code: -3\n"
+    )
+    # fmt: on
+
+    assert capsys.readouterr().out == expected_output
+
+
+def test_debug_call_with_env(mock_sub, capsys, tmp_path):
+    """If verbosity is turned up, injected env vars are included output."""
+    mock_sub.tools.logger.verbosity = 2
+
+    env = {"NewVar": "NewVarValue"}
+    mock_sub.run(["hello", "world"], env=env, cwd=tmp_path / "cwd")
+
+    merged_env = mock_sub.tools.os.environ.copy()
+    merged_env.update(env)
+
+    mock_sub._subprocess.Popen.assert_called_with(
+        ["hello", "world"],
+        env=merged_env,
+        cwd=os.fsdecode(tmp_path / "cwd"),
+        text=True,
+        encoding=ANY,
+        stdout=subprocess.PIPE,
+        stderr=subprocess.STDOUT,
+        bufsize=1,
+    )
+    expected_output = (
+        "\n"
+        ">>> Running Command:\n"
+        ">>>     hello world\n"
+        ">>> Working Directory:\n"
+        f">>>     {tmp_path / 'cwd'}\n"
+        ">>> Environment Overrides:\n"
+        ">>>     NewVar=NewVarValue\n"
+        "output line 1\n"
+        "\n"
+        "output line 3\n"
+        ">>> Return code: -3\n"
+    )
+    assert capsys.readouterr().out == expected_output
+
+
+def test_calledprocesserror_exception_logging(mock_sub, capsys):
+    mock_sub.tools.logger.verbosity = 2
+
+    with pytest.raises(CalledProcessError):
+        mock_sub.run(["hello", "world"], check=True)
+
+    # fmt: off
+    expected_output = (
+        "\n"
+        ">>> Running Command:\n"
+        ">>>     hello world\n"
+        ">>> Working Directory:\n"
+        f">>>     {Path.cwd()}\n"
+        "output line 1\n"
+        "\n"
+        "output line 3\n"
+        ">>> Return code: -3\n"
+    )
+    # fmt: on
+    assert capsys.readouterr().out == expected_output
+
+
+@pytest.mark.parametrize(
+    "in_kwargs, kwargs",
+    [
+        ({}, {"text": True, "encoding": ANY}),
+        ({"text": True}, {"text": True, "encoding": ANY}),
+        ({"text": False}, {"text": False}),
+        ({"universal_newlines": False}, {"universal_newlines": False}),
+        ({"universal_newlines": True}, {"universal_newlines": True, "encoding": ANY}),
+    ],
+)
+def test_text_eq_true_default_overriding(mock_sub, in_kwargs, kwargs):
+    """If text or universal_newlines is explicitly provided, those should
+    override text=true default."""
+    mock_sub.run(["hello", "world"], **in_kwargs)
+
+    mock_sub._subprocess.Popen.assert_called_with(
+        ["hello", "world"],
+        stdout=subprocess.PIPE,
+        stderr=subprocess.STDOUT,
+        bufsize=1,
+        **kwargs,
+    )
diff --git a/tests/integrations/subprocess/test_Subprocess__stream_output.py b/tests/integrations/subprocess/test_Subprocess__stream_output.py
index d7a79c39..4d6c475e 100644
--- a/tests/integrations/subprocess/test_Subprocess__stream_output.py
+++ b/tests/integrations/subprocess/test_Subprocess__stream_output.py
@@ -15,9 +15,9 @@ def mock_sub(mock_sub):
     return mock_sub
 
 
-def test_output(mock_sub, popen_process, capsys):
+def test_output(mock_sub, streaming_process, capsys):
     """Process output is printed."""
-    mock_sub.stream_output("testing", popen_process)
+    mock_sub.stream_output("testing", streaming_process)
 
     # fmt: off
     assert capsys.readouterr().out == (
@@ -26,14 +26,14 @@ def test_output(mock_sub, popen_process, capsys):
         "output line 3\n"
     )
     # fmt: on
-    mock_sub.cleanup.assert_called_once_with("testing", popen_process)
+    mock_sub.cleanup.assert_called_once_with("testing", streaming_process)
 
 
-def test_output_debug(mock_sub, popen_process, capsys):
+def test_output_debug(mock_sub, streaming_process, capsys):
     """Process output is printed; no debug output for only stream_output."""
     mock_sub.tools.logger.verbosity = 2
 
-    mock_sub.stream_output("testing", popen_process)
+    mock_sub.stream_output("testing", streaming_process)
 
     # fmt: off
     expected_output = (
@@ -44,10 +44,10 @@ def test_output_debug(mock_sub, popen_process, capsys):
     # fmt: on
     assert capsys.readouterr().out == expected_output
 
-    mock_sub.cleanup.assert_called_once_with("testing", popen_process)
+    mock_sub.cleanup.assert_called_once_with("testing", streaming_process)
 
 
-def test_keyboard_interrupt(mock_sub, popen_process, capsys):
+def test_keyboard_interrupt(mock_sub, streaming_process, capsys):
     """KeyboardInterrupt is suppressed if user sends CTRL+C and all output is
     printed."""
 
@@ -55,7 +55,7 @@ def test_keyboard_interrupt(mock_sub, popen_process, capsys):
     send_ctrl_c.side_effect = [False, KeyboardInterrupt]
 
     with pytest.raises(KeyboardInterrupt):
-        mock_sub.stream_output("testing", popen_process, stop_func=send_ctrl_c)
+        mock_sub.stream_output("testing", streaming_process, stop_func=send_ctrl_c)
 
     assert (
         capsys.readouterr().out == "output line 1\n"
@@ -63,14 +63,14 @@ def test_keyboard_interrupt(mock_sub, popen_process, capsys):
         "output line 3\n"
         "Stopping...\n"
     )
-    mock_sub.cleanup.assert_called_once_with("testing", popen_process)
+    mock_sub.cleanup.assert_called_once_with("testing", streaming_process)
 
 
-def test_process_exit_with_queued_output(mock_sub, popen_process, capsys):
+def test_process_exit_with_queued_output(mock_sub, streaming_process, capsys):
     """All output is printed despite the process exiting early."""
-    popen_process.poll.side_effect = [None, -3, -3, -3]
+    streaming_process.poll.side_effect = [None, -3, -3, -3]
 
-    mock_sub.stream_output("testing", popen_process)
+    mock_sub.stream_output("testing", streaming_process)
     # fmt: off
     assert capsys.readouterr().out == (
         "output line 1\n"
@@ -78,14 +78,14 @@ def test_process_exit_with_queued_output(mock_sub, popen_process, capsys):
         "output line 3\n"
     )
     # fmt: on
-    mock_sub.cleanup.assert_called_once_with("testing", popen_process)
+    mock_sub.cleanup.assert_called_once_with("testing", streaming_process)
 
 
 @pytest.mark.parametrize("stop_func_ret_val", (True, False))
-def test_stop_func(mock_sub, popen_process, stop_func_ret_val, capsys):
+def test_stop_func(mock_sub, streaming_process, stop_func_ret_val, capsys):
     """All output is printed whether stop_func aborts streaming or not."""
     mock_sub.stream_output(
-        "testing", popen_process, stop_func=lambda: stop_func_ret_val
+        "testing", streaming_process, stop_func=lambda: stop_func_ret_val
     )
     # fmt: off
     assert capsys.readouterr().out == (
@@ -94,10 +94,10 @@ def test_stop_func(mock_sub, popen_process, stop_func_ret_val, capsys):
         "output line 3\n"
     )
     # fmt: on
-    mock_sub.cleanup.assert_called_once_with("testing", popen_process)
+    mock_sub.cleanup.assert_called_once_with("testing", streaming_process)
 
 
-def test_stuck_streamer(mock_sub, popen_process, monkeypatch, capsys):
+def test_stuck_streamer(mock_sub, streaming_process, monkeypatch, capsys):
     """Following a KeyboardInterrupt, output streaming returns even if the
     output streamer becomes stuck."""
 
@@ -127,7 +127,7 @@ def test_stuck_streamer(mock_sub, popen_process, monkeypatch, capsys):
     send_ctrl_c = mock.MagicMock()
     send_ctrl_c.side_effect = [False, KeyboardInterrupt]
     with pytest.raises(KeyboardInterrupt):
-        mock_sub.stream_output("testing", popen_process, stop_func=send_ctrl_c)
+        mock_sub.stream_output("testing", streaming_process, stop_func=send_ctrl_c)
 
     # fmt: off
     assert capsys.readouterr().out == (
@@ -139,17 +139,17 @@ def test_stuck_streamer(mock_sub, popen_process, monkeypatch, capsys):
     monkeypatched_streamer_should_exit.set()
 
 
-def test_stdout_closes_unexpectedly(mock_sub, popen_process, monkeypatch, capsys):
+def test_stdout_closes_unexpectedly(mock_sub, streaming_process, monkeypatch, capsys):
     """Streamer silently exits from ValueError because stdout was closed."""
 
     def monkeypatch_ensure_str(value):
         """Close stdout when ensure_str() runs on output from readline()."""
-        popen_process.stdout.close()
+        streaming_process.stdout.close()
         return value
 
-    popen_process.stdout = StringIO(initial_value="output line 1\noutput line 2")
+    streaming_process.stdout = StringIO(initial_value="output line 1\noutput line 2")
     monkeypatch.setattr(subprocess, "ensure_str", monkeypatch_ensure_str)
 
-    mock_sub.stream_output("testing", popen_process)
+    mock_sub.stream_output("testing", streaming_process)
 
     assert capsys.readouterr().out == "output line 1\n"
diff --git a/tests/integrations/subprocess/test_ensure_console_is_safe.py b/tests/integrations/subprocess/test_ensure_console_is_safe.py
index 1b81f24e..26c2cabd 100644
--- a/tests/integrations/subprocess/test_ensure_console_is_safe.py
+++ b/tests/integrations/subprocess/test_ensure_console_is_safe.py
@@ -1,3 +1,4 @@
+import subprocess
 from unittest.mock import ANY, MagicMock, Mock
 
 import pytest
@@ -11,9 +12,8 @@ def mock_sub(mock_tools):
         wraps=mock_tools.input.release_console_control
     )
     mock_sub = Subprocess(mock_tools)
+    mock_sub._subprocess = MagicMock(spec_set=subprocess)
     mock_sub._run_and_stream_output = MagicMock()
-    mock_sub._subprocess.run = MagicMock()
-    mock_sub._subprocess.check_output = MagicMock()
     return mock_sub
 
 
@@ -26,11 +26,7 @@ def test_run_windows_batch_script(mock_sub, batch_script):
     with mock_sub.tools.input.wait_bar("Testing..."):
         mock_sub.run([batch_script, "World"])
 
-    mock_sub._subprocess.run.assert_called_with(
-        [batch_script, "World"],
-        text=True,
-        encoding=ANY,
-    )
+    mock_sub._run_and_stream_output.assert_called_with([batch_script, "World"])
     mock_sub.tools.input.release_console_control.assert_called_once()
 
 
@@ -64,12 +60,7 @@ def test_negative_condition_not_controlled(mock_sub, cmdline, kwargs):
     """Passthrough to Subprocess if conditions to release console control are
     not met while the console is not controlled."""
     mock_sub.run(cmdline, **kwargs)
-    mock_sub._subprocess.run.assert_called_with(
-        cmdline,
-        **kwargs,
-        text=True,
-        encoding=ANY,
-    )
+    mock_sub._run_and_stream_output.assert_called_with(cmdline, **kwargs)
 
     mock_sub.check_output(cmdline, **kwargs)
     mock_sub._subprocess.check_output.assert_called_with(
diff --git a/tests/integrations/xcode/test_confirm_xcode_license_accepted.py b/tests/integrations/xcode/test_confirm_xcode_license_accepted.py
index 33e5525d..1f923840 100644
--- a/tests/integrations/xcode/test_confirm_xcode_license_accepted.py
+++ b/tests/integrations/xcode/test_confirm_xcode_license_accepted.py
@@ -60,6 +60,7 @@ def test_accept_license(mock_tools):
     mock_tools.subprocess.run.assert_called_once_with(
         ["sudo", "xcodebuild", "-license"],
         check=True,
+        stream_output=False,
     )
 
 
@@ -87,6 +88,7 @@ def test_sudo_fail(mock_tools):
     mock_tools.subprocess.run.assert_called_once_with(
         ["sudo", "xcodebuild", "-license"],
         check=True,
+        stream_output=False,
     )
 
 
@@ -113,6 +115,7 @@ def test_license_not_accepted(mock_tools):
     mock_tools.subprocess.run.assert_called_once_with(
         ["sudo", "xcodebuild", "-license"],
         check=True,
+        stream_output=False,
     )
 
 
@@ -136,6 +139,7 @@ def test_license_status_unknown(capsys, mock_tools):
     mock_tools.subprocess.run.assert_called_once_with(
         ["sudo", "xcodebuild", "-license"],
         check=True,
+        stream_output=False,
     )
 
     # ...but stdout contains a warning
diff --git a/tests/platforms/android/gradle/test_run.py b/tests/platforms/android/gradle/test_run.py
index 93de4ce4..96ada255 100644
--- a/tests/platforms/android/gradle/test_run.py
+++ b/tests/platforms/android/gradle/test_run.py
@@ -1,5 +1,6 @@
 import os
 import platform
+import subprocess
 import sys
 import time
 from os.path import normpath
@@ -266,4 +267,5 @@ def test_log_file_extra(run_command, monkeypatch):
             "ANDROID_SDK_ROOT": str(run_command.tools.android_sdk.root_path),
             "JAVA_HOME": str(run_command.tools.java.java_home),
         },
+        stderr=subprocess.STDOUT,
     )
diff --git a/tests/platforms/linux/appimage/test_run.py b/tests/platforms/linux/appimage/test_run.py
index 04823d43..32ef1a55 100644
--- a/tests/platforms/linux/appimage/test_run.py
+++ b/tests/platforms/linux/appimage/test_run.py
@@ -80,7 +80,6 @@ def test_run_app(first_app_config, tmp_path):
         ],
         cwd=tmp_path / "home",
         check=True,
-        stream_output=True,
     )
 
 
@@ -114,7 +113,6 @@ def test_run_app_failed(first_app_config, tmp_path):
         ],
         cwd=tmp_path / "home",
         check=True,
-        stream_output=True,
     )
 
 
@@ -147,7 +145,6 @@ def test_run_app_ctrl_c(first_app_config, tmp_path, capsys):
         ],
         cwd=tmp_path / "home",
         check=True,
-        stream_output=True,
     )
 
     # Shows the try block for KeyboardInterrupt was entered
diff --git a/tests/platforms/windows/app/test_run.py b/tests/platforms/windows/app/test_run.py
index 457f84ae..9b138da8 100644
--- a/tests/platforms/windows/app/test_run.py
+++ b/tests/platforms/windows/app/test_run.py
@@ -37,7 +37,6 @@ def test_run_app(first_app_config, tmp_path):
         ],
         cwd=tmp_path / "home",
         check=True,
-        stream_output=True,
     )
 
 
@@ -73,7 +72,6 @@ def test_run_app_failed(first_app_config, tmp_path):
         ],
         cwd=tmp_path / "home",
         check=True,
-        stream_output=True,
     )
 
 
@@ -108,7 +106,6 @@ def test_run_app_ctrl_c(first_app_config, tmp_path, capsys):
         ],
         cwd=tmp_path / "home",
         check=True,
-        stream_output=True,
     )
 
     # Shows the try block for KeyboardInterrupt was entered
diff --git a/tests/platforms/windows/visualstudio/test_run.py b/tests/platforms/windows/visualstudio/test_run.py
index 614a794f..165aef34 100644
--- a/tests/platforms/windows/visualstudio/test_run.py
+++ b/tests/platforms/windows/visualstudio/test_run.py
@@ -37,5 +37,4 @@ def test_run_app(first_app_config, tmp_path):
         ],
         cwd=tmp_path / "home",
         check=True,
-        stream_output=True,
     )