From 7cf323df23b2e1a43a0559927a0c5f9d4b8f8e27 Mon Sep 17 00:00:00 2001 From: smillmorel Date: Wed, 16 Sep 2026 20:18:23 -0400 Subject: [PATCH] feat: surface the Plotly page console in the Console dock A plotly failure inside the WebEngine page was invisible from Python - the react promise just rejected and the canvas looked frozen with no evidence. PlotlyCanvas now installs a QWebEnginePage that forwards javaScriptConsoleMessage into the Console dock with the right severity (error/warning/info), so the next silent failure is diagnosable. --- src/otko/views/canvas_plotly/plotly_canvas.py | 36 ++++++++++++++++++- src/otko/views/main_window.py | 17 ++++++++- tests/gui/test_plotly_view_preservation.py | 22 ++++++++++++ 3 files changed, 73 insertions(+), 2 deletions(-) diff --git a/src/otko/views/canvas_plotly/plotly_canvas.py b/src/otko/views/canvas_plotly/plotly_canvas.py index 754a779..ddecb15 100644 --- a/src/otko/views/canvas_plotly/plotly_canvas.py +++ b/src/otko/views/canvas_plotly/plotly_canvas.py @@ -18,11 +18,13 @@ from __future__ import annotations import json import math +from collections.abc import Callable from dataclasses import replace from typing import Any -from PySide6.QtCore import QUrl, Signal +from PySide6.QtCore import QObject, QUrl, Signal from PySide6.QtWebChannel import QWebChannel +from PySide6.QtWebEngineCore import QWebEnginePage from PySide6.QtWebEngineWidgets import QWebEngineView from PySide6.QtWidgets import QVBoxLayout, QWidget @@ -47,6 +49,34 @@ _VIEW_DIRECTIONS = { } +class _LoggingPage(QWebEnginePage): + """A web page that forwards the JS console to the app's console dock. + + A plotly failure inside the page is otherwise invisible from Python (the + ``react`` promise just rejects), which makes a frozen canvas very hard to + diagnose. + """ + + def __init__(self, log: Callable[[str, str], None], parent: QObject | None = None) -> None: + super().__init__(parent) + self._log = log + + def javaScriptConsoleMessage( + self, + level: QWebEnginePage.JavaScriptConsoleMessageLevel, + message: str, + line_number: int, + source_id: str, + ) -> None: + names = { + QWebEnginePage.JavaScriptConsoleMessageLevel.ErrorMessageLevel: "error", + QWebEnginePage.JavaScriptConsoleMessageLevel.WarningMessageLevel: "warning", + } + severity = names.get(level, "info") + source = source_id.rsplit("/", 1)[-1] or "plotly" + self._log(f"[web] {source}:{line_number} {message}", severity) + + class _CameraShim: """Mimics ``canvas.camera.parallel_projection`` as consumed elsewhere.""" @@ -126,8 +156,10 @@ class PlotlyCanvas(QWidget): parent: QWidget | None = None, style: RenderStyle | None = None, selection: SelectionState | None = None, + log: Callable[[str, str], None] | None = None, ) -> None: super().__init__(parent) + self._log = log self._style = style or RenderStyle() self.selection = selection or SelectionState(self) self._builder = PlotlyTraceBuilder(self._style) @@ -153,6 +185,8 @@ class PlotlyCanvas(QWidget): layout = QVBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) self._web = QWebEngineView(self) + if self._log is not None: + self._web.setPage(_LoggingPage(self._log, self._web)) self._channel = QWebChannel(self._web.page()) self._bridge = _Bridge(self) self._channel.registerObject("otkoBridge", self._bridge) diff --git a/src/otko/views/main_window.py b/src/otko/views/main_window.py index cca1d7e..5241869 100644 --- a/src/otko/views/main_window.py +++ b/src/otko/views/main_window.py @@ -119,7 +119,12 @@ class MainWindow( # Imported lazily so a plotly-less install still boots PyVista. from otko.views.canvas_plotly import PlotlyCanvas - canvas = PlotlyCanvas(self, style=self._style, selection=self._selection) + canvas = PlotlyCanvas( + self, + style=self._style, + selection=self._selection, + log=self._log_canvas_message, + ) else: canvas = ModelCanvas(self, style=self._style, selection=self._selection) self._canvases[backend] = canvas @@ -149,6 +154,16 @@ class MainWindow( """Name of the active canvas backend (``"pyvista"`` or ``"plotly"``).""" return self._canvas_backend + def _log_canvas_message(self, message: str, severity: str = "info") -> None: + """Route a JS console line from the Plotly page to the Console dock. + + The dock may not exist yet while canvases are being constructed, so + the lookup is guarded. + """ + dock = getattr(self, "_console_dock", None) + if dock is not None: + dock.log(message, severity=severity) + # ── plot style ─────────────────────────────────────────────────── def _load_plot_style(self) -> RenderStyle: """Style from the last session, ignoring anything unrecognised.""" diff --git a/tests/gui/test_plotly_view_preservation.py b/tests/gui/test_plotly_view_preservation.py index 3250779..97ef3ee 100644 --- a/tests/gui/test_plotly_view_preservation.py +++ b/tests/gui/test_plotly_view_preservation.py @@ -99,3 +99,25 @@ def test_style_change_keeps_the_view_but_updates_the_layout(qtbot) -> None: # t payload, preserve = _parse_call(calls[-1]) assert preserve is True, "colour changes must not re-frame the view" assert payload["layout"]["scene"]["xaxis"]["showgrid"] is True # layout applied + + +@pytest.mark.gui +def test_js_console_messages_reach_the_log(qtbot) -> None: # type: ignore[no-untyped-def] + """A plotly failure inside the page must not be invisible from Python.""" + from PySide6.QtWebEngineCore import QWebEnginePage + + from otko.views.canvas_plotly.plotly_canvas import _LoggingPage + + messages: list[tuple[str, str]] = [] + page = _LoggingPage(lambda msg, severity="info": messages.append((severity, msg))) + + levels = QWebEnginePage.JavaScriptConsoleMessageLevel + page.javaScriptConsoleMessage(levels.ErrorMessageLevel, "boom", 7, "file:///tmp/plot.html") + page.javaScriptConsoleMessage(levels.WarningMessageLevel, "careful", 8, "file:///tmp/plot.html") + page.javaScriptConsoleMessage(levels.InfoMessageLevel, "hello", 9, "") + + assert messages == [ + ("error", "[web] plot.html:7 boom"), + ("warning", "[web] plot.html:8 careful"), + ("info", "[web] plotly:9 hello"), + ]