Merge remote-tracking branch 'origin/pr/5457' into dev

This commit is contained in:
Florian Bruhin 2021-03-24 14:12:36 +01:00
commit 2f592f7ce6
13 changed files with 514 additions and 14 deletions

View File

@ -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()"

View File

@ -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

View File

@ -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

View File

@ -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):

View File

@ -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

View File

@ -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

View File

@ -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:

View File

@ -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>

View File

@ -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

View File

@ -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_)

View File

@ -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

View File

@ -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()

View File

@ -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}