Every non-zero gh exit collapsed to a generic "is gh installed and authenticated?" — a lie whenever gh was fine and the real failure was "no commits between main and feature", a missing upstream, or a refused push (#87731). The user had to drop to a terminal to learn what gh already printed. - apps/desktop git-review-ops.ts runGh() now resolves {ok, stdout, stderr} (execFile's err.stderr carries the exit's own stderr), and reviewCreatePr() prefixes the surfaced message with gh's reason, keeping the generic text only when gh reported nothing. - hermes_cli web_git.py _gh() keeps (ok, stdout, stderr) through the same collapse — _run already captured stderr; it was discarded at the tuple boundary — and review_create_pr() surfaces a bounded stderr tail (400 chars) plus the gh context. Tests pin the contract with unique stderr markers and non-zero exits on both wrappers; successful-path return contracts unchanged. Electron half salvaged from PR #87751 (author preserved); CLI half added per the issue's acceptance scope. Fixes #87731
91 lines
4.1 KiB
Python
91 lines
4.1 KiB
Python
"""Behavioral tests for the gh wrappers' failure reporting (#87731).
|
|
|
|
`_gh` used to collapse every non-zero exit to `(False, "")` and
|
|
`review_create_pr` raised a generic "is gh installed and authenticated?"
|
|
message — which lied whenever gh was fine and the failure was real
|
|
("no commits between main and feature", a missing upstream, a refused
|
|
push). These tests pin the contract that stderr survives the wrapper and
|
|
reaches the surfaced error, with the generic text reserved for the case
|
|
gh itself reported nothing.
|
|
"""
|
|
|
|
from subprocess import CompletedProcess
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
|
|
import hermes_cli.web_git as web_git
|
|
|
|
|
|
def _proc(returncode: int = 0, stdout: str = "", stderr: str = "") -> CompletedProcess:
|
|
return CompletedProcess(args=["gh"], returncode=returncode, stdout=stdout, stderr=stderr)
|
|
|
|
|
|
def _make_gh(returncode: int, stdout: str = "", stderr: str = ""):
|
|
"""Patch gh as installed (shutil.which) and running with the given result."""
|
|
return patch.object(web_git, "_run", return_value=_proc(returncode, stdout, stderr))
|
|
|
|
|
|
class TestGhRetainsStderr:
|
|
def test_success_returns_ok_with_stdout_and_stderr(self, tmp_path):
|
|
with patch.object(web_git.shutil, "which", return_value="/usr/bin/gh"), _make_gh(0, "ok\n", ""):
|
|
ok, out, err = web_git._gh(str(tmp_path), ["auth", "status"])
|
|
|
|
assert ok is True
|
|
assert out == "ok\n"
|
|
assert err == ""
|
|
|
|
def test_nonzero_exit_keeps_exit_status_and_stderr(self, tmp_path):
|
|
with patch.object(web_git.shutil, "which", return_value="/usr/bin/gh"), _make_gh(
|
|
1, "", "no commits between main and feature"
|
|
):
|
|
ok, out, err = web_git._gh(str(tmp_path), ["pr", "create", "--fill"])
|
|
|
|
assert ok is False
|
|
assert out == ""
|
|
assert err == "no commits between main and feature"
|
|
|
|
def test_missing_gh_binary_is_not_ok_without_raising(self, tmp_path):
|
|
with patch.object(web_git.shutil, "which", return_value=None):
|
|
ok, out, err = web_git._gh(str(tmp_path), ["auth", "status"])
|
|
|
|
assert (ok, out, err) == (False, "", "")
|
|
|
|
|
|
class TestReviewCreatePrSurfacesGhStderr:
|
|
def test_failure_message_carries_gh_stderr_marker(self, tmp_path):
|
|
# A unique marker: the assertion fails if the wrapper regresses to the
|
|
# generic message or drops stderr anywhere between exec and the raise.
|
|
with patch.object(web_git.shutil, "which", return_value="/usr/bin/gh"), _make_gh(
|
|
1, "", "gh: no commits between main and bb/fix-wave2g"
|
|
):
|
|
with pytest.raises(RuntimeError, match="no commits between main and bb/fix-wave2g"):
|
|
web_git.review_create_pr(str(tmp_path))
|
|
|
|
def test_failure_message_still_names_gh_when_stderr_is_empty(self, tmp_path):
|
|
with patch.object(web_git.shutil, "which", return_value="/usr/bin/gh"), _make_gh(1, "", " "):
|
|
with pytest.raises(RuntimeError, match="is gh installed and authenticated"):
|
|
web_git.review_create_pr(str(tmp_path))
|
|
|
|
def test_success_path_returns_the_pr_url(self, tmp_path):
|
|
with patch.object(web_git.shutil, "which", return_value="/usr/bin/gh"), _make_gh(
|
|
0, "https://github.com/org/repo/pull/1234\n", ""
|
|
):
|
|
result = web_git.review_create_pr(str(tmp_path))
|
|
|
|
assert result == {"url": "https://github.com/org/repo/pull/1234"}
|
|
|
|
def test_oversized_stderr_is_bounded_to_its_tail(self, tmp_path):
|
|
marker = "TRAILING-MARKER-reason"
|
|
stderr = "x" * 5_000 + "\n" + marker
|
|
with patch.object(web_git.shutil, "which", return_value="/usr/bin/gh"), _make_gh(1, "", stderr):
|
|
with pytest.raises(RuntimeError) as excinfo:
|
|
web_git.review_create_pr(str(tmp_path))
|
|
|
|
message = str(excinfo.value)
|
|
assert marker in message
|
|
# The 5_000-char noise prefix does not ride along: the surfaced detail
|
|
# is bounded to the configured tail, not the whole stderr.
|
|
assert len(message) <= len("gh pr create failed: ") + web_git._GH_ERR_TAIL_CHARS
|
|
assert "x" * web_git._GH_ERR_TAIL_CHARS not in message
|