From e7af54898ec1445ef59bb26be9bacfed749913a0 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Tue, 24 Jun 2025 18:20:58 +0200 Subject: [PATCH] Add X11/Wayland information to version info Unfortunately there is no way to get this information from Qt, so I had to resort to some funny low-level C-like Python programming to directly use libwayland-client and Xlib. Fun was had! Hopefully this avoids having to ask for this information every time someone shows a bug/crash report, as there are various subtleties that can be specific to the Wayland compositor in use. --- doc/changelog.asciidoc | 10 + qutebrowser/misc/wmname.py | 334 +++++++++++++++++++++++++++++++ qutebrowser/utils/version.py | 17 +- tests/unit/misc/test_wmname.py | 312 +++++++++++++++++++++++++++++ tests/unit/utils/test_version.py | 73 ++++++- 5 files changed, 738 insertions(+), 8 deletions(-) create mode 100644 qutebrowser/misc/wmname.py create mode 100644 tests/unit/misc/test_wmname.py diff --git a/doc/changelog.asciidoc b/doc/changelog.asciidoc index a2cbdd6a8..3f7e37ce6 100644 --- a/doc/changelog.asciidoc +++ b/doc/changelog.asciidoc @@ -15,6 +15,16 @@ breaking changes (such as renamed commands) can happen in minor releases. // `Fixed` for any bug fixes. // `Security` to invite users to upgrade in case of vulnerabilities. +[[v3.6.0]] +v3.6.0 (unreleased) +------------------- + +Added +~~~~~ + +- The `:version` info now shows the X11 window manager / Wayland compositor name + (mostly useful for bug/crash reports) + [[v3.5.2]] v3.5.2 (unreleased) ------------------- diff --git a/qutebrowser/misc/wmname.py b/qutebrowser/misc/wmname.py new file mode 100644 index 000000000..04d267d14 --- /dev/null +++ b/qutebrowser/misc/wmname.py @@ -0,0 +1,334 @@ +# SPDX-FileCopyrightText: Florian Bruhin (The Compiler) +# +# SPDX-License-Identifier: GPL-3.0-or-later + +"""Utilities to get the name of the window manager (X11) / compositor (Wayland).""" + +from typing import NewType +from collections.abc import Iterator +import ctypes +import socket +import struct +import pathlib +import dataclasses +import contextlib +import ctypes.util + + +class Error(Exception): + """Base class for errors in this module.""" + + +class _WaylandDisplayStruct(ctypes.Structure): + pass + + +_WaylandDisplay = NewType("_WaylandDisplay", "ctypes._Pointer[_WaylandDisplayStruct]") + + +def _load_libwayland_client() -> ctypes.CDLL: + """Load the Wayland client library.""" + try: + return ctypes.CDLL("libwayland-client.so") + except OSError as e: + raise Error(f"Failed to load libwayland-client: {e}") + + +def _pid_from_fd(fd: int) -> int: + """Get the process ID from a file descriptor using SO_PEERCRED. + + https://stackoverflow.com/a/35827184 + """ + if not hasattr(socket, "SO_PEERCRED"): + raise Error("Missing socket.SO_PEERCRED") + + # struct ucred { + # pid_t pid; + # uid_t uid; + #  gid_t gid; + # }; // where all of those are integers + ucred_format = "3i" + ucred_size = struct.calcsize(ucred_format) + + try: + sock = socket.fromfd(fd, socket.AF_UNIX, socket.SOCK_STREAM) + except OSError as e: + raise Error(f"Error creating socket for fd {fd}: {e}") + + try: + ucred = sock.getsockopt(socket.SOL_SOCKET, socket.SO_PEERCRED, ucred_size) + except OSError as e: + raise Error(f"Error getting SO_PEERCRED for fd {fd}: {e}") + finally: + sock.close() + + pid, _uid, _gid = struct.unpack(ucred_format, ucred) + return pid + + +def _process_name_from_pid(pid: int) -> str: + """Get the process name from a PID by reading /proc/[pid]/cmdline.""" + proc_path = pathlib.Path(f"/proc/{pid}/cmdline") + try: + return proc_path.read_text(encoding="utf-8").replace("\0", " ").strip() + except OSError as e: + raise Error(f"Error opening {proc_path}: {e}") + + +@contextlib.contextmanager +def _wayland_display(wayland_client: ctypes.CDLL) -> Iterator[_WaylandDisplay]: + """Context manager to connect to a Wayland display.""" + wayland_client.wl_display_connect.argtypes = [ctypes.c_char_p] # name + wayland_client.wl_display_connect.restype = ctypes.POINTER(_WaylandDisplayStruct) + + wayland_client.wl_display_disconnect.argtypes = [ + ctypes.POINTER(_WaylandDisplayStruct) + ] + wayland_client.wl_display_disconnect.restype = None + + display = wayland_client.wl_display_connect(None) + if not display: + raise Error("Can't connect to display") + + try: + yield display + finally: + wayland_client.wl_display_disconnect(display) + + +def _wayland_get_fd(wayland_client: ctypes.CDLL, display: _WaylandDisplay) -> int: + """Get the file descriptor for the Wayland display.""" + wayland_client.wl_display_get_fd.argtypes = [ctypes.POINTER(_WaylandDisplayStruct)] + wayland_client.wl_display_get_fd.restype = ctypes.c_int + + fd = wayland_client.wl_display_get_fd(display) + if fd < 0: + raise Error(f"Failed to get Wayland display file descriptor: {fd}") + return fd + + +def wayland_compositor_name() -> str: + """Get the name of the running Wayland compositor. + + Approach based on: + https://stackoverflow.com/questions/69302630/wayland-client-get-compositor-name + """ + wayland_client = _load_libwayland_client() + with _wayland_display(wayland_client) as display: + fd = _wayland_get_fd(wayland_client, display) + pid = _pid_from_fd(fd) + process_name = _process_name_from_pid(pid) + return process_name + + +@dataclasses.dataclass +class _X11Atoms: + NET_SUPPORTING_WM_CHECK: int + NET_WM_NAME: int + UTF8_STRING: int + + +class _X11DisplayStruct(ctypes.Structure): + pass + + +_X11Display = NewType("_X11Display", "ctypes._Pointer[_X11DisplayStruct]") +_X11Window = NewType("_X11Window", int) + + +def _x11_load_lib() -> ctypes.CDLL: + """Load the X11 library.""" + lib = ctypes.util.find_library("X11") + if lib is None: + raise Error("X11 library not found") + + try: + return ctypes.CDLL(lib) + except OSError as e: + raise Error(f"Failed to load X11 library: {e}") + + +@contextlib.contextmanager +def _x11_open_display(xlib: ctypes.CDLL) -> Iterator[_X11Display]: + """Open a connection to the X11 display.""" + xlib.XOpenDisplay.argtypes = [ctypes.c_char_p] + xlib.XOpenDisplay.restype = ctypes.POINTER(_X11DisplayStruct) + + xlib.XCloseDisplay.argtypes = [ctypes.POINTER(_X11DisplayStruct)] + xlib.XCloseDisplay.restype = None + + display = xlib.XOpenDisplay(None) + if not display: + raise Error("Cannot open display") + + try: + yield display + finally: + xlib.XCloseDisplay(display) + + +def _x11_intern_atom( + xlib: ctypes.CDLL, display: _X11Display, name: bytes, only_if_exists: bool = True +) -> int: + """Call xlib's XInternAtom function.""" + xlib.XInternAtom.argtypes = [ + ctypes.POINTER(_X11DisplayStruct), # Display + ctypes.c_char_p, # Atom name + ctypes.c_int, # Only if exists (bool) + ] + xlib.XInternAtom.restype = ctypes.c_ulong + + atom = xlib.XInternAtom(display, name, only_if_exists) + if atom == 0: + raise Error(f"Failed to intern atom: {name!r}") + + return atom + + +@contextlib.contextmanager +def _x11_get_window_property( + xlib: ctypes.CDLL, + display: _X11Display, + *, + window: _X11Window, + prop: int, + req_type: int, + length: int, + offset: int = 0, + delete: bool = False, +) -> Iterator[tuple["ctypes._Pointer[ctypes.c_ubyte]", ctypes.c_ulong]]: + """Call xlib's XGetWindowProperty function.""" + ret_actual_type = ctypes.c_ulong() + ret_actual_format = ctypes.c_int() + ret_nitems = ctypes.c_ulong() + ret_bytes_after = ctypes.c_ulong() + ret_prop = ctypes.POINTER(ctypes.c_ubyte)() + + xlib.XGetWindowProperty.argtypes = [ + ctypes.POINTER(_X11DisplayStruct), # Display + ctypes.c_ulong, # Window + ctypes.c_ulong, # Property + ctypes.c_long, # Offset + ctypes.c_long, # Length + ctypes.c_int, # Delete (bool) + ctypes.c_ulong, # Required type (Atom) + ctypes.POINTER(ctypes.c_ulong), # return: Actual type (Atom) + ctypes.POINTER(ctypes.c_int), # return: Actual format + ctypes.POINTER(ctypes.c_ulong), # return: Number of items + ctypes.POINTER(ctypes.c_ulong), # return: Bytes after + ctypes.POINTER(ctypes.POINTER(ctypes.c_ubyte)), # return: Property value + ] + xlib.XGetWindowProperty.restype = ctypes.c_int + + result = xlib.XGetWindowProperty( + display, + window, + prop, + offset, + length, + delete, + req_type, + ctypes.byref(ret_actual_type), + ctypes.byref(ret_actual_format), + ctypes.byref(ret_nitems), + ctypes.byref(ret_bytes_after), + ctypes.byref(ret_prop), + ) + if result != 0: + raise Error(f"XGetWindowProperty for {prop} failed: {result}") + if not ret_prop: + raise Error(f"Property {prop} is NULL") + if ret_actual_type.value != req_type: + raise Error( + f"Expected type {req_type}, got {ret_actual_type.value} for property {prop}" + ) + if ret_bytes_after.value != 0: + raise Error( + f"Expected no bytes after property {prop}, got {ret_bytes_after.value}" + ) + + try: + yield ret_prop, ret_nitems + finally: + xlib.XFree(ret_prop) + + +def _x11_get_wm_window( + xlib: ctypes.CDLL, display: _X11Display, *, atoms: _X11Atoms +) -> _X11Window: + """Get the _NET_SUPPORTING_WM_CHECK window.""" + xlib.XDefaultScreen.argtypes = [ctypes.POINTER(_X11DisplayStruct)] + xlib.XDefaultScreen.restype = ctypes.c_int + + xlib.XRootWindow.argtypes = [ + ctypes.POINTER(_X11DisplayStruct), # Display + ctypes.c_int, # Screen number + ] + xlib.XRootWindow.restype = ctypes.c_ulong + + screen = xlib.XDefaultScreen(display) + root_window = xlib.XRootWindow(display, screen) + + with _x11_get_window_property( + xlib, + display, + window=root_window, + prop=atoms.NET_SUPPORTING_WM_CHECK, + req_type=33, # XA_WINDOW + length=1, + ) as (prop, _nitems): + win = ctypes.cast(prop, ctypes.POINTER(ctypes.c_ulong)).contents.value + return _X11Window(win) + + +def _x11_get_wm_name( + xlib: ctypes.CDLL, + display: _X11Display, + *, + atoms: _X11Atoms, + wm_window: _X11Window, +) -> str: + """Get the _NET_WM_NAME property of the window manager.""" + with _x11_get_window_property( + xlib, + display, + window=wm_window, + prop=atoms.NET_WM_NAME, + req_type=atoms.UTF8_STRING, + length=1024, # somewhat arbitrary + ) as (prop, nitems): + if nitems.value <= 0: + raise Error(f"{nitems.value} items found in _NET_WM_NAME property") + wm_name = ctypes.string_at(prop, nitems.value).decode("utf-8") + if not wm_name: + raise Error("Window manager name is empty") + return wm_name + + +def x11_wm_name() -> str: + """Get the name of the running X11 window manager.""" + xlib = _x11_load_lib() + with _x11_open_display(xlib) as display: + atoms = _X11Atoms( + NET_SUPPORTING_WM_CHECK=_x11_intern_atom( + xlib, display, b"_NET_SUPPORTING_WM_CHECK" + ), + NET_WM_NAME=_x11_intern_atom(xlib, display, b"_NET_WM_NAME"), + UTF8_STRING=_x11_intern_atom(xlib, display, b"UTF8_STRING"), + ) + wm_window = _x11_get_wm_window(xlib, display, atoms=atoms) + return _x11_get_wm_name(xlib, display, atoms=atoms, wm_window=wm_window) + + +if __name__ == "__main__": + try: + wayland_name = wayland_compositor_name() + print(f"Wayland compositor name: {wayland_name}") + except Error as e: + print(f"Wayland error: {e}") + + try: + x11_name = x11_wm_name() + print(f"X11 window manager name: {x11_name}") + except Error as e: + print(f"X11 error: {e}") diff --git a/qutebrowser/utils/version.py b/qutebrowser/utils/version.py index 3a6c62802..655fa08b4 100644 --- a/qutebrowser/utils/version.py +++ b/qutebrowser/utils/version.py @@ -44,7 +44,7 @@ except ImportError: # pragma: no cover import qutebrowser from qutebrowser.utils import (log, utils, standarddir, usertypes, message, resources, qtutils) -from qutebrowser.misc import objects, earlyinit, sql, httpclient, pastebin, elf +from qutebrowser.misc import objects, earlyinit, sql, httpclient, pastebin, elf, wmname from qutebrowser.browser import pdfjs from qutebrowser.config import config if TYPE_CHECKING: @@ -978,7 +978,7 @@ def version_info() -> str: metaobj = style.metaObject() assert metaobj is not None lines.append('Style: {}'.format(metaobj.className())) - lines.append('Platform plugin: {}'.format(objects.qapp.platformName())) + lines.append('Qt Platform: {}'.format(gui_platform_info())) lines.append('OpenGL: {}'.format(opengl_info())) importpath = os.path.dirname(os.path.abspath(qutebrowser.__file__)) @@ -1147,6 +1147,19 @@ def opengl_info() -> Optional[OpenGLInfo]: # pragma: no cover old_context.makeCurrent(old_surface) +def gui_platform_info() -> str: + """Get the Qt GUI platform name, optionally with the WM/compositor name.""" + info = objects.qapp.platformName() + try: + if info == "xcb": + info += f" ({wmname.x11_wm_name()})" + elif info in ["wayland", "wayland-egl"]: + info += f" ({wmname.wayland_compositor_name()})" + except wmname.Error as e: + info += f" (Error: {e})" + return info + + def pastebin_version(pbclient: pastebin.PastebinClient = None) -> None: """Pastebin the version and log the url to messages.""" def _yank_url(url: str) -> None: diff --git a/tests/unit/misc/test_wmname.py b/tests/unit/misc/test_wmname.py new file mode 100644 index 000000000..4c4b6c50c --- /dev/null +++ b/tests/unit/misc/test_wmname.py @@ -0,0 +1,312 @@ +# SPDX-FileCopyrightText: Florian Bruhin (The Compiler) +# +# SPDX-License-Identifier: GPL-3.0-or-later + +import os +import sys +import socket +import ctypes +import ctypes.util +import unittest.mock +from collections.abc import Iterator + +import pytest +import pytest_mock +import pytestqt.qtbot +from qutebrowser.qt.widgets import QApplication, QWidget + +from qutebrowser.misc import wmname + + +def test_load_libwayland_client(): + """Test loading the Wayland client library, which might or might not exist.""" + try: + wmname._load_libwayland_client() + except wmname.Error: + pass + + +def test_load_libwayland_client_error(mocker: pytest_mock.MockerFixture): + """Test that an error in loading the Wayland client library raises an error.""" + mocker.patch("ctypes.CDLL", side_effect=OSError("Library not found")) + + with pytest.raises(wmname.Error, match="Failed to load libwayland-client"): + wmname._load_libwayland_client() + + +@pytest.fixture +def sock() -> Iterator[socket.socket]: + """Fixture to create a Unix domain socket.""" + parent_sock, child_sock = socket.socketpair(socket.AF_UNIX, socket.SOCK_STREAM) + yield parent_sock + parent_sock.close() + child_sock.close() + + +@pytest.mark.linux +def test_pid_from_fd(sock: socket.socket): + assert wmname._pid_from_fd(sock.fileno()) == os.getpid() + + +@pytest.mark.skipif( + not hasattr(socket, "SO_PEERCRED"), reason="socket.SO_PEERCRED not available" +) +def test_pid_from_fd_invalid(): + """Test that an invalid file descriptor raises an error.""" + with pytest.raises( + wmname.Error, + match=r"Error creating socket for fd -1: \[Errno 9\] Bad file descriptor", + ): + wmname._pid_from_fd(-1) + + +@pytest.mark.linux +def test_pid_from_fd_getsockopt_error( + sock: socket.socket, mocker: pytest_mock.MockerFixture +): + """Test that an error in getsockopt raises an error.""" + mocker.patch.object( + socket.socket, "getsockopt", side_effect=OSError("Mocked error") + ) + + with pytest.raises(wmname.Error, match="Error getting SO_PEERCRED for fd"): + wmname._pid_from_fd(sock.fileno()) + + +def test_pid_from_fd_no_so_peercred(monkeypatch: pytest.MonkeyPatch): + monkeypatch.delattr(socket, "SO_PEERCRED", raising=False) + with pytest.raises(wmname.Error, match=r"Missing socket\.SO_PEERCRED"): + wmname._pid_from_fd(-1) + + +@pytest.mark.linux +def test_process_name_from_pid(): + """Test getting the process name from a PID.""" + pid = os.getpid() + name = wmname._process_name_from_pid(pid) + assert os.path.basename(name.split()[0]) == os.path.basename(sys.executable) + + +def test_process_name_from_pid_invalid(): + """Test that an invalid PID raises an error.""" + with pytest.raises(wmname.Error, match=r"Error opening .proc.-1.cmdline"): + wmname._process_name_from_pid(-1) + + +@pytest.fixture +def libwayland_client_mock(mocker: pytest_mock.MockerFixture) -> None: + """Mock the libwayland-client library.""" + return mocker.Mock() + + +@pytest.fixture +def fake_wayland_display() -> wmname._WaylandDisplay: + return wmname._WaylandDisplay(ctypes.pointer(wmname._WaylandDisplayStruct())) + + +def test_wayland_display( + libwayland_client_mock: unittest.mock.Mock, + fake_wayland_display: wmname._WaylandDisplay, +): + """Test getting the Wayland display.""" + libwayland_client_mock.wl_display_connect.return_value = fake_wayland_display + + with wmname._wayland_display(libwayland_client_mock): + pass + + libwayland_client_mock.wl_display_connect.assert_called_once_with(None) + libwayland_client_mock.wl_display_disconnect.assert_called_once_with( + fake_wayland_display + ) + + +def test_wayland_display_error(libwayland_client_mock: unittest.mock.Mock): + """Test that an error in getting the Wayland display raises an error.""" + libwayland_client_mock.wl_display_connect.return_value = ctypes.c_void_p(0) + + with pytest.raises(wmname.Error, match="Can't connect to display"): + with wmname._wayland_display(libwayland_client_mock): + pass + + libwayland_client_mock.wl_display_disconnect.assert_not_called() # Not called on error + + +def test_wayland_get_fd( + libwayland_client_mock: unittest.mock.Mock, + fake_wayland_display: wmname._WaylandDisplay, +): + """Test getting the file descriptor from a Wayland display.""" + libwayland_client_mock.wl_display_get_fd.return_value = 42 + + fd = wmname._wayland_get_fd(libwayland_client_mock, fake_wayland_display) + assert fd == 42 + + libwayland_client_mock.wl_display_get_fd.assert_called_once_with( + fake_wayland_display + ) + + +def test_wayland_get_fd_error( + libwayland_client_mock: unittest.mock.Mock, + fake_wayland_display: wmname._WaylandDisplay, +): + """Test that an error in getting the file descriptor raises an error.""" + libwayland_client_mock.wl_display_get_fd.return_value = -1 + + with pytest.raises( + wmname.Error, match="Failed to get Wayland display file descriptor: -1" + ): + wmname._wayland_get_fd(libwayland_client_mock, fake_wayland_display) + + libwayland_client_mock.wl_display_get_fd.assert_called_once_with( + fake_wayland_display + ) + + +def test_wayland_real(): + """Test getting the Wayland window manager name.""" + try: + name = wmname.wayland_compositor_name() + except wmname.Error: + return + + assert isinstance(name, str) + assert name + + +def test_load_xlib(): + """Test loading Xlib, which might or might not exist.""" + try: + wmname._x11_load_lib() + except wmname.Error: + pass + + +def test_load_xlib_not_found(monkeypatch: pytest.MonkeyPatch): + """Test loading Xlib simulating a missing library.""" + monkeypatch.setattr(ctypes.util, "find_library", lambda x: None) + + with pytest.raises(wmname.Error, match="X11 library not found"): + wmname._x11_load_lib() + + +def test_load_xlib_error(mocker: pytest_mock.MockerFixture): + """Test that an error in loading Xlib raises an error.""" + mocker.patch.object(ctypes.util, "find_library", return_value="libX11.so.6") + mocker.patch.object(ctypes, "CDLL", side_effect=OSError("Failed to load library")) + + with pytest.raises( + wmname.Error, match="Failed to load X11 library: Failed to load library" + ): + wmname._x11_load_lib() + + +@pytest.fixture +def xlib_mock(mocker: pytest_mock.MockerFixture) -> None: + """Mock the XLib library.""" + return mocker.Mock() + + +@pytest.fixture +def fake_x11_display() -> wmname._X11Display: + return wmname._X11Display(ctypes.pointer(wmname._X11DisplayStruct())) + + +def test_x11_display( + xlib_mock: unittest.mock.Mock, + fake_x11_display: wmname._X11Display, +): + """Test getting the X11 display.""" + xlib_mock.XOpenDisplay.return_value = fake_x11_display + + with wmname._x11_open_display(xlib_mock): + pass + + xlib_mock.XOpenDisplay.assert_called_once_with(None) + xlib_mock.XCloseDisplay.assert_called_once_with(fake_x11_display) + + +def test_x11_display_error(xlib_mock: unittest.mock.Mock): + """Test that an error in getting the X11 display raises an error.""" + xlib_mock.XOpenDisplay.return_value = ctypes.c_void_p(0) + + with pytest.raises(wmname.Error, match="Cannot open display"): + with wmname._x11_open_display(xlib_mock): + pass + + xlib_mock.XCloseDisplay.assert_not_called() # Not called on error + + +def test_x11_intern_atom( + xlib_mock: unittest.mock.Mock, + fake_x11_display: wmname._X11Display, +): + """Test getting an interned atom from X11.""" + atom_name = b"_NET_WM_NAME" + atom = 12345 + xlib_mock.XInternAtom.return_value = atom + + result = wmname._x11_intern_atom(xlib_mock, fake_x11_display, atom_name) + assert result == atom + + xlib_mock.XInternAtom.assert_called_once_with( + fake_x11_display, + atom_name, + True, # don't create if not found + ) + + +def test_x11_intern_atom_error( + xlib_mock: unittest.mock.Mock, + fake_x11_display: wmname._X11Display, +): + """Test that an error in getting an interned atom raises an error.""" + xlib_mock.XInternAtom.return_value = 0 + + with pytest.raises(wmname.Error, match="Failed to intern atom: b'_NET_WM_NAME'"): + wmname._x11_intern_atom(xlib_mock, fake_x11_display, b"_NET_WM_NAME") + + xlib_mock.XInternAtom.assert_called_once_with( + fake_x11_display, + b"_NET_WM_NAME", + True, # don't create if not found + ) + + +def test_x11_get_wm_name( + qapp: QApplication, + qtbot: pytestqt.qtbot.QtBot, +) -> None: + """Test getting a property from X11. + + This is difficult to mock (as it involves a C layer via ctypes with return + arguments), so we instead try getting data from a real window. + """ + if qapp.platformName() != "xcb": + pytest.skip("This test only works on X11 (xcb) platforms") + + w = QWidget() + qtbot.add_widget(w) + w.setWindowTitle("Test Window") + + xlib = wmname._x11_load_lib() + with wmname._x11_open_display(xlib) as display: + atoms = wmname._X11Atoms( + NET_SUPPORTING_WM_CHECK=-1, + NET_WM_NAME=wmname._x11_intern_atom(xlib, display, b"_NET_WM_NAME"), + UTF8_STRING=wmname._x11_intern_atom(xlib, display, b"UTF8_STRING"), + ) + window = wmname._X11Window(int(w.winId())) + name = wmname._x11_get_wm_name(xlib, display, atoms=atoms, wm_window=window) + + assert name == "Test Window" + + +def test_x11_real(): + try: + name = wmname.x11_wm_name() + except wmname.Error: + return + + assert isinstance(name, str) + assert name diff --git a/tests/unit/utils/test_version.py b/tests/unit/utils/test_version.py index 673a5657d..a6db93bb2 100644 --- a/tests/unit/utils/test_version.py +++ b/tests/unit/utils/test_version.py @@ -16,6 +16,7 @@ import datetime import dataclasses import importlib.metadata import unittest.mock +from typing import Any import pytest import pytest_mock @@ -27,7 +28,7 @@ from qutebrowser.qt.core import PYQT_VERSION_STR import qutebrowser from qutebrowser.config import config, websettings from qutebrowser.utils import version, usertypes, utils, standarddir -from qutebrowser.misc import pastebin, objects, elf +from qutebrowser.misc import pastebin, objects, elf, wmname from qutebrowser.browser import pdfjs try: @@ -1283,6 +1284,7 @@ class TestChromiumVersion: class VersionParams: name: str + gui_platform: str = 'GUI_PLATFORM' git_commit: bool = True frozen: bool = False qapp: bool = True @@ -1303,6 +1305,8 @@ class VersionParams: VersionParams('no-ssl', ssl_support=False), VersionParams('no-autoconfig-loaded', autoconfig_loaded=False), VersionParams('no-config-py-loaded', config_py_loaded=False), + VersionParams('xcb-platform', gui_platform='xcb'), + VersionParams('wayland-platform', gui_platform='wayland'), ], ids=lambda param: param.name) def test_version_info(params, stubs, monkeypatch, config_stub): """Test version.version_info().""" @@ -1323,16 +1327,21 @@ def test_version_info(params, stubs, monkeypatch, config_stub): 'QSslSocket': FakeQSslSocket('SSL VERSION', params.ssl_support), 'platform.platform': lambda: 'PLATFORM', 'platform.architecture': lambda: ('ARCHITECTURE', ''), + 'wmname.x11_wm_name': lambda: 'X11 WM NAME', + 'wmname.wayland_compositor_name': lambda: 'WAYLAND COMPOSITOR NAME', '_os_info': lambda: ['OS INFO 1', 'OS INFO 2'], '_path_info': lambda: {'PATH DESC': 'PATH NAME'}, - 'objects.qapp': (stubs.FakeQApplication(style='STYLE', platform_name='PLATFORM') - if params.qapp else None), + 'objects.qapp': ( + stubs.FakeQApplication(style='STYLE', platform_name=params.gui_platform) + if params.qapp + else None + ), 'qtutils.library_path': (lambda _loc: 'QT PATH'), 'sql.version': lambda: 'SQLITE VERSION', '_uptime': lambda: datetime.timedelta(hours=1, minutes=23, seconds=45), 'config.instance.yaml_loaded': params.autoconfig_loaded, 'machinery.INFO': machinery.SelectionInfo( - wrapper="QT WRAPPER", + wrapper='QT WRAPPER', reason=machinery.SelectionReason.fake ), } @@ -1340,11 +1349,23 @@ def test_version_info(params, stubs, monkeypatch, config_stub): version.opengl_info.cache_clear() monkeypatch.setenv('QUTE_FAKE_OPENGL', 'VENDOR, 1.0 VERSION') + if not params.qapp: + expected_gui_platform = None + elif params.gui_platform == 'GUI_PLATFORM': + expected_gui_platform = 'GUI_PLATFORM' + elif params.gui_platform == 'xcb': + expected_gui_platform = 'xcb (X11 WM NAME)' + elif params.gui_platform == 'wayland': + expected_gui_platform = 'wayland (WAYLAND COMPOSITOR NAME)' + else: + raise utils.Unreachable(params.gui_platform) + substitutions = { 'git_commit': '\nGit commit: GIT COMMIT' if params.git_commit else '', 'style': '\nStyle: STYLE' if params.qapp else '', - 'platform_plugin': ('\nPlatform plugin: PLATFORM' if params.qapp - else ''), + 'platform_plugin': ( + f'\nQt Platform: {expected_gui_platform}' if params.qapp else '' + ), 'opengl': '\nOpenGL: VENDOR, 1.0 VERSION' if params.qapp else '', 'qt': 'QT VERSION', 'frozen': str(params.frozen), @@ -1552,6 +1573,46 @@ def test_pastebin_version_error(pbclient, caplog, message_mock, monkeypatch): assert msg.text == "Failed to pastebin version info: test" +@pytest.mark.parametrize("platform, expected", [ + ("windows", "windows"), + ("xcb", "xcb (X11 WM NAME)"), + ("wayland", "wayland (WAYLAND COMPOSITOR NAME)"), + ("wayland-egl", "wayland-egl (WAYLAND COMPOSITOR NAME)"), +]) +def test_gui_platform_info( + platform: str, expected: str, monkeypatch: pytest.MonkeyPatch, stubs: Any +) -> None: + monkeypatch.setattr( + version.objects, + "qapp", + stubs.FakeQApplication(platform_name=platform, style="STYLE"), + ) + monkeypatch.setattr(version.wmname, "x11_wm_name", lambda: "X11 WM NAME") + monkeypatch.setattr( + version.wmname, "wayland_compositor_name", lambda: "WAYLAND COMPOSITOR NAME" + ) + assert version.gui_platform_info() == expected + + +@pytest.mark.parametrize("platform", ["xcb", "wayland", "wayland-egl"]) +def test_gui_platform_info_error( + platform: str, + monkeypatch: pytest.MonkeyPatch, + mocker: pytest_mock.MockerFixture, + stubs: Any, +) -> None: + monkeypatch.setattr( + version.objects, + "qapp", + stubs.FakeQApplication(platform_name=platform, style="STYLE"), + ) + mocker.patch.object(wmname, "x11_wm_name", side_effect=wmname.Error("fake error")) + mocker.patch.object( + wmname, "wayland_compositor_name", side_effect=wmname.Error("fake error") + ) + assert version.gui_platform_info() == f"{platform} (Error: fake error)" + + def test_uptime(monkeypatch, qapp): """Test _uptime runs and check if microseconds are dropped.""" monkeypatch.setattr(objects, 'qapp', qapp)