1074 lines
38 KiB
Python
1074 lines
38 KiB
Python
# SPDX-FileCopyrightText: Florian Bruhin (The Compiler) <mail@qutebrowser.org>
|
|
#
|
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
|
|
"""Test starting qutebrowser with special arguments/environments."""
|
|
|
|
import os
|
|
import signal
|
|
import configparser
|
|
import subprocess
|
|
import sys
|
|
import logging
|
|
import importlib
|
|
import re
|
|
import json
|
|
import platform
|
|
from contextlib import nullcontext as does_not_raise
|
|
from unittest.mock import ANY
|
|
|
|
import pytest
|
|
from qutebrowser.qt.core import QProcess, QPoint
|
|
|
|
from helpers import testutils
|
|
from end2end.fixtures import quteprocess
|
|
from qutebrowser.utils import qtutils, utils, version
|
|
|
|
|
|
ascii_locale = pytest.mark.skipif(sys.hexversion >= 0x03070000,
|
|
reason="Python >= 3.7 doesn't force ASCII "
|
|
"locale with LC_ALL=C")
|
|
|
|
|
|
# For some reason (some floating point rounding differences?), color values are
|
|
# slightly different (and wrong!) on ARM machines. We adjust our expected values
|
|
# accordingly, since we don't really care about the exact value, we just want to
|
|
# know that the underlying Chromium is respecting our preferences.
|
|
# FIXME what to do about 32-bit ARM?
|
|
IS_ARM = platform.machine() == 'aarch64'
|
|
|
|
|
|
def _base_args(config):
|
|
"""Get the arguments to pass with every invocation."""
|
|
args = ['--debug', '--json-logging', '--no-err-windows']
|
|
if config.webengine:
|
|
args += ['--backend', 'webengine']
|
|
else:
|
|
args += ['--backend', 'webkit']
|
|
|
|
if config.webengine:
|
|
if testutils.disable_seccomp_bpf_sandbox():
|
|
args += testutils.DISABLE_SECCOMP_BPF_ARGS
|
|
if testutils.use_software_rendering():
|
|
args += testutils.SOFTWARE_RENDERING_ARGS
|
|
|
|
args.append('about:blank')
|
|
return args
|
|
|
|
|
|
@pytest.fixture
|
|
def runtime_tmpdir(short_tmpdir):
|
|
"""A directory suitable for XDG_RUNTIME_DIR."""
|
|
runtime_dir = short_tmpdir / 'rt'
|
|
runtime_dir.ensure(dir=True)
|
|
runtime_dir.chmod(0o700)
|
|
return runtime_dir
|
|
|
|
|
|
@pytest.fixture
|
|
def temp_basedir_env(tmp_path, runtime_tmpdir):
|
|
"""Return a dict of environment variables that fakes --temp-basedir.
|
|
|
|
We can't run --basedir or --temp-basedir for some tests, so we mess with
|
|
XDG_*_DIR to get things relocated.
|
|
"""
|
|
data_dir = tmp_path / 'data'
|
|
config_dir = tmp_path / 'config'
|
|
cache_dir = tmp_path / 'cache'
|
|
|
|
lines = [
|
|
'[general]',
|
|
'quickstart-done = 1',
|
|
'backend-warning-shown = 1',
|
|
'webkit-warning-shown = 1',
|
|
]
|
|
|
|
state_file = data_dir / 'qutebrowser' / 'state'
|
|
state_file.parent.mkdir(parents=True)
|
|
state_file.write_text('\n'.join(lines), encoding='utf-8')
|
|
|
|
env = {
|
|
'XDG_DATA_HOME': str(data_dir),
|
|
'XDG_CONFIG_HOME': str(config_dir),
|
|
'XDG_RUNTIME_DIR': str(runtime_tmpdir),
|
|
'XDG_CACHE_HOME': str(cache_dir),
|
|
}
|
|
return env
|
|
|
|
|
|
@pytest.mark.linux
|
|
@ascii_locale
|
|
def test_downloads_with_ascii_locale(request, server, tmp_path, quteproc_new):
|
|
"""Test downloads with LC_ALL=C set.
|
|
|
|
https://github.com/qutebrowser/qutebrowser/issues/908
|
|
https://github.com/qutebrowser/qutebrowser/issues/1726
|
|
"""
|
|
args = ['--temp-basedir'] + _base_args(request.config)
|
|
quteproc_new.start(args, env={'LC_ALL': 'C'})
|
|
quteproc_new.set_setting('downloads.location.directory', str(tmp_path))
|
|
|
|
# Test a normal download
|
|
quteproc_new.set_setting('downloads.location.prompt', 'false')
|
|
url = 'http://localhost:{port}/data/downloads/ä-issue908.bin'.format(
|
|
port=server.port)
|
|
quteproc_new.send_cmd(':download {}'.format(url))
|
|
quteproc_new.wait_for(category='downloads',
|
|
message='Download ?-issue908.bin finished')
|
|
|
|
# Test :prompt-open-download
|
|
quteproc_new.set_setting('downloads.location.prompt', 'true')
|
|
quteproc_new.send_cmd(':download {}'.format(url))
|
|
quteproc_new.send_cmd(':prompt-open-download "{}" -c pass'
|
|
.format(sys.executable))
|
|
quteproc_new.wait_for(category='downloads',
|
|
message='Download ä-issue908.bin finished')
|
|
quteproc_new.wait_for(category='misc',
|
|
message='Opening * with [*python*]')
|
|
|
|
assert len(list(tmp_path.iterdir())) == 1
|
|
assert (tmp_path / '?-issue908.bin').exists()
|
|
|
|
|
|
@pytest.mark.linux
|
|
@pytest.mark.parametrize('url', ['/föö.html', 'file:///föö.html'])
|
|
@ascii_locale
|
|
def test_open_with_ascii_locale(request, server, tmp_path, quteproc_new, url):
|
|
"""Test opening non-ascii URL with LC_ALL=C set.
|
|
|
|
https://github.com/qutebrowser/qutebrowser/issues/1450
|
|
"""
|
|
args = ['--temp-basedir'] + _base_args(request.config)
|
|
quteproc_new.start(args, env={'LC_ALL': 'C'})
|
|
quteproc_new.set_setting('url.auto_search', 'never')
|
|
|
|
# Test opening a file whose name contains non-ascii characters.
|
|
# No exception thrown means test success.
|
|
quteproc_new.send_cmd(':open {}'.format(url))
|
|
|
|
if not request.config.webengine:
|
|
line = quteproc_new.wait_for(message="Error while loading *: Error "
|
|
"opening /*: No such file or directory")
|
|
line.expected = True
|
|
|
|
quteproc_new.wait_for(message="load status for <* tab_id=* "
|
|
"url='*/f%C3%B6%C3%B6.html'>: LoadStatus.error")
|
|
|
|
if request.config.webengine:
|
|
line = quteproc_new.wait_for(message='Load error: ERR_FILE_NOT_FOUND')
|
|
line.expected = True
|
|
|
|
|
|
@pytest.mark.linux
|
|
@ascii_locale
|
|
def test_open_command_line_with_ascii_locale(request, server, tmp_path,
|
|
quteproc_new):
|
|
"""Test opening file via command line with a non-ascii name with LC_ALL=C.
|
|
|
|
https://github.com/qutebrowser/qutebrowser/issues/1450
|
|
"""
|
|
# The file does not actually have to exist because the relevant checks will
|
|
# all be called. No exception thrown means test success.
|
|
args = (['--temp-basedir'] + _base_args(request.config) +
|
|
['/home/user/föö.html'])
|
|
quteproc_new.start(args, env={'LC_ALL': 'C'})
|
|
|
|
if not request.config.webengine:
|
|
line = quteproc_new.wait_for(message="Error while loading *: Error "
|
|
"opening /*: No such file or directory")
|
|
line.expected = True
|
|
|
|
quteproc_new.wait_for(message="load status for <* tab_id=* "
|
|
"url='*/f*.html'>: LoadStatus.error")
|
|
|
|
if request.config.webengine:
|
|
line = quteproc_new.wait_for(message="Load error: ERR_FILE_NOT_FOUND")
|
|
line.expected = True
|
|
|
|
|
|
@pytest.mark.linux
|
|
def test_misconfigured_user_dirs(request, server, temp_basedir_env,
|
|
tmp_path, quteproc_new):
|
|
"""Test downloads with a misconfigured XDG_DOWNLOAD_DIR.
|
|
|
|
https://github.com/qutebrowser/qutebrowser/issues/866
|
|
https://github.com/qutebrowser/qutebrowser/issues/1269
|
|
"""
|
|
home = tmp_path / 'home'
|
|
home.mkdir()
|
|
temp_basedir_env['HOME'] = str(home)
|
|
config_userdir_dir = tmp_path / 'config'
|
|
config_userdir_dir.mkdir(parents=True)
|
|
config_userdir_file = tmp_path / 'config' / 'user-dirs.dirs'
|
|
config_userdir_file.touch()
|
|
|
|
assert temp_basedir_env['XDG_CONFIG_HOME'] == str(tmp_path / 'config')
|
|
config_userdir_file.write_text('XDG_DOWNLOAD_DIR="relative"')
|
|
|
|
quteproc_new.start(_base_args(request.config), env=temp_basedir_env)
|
|
|
|
quteproc_new.set_setting('downloads.location.prompt', 'false')
|
|
url = 'http://localhost:{port}/data/downloads/download.bin'.format(
|
|
port=server.port)
|
|
quteproc_new.send_cmd(':download {}'.format(url))
|
|
line = quteproc_new.wait_for(
|
|
loglevel=logging.ERROR, category='message',
|
|
message='XDG_DOWNLOAD_DIR points to a relative path - please check '
|
|
'your ~/.config/user-dirs.dirs. The download is saved in your '
|
|
'home directory.')
|
|
line.expected = True
|
|
quteproc_new.wait_for(category='downloads',
|
|
message='Download download.bin finished')
|
|
|
|
assert (home / 'download.bin').exists()
|
|
|
|
|
|
def test_no_loglines(request, quteproc_new):
|
|
"""Test qute://log with --loglines=0."""
|
|
quteproc_new.start(args=['--temp-basedir', '--loglines=0'] +
|
|
_base_args(request.config))
|
|
quteproc_new.open_path('qute://log')
|
|
assert quteproc_new.get_content() == 'Log output was disabled.'
|
|
|
|
|
|
@pytest.mark.not_frozen
|
|
@pytest.mark.parametrize('level', ['1', '2'])
|
|
def test_optimize(request, quteproc_new, capfd, level):
|
|
quteproc_new.start(args=['--temp-basedir'] + _base_args(request.config),
|
|
env={'PYTHONOPTIMIZE': level})
|
|
if level == '2':
|
|
msg = ("Running on optimize level higher than 1, unexpected behavior "
|
|
"may occur.")
|
|
line = quteproc_new.wait_for(message=msg)
|
|
line.expected = True
|
|
|
|
# Waiting for quit to make sure no other warning is emitted
|
|
quteproc_new.send_cmd(':quit')
|
|
quteproc_new.wait_for_quit()
|
|
|
|
|
|
@pytest.mark.not_frozen
|
|
@pytest.mark.flaky # Fails sometimes with empty output...
|
|
def test_version(request):
|
|
"""Test invocation with --version argument."""
|
|
args = ['-m', 'qutebrowser', '--version'] + _base_args(request.config)
|
|
args.remove("--json-logging")
|
|
# can't use quteproc_new here because it's confused by
|
|
# early process termination
|
|
proc = QProcess()
|
|
proc.setProcessChannelMode(QProcess.ProcessChannelMode.SeparateChannels)
|
|
|
|
proc.start(sys.executable, args)
|
|
ok = proc.waitForStarted(2000)
|
|
assert ok
|
|
ok = proc.waitForFinished(10000)
|
|
|
|
stdout = bytes(proc.readAllStandardOutput()).decode('utf-8')
|
|
print(stdout)
|
|
stderr = bytes(proc.readAllStandardError()).decode('utf-8')
|
|
print(stderr)
|
|
|
|
assert ok
|
|
assert proc.exitStatus() == QProcess.ExitStatus.NormalExit
|
|
|
|
match = re.search(r'^qutebrowser\s+v\d+(\.\d+)', stdout, re.MULTILINE)
|
|
assert match is not None
|
|
|
|
|
|
def test_qt_arg(request, quteproc_new, tmp_path):
|
|
"""Test --qt-arg."""
|
|
args = (['--temp-basedir', '--qt-arg', 'stylesheet',
|
|
str(tmp_path / 'does-not-exist')] + _base_args(request.config))
|
|
quteproc_new.start(args)
|
|
|
|
msg = 'QCss::Parser - Failed to load file "*does-not-exist"'
|
|
line = quteproc_new.wait_for(message=msg)
|
|
line.expected = True
|
|
|
|
quteproc_new.send_cmd(':quit')
|
|
quteproc_new.wait_for_quit()
|
|
|
|
|
|
@pytest.mark.linux
|
|
def test_webengine_download_suffix(request, quteproc_new, tmp_path):
|
|
"""Make sure QtWebEngine does not add a suffix to downloads."""
|
|
if not request.config.webengine:
|
|
pytest.skip()
|
|
|
|
download_dir = tmp_path / 'downloads'
|
|
download_dir.mkdir()
|
|
|
|
(tmp_path / 'user-dirs.dirs').write_text(
|
|
'XDG_DOWNLOAD_DIR={}'.format(download_dir))
|
|
env = {'XDG_CONFIG_HOME': str(tmp_path)}
|
|
args = ['--temp-basedir'] + _base_args(request.config)
|
|
quteproc_new.start(args, env=env)
|
|
|
|
quteproc_new.set_setting('downloads.location.prompt', 'false')
|
|
quteproc_new.set_setting('downloads.location.directory', str(download_dir))
|
|
quteproc_new.open_path('data/downloads/download.bin', wait=False)
|
|
quteproc_new.wait_for(category='downloads', message='Download * finished')
|
|
quteproc_new.open_path('data/downloads/download.bin', wait=False)
|
|
quteproc_new.wait_for(message='Entering mode KeyMode.yesno *')
|
|
quteproc_new.send_cmd(':prompt-accept yes')
|
|
quteproc_new.wait_for(category='downloads', message='Download * finished')
|
|
|
|
files = list(download_dir.iterdir())
|
|
assert len(files) == 1
|
|
assert files[0].name == 'download.bin'
|
|
|
|
|
|
def test_command_on_start(request, quteproc_new):
|
|
"""Make sure passing a command on start works.
|
|
|
|
See https://github.com/qutebrowser/qutebrowser/issues/2408
|
|
"""
|
|
args = (['--temp-basedir'] + _base_args(request.config) +
|
|
[':quickmark-add https://www.example.com/ example'])
|
|
quteproc_new.start(args)
|
|
quteproc_new.send_cmd(':quit')
|
|
quteproc_new.wait_for_quit()
|
|
|
|
|
|
@pytest.mark.parametrize('python', ['python2', 'python3.6', 'python3.7'])
|
|
def test_launching_with_old_python(python):
|
|
try:
|
|
proc = subprocess.run(
|
|
[python, '-m', 'qutebrowser', '--no-err-windows'],
|
|
stderr=subprocess.PIPE,
|
|
check=False)
|
|
except FileNotFoundError:
|
|
pytest.skip(f"{python} not found")
|
|
assert proc.returncode == 1
|
|
error = "At least Python 3.9 is required to run qutebrowser"
|
|
assert proc.stderr.decode('ascii').startswith(error)
|
|
|
|
|
|
def test_initial_private_browsing(request, quteproc_new):
|
|
"""Make sure the initial window is private when the setting is set."""
|
|
args = (_base_args(request.config) +
|
|
['--temp-basedir', '-s', 'content.private_browsing', 'true'])
|
|
quteproc_new.start(args)
|
|
|
|
quteproc_new.compare_session("""
|
|
windows:
|
|
- private: True
|
|
tabs:
|
|
- history:
|
|
- url: about:blank
|
|
""")
|
|
|
|
quteproc_new.send_cmd(':quit')
|
|
quteproc_new.wait_for_quit()
|
|
|
|
|
|
def test_loading_empty_session(tmp_path, request, quteproc_new):
|
|
"""Make sure loading an empty session opens a window."""
|
|
session = tmp_path / 'session.yml'
|
|
session.write_text('windows: []')
|
|
|
|
args = _base_args(request.config) + ['--temp-basedir', '-r', str(session)]
|
|
quteproc_new.start(args)
|
|
|
|
quteproc_new.compare_session("""
|
|
windows:
|
|
- tabs:
|
|
- history:
|
|
- url: about:blank
|
|
""")
|
|
|
|
quteproc_new.send_cmd(':quit')
|
|
quteproc_new.wait_for_quit()
|
|
|
|
|
|
def test_qute_settings_persistence(short_tmpdir, request, quteproc_new):
|
|
"""Make sure settings from qute://settings are persistent."""
|
|
args = _base_args(request.config) + ['--basedir', str(short_tmpdir)]
|
|
quteproc_new.start(args)
|
|
quteproc_new.open_path('qute://settings/')
|
|
quteproc_new.send_cmd(':jseval --world main '
|
|
'cset("search.ignore_case", "always")')
|
|
quteproc_new.wait_for(message='No output or error')
|
|
quteproc_new.wait_for(category='config',
|
|
message='Config option changed: '
|
|
'search.ignore_case = always')
|
|
|
|
assert quteproc_new.get_setting('search.ignore_case') == 'always'
|
|
|
|
quteproc_new.send_cmd(':quit')
|
|
quteproc_new.wait_for_quit()
|
|
|
|
quteproc_new.start(args)
|
|
assert quteproc_new.get_setting('search.ignore_case') == 'always'
|
|
|
|
quteproc_new.send_cmd(':quit')
|
|
quteproc_new.wait_for_quit()
|
|
|
|
|
|
@pytest.mark.parametrize('value, expected', [
|
|
# https://chromium-review.googlesource.com/c/chromium/src/+/2545444
|
|
pytest.param(
|
|
'always',
|
|
'http://localhost:(port2)/headers-link/(port)',
|
|
marks=pytest.mark.qt5_only,
|
|
),
|
|
pytest.param(
|
|
'always',
|
|
'http://localhost:(port2)/',
|
|
marks=pytest.mark.qt6_only,
|
|
),
|
|
|
|
('never', None),
|
|
('same-domain', 'http://localhost:(port2)/'), # None with QtWebKit
|
|
])
|
|
def test_referrer(quteproc_new, server, server2, request, value, expected):
|
|
"""Check referrer settings."""
|
|
args = _base_args(request.config) + [
|
|
'--temp-basedir',
|
|
'-s', 'content.headers.referer', value,
|
|
]
|
|
quteproc_new.start(args)
|
|
|
|
quteproc_new.open_path(f'headers-link/{server.port}', port=server2.port)
|
|
quteproc_new.send_cmd(':click-element id link')
|
|
quteproc_new.wait_for_load_finished('headers')
|
|
|
|
content = quteproc_new.get_content()
|
|
data = json.loads(content)
|
|
print(data)
|
|
headers = data['headers']
|
|
|
|
if not request.config.webengine and value == 'same-domain':
|
|
# With QtWebKit and same-domain, we don't send a referer at all.
|
|
expected = None
|
|
|
|
if expected is not None:
|
|
for key, val in [('(port)', server.port), ('(port2)', server2.port)]:
|
|
expected = expected.replace(key, str(val))
|
|
|
|
assert headers.get('Referer') == expected
|
|
|
|
|
|
def test_preferred_colorscheme_unsupported(request, quteproc_new):
|
|
"""Test versions without preferred-color-scheme support."""
|
|
if request.config.webengine:
|
|
pytest.skip("preferred-color-scheme is supported")
|
|
|
|
args = _base_args(request.config) + ['--temp-basedir']
|
|
quteproc_new.start(args)
|
|
quteproc_new.open_path('data/darkmode/prefers-color-scheme.html')
|
|
content = quteproc_new.get_content()
|
|
assert content == "Preference support missing."
|
|
|
|
|
|
@pytest.mark.qtwebkit_skip
|
|
@pytest.mark.parametrize('value', ["dark", "light", "auto", None])
|
|
def test_preferred_colorscheme(request, quteproc_new, value):
|
|
"""Make sure the the preferred colorscheme is set."""
|
|
if not request.config.webengine:
|
|
pytest.skip("Skipped with QtWebKit")
|
|
|
|
args = _base_args(request.config) + ['--temp-basedir']
|
|
if value is not None:
|
|
args += ['-s', 'colors.webpage.preferred_color_scheme', value]
|
|
quteproc_new.start(args)
|
|
|
|
dark_text = "Dark preference detected."
|
|
light_text = "Light preference detected."
|
|
|
|
expected_values = {
|
|
"dark": [dark_text],
|
|
"light": [light_text],
|
|
|
|
# Depends on the environment the test is running in.
|
|
"auto": [dark_text, light_text],
|
|
None: [dark_text, light_text],
|
|
}
|
|
xfail = False
|
|
if qtutils.version_check('5.15.2', exact=True, compiled=False):
|
|
# Test the WORKAROUND https://bugreports.qt.io/browse/QTBUG-89753
|
|
# With that workaround, we should always get the light preference.
|
|
for key in ["auto", None]:
|
|
expected_values[key].remove(dark_text)
|
|
xfail = value in ["auto", None]
|
|
|
|
quteproc_new.open_path('data/darkmode/prefers-color-scheme.html')
|
|
content = quteproc_new.get_content()
|
|
assert content in expected_values[value]
|
|
|
|
if xfail:
|
|
# Unsatisfactory result, but expected based on a Qt bug.
|
|
pytest.xfail("QTBUG-89753")
|
|
|
|
|
|
def test_preferred_colorscheme_with_dark_mode(
|
|
request, quteproc_new, webengine_versions):
|
|
"""Test interaction between preferred-color-scheme and dark mode.
|
|
|
|
We would actually expect a color of 34, 34, 34 and 'Dark preference detected.'.
|
|
That was the behavior on Qt 5.14 and 5.15.0/.1.
|
|
"""
|
|
if not request.config.webengine:
|
|
pytest.skip("Skipped with QtWebKit")
|
|
|
|
args = _base_args(request.config) + [
|
|
'--temp-basedir',
|
|
'-s', 'colors.webpage.preferred_color_scheme', 'dark',
|
|
'-s', 'colors.webpage.darkmode.enabled', 'true',
|
|
'-s', 'colors.webpage.darkmode.algorithm', 'brightness-rgb',
|
|
]
|
|
if webengine_versions.webengine == utils.VersionNumber(6, 9):
|
|
# WORKAROUND: For unknown reasons, dark mode colors are wrong with
|
|
# Qt 6.9 + hardware rendering + Xvfb.
|
|
args += testutils.SOFTWARE_RENDERING_ARGS
|
|
quteproc_new.start(args)
|
|
|
|
quteproc_new.open_path('data/darkmode/prefers-color-scheme.html')
|
|
content = quteproc_new.get_content()
|
|
|
|
if webengine_versions.webengine == utils.VersionNumber(5, 15, 2):
|
|
# Our workaround breaks when dark mode is enabled...
|
|
# Also, for some reason, dark mode doesn't work on that page either!
|
|
expected_text = 'No preference detected.'
|
|
expected_color = testutils.Color(0, 170, 0) # green
|
|
xfail = "QTBUG-89753"
|
|
elif webengine_versions.webengine < utils.VersionNumber(6, 4):
|
|
# https://bugs.chromium.org/p/chromium/issues/detail?id=1177973
|
|
# No workaround known.
|
|
expected_text = 'Light preference detected.'
|
|
# light website color, inverted by darkmode
|
|
if webengine_versions.webengine >= utils.VersionNumber(6):
|
|
expected_color = (testutils.Color(148, 146, 148) if IS_ARM
|
|
else testutils.Color(144, 144, 144))
|
|
else:
|
|
expected_color = (testutils.Color(123, 125, 123) if IS_ARM
|
|
else testutils.Color(127, 127, 127))
|
|
xfail = "Chromium bug 1177973"
|
|
else:
|
|
# Correct behavior on QtWebEngine 6.4 (and 5.14/5.15.0/5.15.1 in the past)
|
|
expected_text = 'Dark preference detected.'
|
|
expected_color = (testutils.Color(33, 32, 33) if IS_ARM
|
|
else testutils.Color(34, 34, 34)) # dark website color
|
|
xfail = False
|
|
|
|
pos = QPoint(0, 0)
|
|
img = quteproc_new.get_screenshot(probe_pos=pos, probe_color=expected_color)
|
|
color = testutils.Color(img.pixelColor(pos))
|
|
|
|
assert content == expected_text
|
|
assert color == expected_color
|
|
if xfail:
|
|
# We still do some checks, but we want to mark the test outcome as xfail.
|
|
pytest.xfail(xfail)
|
|
|
|
|
|
@pytest.mark.qtwebkit_skip
|
|
@pytest.mark.parametrize('reason', [
|
|
'Explicitly enabled',
|
|
'Qt version changed',
|
|
None,
|
|
])
|
|
def test_service_worker_workaround(
|
|
request, server, quteproc_new, short_tmpdir, reason):
|
|
"""Make sure we remove the QtWebEngine Service Worker directory if configured."""
|
|
args = _base_args(request.config) + ['--basedir', str(short_tmpdir)]
|
|
if reason == 'Explicitly enabled':
|
|
settings_args = ['-s', 'qt.workarounds.remove_service_workers', 'true']
|
|
else:
|
|
settings_args = []
|
|
|
|
service_worker_dir = short_tmpdir / 'data' / 'webengine' / 'Service Worker'
|
|
|
|
# First invocation: Create directory
|
|
quteproc_new.start(args)
|
|
quteproc_new.open_path('data/service-worker/index.html')
|
|
server.wait_for(verb='GET', path='/data/service-worker/data.json')
|
|
quteproc_new.send_cmd(':quit')
|
|
quteproc_new.wait_for_quit()
|
|
assert service_worker_dir.exists()
|
|
|
|
# Edit state file if needed
|
|
state_file = short_tmpdir / 'data' / 'state'
|
|
if reason == 'Qt version changed':
|
|
parser = configparser.ConfigParser()
|
|
parser.read(state_file)
|
|
del parser['general']['qt_version']
|
|
with state_file.open('w', encoding='utf-8') as f:
|
|
parser.write(f)
|
|
|
|
# Second invocation: Directory gets removed (if workaround enabled)
|
|
quteproc_new.start(args + settings_args)
|
|
if reason is not None:
|
|
quteproc_new.wait_for(
|
|
message=(f'Removing service workers at {service_worker_dir} '
|
|
f'(reason: {reason})'))
|
|
|
|
quteproc_new.send_cmd(':quit')
|
|
quteproc_new.wait_for_quit()
|
|
|
|
if reason is None:
|
|
assert service_worker_dir.exists()
|
|
quteproc_new.ensure_not_logged(message='Removing service workers at *')
|
|
else:
|
|
assert not service_worker_dir.exists()
|
|
|
|
|
|
@pytest.mark.qt6_only
|
|
def test_disable_hangouts_extension_crash(
|
|
quteproc_new: quteprocess.QuteProc,
|
|
request: pytest.FixtureRequest,
|
|
webengine_versions: version.WebEngineVersions,
|
|
):
|
|
"""Make sure disabling the Hangouts extension doesn't crash."""
|
|
args = _base_args(request.config) + [
|
|
'--temp-basedir',
|
|
'-s', 'qt.workarounds.disable_hangouts_extension', 'true',
|
|
]
|
|
quteproc_new.start(args)
|
|
if webengine_versions.webengine == utils.VersionNumber(6, 10, 1):
|
|
line = quteproc_new.wait_for(message="Not disabling Hangouts extension *")
|
|
line.expected = True
|
|
|
|
quteproc_new.send_cmd(':quit')
|
|
quteproc_new.wait_for_quit()
|
|
|
|
|
|
@pytest.mark.parametrize('store', [True, False])
|
|
def test_cookies_store(quteproc_new, request, short_tmpdir, store):
|
|
# Start test process
|
|
args = _base_args(request.config) + [
|
|
'--basedir', str(short_tmpdir),
|
|
'-s', 'content.cookies.store', str(store),
|
|
]
|
|
quteproc_new.start(args)
|
|
|
|
# Set cookie and ensure it's set
|
|
quteproc_new.open_path('cookies/set-custom?max_age=30', wait=False)
|
|
quteproc_new.wait_for_load_finished('cookies')
|
|
content = quteproc_new.get_content()
|
|
data = json.loads(content)
|
|
assert data == {'cookies': {'cookie': 'value'}}
|
|
|
|
# Restart
|
|
quteproc_new.send_cmd(':quit')
|
|
quteproc_new.wait_for_quit()
|
|
quteproc_new.start(args)
|
|
|
|
# Check cookies
|
|
quteproc_new.open_path('cookies')
|
|
content = quteproc_new.get_content()
|
|
data = json.loads(content)
|
|
expected_cookies = {'cookie': 'value'} if store else {}
|
|
assert data == {'cookies': expected_cookies}
|
|
|
|
quteproc_new.send_cmd(':quit')
|
|
quteproc_new.wait_for_quit()
|
|
|
|
|
|
def test_permission_prompt_across_restart(quteproc_new, request, short_tmpdir):
|
|
# Start test process
|
|
args = _base_args(request.config) + [
|
|
'--basedir', str(short_tmpdir),
|
|
'-s', 'content.notifications.enabled', 'ask',
|
|
]
|
|
quteproc_new.start(args)
|
|
|
|
def notification_prompt(answer):
|
|
quteproc_new.open_path('data/prompt/notifications.html')
|
|
quteproc_new.send_cmd(':click-element id button')
|
|
quteproc_new.wait_for(message='Asking question *')
|
|
quteproc_new.send_cmd(f':prompt-accept {answer}')
|
|
|
|
# Make sure we are prompted the first time we are opened in this basedir
|
|
notification_prompt('yes')
|
|
quteproc_new.wait_for_js('notification permission granted')
|
|
|
|
# Restart with same basedir
|
|
quteproc_new.send_cmd(':quit')
|
|
quteproc_new.wait_for_quit()
|
|
quteproc_new.start(args)
|
|
|
|
# We should be re-prompted in the new instance
|
|
notification_prompt('no')
|
|
|
|
quteproc_new.send_cmd(':quit')
|
|
quteproc_new.wait_for_quit()
|
|
|
|
|
|
# The 'colors' dictionaries in the parametrize decorator below have (QtWebEngine
|
|
# version, CPU architecture) as keys. Either of those (or both) can be None to
|
|
# say "on all other Qt versions" or "on all other CPU architectures".
|
|
@pytest.mark.parametrize('filename, algorithm, colors', [
|
|
(
|
|
'blank',
|
|
'lightness-cielab',
|
|
{
|
|
(None, None): testutils.Color(18, 18, 18),
|
|
(None, 'aarch64'): testutils.Color(16, 16, 16),
|
|
}
|
|
),
|
|
(
|
|
'blank',
|
|
'lightness-hsl',
|
|
{
|
|
('5.15', None): testutils.Color(0, 0, 0),
|
|
('6.2', None): testutils.Color(0, 0, 0),
|
|
# Qt 6.3+ (why #121212 rather than #000000?)
|
|
(None, None): testutils.Color(18, 18, 18),
|
|
}
|
|
),
|
|
(
|
|
'blank',
|
|
'brightness-rgb',
|
|
{
|
|
('5.15', None): testutils.Color(0, 0, 0),
|
|
('6.2', None): testutils.Color(0, 0, 0),
|
|
# Qt 6.3+ (why #121212 rather than #000000?)
|
|
(None, None): testutils.Color(18, 18, 18),
|
|
}
|
|
),
|
|
|
|
(
|
|
'yellow',
|
|
'lightness-cielab',
|
|
{
|
|
(None, None): testutils.Color(35, 34, 0),
|
|
(None, 'aarch64'): testutils.Color(33, 32, 0),
|
|
}
|
|
),
|
|
(
|
|
'yellow',
|
|
'lightness-hsl',
|
|
{
|
|
(None, None): testutils.Color(215, 215, 0),
|
|
(None, 'aarch64'): testutils.Color(214, 215, 0),
|
|
('5.15', None): testutils.Color(204, 204, 0),
|
|
('5.15', 'aarch64'): testutils.Color(206, 207, 0),
|
|
},
|
|
),
|
|
(
|
|
'yellow',
|
|
'brightness-rgb',
|
|
{
|
|
(None, None): testutils.Color(0, 0, 215),
|
|
(None, 'aarch64'): testutils.Color(0, 0, 214),
|
|
('5.15', None): testutils.Color(0, 0, 204),
|
|
('5.15', 'aarch64'): testutils.Color(0, 0, 206),
|
|
}
|
|
),
|
|
])
|
|
def test_dark_mode(webengine_versions, quteproc_new, request,
|
|
filename, algorithm, colors):
|
|
if not request.config.webengine:
|
|
pytest.skip("Skipped with QtWebKit")
|
|
|
|
args = _base_args(request.config) + [
|
|
'--temp-basedir',
|
|
'-s', 'colors.webpage.darkmode.enabled', 'true',
|
|
'-s', 'colors.webpage.darkmode.algorithm', algorithm,
|
|
]
|
|
if webengine_versions.webengine == utils.VersionNumber(6, 9):
|
|
# WORKAROUND: For unknown reasons, dark mode colors are wrong with
|
|
# Qt 6.9 + hardware rendering + Xvfb.
|
|
args += testutils.SOFTWARE_RENDERING_ARGS
|
|
|
|
quteproc_new.start(args)
|
|
minor_version = str(webengine_versions.webengine.strip_patch())
|
|
|
|
arch = platform.machine()
|
|
for key in [
|
|
(minor_version, arch),
|
|
(minor_version, None),
|
|
(None, arch),
|
|
(None, None),
|
|
]:
|
|
if key in colors:
|
|
expected = colors[key]
|
|
break
|
|
|
|
quteproc_new.open_path(f'data/darkmode/{filename}.html')
|
|
|
|
# Position chosen by fair dice roll.
|
|
# https://xkcd.com/221/
|
|
quteproc_new.get_screenshot(
|
|
probe_pos=QPoint(4, 4),
|
|
probe_color=expected,
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize("suffix", ["inline", "display"])
|
|
def test_dark_mode_mathml(webengine_versions, quteproc_new, request, qtbot, suffix):
|
|
if not request.config.webengine:
|
|
pytest.skip("Skipped with QtWebKit")
|
|
|
|
args = _base_args(request.config) + [
|
|
'--temp-basedir',
|
|
'-s', 'colors.webpage.darkmode.enabled', 'true',
|
|
'-s', 'colors.webpage.darkmode.algorithm', 'brightness-rgb',
|
|
]
|
|
if webengine_versions.webengine == utils.VersionNumber(6, 9):
|
|
# WORKAROUND: For unknown reasons, dark mode colors are wrong with
|
|
# Qt 6.9 + hardware rendering + Xvfb.
|
|
args += testutils.SOFTWARE_RENDERING_ARGS
|
|
|
|
quteproc_new.start(args)
|
|
|
|
quteproc_new.open_path(f'data/darkmode/mathml-{suffix}.html')
|
|
quteproc_new.wait_for_js('Image loaded')
|
|
|
|
# First make sure loading finished by looking outside of the image
|
|
if webengine_versions.webengine >= utils.VersionNumber(6):
|
|
expected = testutils.Color(0, 0, 214) if IS_ARM else testutils.Color(0, 0, 215)
|
|
else:
|
|
expected = testutils.Color(0, 0, 206) if IS_ARM else testutils.Color(0, 0, 204)
|
|
|
|
quteproc_new.get_screenshot(
|
|
probe_pos=QPoint(105, 0),
|
|
probe_color=expected,
|
|
)
|
|
|
|
# Then get the actual formula color, probing again in case it's not displayed yet...
|
|
quteproc_new.get_screenshot(
|
|
probe_pos=QPoint(4, 4),
|
|
probe_color=testutils.Color(255, 255, 255),
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize('value, preference', [
|
|
('true', 'Reduced motion'),
|
|
('false', 'No'),
|
|
])
|
|
@pytest.mark.skipif(
|
|
utils.is_windows,
|
|
reason="Outcome on Windows depends on system settings",
|
|
)
|
|
def test_prefers_reduced_motion(quteproc_new, request, value, preference):
|
|
if not request.config.webengine:
|
|
pytest.skip("Skipped with QtWebKit")
|
|
|
|
args = _base_args(request.config) + [
|
|
'--temp-basedir',
|
|
'-s', 'content.prefers_reduced_motion', value,
|
|
]
|
|
quteproc_new.start(args)
|
|
|
|
quteproc_new.open_path('data/prefers_reduced_motion.html')
|
|
content = quteproc_new.get_content()
|
|
assert content == f"{preference} preference detected."
|
|
|
|
|
|
def test_unavailable_backend(request, quteproc_new):
|
|
"""Test starting with a backend which isn't available.
|
|
|
|
If we use --qute-bdd-webengine, we test with QtWebKit here; otherwise we test with
|
|
QtWebEngine. If both are available, the test is skipped.
|
|
|
|
This ensures that we don't accidentally use backend-specific code before checking
|
|
that the chosen backend is actually available - i.e., that the error message is
|
|
properly printed, rather than an unhandled exception.
|
|
"""
|
|
qtwe_module = "qutebrowser.qt.webenginewidgets"
|
|
qtwk_module = "qutebrowser.qt.webkitwidgets"
|
|
# Note we want to try the *opposite* backend here.
|
|
if request.config.webengine:
|
|
pytest.importorskip(qtwe_module)
|
|
module = qtwk_module
|
|
backend = 'webkit'
|
|
else:
|
|
pytest.importorskip(qtwk_module)
|
|
module = qtwe_module
|
|
backend = 'webengine'
|
|
|
|
try:
|
|
importlib.import_module(module)
|
|
except ImportError:
|
|
pass
|
|
else:
|
|
pytest.skip(f"{module} is available")
|
|
|
|
args = [
|
|
'--debug', '--json-logging', '--no-err-windows',
|
|
'--backend', backend,
|
|
'--temp-basedir'
|
|
]
|
|
quteproc_new.exit_expected = True
|
|
quteproc_new.start(args)
|
|
line = quteproc_new.wait_for(
|
|
message=('*qutebrowser tried to start with the Qt* backend but failed '
|
|
'because * could not be imported.*'))
|
|
line.expected = True
|
|
|
|
|
|
def test_json_logging_without_debug(request, quteproc_new, runtime_tmpdir):
|
|
args = _base_args(request.config) + ['--temp-basedir', ':quit']
|
|
args.remove('--debug')
|
|
args.remove('about:blank') # interferes with :quit at the end
|
|
|
|
quteproc_new.exit_expected = True
|
|
quteproc_new.start(args, env={'XDG_RUNTIME_DIR': str(runtime_tmpdir)})
|
|
assert not quteproc_new.is_running()
|
|
|
|
|
|
@pytest.mark.qtwebkit_skip
|
|
@pytest.mark.parametrize(
|
|
'sandboxing, has_namespaces, has_seccomp, has_yama, expected_result', [
|
|
('enable-all', True, True, True, "You are adequately sandboxed."),
|
|
('disable-seccomp-bpf', True, False, True, "You are NOT adequately sandboxed."),
|
|
('disable-all', False, False, False, "You are NOT adequately sandboxed."),
|
|
]
|
|
)
|
|
def test_sandboxing(
|
|
request, quteproc_new, sandboxing,
|
|
has_namespaces, has_seccomp, has_yama, expected_result,
|
|
):
|
|
# https://github.com/qutebrowser/qutebrowser/issues/8424
|
|
userns_restricted = testutils.is_userns_restricted()
|
|
|
|
if not request.config.webengine:
|
|
pytest.skip("Skipped with QtWebKit")
|
|
elif sandboxing == "enable-all" and testutils.disable_seccomp_bpf_sandbox():
|
|
pytest.skip("Full sandboxing not supported")
|
|
elif version.is_flatpak() or userns_restricted:
|
|
# https://github.com/flathub/io.qt.qtwebengine.BaseApp/pull/66
|
|
has_namespaces = False
|
|
expected_result = "You are NOT adequately sandboxed."
|
|
has_yama_non_broker = has_yama
|
|
else:
|
|
has_yama_non_broker = False
|
|
|
|
args = _base_args(request.config) + [
|
|
'--temp-basedir',
|
|
'-s', 'qt.chromium.sandboxing', sandboxing,
|
|
]
|
|
quteproc_new.start(args)
|
|
|
|
quteproc_new.open_url('chrome://sandbox')
|
|
text = quteproc_new.get_content()
|
|
print(text)
|
|
|
|
not_found_msg = ("The webpage at chrome://sandbox/ might be temporarily down or "
|
|
"it may have moved permanently to a new web address.")
|
|
if not_found_msg in text.split("\n"):
|
|
line = quteproc_new.wait_for(message='Load error: ERR_INVALID_URL')
|
|
line.expected = True
|
|
pytest.skip("chrome://sandbox/ not supported")
|
|
|
|
if len(text.split("\n")) == 1:
|
|
# Try again, maybe the JS hasn't run yet?
|
|
text = quteproc_new.get_content()
|
|
print(text)
|
|
|
|
bpf_text = "Seccomp-BPF sandbox"
|
|
yama_text = "Ptrace Protection with Yama LSM"
|
|
|
|
if not utils.is_windows:
|
|
header, *lines, empty, result = text.split("\n")
|
|
assert not empty
|
|
|
|
expected_status = {
|
|
"Layer 1 Sandbox": "Namespace" if has_namespaces else "None",
|
|
|
|
"PID namespaces": "Yes" if has_namespaces else "No",
|
|
"Network namespaces": "Yes" if has_namespaces else "No",
|
|
|
|
bpf_text: "Yes" if has_seccomp else "No",
|
|
f"{bpf_text} supports TSYNC": "Yes" if has_seccomp else "No",
|
|
|
|
f"{yama_text} (Broker)": "Yes" if has_yama else "No",
|
|
# pylint: disable-next=used-before-assignment
|
|
f"{yama_text} (Non-broker)": "Yes" if has_yama_non_broker else "No",
|
|
}
|
|
|
|
assert header == "Sandbox Status"
|
|
assert result == expected_result
|
|
|
|
status = dict(line.split("\t") for line in lines)
|
|
assert status == expected_status
|
|
|
|
else: # utils.is_windows
|
|
# The sandbox page on Windows if different that Linux and macOS. It's
|
|
# a lot more complex. There is a table up top with lots of columns and
|
|
# a row per tab and helper process then a json object per row down
|
|
# below with even more detail (which we ignore).
|
|
# https://www.chromium.org/Home/chromium-security/articles/chrome-sandbox-diagnostics-for-windows/
|
|
|
|
# We're not getting full coverage of the table and there doesn't seem
|
|
# to be a simple summary like for linux. The "Sandbox" and "Lockdown"
|
|
# column are probably the key ones.
|
|
# We are looking at all the rows in the table for the sake of
|
|
# completeness, but I expect there will always be just one row with a
|
|
# renderer process in it for this test. If other helper processes pop
|
|
# up we might want to exclude them.
|
|
lines = text.split("\n")
|
|
assert lines.pop(0) == "Sandbox Status"
|
|
header = lines.pop(0).split("\t")
|
|
rows = []
|
|
current_line = lines.pop(0)
|
|
while current_line.strip():
|
|
if lines[0].startswith("\t"):
|
|
# Continuation line. Not sure how to 100% identify them
|
|
# but new rows should start with a process ID.
|
|
current_line += lines.pop(0)
|
|
continue
|
|
|
|
columns = current_line.split("\t")
|
|
assert len(header) == len(columns)
|
|
rows.append(dict(zip(header, columns)))
|
|
current_line = lines.pop(0)
|
|
|
|
assert rows
|
|
|
|
# I'm using has_namespaces as a proxy for "should be sandboxed" here,
|
|
# which is a bit lazy but its either that or match on the text
|
|
# "sandboxing" arg. The seccomp-bpf arg does nothing on windows, so
|
|
# we only have the off and on states.
|
|
for row in rows:
|
|
assert row == {
|
|
"Process": ANY,
|
|
"Type": "Renderer",
|
|
"Name": "",
|
|
"Sandbox": "Renderer" if has_namespaces else "Not Sandboxed",
|
|
"Lockdown": "Lockdown" if has_namespaces else "",
|
|
"Integrity": ANY if has_namespaces else "",
|
|
"Mitigations": ANY if has_namespaces else "",
|
|
"Component Filter": ANY if has_namespaces else "",
|
|
"Lowbox/AppContainer": "",
|
|
}
|
|
|
|
|
|
@pytest.mark.not_frozen
|
|
def test_logfilter_arg_does_not_crash(request, quteproc_new):
|
|
args = ['--temp-basedir', '--debug', '--logfilter', 'commands, init, ipc, webview']
|
|
|
|
with does_not_raise():
|
|
quteproc_new.start(args=args + _base_args(request.config))
|
|
|
|
# Waiting for quit to make sure no other warning is emitted
|
|
quteproc_new.send_cmd(':quit')
|
|
quteproc_new.wait_for_quit()
|
|
|
|
|
|
def test_restart(request, quteproc_new):
|
|
args = _base_args(request.config) + ['--temp-basedir']
|
|
quteproc_new.start(args)
|
|
quteproc_new.send_cmd(':restart')
|
|
|
|
prefix = "New process PID: "
|
|
line = quteproc_new.wait_for(message=f"{prefix}*")
|
|
quteproc_new.wait_for_quit()
|
|
|
|
assert line.message.startswith(prefix)
|
|
pid = int(line.message.removeprefix(prefix))
|
|
os.kill(pid, signal.SIGTERM)
|
|
|
|
# This often hangs on Windows for unknown reasons
|
|
if not utils.is_windows:
|
|
try:
|
|
# If the new process hangs, this will hang too.
|
|
# Still better than just ignoring it, so we can fix it if something is broken.
|
|
os.waitpid(pid, 0) # pid, options... positional-only :(
|
|
except (ChildProcessError, PermissionError):
|
|
# Already gone. Even if not documented, Windows seems to raise PermissionError
|
|
# here...
|
|
pass
|