diff --git a/src/qmxgraph/_web_view.py b/src/qmxgraph/_web_view.py index 6aa779b..25b353a 100644 --- a/src/qmxgraph/_web_view.py +++ b/src/qmxgraph/_web_view.py @@ -1,4 +1,5 @@ # Dynamic dependency that may mess freezing tools if not included +import json from enum import auto from enum import Enum from typing import Any @@ -132,7 +133,8 @@ def eval_js(self, script, *, timeout_ms: int = 10_000) -> Any: """ self._check_valid_eval_state() with wait_callback_called(timeout_ms=timeout_ms) as callback: - self.page().runJavaScript(script, callback) + self.page().runJavaScript(_in_page_context(script), callback) + # Leaving the block guarantees the callback was called, as it raises on timeout. assert callback.args is not None return callback.args[0] @@ -150,7 +152,7 @@ def eval_js_async(self, script: str) -> None: :param script: A JavaScript statement. """ self._check_valid_eval_state() - self.page().runJavaScript(script) + self.page().runJavaScript(_in_page_context(script)) def _check_valid_eval_state(self) -> None: """Check the view is in a valid state to evaluate JS commands.""" @@ -167,3 +169,11 @@ def dragMoveEvent(self, event): def dropEvent(self, event): self.on_drop_event.emit(event) + + +def _in_page_context(script: str) -> str: + """ + Wrap a script so the page evaluates it as its own code (see `qmxgraphEval`), otherwise + its uncaught errors reach the error bridge without any details. + """ + return f"qmxgraphEval({json.dumps(script)})" diff --git a/src/qmxgraph/page/error_redirect.js b/src/qmxgraph/page/error_redirect.js index 2344dd9..c751023 100644 --- a/src/qmxgraph/page/error_redirect.js +++ b/src/qmxgraph/page/error_redirect.js @@ -29,3 +29,19 @@ window.onerror = function (msg, url, lineNo, columnNo, error) { bridge_error_handler.error_slot(msg, url, lineNo, columnNo); }; + +/** + * Evaluates a script on behalf of Python, returning the value of its last statement. + * + * Chromium reports uncaught errors from scripts injected by `runJavaScript` as a bare + * "Script error." with no details. Evaluated from here, the code counts as the page's own, + * so `onerror` above receives its message and stack. + * + * @param {string} source The script to evaluate, in global scope. + * @returns {*} The value of the script's last statement. + */ +window.qmxgraphEval = function (source) { + "use strict"; + /* jshint evil: true */ + return (0, eval)(source); +}; diff --git a/tests/test_qt_js_integration.py b/tests/test_qt_js_integration.py index 91a02f6..986c685 100644 --- a/tests/test_qt_js_integration.py +++ b/tests/test_qt_js_integration.py @@ -29,15 +29,12 @@ from qmxgraph.widget import QmxGraph -# TODO(ASIM-6921): Remove once JS errors are no longer muted. -@pytest.mark.xfail( - reason="QtWebEngine 5.15.15 reports JS errors as a bare 'Script error.'", strict=True -) -def test_error_redirection(loaded_graph) -> None: +def test_error_redirection(loaded_graph: QmxGraph) -> None: """ It is possible to redirect errors in JS code to Python/Qt side. - :type loaded_graph: qmxgraph.widget.qmxgraph + The stack is only checked up to the error's position in the evaluated code, as the frames + after it belong to the evaluation machinery. """ error_redirection = loaded_graph.error_bridge @@ -46,15 +43,40 @@ def test_error_redirection(loaded_graph) -> None: assert cb.args is not None msg, url, line, column = cb.args - expected = textwrap.dedent( + expected_start = textwrap.dedent( """\ Uncaught Error: test stack: Error: test - at :1:7""" + """ ) assert (url, line, column) == ("qrc:/", 1, 1) - assert msg == expected + assert msg.startswith(expected_start) + assert ":1:7" in msg + + +@pytest.mark.parametrize( + "statement, expected_error", + [ + ("api.noSuchFunction(1)", "TypeError: api.noSuchFunction is not a function"), + ("api.updateTable(", "SyntaxError: Unexpected end of input"), + ], +) +def test_error_redirection_from_evaluated_code( + loaded_graph: QmxGraph, statement: str, expected_error: str +) -> None: + """ + Regression test for ASIM-6921: errors raised by the evaluated code itself, like calling a + function that doesn't exist or a syntax error, reached Python as a bare "Script error.", + as Chromium hides the details of errors from scripts injected by `runJavaScript`. + """ + with wait_signals_called(loaded_graph.error_bridge.on_error) as cb: + eval_js(loaded_graph, statement) + + assert cb.args is not None + msg, url, line, column = cb.args + assert msg.startswith(f"Uncaught {expected_error}\nstack:\n") + assert (url, line) == ("qrc:/", 1) def test_events_bridge_delayed_signals(graph, qtbot, mocker) -> None: