Repository: beeware/briefcase
Pull request #1992 (2024-09-10 15:28:40)

Allow test mode for console apps on macOS
## Changes
- Directly run and stream console apps on macOS to allow capturing testing outcome

## Related
- https://github.com/beeware/toga/pull/2822
- https://github.com/beeware/toga/pull/2823

## 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


Patch:
diff --git a/changes/1992.bugfix.rst b/changes/1992.bugfix.rst
new file mode 100644
index 00000000..ea2129d6
--- /dev/null
+++ b/changes/1992.bugfix.rst
@@ -0,0 +1,1 @@
+The ``--test`` flag now works for console apps for macOS.
diff --git a/src/briefcase/platforms/macOS/__init__.py b/src/briefcase/platforms/macOS/__init__.py
index f31b3271..536327b9 100644
--- a/src/briefcase/platforms/macOS/__init__.py
+++ b/src/briefcase/platforms/macOS/__init__.py
@@ -250,10 +250,11 @@ class macOSRunMixin:
         """
         # Console apps must operate in non-streaming mode so that console input can
         # be handled correctly. However, if we're in test mode, we *must* stream so
-        # that we can see the test exit sentinel
-        if app.console_app and not test_mode:
+        # that we can see the test exit sentinel.
+        if app.console_app:
             self.run_console_app(
                 app,
+                test_mode=test_mode,
                 passthrough=passthrough,
                 **kwargs,
             )
@@ -268,32 +269,51 @@ class macOSRunMixin:
     def run_console_app(
         self,
         app: AppConfig,
+        test_mode: bool,
         passthrough: list[str],
         **kwargs,
     ):
         """Start the console application.
 
         :param app: The config object for the app
+        :param test_mode: Boolean; Is the app running in test mode?
         :param passthrough: The list of arguments to pass to the app
         """
-        try:
-            kwargs = self._prepare_app_kwargs(app=app, test_mode=False)
-
-            # Start the app directly
-            self.logger.info("=" * 75)
-            self.tools.subprocess.run(
-                [self.binary_path(app) / "Contents" / "MacOS" / f"{app.formal_name}"]
-                + (passthrough if passthrough else []),
+        sub_kwargs = self._prepare_app_kwargs(app=app, test_mode=test_mode)
+        cmdline = [self.binary_path(app) / f"Contents/MacOS/{app.formal_name}"]
+        cmdline.extend(passthrough)
+
+        if test_mode:
+            # Stream the app's output for testing.
+            # When a console app runs normally, its stdout should be connected
+            # directly to the terminal to properly display the app. When its test
+            # suite is running, though, Briefcase should stream the output to
+            # capture the testing outcome.
+            app_popen = self.tools.subprocess.Popen(
+                cmdline,
                 cwd=self.tools.home_path,
-                check=True,
-                stream_output=False,
-                **kwargs,
+                stdout=subprocess.PIPE,
+                stderr=subprocess.STDOUT,
+                bufsize=1,
+                **sub_kwargs,
             )
+            self._stream_app_logs(app, popen=app_popen, test_mode=test_mode)
 
-        except subprocess.CalledProcessError:
-            # The command line app *could* returns an error code, which is entirely legal.
-            # Ignore any subprocess error here.
-            pass
+        else:
+            try:
+                # Start the app directly
+                self.logger.info("=" * 75)
+                self.tools.subprocess.run(
+                    cmdline,
+                    cwd=self.tools.home_path,
+                    check=True,
+                    stream_output=False,
+                    **sub_kwargs,
+                )
+            except subprocess.CalledProcessError:
+                # The command line app *could* returns an error code, which is entirely legal.
+                # Ignore any subprocess error here.
+                pass
 
     def run_gui_app(
         self,
@@ -347,7 +367,7 @@ class macOSRunMixin:
         app_pid = None
         try:
             # Set up the log stream
-            kwargs = self._prepare_app_kwargs(app=app, test_mode=test_mode)
+            sub_kwargs = self._prepare_app_kwargs(app=app, test_mode=test_mode)
 
             # Start the app in a way that lets us stream the logs
             self.tools.subprocess.run(
@@ -356,7 +376,7 @@ class macOSRunMixin:
                 + ((["--args"] + passthrough) if passthrough else []),
                 cwd=self.tools.home_path,
                 check=True,
-                **kwargs,
+                **sub_kwargs,
             )
 
             # Find the App process ID so log streaming can exit when the app exits


Test patch:
diff --git a/tests/platforms/macOS/app/test_run.py b/tests/platforms/macOS/app/test_run.py
index d82c0dee..fcd619ab 100644
--- a/tests/platforms/macOS/app/test_run.py
+++ b/tests/platforms/macOS/app/test_run.py
@@ -25,7 +25,7 @@ def run_command(tmp_path):
 
     # 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):
+    def mock_stream_app_logs(app, stop_func=lambda: 1 + 1, **kwargs):
         stop_func()
 
     command._stream_app_logs.side_effect = mock_stream_app_logs
@@ -242,19 +242,14 @@ def test_run_gui_app_find_pid_failed(
     run_command.tools.os.kill.assert_not_called()
 
 
-@pytest.mark.parametrize("is_console_app", [True, False])
-def test_run_app_test_mode(
+def test_run_gui_app_test_mode(
     run_command,
     first_app_config,
-    is_console_app,
     sleep_zero,
     tmp_path,
     monkeypatch,
 ):
-    """A macOS app can be started in test mode."""
-    # Test mode is the same regardless of whether it's test mode or not.
-    first_app_config.console_app = is_console_app
-
+    """A macOS GUI app can be started in test mode."""
     # Mock a popen object that represents the log stream
     log_stream_process = mock.MagicMock(spec_set=subprocess.Popen)
     run_command.tools.subprocess.Popen.return_value = log_stream_process
@@ -358,6 +353,72 @@ def test_run_console_app_with_passthrough(
     run_command._stream_app_logs.assert_not_called()
 
 
+def test_run_console_app_test_mode(run_command, first_app_config, sleep_zero, tmp_path):
+    """A macOS console app can be started in test mode."""
+    first_app_config.console_app = True
+
+    # Mock a popen object that represents the app
+    app_process = mock.MagicMock(spec_set=subprocess.Popen)
+    run_command.tools.subprocess.Popen.return_value = app_process
+
+    run_command.run_app(first_app_config, test_mode=True, passthrough=[])
+
+    # Calls were made to start the app and to start a log stream.
+    bin_path = run_command.binary_path(first_app_config)
+    run_command.tools.subprocess.Popen.assert_called_with(
+        [bin_path / "Contents/MacOS/First App"],
+        cwd=tmp_path / "home",
+        env={"BRIEFCASE_MAIN_MODULE": "tests.first_app"},
+        stdout=subprocess.PIPE,
+        stderr=subprocess.STDOUT,
+        bufsize=1,
+    )
+
+    # The log stream was not started
+    run_command._stream_app_logs.assert_called_with(
+        first_app_config,
+        popen=app_process,
+        test_mode=True,
+    )
+
+
+def test_run_console_app_test_mode_with_passthrough(
+    run_command,
+    first_app_config,
+    sleep_zero,
+    tmp_path,
+):
+    """A macOS console app can be started in test mode with parameters and debug
+    mode."""
+    run_command.logger.verbosity = LogLevel.DEBUG
+
+    first_app_config.console_app = True
+
+    # Mock a popen object that represents the app
+    app_process = mock.MagicMock(spec_set=subprocess.Popen)
+    run_command.tools.subprocess.Popen.return_value = app_process
+
+    run_command.run_app(first_app_config, test_mode=True, passthrough=["foo", "--bar"])
+
+    # Calls were made to start the app and to start a log stream.
+    bin_path = run_command.binary_path(first_app_config)
+    run_command.tools.subprocess.Popen.assert_called_with(
+        [bin_path / "Contents/MacOS/First App", "foo", "--bar"],
+        cwd=tmp_path / "home",
+        env={"BRIEFCASE_DEBUG": "1", "BRIEFCASE_MAIN_MODULE": "tests.first_app"},
+        stdout=subprocess.PIPE,
+        stderr=subprocess.STDOUT,
+        bufsize=1,
+    )
+
+    # The log stream was not started
+    run_command._stream_app_logs.assert_called_with(
+        first_app_config,
+        popen=app_process,
+        test_mode=True,
+    )
+
+
 def test_run_console_app_failed(run_command, first_app_config, sleep_zero, tmp_path):
     """If there's a problem started a console app, an exception is raised."""
     # Set the app to be a console app