mirror of
https://github.com/bec-project/bec_widgets.git
synced 2026-09-03 23:30:42 +02:00
feat: stream startup stages to the launcher over a progress socket
This commit is contained in:
@@ -1,5 +1,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
# isort: off
|
||||
# startup_profiler must load before the heavy third-party imports below so its origin
|
||||
# timestamp captures this process's real import cost (streamed as the "module imports"
|
||||
# stage to the launcher banner). The fence keeps isort from reordering it downward.
|
||||
from bec_widgets.applications.startup_profiler import startup_profiler
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
@@ -14,7 +20,7 @@ from bec_lib.logger import bec_logger
|
||||
from bec_lib.service_config import ServiceConfig
|
||||
from bec_qthemes import apply_theme
|
||||
from qtmonaco.pylsp_provider import pylsp_server
|
||||
from qtpy.QtCore import QSize, Qt
|
||||
from qtpy.QtCore import QSize, Qt, QTimer
|
||||
from qtpy.QtGui import QIcon
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
@@ -23,10 +29,14 @@ from bec_widgets.applications.launch_window import LaunchWindow
|
||||
from bec_widgets.utils.bec_dispatcher import BECDispatcher
|
||||
from bec_widgets.utils.rpc_register import RPCRegister
|
||||
|
||||
# isort: on
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
MODULE_PATH = os.path.dirname(bec_widgets.__file__)
|
||||
|
||||
startup_profiler.mark("module imports")
|
||||
|
||||
|
||||
class SimpleFileLikeFromLogOutputFunc:
|
||||
def __init__(self, log_func):
|
||||
@@ -99,10 +109,12 @@ class GUIServer:
|
||||
"""
|
||||
logger.info("Starting GUIServer", repr(self))
|
||||
self.app = QApplication(sys.argv)
|
||||
startup_profiler.mark("QApplication")
|
||||
if darkdetect.isDark():
|
||||
apply_theme("dark")
|
||||
else:
|
||||
apply_theme("light")
|
||||
startup_profiler.mark("theme applied")
|
||||
|
||||
self.app.setApplicationName("BEC")
|
||||
self.app.gui_id = self.gui_id # type: ignore
|
||||
@@ -111,6 +123,8 @@ class GUIServer:
|
||||
|
||||
service_config = self._get_service_config()
|
||||
self.dispatcher = BECDispatcher(config=service_config, gui_id=self.gui_id)
|
||||
# Dominant cold-start cost when Redis is remote.
|
||||
startup_profiler.mark("BEC connection")
|
||||
|
||||
if self.gui_class:
|
||||
self.launcher_window = LaunchWindow(
|
||||
@@ -121,6 +135,7 @@ class GUIServer:
|
||||
else:
|
||||
self.launcher_window = LaunchWindow(gui_id=f"{self.gui_id}:launcher")
|
||||
self.launcher_window.setAttribute(Qt.WA_ShowWithoutActivating) # type: ignore
|
||||
startup_profiler.mark("launch window built")
|
||||
|
||||
self.app.aboutToQuit.connect(self.shutdown)
|
||||
self.app.setQuitOnLastWindowClosed(True)
|
||||
@@ -128,8 +143,24 @@ class GUIServer:
|
||||
signal.signal(signal.SIGINT, self.request_shutdown)
|
||||
signal.signal(signal.SIGTERM, self.request_shutdown)
|
||||
|
||||
# First event-loop iteration -> the server is up and interactive.
|
||||
QTimer.singleShot(0, self._notify_server_ready)
|
||||
|
||||
sys.exit(self.app.exec())
|
||||
|
||||
def _notify_server_ready(self):
|
||||
"""Mark the final startup stage and resolve the launcher's loading banner.
|
||||
|
||||
This is a safety net so the banner resolves even when no ``gui_class`` window
|
||||
is auto-launched; :meth:`LaunchWindow.launch` also notifies for a launched
|
||||
window, and a duplicate ready edge is harmless (the launcher finalises once).
|
||||
"""
|
||||
# pylint: disable=import-outside-toplevel
|
||||
from bec_widgets.utils.launcher_ready import notify_launcher_ready
|
||||
|
||||
startup_profiler.mark("interactive", final=True)
|
||||
notify_launcher_ready("bec-gui-server", self.launcher_window)
|
||||
|
||||
def setup_bec_icon(self):
|
||||
"""
|
||||
Set the BEC icon for the application
|
||||
|
||||
@@ -484,14 +484,24 @@ class LaunchWindow(BECMainWindow):
|
||||
if isinstance(result_widget, BECMainWindow):
|
||||
apply_window_geometry(result_widget, geometry)
|
||||
result_widget.show()
|
||||
self._notify_launcher_ready(result_widget)
|
||||
else:
|
||||
window = BECMainWindowNoRPC()
|
||||
window.setCentralWidget(result_widget)
|
||||
window.setWindowTitle(f"BEC - {result_widget.objectName()}")
|
||||
apply_window_geometry(window, geometry)
|
||||
window.show()
|
||||
self._notify_launcher_ready(window)
|
||||
return result_widget
|
||||
|
||||
@staticmethod
|
||||
def _notify_launcher_ready(window: QWidget) -> None:
|
||||
from qtpy.QtCore import QTimer
|
||||
|
||||
from bec_widgets.utils.launcher_ready import notify_launcher_ready
|
||||
|
||||
QTimer.singleShot(0, lambda: notify_launcher_ready("bec-gui-server", window))
|
||||
|
||||
def _launch_custom_ui_file(self, ui_file: str | None) -> BECMainWindow:
|
||||
"""
|
||||
Load a custom .ui file. If the top-level widget is a MainWindow subclass,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from bec_widgets.applications.startup_profiler import startup_profiler # isort: skip
|
||||
|
||||
from bec_qthemes import material_icon
|
||||
from qtpy.QtCore import QTimer
|
||||
from qtpy.QtGui import QAction # type: ignore
|
||||
from qtpy.QtWidgets import QApplication, QHBoxLayout, QStackedWidget, QWidget
|
||||
|
||||
@@ -14,6 +15,7 @@ from bec_widgets.applications.views.dock_area_view.dock_area_view import DockAre
|
||||
from bec_widgets.applications.views.view import ViewBase, WaveformViewInline, WaveformViewPopup
|
||||
from bec_widgets.utils.colors import apply_theme
|
||||
from bec_widgets.utils.guided_tour import GuidedTour
|
||||
from bec_widgets.utils.launcher_ready import notify_launcher_ready
|
||||
from bec_widgets.utils.name_utils import sanitize_namespace
|
||||
from bec_widgets.utils.screen_utils import (
|
||||
apply_centered_size,
|
||||
@@ -41,6 +43,7 @@ class BECMainApp(BECMainWindow):
|
||||
super().__init__(parent=parent, *args, **kwargs)
|
||||
startup_profiler.mark("BEC connection + base window")
|
||||
self._show_examples = bool(show_examples)
|
||||
self._launcher_ready_notified = False
|
||||
|
||||
# --- Compose central UI (sidebar + stack)
|
||||
self.sidebar = SideBar(parent=self, anim_duration=anim_duration)
|
||||
@@ -67,6 +70,13 @@ class BECMainApp(BECMainWindow):
|
||||
self._setup_guided_tour()
|
||||
startup_profiler.mark("guided tour")
|
||||
|
||||
def showEvent(self, event):
|
||||
super().showEvent(event)
|
||||
if self._launcher_ready_notified:
|
||||
return
|
||||
self._launcher_ready_notified = True
|
||||
QTimer.singleShot(0, lambda: notify_launcher_ready("bec-app", self))
|
||||
|
||||
def _add_views(self):
|
||||
self.add_section("BEC Applications", "bec_apps")
|
||||
self.dock_area = DockAreaView(self)
|
||||
|
||||
@@ -38,6 +38,13 @@ try:
|
||||
except Exception: # pragma: no cover - logging must never break startup
|
||||
_logger = None
|
||||
|
||||
try:
|
||||
# Stdlib-only client: streams the marks below to bec_launcher's loading banner
|
||||
# when this process was started by the launcher. A no-op otherwise.
|
||||
from bec_widgets.utils.launch_progress import launch_progress
|
||||
except Exception: # pragma: no cover - progress streaming must never break startup
|
||||
launch_progress = None
|
||||
|
||||
|
||||
def _emit(msg: str) -> None:
|
||||
if _logger is not None:
|
||||
@@ -76,6 +83,9 @@ class StartupProfiler:
|
||||
_emit(f"[startup] {stage:<26} +{delta:6.2f}s (total {total:6.2f}s)")
|
||||
if final:
|
||||
_emit(f"[startup] ---- bec-app interactive after {total:.2f}s ----")
|
||||
if launch_progress is not None:
|
||||
# Best-effort; the client swallows all socket errors internally.
|
||||
launch_progress.emit_stage(stage, delta * 1000.0, total * 1000.0)
|
||||
return total
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
"""Client side of the BEC launch-progress handshake.
|
||||
|
||||
When ``bec_launcher`` starts a GUI it opens a per-launch AF_UNIX socket and passes
|
||||
its path plus a one-shot token to the child through the environment. The child
|
||||
streams startup-stage updates and a final ``ready`` edge back over that socket so
|
||||
the launcher can show a live loading banner instead of a blind spinner.
|
||||
|
||||
The whole thing is best-effort and must *never* interfere with startup:
|
||||
|
||||
* if the environment variables are absent (app started outside the launcher) every
|
||||
call is a cheap no-op returning ``False``;
|
||||
* any socket error disables the client for the rest of the process and is swallowed.
|
||||
|
||||
The module deliberately depends only on the standard library (no Qt, no bec_lib
|
||||
heavy imports) because it is imported by :mod:`startup_profiler` *before* the
|
||||
QApplication and the heavy widget imports exist.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import sys
|
||||
import threading
|
||||
|
||||
SOCKET_ENV = "BEC_LAUNCH_PROGRESS_SOCKET"
|
||||
TOKEN_ENV = "BEC_LAUNCH_PROGRESS_TOKEN"
|
||||
APP_ENV = "BEC_LAUNCH_APP"
|
||||
|
||||
_CONNECT_TIMEOUT_S = 0.75
|
||||
_SEND_TIMEOUT_S = 0.75
|
||||
|
||||
|
||||
class LaunchProgressClient:
|
||||
"""Best-effort AF_UNIX client that streams startup stages to the launcher."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._lock = threading.Lock()
|
||||
self._sock: socket.socket | None = None
|
||||
# Tri-state: None = not yet attempted, True = live, False = disabled.
|
||||
self._live: bool | None = None
|
||||
self._hello_sent = False
|
||||
self._path = os.environ.get(SOCKET_ENV) or ""
|
||||
self._token = os.environ.get(TOKEN_ENV) or ""
|
||||
|
||||
# -- public API ---------------------------------------------------------
|
||||
@property
|
||||
def enabled(self) -> bool:
|
||||
"""True when the launcher provided a socket + token for this launch."""
|
||||
return bool(self._path and self._token and self._live is not False)
|
||||
|
||||
def emit_stage(self, name: str, delta_ms: float, total_ms: float) -> bool:
|
||||
return self._send(
|
||||
{
|
||||
"t": "stage",
|
||||
"name": name,
|
||||
"delta_ms": round(float(delta_ms), 1),
|
||||
"total_ms": round(float(total_ms), 1),
|
||||
}
|
||||
)
|
||||
|
||||
def emit_ready(self, total_ms: float | None = None) -> bool:
|
||||
payload: dict[str, object] = {"t": "ready"}
|
||||
if total_ms is not None:
|
||||
payload["total_ms"] = round(float(total_ms), 1)
|
||||
return self._send(payload)
|
||||
|
||||
def emit_error(self, message: str) -> bool:
|
||||
return self._send({"t": "error", "msg": str(message)})
|
||||
|
||||
def close(self) -> None:
|
||||
with self._lock:
|
||||
self._disconnect_locked()
|
||||
self._live = False
|
||||
|
||||
# -- internals ----------------------------------------------------------
|
||||
def _send(self, payload: dict[str, object]) -> bool:
|
||||
if not (self._path and self._token):
|
||||
return False
|
||||
with self._lock:
|
||||
if self._live is False:
|
||||
return False
|
||||
if self._sock is None and not self._connect_locked():
|
||||
return False
|
||||
line = (json.dumps(payload, separators=(",", ":")) + "\n").encode("utf-8")
|
||||
try:
|
||||
self._sock.sendall(line) # type: ignore[union-attr]
|
||||
return True
|
||||
except OSError:
|
||||
self._disconnect_locked()
|
||||
self._live = False
|
||||
return False
|
||||
|
||||
def _connect_locked(self) -> bool:
|
||||
try:
|
||||
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
sock.settimeout(_CONNECT_TIMEOUT_S)
|
||||
sock.connect(self._path)
|
||||
sock.settimeout(_SEND_TIMEOUT_S)
|
||||
except OSError:
|
||||
self._live = False
|
||||
return False
|
||||
self._sock = sock
|
||||
self._live = True
|
||||
if not self._hello_sent:
|
||||
hello = {
|
||||
"t": "hello",
|
||||
"token": self._token,
|
||||
"app": os.environ.get(APP_ENV) or _default_app_name(),
|
||||
"pid": os.getpid(),
|
||||
}
|
||||
try:
|
||||
sock.sendall((json.dumps(hello, separators=(",", ":")) + "\n").encode("utf-8"))
|
||||
self._hello_sent = True
|
||||
except OSError:
|
||||
self._disconnect_locked()
|
||||
self._live = False
|
||||
return False
|
||||
return True
|
||||
|
||||
def _disconnect_locked(self) -> None:
|
||||
if self._sock is not None:
|
||||
try:
|
||||
self._sock.close()
|
||||
except OSError:
|
||||
pass
|
||||
self._sock = None
|
||||
|
||||
|
||||
def _default_app_name() -> str:
|
||||
argv0 = sys.argv[0] if sys.argv else ""
|
||||
return os.path.basename(argv0) or "bec"
|
||||
|
||||
|
||||
# Process-wide singleton; env is read once at import time.
|
||||
launch_progress = LaunchProgressClient()
|
||||
@@ -0,0 +1,37 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from bec_lib.logger import bec_logger
|
||||
|
||||
from bec_widgets.utils.launch_progress import launch_progress
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
def notify_launcher_ready(app_name: str, window: Any | None = None) -> bool:
|
||||
"""
|
||||
Notify bec_launcher that the GUI window for this launch is visible.
|
||||
|
||||
Sends the final ``ready`` edge over the per-launch progress socket that
|
||||
bec_launcher opened for this process (see
|
||||
:mod:`bec_widgets.utils.launch_progress`). This resolves the launcher's
|
||||
loading banner and lets it close itself.
|
||||
|
||||
The call is intentionally a no-op returning ``False`` unless bec_launcher
|
||||
provided the socket + token through the environment, and it never raises:
|
||||
all socket errors are swallowed by the client.
|
||||
|
||||
Args:
|
||||
app_name(str): The launched app identifier (informational).
|
||||
window(Any | None): The visible top-level window, if available (informational).
|
||||
|
||||
Returns:
|
||||
bool: True if the ready edge was delivered to the launcher, else False.
|
||||
"""
|
||||
if launch_progress is None or not launch_progress.enabled:
|
||||
return False
|
||||
delivered = launch_progress.emit_ready()
|
||||
if not delivered:
|
||||
logger.debug(f"Launcher ready edge not delivered for '{app_name}'.")
|
||||
return delivered
|
||||
@@ -0,0 +1,36 @@
|
||||
"""Unit tests for the GUI-server startup handshake wiring in companion_app."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
from bec_widgets.applications import companion_app
|
||||
|
||||
|
||||
def _server(**overrides):
|
||||
args = SimpleNamespace(
|
||||
config=None, id="test", gui_class=None, gui_class_id="bec", hide=False, **overrides
|
||||
)
|
||||
return companion_app.GUIServer(args)
|
||||
|
||||
|
||||
def test_notify_server_ready_resolves_launcher(monkeypatch):
|
||||
calls = []
|
||||
monkeypatch.setattr(
|
||||
"bec_widgets.utils.launcher_ready.notify_launcher_ready",
|
||||
lambda app_name, window: calls.append((app_name, window)) or True,
|
||||
)
|
||||
marks = []
|
||||
monkeypatch.setattr(
|
||||
companion_app.startup_profiler, "mark", lambda stage, **kw: marks.append((stage, kw))
|
||||
)
|
||||
|
||||
server = _server()
|
||||
sentinel_window = object()
|
||||
server.launcher_window = sentinel_window
|
||||
server._notify_server_ready()
|
||||
|
||||
# The GUI-server path sends the ready edge itself (safety net for launches with no
|
||||
# auto-launched gui_class window) and records the final startup stage.
|
||||
assert calls == [("bec-gui-server", sentinel_window)]
|
||||
assert ("interactive", {"final": True}) in marks
|
||||
@@ -0,0 +1,128 @@
|
||||
"""Unit tests for the launch-progress socket client (child side)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
|
||||
import pytest
|
||||
|
||||
from bec_widgets.utils import launch_progress as lp
|
||||
|
||||
|
||||
def _short_socket_path(suffix: str = "") -> str:
|
||||
# AF_UNIX paths are length-limited (~104 on macOS); keep it short and under /tmp.
|
||||
return f"/tmp/bec-lp-{os.getpid()}-{suffix or 'x'}.sock"
|
||||
|
||||
|
||||
class _Server:
|
||||
"""Minimal single-connection AF_UNIX server for assertions."""
|
||||
|
||||
def __init__(self, path: str):
|
||||
self.path = path
|
||||
self._srv = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
self._srv.settimeout(2.0)
|
||||
self._srv.bind(path)
|
||||
self._srv.listen(1)
|
||||
self._conn: socket.socket | None = None
|
||||
|
||||
def read_lines(self) -> list[dict]:
|
||||
if self._conn is None:
|
||||
self._conn, _ = self._srv.accept()
|
||||
self._conn.settimeout(2.0)
|
||||
chunks = b""
|
||||
while True:
|
||||
try:
|
||||
data = self._conn.recv(65536)
|
||||
except socket.timeout:
|
||||
break
|
||||
if not data:
|
||||
break
|
||||
chunks += data
|
||||
# Heuristic: stop once we have drained what's buffered.
|
||||
if len(data) < 65536:
|
||||
break
|
||||
return [json.loads(line) for line in chunks.decode().splitlines() if line.strip()]
|
||||
|
||||
def close(self) -> None:
|
||||
for sock in (self._conn, self._srv):
|
||||
if sock is not None:
|
||||
try:
|
||||
sock.close()
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
os.unlink(self.path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def server():
|
||||
srv = _Server(_short_socket_path("srv"))
|
||||
try:
|
||||
yield srv
|
||||
finally:
|
||||
srv.close()
|
||||
|
||||
|
||||
def _client_env(monkeypatch, path: str, token: str = "tok-123", app: str = "bec-app"):
|
||||
monkeypatch.setenv(lp.SOCKET_ENV, path)
|
||||
monkeypatch.setenv(lp.TOKEN_ENV, token)
|
||||
monkeypatch.setenv(lp.APP_ENV, app)
|
||||
|
||||
|
||||
def test_client_is_noop_without_env(monkeypatch):
|
||||
monkeypatch.delenv(lp.SOCKET_ENV, raising=False)
|
||||
monkeypatch.delenv(lp.TOKEN_ENV, raising=False)
|
||||
client = lp.LaunchProgressClient()
|
||||
assert client.enabled is False
|
||||
assert client.emit_stage("module imports", 10, 10) is False
|
||||
assert client.emit_ready() is False
|
||||
|
||||
|
||||
def test_client_streams_hello_stage_and_ready(monkeypatch, server):
|
||||
_client_env(monkeypatch, server.path, token="tok-abc", app="bec-app")
|
||||
client = lp.LaunchProgressClient()
|
||||
assert client.enabled is True
|
||||
|
||||
assert client.emit_stage("module imports", 6210.4, 6210.4) is True
|
||||
assert client.emit_stage("BEC connection", 18400.0, 24610.4) is True
|
||||
assert client.emit_ready(27710.0) is True
|
||||
|
||||
messages = server.read_lines()
|
||||
assert messages[0]["t"] == "hello"
|
||||
assert messages[0]["token"] == "tok-abc"
|
||||
assert messages[0]["app"] == "bec-app"
|
||||
assert messages[0]["pid"] == os.getpid()
|
||||
|
||||
stages = [m for m in messages if m["t"] == "stage"]
|
||||
assert [s["name"] for s in stages] == ["module imports", "BEC connection"]
|
||||
assert stages[0]["delta_ms"] == 6210.4
|
||||
assert stages[0]["total_ms"] == 6210.4
|
||||
|
||||
ready = [m for m in messages if m["t"] == "ready"]
|
||||
assert ready and ready[0]["total_ms"] == 27710.0
|
||||
|
||||
|
||||
def test_client_never_raises_on_bad_socket(monkeypatch):
|
||||
_client_env(monkeypatch, _short_socket_path("nonexistent"))
|
||||
client = lp.LaunchProgressClient()
|
||||
# No server is listening at the path -> connect fails, but nothing raises.
|
||||
assert client.emit_stage("module imports", 1, 1) is False
|
||||
assert client.enabled is False # disabled after the failed connect
|
||||
assert client.emit_ready() is False
|
||||
|
||||
|
||||
def test_client_disables_after_server_disconnect(monkeypatch, server):
|
||||
_client_env(monkeypatch, server.path)
|
||||
client = lp.LaunchProgressClient()
|
||||
assert client.emit_stage("first", 1, 1) is True
|
||||
# Force the server to drop the connection.
|
||||
server.read_lines()
|
||||
server.close()
|
||||
# Subsequent sends eventually fail and disable the client without raising.
|
||||
for _ in range(5):
|
||||
client.emit_stage("later", 2, 2)
|
||||
assert client.emit_ready() is False
|
||||
@@ -131,6 +131,22 @@ def test_open_dock_area_with_start_empty_option_calls_launch(bec_launch_window):
|
||||
mock_launch.assert_called_once_with("dock_area", startup_profile=None)
|
||||
|
||||
|
||||
def test_launch_window_notifies_launcher_ready(qtbot, bec_launch_window, monkeypatch):
|
||||
calls = []
|
||||
monkeypatch.setattr(
|
||||
"bec_widgets.utils.launcher_ready.notify_launcher_ready",
|
||||
lambda app_name, window: calls.append((app_name, window)) or True,
|
||||
)
|
||||
|
||||
window = QWidget()
|
||||
qtbot.addWidget(window)
|
||||
window.show()
|
||||
bec_launch_window._notify_launcher_ready(window)
|
||||
qtbot.wait(10)
|
||||
|
||||
assert calls == [("bec-gui-server", window)]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"connection_names, hide",
|
||||
[
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
"""Unit tests for ``notify_launcher_ready`` (routes the ready edge over the socket)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import socket
|
||||
|
||||
from bec_widgets.utils import launcher_ready
|
||||
from bec_widgets.utils.launch_progress import LaunchProgressClient
|
||||
|
||||
|
||||
def _short_socket_path() -> str:
|
||||
return f"/tmp/bec-ready-{os.getpid()}.sock"
|
||||
|
||||
|
||||
def test_notify_launcher_ready_noops_without_env(monkeypatch):
|
||||
monkeypatch.delenv("BEC_LAUNCH_PROGRESS_SOCKET", raising=False)
|
||||
monkeypatch.delenv("BEC_LAUNCH_PROGRESS_TOKEN", raising=False)
|
||||
# A freshly-built client with no configured socket is disabled -> notify no-ops.
|
||||
monkeypatch.setattr(launcher_ready, "launch_progress", LaunchProgressClient())
|
||||
|
||||
assert launcher_ready.notify_launcher_ready("bec-app") is False
|
||||
|
||||
|
||||
def test_notify_launcher_ready_sends_ready(monkeypatch):
|
||||
path = _short_socket_path()
|
||||
srv = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
srv.settimeout(2.0)
|
||||
srv.bind(path)
|
||||
srv.listen(1)
|
||||
try:
|
||||
monkeypatch.setenv("BEC_LAUNCH_PROGRESS_SOCKET", path)
|
||||
monkeypatch.setenv("BEC_LAUNCH_PROGRESS_TOKEN", "tok-xyz")
|
||||
monkeypatch.setattr(launcher_ready, "launch_progress", LaunchProgressClient())
|
||||
|
||||
assert launcher_ready.notify_launcher_ready("bec-app") is True
|
||||
|
||||
conn, _ = srv.accept()
|
||||
conn.settimeout(2.0)
|
||||
data = conn.recv(65536).decode()
|
||||
conn.close()
|
||||
assert '"t":"hello"' in data
|
||||
assert '"token":"tok-xyz"' in data
|
||||
assert '"t":"ready"' in data
|
||||
finally:
|
||||
srv.close()
|
||||
try:
|
||||
os.unlink(path)
|
||||
except OSError:
|
||||
pass
|
||||
@@ -77,6 +77,22 @@ def test_viewbase_initializes(viewbase):
|
||||
assert viewbase.on_exit() is True
|
||||
|
||||
|
||||
def test_main_app_notifies_launcher_ready_after_show(qtbot, mocked_client, monkeypatch):
|
||||
calls = []
|
||||
monkeypatch.setattr(
|
||||
"bec_widgets.applications.main_app.notify_launcher_ready",
|
||||
lambda app_name, window: calls.append((app_name, window)) or True,
|
||||
)
|
||||
|
||||
app = BECMainApp(client=mocked_client, anim_duration=ANIM_TEST_DURATION, show_examples=False)
|
||||
qtbot.addWidget(app)
|
||||
app.show()
|
||||
qtbot.waitExposed(app)
|
||||
qtbot.wait(10)
|
||||
|
||||
assert calls == [("bec-app", app)]
|
||||
|
||||
|
||||
def test_on_enter_and_on_exit_are_called_on_switch(app_with_spies, qtbot):
|
||||
app, v1, v2, _ = app_with_spies
|
||||
|
||||
|
||||
Reference in New Issue
Block a user