From 701a7559d398b72852772f36eaee0526ae69e008 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Fri, 25 Sep 2026 11:39:42 -0500 Subject: [PATCH] test(desktop): pin the detached Windows launch and clean Ctrl-C exit Co-authored-by: briandevans <252620095+briandevans@users.noreply.github.com> Co-authored-by: webtecnica <75556242+webtecnica@users.noreply.github.com> --- tests/hermes_cli/test_gui_command.py | 132 +++++++++++++++++++++++++++ 1 file changed, 132 insertions(+) diff --git a/tests/hermes_cli/test_gui_command.py b/tests/hermes_cli/test_gui_command.py index 2c3c2681e4..c7c066a007 100644 --- a/tests/hermes_cli/test_gui_command.py +++ b/tests/hermes_cli/test_gui_command.py @@ -1015,6 +1015,138 @@ def test_gui_password_store_bridge_is_linux_only(tmp_path, monkeypatch): assert "HERMES_DESKTOP_PASSWORD_STORE" not in launch_env +# --------------------------------------------------------------------------- +# #58275: the Windows packaged launch must detach from the parent console +# (Popen + windows_detach_flags, DEVNULL stdio, immediate exit 0) instead of +# a console-inheriting subprocess.run that dies with the launching shell. +# #59848: on the foreground platforms, Ctrl-C in the attached terminal must +# exit cleanly instead of raising a KeyboardInterrupt traceback. +# --------------------------------------------------------------------------- + + +@pytest.mark.platforms("windows") +def test_gui_win32_launches_detached_and_returns(tmp_path, monkeypatch): + import hermes_cli._subprocess_compat as _subproc_compat + + root = _make_desktop_tree(tmp_path) + monkeypatch.setattr(cli_main, "PROJECT_ROOT", root) + packaged_exe = _make_packaged_executable(root, monkeypatch) + ok = subprocess.CompletedProcess([], 0) + + with patch("hermes_cli.source_build.prepare_source_dependencies", return_value=ok), \ + patch("hermes_cli.main_desktop._desktop_build_needed", return_value=False), \ + patch("hermes_cli.main_desktop._desktop_exe_integrity_error", return_value=None), \ + patch("hermes_cli.config.load_config", return_value={}), \ + patch("hermes_cli.main.subprocess.Popen") as mock_popen, \ + patch("hermes_cli.main.subprocess.run") as mock_run, \ + pytest.raises(SystemExit) as exc: + cli_main.cmd_gui(_ns(skip_build=True)) + + # Parent returns cleanly so the user can close the launching shell. + assert exc.value.code == 0 + # The blocking, console-inheriting run() path must NOT be used for launch. + mock_run.assert_not_called() + # Detached spawn happened exactly once, targeting the packaged exe with the + # Windows detach creationflags and fully severed stdio. + mock_popen.assert_called_once() + call = mock_popen.call_args + assert call.args[0][0] == str(packaged_exe) + assert call.kwargs["creationflags"] == _subproc_compat.windows_detach_flags() + assert call.kwargs["stdin"] is subprocess.DEVNULL + assert call.kwargs["stdout"] is subprocess.DEVNULL + assert call.kwargs["stderr"] is subprocess.DEVNULL + + +@pytest.mark.platforms("windows") +def test_gui_win32_detach_falls_back_without_breakaway(tmp_path, monkeypatch): + import hermes_cli._subprocess_compat as _subproc_compat + + root = _make_desktop_tree(tmp_path) + monkeypatch.setattr(cli_main, "PROJECT_ROOT", root) + _make_packaged_executable(root, monkeypatch) + ok = subprocess.CompletedProcess([], 0) + + breakaway_denied = PermissionError("breakaway denied") + breakaway_denied.winerror = 5 + + with patch("hermes_cli.source_build.prepare_source_dependencies", return_value=ok), \ + patch("hermes_cli.main_desktop._desktop_build_needed", return_value=False), \ + patch("hermes_cli.main_desktop._desktop_exe_integrity_error", return_value=None), \ + patch("hermes_cli.config.load_config", return_value={}), \ + patch("hermes_cli.main.subprocess.Popen", + side_effect=[breakaway_denied, None]) as mock_popen, \ + pytest.raises(SystemExit) as exc: + cli_main.cmd_gui(_ns(skip_build=True)) + + assert exc.value.code == 0 + assert mock_popen.call_count == 2 + assert ( + mock_popen.call_args_list[0].kwargs["creationflags"] + == _subproc_compat.windows_detach_flags() + ) + assert ( + mock_popen.call_args_list[1].kwargs["creationflags"] + == _subproc_compat.windows_detach_flags_without_breakaway() + ) + + +@pytest.mark.platforms("windows") +def test_gui_win32_detach_reraises_non_breakaway_oserror(tmp_path, monkeypatch): + root = _make_desktop_tree(tmp_path) + monkeypatch.setattr(cli_main, "PROJECT_ROOT", root) + _make_packaged_executable(root, monkeypatch) + ok = subprocess.CompletedProcess([], 0) + + spawn_error = OSError("The system cannot find the file specified") + spawn_error.winerror = 2 # ERROR_FILE_NOT_FOUND — unrelated to breakaway. + + with patch("hermes_cli.source_build.prepare_source_dependencies", return_value=ok), \ + patch("hermes_cli.main_desktop._desktop_build_needed", return_value=False), \ + patch("hermes_cli.main_desktop._desktop_exe_integrity_error", return_value=None), \ + patch("hermes_cli.config.load_config", return_value={}), \ + patch("hermes_cli.main.subprocess.Popen", + side_effect=[spawn_error, None]) as mock_popen, \ + pytest.raises(OSError) as exc: + cli_main.cmd_gui(_ns(skip_build=True)) + + assert exc.value.winerror == 2 + # Only the first (breakaway) attempt ran; no doomed retry masked the error. + assert mock_popen.call_count == 1 + + +@pytest.mark.platforms("macos", "linux") +def test_gui_foreground_launch_ctrl_c_exits_cleanly(tmp_path, monkeypatch, capsys): + """Ctrl-C during the attached launch is a clean close, not a traceback (#59848). + + On the foreground platforms the launcher intentionally stays attached to the + Electron child; a KeyboardInterrupt raised through subprocess.run must exit + 0 with a short message instead of the raw traceback the reporter saw (which, + unhandled, aborts the whole CLI process). + """ + root = _make_desktop_tree(tmp_path) + monkeypatch.setattr(cli_main, "PROJECT_ROOT", root) + packaged_exe = _make_packaged_executable(root, monkeypatch) + ok = subprocess.CompletedProcess([], 0) + + def _interrupted_launch(*call_args, **kwargs): + raise KeyboardInterrupt() + + with patch("hermes_cli.source_build.prepare_source_dependencies", return_value=ok), \ + patch("hermes_cli.main_desktop._desktop_build_needed", return_value=False), \ + patch("hermes_cli.main_desktop._desktop_exe_integrity_error", return_value=None), \ + patch("hermes_cli.config.load_config", return_value={}), \ + patch("hermes_cli.main.subprocess.run", side_effect=_interrupted_launch) as mock_run, \ + patch("hermes_cli.main.subprocess.Popen") as mock_popen, \ + pytest.raises(SystemExit) as exc: + cli_main.cmd_gui(_ns(skip_build=True)) + + assert exc.value.code == 0 + mock_popen.assert_not_called() + assert mock_run.call_count == 1 + assert mock_run.call_args.args[0][0] == str(packaged_exe) + assert "closed" in capsys.readouterr().out.lower() + + # --------------------------------------------------------------------------- # #86443: stage-and-swap — a failed Desktop rebuild must never remove the # working app. electron-builder packs IN PLACE (before-pack.mjs wipes