fix(vmgui): pre-flight X display, stop failing silently
`vmgui` sent the child's stderr to /dev/null, so when the X display was unreachable (the common case: sudo -u ci-runner loses the desktop user's XAUTHORITY and xhost wasn't granted) the GUI died with "cannot open display" and the command still printed success — "nothing opens". Now it: - pre-flights the display with xdpyinfo and aborts with the exact `xhost +SI:localuser:ci-runner` hint instead of spawning a doomed process; - drops an inherited XAUTHORITY that this user can't read (falls back to host-based xhost auth); - logs the GUI's stdio to /tmp/vmgui-*.log and grace-polls 1.5s, surfacing the log if the GUI exits immediately; - errors (non-zero) when no DISPLAY is resolvable. Runbook §4.7 updated with the "if nothing opens" guidance. 13 tests; green ≥90%. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -502,26 +502,44 @@ def test_vm_cleanup_backend_unavailable(monkeypatch: pytest.MonkeyPatch, tmp_pat
|
||||
# ── vmgui ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _capture_launch(monkeypatch: pytest.MonkeyPatch) -> dict[str, Any]:
|
||||
"""Patch _launch_gui to record argv/env instead of spawning a process."""
|
||||
class _FakeProc:
|
||||
"""Minimal subprocess.Popen stand-in for vmgui tests."""
|
||||
|
||||
def __init__(self, pid: int = 4242, rc: int | None = None) -> None:
|
||||
self.pid = pid
|
||||
self._rc = rc
|
||||
|
||||
def poll(self) -> int | None:
|
||||
return self._rc
|
||||
|
||||
|
||||
def _capture_launch(
|
||||
monkeypatch: pytest.MonkeyPatch, *, rc: int | None = None
|
||||
) -> dict[str, Any]:
|
||||
"""Patch _launch_gui + preflight + sleep so no real process is spawned."""
|
||||
captured: dict[str, Any] = {}
|
||||
|
||||
def _fake(argv: list[str], env: dict[str, str]) -> int:
|
||||
def _fake(argv: list[str], env: dict[str, str], log_path: Path) -> _FakeProc:
|
||||
captured["argv"] = argv
|
||||
captured["env"] = env
|
||||
return 4242
|
||||
captured["log_path"] = log_path
|
||||
if rc is not None and rc != 0:
|
||||
log_path.write_text("cannot open display\n")
|
||||
return _FakeProc(rc=rc)
|
||||
|
||||
monkeypatch.setattr(vm_module, "_launch_gui", _fake)
|
||||
monkeypatch.setattr(vm_module, "_x_display_reachable", lambda _env: (True, ""))
|
||||
monkeypatch.setattr(vm_module.time, "sleep", lambda _s: None)
|
||||
return captured
|
||||
|
||||
|
||||
def test_vm_open_missing_vmx_errors(tmp_path: Path) -> None:
|
||||
def test_vmgui_missing_vmx_errors(tmp_path: Path) -> None:
|
||||
result = CliRunner().invoke(cli, ["vmgui", "--vmx", str(tmp_path / "nope.vmx")])
|
||||
assert result.exit_code != 0
|
||||
assert "VMX file not found" in result.output
|
||||
|
||||
|
||||
def test_vm_open_happy_path(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||
def test_vmgui_happy_path(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||
vmx = tmp_path / "clone.vmx"
|
||||
vmx.write_text("config")
|
||||
cap = _capture_launch(monkeypatch)
|
||||
@@ -533,7 +551,7 @@ def test_vm_open_happy_path(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) ->
|
||||
assert "pid 4242" in result.output
|
||||
|
||||
|
||||
def test_vm_open_power_on_adds_x(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||
def test_vmgui_power_on_adds_x(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||
vmx = tmp_path / "clone.vmx"
|
||||
vmx.write_text("config")
|
||||
cap = _capture_launch(monkeypatch)
|
||||
@@ -543,7 +561,7 @@ def test_vm_open_power_on_adds_x(monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
assert "-x" in cap["argv"]
|
||||
|
||||
|
||||
def test_vm_open_fullscreen_adds_capital_x(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||
def test_vmgui_fullscreen_adds_capital_x(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||
vmx = tmp_path / "clone.vmx"
|
||||
vmx.write_text("config")
|
||||
cap = _capture_launch(monkeypatch)
|
||||
@@ -555,7 +573,7 @@ def test_vm_open_fullscreen_adds_capital_x(monkeypatch: pytest.MonkeyPatch, tmp_
|
||||
assert "-X" in cap["argv"] and "-n" in cap["argv"]
|
||||
|
||||
|
||||
def test_vm_open_display_option_overrides_env(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||
def test_vmgui_display_option_overrides_env(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||
vmx = tmp_path / "clone.vmx"
|
||||
vmx.write_text("config")
|
||||
cap = _capture_launch(monkeypatch)
|
||||
@@ -566,42 +584,80 @@ def test_vm_open_display_option_overrides_env(monkeypatch: pytest.MonkeyPatch, t
|
||||
assert "display=:1" in result.output
|
||||
|
||||
|
||||
def test_vm_open_warns_without_display(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||
def test_vmgui_errors_without_display(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||
vmx = tmp_path / "clone.vmx"
|
||||
vmx.write_text("config")
|
||||
_capture_launch(monkeypatch)
|
||||
monkeypatch.delenv("DISPLAY", raising=False)
|
||||
result = CliRunner().invoke(cli, ["vmgui", "--vmx", str(vmx)])
|
||||
assert result.exit_code == 0, result.output
|
||||
assert result.exit_code != 0
|
||||
assert "no X display" in result.output
|
||||
|
||||
|
||||
def test_vm_open_launch_failure_is_reported(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||
def test_vmgui_preflight_failure_reports_xhost(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||
vmx = tmp_path / "clone.vmx"
|
||||
vmx.write_text("config")
|
||||
_capture_launch(monkeypatch)
|
||||
# Override the optimistic preflight with a failing probe.
|
||||
monkeypatch.setattr(
|
||||
vm_module, "_x_display_reachable", lambda _env: (False, "cannot open display :0")
|
||||
)
|
||||
monkeypatch.setenv("DISPLAY", ":0")
|
||||
result = CliRunner().invoke(cli, ["vmgui", "--vmx", str(vmx)])
|
||||
assert result.exit_code != 0
|
||||
assert "xhost +SI:localuser:ci-runner" in result.output
|
||||
|
||||
|
||||
def test_vmgui_drops_unreadable_xauthority(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||
vmx = tmp_path / "clone.vmx"
|
||||
vmx.write_text("config")
|
||||
cap = _capture_launch(monkeypatch)
|
||||
monkeypatch.setenv("DISPLAY", ":0")
|
||||
monkeypatch.setenv("XAUTHORITY", str(tmp_path / "missing.Xauthority"))
|
||||
result = CliRunner().invoke(cli, ["vmgui", "--vmx", str(vmx)])
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "XAUTHORITY" not in cap["env"]
|
||||
assert "dropping unreadable XAUTHORITY" in result.output
|
||||
|
||||
|
||||
def test_vmgui_immediate_exit_surfaces_log(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||
vmx = tmp_path / "clone.vmx"
|
||||
vmx.write_text("config")
|
||||
_capture_launch(monkeypatch, rc=1)
|
||||
monkeypatch.setenv("DISPLAY", ":0")
|
||||
result = CliRunner().invoke(cli, ["vmgui", "--vmx", str(vmx)])
|
||||
assert result.exit_code != 0
|
||||
assert "exited immediately" in result.output
|
||||
assert "cannot open display" in result.output
|
||||
|
||||
|
||||
def test_vmgui_launch_oserror_is_reported(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||
vmx = tmp_path / "clone.vmx"
|
||||
vmx.write_text("config")
|
||||
|
||||
def _boom(argv: list[str], env: dict[str, str]) -> int:
|
||||
def _boom(argv: list[str], env: dict[str, str], log_path: Path) -> Any:
|
||||
raise OSError("vmware not found")
|
||||
|
||||
monkeypatch.setattr(vm_module, "_launch_gui", _boom)
|
||||
monkeypatch.setattr(vm_module, "_x_display_reachable", lambda _env: (True, ""))
|
||||
monkeypatch.setattr(vm_module.time, "sleep", lambda _s: None)
|
||||
monkeypatch.setenv("DISPLAY", ":0")
|
||||
result = CliRunner().invoke(cli, ["vmgui", "--vmx", str(vmx)])
|
||||
assert result.exit_code != 0
|
||||
assert "failed to launch VMware GUI" in result.output
|
||||
|
||||
|
||||
def test_vm_open_without_vmx_launches_bare_gui(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def test_vmgui_without_vmx_launches_bare_gui(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
cap = _capture_launch(monkeypatch)
|
||||
monkeypatch.setenv("DISPLAY", ":0")
|
||||
result = CliRunner().invoke(cli, ["vmgui"])
|
||||
assert result.exit_code == 0, result.output
|
||||
# Only the binary (no vmx path, no power-on flags).
|
||||
assert cap["argv"][1:] == [] or cap["argv"][1:] == ["-n"]
|
||||
assert "-x" not in cap["argv"] and "-X" not in cap["argv"]
|
||||
assert "pid 4242" in result.output
|
||||
|
||||
|
||||
def test_vm_open_power_on_ignored_without_vmx(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def test_vmgui_power_on_ignored_without_vmx(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
cap = _capture_launch(monkeypatch)
|
||||
monkeypatch.setenv("DISPLAY", ":0")
|
||||
result = CliRunner().invoke(cli, ["vmgui", "--power-on"])
|
||||
|
||||
Reference in New Issue
Block a user