From 79f83a033d03bc8cf99eeb785066535da8a07b46 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Wed, 3 Feb 2016 20:27:11 +0100 Subject: [PATCH] Add a fake clipboard for tests There are a lot of problems and flakiness with using a real clipboard. Instead we now have a :debug-set-fake-clipboard command to set a text, and use logging when getting the contents. Fixes #1285. --- doc/help/commands.asciidoc | 10 +++ qutebrowser/app.py | 2 +- qutebrowser/browser/commands.py | 53 +++++++-------- qutebrowser/browser/hints.py | 10 ++- qutebrowser/misc/miscwidgets.py | 14 ++-- qutebrowser/misc/utilcmds.py | 15 ++++- qutebrowser/utils/utils.py | 42 +++++++++++- scripts/dev/run_vulture.py | 2 + tests/integration/features/conftest.py | 71 +++----------------- tests/integration/features/test_caret.py | 2 +- tests/integration/features/test_search.py | 2 +- tests/integration/features/test_yankpaste.py | 18 +---- tests/integration/features/yankpaste.feature | 4 -- tests/unit/utils/test_utils.py | 49 +++++++++++++- 14 files changed, 166 insertions(+), 128 deletions(-) diff --git a/doc/help/commands.asciidoc b/doc/help/commands.asciidoc index 8e3b00b1b..81daecb79 100644 --- a/doc/help/commands.asciidoc +++ b/doc/help/commands.asciidoc @@ -1236,6 +1236,7 @@ These commands are mainly intended for debugging. They are hidden if qutebrowser |<>|Crash for debugging purposes. |<>|Dump the current page's content to a file. |<>|Evaluate a python string and display the results as a web page. +|<>|Put data into the fake clipboard and enable logging, used for tests. |<>|Trace executed code via hunter. |<>|Execute a webaction. |============== @@ -1292,6 +1293,15 @@ Evaluate a python string and display the results as a web page. * This command does not split arguments after the last argument and handles quotes literally. * With this command, +;;+ is interpreted literally instead of splitting off a second command. +[[debug-set-fake-clipboard]] +=== debug-set-fake-clipboard +Syntax: +:debug-set-fake-clipboard ['s']+ + +Put data into the fake clipboard and enable logging, used for tests. + +==== positional arguments +* +'s'+: The text to put into the fake clipboard, or unset to enable logging. + [[debug-trace]] === debug-trace Syntax: +:debug-trace ['expr']+ diff --git a/qutebrowser/app.py b/qutebrowser/app.py index a9594da8e..7ba630ddb 100644 --- a/qutebrowser/app.py +++ b/qutebrowser/app.py @@ -260,7 +260,7 @@ def process_pos_args(args, via_ipc=False, cwd=None, target_arg=None): if cmd.startswith(':'): if win_id is None: win_id = mainwindow.get_window(via_ipc, force_tab=True) - log.init.debug("Startup cmd {}".format(cmd)) + log.init.debug("Startup cmd {!r}".format(cmd)) commandrunner = runners.CommandRunner(win_id) commandrunner.run_safely_init(cmd[1:]) elif not cmd: diff --git a/qutebrowser/browser/commands.py b/qutebrowser/browser/commands.py index a83bf03c0..586573948 100644 --- a/qutebrowser/browser/commands.py +++ b/qutebrowser/browser/commands.py @@ -30,7 +30,7 @@ import xml.etree.ElementTree from PyQt5.QtWebKit import QWebSettings from PyQt5.QtWidgets import QApplication, QTabBar from PyQt5.QtCore import Qt, QUrl, QEvent -from PyQt5.QtGui import QClipboard, QKeyEvent +from PyQt5.QtGui import QKeyEvent from PyQt5.QtPrintSupport import QPrintDialog, QPrintPreviewDialog from PyQt5.QtWebKitWidgets import QWebPage import pygments @@ -659,7 +659,6 @@ class CommandDispatcher: title: Yank the title instead of the URL. domain: Yank only the scheme, domain, and port number. """ - clipboard = QApplication.clipboard() if title: s = self._tabbed_browser.page_title(self._current_index()) what = 'title' @@ -673,14 +672,14 @@ class CommandDispatcher: s = self._current_url().toString( QUrl.FullyEncoded | QUrl.RemovePassword) what = 'URL' - if sel and clipboard.supportsSelection(): - mode = QClipboard.Selection + + if sel and QApplication.clipboard().supportsSelection(): target = "primary selection" else: - mode = QClipboard.Clipboard + sel = False target = "clipboard" - log.misc.debug("Yanking to {}: '{}'".format(target, s)) - clipboard.setText(s, mode) + + utils.set_clipboard(s, selection=sel) message.info(self._win_id, "Yanked {} to {}: {}".format( what, target, s)) @@ -811,14 +810,12 @@ class CommandDispatcher: bg: Open in a background tab. window: Open in new window. """ - clipboard = QApplication.clipboard() - if sel and clipboard.supportsSelection(): - mode = QClipboard.Selection + if sel and QApplication.clipboard().supportsSelection(): target = "Primary selection" else: - mode = QClipboard.Clipboard + sel = False target = "Clipboard" - text = clipboard.text(mode) + text = utils.get_clipboard(selection=sel) if not text.strip(): raise cmdexc.CommandError("{} is empty.".format(target)) log.misc.debug("{} contained: '{}'".format(target, @@ -1313,17 +1310,19 @@ class CommandDispatcher: if not elem.is_editable(strict=True): raise cmdexc.CommandError("Focused element is not editable!") - clipboard = QApplication.clipboard() - if clipboard.supportsSelection(): - sel = clipboard.text(QClipboard.Selection) - log.misc.debug("Pasting primary selection into element {}".format( - elem.debug_text())) - elem.evaluateJavaScript(""" - var sel = '{}'; - var event = document.createEvent('TextEvent'); - event.initTextEvent('textInput', true, true, null, sel); - this.dispatchEvent(event); - """.format(webelem.javascript_escape(sel))) + try: + sel = utils.get_clipboard(selection=True) + except utils.SelectionUnsupportedError: + return + + log.misc.debug("Pasting primary selection into element {}".format( + elem.debug_text())) + elem.evaluateJavaScript(""" + var sel = '{}'; + var event = document.createEvent('TextEvent'); + event.initTextEvent('textInput', true, true, null, sel); + this.dispatchEvent(event); + """.format(webelem.javascript_escape(sel))) def _clear_search(self, view, text): """Clear search string/highlights for the given view. @@ -1667,14 +1666,12 @@ class CommandDispatcher: message.info(self._win_id, "Nothing to yank") return - clipboard = QApplication.clipboard() - if sel and clipboard.supportsSelection(): - mode = QClipboard.Selection + if sel and QApplication.clipboard().supportsSelection(): target = "primary selection" else: - mode = QClipboard.Clipboard + sel = False target = "clipboard" - clipboard.setText(s, mode) + utils.set_clipboard(s, sel) message.info(self._win_id, "{} {} yanked to {}".format( len(s), "char" if len(s) == 1 else "chars", target)) if not keep: diff --git a/qutebrowser/browser/hints.py b/qutebrowser/browser/hints.py index 20a91ec94..c9a722dc6 100644 --- a/qutebrowser/browser/hints.py +++ b/qutebrowser/browser/hints.py @@ -27,8 +27,7 @@ import string from PyQt5.QtCore import (pyqtSignal, pyqtSlot, QObject, QEvent, Qt, QUrl, QTimer) -from PyQt5.QtGui import QMouseEvent, QClipboard -from PyQt5.QtWidgets import QApplication +from PyQt5.QtGui import QMouseEvent from PyQt5.QtWebKit import QWebElement from PyQt5.QtWebKitWidgets import QWebPage @@ -36,8 +35,7 @@ from qutebrowser.config import config from qutebrowser.keyinput import modeman, modeparsers from qutebrowser.browser import webelem from qutebrowser.commands import userscripts, cmdexc, cmdutils, runners -from qutebrowser.utils import (usertypes, log, qtutils, message, - objreg) +from qutebrowser.utils import usertypes, log, qtutils, message, objreg, utils from qutebrowser.misc import guiprocess @@ -481,9 +479,9 @@ class HintManager(QObject): context: The HintContext to use. """ sel = context.target == Target.yank_primary - mode = QClipboard.Selection if sel else QClipboard.Clipboard urlstr = url.toString(QUrl.FullyEncoded | QUrl.RemovePassword) - QApplication.clipboard().setText(urlstr, mode) + utils.set_clipboard(urlstr, selection=sel) + msg = "Yanked URL to {}: {}".format( "primary selection" if sel else "clipboard", urlstr) diff --git a/qutebrowser/misc/miscwidgets.py b/qutebrowser/misc/miscwidgets.py index b91531020..87ef99905 100644 --- a/qutebrowser/misc/miscwidgets.py +++ b/qutebrowser/misc/miscwidgets.py @@ -20,9 +20,9 @@ """Misc. widgets used at different places.""" from PyQt5.QtCore import pyqtSlot, pyqtSignal, Qt, QSize -from PyQt5.QtWidgets import (QLineEdit, QApplication, QWidget, QHBoxLayout, - QLabel, QStyleOption, QStyle) -from PyQt5.QtGui import QValidator, QClipboard, QPainter +from PyQt5.QtWidgets import (QLineEdit, QWidget, QHBoxLayout, QLabel, + QStyleOption, QStyle) +from PyQt5.QtGui import QValidator, QPainter from qutebrowser.utils import utils from qutebrowser.misc import cmdhistory @@ -101,10 +101,12 @@ class CommandLineEdit(QLineEdit): def keyPressEvent(self, e): """Override keyPressEvent to paste primary selection on Shift + Ins.""" if e.key() == Qt.Key_Insert and e.modifiers() == Qt.ShiftModifier: - clipboard = QApplication.clipboard() - if clipboard.supportsSelection(): + try: + text = utils.get_clipboard(selection=True) + except utils.SelectionUnsupportedError: + pass + else: e.accept() - text = clipboard.text(QClipboard.Selection) self.insert(text) return super().keyPressEvent(e) diff --git a/qutebrowser/misc/utilcmds.py b/qutebrowser/misc/utilcmds.py index d8d664c9f..994342232 100644 --- a/qutebrowser/misc/utilcmds.py +++ b/qutebrowser/misc/utilcmds.py @@ -29,7 +29,7 @@ except ImportError: hunter = None from qutebrowser.browser.network import qutescheme -from qutebrowser.utils import log, objreg, usertypes, message, debug +from qutebrowser.utils import log, objreg, usertypes, message, debug, utils from qutebrowser.commands import cmdutils, runners, cmdexc from qutebrowser.config import style from qutebrowser.misc import consolewidget @@ -198,3 +198,16 @@ def debug_pyeval(s, quiet=False): tabbed_browser = objreg.get('tabbed-browser', scope='window', window='last-focused') tabbed_browser.openurl(QUrl('qute:pyeval'), newtab=True) + + +@cmdutils.register(debug=True) +def debug_set_fake_clipboard(s=None): + """Put data into the fake clipboard and enable logging, used for tests. + + Args: + s: The text to put into the fake clipboard, or unset to enable logging. + """ + if s is None: + utils.log_clipboard = True + else: + utils.fake_clipboard = s diff --git a/qutebrowser/utils/utils.py b/qutebrowser/utils/utils.py index ecd31ae0f..ef61f3c00 100644 --- a/qutebrowser/utils/utils.py +++ b/qutebrowser/utils/utils.py @@ -29,13 +29,23 @@ import contextlib import itertools from PyQt5.QtCore import Qt -from PyQt5.QtGui import QKeySequence, QColor +from PyQt5.QtGui import QKeySequence, QColor, QClipboard +from PyQt5.QtWidgets import QApplication import pkg_resources import qutebrowser from qutebrowser.utils import qtutils, log +fake_clipboard = None +log_clipboard = False + + +class SelectionUnsupportedError(Exception): + + """Raised if [gs]et_clipboard is used and selection=True is unsupported.""" + + def elide(text, length): """Elide text so it uses a maximum of length chars.""" if length < 1: @@ -743,3 +753,33 @@ def newest_slice(iterable, count): return iterable else: return itertools.islice(iterable, len(iterable) - count, len(iterable)) + + +def set_clipboard(data, selection=False): + """Set the clipboard to some given data.""" + clipboard = QApplication.clipboard() + if selection and not clipboard.supportsSelection(): + raise SelectionUnsupportedError + if log_clipboard: + what = 'primary selection' if selection else 'clipboard' + log.misc.debug("Setting fake {}: {!r}".format(what, data)) + else: + mode = QClipboard.Selection if selection else QClipboard.Clipboard + clipboard.setText(data, mode=mode) + + +def get_clipboard(selection=False): + """Get data from the clipboard.""" + global fake_clipboard + clipboard = QApplication.clipboard() + if selection and not clipboard.supportsSelection(): + raise SelectionUnsupportedError + + if fake_clipboard is not None: + data = fake_clipboard + fake_clipboard = None + else: + mode = QClipboard.Selection if selection else QClipboard.Clipboard + data = clipboard.text(mode=mode) + + return data diff --git a/scripts/dev/run_vulture.py b/scripts/dev/run_vulture.py index 198c6a17c..98892eb1f 100755 --- a/scripts/dev/run_vulture.py +++ b/scripts/dev/run_vulture.py @@ -81,6 +81,8 @@ def whitelist_generator(): yield 'qutebrowser.misc.utilcmds.pyeval_output' yield 'utils.use_color' yield 'qutebrowser.browser.mhtml.last_used_directory' + yield 'qutebrowser.utils.utils.fake_clipboard' + yield 'qutebrowser.utils.utils.log_clipboard' # Other false-positives yield ('qutebrowser.completion.models.sortfilter.CompletionFilterModel().' diff --git a/tests/integration/features/conftest.py b/tests/integration/features/conftest.py index 6f2a84ca7..eaeef1f22 100644 --- a/tests/integration/features/conftest.py +++ b/tests/integration/features/conftest.py @@ -30,28 +30,10 @@ import textwrap import pytest import yaml import pytest_bdd as bdd -from PyQt5.QtCore import QElapsedTimer -from PyQt5.QtGui import QClipboard from helpers import utils -class WaitForClipboardTimeout(Exception): - - """Raised when _wait_for_clipboard didn't get the expected message.""" - - -def _clipboard_mode(qapp, what): - """Get the QClipboard::Mode to use based on a string.""" - if what == 'clipboard': - return QClipboard.Clipboard - elif what == 'primary selection': - assert qapp.clipboard().supportsSelection() - return QClipboard.Selection - else: - raise AssertionError - - ## Given @@ -207,21 +189,17 @@ def selection_supported(qapp): @bdd.when(bdd.parsers.re(r'I put "(?P.*)" into the ' r'(?Pprimary selection|clipboard)')) -def fill_clipboard(qtbot, qapp, httpbin, what, content): - mode = _clipboard_mode(qapp, what) +def fill_clipboard(quteproc, httpbin, what, content): content = content.replace('(port)', str(httpbin.port)) content = content.replace(r'\n', '\n') - - clipboard = qapp.clipboard() - clipboard.setText(content, mode) - _wait_for_clipboard(qtbot, qapp.clipboard(), mode, content) + quteproc.send_cmd(':debug-set-fake-clipboard "{}"'.format(content)) @bdd.when(bdd.parsers.re(r'I put the following lines into the ' r'(?Pprimary selection|clipboard):\n' r'(?P.+)$', flags=re.DOTALL)) -def fill_clipboard_multiline(qtbot, qapp, httpbin, what, content): - fill_clipboard(qtbot, qapp, httpbin, what, textwrap.dedent(content)) +def fill_clipboard_multiline(quteproc, httpbin, what, content): + fill_clipboard(quteproc, httpbin, what, textwrap.dedent(content)) ## Then @@ -417,51 +395,18 @@ def check_open_tabs(quteproc, tabs): assert 'active' not in session_tab -def _wait_for_clipboard(qtbot, clipboard, mode, expected): - timeout = 1000 - timer = QElapsedTimer() - timer.start() - - while True: - if clipboard.text(mode=mode) == expected: - return - - # We need to poll the clipboard, as for some reason it can change with - # emitting changed (?). - with qtbot.waitSignal(clipboard.changed, timeout=100, raising=False): - pass - - if timer.hasExpired(timeout): - mode_names = { - QClipboard.Clipboard: 'clipboard', - QClipboard.Selection: 'primary selection', - } - raise WaitForClipboardTimeout( - "Timed out after {timeout}ms waiting for {what}:\n" - " expected: {expected!r}\n" - " clipboard: {clipboard!r}\n" - " primary: {primary!r}.".format( - timeout=timeout, what=mode_names[mode], - expected=expected, - clipboard=clipboard.text(mode=QClipboard.Clipboard), - primary=clipboard.text(mode=QClipboard.Selection)) - ) - - @bdd.then(bdd.parsers.re(r'the (?Pprimary selection|clipboard) should ' r'contain "(?P.*)"')) -def clipboard_contains(qtbot, qapp, httpbin, what, content): - mode = _clipboard_mode(qapp, what) +def clipboard_contains(quteproc, httpbin, what, content): expected = content.replace('(port)', str(httpbin.port)) expected = expected.replace('\\n', '\n') - _wait_for_clipboard(qtbot, qapp.clipboard(), mode, expected) + quteproc.wait_for(message='Setting fake {}: {!r}'.format(what, expected)) @bdd.then(bdd.parsers.parse('the clipboard should contain:\n{content}')) -def clipboard_contains_multiline(qtbot, qapp, content): +def clipboard_contains_multiline(quteproc, content): expected = textwrap.dedent(content) - _wait_for_clipboard(qtbot, qapp.clipboard(), QClipboard.Clipboard, - expected) + quteproc.wait_for(message='Setting fake clipboard: {!r}'.format(expected)) @bdd.then("qutebrowser should quit") diff --git a/tests/integration/features/test_caret.py b/tests/integration/features/test_caret.py index 155b9fd92..98b70ade5 100644 --- a/tests/integration/features/test_caret.py +++ b/tests/integration/features/test_caret.py @@ -20,7 +20,7 @@ import pytest_bdd as bdd # pylint: disable=unused-import -from test_yankpaste import skip_with_broken_clipboard +from test_yankpaste import init_fake_clipboard bdd.scenarios('caret.feature') diff --git a/tests/integration/features/test_search.py b/tests/integration/features/test_search.py index b53f3120c..45d00e818 100644 --- a/tests/integration/features/test_search.py +++ b/tests/integration/features/test_search.py @@ -20,7 +20,7 @@ import pytest_bdd as bdd # pylint: disable=unused-import -from test_yankpaste import skip_with_broken_clipboard +from test_yankpaste import init_fake_clipboard bdd.scenarios('search.feature') diff --git a/tests/integration/features/test_yankpaste.py b/tests/integration/features/test_yankpaste.py index 3770fd407..72c19830c 100644 --- a/tests/integration/features/test_yankpaste.py +++ b/tests/integration/features/test_yankpaste.py @@ -26,21 +26,9 @@ bdd.scenarios('yankpaste.feature') @pytest.fixture(autouse=True) -def skip_with_broken_clipboard(qtbot, qapp): - """The clipboard seems to be broken on some platforms (OS X Yosemite?). - - This skips the tests if this is the case. - """ - clipboard = qapp.clipboard() - - with qtbot.waitSignal(clipboard.changed, raising=False): - clipboard.setText("Does this work?") - - if clipboard.text() != "Does this work?": - pytest.skip("Clipboard seems to be broken on this platform.") - - with qtbot.waitSignal(clipboard.changed): - clipboard.clear() +def init_fake_clipboard(quteproc): + """Make sure the fake clipboard will be used.""" + quteproc.send_cmd(':debug-set-fake-clipboard') @bdd.when(bdd.parsers.parse('I set the text field to "{value}"')) diff --git a/tests/integration/features/yankpaste.feature b/tests/integration/features/yankpaste.feature index c2e7531dc..b1e329ce9 100644 --- a/tests/integration/features/yankpaste.feature +++ b/tests/integration/features/yankpaste.feature @@ -182,8 +182,6 @@ Feature: Yanking and pasting. And I run :paste -t Then no crash should happen - # https://github.com/The-Compiler/qutebrowser/issues/1285 - @xfail Scenario: Pasting multiple urls with an almost empty one When I open about:blank And I put "http://localhost:(port)/data/hello.txt\n \nhttp://localhost:(port)/data/hello2.txt" into the clipboard @@ -192,8 +190,6 @@ Feature: Yanking and pasting. #### :paste-primary - # https://github.com/The-Compiler/qutebrowser/issues/1285 - @xfail Scenario: Pasting the primary selection into an empty text field When selection is supported And I open data/paste_primary.html diff --git a/tests/unit/utils/test_utils.py b/tests/unit/utils/test_utils.py index 26b3c0920..23bd5b301 100644 --- a/tests/unit/utils/test_utils.py +++ b/tests/unit/utils/test_utils.py @@ -29,7 +29,7 @@ import functools import collections from PyQt5.QtCore import Qt -from PyQt5.QtGui import QColor +from PyQt5.QtGui import QColor, QClipboard import pytest import qutebrowser @@ -930,3 +930,50 @@ class TestNewestSlice: """Test slices which shouldn't raise an exception.""" sliced = utils.newest_slice(items, count) assert list(sliced) == list(expected) + + +class TestGetSetClipboard: + + @pytest.fixture(autouse=True) + def clipboard_mock(self, mocker): + m = mocker.patch('qutebrowser.utils.utils.QApplication.clipboard', + autospec=True) + clipboard = m() + clipboard.text.return_value = 'mocked clipboard text' + return clipboard + + def test_set(self, clipboard_mock, caplog): + utils.set_clipboard('Hello World') + clipboard_mock.setText.assert_called_with('Hello World', + mode=QClipboard.Clipboard) + assert not caplog.records + + def test_set_unsupported_selection(self, clipboard_mock): + clipboard_mock.supportsSelection.return_value = False + with pytest.raises(utils.SelectionUnsupportedError): + utils.set_clipboard('foo', selection=True) + + @pytest.mark.parametrize('selection, what', [ + (True, 'primary selection'), + (False, 'clipboard'), + ]) + def test_set_logging(self, clipboard_mock, caplog, selection, what): + utils.log_clipboard = True + utils.set_clipboard('fake clipboard text', selection=selection) + assert not clipboard_mock.setText.called + expected = "Setting fake {}: 'fake clipboard text'".format(what) + assert caplog.records[0].message == expected + + def test_get(self): + assert utils.get_clipboard() == 'mocked clipboard text' + + def test_get_unsupported_selection(self, clipboard_mock): + clipboard_mock.supportsSelection.return_value = False + with pytest.raises(utils.SelectionUnsupportedError): + utils.get_clipboard(selection=True) + + @pytest.mark.parametrize('selection', [True, False]) + def test_get_fake_clipboard(self, selection): + utils.fake_clipboard = 'fake clipboard text' + utils.get_clipboard(selection=selection) + assert utils.fake_clipboard is None