summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorFlorian Bruhin <me@the-compiler.org>2021-03-24 14:12:36 +0100
committerFlorian Bruhin <me@the-compiler.org>2021-03-24 14:12:36 +0100
commit2f592f7ce6414e3c071eaa6df86bf34b6a213bdf (patch)
treee65f177e655544d756c52b880781d27b2016bfa5
parentfdc21e10dd2b8d11d3f2798b8a3a490ec67c9377 (diff)
parent41bcada133b7235db698a2354df27585438b6a4b (diff)
downloadqutebrowser-2f592f7ce6414e3c071eaa6df86bf34b6a213bdf.tar.gz
qutebrowser-2f592f7ce6414e3c071eaa6df86bf34b6a213bdf.zip
Merge remote-tracking branch 'origin/pr/5457' into dev
-rw-r--r--.github/workflows/ci.yml10
-rw-r--r--pytest.ini1
-rw-r--r--qutebrowser/browser/webengine/notification.py248
-rw-r--r--qutebrowser/browser/webengine/webenginetab.py6
-rw-r--r--qutebrowser/config/configdata.yml17
-rw-r--r--qutebrowser/qutebrowser.py4
-rw-r--r--tests/end2end/conftest.py19
-rw-r--r--tests/end2end/data/prompt/notifications.html17
-rw-r--r--tests/end2end/features/notifications.feature50
-rw-r--r--tests/end2end/features/test_notifications_bdd.py51
-rw-r--r--tests/end2end/fixtures/notificationserver.py96
-rw-r--r--tests/end2end/fixtures/quteprocess.py7
-rw-r--r--tox.ini2
13 files changed, 514 insertions, 14 deletions
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index ccb29d100..3476f2abc 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -62,7 +62,7 @@ jobs:
python -m pip install -U pip
python -m pip install -U -r misc/requirements/requirements-tox.txt
- name: "Run ${{ matrix.testenv }}"
- run: "tox -e ${{ matrix.testenv}} -- ${{ matrix.args }}"
+ run: "dbus-run-session -- tox -e ${{ matrix.testenv}} -- ${{ matrix.args }}"
tests-docker:
if: "!contains(github.event.head_commit.message, '[ci skip]')"
@@ -90,7 +90,7 @@ jobs:
- uses: actions/checkout@v2
- name: Set up problem matchers
run: "python scripts/dev/ci/problemmatchers.py py38 ${{ runner.temp }}"
- - run: tox -e py
+ - run: dbus-run-session tox -e py
tests:
if: "!contains(github.event.head_commit.message, '[ci skip]')"
@@ -164,7 +164,11 @@ jobs:
python -m pip install -U pip
python -m pip install -U -r misc/requirements/requirements-tox.txt
- name: "Run ${{ matrix.testenv }}"
- run: "tox -e ${{ matrix.testenv}} -- ${{ matrix.args }}"
+ run: "dbus-run-session -- tox -e ${{ matrix.testenv }} -- ${{ matrix.args }}"
+ if: "startsWith(matrix.os, 'ubuntu-')"
+ - name: "Run ${{ matrix.testenv }}"
+ run: "tox -e ${{ matrix.testenv }} -- ${{ matrix.args }}"
+ if: "!startsWith(matrix.os, 'ubuntu-')"
- name: Analyze backtraces
run: "bash scripts/dev/ci/backtrace.sh ${{ matrix.testenv }}"
if: "failure()"
diff --git a/pytest.ini b/pytest.ini
index 7f4a58de3..4d8ae4bfe 100644
--- a/pytest.ini
+++ b/pytest.ini
@@ -30,6 +30,7 @@ markers =
qtwebkit_skip: Tests not applicable with QtWebKit
qtwebengine_flaky: Tests which are flaky (and currently skipped) with QtWebEngine
qtwebengine_mac_xfail: Tests which fail on macOS with QtWebEngine
+ qtwebengine_py_5_15: Tests which require PyQtWebEngine 5.15.
this: Used to mark tests during development
no_invalid_lines: Don't fail on unparsable lines in end2end tests
fake_os: Fake utils.is_* to a fake operating system
diff --git a/qutebrowser/browser/webengine/notification.py b/qutebrowser/browser/webengine/notification.py
new file mode 100644
index 000000000..d3dc0af08
--- /dev/null
+++ b/qutebrowser/browser/webengine/notification.py
@@ -0,0 +1,248 @@
+# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et:
+
+# Copyright 2020 Florian Bruhin (The Compiler) <mail@qutebrowser.org>
+#
+# 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 <http://www.gnu.org/licenses/>.
+
+"""Handles sending notifications over DBus."""
+
+import typing
+
+
+from PyQt5.QtGui import QImage
+from PyQt5.QtCore import (QObject, QVariant, QMetaType, QByteArray, pyqtSlot,
+ PYQT_VERSION)
+from PyQt5.QtDBus import (QDBusConnection, QDBusInterface, QDBus,
+ QDBusArgument, QDBusMessage)
+
+if typing.TYPE_CHECKING:
+ # putting these behind TYPE_CHECKING also means this module is importable
+ # on installs that don't have these
+ from PyQt5.QtWebEngineCore import QWebEngineNotification
+ from PyQt5.QtWebEngineWidgets import QWebEngineProfile
+
+from qutebrowser.browser.webengine import webenginesettings
+from qutebrowser.config import config
+from qutebrowser.misc import objects
+from qutebrowser.utils import qtutils, log, utils
+
+
+def init() -> None:
+ """Initialize the DBus notification presenter, if applicable.
+
+ If the user doesn't want a notification presenter or it's not supported,
+ this method does nothing.
+
+ Always succeeds, but might log an error.
+ """
+ should_use_dbus = (
+ qtutils.version_check("5.13") and
+ config.val.content.notification_presenter == "libnotify" and
+ # Don't even try to use DBus notifications on platforms that won't have
+ # it supported.
+ not utils.is_windows and
+ not utils.is_mac
+ )
+ if not should_use_dbus:
+ return
+
+ log.init.debug("Setting up DBus notification presenter...")
+ try:
+ testing = 'test-notification-service' in objects.debug_flags
+ presenter = DBusNotificationPresenter(testing)
+ for p in [webenginesettings.default_profile,
+ webenginesettings.private_profile]:
+ if not p:
+ continue
+ presenter.install(p)
+ except DBusException as e:
+ log.init.error(
+ "Failed to initialize DBus notification presenter: {}"
+ .format(e)
+ )
+
+
+class DBusException(Exception):
+ """Raised when something goes wrong with talking to DBus."""
+
+
+class DBusNotificationPresenter(QObject):
+ """Manages notifications that are sent over DBus."""
+
+ SERVICE = "org.freedesktop.Notifications"
+ # If you change the test service, make sure to also change the one in the
+ # test fixture.
+ TEST_SERVICE = "org.qutebrowser.TestNotifications"
+ PATH = "/org/freedesktop/Notifications"
+ INTERFACE = "org.freedesktop.Notifications"
+
+ def __init__(self, test_service: bool = False):
+ super().__init__()
+ self._active_notifications = {} \
+ # type: typing.Dict[int, QWebEngineNotification]
+ bus = QDBusConnection.sessionBus()
+ if not bus.isConnected():
+ raise DBusException("Failed to connect to DBus session bus")
+
+ service = self.TEST_SERVICE if test_service else self.SERVICE
+
+ self.interface = QDBusInterface(
+ service,
+ self.PATH,
+ self.INTERFACE,
+ bus,
+ )
+
+ bus.connect(
+ service,
+ self.PATH,
+ self.INTERFACE,
+ "NotificationClosed",
+ self._handle_close
+ )
+
+ if not self.interface:
+ raise DBusException("Could not construct a DBus interface")
+
+ # None means we don't know yet.
+ self._needs_escaping = None # type: typing.Optional[bool]
+
+ def install(self, profile: "QWebEngineProfile") -> None:
+ """Sets the profile to use the manager as the presenter."""
+ # WORKAROUND for
+ # https://www.riverbankcomputing.com/pipermail/pyqt/2020-May/042916.html
+ if PYQT_VERSION < 0x050F00: # PyQt 5.15
+ # PyQtWebEngine unrefs the callback after it's called, for some
+ # reason. So we call setNotificationPresenter again to *increase*
+ # its refcount to prevent it from getting GC'd. Otherwise, random
+ # methods start getting called with the notification as `self`, or
+ # segfaults happen, or other badness.
+ def _present_and_reset(qt_notification: "QWebEngineNotification") \
+ -> None:
+ profile.setNotificationPresenter(_present_and_reset)
+ self._present(qt_notification)
+ profile.setNotificationPresenter(_present_and_reset)
+ else:
+ profile.setNotificationPresenter(self._present)
+
+ def _present(self, qt_notification: "QWebEngineNotification") -> None:
+ """Shows a notification over DBus.
+
+ This should *not* be directly passed to setNotificationPresenter on
+ PyQtWebEngine < 5.15 because of a bug in the PyQtWebEngine bindings.
+ """
+ # Deferring this check to the first presentation means we can tweak
+ # whether the test notification server supports body markup.
+ if self._needs_escaping is None:
+ self._needs_escaping = self._check_needs_escaping()
+
+ # notification id 0 means 'assign us the ID'. We can't just pass 0
+ # because it won't get sent as the right type.
+ zero = QVariant(0)
+ zero.convert(QVariant.UInt)
+
+ actions_list = QDBusArgument([], QMetaType.QStringList)
+
+ qt_notification.show()
+ hints = {
+ # Include the origin in case the user wants to do different things
+ # with different origin's notifications.
+ "x-qutebrowser-origin": qt_notification.origin().toDisplayString()
+ } # type: typing.Dict[str, typing.Any]
+ if not qt_notification.icon().isNull():
+ hints["image-data"] = self._convert_image(qt_notification.icon())
+
+ reply = self.interface.call(
+ QDBus.BlockWithGui,
+ "Notify",
+ "qutebrowser", # application name
+ zero, # notification id
+ "qutebrowser", # icon
+ # Titles don't support markup, so no need to escape them.
+ qt_notification.title(),
+ self._format_body(qt_notification.message()),
+ actions_list,
+ hints,
+ -1, # timeout; -1 means 'use default'
+ )
+
+ if reply.signature() != "u":
+ raise DBusException(
+ "Got an unexpected reply {}; expected a single uint32"
+ .format(reply.arguments())
+ )
+
+ notification_id = reply.arguments()[0]
+ self._active_notifications[notification_id] = qt_notification
+ log.webview.debug("Sent out notification {}".format(notification_id))
+
+ def _convert_image(self, qimage: QImage) -> QDBusArgument:
+ """Converts a QImage to the structure DBus expects."""
+ # This is apparently what GTK-based notification daemons expect; tested
+ # it with dunst. Otherwise you get weird color schemes.
+ qimage.convertTo(QImage.Format_RGBA8888)
+ image_data = QDBusArgument()
+ image_data.beginStructure()
+ image_data.add(qimage.width())
+ image_data.add(qimage.height())
+ image_data.add(qimage.bytesPerLine())
+ image_data.add(qimage.hasAlphaChannel())
+ # RGBA_8888 always has 8 bits per color, 4 channels.
+ image_data.add(8)
+ image_data.add(4)
+ try:
+ size = qimage.sizeInBytes()
+ except TypeError:
+ # WORKAROUND for
+ # https://www.riverbankcomputing.com/pipermail/pyqt/2020-May/042919.html
+ # byteCount() is obsolete, but sizeInBytes() is only available with
+ # SIP >= 5.3.0.
+ size = qimage.byteCount()
+ bits = qimage.constBits().asstring(size)
+ image_data.add(QByteArray(bits))
+ image_data.endStructure()
+ return image_data
+
+ @pyqtSlot(QDBusMessage)
+ def _handle_close(self, message: QDBusMessage) -> None:
+ notification_id = message.arguments()[0]
+ if notification_id in self._active_notifications:
+ try:
+ self._active_notifications[notification_id].close()
+ except RuntimeError:
+ # WORKAROUND for
+ # https://www.riverbankcomputing.com/pipermail/pyqt/2020-May/042918.html
+ pass
+
+ def _check_needs_escaping(self) -> bool:
+ """Checks whether we need to escape body messages we send."""
+ reply = self.interface.call(
+ QDBus.BlockWithGui,
+ "GetCapabilities",
+ )
+ if reply.signature() != "as":
+ raise DBusException(
+ "Got an unexpected reply {} when checking capabilities"
+ .format(reply.arguments())
+ )
+ return "body-markup" in reply.arguments()[0]
+
+ def _format_body(self, message: str) -> str:
+ if self._needs_escaping:
+ message = message.replace("&", "&amp;")
+ message = message.replace("<", "&lt;")
+ message = message.replace(">", "&gt;")
+ return message
diff --git a/qutebrowser/browser/webengine/webenginetab.py b/qutebrowser/browser/webengine/webenginetab.py
index 37562e751..5fda708bd 100644
--- a/qutebrowser/browser/webengine/webenginetab.py
+++ b/qutebrowser/browser/webengine/webenginetab.py
@@ -882,9 +882,9 @@ class _WebEnginePermissions(QObject):
if on:
timeout = config.val.content.fullscreen.overlay_timeout
if timeout != 0:
- notification = miscwidgets.FullscreenNotification(self._widget)
- notification.set_timeout(timeout)
- notification.show()
+ notif = miscwidgets.FullscreenNotification(self._widget)
+ notif.set_timeout(timeout)
+ notif.show()
@pyqtSlot(QUrl, 'QWebEnginePage::Feature')
def _on_feature_permission_requested(self, url, feature):
diff --git a/qutebrowser/config/configdata.yml b/qutebrowser/config/configdata.yml
index 8b233e018..696a87d66 100644
--- a/qutebrowser/config/configdata.yml
+++ b/qutebrowser/config/configdata.yml
@@ -909,6 +909,23 @@ content.notifications:
QtWebKit: true
desc: Allow websites to show notifications.
+content.notification_presenter:
+ default: libnotify
+ restart: true # no way to clear the presenter libnotify installs
+ type:
+ name: String
+ valid_values:
+ - qt: Use Qt's native notification presenter.
+ - libnotify: Use a libnotify-compatible presenter if one is present,
+ otherwise fall back to Qt.
+ backend:
+ QtWebEngine: Qt 5.13
+ QtWebKit: false
+ desc: >-
+ What notification presenter to use for web notifications.
+
+ Windows and macOS will always act as if this is set to `qt.`
+
content.pdfjs:
default: false
type: Bool
diff --git a/qutebrowser/qutebrowser.py b/qutebrowser/qutebrowser.py
index 9e1fb91cd..d0819f832 100644
--- a/qutebrowser/qutebrowser.py
+++ b/qutebrowser/qutebrowser.py
@@ -179,11 +179,13 @@ def debug_flag_error(flag):
wait-renderer-process: Wait for debugger in renderer process.
avoid-chromium-init: Enable `--version` without initializing Chromium.
werror: Turn Python warnings into errors.
+ test-notification-service: Use the testing libnotify service.
"""
valid_flags = ['debug-exit', 'pdb-postmortem', 'no-sql-history',
'no-scroll-filtering', 'log-requests', 'log-cookies',
'log-scroll-pos', 'log-sensitive-keys', 'stack', 'chromium',
- 'wait-renderer-process', 'avoid-chromium-init', 'werror']
+ 'wait-renderer-process', 'avoid-chromium-init', 'werror',
+ 'test-notification-service']
if flag in valid_flags:
return flag
diff --git a/tests/end2end/conftest.py b/tests/end2end/conftest.py
index 205e2ece7..b903c96d9 100644
--- a/tests/end2end/conftest.py
+++ b/tests/end2end/conftest.py
@@ -34,6 +34,7 @@ from PyQt5.QtCore import PYQT_VERSION, QCoreApplication
pytest.register_assert_rewrite('end2end.fixtures')
+from end2end.fixtures.notificationserver import notification_server
from end2end.fixtures.webserver import server, server_per_test, server2, ssl_server
from end2end.fixtures.quteprocess import (quteproc_process, quteproc,
quteproc_new)
@@ -111,6 +112,7 @@ def _get_backend_tag(tag):
'qtwebengine_todo': pytest.mark.qtwebengine_todo,
'qtwebengine_skip': pytest.mark.qtwebengine_skip,
'qtwebengine_notifications': pytest.mark.qtwebengine_notifications,
+ 'qtwebengine_py_5_15': pytest.mark.qtwebengine_py_5_15,
'qtwebkit_skip': pytest.mark.qtwebkit_skip,
}
if not any(tag.startswith(t + ':') for t in pytest_marks):
@@ -135,6 +137,14 @@ if not getattr(sys, 'frozen', False):
return None
+def _pyqt_webengine_at_least_5_15() -> bool:
+ try:
+ from PyQt5.QtWebEngine import PYQT_WEBENGINE_VERSION
+ return PYQT_WEBENGINE_VERSION >= 0x050F00
+ except ImportError:
+ return False
+
+
def pytest_collection_modifyitems(config, items):
"""Apply @qtwebengine_* markers; skip unittests with QUTE_BDD_WEBENGINE."""
# WORKAROUND for https://bugreports.qt.io/browse/QTBUG-75884
@@ -150,9 +160,9 @@ def pytest_collection_modifyitems(config, items):
('qtwebengine_skip', 'Skipped with QtWebEngine', pytest.mark.skipif,
config.webengine),
('qtwebengine_notifications',
- 'Skipped with QtWebEngine < 5.13',
+ 'Skipped unless QtWebEngine >= 5.13',
pytest.mark.skipif,
- config.webengine and not qtutils.version_check('5.13')),
+ not (config.webengine and qtutils.version_check('5.13'))),
('qtwebkit_skip', 'Skipped with QtWebKit', pytest.mark.skipif,
not config.webengine),
('qtwebengine_flaky', 'Flaky with QtWebEngine', pytest.mark.skipif,
@@ -170,7 +180,10 @@ def pytest_collection_modifyitems(config, items):
'Skipped on Windows',
pytest.mark.skipif,
utils.is_windows),
-
+ # WORKAROUND for https://www.riverbankcomputing.com/pipermail/pyqt/2020-May/042918.html
+ ('qtwebengine_py_5_15', 'Skipped with PyQtWebEngine < 5.15',
+ pytest.mark.skipif,
+ config.webengine and not _pyqt_webengine_at_least_5_15()),
]
for item in items:
diff --git a/tests/end2end/data/prompt/notifications.html b/tests/end2end/data/prompt/notifications.html
index f96456ff2..a8867277d 100644
--- a/tests/end2end/data/prompt/notifications.html
+++ b/tests/end2end/data/prompt/notifications.html
@@ -31,9 +31,26 @@
console.log("[FAIL] notifications unavailable");
}
}
+
+ function show_notification() {
+ let notification = new Notification("notification title", {
+ body: "notification body"
+ });
+ notification.onclick = function() { console.log("notification clicked"); };
+ notification.onclose = function() { console.log("notification closed"); };
+ notification.onshow = function() { console.log("notification shown"); };
+ }
+
+ function show_symbol_notification() {
+ let str = "<< && >>";
+ let notification = new Notification(str, { body: str });
+ notification.onshow = function() { console.log("notification shown"); };
+ }
</script>
</head>
<body>
<input type="button" onclick="get_notification_permission()" value="Get notification permission" id="button">
+ <input type="button" onclick="show_notification()" value="Show notification" id="show-button">
+ <input type="button" onclick="show_symbol_notification()" value="Show notification with symbols" id="show-symbols-button">
</body>
</html>
diff --git a/tests/end2end/features/notifications.feature b/tests/end2end/features/notifications.feature
new file mode 100644
index 000000000..5eb6769a3
--- /dev/null
+++ b/tests/end2end/features/notifications.feature
@@ -0,0 +1,50 @@
+# vim: ft=cucumber fileencoding=utf-8 sts=4 sw=4 et:
+
+Feature: Notifications
+ HTML5 notification API interaction
+
+ Background:
+ Given I have a fresh instance
+ And I open data/prompt/notifications.html
+ And I set content.notifications to true
+ And I run :click-element id button
+
+ @qtwebengine_notifications
+ Scenario: Notification is shown
+ When I run :click-element id show-button
+ Then the javascript message "notification shown" should be logged
+ And a notification with id 1 is presented
+
+ @qtwebengine_notifications
+ Scenario: Notification containing escaped characters
+ Given the notification server supports body markup
+ When I run :click-element id show-symbols-button
+ Then the javascript message "notification shown" should be logged
+ And notification 1 has body "&lt;&lt; &amp;&amp; &gt;&gt;"
+ And notification 1 has title "<< && >>"
+
+ @qtwebengine_notifications
+ Scenario: Notification containing escaped characters with no body markup
+ Given the notification server doesn't support body markup
+ When I run :click-element id show-symbols-button
+ Then the javascript message "notification shown" should be logged
+ And notification 1 has body "<< && >>"
+ And notification 1 has title "<< && >>"
+
+ # For these tests, we need to wait for the notification to be shown before
+ # we try to close it, otherwise we wind up in race-condition-ish
+ # situations.
+
+ @qtwebengine_notifications @qtwebengine_py_5_15
+ Scenario: User closes presented notification
+ When I run :click-element id show-button
+ And I wait for the javascript message "notification shown"
+ And I close the notification with id 1
+ Then the javascript message "notification closed" should be logged
+
+ @qtwebengine_notifications @qtwebengine_py_5_15
+ Scenario: User closes some other application's notification
+ When I run :click-element id show-button
+ And I wait for the javascript message "notification shown"
+ And I close the notification with id 1234
+ Then the javascript message "notification closed" should not be logged
diff --git a/tests/end2end/features/test_notifications_bdd.py b/tests/end2end/features/test_notifications_bdd.py
new file mode 100644
index 000000000..dc3fe16f4
--- /dev/null
+++ b/tests/end2end/features/test_notifications_bdd.py
@@ -0,0 +1,51 @@
+# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et:
+
+# Copyright 2016-2020 Florian Bruhin (The Compiler) <mail@qutebrowser.org>
+#
+# 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 <http://www.gnu.org/licenses/>.
+
+import pytest_bdd as bdd
+bdd.scenarios('notifications.feature')
+
+
+@bdd.given("the notification server supports body markup")
+def supports_body_markup(notification_server):
+ notification_server.supports_body_markup = True
+
+
+@bdd.given("the notification server doesn't support body markup")
+def doesnt_support_body_markup(notification_server):
+ notification_server.supports_body_markup = False
+
+
+@bdd.then(bdd.parsers.cfparse('a notification with id {id_:d} is presented'))
+def notification_presented(notification_server, id_):
+ assert id_ in notification_server.messages
+
+
+@bdd.then(bdd.parsers.cfparse('notification {id_:d} has body "{body}"'))
+def notification_body(notification_server, id_, body):
+ assert notification_server.messages[id_]["body"] == body
+
+
+@bdd.then(bdd.parsers.cfparse('notification {id_:d} has title "{title}"'))
+def notification_title(notification_server, id_, title):
+ assert notification_server.messages[id_]["title"] == title
+
+
+@bdd.when(bdd.parsers.cfparse('I close the notification with id {id_:d}'))
+def close_notification(notification_server, id_):
+ notification_server.close(id_)
diff --git a/tests/end2end/fixtures/notificationserver.py b/tests/end2end/fixtures/notificationserver.py
new file mode 100644
index 000000000..122499d21
--- /dev/null
+++ b/tests/end2end/fixtures/notificationserver.py
@@ -0,0 +1,96 @@
+# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et:
+
+import typing
+
+from PyQt5.QtCore import QObject, QVariant, pyqtSlot
+from PyQt5.QtDBus import QDBusConnection, QDBusArgument, QDBusMessage
+import pytest
+
+from qutebrowser.utils import utils
+
+
+class TestNotificationServer(QObject):
+ """A libnotify notification server used for testing."""
+
+ # These are the same as in DBusNotificationPresenter. We don't import that
+ # because it relies on Qt 5.13, and this fixture is *always* instantiated.
+ SERVICE = "org.freedesktop.Notifications"
+ TEST_SERVICE = "org.qutebrowser.TestNotifications"
+ PATH = "/org/freedesktop/Notifications"
+ INTERFACE = "org.freedesktop.Notifications"
+
+ def __init__(self, service: str):
+ """Constructs a new server.
+
+ This is safe even if there is no DBus daemon; we don't check whether
+ the connection is successful until register().
+ """
+ # Note that external users should call get() instead.
+ super().__init__()
+ self._service = service
+ # Trying to connect to the bus doesn't fail if there's no bus.
+ self._bus = QDBusConnection.sessionBus()
+ self._message_id = 0
+ # A dict mapping notification IDs to currently-displayed notifications.
+ self.messages = {} # type: typing.Dict[int, QDBusMessage]
+ self.supports_body_markup = True
+
+ def register(self) -> None:
+ assert self._bus.isConnected()
+ assert self._bus.registerService(self._service)
+ assert self._bus.registerObject(TestNotificationServer.PATH,
+ TestNotificationServer.INTERFACE,
+ self,
+ QDBusConnection.ExportAllSlots)
+
+ def unregister(self) -> None:
+ self._bus.unregisterObject(TestNotificationServer.PATH)
+ assert self._bus.unregisterService(self._service)
+
+ @pyqtSlot(QDBusMessage, result="uint")
+ def Notify(self, message: QDBusMessage) -> QDBusArgument: # pylint: disable=invalid-name
+ self._message_id += 1
+ args = message.arguments()
+ self.messages[self._message_id] = {
+ "title": args[3],
+ "body": args[4]
+ }
+ return self._message_id
+
+ @pyqtSlot(QDBusMessage, result="QStringList")
+ def GetCapabilities(self, message: QDBusMessage) -> typing.List[str]: # pylint: disable=invalid-name
+ if self.supports_body_markup:
+ return ["body-markup"]
+ else:
+ return []
+
+ def close(self, notification_id: int) -> None:
+ """Sends a close notification for the given ID."""
+ message = QDBusMessage.createSignal(
+ TestNotificationServer.PATH,
+ TestNotificationServer.INTERFACE,
+ "NotificationClosed")
+ # the 2 here is the notification removal reason; it's effectively
+ # arbitrary
+ message.setArguments([_as_uint32(notification_id), _as_uint32(2)])
+ if not self._bus.send(message):
+ raise IOError("Could not send close notification")
+
+
+@pytest.fixture
+def notification_server(qapp):
+ server = TestNotificationServer(TestNotificationServer.TEST_SERVICE)
+ if utils.is_windows or utils.is_mac:
+ yield server
+ else:
+ try:
+ server.register()
+ yield server
+ finally:
+ server.unregister()
+
+
+def _as_uint32(x: int) -> QVariant:
+ variant = QVariant(x)
+ assert variant.convert(QVariant.UInt)
+ return variant
diff --git a/tests/end2end/fixtures/quteprocess.py b/tests/end2end/fixtures/quteprocess.py
index 0fdc8dfcf..9b3c3cfb5 100644
--- a/tests/end2end/fixtures/quteprocess.py
+++ b/tests/end2end/fixtures/quteprocess.py
@@ -526,10 +526,11 @@ class QuteProc(testprocess.Process):
args = ['--debug', '--no-err-windows', '--temp-basedir',
'--json-logging', '--loglevel', 'vdebug',
'--backend', backend, '--debug-flag', 'no-sql-history',
- '--debug-flag', 'werror']
+ '--debug-flag', 'werror', '--debug-flag',
+ 'test-notification-service']
if self.request.config.webengine:
- args += testutils.seccomp_args(qt_flag=True)
+ args += testutils.sandbox_args(qt_flag=True)
args.append('about:blank')
return args
@@ -1019,7 +1020,7 @@ def quteproc_process(qapp, server, request):
@pytest.fixture
-def quteproc(quteproc_process, server, request):
+def quteproc(quteproc_process, server, request, notification_server):
"""Per-test qutebrowser fixture which uses the per-file process."""
request.node._quteproc_log = quteproc_process.captured_log
quteproc_process.before_test()
diff --git a/tox.ini b/tox.ini
index e305e5c4d..4be5b8620 100644
--- a/tox.ini
+++ b/tox.ini
@@ -15,7 +15,7 @@ setenv =
pyqt{,512,513,514,515,5150}: LINK_PYQT_SKIP=true
pyqt{,512,513,514,515,5150}: QUTE_BDD_WEBENGINE=true
cov: PYTEST_ADDOPTS=--cov --cov-report xml --cov-report=html --cov-report=
-passenv = PYTHON DISPLAY XAUTHORITY HOME USERNAME USER CI XDG_* QUTE_* DOCKER QT_QUICK_BACKEND PY_COLORS
+passenv = PYTHON DISPLAY XAUTHORITY HOME USERNAME USER CI XDG_* QUTE_* DOCKER QT_QUICK_BACKEND PY_COLORS DBUS_SESSION_BUS_ADDRESS
basepython =
py: {env:PYTHON:python3}
py3: {env:PYTHON:python3}