From 76fcec4e4c096de3a3143d6453147e611807c028 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Sat, 10 Oct 2015 19:21:12 +0200 Subject: [PATCH] tests: First steps towards end-to-end tests. --- tests/integration/conftest.py | 5 +- tests/integration/quteprocess.py | 137 +++++++++++++++++++++++++++++++ tests/integration/test_smoke.py | 4 + tests/integration/testprocess.py | 24 ++++-- tests/integration/webserver.py | 4 +- tox.ini | 2 +- 6 files changed, 166 insertions(+), 10 deletions(-) create mode 100644 tests/integration/quteprocess.py diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index ea6044ec9..78f5fb026 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -17,6 +17,9 @@ # You should have received a copy of the GNU General Public License # along with qutebrowser. If not, see . +# pylint: disable=unused-import + """Things needed for integration testing.""" -from webserver import httpbin, httpbin_clean # pylint: disable=unused-import +from webserver import httpbin, httpbin_after_test +from quteprocess import quteproc, quteproc_after_test diff --git a/tests/integration/quteprocess.py b/tests/integration/quteprocess.py new file mode 100644 index 000000000..e94932818 --- /dev/null +++ b/tests/integration/quteprocess.py @@ -0,0 +1,137 @@ +# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et: + +# Copyright 2015 Florian Bruhin (The Compiler) +# +# This file is part of qutebrowser. +# +# qutebrowser is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# qutebrowser is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with qutebrowser. If not, see . + +# pylint doesn't understand the testprocess import +# pylint: disable=no-member + +"""Fixtures to run qutebrowser in a QProcess and communicate.""" + +import re +import sys +import time +import os.path +import collections + +import pytest +from PyQt5.QtCore import pyqtSignal + +import testprocess # pylint: disable=import-error +from qutebrowser.misc import ipc + + +LogLine = collections.namedtuple('LogLine', [ + 'timestamp', 'loglevel', 'category', 'module', 'function', 'line', + 'message']) + + +class QuteProc(testprocess.Process): + + """A running qutebrowser process used for tests. + + Attributes: + _ipc_socket: The IPC socket of the started instance. + """ + + LOG_RE = re.compile(r""" + (?P\d\d:\d\d:\d\d) + \ (?PVDEBUG|DEBUG|INFO|WARNING|ERROR) + \ +(?P\w+) + \ +(?P\w+):(?P\w+):(?P\d+) + \ (?P.+) + """, re.VERBOSE) + + executing_command = pyqtSignal() + + def __init__(self, parent=None): + super().__init__(parent) + self._ipc_socket = None + + def _parse_line(self, line): + match = self.LOG_RE.match(line) + if match is None: + if line.startswith(' '): + # Multiple lines in some log output... + return None + elif not line.strip(): + return None + else: + raise testprocess.InvalidLine + log_line = LogLine(**match.groupdict()) + + start_okay_message = ("load status for " + ": LoadStatus.success") + + if (log_line.category == 'ipc' and + log_line.message.startswith("Listening as ")): + self._ipc_socket = log_line.message.split(' ', maxsplit=2)[2] + elif (log_line.category == 'webview' and + log_line.message == start_okay_message): + self.ready.emit() + elif (log_line.category == 'commands' and + log_line.module =='command' and log_line.function == 'run' and + log_line.message.startswith('Calling ')): + self.executing_command.emit() + + return log_line + + def _executable_args(self): + if hasattr(sys, 'frozen'): + executable = os.path.join(os.path.dirname(sys.executable), + 'qutebrowser') + args = [] + else: + executable = sys.executable + args = ['-m', 'qutebrowser'] + args += ['--debug', '--no-err-windows', '--temp-basedir', + 'about:blank'] + return executable, args + + def after_test(self): + bad_msgs = [msg for msg in self._data + if msg.loglevel not in ['VDEBUG', 'DEBUG', 'INFO']] + super().after_test() + if bad_msgs: + text = 'Logged unexpected errors:\n\n' + '\n'.join( + str(e) for e in bad_msgs) + pytest.fail(text, pytrace=False) + + def send_cmd(self, command): + assert self._ipc_socket is not None + with self._wait_signal(self.executing_command): + ipc.send_to_running_instance(self._ipc_socket, [':' + command], + target_arg='') + # Wait a bit in cause the command triggers any error. + time.sleep(0.5) + + +@pytest.yield_fixture(scope='session', autouse=True) +def quteproc(qapp): + """Fixture for qutebrowser process.""" + proc = QuteProc() + proc.start() + yield proc + proc.cleanup() + + +@pytest.yield_fixture(autouse=True) +def quteproc_after_test(quteproc): + """Fixture to check the status of and restart the qutebrowser process.""" + yield + quteproc.after_test() diff --git a/tests/integration/test_smoke.py b/tests/integration/test_smoke.py index af3e165cb..bbba92e3f 100644 --- a/tests/integration/test_smoke.py +++ b/tests/integration/test_smoke.py @@ -33,3 +33,7 @@ def test_smoke(): argv += ['--debug', '--no-err-windows', '--nowindow', '--temp-basedir', 'about:blank', ':later 500 quit'] subprocess.check_call(argv) + + +def test_smoke_quteproc(quteproc): + pass diff --git a/tests/integration/testprocess.py b/tests/integration/testprocess.py index 46a6ce6b8..984d0ec2b 100644 --- a/tests/integration/testprocess.py +++ b/tests/integration/testprocess.py @@ -44,9 +44,11 @@ class Process(QObject): Reads the log from its stdout and parses it. Signals: + ready: Emitted when the server finished starting up. new_data: Emitted when a new line was parsed. """ + ready = pyqtSignal() new_data = pyqtSignal(object) def __init__(self, parent=None): @@ -80,6 +82,16 @@ class Process(QObject): self.read_log() return self._data + def _wait_signal(self, signal, timeout=5000, raising=True): + """Wait for a signal to be emitted. + + Should be used in a contextmanager. + """ + blocker = pytestqt.plugin.SignalBlocker( + timeout=timeout, raising=raising) + blocker.connect(signal) + return blocker + @pyqtSlot() def read_log(self): """Read the log from the process' stdout.""" @@ -95,16 +107,13 @@ class Process(QObject): print("INVALID: {}".format(line)) continue - print('parsed: {}'.format(parsed)) if parsed is not None: self._data.append(parsed) self.new_data.emit(parsed) def start(self): """Start the process and wait until it started.""" - blocker = pytestqt.plugin.SignalBlocker(timeout=5000, raising=True) - blocker.connect(self.ready) - with blocker: + with self._wait_signal(self.ready): self._start() def _start(self): @@ -113,6 +122,7 @@ class Process(QObject): self.proc.start(executable, args) ok = self.proc.waitForStarted() assert ok + assert self.is_running() self.proc.readyRead.connect(self.read_log) def after_test(self): @@ -122,7 +132,7 @@ class Process(QObject): unexpected output lines earlier. """ self._data.clear() - if self.proc.state() != QProcess.Running: + if not self.is_running(): print("Restarting process...") self.start() raise ProcessExited @@ -133,3 +143,7 @@ class Process(QObject): """Clean up and shut down the process.""" self.proc.terminate() self.proc.waitForFinished() + + def is_running(self): + """Check if the process is currently running.""" + return self.proc.state() == QProcess.Running diff --git a/tests/integration/webserver.py b/tests/integration/webserver.py index d1de1516f..b9fc1c9be 100644 --- a/tests/integration/webserver.py +++ b/tests/integration/webserver.py @@ -47,11 +47,9 @@ class HTTPBin(testprocess.Process): LOG_RE: Used to parse the CLF log which httpbin outputs. Signals: - ready: Emitted when the server finished starting up. new_request: Emitted when there's a new request received. """ - ready = pyqtSignal() new_request = pyqtSignal(Request) LOG_RE = re.compile(r""" @@ -127,7 +125,7 @@ def httpbin(qapp): @pytest.yield_fixture(autouse=True) -def httpbin_clean(httpbin): +def httpbin_after_test(httpbin): """Fixture to clean httpbin request list after each test.""" yield httpbin.after_test() diff --git a/tox.ini b/tox.ini index 1d02a982e..59038fb8d 100644 --- a/tox.ini +++ b/tox.ini @@ -11,7 +11,7 @@ envlist = py34,py35,misc,vulture,pep257,pyflakes,pep8,mccabe,pylint,pyroma,check setenv = QT_QPA_PLATFORM_PLUGIN_PATH={envdir}/Lib/site-packages/PyQt5/plugins/platforms PYTEST_QT_API=pyqt5 -passenv = PYTHON DISPLAY XAUTHORITY HOME USERNAME USER CI +passenv = PYTHON DISPLAY XAUTHORITY HOME USERNAME USER CI XDG_* deps = -r{toxinidir}/requirements.txt wheel==0.26.0