diff options
83 files changed, 1060 insertions, 500 deletions
diff --git a/.coveragerc b/.coveragerc index 2ef20dd12..9d43917a3 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1,5 +1,8 @@ [run] -source = qutebrowser +include = + qutebrowser/* + tests/* + scripts/* branch = true omit = qutebrowser/__main__.py diff --git a/.editorconfig b/.editorconfig index 645ced56e..6aab87c94 100644 --- a/.editorconfig +++ b/.editorconfig @@ -4,7 +4,7 @@ insert_final_newline = true trim_trailing_whitespace = true charset = utf-8 -max_line_length = 79 +max_line_length = 88 indent_style = space indent_size = 4 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2bee4391b..36423aab8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,7 +39,7 @@ jobs: .tox ~/.cache/pip key: "${{ matrix.testenv }}-${{ hashFiles('misc/requirements/requirements-*.txt') }}-${{ hashFiles('requirements.txt') }}" - - uses: actions/setup-python@v2.1.1 + - uses: actions/setup-python@v2.1.2 with: python-version: '3.8' - uses: actions/setup-node@v2.1.1 @@ -129,10 +129,10 @@ jobs: - testenv: py38-pyqt514 os: ubuntu-20.04 python: 3.8 - ### PyQt 5.15 (Python nightly) - - testenv: py3-pyqt515 + ### PyQt 5.15 (Python 3.9) + - testenv: py39-pyqt515 os: ubuntu-20.04 - python: 3.10-dev + python: 3.9-dev ### PyQt 5.15 (Python 3.8, with coverage) - testenv: py38-pyqt515-cov os: ubuntu-20.04 @@ -157,13 +157,7 @@ jobs: ~/.cache/pip key: "${{ matrix.testenv }}-${{ matrix.os }}-${{ matrix.python }}-${{ hashFiles('misc/requirements/requirements-*.txt') }}-${{ hashFiles('requirements.txt') }}" - name: Set up Python - uses: actions/setup-python@v2.1.1 - if: "!endsWith(matrix.python, '-dev')" - with: - python-version: "${{ matrix.python }}" - - name: Set up development Python - uses: deadsnakes/action@v1.0.0 - if: "endsWith(matrix.python, '-dev')" + uses: actions/setup-python@v2.1.2 with: python-version: "${{ matrix.python }}" - name: Set up problem matchers @@ -184,7 +178,7 @@ jobs: if: "failure()" - name: Upload coverage if: "endsWith(matrix.testenv, '-cov')" - uses: codecov/codecov-action@v1.0.12 + uses: codecov/codecov-action@v1.0.13 with: name: "${{ matrix.testenv }}" diff --git a/.github/workflows/recompile-requirements.yml b/.github/workflows/recompile-requirements.yml index 73254d854..045f2ee1e 100644 --- a/.github/workflows/recompile-requirements.yml +++ b/.github/workflows/recompile-requirements.yml @@ -20,11 +20,11 @@ jobs: steps: - uses: actions/checkout@v2 - name: Set up Python 3.7 - uses: actions/setup-python@v2.1.1 + uses: actions/setup-python@v2.1.2 with: python-version: '3.7' - name: Set up Python 3.8 - uses: actions/setup-python@v2.1.1 + uses: actions/setup-python@v2.1.2 with: python-version: '3.8' - name: Recompile requirements @@ -59,7 +59,7 @@ docstring-min-length=3 no-docstring-rgx=(^_|^main$) [FORMAT] -max-line-length=79 +max-line-length=88 ignore-long-lines=(<?https?://|file://|^# Copyright 201\d|link:) expected-line-ending-format=LF diff --git a/doc/changelog.asciidoc b/doc/changelog.asciidoc index 5977dcbc3..17635fd50 100644 --- a/doc/changelog.asciidoc +++ b/doc/changelog.asciidoc @@ -45,12 +45,20 @@ Changed - `:back` and `:forward` now take an optional index which is completed using the current tab's history. - The time a website in a tab was visited is now saved/restored in sessions. +- New argument `strip` for `:navigate` which removes queries and + fragments from the current URL. +- When attempting to download a file to a location for which there's already a + still-running download, a confirmation prompt is now displayed. +- `:completion-item-focus` now understands `next-page` and `prev-page` with + corresponding `<PgDown>` / `<PgUp>` default bindings. +- When the last private window is closed, all private browsing data is now cleared. Added ~~~~~ - `:undo` now has a new `-w` / `--window` argument, which can be used to restore closed windows (rather than tabs). This is bound to `U` by default. +- `:jseval` can now take `javascript:...` URLs via a new `--url` flag. - New replacement `{aligned_index}` for `tabs.title.format` and `format_pinned` which behaves like `{index}`, but space-pads the index based on the total numbers of tabs. This can be used to get aligned tab texts with vertical @@ -63,6 +71,7 @@ Added - The `:download-open` command now has a new `--dir` flag, which can be used to open the directory containing the downloaded file. An entry to do the same was also added to the context menu. +- Messages are now wrapped when they are too long to be displayed on a single line. Fixed ~~~~~ @@ -98,6 +107,12 @@ Fixed instead of displaying the proper text. This is now fixed. - When entering different modes too quickly (e.g. pressing `fV`), the statusbar could end up in a confusing state. This is now fixed. +- When qutebrowser quits, running downloads are now cancelled properly. +- The site-specific quirk for `web.whatsapp.com` has been updated to work after recent + WhatsApp-changes. +- Highlighting in the completion now works properly when UTF-16 surrogate pairs (such as + emoji) are involved. +- When a windowed inspector is clicked, insert mode now isn't entered anymore. v1.13.1 (2020-07-17) -------------------- diff --git a/doc/help/commands.asciidoc b/doc/help/commands.asciidoc index e9ccf03d7..299b34b5e 100644 --- a/doc/help/commands.asciidoc +++ b/doc/help/commands.asciidoc @@ -749,7 +749,7 @@ Insert text at cursor position. [[jseval]] === jseval -Syntax: +:jseval [*--file*] [*--quiet*] [*--world* 'world'] 'js-code'+ +Syntax: +:jseval [*--file*] [*--url*] [*--quiet*] [*--world* 'world'] 'js-code'+ Evaluate a JavaScript string. @@ -761,6 +761,7 @@ Evaluate a JavaScript string. in qutebrowser's data dir, e.g. `~/.local/share/qutebrowser/js`. +* +*-u*+, +*--url*+: Interpret js-code as a `javascript:...` URL. * +*-q*+, +*--quiet*+: Don't show resulting JS object. * +*-w*+, +*--world*+: Ignored on QtWebKit. On QtWebEngine, a world ID or name to run the snippet in. @@ -864,6 +865,7 @@ This tries to automatically click on typical _Previous Page_ or _Next Page_ link Uses the link:settings{outsuffix}#url.incdec_segments[url.incdec_segments] config option. + - `strip`: Strip query and fragment from the current URL. @@ -1662,7 +1664,9 @@ Syntax: +:completion-item-focus [*--history*] 'which'+ Shift the focus of the completion menu to another item. ==== positional arguments -* +'which'+: 'next', 'prev', 'next-category', or 'prev-category'. +* +'which'+: 'next', 'prev', 'next-category', 'prev-category', + 'next-page', or 'prev-page'. + ==== optional arguments * +*-H*+, +*--history*+: Navigate through command history if no text was typed. diff --git a/doc/help/configuring.asciidoc b/doc/help/configuring.asciidoc index 575104fc1..90b7ed65b 100644 --- a/doc/help/configuring.asciidoc +++ b/doc/help/configuring.asciidoc @@ -394,9 +394,10 @@ Pre-built colorschemes ^^^^^^^^^^^^^^^^^^^^^^ - A collection of https://github.com/chriskempson/base16[base16] color-schemes can be found in https://github.com/theova/base16-qutebrowser[base16-qutebrowser] and used with https://github.com/AuditeMarlow/base16-manager[base16-manager]. +- https://gitlab.com/jjzmajic/qutewal[Pywal integration] - Two implementations of the https://github.com/arcticicestudio/nord[Nord] colorscheme for qutebrowser exist: https://github.com/Linuus/nord-qutebrowser[Linuus], https://github.com/KnownAsDon/QuteBrowser-Nord-Theme[KnownAsDon] - https://github.com/dracula/qutebrowser-dracula-theme[Dracula] -- https://github.com/jjzmajic/qutewal[Pywal theme] +- https://gitlab.com/lovetocode999/selenized-qutebrowser[Selenized] Avoiding flake8 errors ^^^^^^^^^^^^^^^^^^^^^^ diff --git a/doc/help/settings.asciidoc b/doc/help/settings.asciidoc index 79de4fc12..d4de80d06 100644 --- a/doc/help/settings.asciidoc +++ b/doc/help/settings.asciidoc @@ -374,6 +374,7 @@ Backend to use to display websites. qutebrowser supports two different web rendering engines / backends, QtWebKit and QtWebEngine. QtWebKit was discontinued by the Qt project with Qt 5.6, but picked up as a well maintained fork: https://github.com/annulen/webkit/wiki - qutebrowser only supports the fork. QtWebEngine is Qt's official successor to QtWebKit. It's slightly more resource hungry than QtWebKit and has a couple of missing features in qutebrowser, but is generally the preferred choice. + This setting requires a restart. Type: <<types,String>> @@ -509,6 +510,8 @@ Default: * +pass:[<Ctrl-Y>]+: +pass:[rl-yank]+ * +pass:[<Down>]+: +pass:[completion-item-focus --history next]+ * +pass:[<Escape>]+: +pass:[leave-mode]+ +* +pass:[<PgDown>]+: +pass:[completion-item-focus next-page]+ +* +pass:[<PgUp>]+: +pass:[completion-item-focus prev-page]+ * +pass:[<Return>]+: +pass:[command-accept]+ * +pass:[<Shift-Delete>]+: +pass:[completion-item-del]+ * +pass:[<Shift-Tab>]+: +pass:[completion-item-focus prev]+ @@ -1569,6 +1572,7 @@ Default: +pass:[white]+ [[colors.webpage.darkmode.algorithm]] === colors.webpage.darkmode.algorithm Which algorithm to use for modifying how colors are rendered with darkmode. + This setting requires a restart. Type: <<types,String>> @@ -1589,6 +1593,7 @@ On QtWebKit, this setting is unavailable. === colors.webpage.darkmode.contrast Contrast for dark mode. This only has an effect when `colors.webpage.darkmode.algorithm` is set to `lightness-hsl` or `brightness-rgb`. + This setting requires a restart. Type: <<types,Float>> @@ -1616,6 +1621,7 @@ Example configurations from Chromium's `chrome://flags`: - "With selective inversion of everything": Combines the two variants above. + This setting requires a restart. Type: <<types,Bool>> @@ -1630,6 +1636,7 @@ On QtWebKit, this setting is unavailable. === colors.webpage.darkmode.grayscale.all Render all colors as grayscale. This only has an effect when `colors.webpage.darkmode.algorithm` is set to `lightness-hsl` or `brightness-rgb`. + This setting requires a restart. Type: <<types,Bool>> @@ -1644,6 +1651,7 @@ On QtWebKit, this setting is unavailable. === colors.webpage.darkmode.grayscale.images Desaturation factor for images in dark mode. If set to 0, images are left as-is. If set to 1, images are completely grayscale. Values between 0 and 1 desaturate the colors accordingly. + This setting requires a restart. Type: <<types,Float>> @@ -1658,6 +1666,7 @@ On QtWebKit, this setting is unavailable. === colors.webpage.darkmode.policy.images Which images to apply dark mode to. WARNING: On Qt 5.15.0, this setting can cause frequent renderer process crashes due to a https://codereview.qt-project.org/c/qt/qtwebengine-chromium/+/304211[bug in Qt]. + This setting requires a restart. Type: <<types,String>> @@ -1677,6 +1686,7 @@ On QtWebKit, this setting is unavailable. [[colors.webpage.darkmode.policy.page]] === colors.webpage.darkmode.policy.page Which pages to apply dark mode to. + This setting requires a restart. Type: <<types,String>> @@ -1697,6 +1707,7 @@ On QtWebKit, this setting is unavailable. Threshold for inverting background elements with dark mode. Background elements with brightness above this threshold will be inverted, and below it will be left as in the original, non-dark-mode page. Set to 256 to never invert the color or to 0 to always invert it. Note: This behavior is the opposite of `colors.webpage.darkmode.threshold.text`! + This setting requires a restart. Type: <<types,Int>> @@ -1711,6 +1722,7 @@ On QtWebKit, this setting is unavailable. === colors.webpage.darkmode.threshold.text Threshold for inverting text with dark mode. Text colors with brightness below this threshold will be inverted, and above it will be left as in the original, non-dark-mode page. Set to 256 to always invert text color or to 0 to never invert text color. + This setting requires a restart. Type: <<types,Int>> @@ -1854,6 +1866,7 @@ Default: +pass:[false]+ A list of patterns which should not be shown in the history. This only affects the completion. Matching URLs are still saved in the history (and visible on the qute://history page), but hidden in the completion. Changing this setting will cause the completion history to be regenerated on the next start, which will take a short while. + This setting requires a restart. Type: <<types,List of UrlPattern>> @@ -2013,6 +2026,7 @@ Default: empty === content.canvas_reading Allow websites to read canvas elements. Note this is needed for some websites to work properly. + This setting requires a restart. Type: <<types,Bool>> @@ -2173,6 +2187,7 @@ Default: +pass:[true]+ When to send the Referer header. The Referer header tells websites from which website you were coming from when visiting them. No restart is needed with QtWebKit. + This setting requires a restart. Type: <<types,String>> @@ -2569,6 +2584,7 @@ On QtWebKit, this setting is unavailable. [[content.site_specific_quirks]] === content.site_specific_quirks Enable quirks (such as faked user agent headers) needed to get specific sites to work properly. + This setting requires a restart. Type: <<types,Bool>> @@ -2633,6 +2649,7 @@ Default: +pass:[true]+ === content.webrtc_ip_handling_policy Which interfaces to expose via WebRTC. On Qt 5.10, this option doesn't work because of a Qt bug. + This setting requires a restart. Type: <<types,String>> @@ -3460,6 +3477,7 @@ Default: +pass:[8]+ === qt.args Additional arguments to pass to Qt, without leading `--`. With QtWebEngine, some Chromium arguments (see https://peter.sh/experiments/chromium-command-line-switches/ for a list) will work. + This setting requires a restart. Type: <<types,List of String>> @@ -3470,6 +3488,7 @@ Default: empty === qt.force_platform Force a Qt platform to use. This sets the `QT_QPA_PLATFORM` environment variable and is useful to force using the XCB plugin when running QtWebEngine on Wayland. + This setting requires a restart. Type: <<types,String>> @@ -3480,6 +3499,7 @@ Default: empty === qt.force_platformtheme Force a Qt platformtheme to use. This sets the `QT_QPA_PLATFORMTHEME` environment variable which controls dialogs like the filepicker. By default, Qt determines the platform theme based on the desktop environment. + This setting requires a restart. Type: <<types,String>> @@ -3490,6 +3510,7 @@ Default: empty === qt.force_software_rendering Force software rendering for QtWebEngine. This is needed for QtWebEngine to work with Nouveau drivers and can be useful in other scenarios related to graphic issues. + This setting requires a restart. Type: <<types,String>> @@ -3510,6 +3531,7 @@ This setting is only available with the QtWebEngine backend. Turn on Qt HighDPI scaling. This is equivalent to setting QT_AUTO_SCREEN_SCALE_FACTOR=1 or QT_ENABLE_HIGHDPI_SCALING=1 (Qt >= 5.14) in the environment. It's off by default as it can cause issues with some bitmap fonts. As an alternative to this, it's possible to set font sizes and the `zoom.default` setting. + This setting requires a restart. Type: <<types,Bool>> @@ -3520,6 +3542,7 @@ Default: +pass:[false]+ === qt.low_end_device_mode When to use Chromium's low-end device mode. This improves the RAM usage of renderer processes, at the expense of performance. + This setting requires a restart. Type: <<types,String>> @@ -3542,6 +3565,7 @@ See the following pages for more details: - https://www.chromium.org/developers/design-documents/process-models - https://doc.qt.io/qt-5/qtwebengine-features.html#process-models + This setting requires a restart. Type: <<types,String>> diff --git a/doc/quickstart.asciidoc b/doc/quickstart.asciidoc index 8d8f21aa0..ab9298fa6 100644 --- a/doc/quickstart.asciidoc +++ b/doc/quickstart.asciidoc @@ -46,24 +46,25 @@ If you get stuck, you can get help in multiple ways: * The `:help` command inside qutebrowser shows the built-in documentation. Additionally, each command can be started with a `--help` flag to show its help. -* IRC channel: irc://irc.freenode.org/#qutebrowser[`#qutebrowser`] on +* Chat via the IRC channel: irc://irc.freenode.org/#qutebrowser[`#qutebrowser`] on http://freenode.net/[Freenode] (https://webchat.freenode.net/?channels=#qutebrowser[webchat]) -* Mailinglist: mailto:qutebrowser@lists.qutebrowser.org[] ( -https://lists.schokokeks.org/mailman/listinfo.cgi/qutebrowser[subscribe]) +* On Reddit: https://www.reddit.com/r/qutebrowser/[/r/qutebrowser] +* Via https://github.com/qutebrowser/qutebrowser/discussions[GitHub Discussions] +* Using the mailinglist: mailto:qutebrowser@lists.qutebrowser.org[] +(https://lists.schokokeks.org/mailman/listinfo.cgi/qutebrowser[subscribe]) Donating -------- -Working on qutebrowser is a very rewarding hobby, but like (nearly) all hobbies -it also costs some money. Namely I have to pay for the server and domain, and -do occasional hardware upgrades footnote:[It turned out a 160 GB SSD is rather -small - the VMs and custom Qt builds I use for testing/developing qutebrowser -need about 100 GB of space]. +qutebrowser's primary maintainer, The-Compiler, is currently working part-time on +qutebrowser, funded by donations. -If you want to give me a beer or a pizza back, I'm trying to make it as easy as -possible for you to do so. If some other way would be easier for you, please -get in touch! +To sustain this for a long time, your help is needed! Check the +https://github.com/sponsors/The-Compiler/[GitHub Sponsors page] for more information. +Depending on your sign-up date and how long you keep a certain level, you can get +qutebrowser t-shirts, stickers and more! -* PayPal: me@the-compiler.org -* Bitcoin: link:bitcoin:1PMzbcetAHfpxoXww8Bj5XqquHtVvMjJtE[1PMzbcetAHfpxoXww8Bj5XqquHtVvMjJtE] +Alternatively, there are also various options available for one-time donations, see the +https://github.com/qutebrowser/qutebrowser/blob/master/README.asciidoc#donating[donation section] +in the README for details. diff --git a/misc/requirements/requirements-dev.txt b/misc/requirements/requirements-dev.txt index 6c76979ae..c543088c6 100644 --- a/misc/requirements/requirements-dev.txt +++ b/misc/requirements/requirements-dev.txt @@ -2,15 +2,15 @@ bump2version==1.0.0 certifi==2020.6.20 -cffi==1.14.1 +cffi==1.14.2 chardet==3.0.4 colorama==0.4.3 cryptography==3.0 cssutils==1.0.2 github3.py==1.3.0 -hunter==3.1.3 +hunter==3.2.1 idna==2.10 -jwcrypto==0.7 +jwcrypto==0.8 manhole==1.6.0 packaging==20.4 pycparser==2.20 diff --git a/misc/requirements/requirements-flake8.txt b/misc/requirements/requirements-flake8.txt index 8a62fcdda..00d22b236 100644 --- a/misc/requirements/requirements-flake8.txt +++ b/misc/requirements/requirements-flake8.txt @@ -1,6 +1,6 @@ # This file is automatically generated by scripts/dev/recompile_requirements.py -attrs==19.3.0 +attrs==20.1.0 flake8==3.8.3 flake8-bugbear==20.1.4 flake8-builtins==1.5.3 @@ -18,7 +18,7 @@ flake8-tuple==0.4.1 mccabe==0.6.1 pep8-naming==0.11.1 pycodestyle==2.6.0 -pydocstyle==5.0.2 +pydocstyle==5.1.0 pyflakes==2.2.0 six==1.15.0 snowballstemmer==2.0.0 diff --git a/misc/requirements/requirements-mypy.txt b/misc/requirements/requirements-mypy.txt index b06cf2e8e..09d11ea6c 100644 --- a/misc/requirements/requirements-mypy.txt +++ b/misc/requirements/requirements-mypy.txt @@ -13,4 +13,4 @@ Pygments==2.6.1 -e git+https://github.com/stlehmann/PyQt5-stubs.git@master#egg=PyQt5_stubs six==1.15.0 typed-ast==1.4.1 -typing-extensions==3.7.4.2 +typing-extensions==3.7.4.3 diff --git a/misc/requirements/requirements-pyinstaller.txt b/misc/requirements/requirements-pyinstaller.txt index 4394c8044..6de7b6fa8 100644 --- a/misc/requirements/requirements-pyinstaller.txt +++ b/misc/requirements/requirements-pyinstaller.txt @@ -1,5 +1,5 @@ # This file is automatically generated by scripts/dev/recompile_requirements.py altgraph==0.17 --e git+https://github.com/pyinstaller/pyinstaller.git@develop#egg=pyinstaller -pyinstaller-hooks-contrib==2020.6 +pyinstaller==4.0 +pyinstaller-hooks-contrib==2020.7 diff --git a/misc/requirements/requirements-pyinstaller.txt-raw b/misc/requirements/requirements-pyinstaller.txt-raw index f6cb8ce72..c313980b0 100644 --- a/misc/requirements/requirements-pyinstaller.txt-raw +++ b/misc/requirements/requirements-pyinstaller.txt-raw @@ -1,4 +1 @@ --e git+https://github.com/pyinstaller/pyinstaller.git@develop#egg=PyInstaller - -# remove @commit-id for scm installs -#@ replace: @.*# @develop# +PyInstaller diff --git a/misc/requirements/requirements-pylint.txt b/misc/requirements/requirements-pylint.txt index 8c1055df6..3c7440627 100644 --- a/misc/requirements/requirements-pylint.txt +++ b/misc/requirements/requirements-pylint.txt @@ -2,13 +2,13 @@ astroid==2.3.3 # rq.filter: < 2.4 certifi==2020.6.20 -cffi==1.14.1 +cffi==1.14.2 chardet==3.0.4 cryptography==3.0 github3.py==1.3.0 idna==2.10 isort==4.3.21 -jwcrypto==0.7 +jwcrypto==0.8 lazy-object-proxy==1.4.3 mccabe==0.6.1 pycparser==2.20 diff --git a/misc/requirements/requirements-sphinx.txt b/misc/requirements/requirements-sphinx.txt index 08c5d57c8..da6447009 100644 --- a/misc/requirements/requirements-sphinx.txt +++ b/misc/requirements/requirements-sphinx.txt @@ -16,7 +16,7 @@ pytz==2020.1 requests==2.24.0 six==1.15.0 snowballstemmer==2.0.0 -Sphinx==3.1.2 +Sphinx==3.2.1 sphinxcontrib-applehelp==1.0.2 sphinxcontrib-devhelp==1.0.2 sphinxcontrib-htmlhelp==1.0.3 diff --git a/misc/requirements/requirements-tests.txt b/misc/requirements/requirements-tests.txt index 1411c984e..a82ba796c 100644 --- a/misc/requirements/requirements-tests.txt +++ b/misc/requirements/requirements-tests.txt @@ -1,20 +1,20 @@ # This file is automatically generated by scripts/dev/recompile_requirements.py -attrs==19.3.0 +attrs==20.1.0 beautifulsoup4==4.9.1 certifi==2020.6.20 chardet==3.0.4 -cheroot==8.4.2 +cheroot==8.4.5 click==7.1.2 # colorama==0.4.3 coverage==5.2.1 EasyProcess==0.3 Flask==1.1.2 glob2==0.7 -hunter==3.1.3 -hypothesis==5.23.7 +hunter==3.2.1 +hypothesis==5.29.0 idna==2.10 -iniconfig==1.0.0 +iniconfig==1.0.1 itsdangerous==1.1.0 jaraco.functools==3.0.1 ; python_version>="3.6" # Jinja2==2.11.2 @@ -33,9 +33,9 @@ pyparsing==2.4.7 pytest==6.0.1 pytest-bdd==3.4.0 pytest-benchmark==3.2.3 -pytest-cov==2.10.0 +pytest-cov==2.10.1 pytest-instafail==0.4.2 -pytest-mock==3.2.0 +pytest-mock==3.3.0 pytest-qt==3.3.0 pytest-repeat==0.8.0 pytest-rerunfailures==9.0 @@ -46,9 +46,10 @@ requests-file==1.5.1 six==1.15.0 sortedcontainers==2.2.2 soupsieve==2.0.1 -tldextract==2.2.2 +tldextract==2.2.3 toml==0.10.1 urllib3==1.25.10 -vulture==1.6 +vulture==2.1 ; python_version>="3.6" Werkzeug==1.0.1 -jaraco.functools==2.0; python_version<"3.6" # rq.filter: <= 2.0 +jaraco.functools==2.0; python_version<"3.6" +vulture==1.6; python_version<"3.6" diff --git a/misc/requirements/requirements-tests.txt-raw b/misc/requirements/requirements-tests.txt-raw index 779078021..f063a3512 100644 --- a/misc/requirements/requirements-tests.txt-raw +++ b/misc/requirements/requirements-tests.txt-raw @@ -30,5 +30,9 @@ PyVirtualDisplay tldextract #@ markers: jaraco.functools python_version>="3.6" -#@ add: jaraco.functools==2.0; python_version<"3.6" # rq.filter: <= 2.0 +#@ add: jaraco.functools==2.0; python_version<"3.6" + +#@ markers: vulture python_version>="3.6" +#@ add: vulture==1.6; python_version<"3.6" + #@ ignore: Jinja2, MarkupSafe, colorama diff --git a/misc/requirements/requirements-tox.txt b/misc/requirements/requirements-tox.txt index 21b252930..3fb7595ad 100644 --- a/misc/requirements/requirements-tox.txt +++ b/misc/requirements/requirements-tox.txt @@ -9,7 +9,7 @@ py==1.9.0 pyparsing==2.4.7 six==1.15.0 toml==0.10.1 -tox==3.18.1 +tox==3.19.0 tox-pip-version==0.0.7 tox-venv==0.4.0 -virtualenv==20.0.28 +virtualenv==20.0.31 diff --git a/misc/requirements/requirements-vulture.txt b/misc/requirements/requirements-vulture.txt index 112606543..70848d8ef 100644 --- a/misc/requirements/requirements-vulture.txt +++ b/misc/requirements/requirements-vulture.txt @@ -1,3 +1,4 @@ # This file is automatically generated by scripts/dev/recompile_requirements.py -vulture==1.6 +toml==0.10.1 +vulture==2.1 diff --git a/misc/userscripts/qute-pass b/misc/userscripts/qute-pass index c624503c4..9d078e94f 100755 --- a/misc/userscripts/qute-pass +++ b/misc/userscripts/qute-pass @@ -27,7 +27,7 @@ for example: "github.com/cryzed" or "websites/github.com". How the username and password are determined is freely configurable using the CLI arguments. As an example, if you instead store the username as part of the secret (and use a site's name as filename), instead of the default configuration, use -`--username-target secret` and `--username-regex "username: (.+)"`. +`--username-target secret` and `--username-pattern "username: (.+)"`. The login information is inserted by emulating key events using qutebrowser's fake-key command in this manner: [USERNAME]<Tab>[PASSWORD], which is compatible diff --git a/misc/userscripts/readability-js b/misc/userscripts/readability-js index 6a1a96c84..4b9446ed5 100755 --- a/misc/userscripts/readability-js +++ b/misc/userscripts/readability-js @@ -1,27 +1,26 @@ #!/usr/bin/env node -// +// // # Description -// +// // Summarize the current page in a new tab, by processing it with the standalone readability // library used for Firefox Reader View. -// +// // # Prerequisites -// -// - NODE_PATH might be required to point to your global node libraries: +// +// - Setting NODE_PATH might be required to point qutebrowser to your global node libraries: // export NODE_PATH=$NODE_PATH:$(npm root -g) -// - Mozilla's readability library (npm install -g https://github.com/mozilla/readability.git) -// NOTE: You might have to *login* as root for a system-wide installation to work (e.g. sudo -s) +// - Mozilla's readability library (npm install -g @mozilla/readability) // - jsdom (npm install -g jsdom) // - qutejs (npm install -g qutejs) -// +// // # Usage -// +// // :spawn --userscript readability-js -// -// One may wish to define an easy to type command alias in Qutebrowser's configuration file: +// +// One may wish to define an easy to type command alias in qutebrowser's configuration file: // c.aliases = {"readability" : "spawn --userscript readability-js", ...} -const Readability = require('readability'); +const { Readability } = require('@mozilla/readability'); const qute = require('qutejs'); const JSDOM = require('jsdom').JSDOM; const fs = require('fs'); diff --git a/pytest.ini b/pytest.ini index a034a27b3..1235efb4b 100644 --- a/pytest.ini +++ b/pytest.ini @@ -38,6 +38,7 @@ markers = unicode_locale: Tests which need an unicode locale to work qtwebkit6021_xfail: Tests which would fail on WebKit version 602.1 js_headers: Sets JS headers dynamically on QtWebEngine (unsupported on some versions) + qtwebkit_pdf_imageformat_skip: Broken on QtWebKit with PDF image format plugin installed qt_log_level_fail = WARNING qt_log_ignore = ^SpellCheck: .* diff --git a/qutebrowser/app.py b/qutebrowser/app.py index 55131ce7d..20459b890 100644 --- a/qutebrowser/app.py +++ b/qutebrowser/app.py @@ -485,8 +485,7 @@ def _init_modules(*, args): cache.init(q_app) log.init.debug("Initializing downloads...") - download_manager = qtnetworkdownloads.DownloadManager(parent=q_app) - objreg.register('qtnetwork-download-manager', download_manager) + qtnetworkdownloads.init() log.init.debug("Initializing Greasemonkey...") greasemonkey.init() diff --git a/qutebrowser/browser/browsertab.py b/qutebrowser/browser/browsertab.py index 05553a122..b7b2f3d91 100644 --- a/qutebrowser/browser/browsertab.py +++ b/qutebrowser/browser/browsertab.py @@ -72,9 +72,11 @@ def create(win_id: int, if objects.backend == usertypes.Backend.QtWebEngine: from qutebrowser.browser.webengine import webenginetab tab_class = webenginetab.WebEngineTab # type: typing.Type[AbstractTab] - else: + elif objects.backend == usertypes.Backend.QtWebKit: from qutebrowser.browser.webkit import webkittab tab_class = webkittab.WebKitTab + else: + raise utils.Unreachable(objects.backend) return tab_class(win_id=win_id, mode_manager=mode_manager, private=private, parent=parent) @@ -84,6 +86,8 @@ def init() -> None: if objects.backend == usertypes.Backend.QtWebEngine: from qutebrowser.browser.webengine import webenginetab webenginetab.init() + return + assert objects.backend == usertypes.Backend.QtWebKit, objects.backend class WebTabError(Exception): diff --git a/qutebrowser/browser/commands.py b/qutebrowser/browser/commands.py index 3a0468ada..ff18b5408 100644 --- a/qutebrowser/browser/commands.py +++ b/qutebrowser/browser/commands.py @@ -170,7 +170,7 @@ class CommandDispatcher: elif mode == "stack-next": tab = tab_deque.next(cur_tab) else: - raise NotImplementedError( + raise utils.Unreachable( "Missing implementation for stack mode!") except IndexError: if not show_error: @@ -562,7 +562,7 @@ class CommandDispatcher: @cmdutils.register(instance='command-dispatcher', scope='window') @cmdutils.argument('where', choices=['prev', 'next', 'up', 'increment', - 'decrement']) + 'decrement', 'strip']) @cmdutils.argument('count', value=cmdutils.Value.count) def navigate(self, where: str, tab: bool = False, bg: bool = False, window: bool = False, count: int = 1) -> None: @@ -587,6 +587,7 @@ class CommandDispatcher: Uses the link:settings{outsuffix}#url.incdec_segments[url.incdec_segments] config option. + - `strip`: Strip query and fragment from the current URL. tab: Open in a new tab. bg: Open in a background tab. @@ -613,9 +614,7 @@ class CommandDispatcher: handler = handlers[where] handler(browsertab=widget, win_id=self._win_id, baseurl=url, tab=tab, background=bg, window=window) - elif where in ['up', 'increment', 'decrement']: - if where == 'up': - url = url.adjusted(QUrl.RemoveFragment | QUrl.RemoveQuery) + elif where in ['up', 'increment', 'decrement', 'strip']: new_url = handlers[where](url, count) self._open(new_url, tab, bg, window, related=True) else: # pragma: no cover @@ -1627,9 +1626,31 @@ class CommandDispatcher: tab.search.prev_result() tab.search.prev_result(result_cb=cb) + def _jseval_cb(self, out): + """Show the data returned from JS.""" + if out is None: + # Getting the actual error (if any) seems to be difficult. + # The error does end up in + # BrowserPage.javaScriptConsoleMessage(), but + # distinguishing between :jseval errors and errors from the + # webpage is not trivial... + message.info('No output or error') + else: + # The output can be a string, number, dict, array, etc. But + # *don't* output too much data, as this will make + # qutebrowser hang + out = str(out) + if len(out) > 5000: + out = out[:5000] + ' [...trimmed...]' + message.info(out) + @cmdutils.register(instance='command-dispatcher', scope='window', maxsplit=0, no_cmd_split=True) - def jseval(self, js_code: str, file: bool = False, quiet: bool = False, *, + def jseval(self, js_code: str, + file: bool = False, + url: bool = False, + quiet: bool = False, + *, world: typing.Union[usertypes.JsWorld, int] = None) -> None: """Evaluate a JavaScript string. @@ -1639,33 +1660,16 @@ class CommandDispatcher: If the path is relative, the file is searched in a js/ subdir in qutebrowser's data dir, e.g. `~/.local/share/qutebrowser/js`. + url: Interpret js-code as a `javascript:...` URL. quiet: Don't show resulting JS object. world: Ignored on QtWebKit. On QtWebEngine, a world ID or name to run the snippet in. """ + cmdutils.check_exclusive((file, url), 'fu') + if world is None: world = usertypes.JsWorld.jseval - - if quiet: - jseval_cb = None - else: - def jseval_cb(out): - """Show the data returned from JS.""" - if out is None: - # Getting the actual error (if any) seems to be difficult. - # The error does end up in - # BrowserPage.javaScriptConsoleMessage(), but - # distinguishing between :jseval errors and errors from the - # webpage is not trivial... - message.info('No output or error') - else: - # The output can be a string, number, dict, array, etc. But - # *don't* output too much data, as this will make - # qutebrowser hang - out = str(out) - if len(out) > 5000: - out = out[:5000] + ' [...trimmed...]' - message.info(out) + jseval_cb = None if quiet else self._jseval_cb if file: path = os.path.expanduser(js_code) @@ -1677,6 +1681,11 @@ class CommandDispatcher: js_code = f.read() except OSError as e: raise cmdutils.CommandError(str(e)) + elif url: + try: + js_code = urlutils.parse_javascript_url(QUrl(js_code)) + except urlutils.Error as e: + raise cmdutils.CommandError(str(e)) widget = self._current_widget() try: diff --git a/qutebrowser/browser/downloads.py b/qutebrowser/browser/downloads.py index a02918495..3c3932c5f 100644 --- a/qutebrowser/browser/downloads.py +++ b/qutebrowser/browser/downloads.py @@ -427,6 +427,7 @@ class AbstractDownloadItem(QObject): raw_headers: The headers sent by the server. _filename: The filename of the download. _dead: Whether the Download has _die()'d. + _manager: The DownloadManager which started this download. Signals: data_changed: The downloads metadata changed. @@ -448,8 +449,9 @@ class AbstractDownloadItem(QObject): remove_requested = pyqtSignal() pdfjs_requested = pyqtSignal(str, QUrl) - def __init__(self, parent=None): + def __init__(self, manager, parent=None): super().__init__(parent) + self._manager = manager self.done = False self.stats = DownloadItemStats(self) self.index = 0 @@ -651,7 +653,7 @@ class AbstractDownloadItem(QObject): """Finish initialization based on self._filename.""" raise NotImplementedError - def _ask_confirm_question(self, title, msg): + def _ask_confirm_question(self, title, msg, *, custom_yes_action=None): """Ask a confirmation question for the download.""" raise NotImplementedError @@ -746,7 +748,13 @@ class AbstractDownloadItem(QObject): last_used_directory = os.path.dirname(self._filename) log.downloads.debug("Setting filename to {}".format(self._filename)) - if force_overwrite: + if self._get_conflicting_download(): + txt = ("<b>{}</b> is already downloading. Cancel and " + "re-download?".format(html.escape(self._filename))) + self._ask_confirm_question( + "Cancel other download?", txt, + custom_yes_action=self._cancel_conflicting_download) + elif force_overwrite: self._after_set_filename() elif os.path.isfile(self._filename): # The file already exists, so ask the user if it should be @@ -763,6 +771,28 @@ class AbstractDownloadItem(QObject): else: self._after_set_filename() + def _conflicts_with(self, other: 'AbstractDownloadItem') -> bool: + """Check if this download conflicts with the other given one.""" + return ( + other is not self and + other._filename == self._filename and # pylint: disable=protected-access + not other.done + ) + + def _get_conflicting_download(self): + """Return another potential active download with the same name.""" + for download in self._manager.downloads: + if self._conflicts_with(download): + return download + return None + + def _cancel_conflicting_download(self): + """Cancel any conflicting download and call _after_set_filename.""" + conflicting_download = self._get_conflicting_download() + if conflicting_download: + conflicting_download.cancel(remove_data=False) + self._after_set_filename() + def _open_if_successful(self, cmdline): """Open the downloaded file, but only if it was successful. @@ -947,6 +977,12 @@ class AbstractDownloadManager(QObject): download.cancelled.connect(question.abort) download.error.connect(question.abort) + @pyqtSlot() + def shutdown(self): + """Cancel all downloads when shutting down.""" + for download in self.downloads: + download.cancel(remove_data=False) + class DownloadModel(QAbstractListModel): diff --git a/qutebrowser/browser/eventfilter.py b/qutebrowser/browser/eventfilter.py index 002949a2b..78ca67cd8 100644 --- a/qutebrowser/browser/eventfilter.py +++ b/qutebrowser/browser/eventfilter.py @@ -27,45 +27,29 @@ from qutebrowser.misc import objects from qutebrowser.keyinput import modeman -class ChildEventFilter(QObject): - - """An event filter re-adding TabEventFilter on ChildEvent. - - This is needed because QtWebEngine likes to randomly change its - focusProxy... +class FocusWorkaroundEventFilter(QObject): - FIXME:qtwebengine Add a test for this happening + """An event filter working Qt 5.11 keyboard focus issues. - Attributes: - _filter: The event filter to install. - _widget: The widget expected to send out childEvents. - _win_id: The window this ChildEventFilter lives in. - _focus_workaround: Whether to enable a workaround for QTBUG-68076. + WORKAROUND for https://bugreports.qt.io/browse/QTBUG-68076 """ - def __init__(self, *, eventfilter, win_id, focus_workaround=False, - widget=None, parent=None): + def __init__(self, win_id, widget, parent=None): super().__init__(parent) - self._filter = eventfilter - self._widget = widget self._win_id = win_id - self._focus_workaround = focus_workaround - if focus_workaround: - assert widget is not None - - def _do_focus_workaround(self): - """WORKAROUND for https://bugreports.qt.io/browse/QTBUG-68076.""" - if not self._focus_workaround: - return + self._widget = widget - assert self._widget is not None + def eventFilter(self, _obj, event): + """Act on ChildAdded events.""" + if event.type() != QEvent.ChildAdded: + return False pass_modes = [usertypes.KeyMode.command, usertypes.KeyMode.prompt, usertypes.KeyMode.yesno] if modeman.instance(self._win_id).mode in pass_modes: - return + return False tabbed_browser = objreg.get('tabbed-browser', scope='window', window=self._win_id) @@ -77,6 +61,28 @@ class ChildEventFilter(QObject): if current_index == widget_index: QTimer.singleShot(0, self._widget.setFocus) + return False + + +class ChildEventFilter(QObject): + + """An event filter re-adding TabEventFilter on ChildEvent. + + This is needed because QtWebEngine likes to randomly change its + focusProxy... + + FIXME:qtwebengine Add a test for this happening + + Attributes: + _filter: The event filter to install. + _widget: The widget expected to send out childEvents. + """ + + def __init__(self, *, eventfilter, widget=None, parent=None): + super().__init__(parent) + self._filter = eventfilter + self._widget = widget + def eventFilter(self, obj, event): """Act on ChildAdded events.""" if event.type() == QEvent.ChildAdded: @@ -89,7 +95,6 @@ class ChildEventFilter(QObject): assert obj is self._widget child.installEventFilter(self._filter) - self._do_focus_workaround() elif event.type() == QEvent.ChildRemoved: child = event.child() log.misc.debug("{}: removed child {}".format(obj, child)) diff --git a/qutebrowser/browser/greasemonkey.py b/qutebrowser/browser/greasemonkey.py index 6d99a3568..89d720682 100644 --- a/qutebrowser/browser/greasemonkey.py +++ b/qutebrowser/browser/greasemonkey.py @@ -136,6 +136,7 @@ class GreasemonkeyScript: those by forcing them to use document-end instead. """ if objects.backend != usertypes.Backend.QtWebEngine: + assert objects.backend == usertypes.Backend.QtWebKit, objects.backend return False elif not qtutils.version_check('5.12', compiled=False): return False diff --git a/qutebrowser/browser/history.py b/qutebrowser/browser/history.py index cf0c1a59a..b7221dc15 100644 --- a/qutebrowser/browser/history.py +++ b/qutebrowser/browser/history.py @@ -401,3 +401,5 @@ def init(parent=None): if objects.backend == usertypes.Backend.QtWebKit: # pragma: no cover from qutebrowser.browser.webkit import webkithistory webkithistory.init(web_history) + return + assert objects.backend == usertypes.Backend.QtWebEngine, objects.backend diff --git a/qutebrowser/browser/inspector.py b/qutebrowser/browser/inspector.py index c13ebc90c..390762ae0 100644 --- a/qutebrowser/browser/inspector.py +++ b/qutebrowser/browser/inspector.py @@ -30,7 +30,7 @@ from PyQt5.QtGui import QCloseEvent from qutebrowser.browser import eventfilter from qutebrowser.config import configfiles -from qutebrowser.utils import log, usertypes +from qutebrowser.utils import log, usertypes, utils from qutebrowser.keyinput import modeman from qutebrowser.misc import miscwidgets, objects @@ -55,9 +55,10 @@ def create(*, splitter: 'miscwidgets.InspectorSplitter', else: return webengineinspector.LegacyWebEngineInspector( splitter, win_id, parent) - else: + elif objects.backend == usertypes.Backend.QtWebKit: from qutebrowser.browser.webkit import webkitinspector return webkitinspector.WebKitInspector(splitter, win_id, parent) + raise utils.Unreachable(objects.backend) class Position(enum.Enum): @@ -91,15 +92,12 @@ class _EventFilter(QObject): the QWebInspector. """ - def __init__(self, win_id: int, parent: QObject) -> None: - super().__init__(parent) - self._win_id = win_id + clicked = pyqtSignal() def eventFilter(self, _obj: QObject, event: QEvent) -> bool: - """Enter insert mode if the inspector is clicked.""" + """Translate mouse presses to a clicked signal.""" if event.type() == QEvent.MouseButtonPress: - modeman.enter(self._win_id, usertypes.KeyMode.insert, - reason='Inspector clicked', only_if_normal=True) + self.clicked.emit() return False @@ -125,10 +123,12 @@ class AbstractWebInspector(QWidget): self._layout = miscwidgets.WrapperLayout(self) self._splitter = splitter self._position = None # type: typing.Optional[Position] - self._event_filter = _EventFilter(win_id, parent=self) + self._win_id = win_id + + self._event_filter = _EventFilter(parent=self) + self._event_filter.clicked.connect(self._on_clicked) self._child_event_filter = eventfilter.ChildEventFilter( eventfilter=self._event_filter, - win_id=win_id, parent=self) def _set_widget(self, widget: QWidget) -> None: @@ -156,6 +156,13 @@ class AbstractWebInspector(QWidget): """ return False + @pyqtSlot() + def _on_clicked(self) -> None: + """Enter insert mode if a docked inspector was clicked.""" + if self._position != Position.window: + modeman.enter(self._win_id, usertypes.KeyMode.insert, + reason='Inspector clicked', only_if_normal=True) + def set_position(self, position: typing.Optional[Position]) -> None: """Set the position of the inspector. diff --git a/qutebrowser/browser/navigate.py b/qutebrowser/browser/navigate.py index 11be02c67..194246344 100644 --- a/qutebrowser/browser/navigate.py +++ b/qutebrowser/browser/navigate.py @@ -132,6 +132,8 @@ def path_up(url, count): url: The current url. count: The number of levels to go up in the url. """ + urlutils.ensure_valid(url) + url = url.adjusted(QUrl.RemoveFragment | QUrl.RemoveQuery) path = url.path() if not path or path == '/': raise Error("Can't go up!") @@ -142,6 +144,14 @@ def path_up(url, count): return url +def strip(url, count): + """Strip fragment/query from a URL.""" + if count != 1: + raise Error("Count is not supported when stripping URL components") + urlutils.ensure_valid(url) + return url.adjusted(QUrl.RemoveFragment | QUrl.RemoveQuery) + + def _find_prevnext(prev, elems): """Find a prev/next element in the given list of elements.""" # First check for <link rel="prev(ious)|next"> diff --git a/qutebrowser/browser/network/proxy.py b/qutebrowser/browser/network/proxy.py index 18d2f060b..770c26aad 100644 --- a/qutebrowser/browser/network/proxy.py +++ b/qutebrowser/browser/network/proxy.py @@ -23,7 +23,7 @@ from PyQt5.QtCore import QUrl, pyqtSlot from PyQt5.QtNetwork import QNetworkProxy, QNetworkProxyFactory from qutebrowser.config import config, configtypes -from qutebrowser.utils import message, usertypes, urlutils +from qutebrowser.utils import message, usertypes, urlutils, utils from qutebrowser.misc import objects from qutebrowser.browser.network import pac @@ -105,8 +105,10 @@ class ProxyFactory(QNetworkProxyFactory): proxy = urlutils.proxy_from_url(QUrl('direct://')) assert not isinstance(proxy, pac.PACFetcher) proxies = [proxy] - else: + elif objects.backend == usertypes.Backend.QtWebKit: proxies = proxy.resolve(query) + else: + raise utils.Unreachable(objects.backend) else: proxies = [proxy] for proxy in proxies: diff --git a/qutebrowser/browser/qtnetworkdownloads.py b/qutebrowser/browser/qtnetworkdownloads.py index 0b14ab50a..0bafeeaf9 100644 --- a/qutebrowser/browser/qtnetworkdownloads.py +++ b/qutebrowser/browser/qtnetworkdownloads.py @@ -27,10 +27,12 @@ import typing import attr from PyQt5.QtCore import pyqtSlot, pyqtSignal, QTimer, QUrl +from PyQt5.QtWidgets import QApplication from PyQt5.QtNetwork import QNetworkRequest, QNetworkReply from qutebrowser.config import config, websettings -from qutebrowser.utils import message, usertypes, log, urlutils, utils, debug +from qutebrowser.utils import message, usertypes, log, urlutils, utils, debug, objreg +from qutebrowser.misc import quitter from qutebrowser.browser import downloads from qutebrowser.browser.webkit import http from qutebrowser.browser.webkit.network import networkmanager @@ -70,7 +72,6 @@ class DownloadItem(downloads.AbstractDownloadItem): target file. _read_timer: A Timer which reads the QNetworkReply into self._buffer periodically. - _manager: The DownloadManager which started this download _reply: The QNetworkReply associated with this download. _autoclose: Whether to close the associated file when the download is done. @@ -90,12 +91,11 @@ class DownloadItem(downloads.AbstractDownloadItem): Args: reply: The QNetworkReply to download. """ - super().__init__(parent=manager) + super().__init__(manager=manager, parent=manager) self.fileobj = None # type: typing.Optional[typing.IO[bytes]] self.raw_headers = {} # type: typing.Dict[bytes, bytes] self._autoclose = True - self._manager = manager self._retry_info = None self._reply = None self._buffer = io.BytesIO() @@ -206,11 +206,11 @@ class DownloadItem(downloads.AbstractDownloadItem): def _after_set_filename(self): self._create_fileobj() - def _ask_confirm_question(self, title, msg): + def _ask_confirm_question(self, title, msg, *, custom_yes_action=None): + yes_action = custom_yes_action or self._after_set_filename no_action = functools.partial(self.cancel, remove_data=False) url = 'file://{}'.format(self._filename) - message.confirm_async(title=title, text=msg, - yes_action=self._after_set_filename, + message.confirm_async(title=title, text=msg, yes_action=yes_action, no_action=no_action, cancel_action=no_action, abort_on=[self.cancelled, self.error], url=url) @@ -578,3 +578,10 @@ class DownloadManager(downloads.AbstractDownloadManager): if download._uses_nam(nam): # pylint: disable=protected-access nam.adopt_download(download) return nam.adopted_downloads + + +def init(): + """Initialize the global QtNetwork download manager.""" + download_manager = DownloadManager(parent=QApplication.instance()) + objreg.register('qtnetwork-download-manager', download_manager) + quitter.instance.shutting_down.connect(download_manager.shutdown) diff --git a/qutebrowser/browser/webengine/webenginedownloads.py b/qutebrowser/browser/webengine/webenginedownloads.py index 80266a1c8..16c7f020a 100644 --- a/qutebrowser/browser/webengine/webenginedownloads.py +++ b/qutebrowser/browser/webengine/webenginedownloads.py @@ -28,7 +28,7 @@ from PyQt5.QtCore import pyqtSlot, Qt, QUrl, QObject from PyQt5.QtWebEngineWidgets import QWebEngineDownloadItem from qutebrowser.browser import downloads, pdfjs -from qutebrowser.utils import debug, usertypes, message, log, qtutils +from qutebrowser.utils import debug, usertypes, message, log, qtutils, objreg class DownloadItem(downloads.AbstractDownloadItem): @@ -40,8 +40,9 @@ class DownloadItem(downloads.AbstractDownloadItem): """ def __init__(self, qt_item: QWebEngineDownloadItem, + manager: downloads.AbstractDownloadManager, parent: QObject = None) -> None: - super().__init__(parent) + super().__init__(manager=manager, parent=manager) self._qt_item = qt_item qt_item.downloadProgress.connect( # type: ignore[attr-defined] self.stats.on_download_progress) @@ -140,14 +141,15 @@ class DownloadItem(downloads.AbstractDownloadItem): "state {} (not in requested state)!".format( filename, self, state_name)) - def _ask_confirm_question(self, title, msg): + def _ask_confirm_question(self, title, msg, *, custom_yes_action=None): + yes_action = custom_yes_action or self._after_set_filename no_action = functools.partial(self.cancel, remove_data=False) question = usertypes.Question() question.title = title question.text = msg question.url = 'file://{}'.format(self._filename) question.mode = usertypes.PromptMode.yesno - question.answered_yes.connect(self._after_set_filename) + question.answered_yes.connect(yes_action) question.answered_no.connect(no_action) question.cancelled.connect(no_action) self.cancelled.connect(question.abort) @@ -185,6 +187,26 @@ class DownloadItem(downloads.AbstractDownloadItem): self._qt_item.accept() + def _get_conflicting_download(self): + """Return another potential active download with the same name. + + webenginedownloads.DownloadItem needs to look for downloads both in its + manager and in qtnetwork-download-manager as both are used + simultaneously. + + This method can be safely removed once #2328 is fixed. + """ + conflicting_download = super()._get_conflicting_download() + if conflicting_download: + return conflicting_download + + qtnetwork_download_manager = objreg.get( + 'qtnetwork-download-manager') + for download in qtnetwork_download_manager.downloads: + if self._conflicts_with(download): + return download + return None + def _get_suggested_filename(path): """Convert a path we got from chromium to a suggested filename. @@ -244,7 +266,7 @@ class DownloadManager(downloads.AbstractDownloadManager): suggested_filename = _get_suggested_filename(qt_item.path()) use_pdfjs = pdfjs.should_use_pdfjs(qt_item.mimeType(), qt_item.url()) - download = DownloadItem(qt_item) + download = DownloadItem(qt_item, manager=self) self._init_item(download, auto_remove=use_pdfjs, suggested_filename=suggested_filename) diff --git a/qutebrowser/browser/webengine/webenginesettings.py b/qutebrowser/browser/webengine/webenginesettings.py index ad22c7d62..f5f4e9c31 100644 --- a/qutebrowser/browser/webengine/webenginesettings.py +++ b/qutebrowser/browser/webengine/webenginesettings.py @@ -55,45 +55,45 @@ class _SettingsWrapper: For read operations, the default profile value is always used. """ - def __init__(self): - self._settings = [default_profile.settings()] + def _settings(self): + yield default_profile.settings() if private_profile: - self._settings.append(private_profile.settings()) + yield private_profile.settings() def setAttribute(self, attribute, on): - for settings in self._settings: + for settings in self._settings(): settings.setAttribute(attribute, on) def setFontFamily(self, which, family): - for settings in self._settings: + for settings in self._settings(): settings.setFontFamily(which, family) def setFontSize(self, fonttype, size): - for settings in self._settings: + for settings in self._settings(): settings.setFontSize(fonttype, size) def setDefaultTextEncoding(self, encoding): - for settings in self._settings: + for settings in self._settings(): settings.setDefaultTextEncoding(encoding) def setUnknownUrlSchemePolicy(self, policy): - for settings in self._settings: + for settings in self._settings(): settings.setUnknownUrlSchemePolicy(policy) def testAttribute(self, attribute): - return self._settings[0].testAttribute(attribute) + return default_profile.settings().testAttribute(attribute) def fontSize(self, fonttype): - return self._settings[0].fontSize(fonttype) + return default_profile.settings().fontSize(fonttype) def fontFamily(self, which): - return self._settings[0].fontFamily(which) + return default_profile.settings().fontFamily(which) def defaultTextEncoding(self): - return self._settings[0].defaultTextEncoding() + return default_profile.settings().defaultTextEncoding() def unknownUrlSchemePolicy(self): - return self._settings[0].unknownUrlSchemePolicy() + return default_profile.settings().unknownUrlSchemePolicy() class WebEngineSettings(websettings.AbstractSettings): @@ -360,9 +360,9 @@ def init_user_agent(): _init_user_agent_str(QWebEngineProfile.defaultProfile().httpUserAgent()) -def _init_profiles(): - """Init the two used QWebEngineProfiles.""" - global default_profile, private_profile +def _init_default_profile(): + """Init the default QWebEngineProfile.""" + global default_profile default_profile = QWebEngineProfile.defaultProfile() init_user_agent() @@ -376,6 +376,11 @@ def _init_profiles(): default_profile.setter.init_profile() default_profile.setter.set_persistent_cookie_policy() + +def init_private_profile(): + """Init the private QWebEngineProfile.""" + global private_profile + if not qtutils.is_single_process(): private_profile = QWebEngineProfile() private_profile.setter = ProfileSetter( # type: ignore[attr-defined] @@ -450,7 +455,8 @@ def init(args): webenginequtescheme.init() spell.init() - _init_profiles() + _init_default_profile() + init_private_profile() config.instance.changed.connect(_update_settings) global global_settings diff --git a/qutebrowser/browser/webengine/webenginetab.py b/qutebrowser/browser/webengine/webenginetab.py index fe4d37745..a139f3d2f 100644 --- a/qutebrowser/browser/webengine/webenginetab.py +++ b/qutebrowser/browser/webengine/webenginetab.py @@ -38,7 +38,7 @@ from qutebrowser.browser.webengine import (webview, webengineelem, tabhistory, interceptor, webenginequtescheme, cookies, webenginedownloads, webenginesettings, certificateerror) -from qutebrowser.misc import miscwidgets, objects +from qutebrowser.misc import miscwidgets, objects, quitter from qutebrowser.utils import (usertypes, qtutils, log, javascript, utils, message, objreg, jinja, debug) from qutebrowser.keyinput import modeman @@ -74,6 +74,7 @@ def init(): if webenginesettings.private_profile: download_manager.install(webenginesettings.private_profile) objreg.register('webengine-download-manager', download_manager) + quitter.instance.shutting_down.connect(download_manager.shutdown) log.init.debug("Initializing cookie filter...") cookies.install_filter(webenginesettings.default_profile) @@ -1392,15 +1393,20 @@ class WebEngineTab(browsertab.AbstractTab): fp = self._widget.focusProxy() if fp is not None: fp.installEventFilter(self._tab_event_filter) + self._child_event_filter = eventfilter.ChildEventFilter( eventfilter=self._tab_event_filter, widget=self._widget, - win_id=self.win_id, - focus_workaround=qtutils.version_check( - '5.11', compiled=False, exact=True), parent=self) self._widget.installEventFilter(self._child_event_filter) + if qtutils.version_check('5.11', compiled=False, exact=True): + focus_event_filter = eventfilter.FocusWorkaroundEventFilter( + win_id=self.win_id, + widget=self._widget, + parent=self) + self._widget.installEventFilter(focus_event_filter) + @pyqtSlot() def _restore_zoom(self): if sip.isdeleted(self._widget): diff --git a/qutebrowser/browser/webkit/webkittab.py b/qutebrowser/browser/webkit/webkittab.py index 7a2addc04..cad9badee 100644 --- a/qutebrowser/browser/webkit/webkittab.py +++ b/qutebrowser/browser/webkit/webkittab.py @@ -55,6 +55,24 @@ class WebKitAction(browsertab.AbstractAction): def show_source(self, pygments=False): self._show_source_pygments() + def run_string(self, name: str) -> None: + """Add special cases for new API. + + Those were added to QtWebKit 5.212 (which we enforce), but we don't get + the new API from PyQt. Thus, we'll need to use the raw numbers. + """ + new_actions = { + # https://github.com/qtwebkit/qtwebkit/commit/a96d9ef5d24b02d996ad14ff050d0e485c9ddc97 + 'RequestClose': QWebPage.ToggleVideoFullscreen + 1, + # https://github.com/qtwebkit/qtwebkit/commit/96b9ba6269a5be44343635a7aaca4a153ea0366b + 'Unselect': QWebPage.ToggleVideoFullscreen + 2, + } + if name in new_actions: + self._widget.triggerPageAction(new_actions[name]) + return + + super().run_string(name) + class WebKitPrinting(browsertab.AbstractPrinting): diff --git a/qutebrowser/completion/completiondelegate.py b/qutebrowser/completion/completiondelegate.py index 11e7c96d8..a8e58b8a2 100644 --- a/qutebrowser/completion/completiondelegate.py +++ b/qutebrowser/completion/completiondelegate.py @@ -26,7 +26,7 @@ import re import html from PyQt5.QtWidgets import QStyle, QStyleOptionViewItem, QStyledItemDelegate -from PyQt5.QtCore import QRectF, QSize, Qt +from PyQt5.QtCore import QRectF, QRegularExpression, QSize, Qt from PyQt5.QtGui import (QIcon, QPalette, QTextDocument, QTextOption, QAbstractTextDocumentLayout, QSyntaxHighlighter, QTextCharFormat) @@ -41,14 +41,23 @@ class _Highlighter(QSyntaxHighlighter): super().__init__(doc) self._format = QTextCharFormat() self._format.setForeground(color) - self._pattern = pattern + words = pattern.split() + words.sort(key=len, reverse=True) + pat = "|".join(re.escape(word) for word in words) + self._expression = QRegularExpression( + pat, QRegularExpression.CaseInsensitiveOption + ) def highlightBlock(self, text): """Override highlightBlock for custom highlighting.""" - for match in re.finditer(self._pattern, text, re.IGNORECASE): - start, end = match.span() - length = end - start - self.setFormat(start, length, self._format) + match_iterator = self._expression.globalMatch(text) + while match_iterator.hasNext(): + match = match_iterator.next() + self.setFormat( + match.capturedStart(), + match.capturedLength(), + self._format + ) class CompletionItemDelegate(QStyledItemDelegate): @@ -226,12 +235,11 @@ class CompletionItemDelegate(QStyledItemDelegate): pattern = view.pattern columns_to_filter = index.model().columns_to_filter(index) if index.column() in columns_to_filter and pattern: - pat = re.escape(pattern).replace(r'\ ', r'|') if self._opt.state & QStyle.State_Selected: color = config.val.colors.completion.item.selected.match.fg else: color = config.val.colors.completion.match.fg - _Highlighter(self._doc, pat, color) + _Highlighter(self._doc, pattern, color) self._doc.setPlainText(self._opt.text) else: self._doc.setHtml( diff --git a/qutebrowser/completion/completionwidget.py b/qutebrowser/completion/completionwidget.py index b4f565d77..50d5bdf62 100644 --- a/qutebrowser/completion/completionwidget.py +++ b/qutebrowser/completion/completionwidget.py @@ -205,6 +205,49 @@ class CompletionView(QTreeView): raise utils.Unreachable + def _next_page(self, upwards): + """Return the index a page away from the selected index. + + Args: + upwards: Get previous item, not next. + + Return: + A QModelIndex. + """ + old_idx = self.selectionModel().currentIndex() + idx = old_idx + model = self.model() + + if not idx.isValid(): + # No item selected yet + return model.last_item() if upwards else model.first_item() + + # Find height of each CompletionView element + element_height = self.visualRect(idx).height() + page_length = self.height() // element_height + + # Skip one pageful, except leave one old line visible + offset = -(page_length - 1) if upwards else page_length - 1 + idx = model.sibling(old_idx.row() + offset, old_idx.column(), old_idx) + + # Skip category headers + while idx.isValid() and not idx.parent().isValid(): + idx = self.indexAbove(idx) if upwards else self.indexBelow(idx) + + if idx.isValid(): + return idx + + border_item = model.first_item() if upwards else model.last_item() + + # Wrap around if we were already at the beginning/end + if old_idx == border_item: + return self._next_idx(upwards) + + # Select the first/last item before wrapping around + if upwards: + self.scrollTo(border_item.parent()) + return border_item + def _next_category_idx(self, upwards): """Get the index of the previous/next category. @@ -238,14 +281,17 @@ class CompletionView(QTreeView): @cmdutils.register(instance='completion', modes=[usertypes.KeyMode.command], scope='window') - @cmdutils.argument('which', choices=['next', 'prev', 'next-category', - 'prev-category']) + @cmdutils.argument('which', choices=['next', 'prev', + 'next-category', 'prev-category', + 'next-page', 'prev-page']) @cmdutils.argument('history', flag='H') def completion_item_focus(self, which, history=False): """Shift the focus of the completion menu to another item. Args: - which: 'next', 'prev', 'next-category', or 'prev-category'. + which: 'next', 'prev', + 'next-category', 'prev-category', + 'next-page', or 'prev-page'. history: Navigate through command history if no text was typed. """ if history: @@ -266,12 +312,14 @@ class CompletionView(QTreeView): selmodel = self.selectionModel() indices = { - 'next': self._next_idx(upwards=False), - 'prev': self._next_idx(upwards=True), - 'next-category': self._next_category_idx(upwards=False), - 'prev-category': self._next_category_idx(upwards=True), + 'next': lambda: self._next_idx(upwards=False), + 'prev': lambda: self._next_idx(upwards=True), + 'next-category': lambda: self._next_category_idx(upwards=False), + 'prev-category': lambda: self._next_category_idx(upwards=True), + 'next-page': lambda: self._next_page(upwards=False), + 'prev-page': lambda: self._next_page(upwards=True), } - idx = indices[which] + idx = indices[which]() if not idx.isValid(): return diff --git a/qutebrowser/config/configdata.yml b/qutebrowser/config/configdata.yml index b0e060ceb..2d3ac9d42 100644 --- a/qutebrowser/config/configdata.yml +++ b/qutebrowser/config/configdata.yml @@ -3320,6 +3320,8 @@ bindings.default: <Tab>: completion-item-focus next <Ctrl-Tab>: completion-item-focus next-category <Ctrl-Shift-Tab>: completion-item-focus prev-category + <PgDown>: completion-item-focus next-page + <PgUp>: completion-item-focus prev-page <Ctrl-D>: completion-item-del <Shift-Delete>: completion-item-del <Ctrl-C>: completion-item-yank diff --git a/qutebrowser/config/qtargs.py b/qutebrowser/config/qtargs.py index 418ae7140..0c517a14c 100644 --- a/qutebrowser/config/qtargs.py +++ b/qutebrowser/config/qtargs.py @@ -50,6 +50,7 @@ def qt_args(namespace: argparse.Namespace) -> typing.List[str]: argv += ['--' + arg for arg in config.val.qt.args] if objects.backend != usertypes.Backend.QtWebEngine: + assert objects.backend == usertypes.Backend.QtWebKit, objects.backend return argv feature_flags = [flag for flag in argv @@ -307,6 +308,8 @@ def init_envvars() -> None: os.environ['QT_QUICK_BACKEND'] = 'software' elif software_rendering == 'chromium': os.environ['QT_WEBENGINE_DISABLE_NOUVEAU_WORKAROUND'] = '1' + else: + assert objects.backend == usertypes.Backend.QtWebKit, objects.backend if config.val.qt.force_platform is not None: os.environ['QT_QPA_PLATFORM'] = config.val.qt.force_platform diff --git a/qutebrowser/config/websettings.py b/qutebrowser/config/websettings.py index 007758254..cc307dd75 100644 --- a/qutebrowser/config/websettings.py +++ b/qutebrowser/config/websettings.py @@ -30,7 +30,7 @@ from PyQt5.QtGui import QFont import qutebrowser from qutebrowser.config import config -from qutebrowser.utils import log, usertypes, urlmatch, qtutils +from qutebrowser.utils import log, usertypes, urlmatch, qtutils, utils from qutebrowser.misc import objects, debugcachestats UNSET = object() @@ -269,9 +269,11 @@ def init(args: argparse.Namespace) -> None: if objects.backend == usertypes.Backend.QtWebEngine: from qutebrowser.browser.webengine import webenginesettings webenginesettings.init(args) - else: + elif objects.backend == usertypes.Backend.QtWebKit: from qutebrowser.browser.webkit import webkitsettings webkitsettings.init(args) + else: + raise utils.Unreachable(objects.backend) # Make sure special URLs always get JS support for pattern in ['chrome://*/*', 'qute://*/*']: @@ -280,12 +282,27 @@ def init(args: argparse.Namespace) -> None: hide_userconfig=True) +def clear_private_data() -> None: + """Clear cookies, cache and related data for private browsing sessions.""" + if objects.backend == usertypes.Backend.QtWebEngine: + from qutebrowser.browser.webengine import webenginesettings + webenginesettings.init_private_profile() + elif objects.backend == usertypes.Backend.QtWebKit: + from qutebrowser.browser.webkit import cookies + assert cookies.ram_cookie_jar is not None + cookies.ram_cookie_jar.setAllCookies([]) + else: + raise utils.Unreachable(objects.backend) + + @pyqtSlot() def shutdown() -> None: """Shut down QWeb(Engine)Settings.""" if objects.backend == usertypes.Backend.QtWebEngine: from qutebrowser.browser.webengine import webenginesettings webenginesettings.shutdown() - else: + elif objects.backend == usertypes.Backend.QtWebKit: from qutebrowser.browser.webkit import webkitsettings webkitsettings.shutdown() + else: + raise utils.Unreachable(objects.backend) diff --git a/qutebrowser/javascript/whatsapp_web_quirk.user.js b/qutebrowser/javascript/whatsapp_web_quirk.user.js index b8979d15e..801d300e1 100644 --- a/qutebrowser/javascript/whatsapp_web_quirk.user.js +++ b/qutebrowser/javascript/whatsapp_web_quirk.user.js @@ -9,7 +9,9 @@ if (document.querySelector("a[href='https://support.google.com/chrome/answer/95414']")) { navigator.serviceWorker.getRegistration().then((registration) => { - registration.unregister(); + if (registration) { + registration.unregister(); + } document.location.reload(); }); } diff --git a/qutebrowser/mainwindow/mainwindow.py b/qutebrowser/mainwindow/mainwindow.py index 89c0f4417..faccdc73c 100644 --- a/qutebrowser/mainwindow/mainwindow.py +++ b/qutebrowser/mainwindow/mainwindow.py @@ -32,7 +32,7 @@ from PyQt5.QtGui import QPalette from qutebrowser.commands import runners from qutebrowser.api import cmdutils -from qutebrowser.config import config, configfiles, stylesheet +from qutebrowser.config import config, configfiles, stylesheet, websettings from qutebrowser.utils import (message, log, usertypes, qtutils, objreg, utils, jinja, debug) from qutebrowser.mainwindow import messageview, prompt @@ -231,10 +231,10 @@ class MainWindow(QWidget): self._downloadview = downloadview.DownloadView( model=self._download_model) - self._private = config.val.content.private_browsing or private + self.is_private = config.val.content.private_browsing or private self.tabbed_browser = tabbedbrowser.TabbedBrowser( - win_id=self.win_id, private=self._private, parent=self + win_id=self.win_id, private=self.is_private, parent=self ) # type: tabbedbrowser.TabbedBrowser objreg.register('tabbed-browser', self.tabbed_browser, scope='window', window=self.win_id) @@ -243,7 +243,8 @@ class MainWindow(QWidget): # We need to set an explicit parent for StatusBar because it does some # show/hide magic immediately which would mean it'd show up as a # window. - self.status = bar.StatusBar(win_id=self.win_id, private=self._private, + self.status = bar.StatusBar(win_id=self.win_id, + private=self.is_private, parent=self) self._add_widgets() @@ -310,12 +311,17 @@ class MainWindow(QWidget): if not widget.isVisible(): return - size_hint = widget.sizeHint() if widget.sizePolicy().horizontalPolicy() == QSizePolicy.Expanding: width = self.width() - 2 * padding + if widget.hasHeightForWidth(): + height = widget.heightForWidth(width) + else: + height = widget.sizeHint().height() left = padding else: + size_hint = widget.sizeHint() width = min(size_hint.width(), self.width() - 2 * padding) + height = size_hint.height() left = (self.width() - width) // 2 if centered else 0 height_padding = 20 @@ -327,7 +333,7 @@ class MainWindow(QWidget): else: status_height = 0 bottom = self.height() - top = self.height() - status_height - size_hint.height() + top = self.height() - status_height - height top = qtutils.check_overflow(top, 'int', fatal=False) topleft = QPoint(left, max(height_padding, top)) bottomright = QPoint(left + width, bottom) @@ -339,7 +345,7 @@ class MainWindow(QWidget): status_height = 0 top = 0 topleft = QPoint(left, top) - bottom = status_height + size_hint.height() + bottom = status_height + height bottom = qtutils.check_overflow(bottom, 'int', fatal=False) bottomright = QPoint(left + width, min(self.height() - height_padding, bottom)) @@ -674,15 +680,28 @@ class MainWindow(QWidget): e.accept() - try: - last_visible = objreg.get('last-visible-main-window') - if self is last_visible: - objreg.delete('last-visible-main-window') - except KeyError: - pass + for key in ['last-visible-main-window', 'last-focused-main-window']: + try: + win = objreg.get(key) + if self is win: + objreg.delete(key) + except KeyError: + pass sessions.session_manager.save_last_window_session() self._save_geometry() + # Wipe private data if we close the last private window, but there are + # still other windows + if ( + self.is_private and + len(objreg.window_registry) > 1 and + len([window for window in objreg.window_registry.values() + if window.is_private]) == 1 + ): + log.destroy.debug("Wiping private data before closing last " + "private window") + websettings.clear_private_data() + log.destroy.debug("Closing window {}".format(self.win_id)) self.tabbed_browser.shutdown() diff --git a/qutebrowser/mainwindow/messageview.py b/qutebrowser/mainwindow/messageview.py index 2a20447ab..1f6295d89 100644 --- a/qutebrowser/mainwindow/messageview.py +++ b/qutebrowser/mainwindow/messageview.py @@ -21,7 +21,7 @@ import typing -from PyQt5.QtCore import pyqtSlot, pyqtSignal, QTimer, Qt, QSize +from PyQt5.QtCore import pyqtSlot, pyqtSignal, QTimer, Qt from PyQt5.QtWidgets import QWidget, QVBoxLayout, QLabel, QSizePolicy from qutebrowser.config import config, stylesheet @@ -36,6 +36,7 @@ class Message(QLabel): super().__init__(text, parent) self.replace = replace self.setAttribute(Qt.WA_StyledBackground, True) + self.setWordWrap(True) qss = """ padding-top: 2px; padding-bottom: 2px; @@ -64,8 +65,6 @@ class Message(QLabel): """ else: # pragma: no cover raise ValueError("Invalid level {!r}".format(level)) - # We don't bother with set_register_stylesheet here as it's short-lived - # anyways. stylesheet.set_register(self, qss, update=False) @@ -89,12 +88,6 @@ class MessageView(QWidget): self._last_text = None - def sizeHint(self): - """Get the proposed height for the view.""" - height = sum(label.sizeHint().height() for label in self._messages) - # The width isn't really relevant as we're expanding anyways. - return QSize(-1, height) - @config.change_filter('messages.timeout') def _set_clear_timer_interval(self): """Configure self._clear_timer according to the config.""" diff --git a/qutebrowser/mainwindow/windowundo.py b/qutebrowser/mainwindow/windowundo.py index d3939f310..4a4ea5d66 100644 --- a/qutebrowser/mainwindow/windowundo.py +++ b/qutebrowser/mainwindow/windowundo.py @@ -38,7 +38,6 @@ class _WindowUndoEntry: """Information needed for :undo -w.""" - private = attr.ib() geometry = attr.ib() tab_stack = attr.ib() @@ -60,9 +59,11 @@ class WindowUndoManager(QObject): self._update_undo_stack_size() def _on_window_closing(self, window): + if window.tabbed_browser.is_private: + return + self._undos.append(_WindowUndoEntry( geometry=window.saveGeometry(), - private=window.tabbed_browser.is_private, tab_stack=window.tabbed_browser.undo_stack, )) @@ -79,7 +80,7 @@ class WindowUndoManager(QObject): """ entry = self._undos.pop() window = mainwindow.MainWindow( - private=entry.private, + private=False, geometry=entry.geometry, ) window.show() diff --git a/qutebrowser/misc/editor.py b/qutebrowser/misc/editor.py index 1d474b380..17fbb9956 100644 --- a/qutebrowser/misc/editor.py +++ b/qutebrowser/misc/editor.py @@ -65,7 +65,9 @@ class ExternalEditor(QObject): def _cleanup(self): """Clean up temporary files after the editor closed.""" assert self._remove_file is not None - if self._watcher is not None and self._watcher.files(): + if (self._watcher is not None and + not sip.isdeleted(self._watcher) and + self._watcher.files()): failed = self._watcher.removePaths(self._watcher.files()) if failed: log.procs.error("Failed to unwatch paths: {}".format(failed)) diff --git a/qutebrowser/utils/qtutils.py b/qutebrowser/utils/qtutils.py index 63e11ff68..e853b38f8 100644 --- a/qutebrowser/utils/qtutils.py +++ b/qutebrowser/utils/qtutils.py @@ -124,6 +124,7 @@ def is_single_process() -> bool: """Check whether QtWebEngine is running in single-process mode.""" if objects.backend == usertypes.Backend.QtWebKit: return False + assert objects.backend == usertypes.Backend.QtWebEngine, objects.backend args = QApplication.instance().arguments() return '--single-process' in args diff --git a/qutebrowser/utils/urlutils.py b/qutebrowser/utils/urlutils.py index 761853a18..a691b2cbc 100644 --- a/qutebrowser/utils/urlutils.py +++ b/qutebrowser/utils/urlutils.py @@ -55,7 +55,12 @@ WEBENGINE_SCHEMES = [ ] -class InvalidUrlError(Exception): +class Error(Exception): + + """Base class for errors in this module.""" + + +class InvalidUrlError(Error): """Error raised if a function got an invalid URL.""" @@ -624,3 +629,28 @@ def proxy_from_url(url: QUrl) -> typing.Union[QNetworkProxy, pac.PACFetcher]: if url.password(): proxy.setPassword(url.password()) return proxy + + +def parse_javascript_url(url: QUrl) -> str: + """Get JavaScript source from the given URL. + + See https://wiki.whatwg.org/wiki/URL_schemes#javascript:_URLs + and https://github.com/whatwg/url/issues/385 + """ + ensure_valid(url) + if url.scheme() != 'javascript': + raise Error("Expected a javascript:... URL") + if url.authority(): + raise Error("URL contains unexpected components: {}" + .format(url.authority())) + + code = url.path(QUrl.FullyDecoded) + if url.hasQuery(): + code += '?' + url.query(QUrl.FullyDecoded) + if url.hasFragment(): + code += '#' + url.fragment(QUrl.FullyDecoded) + + if not code: + raise Error("Resulted in empty JavaScript code") + + return code diff --git a/qutebrowser/utils/version.py b/qutebrowser/utils/version.py index 0be1c1a29..476926d34 100644 --- a/qutebrowser/utils/version.py +++ b/qutebrowser/utils/version.py @@ -337,16 +337,14 @@ def _pdfjs_version() -> str: else: pdfjs_file = pdfjs_file.decode('utf-8') version_re = re.compile( - r"^ *(PDFJS\.version|var pdfjsVersion) = '([^']+)';$", + r"^ *(PDFJS\.version|(var|const) pdfjsVersion) = '(?P<version>[^']+)';$", re.MULTILINE) match = version_re.search(pdfjs_file) - if not match: - pdfjs_version = 'unknown' - else: - pdfjs_version = match.group(2) + pdfjs_version = 'unknown' if not match else match.group('version') if file_path is None: file_path = 'bundled' + return '{} ({})'.format(pdfjs_version, file_path) @@ -360,6 +358,7 @@ def _chromium_version() -> str: Qt 5.7: Chromium 49 49.0.2623.111 (2016-03-31) + 5.7.0: Security fixes from Chromium 50 and 51 5.7.1: Security fixes up to 54.0.2840.87 (2016-11-01) Qt 5.8: Chromium 53 @@ -368,34 +367,64 @@ def _chromium_version() -> str: Qt 5.9: Chromium 56 (LTS) 56.0.2924.122 (2017-01-25) + 5.9.0: Security fixes up to 56.0.2924.122 (?) + 5.9.1: Security fixes up to 59.0.3071.104 (2017-06-15) + 5.9.2: Security fixes up to 61.0.3163.79 (2017-09-05) + 5.9.3: Security fixes up to 62.0.3202.89 (2017-11-06) + 5.9.4: Security fixes up to 63.0.3239.132 (~2017-12-14) + 5.9.5: Security fixes up to 65.0.3325.146 (~2018-03-13) + 5.9.6: Security fixes up to 66.0.3359.170 (2018-05-10) + 5.9.7: Security fixes up to 69.0.3497.113 (~2018-09-11) + 5.9.8: Security fixes up to 72.0.3626.121 (2019-03-01) 5.9.9: Security fixes up to 78.0.3904.108 (2019-11-18) Qt 5.10: Chromium 61 61.0.3163.140 (2017-09-05) + 5.10.0: Security fixes up to 62.0.3202.94 (2017-11-13) 5.10.1: Security fixes up to 64.0.3282.140 (2018-02-01) Qt 5.11: Chromium 65 - 65.0.3325.151 (.1: .230) (2018-03-06) + 65.0.3325.151 (2018-03-06) + 5.11.0: Security fixes up to 66.0.3359.139 (2018-04-26) + 5.11.1: Updated to 65.0.3325.15.230 + Security fixes up to 67.0.3396.87 (2018-06-12) + 5.11.2: Security fixes up to 68.0.3440.75 (~2018-07-31) 5.11.3: Security fixes up to 70.0.3538.102 (2018-11-09) Qt 5.12: Chromium 69 - (LTS) 69.0.3497.113 (2018-09-27) - 5.12.9: Security fixes up to 83.0.4103.97 (2020-06-03) + (LTS) 69.0.3497.128 (~2018-09-11) + 5.12.0: Security fixes up to 70.0.3538.102 (~2018-10-24) + 5.12.1: Security fixes up to 71.0.3578.94 (2018-12-12) + 5.12.2: Security fixes up to 72.0.3626.121 (2019-03-01) + 5.12.3: Security fixes up to 73.0.3683.75 (2019-03-12) + 5.12.4: Security fixes up to 74.0.3729.157 (2019-05-14) + 5.12.5: Security fixes up to 76.0.3809.87 (2019-07-30) + 5.12.6: Security fixes up to 77.0.3865.120 (~2019-09-10) + 5.12.7: Security fixes up to 79.0.3945.130 (2020-01-16) + 5.12.8: Security fixes up to 80.0.3987.149 (2020-03-18) + 5.12.9: Security fixes up to 83.0.4103.97 (2020-06-03) Qt 5.13: Chromium 73 73.0.3683.105 (~2019-02-28) + 5.13.0: Security fixes up to 74.0.3729.157 (2019-05-14) + 5.13.1: Security fixes up to 76.0.3809.87 (2019-07-30) 5.13.2: Security fixes up to 77.0.3865.120 (2019-10-10) Qt 5.14: Chromium 77 77.0.3865.129 (~2019-10-10) + 5.14.0: Security fixes up to 77.0.3865.129 (~2019-09-10) + 5.14.1: Security fixes up to 79.0.3945.117 (2020-01-07) 5.14.2: Security fixes up to 80.0.3987.132 (2020-03-03) Qt 5.15: Chromium 80 80.0.3987.163 (2020-04-02) 5.15.0: Security fixes up to 81.0.4044.138 (2020-05-05) - Also see https://www.chromium.org/developers/calendar - and https://chromereleases.googleblog.com/ + Also see: + + - https://chromiumdash.appspot.com/schedule + - https://www.chromium.org/developers/calendar + - https://chromereleases.googleblog.com/ """ if webenginesettings is None: return 'unavailable' # type: ignore[unreachable] @@ -411,10 +440,11 @@ def _backend() -> str: """Get the backend line with relevant information.""" if objects.backend == usertypes.Backend.QtWebKit: return 'new QtWebKit (WebKit {})'.format(qWebKitVersion()) - else: + elif objects.backend == usertypes.Backend.QtWebEngine: webengine = usertypes.Backend.QtWebEngine assert objects.backend == webengine, objects.backend return 'QtWebEngine (Chromium {})'.format(_chromium_version()) + raise utils.Unreachable(objects.backend) def _uptime() -> datetime.timedelta: diff --git a/requirements.txt b/requirements.txt index d00782fb6..741b8903d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,7 @@ # This file is automatically generated by scripts/dev/recompile_requirements.py adblock==0.3.0 -attrs==19.3.0 +attrs==20.1.0 colorama==0.4.3 cssutils==1.0.2 Jinja2==2.11.2 diff --git a/scripts/dev/build_pyqt_wheel.py b/scripts/dev/build_pyqt_wheel.py index 9a36c8129..aa3fe9322 100644 --- a/scripts/dev/build_pyqt_wheel.py +++ b/scripts/dev/build_pyqt_wheel.py @@ -86,7 +86,9 @@ def main(): for wheel in input_files: utils.print_subtitle(wheel.stem.split('-')[0]) subprocess.run([str(pyqt_bundle), - '--qt-dir', args.qt_location, str(wheel)], + '--qt-dir', args.qt_location, + '--ignore-missing', + str(wheel)], check=True) wheel.unlink() diff --git a/scripts/dev/check_coverage.py b/scripts/dev/check_coverage.py index 12963de38..313aa13e3 100644 --- a/scripts/dev/check_coverage.py +++ b/scripts/dev/check_coverage.py @@ -59,170 +59,170 @@ MsgType = enum.Enum('MsgType', 'insufficient_coverage, perfect_file') # A list of (test_file, tested_file) tuples. test_file can be None. PERFECT_FILES = [ (None, - 'commands/cmdexc.py'), + 'qutebrowser/commands/cmdexc.py'), ('tests/unit/commands/test_argparser.py', - 'commands/argparser.py'), + 'qutebrowser/commands/argparser.py'), ('tests/unit/api/test_cmdutils.py', - 'api/cmdutils.py'), + 'qutebrowser/api/cmdutils.py'), (None, - 'api/apitypes.py'), + 'qutebrowser/api/apitypes.py'), (None, - 'api/config.py'), + 'qutebrowser/api/config.py'), (None, - 'api/message.py'), + 'qutebrowser/api/message.py'), (None, - 'api/qtutils.py'), + 'qutebrowser/api/qtutils.py'), ('tests/unit/browser/webkit/test_cache.py', - 'browser/webkit/cache.py'), + 'qutebrowser/browser/webkit/cache.py'), ('tests/unit/browser/webkit/test_cookies.py', - 'browser/webkit/cookies.py'), + 'qutebrowser/browser/webkit/cookies.py'), ('tests/unit/browser/test_history.py', - 'browser/history.py'), + 'qutebrowser/browser/history.py'), ('tests/unit/browser/test_pdfjs.py', - 'browser/pdfjs.py'), + 'qutebrowser/browser/pdfjs.py'), ('tests/unit/browser/webkit/http/test_http.py', - 'browser/webkit/http.py'), + 'qutebrowser/browser/webkit/http.py'), ('tests/unit/browser/webkit/http/test_content_disposition.py', - 'browser/webkit/rfc6266.py'), + 'qutebrowser/browser/webkit/rfc6266.py'), # ('tests/unit/browser/webkit/test_webkitelem.py', - # 'browser/webkit/webkitelem.py'), + # 'qutebrowser/browser/webkit/webkitelem.py'), # ('tests/unit/browser/webkit/test_webkitelem.py', - # 'browser/webelem.py'), + # 'qutebrowser/browser/webelem.py'), ('tests/unit/browser/webkit/network/test_filescheme.py', - 'browser/webkit/network/filescheme.py'), + 'qutebrowser/browser/webkit/network/filescheme.py'), ('tests/unit/browser/webkit/network/test_networkreply.py', - 'browser/webkit/network/networkreply.py'), + 'qutebrowser/browser/webkit/network/networkreply.py'), ('tests/unit/browser/test_signalfilter.py', - 'browser/signalfilter.py'), + 'qutebrowser/browser/signalfilter.py'), (None, - 'browser/webengine/certificateerror.py'), + 'qutebrowser/browser/webengine/certificateerror.py'), # ('tests/unit/browser/test_tab.py', - # 'browser/tab.py'), + # 'qutebrowser/browser/tab.py'), ('tests/unit/keyinput/test_basekeyparser.py', - 'keyinput/basekeyparser.py'), + 'qutebrowser/keyinput/basekeyparser.py'), ('tests/unit/keyinput/test_keyutils.py', - 'keyinput/keyutils.py'), + 'qutebrowser/keyinput/keyutils.py'), ('tests/unit/components/test_readlinecommands.py', - 'components/readlinecommands.py'), + 'qutebrowser/components/readlinecommands.py'), ('tests/unit/misc/test_autoupdate.py', - 'misc/autoupdate.py'), + 'qutebrowser/misc/autoupdate.py'), ('tests/unit/misc/test_split.py', - 'misc/split.py'), + 'qutebrowser/misc/split.py'), ('tests/unit/misc/test_msgbox.py', - 'misc/msgbox.py'), + 'qutebrowser/misc/msgbox.py'), ('tests/unit/misc/test_checkpyver.py', - 'misc/checkpyver.py'), + 'qutebrowser/misc/checkpyver.py'), ('tests/unit/misc/test_guiprocess.py', - 'misc/guiprocess.py'), + 'qutebrowser/misc/guiprocess.py'), ('tests/unit/misc/test_editor.py', - 'misc/editor.py'), + 'qutebrowser/misc/editor.py'), ('tests/unit/misc/test_cmdhistory.py', - 'misc/cmdhistory.py'), + 'qutebrowser/misc/cmdhistory.py'), ('tests/unit/misc/test_ipc.py', - 'misc/ipc.py'), + 'qutebrowser/misc/ipc.py'), ('tests/unit/misc/test_keyhints.py', - 'misc/keyhintwidget.py'), + 'qutebrowser/misc/keyhintwidget.py'), ('tests/unit/misc/test_pastebin.py', - 'misc/pastebin.py'), + 'qutebrowser/misc/pastebin.py'), ('tests/unit/misc/test_objects.py', - 'misc/objects.py'), + 'qutebrowser/misc/objects.py'), ('tests/unit/misc/test_throttle.py', - 'misc/throttle.py'), + 'qutebrowser/misc/throttle.py'), (None, - 'mainwindow/statusbar/keystring.py'), + 'qutebrowser/mainwindow/statusbar/keystring.py'), ('tests/unit/mainwindow/statusbar/test_percentage.py', - 'mainwindow/statusbar/percentage.py'), + 'qutebrowser/mainwindow/statusbar/percentage.py'), ('tests/unit/mainwindow/statusbar/test_progress.py', - 'mainwindow/statusbar/progress.py'), + 'qutebrowser/mainwindow/statusbar/progress.py'), ('tests/unit/mainwindow/statusbar/test_tabindex.py', - 'mainwindow/statusbar/tabindex.py'), + 'qutebrowser/mainwindow/statusbar/tabindex.py'), ('tests/unit/mainwindow/statusbar/test_textbase.py', - 'mainwindow/statusbar/textbase.py'), + 'qutebrowser/mainwindow/statusbar/textbase.py'), ('tests/unit/mainwindow/statusbar/test_url.py', - 'mainwindow/statusbar/url.py'), + 'qutebrowser/mainwindow/statusbar/url.py'), ('tests/unit/mainwindow/statusbar/test_backforward.py', - 'mainwindow/statusbar/backforward.py'), + 'qutebrowser/mainwindow/statusbar/backforward.py'), ('tests/unit/mainwindow/test_messageview.py', - 'mainwindow/messageview.py'), + 'qutebrowser/mainwindow/messageview.py'), ('tests/unit/config/test_config.py', - 'config/config.py'), + 'qutebrowser/config/config.py'), ('tests/unit/config/test_stylesheet.py', - 'config/stylesheet.py'), + 'qutebrowser/config/stylesheet.py'), ('tests/unit/config/test_configdata.py', - 'config/configdata.py'), + 'qutebrowser/config/configdata.py'), ('tests/unit/config/test_configexc.py', - 'config/configexc.py'), + 'qutebrowser/config/configexc.py'), ('tests/unit/config/test_configfiles.py', - 'config/configfiles.py'), + 'qutebrowser/config/configfiles.py'), ('tests/unit/config/test_configtypes.py', - 'config/configtypes.py'), + 'qutebrowser/config/configtypes.py'), ('tests/unit/config/test_configinit.py', - 'config/configinit.py'), + 'qutebrowser/config/configinit.py'), ('tests/unit/config/test_qtargs.py', - 'config/qtargs.py'), + 'qutebrowser/config/qtargs.py'), ('tests/unit/config/test_configcommands.py', - 'config/configcommands.py'), + 'qutebrowser/config/configcommands.py'), ('tests/unit/config/test_configutils.py', - 'config/configutils.py'), + 'qutebrowser/config/configutils.py'), ('tests/unit/config/test_configcache.py', - 'config/configcache.py'), + 'qutebrowser/config/configcache.py'), ('tests/unit/utils/test_qtutils.py', - 'utils/qtutils.py'), + 'qutebrowser/utils/qtutils.py'), ('tests/unit/utils/test_standarddir.py', - 'utils/standarddir.py'), + 'qutebrowser/utils/standarddir.py'), ('tests/unit/utils/test_urlutils.py', - 'utils/urlutils.py'), + 'qutebrowser/utils/urlutils.py'), ('tests/unit/utils/usertypes', - 'utils/usertypes.py'), + 'qutebrowser/utils/usertypes.py'), ('tests/unit/utils/test_utils.py', - 'utils/utils.py'), + 'qutebrowser/utils/utils.py'), ('tests/unit/utils/test_version.py', - 'utils/version.py'), + 'qutebrowser/utils/version.py'), ('tests/unit/utils/test_debug.py', - 'utils/debug.py'), + 'qutebrowser/utils/debug.py'), ('tests/unit/utils/test_jinja.py', - 'utils/jinja.py'), + 'qutebrowser/utils/jinja.py'), ('tests/unit/utils/test_error.py', - 'utils/error.py'), + 'qutebrowser/utils/error.py'), ('tests/unit/utils/test_javascript.py', - 'utils/javascript.py'), + 'qutebrowser/utils/javascript.py'), ('tests/unit/utils/test_urlmatch.py', - 'utils/urlmatch.py'), + 'qutebrowser/utils/urlmatch.py'), (None, - 'completion/models/util.py'), + 'qutebrowser/completion/models/util.py'), ('tests/unit/completion/test_models.py', - 'completion/models/urlmodel.py'), + 'qutebrowser/completion/models/urlmodel.py'), ('tests/unit/completion/test_models.py', - 'completion/models/configmodel.py'), + 'qutebrowser/completion/models/configmodel.py'), ('tests/unit/completion/test_histcategory.py', - 'completion/models/histcategory.py'), + 'qutebrowser/completion/models/histcategory.py'), ('tests/unit/completion/test_listcategory.py', - 'completion/models/listcategory.py'), + 'qutebrowser/completion/models/listcategory.py'), ('tests/unit/browser/webengine/test_spell.py', - 'browser/webengine/spell.py'), + 'qutebrowser/browser/webengine/spell.py'), ('tests/unit/browser/webengine/test_webengine_cookies.py', - 'browser/webengine/cookies.py'), + 'qutebrowser/browser/webengine/cookies.py'), ] # 100% coverage because of end2end tests, but no perfect unit tests yet. WHITELISTED_FILES = [ - 'browser/webkit/webkitinspector.py', - 'misc/debugcachestats.py', - 'keyinput/macros.py', - 'browser/webkit/webkitelem.py', - 'api/interceptor.py', + 'qutebrowser/browser/webkit/webkitinspector.py', + 'qutebrowser/misc/debugcachestats.py', + 'qutebrowser/keyinput/macros.py', + 'qutebrowser/browser/webkit/webkitelem.py', + 'qutebrowser/api/interceptor.py', ] @@ -243,8 +243,6 @@ def _get_filename(filename): common_path = os.path.commonprefix([basedir, filename]) if common_path: filename = filename[len(common_path):].lstrip('/') - if filename.startswith('qutebrowser/'): - filename = filename.split('/', maxsplit=1)[1] return filename @@ -295,8 +293,10 @@ def check(fileobj, perfect_files): filename, line_cov, branch_cov) messages.append(Message(MsgType.insufficient_coverage, filename, text)) - elif (filename not in perfect_src_files and not is_bad and - filename not in WHITELISTED_FILES): + elif (filename not in perfect_src_files and + not is_bad and + filename not in WHITELISTED_FILES and + not filename.startswith('tests/')): text = ("{} has 100% coverage but is not in " "perfect_files!".format(filename)) messages.append(Message(MsgType.perfect_file, filename, text)) @@ -320,7 +320,7 @@ def main_check(): for msg in messages: msg.show() print() - filters = ','.join('qutebrowser/' + msg.filename for msg in messages) + filters = ','.join(msg.filename for msg in messages) subprocess.run([sys.executable, '-m', 'coverage', 'report', '--show-missing', '--include', filters], check=True) print() diff --git a/scripts/dev/misc_checks.py b/scripts/dev/misc_checks.py index 366abc9ca..6bb3eb1ca 100644 --- a/scripts/dev/misc_checks.py +++ b/scripts/dev/misc_checks.py @@ -162,7 +162,7 @@ def check_userscripts_descriptions(): described.add(match.group(1)) present = {path.name for path in folder.iterdir()} - present.remove('README.md') + present -= {'README.md', '.mypy_cache', '__pycache__'} missing = present - described additional = described - present diff --git a/scripts/dev/recompile_requirements.py b/scripts/dev/recompile_requirements.py index 7474c56c9..e36d7ee1d 100644 --- a/scripts/dev/recompile_requirements.py +++ b/scripts/dev/recompile_requirements.py @@ -42,7 +42,7 @@ CHANGELOG_URLS = { 'cherrypy': 'https://github.com/cherrypy/cherrypy/blob/master/CHANGES.rst', 'pylint': 'http://pylint.pycqa.org/en/latest/whatsnew/changelog.html', 'setuptools': 'https://github.com/pypa/setuptools/blob/master/CHANGES.rst', - 'pytest-cov': 'https://github.com/pytest-dev/pytest-cov', + 'pytest-cov': 'https://github.com/pytest-dev/pytest-cov/blob/master/CHANGELOG.rst', 'requests': 'https://github.com/psf/requests/blob/master/HISTORY.md', 'requests-file': 'https://github.com/dashea/requests-file/blob/master/CHANGES.rst', 'werkzeug': 'https://github.com/pallets/werkzeug/blob/master/CHANGES.rst', @@ -110,6 +110,7 @@ CHANGELOG_URLS = { 'chardet': 'https://github.com/chardet/chardet/releases', 'idna': 'https://github.com/kjd/idna/blob/master/HISTORY.rst', 'tldextract': 'https://github.com/john-kurkowski/tldextract/blob/master/CHANGELOG.md', + 'typing_extensions': 'https://github.com/python/typing/commits/master/typing_extensions', } # PyQt versions which need SIP v4 diff --git a/scripts/dev/src2asciidoc.py b/scripts/dev/src2asciidoc.py index 70df0ebe0..aefbff7f8 100755 --- a/scripts/dev/src2asciidoc.py +++ b/scripts/dev/src2asciidoc.py @@ -429,7 +429,7 @@ def _generate_setting_option(f, opt): f.write("=== {}".format(opt.name) + "\n") f.write(opt.description + "\n") if opt.restart: - f.write("This setting requires a restart.\n") + f.write("\nThis setting requires a restart.\n") if opt.supports_pattern: f.write("\nThis setting supports URL patterns.\n") if opt.no_autoconfig: @@ -98,6 +98,7 @@ try: 'Programming Language :: Python :: 3.6', 'Programming Language :: Python :: 3.7', 'Programming Language :: Python :: 3.8', + 'Programming Language :: Python :: 3.9', 'Topic :: Internet', 'Topic :: Internet :: WWW/HTTP', 'Topic :: Internet :: WWW/HTTP :: Browsers', diff --git a/tests/end2end/conftest.py b/tests/end2end/conftest.py index f87b84a56..43105d6cb 100644 --- a/tests/end2end/conftest.py +++ b/tests/end2end/conftest.py @@ -28,9 +28,10 @@ import sys import shutil import pstats import operator +import pathlib import pytest -from PyQt5.QtCore import PYQT_VERSION +from PyQt5.QtCore import PYQT_VERSION, QCoreApplication pytest.register_assert_rewrite('end2end.fixtures') @@ -142,6 +143,9 @@ def pytest_collection_modifyitems(config, items): header_bug_fixed = (not qtutils.version_check('5.12', compiled=False) or qtutils.version_check('5.15', compiled=False)) + lib_path = pathlib.Path(QCoreApplication.libraryPaths()[0]) + qpdf_image_plugin = lib_path / 'imageformats' / 'libqpdf.so' + markers = [ ('qtwebengine_todo', 'QtWebEngine TODO', pytest.mark.xfail, config.webengine), @@ -160,6 +164,10 @@ def pytest_collection_modifyitems(config, items): ('js_headers', 'Sets headers dynamically via JS', pytest.mark.skipif, config.webengine and not header_bug_fixed), + ('qtwebkit_pdf_imageformat_skip', + 'Skipped with QtWebKit if PDF image plugin is available', + pytest.mark.skipif, + not config.webengine and qpdf_image_plugin.exists()), ] for item in items: diff --git a/tests/end2end/features/misc.feature b/tests/end2end/features/misc.feature index 33a6cb5aa..93a15cd62 100644 --- a/tests/end2end/features/misc.feature +++ b/tests/end2end/features/misc.feature @@ -89,6 +89,10 @@ Feature: Various utility commands. When I run :jseval Array(5002).join("x") Then the message "x* [...trimmed...]" should be shown + Scenario: :jseval --url + When I run :jseval --url javascript:console.log("hello world?") + Then the javascript message "hello world?" should be logged + @qtwebengine_skip Scenario: :jseval with --world on QtWebKit When I run :jseval --world=1 console.log("Hello from JS!"); diff --git a/tests/end2end/features/navigate.feature b/tests/end2end/features/navigate.feature index 2596f3ef1..ec295c4d1 100644 --- a/tests/end2end/features/navigate.feature +++ b/tests/end2end/features/navigate.feature @@ -4,25 +4,10 @@ Feature: Using :navigate Scenario: :navigate with invalid argument When I run :navigate foo - Then the error "where: Invalid value foo - expected one of: prev, next, up, increment, decrement" should be shown + Then the error "where: Invalid value foo - expected one of: prev, next, up, increment, decrement, strip" should be shown # up - Scenario: Navigating up - When I open data/navigate/sub - And I run :navigate up - Then data/navigate should be loaded - - Scenario: Navigating up with a query - When I open data/navigate/sub?foo=bar - And I run :navigate up - Then data/navigate should be loaded - - Scenario: Navigating up by count - When I open data/navigate/sub/index.html - And I run :navigate up with count 2 - Then data/navigate should be loaded - Scenario: Navigating up in qute://help/ When the documentation is up to date And I open qute://help/commands.html @@ -90,48 +75,6 @@ Feature: Using :navigate # increment/decrement - Scenario: Incrementing number in URL - When I open data/numbers/1.txt - And I run :navigate increment - Then data/numbers/2.txt should be loaded - - Scenario: Decrementing number in URL - When I open data/numbers/4.txt - And I run :navigate decrement - Then data/numbers/3.txt should be loaded - - Scenario: Decrementing with no number in URL - When I open data/navigate - And I run :navigate decrement - Then the error "No number found in URL!" should be shown - - Scenario: Incrementing with no number in URL - When I open data/navigate - And I run :navigate increment - Then the error "No number found in URL!" should be shown - - Scenario: Incrementing number in URL by count - When I open data/numbers/3.txt - And I run :navigate increment with count 3 - Then data/numbers/6.txt should be loaded - - Scenario: Decrementing number in URL by count - When I open data/numbers/8.txt - And I run :navigate decrement with count 5 - Then data/numbers/3.txt should be loaded - - Scenario: Setting url.incdec_segments - When I set url.incdec_segments to [anchor] - And I open data/numbers/1.txt - And I run :navigate increment - Then the error "No number found in URL!" should be shown - - Scenario: Incrementing query - When I set url.incdec_segments to ["query"] - And I open data/numbers/1.txt?value=2 - And I run :navigate increment - Then data/numbers/1.txt?value=3 should be loaded - @qtwebengine_todo: Doesn't find any elements Scenario: Navigating multiline links When I open data/navigate/multilinelinks.html diff --git a/tests/end2end/features/private.feature b/tests/end2end/features/private.feature index 6ea9e7b33..07ff225a3 100644 --- a/tests/end2end/features/private.feature +++ b/tests/end2end/features/private.feature @@ -42,6 +42,25 @@ Feature: Using private browsing ## https://github.com/qutebrowser/qutebrowser/issues/1219 + Scenario: Make sure private data is cleared when closing last private window + When I open about:blank in a private window + And I open cookies/set?cookie-to-delete=1 without waiting in a new tab + And I wait until cookies is loaded + And I run :close + And I open about:blank in a private window + And I open cookies + Then the cookie cookie-to-delete should not be set + + Scenario: Make sure private data is not cleared when closing a private window but another remains + When I open about:blank in a private window + And I open about:blank in a private window + And I open cookies/set?cookie-to-preserve=1 without waiting in a new tab + And I wait until cookies is loaded + And I run :close + And I open about:blank in a private window + And I open cookies + Then the cookie cookie-to-preserve should be set to 1 + Scenario: Sharing cookies with private browsing When I open cookies/set?qute-test=42 without waiting in a private window And I wait until cookies is loaded diff --git a/tests/end2end/features/qutescheme.feature b/tests/end2end/features/qutescheme.feature index 2325912c5..55e366b4f 100644 --- a/tests/end2end/features/qutescheme.feature +++ b/tests/end2end/features/qutescheme.feature @@ -177,6 +177,7 @@ Feature: Special qute:// pages And I open data/misc/test.pdf without waiting Then the javascript message "PDF * [*] (PDF.js: *)" should be logged + @qtwebkit_pdf_imageformat_skip Scenario: pdfjs is not used when disabled When I set content.pdfjs to false And I set downloads.location.prompt to false diff --git a/tests/end2end/features/scroll.feature b/tests/end2end/features/scroll.feature index 85223aee2..3aa3f0df4 100644 --- a/tests/end2end/features/scroll.feature +++ b/tests/end2end/features/scroll.feature @@ -106,6 +106,7 @@ Feature: Scrolling When I run :scroll bottom Then the page should be scrolled vertically + @flaky Scenario: Scrolling to bottom and to top When I run :scroll bottom And I wait until the scroll position changed @@ -219,6 +220,7 @@ Feature: Scrolling When I run :scroll-to-perc --horizontal Then the page should be scrolled horizontally + @flaky Scenario: :scroll-to-perc with count When I run :scroll-to-perc with count 50 Then the page should be scrolled vertically diff --git a/tests/end2end/fixtures/quteprocess.py b/tests/end2end/fixtures/quteprocess.py index 5fb095583..78fd0e48a 100644 --- a/tests/end2end/fixtures/quteprocess.py +++ b/tests/end2end/fixtures/quteprocess.py @@ -114,6 +114,12 @@ def is_ignored_lowlevel_message(message): '*/QtWebEngineProcess: /lib/x86_64-linux-gnu/libdbus-1.so.3: no ' 'version information available (required by ' '*/libQt5WebEngineCore.so.5)', + + # hunter and Python 3.9 + # https://github.com/ionelmc/python-hunter/issues/87 + '<frozen importlib._bootstrap>:*: RuntimeWarning: builtins.type size changed, ' + 'may indicate binary incompatibility. Expected 872 from C header, got 880 from ' + 'PyObject', ] return any(testutils.pattern_match(pattern=pattern, value=message) for pattern in ignored_messages) diff --git a/tests/end2end/test_insert_mode.py b/tests/end2end/test_insert_mode.py index a4508441a..eb27b27a6 100644 --- a/tests/end2end/test_insert_mode.py +++ b/tests/end2end/test_insert_mode.py @@ -58,6 +58,7 @@ def test_insert_mode(file_name, elem_id, source, input_text, zoom, (True, False, True), # enabled and foreground tab (True, True, False), # background tab ]) +@pytest.mark.flaky def test_auto_load(quteproc, auto_load, background, insert_mode): quteproc.set_setting('input.insert_mode.auto_load', str(auto_load)) url_path = 'data/insert_mode_settings/html/autofocus.html' diff --git a/tests/helpers/fixtures.py b/tests/helpers/fixtures.py index b62a488ce..015238d1b 100644 --- a/tests/helpers/fixtures.py +++ b/tests/helpers/fixtures.py @@ -215,6 +215,7 @@ def webkit_tab(web_tab_setup, qtbot, cookiejar_and_cache, mode_manager, tab = webkittab.WebKitTab(win_id=0, mode_manager=mode_manager, private=False) + tab.backend = usertypes.Backend.QtWebKit widget_container.set_widget(tab) yield tab @@ -238,6 +239,7 @@ def webengine_tab(web_tab_setup, qtbot, redirect_webengine_data, tab = webenginetab.WebEngineTab(win_id=0, mode_manager=mode_manager, private=False) + tab.backend = usertypes.Backend.QtWebEngine widget_container.set_widget(tab) yield tab diff --git a/tests/unit/browser/webkit/test_downloads.py b/tests/unit/browser/test_downloads.py index b3629b39d..4cd7b3727 100644 --- a/tests/unit/browser/webkit/test_downloads.py +++ b/tests/unit/browser/test_downloads.py @@ -22,10 +22,14 @@ import pytest from qutebrowser.browser import downloads, qtnetworkdownloads -def test_download_model(qapp, qtmodeltester, config_stub, cookiejar_and_cache, - fake_args): +@pytest.fixture +def manager(config_stub, cookiejar_and_cache): + """A QtNetwork download manager.""" + return qtnetworkdownloads.DownloadManager() + + +def test_download_model(qapp, qtmodeltester, manager): """Simple check for download model internals.""" - manager = qtnetworkdownloads.DownloadManager() model = downloads.DownloadModel(manager) qtmodeltester.check(model) @@ -107,7 +111,7 @@ def test_sanitized_filenames(raw, expected, config_stub, download_tmpdir, monkeypatch): manager = downloads.AbstractDownloadManager() target = downloads.FileDownloadTarget(str(download_tmpdir)) - item = downloads.AbstractDownloadItem() + item = downloads.AbstractDownloadItem(manager=manager) # Don't try to start a timer outside of a QThread manager._update_timer.isActive = lambda: True @@ -116,6 +120,58 @@ def test_sanitized_filenames(raw, expected, item._ensure_can_set_filename = lambda *args: True item._after_set_filename = lambda *args: True + # Don't try to get current window + monkeypatch.setattr(item, '_get_conflicting_download', list) + manager._init_item(item, True, raw) item.set_target(target) assert item._filename.endswith(expected) + + +class TestConflictingDownloads: + + @pytest.fixture + def item1(self, manager): + return downloads.AbstractDownloadItem(manager=manager) + + @pytest.fixture + def item2(self, manager): + return downloads.AbstractDownloadItem(manager=manager) + + def test_no_downloads(self, item1): + item1._filename = 'download.txt' + assert item1._get_conflicting_download() is None + + @pytest.mark.parametrize('filename1, filename2, done, conflict', [ + # Different name + ('download.txt', 'download2.txt', False, False), + # Finished + ('download.txt', 'download.txt', True, False), + # Conflict + ('download.txt', 'download.txt', False, True), + ]) + def test_conflicts(self, manager, item1, item2, + filename1, filename2, done, conflict): + item1._filename = filename1 + item2._filename = filename2 + item2.done = done + manager.downloads.append(item1) + manager.downloads.append(item2) + expected = item2 if conflict else None + assert item1._get_conflicting_download() is expected + + def test_cancel_conflicting_downloads(self, manager, item1, item2, monkeypatch): + item1._filename = 'download.txt' + item2._filename = 'download.txt' + item2.done = False + manager.downloads.append(item1) + manager.downloads.append(item2) + + def patched_cancel(remove_data=True): + assert not remove_data + item2.done = True + + monkeypatch.setattr(item2, 'cancel', patched_cancel) + monkeypatch.setattr(item1, '_after_set_filename', lambda: None) + item1._cancel_conflicting_download() + assert item2.done diff --git a/tests/unit/browser/test_navigate.py b/tests/unit/browser/test_navigate.py index efabc3040..5fe0acbf6 100644 --- a/tests/unit/browser/test_navigate.py +++ b/tests/unit/browser/test_navigate.py @@ -172,10 +172,55 @@ class TestIncDec: def test_invalid_url(self): with pytest.raises(urlutils.InvalidUrlError): - navigate.incdec(QUrl(""), 1, "increment") + navigate.incdec(QUrl(), 1, "increment") def test_wrong_mode(self): """Test if incdec rejects a wrong parameter for inc_or_dec.""" valid_url = QUrl("http://example.com/0") with pytest.raises(ValueError): navigate.incdec(valid_url, 1, "foobar") + + +class TestUp: + + @pytest.mark.parametrize('url_suffix, count, expected_suffix', [ + ('/one/two/three', 1, '/one/two'), + ('/one/two/three?foo=bar', 1, '/one/two'), + ('/one/two/three', 2, '/one'), + ]) + def test_up(self, url_suffix, count, expected_suffix): + url_base = 'https://example.com' + url = QUrl(url_base + url_suffix) + assert url.isValid() + + new = navigate.path_up(url, count) + assert new == QUrl(url_base + expected_suffix) + + def test_invalid_url(self): + with pytest.raises(urlutils.InvalidUrlError): + navigate.path_up(QUrl(), count=1) + + +class TestStrip: + + @pytest.mark.parametrize('url_suffix', [ + '?foo=bar', + '#label', + '?foo=bar#label', + ]) + def test_strip(self, url_suffix): + url_base = 'https://example.com/test' + url = QUrl(url_base + url_suffix) + assert url.isValid() + + stripped = navigate.strip(url, count=1) + assert stripped.isValid() + assert stripped == QUrl(url_base) + + def test_count(self): + with pytest.raises(navigate.Error, match='Count is not supported'): + navigate.strip(QUrl('https://example.com/'), count=2) + + def test_invalid_url(self): + with pytest.raises(urlutils.InvalidUrlError): + navigate.strip(QUrl(), count=1) diff --git a/tests/unit/browser/webkit/network/test_pac.py b/tests/unit/browser/webkit/network/test_pac.py index f33642ae3..1ad10cc3c 100644 --- a/tests/unit/browser/webkit/network/test_pac.py +++ b/tests/unit/browser/webkit/network/test_pac.py @@ -87,7 +87,7 @@ def _pac_noexcept_test(call): _pac_common_test(test_str_f.format(call)) -# pylint: disable=line-too-long, invalid-name +# pylint: disable=invalid-name @pytest.mark.parametrize("domain, expected", [ diff --git a/tests/unit/browser/webkit/test_webkitelem.py b/tests/unit/browser/webkit/test_webkitelem.py index cd8c088eb..37262a7b3 100644 --- a/tests/unit/browser/webkit/test_webkitelem.py +++ b/tests/unit/browser/webkit/test_webkitelem.py @@ -743,8 +743,8 @@ class TestGetChildFrames: def test_one_level(self, stubs): r"""Test get_child_frames with one level of children. - o parent - / \ + o parent + / \ ------ child1 o o child2 """ child1 = stubs.FakeChildrenFrame() @@ -763,9 +763,9 @@ class TestGetChildFrames: r"""Test get_child_frames with multiple levels of children. o root - / \ + / \ ------ o o first - /\ /\ + /\ /\ ------ o o o o second """ second = [stubs.FakeChildrenFrame() for _ in range(4)] diff --git a/tests/unit/completion/test_completiondelegate.py b/tests/unit/completion/test_completiondelegate.py index ac07e80dc..739b8b773 100644 --- a/tests/unit/completion/test_completiondelegate.py +++ b/tests/unit/completion/test_completiondelegate.py @@ -34,7 +34,7 @@ from qutebrowser.completion import completiondelegate ('foo', 'barfoobaz', [(3, 3)]), ('foo', 'barfoobazfoo', [(3, 3), (9, 3)]), ('foo', 'foofoo', [(0, 3), (3, 3)]), - ('a|b', 'cadb', [(1, 1), (3, 1)]), + ('a b', 'cadb', [(1, 1), (3, 1)]), ('foo', '<foo>', [(1, 3)]), ('<a>', "<a>bc", [(0, 3)]), @@ -42,6 +42,10 @@ from qutebrowser.completion import completiondelegate ('foo', "'foo'", [(1, 3)]), ('x', "'x'", [(1, 1)]), ('lt', "<lt", [(1, 2)]), + + # See https://github.com/qutebrowser/qutebrowser/pull/5111 + ('bar', '\U0001d65b\U0001d664\U0001d664bar', [(6, 3)]), + ('an anomaly', 'an anomaly', [(0, 2), (3, 7)]), ]) def test_highlight(pat, txt, segments): doc = QTextDocument(txt) @@ -53,6 +57,18 @@ def test_highlight(pat, txt, segments): ]) +def test_benchmark_highlight(benchmark): + txt = 'boofoobar' + pat = 'foo bar' + doc = QTextDocument(txt) + + def bench(): + highlighter = completiondelegate._Highlighter(doc, pat, Qt.red) + highlighter.highlightBlock(txt) + + benchmark(bench) + + def test_highlighted(qtbot): """Make sure highlighting works. diff --git a/tests/unit/completion/test_completionwidget.py b/tests/unit/completion/test_completionwidget.py index bdeda54b7..356b854c5 100644 --- a/tests/unit/completion/test_completionwidget.py +++ b/tests/unit/completion/test_completionwidget.py @@ -22,6 +22,7 @@ from unittest import mock import pytest +from PyQt5.QtCore import QRect from qutebrowser.completion import completionwidget from qutebrowser.completion.models import completionmodel, listcategory @@ -42,9 +43,13 @@ def completionview(qtbot, status_command_stub, config_stub, win_registry, return view -def test_set_model(completionview): +@pytest.fixture() +def model(): + return completionmodel.CompletionModel() + + +def test_set_model(completionview, model): """Ensure set_model actually sets the model and expands all categories.""" - model = completionmodel.CompletionModel() for _i in range(3): model.add_category(listcategory.ListCategory('', [('foo',)])) completionview.set_model(model) @@ -53,8 +58,7 @@ def test_set_model(completionview): assert completionview.isExpanded(model.index(i, 0)) -def test_set_pattern(completionview): - model = completionmodel.CompletionModel() +def test_set_pattern(completionview, model): model.set_pattern = mock.Mock(spec=[]) completionview.set_model(model) completionview.set_pattern('foo') @@ -116,7 +120,7 @@ def test_maybe_update_geometry(completionview, config_stub, qtbot): ('next-category', [[]], [None, None]), ('prev-category', [[]], [None, None]), ]) -def test_completion_item_focus(which, tree, expected, completionview, qtbot): +def test_completion_item_focus(which, tree, expected, completionview, model, qtbot): """Test that on_next_prev_item moves the selection properly. Args: @@ -127,7 +131,6 @@ def test_completion_item_focus(which, tree, expected, completionview, qtbot): successive movement. None implies no signal should be emitted. """ - model = completionmodel.CompletionModel() for catdata in tree: cat = listcategory.ListCategory('', ((x,) for x in catdata)) model.add_category(cat) @@ -142,23 +145,23 @@ def test_completion_item_focus(which, tree, expected, completionview, qtbot): assert sig.args == [entry] -@pytest.mark.parametrize('which', ['next', 'prev', 'next-category', - 'prev-category']) -def test_completion_item_focus_no_model(which, completionview, qtbot): +@pytest.mark.parametrize('which', ['next', 'prev', + 'next-category', 'prev-category', + 'next-page', 'prev-page']) +def test_completion_item_focus_no_model(which, completionview, model, qtbot): """Test that selectionChanged is not fired when the model is None. Validates #1812: help completion repeatedly completes """ with qtbot.assertNotEmitted(completionview.selection_changed): completionview.completion_item_focus(which) - model = completionmodel.CompletionModel() completionview.set_model(model) completionview.set_model(None) with qtbot.assertNotEmitted(completionview.selection_changed): completionview.completion_item_focus(which) -def test_completion_item_focus_fetch(completionview, qtbot): +def test_completion_item_focus_fetch(completionview, model, qtbot): """Test that on_next_prev_item moves the selection properly. Args: @@ -169,7 +172,6 @@ def test_completion_item_focus_fetch(completionview, qtbot): successive movement. None implies no signal should be emitted. """ - model = completionmodel.CompletionModel() cat = mock.Mock(spec=[ 'layoutChanged', 'layoutAboutToBeChanged', 'canFetchMore', 'fetchMore', 'rowCount', 'index', 'data']) @@ -190,10 +192,95 @@ def test_completion_item_focus_fetch(completionview, qtbot): assert cat.fetchMore.called +class TestCompletionItemFocusPage: + + """Test :completion-item-focus with prev-page/next-page.""" + + @pytest.fixture(autouse=True) + def patch_heights(self, monkeypatch, completionview): + """Patch the item/widget heights so that 10 items are always visible.""" + monkeypatch.setattr(completionview, 'visualRect', + lambda _idx: QRect(0, 0, 100, 20)) + monkeypatch.setattr(completionview, 'height', lambda: 200) + + @pytest.mark.parametrize('which, expected', [ + ('prev-page', 'Last Item'), + ('next-page', 'First Item'), + ]) + def test_no_selection(self, qtbot, completionview, model, which, expected): + """With no selection, the first/last item should be selected.""" + items = [("First Item",), ("Middle Item",), ("Last Item",)] + cat = listcategory.ListCategory('Test', items) + model.add_category(cat) + completionview.set_model(model) + with qtbot.waitSignal(completionview.selection_changed) as blocker: + completionview.completion_item_focus(which) + assert blocker.args == [expected] + + @pytest.mark.parametrize('steps', [ + # Select first item and go down + [('next', 'Item 1'), ('next-page', 'Item 10')], + # Go down twice + [('next', 'Item 1'), ('next-page', 'Item 10'), ('next-page', 'Item 19')], + # Last item via Page Down + [('next', 'Item 1'), + ('next-page', 'Item 10'), + ('next-page', 'Item 19'), + ('next-page', 'Item 24')], + # Wrapping around via Page Down + [('next', 'Item 1'), + ('next-page', 'Item 10'), + ('next-page', 'Item 19'), + ('next-page', 'Item 24'), + ('next-page', 'Item 1')], + + # Select last item and go up + [('prev', 'Item 24'), ('prev-page', 'Item 15')], + # Go up twice + [('prev', 'Item 24'), ('prev-page', 'Item 15'), ('prev-page', 'Item 6')], + # Last item via Page Up + [('prev', 'Item 24'), + ('prev-page', 'Item 15'), + ('prev-page', 'Item 6'), + ('prev-page', 'Item 1')], + # Wrapping around via Page Up + [('prev', 'Item 24'), + ('prev-page', 'Item 15'), + ('prev-page', 'Item 6'), + ('prev-page', 'Item 1'), + ('prev-page', 'Item 24')], + ]) + def test_steps(self, completionview, qtbot, model, steps): + items = [("Item {}".format(i),) for i in range(1, 25)] + cat = listcategory.ListCategory('Test', items) + model.add_category(cat) + completionview.set_model(model) + + for move, item in steps: + print('{:9} -> expecting {}'.format(move, item)) + with qtbot.waitSignal(completionview.selection_changed) as blocker: + completionview.completion_item_focus(move) + assert blocker.args == [item] + + def test_category_headers(self, completionview, qtbot, model): + for name, items in [ + ("First", [("Item {}".format(i),) for i in range(1, 9)]), + ("Second", []), + ("Third", [("Target item",)])]: + cat = listcategory.ListCategory(name, items) + model.add_category(cat) + completionview.set_model(model) + + for move, item in [('next', 'Item 1'), ('next-page', 'Target item')]: + with qtbot.waitSignal(completionview.selection_changed) as blocker: + completionview.completion_item_focus(move) + assert blocker.args == [item] + + @pytest.mark.parametrize('show', ['always', 'auto', 'never']) @pytest.mark.parametrize('rows', [[], ['Aa'], ['Aa', 'Bb']]) @pytest.mark.parametrize('quick_complete', [True, False]) -def test_completion_show(show, rows, quick_complete, completionview, +def test_completion_show(show, rows, quick_complete, completionview, model, config_stub): """Test that the completion widget is shown at appropriate times. @@ -205,7 +292,6 @@ def test_completion_show(show, rows, quick_complete, completionview, config_stub.val.completion.show = show config_stub.val.completion.quick = quick_complete - model = completionmodel.CompletionModel() for name in rows: cat = listcategory.ListCategory('', [(name,)]) model.add_category(cat) @@ -222,10 +308,9 @@ def test_completion_show(show, rows, quick_complete, completionview, assert not completionview.isVisible() -def test_completion_item_del(completionview): +def test_completion_item_del(completionview, model): """Test that completion_item_del invokes delete_cur_item in the model.""" func = mock.Mock(spec=[]) - model = completionmodel.CompletionModel() cat = listcategory.ListCategory('', [('foo', 'bar')], delete_func=func) model.add_category(cat) completionview.set_model(model) @@ -234,10 +319,9 @@ def test_completion_item_del(completionview): func.assert_called_once_with(['foo', 'bar']) -def test_completion_item_del_no_selection(completionview): +def test_completion_item_del_no_selection(completionview, model): """Test that completion_item_del with an invalid index.""" func = mock.Mock(spec=[]) - model = completionmodel.CompletionModel() cat = listcategory.ListCategory('', [('foo',)], delete_func=func) model.add_category(cat) completionview.set_model(model) @@ -247,12 +331,11 @@ def test_completion_item_del_no_selection(completionview): @pytest.mark.parametrize('sel', [True, False]) -def test_completion_item_yank(completionview, mocker, sel): +def test_completion_item_yank(completionview, model, mocker, sel): """Test that completion_item_yank invokes delete_cur_item in the model.""" m = mocker.patch( 'qutebrowser.completion.completionwidget.utils', autospec=True) - model = completionmodel.CompletionModel() cat = listcategory.ListCategory('', [('foo', 'bar')]) model.add_category(cat) @@ -264,13 +347,12 @@ def test_completion_item_yank(completionview, mocker, sel): @pytest.mark.parametrize('sel', [True, False]) -def test_completion_item_yank_selected(completionview, status_command_stub, - mocker, sel): +def test_completion_item_yank_selected(completionview, model, + status_command_stub, mocker, sel): """Test that completion_item_yank yanks selected text.""" m = mocker.patch( 'qutebrowser.completion.completionwidget.utils', autospec=True) - model = completionmodel.CompletionModel() cat = listcategory.ListCategory('', [('foo', 'bar')]) model.add_category(cat) diff --git a/tests/unit/config/test_configtypes.py b/tests/unit/config/test_configtypes.py index 97d8707f4..a98584164 100644 --- a/tests/unit/config/test_configtypes.py +++ b/tests/unit/config/test_configtypes.py @@ -63,28 +63,6 @@ class Font(QFont): return utils.get_repr(self, **kwargs) - @classmethod - def fromdesc(cls, desc): - """Get a Font based on a font description.""" - f = cls() - - f.setStyle(desc.style) - f.setWeight(desc.weight) - - if desc.pt is not None and desc.pt != -1: - f.setPointSize(desc.pt) - if desc.px is not None and desc.pt != -1: - f.setPixelSize(desc.px) - - f.setFamily(desc.family) - try: - f.setFamilies([desc.family]) - except AttributeError: - # Added in Qt 5.13 - pass - - return f - class RegexEq: @@ -1434,10 +1412,6 @@ class TestFont: def klass(self): return configtypes.Font - @pytest.fixture - def font_class(self): - return configtypes.Font - @pytest.mark.parametrize('val, desc', sorted(TESTS.items())) def test_to_py_valid(self, klass, val, desc): assert klass().to_py(val) == val @@ -1743,10 +1717,6 @@ class TestFile: def klass(self, request): return request.param - @pytest.fixture - def file_class(self): - return configtypes.File - def test_to_py_does_not_exist_file(self, os_mock): """Test to_py with a file which does not exist (File).""" os_mock.path.isfile.return_value = False diff --git a/tests/unit/mainwindow/test_messageview.py b/tests/unit/mainwindow/test_messageview.py index 77cc072b6..050788a9e 100644 --- a/tests/unit/mainwindow/test_messageview.py +++ b/tests/unit/mainwindow/test_messageview.py @@ -59,6 +59,26 @@ def test_size_hint(view): assert height2 == height1 * 2 +def test_word_wrap(view, qtbot): + """A long message should be wrapped.""" + with qtbot.waitSignal(view._clear_timer.timeout): + view.show_message(usertypes.MessageLevel.info, 'short') + height1 = view.sizeHint().height() + assert height1 > 0 + + text = ("Athene, the bright-eyed goddess, answered him at once: Father of " + "us all, Son of Cronos, Highest King, clearly that man deserved to be " + "destroyed: so let all be destroyed who act as he did. But my heart aches " + "for Odysseus, wise but ill fated, who suffers far from his friends on an " + "island deep in the sea.") + + view.show_message(usertypes.MessageLevel.info, text) + height2 = view.sizeHint().height() + + assert height2 > height1 + assert view._messages[0].wordWrap() + + def test_show_message_twice(view): """Show the same message twice -> only one should be shown.""" view.show_message(usertypes.MessageLevel.info, 'test') diff --git a/tests/unit/misc/test_miscwidgets.py b/tests/unit/misc/test_miscwidgets.py index 7568e56c0..cd9fe93b8 100644 --- a/tests/unit/misc/test_miscwidgets.py +++ b/tests/unit/misc/test_miscwidgets.py @@ -18,10 +18,9 @@ # along with qutebrowser. If not, see <http://www.gnu.org/licenses/>. import logging -from unittest import mock from PyQt5.QtCore import Qt, QSize -from PyQt5.QtWidgets import QApplication, QWidget +from PyQt5.QtWidgets import QWidget import pytest from qutebrowser.misc import miscwidgets @@ -41,19 +40,6 @@ class TestCommandLineEdit: assert cmd_edit.text() == '' yield cmd_edit - @pytest.fixture - def mock_clipboard(self, mocker): - """Fixture to mock QApplication.clipboard. - - Return: - The mocked QClipboard object. - """ - mocker.patch.object(QApplication, 'clipboard') - clipboard = mock.MagicMock() - clipboard.supportsSelection.return_value = True - QApplication.clipboard.return_value = clipboard - return clipboard - def test_position(self, qtbot, cmd_edit): """Test cursor position based on the prompt.""" qtbot.keyClicks(cmd_edit, ':hello') diff --git a/tests/unit/misc/userscripts/test_qute_lastpass.py b/tests/unit/misc/userscripts/test_qute_lastpass.py index 84672e6dc..20646edd0 100644 --- a/tests/unit/misc/userscripts/test_qute_lastpass.py +++ b/tests/unit/misc/userscripts/test_qute_lastpass.py @@ -84,7 +84,6 @@ class TestQuteLastPassComponents: """Test if fake_key_raw properly escapes characters.""" qute_lastpass.fake_key_raw('john.doe@example.com ') - # pylint: disable=line-too-long qutecommand_mock.assert_called_once_with( 'fake-key \\j\\o\\h\\n\\.\\d\\o\\e\\@\\e\\x\\a\\m\\p\\l\\e\\.\\c\\o\\m" "' ) @@ -258,7 +257,6 @@ class TestQuteLastPassMain: assert exit_code == qute_lastpass.ExitCodes.SUCCESS - # pylint: disable=line-too-long subprocess_mock.assert_has_calls([ call(['lpass', 'show', '-x', '-j', '-G', '\\bwww\\.example\\.com'], stdout=ANY, stderr=ANY), @@ -325,7 +323,6 @@ class TestQuteLastPassMain: assert exit_code == qute_lastpass.ExitCodes.SUCCESS - # pylint: disable=line-too-long subprocess_mock.assert_has_calls([ call(['lpass', 'show', '-x', '-j', '-G', '\\bwww\\.example\\.com'], stdout=ANY, stderr=ANY), diff --git a/tests/unit/scripts/test_check_coverage.py b/tests/unit/scripts/test_check_coverage.py index 9aa3e172e..ec9d9666b 100644 --- a/tests/unit/scripts/test_check_coverage.py +++ b/tests/unit/scripts/test_check_coverage.py @@ -227,11 +227,11 @@ def test_skipped_non_linux(covtest): def _generate_files(): """Get filenames from WHITELISTED_/PERFECT_FILES.""" for src_file in check_coverage.WHITELISTED_FILES: - yield pathlib.Path('qutebrowser') / src_file + yield pathlib.Path(src_file) for test_file, src_file in check_coverage.PERFECT_FILES: if test_file is not None: yield pathlib.Path(test_file) - yield pathlib.Path('qutebrowser') / src_file + yield pathlib.Path(src_file) @pytest.mark.parametrize('filename', list(_generate_files())) diff --git a/tests/unit/utils/test_javascript.py b/tests/unit/utils/test_javascript.py index 005b8f86c..fc8267435 100644 --- a/tests/unit/utils/test_javascript.py +++ b/tests/unit/utils/test_javascript.py @@ -22,57 +22,76 @@ import pytest import hypothesis import hypothesis.strategies +import attr -from qutebrowser.utils import javascript +from qutebrowser.utils import javascript, usertypes + + +@attr.s +class Case: + + original = attr.ib() + replacement = attr.ib() + webkit_only = attr.ib(False) + + def __str__(self): + return self.original class TestStringEscape: - TESTS = { - 'foo\\bar': r'foo\\bar', - 'foo\nbar': r'foo\nbar', - 'foo\rbar': r'foo\rbar', - "foo'bar": r"foo\'bar", - 'foo"bar': r'foo\"bar', - 'one\\two\rthree\nfour\'five"six': r'one\\two\rthree\nfour\'five\"six', - '\x00': r'\x00', - 'hellö': 'hellö', - '☃': '☃', - '\x80Ā': '\x80Ā', - '𐀀\x00𐀀\x00': r'𐀀\x00𐀀\x00', - '𐀀\ufeff': r'𐀀\ufeff', - '\ufeff': r'\ufeff', + TESTS = [ + Case('foo\\bar', r'foo\\bar'), + Case('foo\nbar', r'foo\nbar'), + Case('foo\rbar', r'foo\rbar'), + Case("foo'bar", r"foo\'bar"), + Case('foo"bar', r'foo\"bar'), + Case('one\\two\rthree\nfour\'five"six', r'one\\two\rthree\nfour\'five\"six'), + Case('\x00', r'\x00', webkit_only=True), + Case('hellö', 'hellö'), + Case('☃', '☃'), + Case('\x80Ā', '\x80Ā'), + Case('𐀀\x00𐀀\x00', r'𐀀\x00𐀀\x00', webkit_only=True), + Case('𐀀\ufeff', r'𐀀\ufeff'), + Case('\ufeff', r'\ufeff', webkit_only=True), # http://stackoverflow.com/questions/2965293/ - '\u2028': r'\u2028', - '\u2029': r'\u2029', - } + Case('\u2028', r'\u2028'), + Case('\u2029', r'\u2029'), + ] # Once there was this warning here: # load glyph failed err=6 face=0x2680ba0, glyph=1912 # http://qutebrowser.org:8010/builders/debian-jessie/builds/765/steps/unittests/ # Should that be ignored? - @pytest.mark.parametrize('before, after', sorted(TESTS.items()), ids=repr) - def test_fake_escape(self, before, after): + @pytest.mark.parametrize('case', TESTS, ids=str) + def test_fake_escape(self, case): """Test javascript escaping with some expected outcomes.""" - assert javascript.string_escape(before) == after + assert javascript.string_escape(case.original) == case.replacement - def _test_escape(self, text, webframe): - """Test conversion by using evaluateJavaScript.""" + def _test_escape(self, text, web_tab, qtbot): + """Test conversion by running JS in a tab.""" escaped = javascript.string_escape(text) - result = webframe.evaluateJavaScript('"{}";'.format(escaped)) - assert result == text - @pytest.mark.parametrize('text', sorted(TESTS), ids=repr) - def test_real_escape(self, webframe, text): + with qtbot.waitCallback() as cb: + web_tab.run_js_async('"{}";'.format(escaped), cb) + + cb.assert_called_with(text) + + @pytest.mark.parametrize('case', TESTS, ids=str) + def test_real_escape(self, web_tab, qtbot, case): """Test javascript escaping with a real QWebPage.""" - self._test_escape(text, webframe) + if web_tab.backend == usertypes.Backend.QtWebEngine and case.webkit_only: + pytest.xfail("Not supported with QtWebEngine") + self._test_escape(case.original, web_tab, qtbot) @pytest.mark.qt_log_ignore('^OpenType support missing for script') @hypothesis.given(hypothesis.strategies.text()) - def test_real_escape_hypothesis(self, webframe, text): + def test_real_escape_hypothesis(self, web_tab, qtbot, text): """Test javascript escaping with a real QWebPage and hypothesis.""" - self._test_escape(text, webframe) + if web_tab.backend == usertypes.Backend.QtWebEngine: + hypothesis.assume('\x00' not in text) + self._test_escape(text, web_tab, qtbot) @pytest.mark.parametrize('arg, expected', [ diff --git a/tests/unit/utils/test_urlutils.py b/tests/unit/utils/test_urlutils.py index 72fe631ca..04f7f04e6 100644 --- a/tests/unit/utils/test_urlutils.py +++ b/tests/unit/utils/test_urlutils.py @@ -21,11 +21,14 @@ import os.path import logging +import urllib.parse import attr from PyQt5.QtCore import QUrl from PyQt5.QtNetwork import QNetworkProxy import pytest +import hypothesis +import hypothesis.strategies from qutebrowser.api import cmdutils from qutebrowser.browser.network import pac @@ -760,3 +763,42 @@ class TestProxyFromUrl: def test_invalid(self, url, exception): with pytest.raises(exception): urlutils.proxy_from_url(QUrl(url)) + + +class TestParseJavascriptUrl: + + @pytest.mark.parametrize('url, message', [ + (QUrl(), ""), + (QUrl('https://example.com'), "Expected a javascript:... URL"), + (QUrl('javascript://example.com'), + "URL contains unexpected components: example.com"), + (QUrl('javascript://foo:bar@example.com:1234'), + "URL contains unexpected components: foo:bar@example.com:1234"), + ]) + def test_invalid(self, url, message): + with pytest.raises(urlutils.Error, match=message): + urlutils.parse_javascript_url(url) + + @pytest.mark.parametrize('url, source', [ + (QUrl('javascript:"hello" %0a "world"'), '"hello" \n "world"'), + # https://github.com/web-platform-tests/wpt/blob/master/html/browsers/browsing-the-web/navigating-across-documents/javascript-url-query-fragment-components.html + (QUrl('javascript:"nope" ? "yep" : "what";'), '"nope" ? "yep" : "what";'), + (QUrl('javascript:"wrong"; // # %0a "ok";'), '"wrong"; // # \n "ok";'), + (QUrl('javascript:"%252525 ? %252525 # %252525"'), + '"%2525 ? %2525 # %2525"'), + ]) + def test_valid(self, url, source): + assert urlutils.parse_javascript_url(url) == source + + @hypothesis.given(source=hypothesis.strategies.text()) + def test_hypothesis(self, source): + scheme = 'javascript:' + url = QUrl(scheme + urllib.parse.quote(source)) + hypothesis.assume(url.isValid()) + + try: + parsed = urlutils.parse_javascript_url(url) + except urlutils.Error: + pass + else: + assert parsed == source diff --git a/tests/unit/utils/test_version.py b/tests/unit/utils/test_version.py index 4b7d4f5fa..868c4920f 100644 --- a/tests/unit/utils/test_version.py +++ b/tests/unit/utils/test_version.py @@ -809,8 +809,9 @@ class TestPDFJSVersion: assert version._pdfjs_version() == 'unknown (bundled)' @pytest.mark.parametrize('varname', [ - 'PDFJS.version', # older versions - 'var pdfjsVersion', # newer versions + 'PDFJS.version', # v1.10.100 and older + 'var pdfjsVersion', # v2.0.943 + 'const pdfjsVersion', # v2.5.207 ]) def test_known(self, monkeypatch, varname): pdfjs_code = textwrap.dedent(""" @@ -22,6 +22,7 @@ basepython = py36: {env:PYTHON:python3.6} py37: {env:PYTHON:python3.7} py38: {env:PYTHON:python3.8} + py39: {env:PYTHON:python3.9} pip_version = pip deps = -r{toxinidir}/requirements.txt |