diff --git a/pytest.ini b/pytest.ini index 1a3f625e9..c897f0be7 100644 --- a/pytest.ini +++ b/pytest.ini @@ -30,6 +30,7 @@ markers = qtbug60673: Tests which are broken if the conversion from orange selection to real selection is flaky fake_os: Fake utils.is_* to a fake operating system unicode_locale: Tests which need an unicode locale to work + qtwebkit6021_skip: Tests which would fail on WebKit version 602.1 qt_log_level_fail = WARNING qt_log_ignore = ^SpellCheck: .* diff --git a/qutebrowser/browser/greasemonkey.py b/qutebrowser/browser/greasemonkey.py index 578830793..db8246bab 100644 --- a/qutebrowser/browser/greasemonkey.py +++ b/qutebrowser/browser/greasemonkey.py @@ -31,9 +31,10 @@ import attr from PyQt5.QtCore import pyqtSignal, QObject, QUrl from qutebrowser.utils import (log, standarddir, jinja, objreg, utils, - javascript, urlmatch) + javascript, urlmatch, version, usertypes) from qutebrowser.commands import cmdutils from qutebrowser.browser import downloads +from qutebrowser.misc import objects def _scripts_dir(): @@ -108,13 +109,18 @@ class GreasemonkeyScript: browser's debugger/inspector will not match up to the line numbers in the source script directly. """ + # Don't use Proxy on this webkit version, the support isn't there. + use_proxy = not ( + objects.backend == usertypes.Backend.QtWebKit and + version.qWebKitVersion() == '602.1') template = jinja.js_environment.get_template('greasemonkey_wrapper.js') return template.render( scriptName=javascript.string_escape( "/".join([self.namespace or '', self.name])), scriptInfo=self._meta_json(), scriptMeta=javascript.string_escape(self.script_meta), - scriptSource=self._code) + scriptSource=self._code, + use_proxy=use_proxy) def _meta_json(self): return json.dumps({ diff --git a/qutebrowser/javascript/greasemonkey_wrapper.js b/qutebrowser/javascript/greasemonkey_wrapper.js index 0731e93ac..457118696 100644 --- a/qutebrowser/javascript/greasemonkey_wrapper.js +++ b/qutebrowser/javascript/greasemonkey_wrapper.js @@ -145,9 +145,59 @@ } }; - const unsafeWindow = window; + {% if use_proxy %} + /* + * Try to give userscripts an environment that they expect. Which + * seems to be that the global window object should look the same as + * the page's one and that if a script writes to an attribute of + * window it should be able to access that variable in the global + * scope. + * Use a Proxy to stop scripts from actually changing the global + * window (that's what unsafeWindow is for). + * Use the "with" statement to make the proxy provide what looks + * like global scope. + * + * There are other Proxy functions that we may need to override. + * set, get and has are definitely required. + */ + const unsafeWindow = window; + const qute_gm_window_shadow = {}; // stores local changes to window + const qute_gm_windowProxyHandler = { + get: function(target, prop) { + if (prop in qute_gm_window_shadow) + return qute_gm_window_shadow[prop]; + if (prop in target) { + if (typeof target[prop] === 'function' && typeof target[prop].prototype == 'undefined') + // Getting TypeError: Illegal Execution when callers try to execute + // eg addEventListener from here because they were returned + // unbound + return target[prop].bind(target); + return target[prop]; + } + }, + set: function(target, prop, val) { + return qute_gm_window_shadow[prop] = val; + }, + has: function(target, key) { + return key in qute_gm_window_shadow || key in target; + } + }; + const qute_gm_window_proxy = new Proxy( + unsafeWindow, qute_gm_windowProxyHandler); - // ====== The actual user script source ====== // + with (qute_gm_window_proxy) { + // We can't return `this` or `qute_gm_window_proxy` from + // `qute_gm_window_proxy.get('window')` because the Proxy implementation + // does typechecking on read-only things. So we have to shadow `window` + // more conventionally here. + const window = qute_gm_window_proxy; + // ====== The actual user script source ====== // {{ scriptSource }} - // ====== End User Script ====== // + // ====== End User Script ====== // + }; + {% else %} + // ====== The actual user script source ====== // +{{ scriptSource }} + // ====== End User Script ====== // + {% endif %} })(); diff --git a/tests/conftest.py b/tests/conftest.py index 71bc9f388..7df1a1e08 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -36,7 +36,7 @@ from helpers import logfail from helpers.logfail import fail_on_logging from helpers.messagemock import message_mock from helpers.fixtures import * # noqa: F403 -from qutebrowser.utils import qtutils, standarddir, usertypes, utils +from qutebrowser.utils import qtutils, standarddir, usertypes, utils, version from qutebrowser.misc import objects import qutebrowser.app # To register commands @@ -77,6 +77,10 @@ def _apply_platform_markers(config, item): "https://bugreports.qt.io/browse/QTBUG-60673"), ('unicode_locale', sys.getfilesystemencoding() == 'ascii', "Skipped because of ASCII locale"), + ('qtwebkit6021_skip', + version.qWebKitVersion and + version.qWebKitVersion() == '602.1', + "Broken on WebKit 602.1") ] for searched_marker, condition, default_reason in markers: @@ -219,8 +223,10 @@ def check_display(request): @pytest.fixture(autouse=True) def set_backend(monkeypatch, request): """Make sure the backend global is set.""" - backend = (usertypes.Backend.QtWebEngine if request.config.webengine - else usertypes.Backend.QtWebKit) + if not request.config.webengine and version.qWebKitVersion: + backend = usertypes.Backend.QtWebKit + else: + backend = usertypes.Backend.QtWebEngine monkeypatch.setattr(objects, 'backend', backend) diff --git a/tests/helpers/fixtures.py b/tests/helpers/fixtures.py index c4756e1f5..cfaba7a55 100644 --- a/tests/helpers/fixtures.py +++ b/tests/helpers/fixtures.py @@ -43,10 +43,10 @@ import helpers.stubs as stubsmod import helpers.utils from qutebrowser.config import (config, configdata, configtypes, configexc, configfiles) -from qutebrowser.utils import objreg, standarddir, utils +from qutebrowser.utils import objreg, standarddir, utils, usertypes from qutebrowser.browser import greasemonkey from qutebrowser.browser.webkit import cookies -from qutebrowser.misc import savemanager, sql +from qutebrowser.misc import savemanager, sql, objects from qutebrowser.keyinput import modeman @@ -360,9 +360,10 @@ def qnam(qapp): @pytest.fixture -def webengineview(qtbot): +def webengineview(qtbot, monkeypatch): """Get a QWebEngineView if QtWebEngine is available.""" QtWebEngineWidgets = pytest.importorskip('PyQt5.QtWebEngineWidgets') + monkeypatch.setattr(objects, 'backend', usertypes.Backend.QtWebEngine) view = QtWebEngineWidgets.QWebEngineView() qtbot.add_widget(view) return view @@ -379,9 +380,10 @@ def webpage(qnam): @pytest.fixture -def webview(qtbot, webpage): +def webview(qtbot, webpage, monkeypatch): """Get a new QWebView object.""" QtWebKitWidgets = pytest.importorskip('PyQt5.QtWebKitWidgets') + monkeypatch.setattr(objects, 'backend', usertypes.Backend.QtWebKit) view = QtWebKitWidgets.QWebView() qtbot.add_widget(view) diff --git a/tests/unit/javascript/test_greasemonkey.py b/tests/unit/javascript/test_greasemonkey.py index 8701e8170..f3d29608c 100644 --- a/tests/unit/javascript/test_greasemonkey.py +++ b/tests/unit/javascript/test_greasemonkey.py @@ -158,3 +158,70 @@ def test_required_scripts_are_included(download_stub, tmpdir): # Additionally check that the base script is still being parsed correctly assert "Script is running." in scripts[0].code() assert scripts[0].excludes + + +class TestWindowIsolation: + """Check that greasemonkey scripts get a shadowed global scope.""" + + @pytest.fixture + def setup(self): + # pylint: disable=attribute-defined-outside-init + class SetupData: + pass + ret = SetupData() + + # Change something in the global scope + ret.setup_script = "window.$ = 'global'" + + # Greasemonkey script to report back on its scope. + test_script = greasemonkey.GreasemonkeyScript.parse( + textwrap.dedent(""" + // ==UserScript== + // @name scopetest + // ==/UserScript== + // Check the thing the page set is set to the expected type + result.push(window.$); + result.push($); + // Now overwrite it + window.$ = 'shadowed'; + // And check everything is how the script would expect it to be + // after just writing to the "global" scope + result.push(window.$); + result.push($); + """) + ) + + # The compiled source of that scripts with some additional setup + # bookending it. + ret.test_script = "\n".join([ + "var result = [];", + test_script.code(), + """ + // Now check that the actual global scope has + // not been overwritten + result.push(window.$); + result.push($); + // And return our findings + result;""" + ]) + + # What we expect the script to report back. + ret.expected = [ + "global", "global", + "shadowed", "shadowed", + "global", "global"] + return ret + + def test_webengine(self, callback_checker, webengineview, setup): + page = webengineview.page() + page.runJavaScript(setup.setup_script) + page.runJavaScript(setup.test_script, callback_checker.callback) + callback_checker.check(setup.expected) + + # The JSCore in 602.1 doesn't fully support Proxy. + @pytest.mark.qtwebkit6021_skip + def test_webkit(self, webview, setup): + elem = webview.page().mainFrame().documentElement() + elem.evaluateJavaScript(setup.setup_script) + result = elem.evaluateJavaScript(setup.test_script) + assert result == setup.expected diff --git a/tests/unit/javascript/test_js_execution.py b/tests/unit/javascript/test_js_execution.py index c5b5018d2..81f999962 100644 --- a/tests/unit/javascript/test_js_execution.py +++ b/tests/unit/javascript/test_js_execution.py @@ -26,7 +26,7 @@ import pytest @pytest.mark.parametrize('js_enabled, expected', [(True, 2.0), (False, None)]) def test_simple_js_webkit(webview, js_enabled, expected): """With QtWebKit, evaluateJavaScript works when JS is on.""" - # If we get there (because of the webengineview fixture) we can be certain + # If we get there (because of the webview fixture) we can be certain # QtWebKit is available from PyQt5.QtWebKit import QWebSettings webview.settings().setAttribute(QWebSettings.JavascriptEnabled, js_enabled) @@ -37,7 +37,7 @@ def test_simple_js_webkit(webview, js_enabled, expected): @pytest.mark.parametrize('js_enabled, expected', [(True, 2.0), (False, 2.0)]) def test_element_js_webkit(webview, js_enabled, expected): """With QtWebKit, evaluateJavaScript on an element works with JS off.""" - # If we get there (because of the webengineview fixture) we can be certain + # If we get there (because of the webview fixture) we can be certain # QtWebKit is available from PyQt5.QtWebKit import QWebSettings webview.settings().setAttribute(QWebSettings.JavascriptEnabled, js_enabled)