From 44a42bbfa2cfdbc704b6438905c8197fff388942 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Thu, 10 Sep 2026 14:59:54 +0200 Subject: [PATCH] test(gui): isolate window remapping from BEC and RPC dispatch --- README.md | 13 ++++ bec_widgets/examples/qt_window_probe.py | 96 +++++++++++++++++++++++++ tests/unit_tests/test_rpc_server.py | 37 +++++++++- 3 files changed, 145 insertions(+), 1 deletion(-) create mode 100644 bec_widgets/examples/qt_window_probe.py diff --git a/README.md b/README.md index 5f173a06..c9c7ad6b 100644 --- a/README.md +++ b/README.md @@ -141,6 +141,19 @@ For an XWayland comparison on a Linux desktop with XWayland available, start a f `QT_QPA_PLATFORM=xcb bec --session flomni` and verify that `qt_platform` is `xcb`. This sets the backend for that invocation only. For plugin-loading details, start with `QT_DEBUG_PLUGINS=1 bec --session flomni`. +To isolate a visibility problem from BEC, run the plain Qt probe directly from this checkout, +using the same Python environment as the affected GUI: + +```bash +QT_QPA_PLATFORM=wayland python bec_widgets/examples/qt_window_probe.py +``` + +Type `cycle` repeatedly in its terminal to test plain Qt hide/show. Compare with separate `hide` +and `show` commands; `raise` tests activation without hiding. The probe reports Qt's state but +cannot determine whether another application's window obscures it. Record what you see alongside +the output and the result of `gnome-shell --version` when reporting a GNOME issue. Run again with +`QT_QPA_PLATFORM=xcb` to compare backends. This script starts no BEC services and imports no BEC code. + ### 4. Rapid development (extensible by design) Build new widgets fast: Inherit from `BECWidget`, list your RPC methods in `USER_ACCESS`, and use `bec_dispatcher` to diff --git a/bec_widgets/examples/qt_window_probe.py b/bec_widgets/examples/qt_window_probe.py new file mode 100644 index 00000000..d0845a0a --- /dev/null +++ b/bec_widgets/examples/qt_window_probe.py @@ -0,0 +1,96 @@ +"""Probe window visibility from a terminal using only Qt, without starting BEC. + +Run this file directly with the affected environment's Python interpreter. +Enter ``cycle`` repeatedly to hide/show a plain QWidget in one event-loop callback. +Compare with separate ``hide`` and ``show`` commands and with ``raise``. +""" + +import json +import os +import sys + +from qtpy import API_NAME +from qtpy.QtCore import QSocketNotifier, QTimer, qVersion +from qtpy.QtWidgets import QApplication, QLabel, QVBoxLayout, QWidget + + +def main() -> int: + """Run a plain Qt window controlled through standard input on macOS or Linux.""" + app = QApplication(sys.argv) + window = QWidget() + window.setWindowTitle("Qt window visibility probe") + window.resize(800, 600) + layout = QVBoxLayout(window) + layout.addWidget(QLabel("Control this plain Qt window from the terminal.")) + window.show() + handle = window.windowHandle() + command_number = 0 + + print( + json.dumps( + { + "pid": os.getpid(), + "python": sys.executable, + "qt_binding": API_NAME, + "qt_version": qVersion(), + "qt_platform": app.platformName(), + "session_type": os.environ.get("XDG_SESSION_TYPE"), + "desktop": os.environ.get("XDG_CURRENT_DESKTOP"), + } + ), + flush=True, + ) + print("Commands: cycle, hide, show, raise, state, quit", flush=True) + + def report(stage: str, number: int) -> None: + print( + json.dumps( + { + "command": number, + "stage": stage, + "visible": window.isVisible(), + "native_visible": handle.isVisible(), + "active": window.isActiveWindow(), + "exposed": handle.isExposed(), + "minimized": window.isMinimized(), + } + ), + flush=True, + ) + + notifier = QSocketNotifier(sys.stdin.fileno(), QSocketNotifier.Type.Read, window) + + def read_command() -> None: + nonlocal command_number + line = sys.stdin.readline() + if not line or line.strip() == "quit": + notifier.setEnabled(False) + app.quit() + return + command = line.strip() + command_number += 1 + number = command_number + report(f"before {command}", number) + if command == "cycle": + window.hide() + window.show() + elif command == "hide": + window.hide() + elif command == "show": + window.show() + elif command == "raise": + window.raise_() + window.activateWindow() + elif command != "state": + print(f"Unknown command: {command}", flush=True) + return + report(f"after {command}", number) + # Observe settled state; this timer does not alter the window or delay the operation. + QTimer.singleShot(250, window, lambda: report(f"250ms after {command}", number)) + + notifier.activated.connect(read_command) + return app.exec() + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/unit_tests/test_rpc_server.py b/tests/unit_tests/test_rpc_server.py index 72e12947..aa84c980 100644 --- a/tests/unit_tests/test_rpc_server.py +++ b/tests/unit_tests/test_rpc_server.py @@ -1,15 +1,17 @@ import argparse +from threading import Thread from unittest.mock import MagicMock, patch import pytest from bec_lib.service_config import ServiceConfig -from qtpy.QtCore import QCoreApplication, QEvent, QObject, Qt +from qtpy.QtCore import QCoreApplication, QEvent, QObject, Qt, QThread from qtpy.QtWidgets import QWidget from bec_widgets.applications import companion_app as companion_app_module from bec_widgets.applications.companion_app import GUIServer from bec_widgets.utils import rpc_server as rpc_server_module from bec_widgets.utils.bec_connector import BECConnector +from bec_widgets.utils.bec_dispatcher import QtThreadSafeCallback from bec_widgets.utils.rpc_server import RegistryNotReadyError, RPCServer, SingleshotRPCRepeat from .client_mocks import mocked_client @@ -251,6 +253,39 @@ def test_repeated_wayland_raises_share_pending_remap(rpc_server, dummy_widget, m assert events.hide_events == events.show_events == 1 +def test_wayland_raise_from_dispatcher_worker_runs_on_gui_thread( + rpc_server, dummy_widget, monkeypatch, qtbot, qapp +): + monkeypatch.setattr(rpc_server_module.QApplication, "platformName", lambda: "wayland") + monkeypatch.setattr(dummy_widget, "isActiveWindow", lambda: False) + dummy_widget.show() + events = WindowEvents(dummy_widget) + activation_threads = [] + callback = QtThreadSafeCallback(rpc_server.on_rpc_update) + instruction = { + "action": "raise", + "parameter": {"gui_id": dummy_widget.gui_id, "args": [], "kwargs": {}}, + } + with ( + patch.object(rpc_server, "send_response") as response, + patch.object( + dummy_widget, + "activateWindow", + side_effect=lambda: activation_threads.append(QThread.currentThread()), + ), + ): + worker = Thread(target=callback, args=(instruction, {"request_id": "raise-from-worker"})) + worker.start() + worker.join(timeout=2) + assert not worker.is_alive() + qtbot.waitUntil(lambda: bool(activation_threads) and response.called) + + assert activation_threads == [qapp.thread()] + assert events.hide_events == events.show_events == 1 + assert dummy_widget.isVisible() + response.assert_called_once_with("raise-from-worker", True, {"result": None}) + + @pytest.mark.parametrize("method", ["hide", "close", "show"]) def test_visibility_request_cancels_pending_wayland_raise( rpc_server, dummy_widget, monkeypatch, qapp, method