diff --git a/.coveragerc b/.coveragerc index 18fb85ba4..9ba8e8a5e 100644 --- a/.coveragerc +++ b/.coveragerc @@ -12,6 +12,7 @@ exclude_lines = def __repr__ raise AssertionError raise NotImplementedError + raise utils\.Unreachable if __name__ == ["']__main__["']: [xml] diff --git a/.flake8 b/.flake8 index 1d33859fc..340132d49 100644 --- a/.flake8 +++ b/.flake8 @@ -1,5 +1,8 @@ [flake8] exclude = .*,__pycache__,resources.py +# B001: bare except +# B008: Do not perform calls in argument defaults. (fine with some Qt stuff) +# B305: .next() (false-positives) # E128: continuation line under-indented for visual indent # E226: missing whitespace around arithmetic operator # E265: Block comment should start with '#' @@ -19,32 +22,33 @@ exclude = .*,__pycache__,resources.py # D103: Missing docstring in public function (will be handled by others) # D104: Missing docstring in public package (will be handled by others) # D105: Missing docstring in magic method (will be handled by others) +# D106: Missing docstring in public nested class (will be handled by others) +# D107: Missing docstring in __init__ (will be handled by others) # D209: Blank line before closing """ (removed from PEP257) # D211: No blank lines allowed before class docstring # (PEP257 got changed, but let's stick to the old standard) +# D401: First line should be in imperative mood (okay sometimes) # D402: First line should not be function's signature (false-positives) # D403: First word of the first line should be properly capitalized # (false-positives) +# D413: Missing blank line after last section (not in pep257?) +# A003: Builtin name for class attribute (needed for attrs) ignore = + B001,B008,B305, E128,E226,E265,E501,E402,E266,E722,E731, F401, N802, P101,P102,P103, - D102,D103,D104,D105,D209,D211,D402,D403 + D102,D103,D106,D107,D104,D105,D209,D211,D401,D402,D403,D413, + A003 min-version = 3.4.0 max-complexity = 12 -putty-auto-ignore = True -putty-ignore = - /# pylint: disable=invalid-name/ : +N801,N806 - /# pragma: no mccabe/ : +C901 - tests/*/test_*.py : +D100,D101,D401 - tests/conftest.py : +F403 - tests/unit/browser/test_history.py : +N806 - tests/helpers/fixtures.py : +N806 - tests/unit/browser/webkit/http/test_content_disposition.py : +D400 - scripts/dev/ci/appveyor_install.py : +FI53 - # FIXME:conf - tests/unit/completion/test_models.py : +F821 +per-file-ignores = + tests/*/test_*.py : D100,D101,D401 + tests/unit/browser/test_history.py : N806 + tests/helpers/fixtures.py : N806 + tests/unit/browser/webkit/http/test_content_disposition.py : D400 + scripts/dev/ci/appveyor_install.py : FI53 copyright-check = True copyright-regexp = # Copyright [\d-]+ .* copyright-min-file-size = 110 diff --git a/.github/CODE_OF_CONDUCT.md b/.github/CODE_OF_CONDUCT.md new file mode 100644 index 000000000..549278d1e --- /dev/null +++ b/.github/CODE_OF_CONDUCT.md @@ -0,0 +1,46 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. + +## Our Standards + +Examples of behavior that contributes to creating a positive environment include: + +* Using welcoming and inclusive language +* Being respectful of differing viewpoints and experiences +* Gracefully accepting constructive criticism +* Focusing on what is best for the community +* Showing empathy towards other community members + +Examples of unacceptable behavior by participants include: + +* The use of sexualized language or imagery and unwelcome sexual attention or advances +* Trolling, insulting/derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or electronic address, without explicit permission +* Other conduct which could reasonably be considered inappropriate in a professional setting + +## Our Responsibilities + +Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. + +Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. + +## Scope + +This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at mail@qutebrowser.org. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. + +Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version] + +[homepage]: http://contributor-covenant.org +[version]: http://contributor-covenant.org/version/1/4/ diff --git a/.github/CONTRIBUTING.asciidoc b/.github/CONTRIBUTING.asciidoc index 6c64f296a..6449c6323 100644 --- a/.github/CONTRIBUTING.asciidoc +++ b/.github/CONTRIBUTING.asciidoc @@ -4,6 +4,9 @@ - Either run the testsuite locally, or keep an eye on Travis CI / AppVeyor after pushing changes. -See the full contribution docs for details: +- If you are stuck somewhere or have questions, + https://github.com/qutebrowser/qutebrowser#getting-help[please ask]! + +See the full contribution documentation for details and other useful hints: include::../doc/contributing.asciidoc[] diff --git a/.pylintrc b/.pylintrc index bca11bf80..b654355c2 100644 --- a/.pylintrc +++ b/.pylintrc @@ -13,38 +13,33 @@ persistent=n [MESSAGES CONTROL] enable=all -disable=no-self-use, - fixme, - global-statement, - locally-disabled, +disable=locally-disabled, locally-enabled, - too-many-ancestors, - too-few-public-methods, - too-many-public-methods, + suppressed-message, + fixme, + no-self-use, cyclic-import, - bad-continuation, - too-many-instance-attributes, blacklisted-name, - too-many-lines, logging-format-interpolation, + logging-not-lazy, broad-except, bare-except, eval-used, exec-used, - ungrouped-imports, - suppressed-message, - too-many-return-statements, - duplicate-code, + global-statement, wrong-import-position, + duplicate-code, no-else-return, - # https://github.com/PyCQA/pylint/issues/1698 - unsupported-membership-test, - unsupported-assignment-operation, - unsubscriptable-object, + too-many-ancestors, + too-many-public-methods, + too-many-instance-attributes, + too-many-lines, + too-many-return-statements, too-many-boolean-expressions, too-many-locals, too-many-branches, - too-many-statements + too-many-statements, + too-few-public-methods [BASIC] function-rgx=[a-z_][a-z0-9_]{2,50}$ @@ -73,10 +68,10 @@ valid-metaclass-classmethod-first-arg=cls [TYPECHECK] ignored-modules=PyQt5,PyQt5.QtWebKit -ignored-classes=_CountingAttr [IMPORTS] # WORKAROUND # For some reason, pylint doesn't know about some Python 3 modules on # AppVeyor... known-standard-library=faulthandler,http,enum,tokenize,posixpath,importlib,types +known-third-party=sip diff --git a/.travis.yml b/.travis.yml index 65d917d73..251842d06 100644 --- a/.travis.yml +++ b/.travis.yml @@ -23,7 +23,7 @@ matrix: env: TESTENV=py36-pyqt59-cov - os: osx env: TESTENV=py36 OSX=sierra - osx_image: xcode8.3 + osx_image: xcode9.2 language: generic # https://github.com/qutebrowser/qutebrowser/issues/2013 # - os: osx @@ -52,6 +52,10 @@ matrix: language: node_js python: null node_js: "lts/*" + - os: linux + language: generic + env: TESTENV=shellcheck + services: docker fast_finish: true cache: diff --git a/MANIFEST.in b/MANIFEST.in index f09e9c0ef..54bb613f3 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -14,6 +14,7 @@ include qutebrowser/git-commit-id include LICENSE doc/* README.asciidoc include misc/qutebrowser.desktop include misc/qutebrowser.appdata.xml +include misc/Makefile include requirements.txt include tox.ini include qutebrowser.py diff --git a/README.asciidoc b/README.asciidoc index 48603d2be..a625f317c 100644 --- a/README.asciidoc +++ b/README.asciidoc @@ -15,7 +15,7 @@ image:https://travis-ci.org/qutebrowser/qutebrowser.svg?branch=master["Build Sta image:https://ci.appveyor.com/api/projects/status/5pyauww2k68bbow2/branch/master?svg=true["AppVeyor build status", link="https://ci.appveyor.com/project/qutebrowser/qutebrowser"] image:https://codecov.io/github/qutebrowser/qutebrowser/coverage.svg?branch=master["coverage badge",link="https://codecov.io/github/qutebrowser/qutebrowser?branch=master"] -link:https://www.qutebrowser.org[website] | link:https://blog.qutebrowser.org[blog] | link:https://github.com/qutebrowser/qutebrowser/releases[releases] +link:https://www.qutebrowser.org[website] | link:https://blog.qutebrowser.org[blog] | https://github.com/qutebrowser/qutebrowser/blob/master/doc/faq.asciidoc[FAQ] | https://www.qutebrowser.org/doc/contributing.html[contributing] | link:https://github.com/qutebrowser/qutebrowser/releases[releases] | https://github.com/qutebrowser/qutebrowser/blob/master/doc/install.asciidoc[installing] // QUTE_WEB_HIDE_END qutebrowser is a keyboard-focused browser with a minimal GUI. It's based @@ -109,7 +109,7 @@ The following software and libraries are required to run qutebrowser: link:https://github.com/annulen/webkit/wiki[updated fork] (5.212) is supported * http://www.riverbankcomputing.com/software/pyqt/intro[PyQt] 5.7.0 or newer - (5.9 recommended) for Python 3 + (5.9.2 recommended) for Python 3 * https://pypi.python.org/pypi/setuptools/[pkg_resources/setuptools] * http://fdik.org/pyPEG/[pyPEG2] * http://jinja.pocoo.org/[jinja2] diff --git a/doc/changelog.asciidoc b/doc/changelog.asciidoc index e78dd5d2f..4e51f1c5b 100644 --- a/doc/changelog.asciidoc +++ b/doc/changelog.asciidoc @@ -21,6 +21,11 @@ v1.1.0 (unreleased) Added ~~~~~ +- Initial support for Greasemonkey scripts. There are still some rough edges, + but many scripts should already work. +- There's now a `misc/Makefile` file in releases, which should help + distributions which package qutebrowser, as they can run something like + `make -f misc/Makefile DESTDIR="$pkgdir" install` now. - New `{current_url}` field for `window.title_format` and `tabs.title.format`. - New `colors.statusbar.passthrough.fg`/`.bg` settings. - New `completion.delay` and `completion.min_chars` settings to update the @@ -43,16 +48,31 @@ Added completion item text. - New `tabs.pinned.shrink` setting to (`true` by default) to make it possible for pinned tabs and normal tabs to have the same size. -_ New `content.windowed_fullscreen` setting to show e.g. a fullscreened video in +- New `content.windowed_fullscreen` setting to show e.g. a fullscreened video in the window without fullscreening that window. +- New `:edit-command` command to edit the commandline in an editor. +- New `tabs.persist_mode_on_change` setting to keep the current mode when + switching tabs. +- New `session.lazy_restore` setting which allows to not load pages immediately + when restoring a session. +- New `hist_importer.py` script to import history from Firefox/Chromium. +- New `{protocol}` replacement for `tabs.title.format` and friends. +- New `-o` flag for `:spawn` to show stdout/stderr in a new tab. +- Support for incremental search, with a new `search.incremental` setting. +- New `--rapid` flag for `:command-accept` (bound to `Ctrl-Enter` by default), + which allows executing a command in the completion without closing it. +- The `colors.completion.fg` setting can now be a list, allowing to specify + different colors for the three completion columns. Changed ~~~~~~~ -- Some tabs settings got renamed: +- Some settings got renamed: * `tabs.width.bar` -> `tabs.width` * `tabs.width.indicator` -> `tabs.indicator.width` * `tabs.indicator_padding` -> `tabs.indicator.padding` + * `session_default_name` -> `session.default_name` + * `ignore_case` -> `search.ignore_case` - High-DPI favicons are now used when available. - The `asciidoc2html.py` script now uses Pygments (which is already a dependency of qutebrowser) instead of `source-highlight` for syntax highlighting. @@ -71,6 +91,25 @@ Changed - The `qute://version` page now also shows the uptime of qutebrowser. - qutebrowser now prompts to create a non-existing directory when starting a download. +- Much improved user stylesheet handling which reduces flickering + and updates immediately after setting a stylesheet. +- `:completion-item-focus` now has a `--history` flag which causes it to go + through the command history when no text was entered. The default bindings for + cursor keys in the completion changed to use that, so that they can be used + again to navigate through completion items when a text was entered. +- `:debug-pyeval` now has a `--file` argument so it takes a filename instead of + a line of code. +- `:jseval --file` now searches relative paths in a js/ subdir in qutebrowser's + data dir, e.g. `~/.local/share/qutebrowser/js`. +- The current/default bindings are now shown in the :bind completion. +- Empty categories are now hidden in the `:open` completion. +- Search terms for URLs and titles can now be mixed when filtering the + completion. +- The default font size for the UI got bumped up from 8pt to 10pt. +- Improved matching in the completion: The words entered are now matched in any + order, and mixed matches on URL/tite are possible. +- The system's default encoding (rather than UTF-8) is now used to decode + subprocess output. Fixed ~~~~~ @@ -87,6 +126,14 @@ Fixed - Fixed crash when opening `qute://help/img` - Fixed `gU` (`:navigate up`) on `qute://help` and webservers not handling `..` in a URL. +- Using e.g. `-s backend webkit` to set the backend now works correctly. +- Fixed crash when closing the tab an external editor was opened in. +- When using `:search-next` before a search is finished, no warning about no + results being found is shown anymore. +- Fix :click-element with an ID containing non-alphanumeric characters. +- Fix crash when a subprocess outputs data which is not decodable as UTF-8. +- Fix crash when closing a tab immediately after hinting. +- Worked around issues in Qt 5.10 with loading progress never being finished. Deprecated ~~~~~~~~~~ @@ -99,18 +146,24 @@ Removed - The long-deprecated `:prompt-yes`, `:prompt-no`, `:paste-primary` and `:paste` commands have been removed. +- The invocation `:download ` which was deprecated in v0.5.0 was + removed, use `:download --dest ` instead. - The `messages.unfocused` option which wasn't used anymore was removed. - The `x[xtb]` default bindings got removed again as many users accidentally triggered them. -v1.0.4 (unreleased) -------------------- +v1.0.4 +------ Fixed ~~~~~ - The `qute://gpl` page now works correctly again. - Trying to bind an empty command now doesn't crash anymore. +- Fixed crash when `:config-write-py` fails to write to the given path. +- Fixed crash for some users when selecting a file with Qt 5.9.3 +- Improved handling for various SQL errors +- Fix crash when setting content.cache.size to a big value (> 2 GB) v1.0.3 ------ diff --git a/doc/contributing.asciidoc b/doc/contributing.asciidoc index b39f3d902..afbb752c5 100644 --- a/doc/contributing.asciidoc +++ b/doc/contributing.asciidoc @@ -100,16 +100,10 @@ Currently, the following tox environments are available: - `py35`, `py36`: Run pytest for python 3.5/3.6 with the system-wide PyQt. - `py36-pyqt57`, ..., `py36-pyqt59`: Run pytest with the given PyQt version (`py35-*` also works). - `py36-pyqt59-cov`: Run with coverage support (other Python/PyQt versions work too). -* `flake8`: Run https://pypi.python.org/pypi/flake8[flake8] checks: - https://pypi.python.org/pypi/pyflakes[pyflakes], - https://pypi.python.org/pypi/pep8[pep8], - https://pypi.python.org/pypi/mccabe[mccabe]. +* `flake8`: Run various linting checks via https://pypi.python.org/pypi/flake8[flake8]. * `vulture`: Run https://pypi.python.org/pypi/vulture[vulture] to find unused code portions. * `pylint`: Run http://pylint.org/[pylint] static code analysis. -* `pydocstyle`: Check - https://www.python.org/dev/peps/pep-0257/[PEP257] compliance with - https://github.com/PyCQA/pydocstyle[pydocstyle]. * `pyroma`: Check packaging practices with https://pypi.python.org/pypi/pyroma/[pyroma]. * `eslint`: Run http://eslint.org/[ESLint] javascript checker. diff --git a/doc/faq.asciidoc b/doc/faq.asciidoc index 7794c9d13..0d94796a4 100644 --- a/doc/faq.asciidoc +++ b/doc/faq.asciidoc @@ -221,5 +221,5 @@ My issue is not listed.:: https://github.com/qutebrowser/qutebrowser/issues[the issue tracker] or using the `:report` command. If you are reporting a segfault, make sure you read the - link:doc/stacktrace.asciidoc[guide] on how to report them with all needed + link:stacktrace.asciidoc[guide] on how to report them with all needed information. diff --git a/doc/help/commands.asciidoc b/doc/help/commands.asciidoc index 04377c055..098ada8af 100644 --- a/doc/help/commands.asciidoc +++ b/doc/help/commands.asciidoc @@ -47,12 +47,14 @@ It is possible to run or bind multiple commands by separating them with `;;`. |<>|Open the last/[count]th download. |<>|Remove the last/[count]th download from the list. |<>|Retry the first failed/[count]th download. +|<>|Open an editor to modify the current command. |<>|Navigate to a url formed in an external editor. |<>|Enter a key mode. |<>|Send a fake keypress or key string to the website or qutebrowser. |<>|Follow the selected text. |<>|Go forward in the history of the current tab. |<>|Toggle fullscreen mode. +|<>|Re-read Greasemonkey scripts from disk. |<>|Show help about a command or setting. |<>|Start hinting. |<>|Show browsing history. @@ -332,12 +334,10 @@ Write the current configuration to a config.py file. [[download]] === download -Syntax: +:download [*--mhtml*] [*--dest* 'dest'] ['url'] ['dest-old']+ +Syntax: +:download [*--mhtml*] [*--dest* 'dest'] ['url']+ Download a given URL, or current page if no URL given. -The form `:download [url] [dest]` is deprecated, use `:download --dest [dest] [url]` instead. - ==== positional arguments * +'url'+: The URL to download. If not given, download the current page. @@ -407,6 +407,15 @@ Retry the first failed/[count]th download. ==== count The index of the download to retry. +[[edit-command]] +=== edit-command +Syntax: +:edit-command [*--run*]+ + +Open an editor to modify the current command. + +==== optional arguments +* +*-r*+, +*--run*+: Run the command if the editor exits successfully. + [[edit-url]] === edit-url Syntax: +:edit-url [*--bg*] [*--tab*] [*--window*] [*--private*] [*--related*] ['url']+ @@ -481,6 +490,12 @@ Toggle fullscreen mode. ==== optional arguments * +*-l*+, +*--leave*+: Only leave fullscreen if it was entered by the page. +[[greasemonkey-reload]] +=== greasemonkey-reload +Re-read Greasemonkey scripts from disk. + +The scripts are read from a 'greasemonkey' subdirectory in qutebrowser's data directory (see `:version`). + [[help]] === help Syntax: +:help [*--tab*] [*--bg*] [*--window*] ['topic']+ @@ -501,7 +516,7 @@ Show help about a command or setting. [[hint]] === hint -Syntax: +:hint [*--rapid*] [*--mode* 'mode'] [*--add-history*] +Syntax: +:hint [*--mode* 'mode'] [*--add-history*] [*--rapid*] ['group'] ['target'] ['args' ['args' ...]]+ Start hinting. @@ -555,11 +570,6 @@ Start hinting. ==== optional arguments -* +*-r*+, +*--rapid*+: Whether to do rapid hinting. With rapid hinting, the hint mode isn't left after a hint is followed, so you can easily - open multiple links. This is only possible with targets - `tab` (with `tabs.background_tabs=true`), `tab-bg`, - `window`, `run`, `hover`, `userscript` and `spawn`. - * +*-m*+, +*--mode*+: The hinting mode to use. - `number`: Use numeric hints. @@ -571,6 +581,11 @@ Start hinting. * +*-a*+, +*--add-history*+: Whether to add the spawned or yanked link to the browsing history. +* +*-r*+, +*--rapid*+: Whether to do rapid hinting. With rapid hinting, the hint mode isn't left after a hint is followed, so you can easily + open multiple links. This is only possible with targets + `tab` (with `tabs.background_tabs=true`), `tab-bg`, + `window`, `run`, `hover`, `userscript` and `spawn`. + ==== note * This command does not split arguments after the last argument and handles quotes literally. @@ -629,7 +644,10 @@ Evaluate a JavaScript string. * +'js-code'+: The string/file to evaluate. ==== optional arguments -* +*-f*+, +*--file*+: Interpret js-code as a path to a file. +* +*-f*+, +*--file*+: Interpret js-code as a path to a file. If the path is relative, the file is searched in a js/ subdir + in qutebrowser's data dir, e.g. + `~/.local/share/qutebrowser/js`. + * +*-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. @@ -1070,7 +1088,7 @@ Syntax: +:session-save [*--current*] [*--quiet*] [*--force*] [*--only-active-win Save a session. ==== positional arguments -* +'name'+: The name of the session. If not given, the session configured in session_default_name is saved. +* +'name'+: The name of the session. If not given, the session configured in session.default_name is saved. ==== optional arguments @@ -1128,7 +1146,7 @@ Set a mark at the current scroll position in the current tab. [[spawn]] === spawn -Syntax: +:spawn [*--userscript*] [*--verbose*] [*--detach*] 'cmdline'+ +Syntax: +:spawn [*--userscript*] [*--verbose*] [*--output*] [*--detach*] 'cmdline'+ Spawn a command in a shell. @@ -1143,6 +1161,7 @@ Spawn a command in a shell. - `/usr/share/qutebrowser/userscripts` * +*-v*+, +*--verbose*+: Show notifications when the command started/exited. +* +*-o*+, +*--output*+: Whether the output should be shown in a new tab. * +*-d*+, +*--detach*+: Whether the command should be detached from qutebrowser. ==== note @@ -1402,8 +1421,13 @@ How many steps to zoom out. |============== [[command-accept]] === command-accept +Syntax: +:command-accept [*--rapid*]+ + Execute the command currently in the commandline. +==== optional arguments +* +*-r*+, +*--rapid*+: Run the command without closing or clearing the command bar. + [[command-history-next]] === command-history-next Go forward in the commandline history. @@ -1418,13 +1442,16 @@ Delete the current completion item. [[completion-item-focus]] === completion-item-focus -Syntax: +:completion-item-focus 'which'+ +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'. +==== optional arguments +* +*-H*+, +*--history*+: Navigate through command history if no text was typed. + [[completion-item-yank]] === completion-item-yank Syntax: +:completion-item-yank [*--sel*]+ @@ -1773,7 +1800,7 @@ Change the log level for console logging. [[debug-pyeval]] === debug-pyeval -Syntax: +:debug-pyeval [*--quiet*] 's'+ +Syntax: +:debug-pyeval [*--file*] [*--quiet*] 's'+ Evaluate a python string and display the results as a web page. @@ -1781,6 +1808,7 @@ Evaluate a python string and display the results as a web page. * +'s'+: The string to evaluate. ==== optional arguments +* +*-f*+, +*--file*+: Interpret s as a path to file, also implies --quiet. * +*-q*+, +*--quiet*+: Don't show the output in a new tab. ==== note diff --git a/doc/help/configuring.asciidoc b/doc/help/configuring.asciidoc index 347f7b49b..a9b7b6ddf 100644 --- a/doc/help/configuring.asciidoc +++ b/doc/help/configuring.asciidoc @@ -17,15 +17,24 @@ did in your old configuration, compared to the old defaults. Other changes in default settings: -- `` and `` in the completion now navigate through command history - instead of selecting completion items. Use ``/`` to cycle - through the completion instead. +- In v1.1.x and newer, `` and `` navigate through command history + if no text was entered yet. + With v1.0.x, they always navigate through command history instead of selecting + completion items. Use ``/`` to cycle through the completion + instead. You can get back the old behavior by doing: + ---- :bind -m command completion-item-focus prev :bind -m command completion-item-focus next ---- ++ +or always navigate through command history with ++ +---- +:bind -m command command-history-prev +:bind -m command command-history-next +---- - The default for `completion.web_history_max_items` is now set to `-1`, showing an unlimited number of items in the completion for `:open` as the new @@ -255,7 +264,7 @@ get a string: .config.py: [source,python] ---- -print(str(config.configdir / 'config.py') +print(str(config.configdir / 'config.py')) ---- Handling errors diff --git a/doc/help/settings.asciidoc b/doc/help/settings.asciidoc index 64881f619..53af8399d 100644 --- a/doc/help/settings.asciidoc +++ b/doc/help/settings.asciidoc @@ -199,7 +199,6 @@ |<>|Scatter hint key chains (like Vimium) or not (like dwb). |<>|Make characters in hint strings uppercase. |<>|Maximum time (in minutes) between two history items for them to be considered being from the same browsing session. -|<>|When to find text on a page case-insensitively. |<>|Which unbound keys to forward to the webview in normal mode. |<>|Leave insert mode if a non-editable element is clicked. |<>|Automatically enter insert mode if an editable element is focused after loading the page. @@ -222,7 +221,10 @@ |<>|Turn on Qt HighDPI scaling. |<>|Show a scrollbar. |<>|Enable smooth scrolling for web pages. -|<>|Name of the session to save by default. +|<>|When to find text on a page case-insensitively. +|<>|Find text on a page incrementally, renewing the search for each typed character. +|<>|Name of the session to save by default. +|<>|Load a restored tab as soon as it takes focus. |<>|Languages to use for spell checking. |<>|Hide the statusbar unless a message is shown. |<>|Padding (in pixels) for the statusbar. @@ -239,6 +241,7 @@ |<>|Position of new tabs opened from another tab. |<>|Position of new tabs which aren't opened from another tab. |<>|Padding (in pixels) around text for tabs. +|<>|Stay in insert/passthrough mode when switching tabs. |<>|Shrink pinned tabs down to their contents. |<>|Position of the tab bar. |<>|Which tab to select when the focused tab is removed. @@ -424,19 +427,20 @@ Default: * +pass:[<Ctrl-K>]+: +pass:[rl-kill-line]+ * +pass:[<Ctrl-N>]+: +pass:[command-history-next]+ * +pass:[<Ctrl-P>]+: +pass:[command-history-prev]+ +* +pass:[<Ctrl-Return>]+: +pass:[command-accept --rapid]+ * +pass:[<Ctrl-Shift-C>]+: +pass:[completion-item-yank --sel]+ * +pass:[<Ctrl-Shift-Tab>]+: +pass:[completion-item-focus prev-category]+ * +pass:[<Ctrl-Tab>]+: +pass:[completion-item-focus next-category]+ * +pass:[<Ctrl-U>]+: +pass:[rl-unix-line-discard]+ * +pass:[<Ctrl-W>]+: +pass:[rl-unix-word-rubout]+ * +pass:[<Ctrl-Y>]+: +pass:[rl-yank]+ -* +pass:[<Down>]+: +pass:[command-history-next]+ +* +pass:[<Down>]+: +pass:[completion-item-focus --history next]+ * +pass:[<Escape>]+: +pass:[leave-mode]+ * +pass:[<Return>]+: +pass:[command-accept]+ * +pass:[<Shift-Delete>]+: +pass:[completion-item-del]+ * +pass:[<Shift-Tab>]+: +pass:[completion-item-focus prev]+ * +pass:[<Tab>]+: +pass:[completion-item-focus next]+ -* +pass:[<Up>]+: +pass:[command-history-prev]+ +* +pass:[<Up>]+: +pass:[completion-item-focus --history prev]+ - +pass:[hint]+: * +pass:[<Ctrl-B>]+: +pass:[hint all tab-bg]+ @@ -697,10 +701,15 @@ Default: +pass:[#333333]+ [[colors.completion.fg]] === colors.completion.fg Text color of the completion widget. +May be a single color to use for all columns or a list of three colors, one for each column. -Type: <> +Type: <> -Default: +pass:[white]+ +Default: + +- +pass:[white]+ +- +pass:[white]+ +- +pass:[white]+ [[colors.completion.item.selected.bg]] === colors.completion.item.selected.bg @@ -1455,6 +1464,7 @@ This setting is only available with the QtWebKit backend. [[content.cache.size]] === content.cache.size Size (in bytes) of the HTTP network cache. Null to use the default value. +With QtWebEngine, the maximum supported value is 2147483647 (~2 GB). Type: <> @@ -2323,20 +2333,6 @@ Type: <> Default: +pass:[30]+ -[[ignore_case]] -=== ignore_case -When to find text on a page case-insensitively. - -Type: <> - -Valid values: - - * +always+: Search case-insensitively. - * +never+: Search case-sensitively. - * +smart+: Search case-sensitively if there are capital characters. - -Default: +pass:[smart]+ - [[input.forward_unbound_keys]] === input.forward_unbound_keys Which unbound keys to forward to the webview in normal mode. @@ -2554,8 +2550,30 @@ Type: <> Default: +pass:[false]+ -[[session_default_name]] -=== session_default_name +[[search.ignore_case]] +=== search.ignore_case +When to find text on a page case-insensitively. + +Type: <> + +Valid values: + + * +always+: Search case-insensitively. + * +never+: Search case-sensitively. + * +smart+: Search case-sensitively if there are capital characters. + +Default: +pass:[smart]+ + +[[search.incremental]] +=== search.incremental +Find text on a page incrementally, renewing the search for each typed character. + +Type: <> + +Default: +pass:[true]+ + +[[session.default_name]] +=== session.default_name Name of the session to save by default. If this is set to null, the session which was last loaded is saved. @@ -2563,6 +2581,14 @@ Type: <> Default: empty +[[session.lazy_restore]] +=== session.lazy_restore +Load a restored tab as soon as it takes focus. + +Type: <> + +Default: +pass:[false]+ + [[spellcheck.languages]] === spellcheck.languages Languages to use for spell checking. @@ -2798,6 +2824,14 @@ Default: - +pass:[right]+: +pass:[5]+ - +pass:[top]+: +pass:[0]+ +[[tabs.persist_mode_on_change]] +=== tabs.persist_mode_on_change +Stay in insert/passthrough mode when switching tabs. + +Type: <> + +Default: +pass:[false]+ + [[tabs.pinned.shrink]] === tabs.pinned.shrink Shrink pinned tabs down to their contents. @@ -2894,8 +2928,9 @@ The following placeholders are defined: * `{scroll_pos}`: Page scroll position. * `{host}`: Host of the current web page. * `{backend}`: Either ''webkit'' or ''webengine'' -* `{private}` : Indicates when private mode is enabled. -* `{current_url}` : URL of the current web page. +* `{private}`: Indicates when private mode is enabled. +* `{current_url}`: URL of the current web page. +* `{protocol}`: Protocol (http/https/...) of the current web page. Type: <> diff --git a/doc/install.asciidoc b/doc/install.asciidoc index dba1410eb..4b1fd3f26 100644 --- a/doc/install.asciidoc +++ b/doc/install.asciidoc @@ -21,7 +21,7 @@ Those distributions only have Python 3.4 and a too old Qt version available, while qutebrowser requires Python 3.5 and Qt 5.7.1 or newer. It should be possible to install Python 3.5 e.g. from the -https://launchpad.net/~deadsnakes/+archive/ubuntu/ppa[deadsnakes PPA] or via_ipca +https://launchpad.net/~deadsnakes/+archive/ubuntu/ppa[deadsnakes PPA] or via https://github.com/pyenv/pyenv[pyenv], but nobody tried that yet. If you get qutebrowser running on those distributions, please @@ -35,7 +35,7 @@ Ubuntu 16.04 doesn't come with an up-to-date engine (a new enough QtWebKit, or QtWebEngine). However, it comes with Python 3.5, so you can <>. -Debian Stretch / Ubuntu 17.04 and newer +Debian Stretch / Ubuntu 17.04 and 17.10 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Those versions come with QtWebEngine in the repositories. This makes it possible @@ -54,7 +54,18 @@ Install the packages: # apt install ./qutebrowser_*_all.deb ---- -Some additional hints: +Debian Testing / Ubuntu 18.04 +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +On Debian Testing, qutebrowser is in the official repositories, and you can +install it with apt: + +---- +# apt install qutebrowser +---- + +Additional hints +~~~~~~~~~~~~~~~~ - Alternatively, you can <> to get a newer QtWebEngine version. @@ -67,8 +78,7 @@ $ python3 scripts/asciidoc2html.py ---- - If you prefer using QtWebKit, there's an up-to-date version available in - Debian experimental, or from http://repo.paretje.be/unstable/[this repository] - for Debian Stretch. + https://packages.debian.org/buster/libqt5webkit5[Debian Testing]. - If video or sound don't work with QtWebKit, try installing the gstreamer plugins: + ---- @@ -78,10 +88,29 @@ $ python3 scripts/asciidoc2html.py On Fedora --------- -The Fedora packages are lagging behind a lot and are currently effectively -unmaintained. It's recommended to <> instead. +NOTE: Fedora's packages used to be outdated for a long time, but are +now (November 2017) maintained and up-to-date again. -Related Fedora bug: https://bugzilla.redhat.com/show_bug.cgi?id=1467748[1467748] +qutebrowser is available in the official repositories: + +----- +# dnf install qutebrowser +----- + +However, note that Fedora 25/26 won't be updated to qutebrowser v1.0, so you +might want to <> instead there. + +Additional hints +~~~~~~~~~~~~~~~~ + +Fedora only ships free software in the repositories. +To be able to play videos with proprietary codecs with QtWebEngine, you will +need to install an additional package from the RPM Fusion Free repository. +For more information see https://rpmfusion.org/Configuration. + +----- +# dnf install qt5-qtwebengine-freeworld +----- On Archlinux ------------ @@ -182,6 +211,10 @@ To use the QtWebEngine backend, install `libqt5-qtwebengine`. On OpenBSD ---------- +WARNING: OpenBSD only packages a legacy unmaintained version of QtWebKit (for +which support was dropped in qutebrowser v1.0). It's advised to not use +qutebrowser from OpenBSD ports for untrusted websites. + qutebrowser is in http://cvsweb.openbsd.org/cgi-bin/cvsweb/ports/www/qutebrowser/[OpenBSD ports]. Install the package: @@ -197,6 +230,21 @@ Or alternatively, use the ports system : # make install ---- +On FreeBSD +---------- + +qutebrowser is in https://www.freshports.org/www/qutebrowser/[FreeBSD ports]. + +It can be installed with: + +---- +# cd /usr/ports/www/qutebrowser +# make install clean +---- + +At present, precompiled packages are not available for this port, +and QtWebEngine backend is also not available. + On Windows ---------- @@ -353,8 +401,8 @@ local Qt install instead of installing PyQt in the virtualenv. However, unless you have a new QtWebKit or QtWebEngine available, qutebrowser will not work. It also typically means you'll be using an older release of QtWebEngine. -On Windows, run `tox -e 'mkvenv-win' instead, however make sure that ONLY -Python3 is in your PATH before running tox. +On Windows, run `set PYTHON=C:\path\to\python.exe` (CMD) or ``$Env:PYTHON = +"..."` (Powershell) first. Creating a wrapper script ~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/misc/Makefile b/misc/Makefile new file mode 100644 index 000000000..fe97eb6bf --- /dev/null +++ b/misc/Makefile @@ -0,0 +1,25 @@ +PYTHON = python3 +DESTDIR = / +ICONSIZES = 16 24 32 48 64 128 256 512 + +.PHONY: install + +doc/qutebrowser.1.html: + a2x -f manpage doc/qutebrowser.1.asciidoc + +install: doc/qutebrowser.1.html + $(PYTHON) setup.py install --root="$(DESTDIR)" --optimize=1 + install -Dm644 doc/qutebrowser.1 \ + "$(DESTDIR)/usr/share/man/man1/qutebrowser.1" + install -Dm644 misc/qutebrowser.desktop \ + "$(DESTDIR)/usr/share/applications/qutebrowser.desktop" + $(foreach i,$(ICONSIZES),install -Dm644 "icons/qutebrowser-$(i)x$(i).png" \ + "$(DESTDIR)/usr/share/icons/hicolor/$(i)x$(i)/apps/qutebrowser.png";) + install -Dm644 icons/qutebrowser.svg \ + "$(DESTDIR)/usr/share/icons/hicolor/scalable/apps/qutebrowser.svg" + install -Dm755 -t "$(DESTDIR)/usr/share/qutebrowser/userscripts/" \ + $(wildcard misc/userscripts/*) + install -Dm755 -t "$(DESTDIR)/usr/share/qutebrowser/scripts/" \ + $(filter-out scripts/__init__.py scripts/__pycache__ scripts/dev \ + scripts/testbrowser_cpp scripts/asciidoc2html.py scripts/setupcommon.py \ + scripts/link_pyqt.py,$(wildcard scripts/*)) diff --git a/misc/qutebrowser.desktop b/misc/qutebrowser.desktop index e505774a8..96cbda392 100644 --- a/misc/qutebrowser.desktop +++ b/misc/qutebrowser.desktop @@ -7,5 +7,5 @@ Categories=Network;WebBrowser; Exec=qutebrowser %u Terminal=false StartupNotify=false -MimeType=text/html;text/xml;application/xhtml+xml;application/xml;application/rdf+xml;image/gif;image/jpeg;image/png;x-scheme-handler/http;x-scheme-handler/https; +MimeType=text/html;text/xml;application/xhtml+xml;application/xml;application/rdf+xml;image/gif;image/jpeg;image/png;x-scheme-handler/http;x-scheme-handler/https;x-scheme-handler/qute; Keywords=Browser diff --git a/misc/requirements/README.md b/misc/requirements/README.md index f38c4443e..6ae986279 100644 --- a/misc/requirements/README.md +++ b/misc/requirements/README.md @@ -1,5 +1,5 @@ This directory contains various `requirements` files which are used by `tox` to -have reproducable tests with pinned versions. +have reproducible tests with pinned versions. The files are generated based on unpinned requirements in `*.txt-raw` files. diff --git a/misc/requirements/requirements-check-manifest.txt b/misc/requirements/requirements-check-manifest.txt index 42724e0c7..954e3a562 100644 --- a/misc/requirements/requirements-check-manifest.txt +++ b/misc/requirements/requirements-check-manifest.txt @@ -1,3 +1,3 @@ # This file is automatically generated by scripts/dev/recompile_requirements.py -check-manifest==0.35 +check-manifest==0.36 diff --git a/misc/requirements/requirements-codecov.txt b/misc/requirements/requirements-codecov.txt index 31c319c39..6601cfb12 100644 --- a/misc/requirements/requirements-codecov.txt +++ b/misc/requirements/requirements-codecov.txt @@ -2,7 +2,7 @@ certifi==2017.11.5 chardet==3.0.4 -codecov==2.0.9 +codecov==2.0.10 coverage==4.4.2 idna==2.6 requests==2.18.4 diff --git a/misc/requirements/requirements-flake8.txt b/misc/requirements/requirements-flake8.txt index 053d06ec4..bf7a02389 100644 --- a/misc/requirements/requirements-flake8.txt +++ b/misc/requirements/requirements-flake8.txt @@ -1,23 +1,25 @@ # This file is automatically generated by scripts/dev/recompile_requirements.py -flake8==2.6.2 # rq.filter: < 3.0.0 +attrs==17.3.0 +flake8==3.5.0 +flake8-bugbear==17.12.0 +flake8-builtins==1.0.post0 +flake8-comprehensions==1.4.1 flake8-copyright==0.2.0 flake8-debugger==3.0.0 -flake8-deprecated==1.2.1 # rq.filter: < 1.3 -flake8-docstrings==1.0.3 # rq.filter: < 1.1.0 +flake8-deprecated==1.3 +flake8-docstrings==1.1.0 flake8-future-import==0.4.3 flake8-mock==0.3 -flake8-pep3101==1.0 # rq.filter: < 1.1 +flake8-per-file-ignores==0.4 flake8-polyfill==1.0.1 -flake8-putty==0.4.0 flake8-string-format==0.2.3 flake8-tidy-imports==1.1.0 flake8-tuple==0.2.13 mccabe==0.6.1 -packaging==16.8 pep8-naming==0.4.1 pycodestyle==2.3.1 -pydocstyle==1.1.1 # rq.filter: < 2.0.0 +pydocstyle==2.1.1 pyflakes==1.6.0 -pyparsing==2.2.0 six==1.11.0 +snowballstemmer==1.2.1 diff --git a/misc/requirements/requirements-flake8.txt-raw b/misc/requirements/requirements-flake8.txt-raw index 7f0b5153a..1f30b83ae 100644 --- a/misc/requirements/requirements-flake8.txt-raw +++ b/misc/requirements/requirements-flake8.txt-raw @@ -1,27 +1,17 @@ -flake8<3.0.0 +flake8 +flake8-bugbear +flake8-builtins +flake8-comprehensions flake8-copyright flake8-debugger -flake8-deprecated<1.3 -flake8-docstrings<1.1.0 +flake8-deprecated +flake8-docstrings flake8-future-import flake8-mock -flake8-pep3101<1.1 -flake8-putty +flake8-per-file-ignores flake8-string-format flake8-tidy-imports flake8-tuple pep8-naming -pydocstyle<2.0.0 +pydocstyle pyflakes - -# Pinned to 2.0.0 otherwise -pycodestyle==2.3.1 -# Pinned to 0.5.3 otherwise -mccabe==0.6.1 - -# Waiting until flake8-putty updated -#@ filter: flake8 < 3.0.0 -#@ filter: pydocstyle < 2.0.0 -#@ filter: flake8-docstrings < 1.1.0 -#@ filter: flake8-pep3101 < 1.1 -#@ filter: flake8-deprecated < 1.3 diff --git a/misc/requirements/requirements-pip.txt b/misc/requirements/requirements-pip.txt index 8ec830003..42fae6bc9 100644 --- a/misc/requirements/requirements-pip.txt +++ b/misc/requirements/requirements-pip.txt @@ -3,6 +3,6 @@ appdirs==1.4.3 packaging==16.8 pyparsing==2.2.0 -setuptools==36.7.1 +setuptools==38.2.5 six==1.11.0 wheel==0.30.0 diff --git a/misc/requirements/requirements-pyinstaller.txt b/misc/requirements/requirements-pyinstaller.txt index e542e4243..f65e8c62a 100644 --- a/misc/requirements/requirements-pyinstaller.txt +++ b/misc/requirements/requirements-pyinstaller.txt @@ -1,7 +1,7 @@ # This file is automatically generated by scripts/dev/recompile_requirements.py -altgraph==0.14 +altgraph==0.15 future==0.16.0 -macholib==1.8 +macholib==1.9 pefile==2017.11.5 -e git+https://github.com/pyinstaller/pyinstaller.git@develop#egg=PyInstaller diff --git a/misc/requirements/requirements-pylint.txt b/misc/requirements/requirements-pylint.txt index cab15c497..6267fc2b0 100644 --- a/misc/requirements/requirements-pylint.txt +++ b/misc/requirements/requirements-pylint.txt @@ -1,6 +1,6 @@ # This file is automatically generated by scripts/dev/recompile_requirements.py -astroid==1.5.3 +astroid==1.6.0 certifi==2017.11.5 chardet==3.0.4 github3.py==0.9.6 @@ -8,7 +8,7 @@ idna==2.6 isort==4.2.15 lazy-object-proxy==1.3.1 mccabe==0.6.1 -pylint==1.7.4 +pylint==1.8.1 ./scripts/dev/pylint_checkers requests==2.18.4 six==1.11.0 diff --git a/misc/requirements/requirements-pyqt.txt b/misc/requirements/requirements-pyqt.txt index 0ced8d869..5a08f2f73 100644 --- a/misc/requirements/requirements-pyqt.txt +++ b/misc/requirements/requirements-pyqt.txt @@ -1,4 +1,4 @@ # This file is automatically generated by scripts/dev/recompile_requirements.py -PyQt5==5.9 # rq.filter: != 5.9.1 -sip==4.19.5 +PyQt5==5.9.2 +sip==4.19.6 diff --git a/misc/requirements/requirements-pyqt.txt-raw b/misc/requirements/requirements-pyqt.txt-raw index bca0092dc..37a69c45a 100644 --- a/misc/requirements/requirements-pyqt.txt-raw +++ b/misc/requirements/requirements-pyqt.txt-raw @@ -1,3 +1 @@ -#@ filter: PyQt5 != 5.9.1 - -PyQt5==5.9 \ No newline at end of file +PyQt5 \ No newline at end of file diff --git a/misc/requirements/requirements-pyroma.txt b/misc/requirements/requirements-pyroma.txt index d6ed0c190..241273169 100644 --- a/misc/requirements/requirements-pyroma.txt +++ b/misc/requirements/requirements-pyroma.txt @@ -1,4 +1,4 @@ # This file is automatically generated by scripts/dev/recompile_requirements.py docutils==0.14 -pyroma==2.2 +pyroma==2.3 diff --git a/misc/requirements/requirements-tests-git.txt b/misc/requirements/requirements-tests-git.txt index 6b31140bb..6681dd15e 100644 --- a/misc/requirements/requirements-tests-git.txt +++ b/misc/requirements/requirements-tests-git.txt @@ -12,12 +12,6 @@ git+https://github.com/jenisys/parse_type.git hg+https://bitbucket.org/pytest-dev/py git+https://github.com/pytest-dev/pytest.git@features git+https://github.com/pytest-dev/pytest-bdd.git - -# This is broken at the moment because logfail tries to access -# LogCaptureHandler -# git+https://github.com/eisensheng/pytest-catchlog.git -pytest-catchlog==1.2.2 - git+https://github.com/pytest-dev/pytest-cov.git git+https://github.com/pytest-dev/pytest-faulthandler.git git+https://github.com/pytest-dev/pytest-instafail.git diff --git a/misc/requirements/requirements-tests.txt b/misc/requirements/requirements-tests.txt index 42a0280a3..90b60df47 100644 --- a/misc/requirements/requirements-tests.txt +++ b/misc/requirements/requirements-tests.txt @@ -2,7 +2,7 @@ attrs==17.3.0 beautifulsoup4==4.6.0 -cheroot==5.8.3 +cheroot==6.0.0 click==6.7 # colorama==0.3.9 coverage==4.4.2 @@ -10,30 +10,30 @@ EasyProcess==0.2.3 fields==5.0.0 Flask==0.12.2 glob2==0.6 -hunter==2.0.1 -hypothesis==3.37.0 +hunter==2.0.2 +hypothesis==3.44.4 itsdangerous==0.24 -# Jinja2==2.9.6 +# Jinja2==2.10 Mako==1.0.7 # MarkupSafe==1.0 parse==1.8.2 parse-type==0.4.2 -py==1.4.34 +pluggy==0.6.0 +py==1.5.2 py-cpuinfo==3.3.0 -pytest==3.2.3 +pytest==3.3.1 pytest-bdd==2.19.0 pytest-benchmark==3.1.1 -pytest-catchlog==1.2.2 pytest-cov==2.5.1 pytest-faulthandler==1.3.1 pytest-instafail==0.3.0 pytest-mock==1.6.3 -pytest-qt==2.2.1 +pytest-qt==2.3.0 pytest-repeat==0.4.1 -pytest-rerunfailures==3.1 -pytest-travis-fold==1.2.0 +pytest-rerunfailures==4.0 +pytest-travis-fold==1.3.0 pytest-xvfb==1.0.0 PyVirtualDisplay==0.2.1 six==1.11.0 vulture==0.26 -Werkzeug==0.12.2 +Werkzeug==0.13 diff --git a/misc/requirements/requirements-tests.txt-raw b/misc/requirements/requirements-tests.txt-raw index bc44bc8e1..121689980 100644 --- a/misc/requirements/requirements-tests.txt-raw +++ b/misc/requirements/requirements-tests.txt-raw @@ -7,7 +7,6 @@ hypothesis pytest pytest-bdd pytest-benchmark -pytest-catchlog pytest-cov pytest-faulthandler pytest-instafail diff --git a/misc/requirements/requirements-tox.txt b/misc/requirements/requirements-tox.txt index 76e7c1ff2..d2b3a719b 100644 --- a/misc/requirements/requirements-tox.txt +++ b/misc/requirements/requirements-tox.txt @@ -1,6 +1,7 @@ # This file is automatically generated by scripts/dev/recompile_requirements.py -pluggy==0.5.2 -py==1.4.34 +pluggy==0.6.0 +py==1.5.2 +six==1.11.0 tox==2.9.1 virtualenv==15.1.0 diff --git a/misc/userscripts/cast b/misc/userscripts/cast index da68297d8..f7b64df70 100755 --- a/misc/userscripts/cast +++ b/misc/userscripts/cast @@ -144,7 +144,7 @@ fi pkill -f "${program_}" # start youtube download in stream mode (-o -) into temporary file -youtube-dl -qo - "$1" > ${file_to_cast} & +youtube-dl -qo - "$1" > "${file_to_cast}" & ytdl_pid=$! msg info "Casting $1" >> "$QUTE_FIFO" @@ -153,4 +153,4 @@ tail -F "${file_to_cast}" | ${program_} - # cleanup remaining background process and file on disk kill ${ytdl_pid} -rm -rf ${tmpdir} +rm -rf "${tmpdir}" diff --git a/misc/userscripts/dmenu_qutebrowser b/misc/userscripts/dmenu_qutebrowser index 9c809d5ad..82e6d2f18 100755 --- a/misc/userscripts/dmenu_qutebrowser +++ b/misc/userscripts/dmenu_qutebrowser @@ -41,7 +41,7 @@ [ -z "$QUTE_URL" ] && QUTE_URL='http://google.com' url=$(echo "$QUTE_URL" | cat - "$QUTE_CONFIG_DIR/quickmarks" "$QUTE_DATA_DIR/history" | dmenu -l 15 -p qutebrowser) -url=$(echo "$url" | sed -E 's/[^ ]+ +//g' | egrep "https?:" || echo "$url") +url=$(echo "$url" | sed -E 's/[^ ]+ +//g' | grep -E "https?:" || echo "$url") [ -z "${url// }" ] && exit diff --git a/misc/userscripts/format_json b/misc/userscripts/format_json index f756850f1..0d476b327 100755 --- a/misc/userscripts/format_json +++ b/misc/userscripts/format_json @@ -1,4 +1,5 @@ #!/bin/sh +set -euo pipefail # # Behavior: # Userscript for qutebrowser which will take the raw JSON text of the current @@ -19,29 +20,23 @@ # # Bryan Gilbert, 2017 +# do not run pygmentize on files larger than this amount of bytes +MAX_SIZE_PRETTIFY=10485760 # 10 MB # default style to monokai if none is provided STYLE=${1:-monokai} -# format json using jq -FORMATTED_JSON="$(cat "$QUTE_TEXT" | jq '.')" -# if jq command failed or formatted json is empty, assume failure and terminate -if [ $? -ne 0 ] || [ -z "$FORMATTED_JSON" ]; then - echo "Invalid json, aborting..." - exit 1 +TEMP_FILE="$(mktemp)" +jq . "$QUTE_TEXT" >"$TEMP_FILE" + +# try GNU stat first and then OSX stat if the former fails +FILE_SIZE=$( + stat --printf="%s" "$TEMP_FILE" 2>/dev/null || + stat -f%z "$TEMP_FILE" 2>/dev/null +) +if [ "$FILE_SIZE" -lt "$MAX_SIZE_PRETTIFY" ]; then + pygmentize -l json -f html -O full,style="$STYLE" <"$TEMP_FILE" >"${TEMP_FILE}_" + mv -f "${TEMP_FILE}_" "$TEMP_FILE" fi -# calculate the filesize of the json document -FILE_SIZE=$(ls -s --block-size=1048576 "$QUTE_TEXT" | cut -d' ' -f1) - -# use pygments to pretty-up the json (syntax highlight) if file is less than 10MB -if [ "$FILE_SIZE" -lt "10" ]; then - FORMATTED_JSON="$(echo "$FORMATTED_JSON" | pygmentize -l json -f html -O full,style=$STYLE)" -fi - -# create a temp file and write the formatted json to that file -TEMP_FILE="$(mktemp --suffix '.html')" -echo "$FORMATTED_JSON" > $TEMP_FILE - - # send the command to qutebrowser to open the new file containing the formatted json echo "open -t file://$TEMP_FILE" >> "$QUTE_FIFO" diff --git a/misc/userscripts/open_download b/misc/userscripts/open_download index 6c1213b65..ecc1d7209 100755 --- a/misc/userscripts/open_download +++ b/misc/userscripts/open_download @@ -76,6 +76,7 @@ crop-first-column() { ls-files() { # add the slash at the end of the download dir enforces to follow the # symlink, if the DOWNLOAD_DIR itself is a symlink + # shellcheck disable=SC2010 ls -Q --quoting-style escape -h -o -1 -A -t "${DOWNLOAD_DIR}/" \ | grep '^[-]' \ | cut -d' ' -f3- \ @@ -91,10 +92,10 @@ if [ "${#entries[@]}" -eq 0 ] ; then die "Download directory »${DOWNLOAD_DIR}« empty" fi -line=$(printf "%s\n" "${entries[@]}" \ +line=$(printf '%s\n' "${entries[@]}" \ | crop-first-column 55 \ | column -s $'\t' -t \ - | $ROFI_CMD "${rofi_default_args[@]}" $ROFI_ARGS) || true + | $ROFI_CMD "${rofi_default_args[@]}" "$ROFI_ARGS") || true if [ -z "$line" ]; then exit 0 fi diff --git a/misc/userscripts/password_fill b/misc/userscripts/password_fill index af394ac2c..8dba68c2b 100755 --- a/misc/userscripts/password_fill +++ b/misc/userscripts/password_fill @@ -64,7 +64,7 @@ die() { javascript_escape() { # print the first argument in an escaped way, such that it can safely # be used within javascripts double quotes - sed "s,[\\\'\"],\\\&,g" <<< "$1" + sed "s,[\\\\'\"],\\\\&,g" <<< "$1" } # ======================================================= # @@ -178,7 +178,7 @@ choose_entry_menu() { if [ "$nr" -eq 1 ] && ! ((menu_if_one_entry)) ; then file="${files[0]}" else - file=$( printf "%s\n" "${files[@]}" | "${MENU_COMMAND[@]}" ) + file=$( printf '%s\n' "${files[@]}" | "${MENU_COMMAND[@]}" ) fi } @@ -236,7 +236,7 @@ pass_backend() { if ((match_line)) ; then # add entries with matching URL-tag while read -r -d "" passfile ; do - if $GPG "${GPG_OPTS}" -d "$passfile" \ + if $GPG "${GPG_OPTS[@]}" -d "$passfile" \ | grep --max-count=1 -iE "${match_line_pattern}${url}" > /dev/null then passfile="${passfile#$PREFIX}" @@ -269,7 +269,7 @@ pass_backend() { break fi fi - done < <($GPG "${GPG_OPTS}" -d "$path" ) + done < <($GPG "${GPG_OPTS[@]}" -d "$path" ) } } # ======================================================= @@ -283,8 +283,8 @@ secret_backend() { query_entries() { local domain="$1" while read -r line ; do - if [[ "$line" =~ "attribute.username = " ]] ; then - files+=("$domain ${line#${BASH_REMATCH[0]}}") + if [[ "$line" == "attribute.username = "* ]] ; then + files+=("$domain ${line:21}") fi done < <( secret-tool search --unlock --all domain "$domain" 2>&1 ) } @@ -303,6 +303,7 @@ pass_backend QUTE_CONFIG_DIR=${QUTE_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/qutebrowser/} PWFILL_CONFIG=${PWFILL_CONFIG:-${QUTE_CONFIG_DIR}/password_fill_rc} if [ -f "$PWFILL_CONFIG" ] ; then + # shellcheck source=/dev/null source "$PWFILL_CONFIG" fi init @@ -311,7 +312,7 @@ simplify_url "$QUTE_URL" query_entries "${simple_url}" no_entries_found # remove duplicates -mapfile -t files < <(printf "%s\n" "${files[@]}" | sort | uniq ) +mapfile -t files < <(printf '%s\n' "${files[@]}" | sort | uniq ) choose_entry if [ -z "$file" ] ; then # choose_entry didn't want any of these entries diff --git a/misc/userscripts/qute-pass b/misc/userscripts/qute-pass index 1592d6349..5bab9db93 100755 --- a/misc/userscripts/qute-pass +++ b/misc/userscripts/qute-pass @@ -17,20 +17,21 @@ # You should have received a copy of the GNU General Public License # along with qutebrowser. If not, see . +""" +Insert login information using pass and a dmenu-compatible application (e.g. dmenu, rofi -dmenu, ...). A short +demonstration can be seen here: https://i.imgur.com/KN3XuZP.gif. +""" -# Insert login information using pass and a dmenu-provider (e.g. dmenu, rofi -dmenu, ...). -# A short demonstration can be seen here: https://i.imgur.com/KN3XuZP.gif. -# -# The domain of the site has to appear as a segment in the pass path, for example: "github.com/cryzed" or -# "websites/github.com". How the username and password are determined is freely configurable using the CLI arguments. -# The login information is inserted by emulating key events using qutebrowser's fake-key command in this manner: -# [USERNAME][PASSWORD], which is compatible with almost all login forms. -# -# Dependencies: tldextract (Python 3 module), pass -# For issues and feedback please use: https://github.com/cryzed/qutebrowser-userscripts. -# -# WARNING: The login details are viewable as plaintext in qutebrowser's debug log (qute://log) and might be shared if -# you decide to submit a crash report! +USAGE = """The domain of the site has to appear as a segment in the pass path, for example: "github.com/cryzed" or +"websites/github.com". How the username and password are determined is freely configurable using the CLI arguments. The +login information is inserted by emulating key events using qutebrowser's fake-key command in this manner: +[USERNAME][PASSWORD], which is compatible with almost all login forms.""" + +EPILOG = """Dependencies: tldextract (Python 3 module), pass. +For issues and feedback please use: https://github.com/cryzed/qutebrowser-userscripts. + +WARNING: The login details are viewable as plaintext in qutebrowser's debug log (qute://log) and might be shared if +you decide to submit a crash report!""" import argparse import enum @@ -44,8 +45,8 @@ import sys import tldextract -argument_parser = argparse.ArgumentParser() -argument_parser.add_argument('url', nargs='?', default=os.environ['QUTE_URL']) +argument_parser = argparse.ArgumentParser(description=__doc__, usage=USAGE, epilog=EPILOG) +argument_parser.add_argument('url', nargs='?', default=os.getenv('QUTE_URL')) argument_parser.add_argument('--password-store', '-p', default=os.path.expanduser('~/.password-store'), help='Path to your pass password-store') argument_parser.add_argument('--username-pattern', '-u', default=r'.*/(.+)', @@ -71,6 +72,7 @@ stderr = functools.partial(print, file=sys.stderr) class ExitCodes(enum.IntEnum): SUCCESS = 0 + FAILURE = 1 # 1 is automatically used if Python throws an exception NO_PASS_CANDIDATES = 2 COULD_NOT_MATCH_USERNAME = 3 @@ -108,6 +110,10 @@ def dmenu(items, invocation, encoding): def main(arguments): + if not arguments.url: + argument_parser.print_help() + return ExitCodes.FAILURE + extract_result = tldextract.extract(arguments.url) # Expand potential ~ in paths, since this script won't be called from a shell that does it for us diff --git a/misc/userscripts/qutedmenu b/misc/userscripts/qutedmenu index 3f8b13514..de1b8d641 100755 --- a/misc/userscripts/qutedmenu +++ b/misc/userscripts/qutedmenu @@ -35,17 +35,12 @@ get_selection() { # Main # https://github.com/halfwit/dotfiles/blob/master/.config/dmenu/font -if [[ -s $confdir/dmenu/font ]]; then - read -r font < "$confdir"/dmenu/font -fi +[[ -s $confdir/dmenu/font ]] && read -r font < "$confdir"/dmenu/font -if [[ $font ]]; then - opts+=(-fn "$font") -fi +[[ $font ]] && opts+=(-fn "$font") -if [[ -s $optsfile ]]; then - source "$optsfile" -fi +# shellcheck source=/dev/null +[[ -s $optsfile ]] && source "$optsfile" url=$(get_selection) url=${url/*http/http} diff --git a/misc/userscripts/rss b/misc/userscripts/rss index 222d990a2..f8feebee7 100755 --- a/misc/userscripts/rss +++ b/misc/userscripts/rss @@ -32,7 +32,7 @@ add_feed () { if grep -Fq "$1" "feeds"; then notice "$1 is saved already." else - printf "%s\n" "$1" >> "feeds" + printf '%s\n' "$1" >> "feeds" fi } @@ -57,7 +57,7 @@ notice () { # Update a database of a feed and open new URLs read_items () { - cd read_urls + cd read_urls || return 1 feed_file="$(echo "$1" | tr -d /)" feed_temp_file="$(mktemp "$feed_file.tmp.XXXXXXXXXX")" feed_new_items="$(mktemp "$feed_file.new.XXXXXXXXXX")" @@ -75,7 +75,7 @@ read_items () { cat "$feed_new_items" >> "$feed_file" sort -o "$feed_file" "$feed_file" rm "$feed_temp_file" "$feed_new_items" - fi | while read item; do + fi | while read -r item; do echo "open -t $item" > "$QUTE_FIFO" done } @@ -85,7 +85,7 @@ if [ ! -d "$config_dir/read_urls" ]; then mkdir -p "$config_dir/read_urls" fi -cd "$config_dir" +cd "$config_dir" || exit 1 if [ $# != 0 ]; then for arg in "$@"; do @@ -115,7 +115,7 @@ if < /dev/null grep --help 2>&1 | grep -q -- -a; then text_only="-a" fi -while read feed_url; do +while read -r feed_url; do read_items "$feed_url" & done < "$config_dir/feeds" diff --git a/misc/userscripts/taskadd b/misc/userscripts/taskadd index 6add71c68..b1ded245c 100755 --- a/misc/userscripts/taskadd +++ b/misc/userscripts/taskadd @@ -25,12 +25,10 @@ [[ $QUTE_MODE == 'hints' ]] && title=$QUTE_SELECTED_TEXT || title=$QUTE_TITLE # try to add the task and grab the output -msg="$(task add $title $@ 2>&1)" - -if [[ $? == 0 ]]; then +if msg="$(task add "$title" "$*" 2>&1)"; then # annotate the new task with the url, send the output back to the browser task +LATEST annotate "$QUTE_URL" - echo "message-info '$msg'" >> $QUTE_FIFO + echo "message-info '$msg'" >> "$QUTE_FIFO" else - echo "message-error '$msg'" >> $QUTE_FIFO + echo "message-error '$msg'" >> "$QUTE_FIFO" fi diff --git a/misc/userscripts/view_in_mpv b/misc/userscripts/view_in_mpv index 9eb6ff7c6..f465fc4e4 100755 --- a/misc/userscripts/view_in_mpv +++ b/misc/userscripts/view_in_mpv @@ -50,7 +50,7 @@ msg() { MPV_COMMAND=${MPV_COMMAND:-mpv} # Warning: spaces in single flags are not supported MPV_FLAGS=${MPV_FLAGS:- --force-window --no-terminal --keep-open=yes --ytdl --ytdl-raw-options=yes-playlist=} -video_command=( "$MPV_COMMAND" $MPV_FLAGS ) +IFS=" " read -r -a video_command <<< "$MPV_COMMAND $MPV_FLAGS" js() { cat <= 0: self._go_to_item(self._item_at(idx)) @@ -494,6 +496,7 @@ class AbstractHistory: raise WebTabError("At beginning of history.") def forward(self, count=1): + """Go forward in the tab's history.""" idx = self.current_idx() + count if idx < len(self): self._go_to_item(self._item_at(idx)) @@ -703,8 +706,8 @@ class AbstractTab(QWidget): # This only gives us some mild protection against re-using events, but # it's certainly better than a segfault. if getattr(evt, 'posted', False): - raise AssertionError("Can't re-use an event which was already " - "posted!") + raise utils.Unreachable("Can't re-use an event which was already " + "posted!") recipient = self.event_target() evt.posted = True QApplication.postEvent(recipient, evt) @@ -864,3 +867,6 @@ class AbstractTab(QWidget): except (AttributeError, RuntimeError) as exc: url = '<{}>'.format(exc.__class__.__name__) return utils.get_repr(self, tab_id=self.tab_id, url=url) + + def is_deleted(self): + return sip.isdeleted(self._widget) diff --git a/qutebrowser/browser/commands.py b/qutebrowser/browser/commands.py index 53fb03830..d3f865a52 100644 --- a/qutebrowser/browser/commands.py +++ b/qutebrowser/browser/commands.py @@ -39,7 +39,7 @@ from qutebrowser.browser import (urlmarks, browsertab, inspector, navigate, webelem, downloads) from qutebrowser.keyinput import modeman from qutebrowser.utils import (message, usertypes, log, qtutils, urlutils, - objreg, utils, debug) + objreg, utils, debug, standarddir) from qutebrowser.utils.usertypes import KeyMode from qutebrowser.misc import editor, guiprocess from qutebrowser.completion.models import urlmodel, miscmodels @@ -520,7 +520,7 @@ class CommandDispatcher: return newtab @cmdutils.register(instance='command-dispatcher', scope='window') - @cmdutils.argument('index', completion=miscmodels.buffer) + @cmdutils.argument('index', completion=miscmodels.other_buffer) def tab_take(self, index): """Take a tab from another window. @@ -675,7 +675,7 @@ class CommandDispatcher: self._open(new_url, tab, bg, window, related=True) else: # pragma: no cover raise ValueError("Got called with invalid value {} for " - "`where'.".format(where)) + "`where'.".format(where)) except navigate.Error as e: raise cmdexc.CommandError(e) @@ -1194,7 +1194,8 @@ class CommandDispatcher: @cmdutils.register(instance='command-dispatcher', scope='window', maxsplit=0, no_replace_variables=True) - def spawn(self, cmdline, userscript=False, verbose=False, detach=False): + def spawn(self, cmdline, userscript=False, verbose=False, + output=False, detach=False): """Spawn a command in a shell. Args: @@ -1205,6 +1206,7 @@ class CommandDispatcher: (or `$XDG_DATA_DIR`) - `/usr/share/qutebrowser/userscripts` verbose: Show notifications when the command started/exited. + output: Whether the output should be shown in a new tab. detach: Whether the command should be detached from qutebrowser. cmdline: The commandline to execute. """ @@ -1241,6 +1243,11 @@ class CommandDispatcher: else: proc.start(cmd, args) + if output: + tabbed_browser = objreg.get('tabbed-browser', scope='window', + window='last-focused') + tabbed_browser.openurl(QUrl('qute://spawn-output'), newtab=True) + @cmdutils.register(instance='command-dispatcher', scope='window') def home(self): """Open main startpage in current tab.""" @@ -1454,27 +1461,14 @@ class CommandDispatcher: raise cmdexc.CommandError(e) @cmdutils.register(instance='command-dispatcher', scope='window') - @cmdutils.argument('dest_old', hide=True) - def download(self, url=None, dest_old=None, *, mhtml_=False, dest=None): + def download(self, url=None, *, mhtml_=False, dest=None): """Download a given URL, or current page if no URL given. - The form `:download [url] [dest]` is deprecated, use `:download --dest - [dest] [url]` instead. - Args: url: The URL to download. If not given, download the current page. - dest_old: (deprecated) Same as dest. dest: The file path to write the download to, or None to ask. mhtml_: Download the current page and all assets as mhtml file. """ - if dest_old is not None: - message.warning(":download [url] [dest] is deprecated - use " - ":download --dest [dest] [url]") - if dest is not None: - raise cmdexc.CommandError("Can't give two destinations for the" - " download.") - dest = dest_old - # FIXME:qtwebengine do this with the QtWebEngine download manager? download_manager = objreg.get('qtnetwork-download-manager', scope='window', window=self._win_id) @@ -1564,6 +1558,7 @@ class CommandDispatcher: dest = os.path.expanduser(dest) def callback(data): + """Write the data to disk.""" try: with open(dest, 'w', encoding='utf-8') as f: f.write(data) @@ -1680,6 +1675,8 @@ class CommandDispatcher: """ try: elem.set_value(text) + except webelem.OrphanedError as e: + message.error('Edited element vanished') except webelem.Error as e: raise cmdexc.CommandError(str(e)) @@ -1776,7 +1773,8 @@ class CommandDispatcher: elif going_up and tab.scroller.pos_px().y() > old_scroll_pos.y(): message.info("Search hit TOP, continuing at BOTTOM") else: - message.warning("Text '{}' not found on page!".format(text)) + message.warning("Text '{}' not found on page!".format(text), + replace=True) @cmdutils.register(instance='command-dispatcher', scope='window', maxsplit=0) @@ -1796,7 +1794,7 @@ class CommandDispatcher: return options = { - 'ignore_case': config.val.ignore_case, + 'ignore_case': config.val.search.ignore_case, 'reverse': reverse, } @@ -2065,6 +2063,9 @@ class CommandDispatcher: Args: js_code: The string/file to evaluate. file: Interpret js-code as a path to a file. + If the path is relative, the file is searched in a js/ subdir + in qutebrowser's data dir, e.g. + `~/.local/share/qutebrowser/js`. quiet: Don't show resulting JS object. world: Ignored on QtWebKit. On QtWebEngine, a world ID or name to run the snippet in. @@ -2076,6 +2077,7 @@ class CommandDispatcher: 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 @@ -2094,6 +2096,9 @@ class CommandDispatcher: if file: path = os.path.expanduser(js_code) + if not os.path.isabs(path): + path = os.path.join(standarddir.data(), 'js', path) + try: with open(path, 'r', encoding='utf-8') as f: js_code = f.read() diff --git a/qutebrowser/browser/downloads.py b/qutebrowser/browser/downloads.py index 5a2daae7e..c064d700e 100644 --- a/qutebrowser/browser/downloads.py +++ b/qutebrowser/browser/downloads.py @@ -103,6 +103,8 @@ def immediate_download_path(prompt_download_directory=None): if not prompt_download_directory: return download_dir() + return None + def _path_suggestion(filename): """Get the suggested file path. @@ -180,7 +182,7 @@ def transform_path(path): path = utils.expand_windows_drive(path) # Drive dependent working directories are not supported, e.g. # E:filename is invalid - if re.match(r'[A-Z]:[^\\]', path, re.IGNORECASE): + if re.search(r'^[A-Z]:[^\\]', path, re.IGNORECASE): return None # Paths like COM1, ... # See https://github.com/qutebrowser/qutebrowser/issues/82 @@ -990,7 +992,7 @@ class DownloadModel(QAbstractListModel): if not count: count = len(self) raise cmdexc.CommandError("Download {} is already done!" - .format(count)) + .format(count)) download.cancel() @cmdutils.register(instance='download-model', scope='window') diff --git a/qutebrowser/browser/greasemonkey.py b/qutebrowser/browser/greasemonkey.py new file mode 100644 index 000000000..9a82d6a93 --- /dev/null +++ b/qutebrowser/browser/greasemonkey.py @@ -0,0 +1,224 @@ +# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et: + +# Copyright 2017 Florian Bruhin (The Compiler) +# +# This file is part of qutebrowser. +# +# qutebrowser is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# qutebrowser is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with qutebrowser. If not, see . + +"""Load, parse and make available Greasemonkey scripts.""" + +import re +import os +import json +import fnmatch +import functools +import glob + +import attr +from PyQt5.QtCore import pyqtSignal, QObject, QUrl + +from qutebrowser.utils import log, standarddir, jinja, objreg +from qutebrowser.commands import cmdutils + + +def _scripts_dir(): + """Get the directory of the scripts.""" + return os.path.join(standarddir.data(), 'greasemonkey') + + +class GreasemonkeyScript: + + """Container class for userscripts, parses metadata blocks.""" + + def __init__(self, properties, code): + self._code = code + self.includes = [] + self.excludes = [] + self.description = None + self.name = None + self.namespace = None + self.run_at = None + self.script_meta = None + self.runs_on_sub_frames = True + for name, value in properties: + if name == 'name': + self.name = value + elif name == 'namespace': + self.namespace = value + elif name == 'description': + self.description = value + elif name in ['include', 'match']: + self.includes.append(value) + elif name in ['exclude', 'exclude_match']: + self.excludes.append(value) + elif name == 'run-at': + self.run_at = value + elif name == 'noframes': + self.runs_on_sub_frames = False + + HEADER_REGEX = r'// ==UserScript==|\n+// ==/UserScript==\n' + PROPS_REGEX = r'// @(?P[^\s]+)\s*(?P.*)' + + @classmethod + def parse(cls, source): + """GreasemonkeyScript factory. + + Takes a userscript source and returns a GreasemonkeyScript. + Parses the Greasemonkey metadata block, if present, to fill out + attributes. + """ + matches = re.split(cls.HEADER_REGEX, source, maxsplit=2) + try: + _head, props, _code = matches + except ValueError: + props = "" + script = cls(re.findall(cls.PROPS_REGEX, props), source) + script.script_meta = props + if not props: + script.includes = ['*'] + return script + + def code(self): + """Return the processed JavaScript code of this script. + + Adorns the source code with GM_* methods for Greasemonkey + compatibility and wraps it in an IFFE to hide it within a + lexical scope. Note that this means line numbers in your + browser's debugger/inspector will not match up to the line + numbers in the source script directly. + """ + return jinja.js_environment.get_template( + 'greasemonkey_wrapper.js').render( + scriptName="/".join([self.namespace or '', self.name]), + scriptInfo=self._meta_json(), + scriptMeta=self.script_meta, + scriptSource=self._code) + + def _meta_json(self): + return json.dumps({ + 'name': self.name, + 'description': self.description, + 'matches': self.includes, + 'includes': self.includes, + 'excludes': self.excludes, + 'run-at': self.run_at, + }) + + +@attr.s +class MatchingScripts(object): + + """All userscripts registered to run on a particular url.""" + + url = attr.ib() + start = attr.ib(default=attr.Factory(list)) + end = attr.ib(default=attr.Factory(list)) + idle = attr.ib(default=attr.Factory(list)) + + +class GreasemonkeyManager(QObject): + + """Manager of userscripts and a Greasemonkey compatible environment. + + Signals: + scripts_reloaded: Emitted when scripts are reloaded from disk. + Any cached or already-injected scripts should be + considered obselete. + """ + + scripts_reloaded = pyqtSignal() + # https://wiki.greasespot.net/Include_and_exclude_rules#Greaseable_schemes + # Limit the schemes scripts can run on due to unreasonable levels of + # exploitability + greaseable_schemes = ['http', 'https', 'ftp', 'file'] + + def __init__(self, parent=None): + super().__init__(parent) + self.load_scripts() + + @cmdutils.register(name='greasemonkey-reload', + instance='greasemonkey') + def load_scripts(self): + """Re-read Greasemonkey scripts from disk. + + The scripts are read from a 'greasemonkey' subdirectory in + qutebrowser's data directory (see `:version`). + """ + self._run_start = [] + self._run_end = [] + self._run_idle = [] + + scripts_dir = os.path.abspath(_scripts_dir()) + log.greasemonkey.debug("Reading scripts from: {}".format(scripts_dir)) + for script_filename in glob.glob(os.path.join(scripts_dir, '*.js')): + if not os.path.isfile(script_filename): + continue + script_path = os.path.join(scripts_dir, script_filename) + with open(script_path, encoding='utf-8') as script_file: + script = GreasemonkeyScript.parse(script_file.read()) + if not script.name: + script.name = script_filename + + if script.run_at == 'document-start': + self._run_start.append(script) + elif script.run_at == 'document-end': + self._run_end.append(script) + elif script.run_at == 'document-idle': + self._run_idle.append(script) + else: + log.greasemonkey.warning("Script {} has invalid run-at " + "defined, defaulting to " + "document-end" + .format(script_path)) + # Default as per + # https://wiki.greasespot.net/Metadata_Block#.40run-at + self._run_end.append(script) + log.greasemonkey.debug("Loaded script: {}".format(script.name)) + self.scripts_reloaded.emit() + + def scripts_for(self, url): + """Fetch scripts that are registered to run for url. + + returns a tuple of lists of scripts meant to run at (document-start, + document-end, document-idle) + """ + if url.scheme() not in self.greaseable_schemes: + return MatchingScripts(url, [], [], []) + match = functools.partial(fnmatch.fnmatch, + url.toString(QUrl.FullyEncoded)) + tester = (lambda script: + any(match(pat) for pat in script.includes) and + not any(match(pat) for pat in script.excludes)) + return MatchingScripts( + url, + [script for script in self._run_start if tester(script)], + [script for script in self._run_end if tester(script)], + [script for script in self._run_idle if tester(script)] + ) + + def all_scripts(self): + """Return all scripts found in the configured script directory.""" + return self._run_start + self._run_end + self._run_idle + + +def init(): + """Initialize Greasemonkey support.""" + gm_manager = GreasemonkeyManager() + objreg.register('greasemonkey', gm_manager) + + try: + os.mkdir(_scripts_dir()) + except FileExistsError: + pass diff --git a/qutebrowser/browser/hints.py b/qutebrowser/browser/hints.py index 8d8a5ae68..14cc2b574 100644 --- a/qutebrowser/browser/hints.py +++ b/qutebrowser/browser/hints.py @@ -390,10 +390,8 @@ class HintManager(QObject): def _cleanup(self): """Clean up after hinting.""" - # pylint: disable=not-an-iterable for label in self._context.all_labels: label.cleanup() - # pylint: enable=not-an-iterable text = self._get_text() message_bridge = objreg.get('message-bridge', scope='window', @@ -446,8 +444,17 @@ class HintManager(QObject): # Short hints are the number of hints we can possibly show which are # (needed - 1) digits in length. if needed > min_chars: - short_count = math.floor((len(chars) ** needed - len(elems)) / + total_space = len(chars) ** needed + # Calculate short_count naively, by finding the avaiable space and + # dividing by the number of spots we would loose by adding a + # short element + short_count = math.floor((total_space - len(elems)) / len(chars)) + # Check if we double counted above to warrant another short_count + # https://github.com/qutebrowser/qutebrowser/issues/3242 + if total_space - (short_count * len(chars) + + (len(elems) - short_count)) >= len(chars) - 1: + short_count += 1 else: short_count = 0 @@ -612,8 +619,9 @@ class HintManager(QObject): @cmdutils.register(instance='hintmanager', scope='tab', name='hint', star_args_optional=True, maxsplit=2) @cmdutils.argument('win_id', win_id=True) - def start(self, rapid=False, group=webelem.Group.all, target=Target.normal, - *args, win_id, mode=None, add_history=False): + def start(self, # pylint: disable=keyword-arg-before-vararg + group=webelem.Group.all, target=Target.normal, + *args, win_id, mode=None, add_history=False, rapid=False): """Start hinting. Args: @@ -800,7 +808,6 @@ class HintManager(QObject): log.hints.debug("Filtering hints on {!r}".format(filterstr)) visible = [] - # pylint: disable=not-an-iterable for label in self._context.all_labels: try: if self._filter_matches(filterstr, str(label.elem)): @@ -812,7 +819,6 @@ class HintManager(QObject): label.hide() except webelem.Error: pass - # pylint: enable=not-an-iterable if not visible: # Whoops, filtered all hints diff --git a/qutebrowser/browser/history.py b/qutebrowser/browser/history.py index de9e9bb4f..ecab730ae 100644 --- a/qutebrowser/browser/history.py +++ b/qutebrowser/browser/history.py @@ -21,6 +21,7 @@ import os import time +import contextlib from PyQt5.QtCore import pyqtSlot, QUrl, QTimer @@ -87,6 +88,16 @@ class WebHistory(sql.SqlTable): def __contains__(self, url): return self._contains_query.run(val=url).value() + @contextlib.contextmanager + def _handle_sql_errors(self): + try: + yield + except sql.SqlError as e: + if e.environmental: + message.error("Failed to write history: {}".format(e.text())) + else: + raise + def _rebuild_completion(self): data = {'url': [], 'title': [], 'last_atime': []} # select the latest entry for each url @@ -138,12 +149,13 @@ class WebHistory(sql.SqlTable): if force: self._do_clear() else: - message.confirm_async(self._do_clear, title="Clear all browsing " - "history?") + message.confirm_async(yes_action=self._do_clear, + title="Clear all browsing history?") def _do_clear(self): - self.delete_all() - self.completion.delete_all() + with self._handle_sql_errors(): + self.delete_all() + self.completion.delete_all() def delete_url(self, url): """Remove all history entries with the given url. @@ -191,7 +203,7 @@ class WebHistory(sql.SqlTable): atime = int(atime) if (atime is not None) else int(time.time()) - try: + with self._handle_sql_errors(): self.insert({'url': self._format_url(url), 'title': title, 'atime': atime, @@ -202,11 +214,6 @@ class WebHistory(sql.SqlTable): 'title': title, 'last_atime': atime }, replace=True) - except sql.SqlError as e: - if e.environmental: - message.error("Failed to write history: {}".format(e.text())) - else: - raise def _parse_entry(self, line): """Parse a history line like '12345 http://example.com title'.""" @@ -261,6 +268,7 @@ class WebHistory(sql.SqlTable): return def action(): + """Actually run the import.""" with debug.log_time(log.init, 'Import old history file to sqlite'): try: self._read(path) @@ -333,7 +341,7 @@ class WebHistory(sql.SqlTable): f.write('\n'.join(lines)) message.info("Dumped history to {}".format(dest)) except OSError as e: - raise cmdexc.CommandError('Could not write history: {}', e) + raise cmdexc.CommandError('Could not write history: {}'.format(e)) def init(parent=None): diff --git a/qutebrowser/browser/inspector.py b/qutebrowser/browser/inspector.py index c88242bb8..9c583f4b3 100644 --- a/qutebrowser/browser/inspector.py +++ b/qutebrowser/browser/inspector.py @@ -94,6 +94,7 @@ class AbstractWebInspector(QWidget): raise NotImplementedError def toggle(self, page): + """Show/hide the inspector.""" if self._widget.isVisible(): self.hide() else: diff --git a/qutebrowser/browser/mouse.py b/qutebrowser/browser/mouse.py index 619f75120..d08f191a8 100644 --- a/qutebrowser/browser/mouse.py +++ b/qutebrowser/browser/mouse.py @@ -19,15 +19,13 @@ """Mouse handling for a browser tab.""" +from PyQt5.QtCore import QObject, QEvent, Qt, QTimer from qutebrowser.config import config from qutebrowser.utils import message, log, usertypes from qutebrowser.keyinput import modeman -from PyQt5.QtCore import QObject, QEvent, Qt, QTimer - - class ChildEventFilter(QObject): """An event filter re-adding MouseEventFilter on ChildEvent. diff --git a/qutebrowser/browser/network/pac.py b/qutebrowser/browser/network/pac.py index 69158fe62..a3f0813c8 100644 --- a/qutebrowser/browser/network/pac.py +++ b/qutebrowser/browser/network/pac.py @@ -59,6 +59,7 @@ def _js_slot(*args): def _decorator(method): @functools.wraps(method) def new_method(self, *args, **kwargs): + """Call the underlying function.""" try: return method(self, *args, **kwargs) except: diff --git a/qutebrowser/browser/pdfjs.py b/qutebrowser/browser/pdfjs.py index d003cefb1..cd5bcac0a 100644 --- a/qutebrowser/browser/pdfjs.py +++ b/qutebrowser/browser/pdfjs.py @@ -82,7 +82,7 @@ def fix_urls(asset): ('viewer.css', 'qute://pdfjs/web/viewer.css'), ('compatibility.js', 'qute://pdfjs/web/compatibility.js'), ('locale/locale.properties', - 'qute://pdfjs/web/locale/locale.properties'), + 'qute://pdfjs/web/locale/locale.properties'), ('l10n.js', 'qute://pdfjs/web/l10n.js'), ('../build/pdf.js', 'qute://pdfjs/build/pdf.js'), ('debugger.js', 'qute://pdfjs/web/debugger.js'), diff --git a/qutebrowser/browser/qtnetworkdownloads.py b/qutebrowser/browser/qtnetworkdownloads.py index 709c8207b..378bc72b5 100644 --- a/qutebrowser/browser/qtnetworkdownloads.py +++ b/qutebrowser/browser/qtnetworkdownloads.py @@ -215,7 +215,7 @@ class DownloadItem(downloads.AbstractDownloadItem): abort_on=[self.cancelled, self.error]) def _set_fileobj(self, fileobj, *, autoclose=True): - """"Set the file object to write the download to. + """Set the file object to write the download to. Args: fileobj: A file-like object. @@ -303,8 +303,7 @@ class DownloadItem(downloads.AbstractDownloadItem): """Handle QNetworkReply errors.""" if code == QNetworkReply.OperationCanceledError: return - else: - self._die(self._reply.errorString()) + self._die(self._reply.errorString()) @pyqtSlot() def _on_read_timer_timeout(self): @@ -399,7 +398,7 @@ class DownloadManager(downloads.AbstractDownloadManager): """ if not url.isValid(): urlutils.invalid_url_error(url, "start download") - return + return None req = QNetworkRequest(url) if user_agent is not None: req.setHeader(QNetworkRequest.UserAgentHeader, user_agent) diff --git a/qutebrowser/browser/qutescheme.py b/qutebrowser/browser/qutescheme.py index 11dcfe004..8bcb7ff37 100644 --- a/qutebrowser/browser/qutescheme.py +++ b/qutebrowser/browser/qutescheme.py @@ -29,6 +29,7 @@ import os import time import textwrap import mimetypes +import urllib import pkg_resources from PyQt5.QtCore import QUrlQuery, QUrl @@ -41,6 +42,7 @@ from qutebrowser.misc import objects pyeval_output = ":pyeval was never called" +spawn_output = ":spawn was never called" _HANDLERS = {} @@ -91,7 +93,7 @@ class Redirect(Exception): self.url = url -class add_handler: # pylint: disable=invalid-name +class add_handler: # noqa: N801,N806 pylint: disable=invalid-name """Decorator to register a qute://* URL handler. @@ -111,6 +113,7 @@ class add_handler: # pylint: disable=invalid-name return function def wrapper(self, *args, **kwargs): + """Call the underlying function.""" if self._backend is not None and objects.backend != self._backend: return self.wrong_backend_handler(*args, **kwargs) else: @@ -136,7 +139,7 @@ def data_for_url(url): A (mimetype, data) tuple. """ norm_url = url.adjusted(QUrl.NormalizePathSegments | - QUrl.StripTrailingSlash) + QUrl.StripTrailingSlash) if norm_url != url: raise Redirect(norm_url) @@ -267,6 +270,13 @@ def qute_pyeval(_url): return 'text/html', html +@add_handler('spawn-output') +def qute_spawn_output(_url): + """Handler for qute://spawn-output.""" + html = jinja.render('pre.html', title='spawn output', content=spawn_output) + return 'text/html', html + + @add_handler('version') @add_handler('verizon') def qute_version(_url): @@ -425,6 +435,18 @@ def qute_settings(url): return 'text/html', html +@add_handler('back') +def qute_back(url): + """Handler for qute://back. + + Simple page to free ram / lazy load a site, goes back on focusing the tab. + """ + html = jinja.render( + 'back.html', + title='Suspended: ' + urllib.parse.unquote(url.fragment())) + return 'text/html', html + + @add_handler('configdiff') def qute_configdiff(url): """Handler for qute://configdiff.""" @@ -433,7 +455,7 @@ def qute_configdiff(url): return 'text/html', configdiff.get_diff() except OSError as e: error = (b'Failed to read old config: ' + - str(e.strerror).encode('utf-8')) + str(e.strerror).encode('utf-8')) return 'text/plain', error else: data = config.instance.dump_userconfig().encode('utf-8') diff --git a/qutebrowser/browser/shared.py b/qutebrowser/browser/shared.py index ab1b6ad9f..b6bfefe7b 100644 --- a/qutebrowser/browser/shared.py +++ b/qutebrowser/browser/shared.py @@ -22,7 +22,7 @@ import html from qutebrowser.config import config -from qutebrowser.utils import usertypes, message, log, objreg, jinja +from qutebrowser.utils import usertypes, message, log, objreg, jinja, utils from qutebrowser.mainwindow import mainwindow @@ -182,7 +182,7 @@ def ignore_certificate_errors(url, errors, abort_on): return False else: raise ValueError("Invalid ssl_strict value {!r}".format(ssl_strict)) - raise AssertionError("Not reached") + raise utils.Unreachable def feature_permission(url, option, msg, yes_action, no_action, abort_on): diff --git a/qutebrowser/browser/urlmarks.py b/qutebrowser/browser/urlmarks.py index b7c93a994..5e2c60dfb 100644 --- a/qutebrowser/browser/urlmarks.py +++ b/qutebrowser/browser/urlmarks.py @@ -26,8 +26,8 @@ to a file on shutdown, so it makes sense to keep them as strings here. """ import os -import html import os.path +import html import functools import collections diff --git a/qutebrowser/browser/webelem.py b/qutebrowser/browser/webelem.py index 067f33cff..4a1cc02b5 100644 --- a/qutebrowser/browser/webelem.py +++ b/qutebrowser/browser/webelem.py @@ -60,6 +60,13 @@ class Error(Exception): pass +class OrphanedError(Error): + + """Raised when a webelement's parent has vanished.""" + + pass + + class AbstractWebElement(collections.abc.MutableMapping): """A wrapper around QtWebKit/QtWebEngine web element. @@ -221,7 +228,7 @@ class AbstractWebElement(collections.abc.MutableMapping): } relevant_classes = classes[self.tag_name()] for klass in self.classes(): - if any([klass.strip().startswith(e) for e in relevant_classes]): + if any(klass.strip().startswith(e) for e in relevant_classes): return True return False diff --git a/qutebrowser/browser/webengine/spell.py b/qutebrowser/browser/webengine/spell.py index ee2fb7813..9166180d4 100644 --- a/qutebrowser/browser/webengine/spell.py +++ b/qutebrowser/browser/webengine/spell.py @@ -30,7 +30,7 @@ from qutebrowser.utils import log def version(filename): """Extract the version number from the dictionary file name.""" version_re = re.compile(r".+-(?P[0-9]+-[0-9]+?)\.bdic") - match = version_re.match(filename) + match = version_re.fullmatch(filename) if match is None: raise ValueError('the given dictionary file name is malformed: {}' .format(filename)) diff --git a/qutebrowser/browser/webengine/webengineelem.py b/qutebrowser/browser/webengine/webengineelem.py index bdb8b4192..d6d74ebe4 100644 --- a/qutebrowser/browser/webengine/webengineelem.py +++ b/qutebrowser/browser/webengine/webengineelem.py @@ -100,6 +100,8 @@ class WebEngineElement(webelem.AbstractWebElement): def _js_call(self, name, *args, callback=None): """Wrapper to run stuff from webelem.js.""" + if self._tab.is_deleted(): + raise webelem.OrphanedError("Tab containing element vanished") js_code = javascript.assemble('webelem', name, self._id, *args) self._tab.run_js_async(js_code, callback=callback) @@ -209,11 +211,11 @@ class WebEngineElement(webelem.AbstractWebElement): def _click_js(self, _click_target): # FIXME:qtwebengine Have a proper API for this # pylint: disable=protected-access - settings = self._tab._widget.settings() + view = self._tab._widget # pylint: enable=protected-access attribute = QWebEngineSettings.JavascriptCanOpenWindows - could_open_windows = settings.testAttribute(attribute) - settings.setAttribute(attribute, True) + could_open_windows = view.settings().testAttribute(attribute) + view.settings().setAttribute(attribute, True) # Get QtWebEngine do apply the settings # (it does so with a 0ms QTimer...) @@ -224,6 +226,12 @@ class WebEngineElement(webelem.AbstractWebElement): QEventLoop.ExcludeUserInputEvents) def reset_setting(_arg): - settings.setAttribute(attribute, could_open_windows) + """Set the JavascriptCanOpenWindows setting to its old value.""" + try: + view.settings().setAttribute(attribute, could_open_windows) + except RuntimeError: + # Happens if this callback gets called during QWebEnginePage + # destruction, i.e. if the tab was closed in the meantime. + pass self._js_call('click', callback=reset_setting) diff --git a/qutebrowser/browser/webengine/webenginesettings.py b/qutebrowser/browser/webengine/webenginesettings.py index 5ea065cb6..f8b54e065 100644 --- a/qutebrowser/browser/webengine/webenginesettings.py +++ b/qutebrowser/browser/webengine/webenginesettings.py @@ -29,6 +29,7 @@ Module attributes: import os +import sip from PyQt5.QtGui import QFont from PyQt5.QtWebEngineWidgets import (QWebEngineSettings, QWebEngineProfile, QWebEngineScript) @@ -37,7 +38,7 @@ from qutebrowser.browser import shared from qutebrowser.browser.webengine import spell from qutebrowser.config import config, websettings from qutebrowser.utils import (utils, standarddir, javascript, qtutils, - message, log) + message, log, objreg) # The default QWebEngineProfile default_profile = None @@ -93,9 +94,10 @@ class DefaultProfileSetter(websettings.Base): """A setting set on the QWebEngineProfile.""" - def __init__(self, setter, default=websettings.UNSET): + def __init__(self, setter, converter=None, default=websettings.UNSET): super().__init__(default) self._setter = setter + self._converter = converter def __repr__(self): return utils.get_repr(self, setter=self._setter, constructor=True) @@ -104,7 +106,11 @@ class DefaultProfileSetter(websettings.Base): if settings is not None: raise ValueError("'settings' may not be set with " "DefaultProfileSetters!") + setter = getattr(default_profile, self._setter) + if self._converter is not None: + value = self._converter(value) + setter(value) @@ -153,33 +159,44 @@ class DictionaryLanguageSetter(DefaultProfileSetter): def _init_stylesheet(profile): """Initialize custom stylesheets. - Mostly inspired by QupZilla: + Partially inspired by QupZilla: https://github.com/QupZilla/qupzilla/blob/v2.0/src/lib/app/mainapplication.cpp#L1063-L1101 - https://github.com/QupZilla/qupzilla/blob/v2.0/src/lib/tools/scripts.cpp#L119-L132 """ old_script = profile.scripts().findScript('_qute_stylesheet') if not old_script.isNull(): profile.scripts().remove(old_script) css = shared.get_user_stylesheet() - source = """ - (function() {{ - var css = document.createElement('style'); - css.setAttribute('type', 'text/css'); - css.appendChild(document.createTextNode('{}')); - document.getElementsByTagName('head')[0].appendChild(css); - }})() - """.format(javascript.string_escape(css)) + source = '\n'.join([ + '"use strict";', + 'window._qutebrowser = window._qutebrowser || {};', + utils.read_file('javascript/stylesheet.js'), + javascript.assemble('stylesheet', 'set_css', css), + ]) script = QWebEngineScript() script.setName('_qute_stylesheet') - script.setInjectionPoint(QWebEngineScript.DocumentReady) + script.setInjectionPoint(QWebEngineScript.DocumentCreation) script.setWorldId(QWebEngineScript.ApplicationWorld) script.setRunsOnSubFrames(True) script.setSourceCode(source) profile.scripts().insert(script) +def _update_stylesheet(): + """Update the custom stylesheet in existing tabs.""" + css = shared.get_user_stylesheet() + code = javascript.assemble('stylesheet', 'set_css', css) + for win_id, window in objreg.window_registry.items(): + # We could be in the middle of destroying a window here + if sip.isdeleted(window): + continue + tab_registry = objreg.get('tab-registry', scope='window', + window=win_id) + for tab in tab_registry.values(): + tab.run_js_async(code) + + def _set_http_headers(profile): """Set the user agent and accept-language for the given profile. @@ -199,6 +216,7 @@ def _update_settings(option): if option in ['scrolling.bar', 'content.user_stylesheets']: _init_stylesheet(default_profile) _init_stylesheet(private_profile) + _update_stylesheet() elif option in ['content.headers.user_agent', 'content.headers.accept_language']: _set_http_headers(default_profile) @@ -226,6 +244,43 @@ def _init_profiles(): private_profile.setSpellCheckEnabled(True) +def inject_userscripts(): + """Register user JavaScript files with the global profiles.""" + # The Greasemonkey metadata block support in QtWebEngine only starts at + # Qt 5.8. With 5.7.1, we need to inject the scripts ourselves in response + # to urlChanged. + if not qtutils.version_check('5.8'): + return + + # Since we are inserting scripts into profile.scripts they won't + # just get replaced by new gm scripts like if we were injecting them + # ourselves so we need to remove all gm scripts, while not removing + # any other stuff that might have been added. Like the one for + # stylesheets. + greasemonkey = objreg.get('greasemonkey') + for profile in [default_profile, private_profile]: + scripts = profile.scripts() + for script in scripts.toList(): + if script.name().startswith("GM-"): + log.greasemonkey.debug('Removing script: {}' + .format(script.name())) + removed = scripts.remove(script) + assert removed, script.name() + + # Then add the new scripts. + for script in greasemonkey.all_scripts(): + # @run-at (and @include/@exclude/@match) is parsed by + # QWebEngineScript. + new_script = QWebEngineScript() + new_script.setWorldId(QWebEngineScript.MainWorld) + new_script.setSourceCode(script.code()) + new_script.setName("GM-{}".format(script.name)) + new_script.setRunsOnSubFrames(script.runs_on_sub_frames) + log.greasemonkey.debug('adding script: {}' + .format(new_script.name())) + scripts.insert(new_script) + + def init(args): """Initialize the global QWebSettings.""" if args.enable_webengine_inspector: @@ -283,7 +338,9 @@ MAPPINGS = { Attribute(QWebEngineSettings.LocalStorageEnabled), 'content.cache.size': # 0: automatically managed by QtWebEngine - DefaultProfileSetter('setHttpCacheMaximumSize', default=0), + DefaultProfileSetter('setHttpCacheMaximumSize', default=0, + converter=lambda val: + qtutils.check_overflow(val, 'int', fatal=False)), 'content.xss_auditing': Attribute(QWebEngineSettings.XSSAuditingEnabled), 'content.default_encoding': diff --git a/qutebrowser/browser/webengine/webenginetab.py b/qutebrowser/browser/webengine/webenginetab.py index 2e300296b..b816e8f2a 100644 --- a/qutebrowser/browser/webengine/webenginetab.py +++ b/qutebrowser/browser/webengine/webenginetab.py @@ -24,7 +24,8 @@ import functools import html as html_utils import sip -from PyQt5.QtCore import pyqtSlot, Qt, QEvent, QPoint, QPointF, QUrl, QTimer +from PyQt5.QtCore import (pyqtSignal, pyqtSlot, Qt, QEvent, QPoint, QPointF, + QUrl, QTimer) from PyQt5.QtGui import QKeyEvent from PyQt5.QtNetwork import QAuthenticator from PyQt5.QtWidgets import QApplication @@ -69,6 +70,10 @@ def init(): download_manager.install(webenginesettings.private_profile) objreg.register('webengine-download-manager', download_manager) + greasemonkey = objreg.get('greasemonkey') + greasemonkey.scripts_reloaded.connect(webenginesettings.inject_userscripts) + webenginesettings.inject_userscripts() + # Mapping worlds from usertypes.JsWorld to QWebEngineScript world IDs. _JS_WORLD_MAP = { @@ -121,18 +126,35 @@ class WebEnginePrinting(browsertab.AbstractPrinting): class WebEngineSearch(browsertab.AbstractSearch): - """QtWebEngine implementations related to searching on the page.""" + """QtWebEngine implementations related to searching on the page. + + Attributes: + _flags: The QWebEnginePage.FindFlags of the last search. + _pending_searches: How many searches have been started but not called + back yet. + """ def __init__(self, parent=None): super().__init__(parent) self._flags = QWebEnginePage.FindFlags(0) + self._pending_searches = 0 def _find(self, text, flags, callback, caller): """Call findText on the widget.""" self.search_displayed = True + self._pending_searches += 1 def wrapped_callback(found): """Wrap the callback to do debug logging.""" + self._pending_searches -= 1 + if self._pending_searches > 0: + # See https://github.com/qutebrowser/qutebrowser/issues/2442 + # and https://github.com/qt/qtwebengine/blob/5.10/src/core/web_contents_adapter.cpp#L924-L934 + log.webview.debug("Ignoring cancelled search callback with " + "{} pending searches".format( + self._pending_searches)) + return + found_text = 'found' if found else "didn't find" if flags: flag_text = 'with flags {}'.format(debug.qflags_key( @@ -560,7 +582,15 @@ class WebEngineElements(browsertab.AbstractElements): class WebEngineTab(browsertab.AbstractTab): - """A QtWebEngine tab in the browser.""" + """A QtWebEngine tab in the browser. + + Signals: + _load_finished_fake: + Used in place of unreliable loadFinished + """ + + # WORKAROUND for https://bugreports.qt.io/browse/QTBUG-65223 + _load_finished_fake = pyqtSignal(bool) def __init__(self, *, win_id, mode_manager, private, parent=None): super().__init__(win_id=win_id, mode_manager=mode_manager, @@ -586,7 +616,7 @@ class WebEngineTab(browsertab.AbstractTab): def _init_js(self): js_code = '\n'.join([ '"use strict";', - 'window._qutebrowser = {};', + 'window._qutebrowser = window._qutebrowser || {};', utils.read_file('javascript/scroll.js'), utils.read_file('javascript/webelem.js'), utils.read_file('javascript/webengine_caret.js'), @@ -815,6 +845,24 @@ class WebEngineTab(browsertab.AbstractTab): } self.renderer_process_terminated.emit(status_map[status], exitcode) + @pyqtSlot(int) + def _on_load_progress_workaround(self, perc): + """Use loadProgress(100) to emit loadFinished(True). + + See https://bugreports.qt.io/browse/QTBUG-65223 + """ + if perc == 100 and self.load_status() != usertypes.LoadStatus.error: + self._load_finished_fake.emit(True) + + @pyqtSlot(bool) + def _on_load_finished_workaround(self, ok): + """Use only loadFinished(False). + + See https://bugreports.qt.io/browse/QTBUG-65223 + """ + if not ok: + self._load_finished_fake.emit(False) + def _connect_signals(self): view = self._widget page = view.page() @@ -823,9 +871,6 @@ class WebEngineTab(browsertab.AbstractTab): page.linkHovered.connect(self.link_hovered) page.loadProgress.connect(self._on_load_progress) page.loadStarted.connect(self._on_load_started) - page.loadFinished.connect(self._on_history_trigger) - page.loadFinished.connect(self._restore_zoom) - page.loadFinished.connect(self._on_load_finished) page.certificate_error.connect(self._on_ssl_errors) page.authenticationRequired.connect(self._on_authentication_required) page.proxyAuthenticationRequired.connect( @@ -838,6 +883,19 @@ class WebEngineTab(browsertab.AbstractTab): view.renderProcessTerminated.connect( self._on_render_process_terminated) view.iconChanged.connect(self.icon_changed) + # WORKAROUND for https://bugreports.qt.io/browse/QTBUG-65223 + if qtutils.version_check('5.10', compiled=False): + page.loadProgress.connect(self._on_load_progress_workaround) + self._load_finished_fake.connect(self._on_history_trigger) + self._load_finished_fake.connect(self._restore_zoom) + self._load_finished_fake.connect(self._on_load_finished) + page.loadFinished.connect(self._on_load_finished_workaround) + else: + # for older Qt versions which break with the above + page.loadProgress.connect(self._on_load_progress) + page.loadFinished.connect(self._on_history_trigger) + page.loadFinished.connect(self._restore_zoom) + page.loadFinished.connect(self._on_load_finished) def event_target(self): return self._widget.focusProxy() diff --git a/qutebrowser/browser/webengine/webview.py b/qutebrowser/browser/webengine/webview.py index 56bd1eb5a..b313fc36c 100644 --- a/qutebrowser/browser/webengine/webview.py +++ b/qutebrowser/browser/webengine/webview.py @@ -23,12 +23,14 @@ import functools from PyQt5.QtCore import pyqtSignal, pyqtSlot, QUrl, PYQT_VERSION from PyQt5.QtGui import QPalette -from PyQt5.QtWebEngineWidgets import QWebEngineView, QWebEnginePage +from PyQt5.QtWebEngineWidgets import (QWebEngineView, QWebEnginePage, + QWebEngineScript) from qutebrowser.browser import shared from qutebrowser.browser.webengine import certificateerror, webenginesettings from qutebrowser.config import config -from qutebrowser.utils import log, debug, usertypes, jinja, urlutils, message +from qutebrowser.utils import (log, debug, usertypes, jinja, urlutils, message, + objreg, qtutils) class WebEngineView(QWebEngineView): @@ -135,6 +137,7 @@ class WebEnginePage(QWebEnginePage): self._theme_color = theme_color self._set_bg_color() config.instance.changed.connect(self._set_bg_color) + self.urlChanged.connect(self._inject_userjs) @config.change_filter('colors.webpage.bg') def _set_bg_color(self): @@ -300,3 +303,43 @@ class WebEnginePage(QWebEnginePage): message.error(msg) return False return True + + @pyqtSlot('QUrl') + def _inject_userjs(self, url): + """Inject userscripts registered for `url` into the current page.""" + if qtutils.version_check('5.8'): + # Handled in webenginetab with the builtin Greasemonkey + # support. + return + + # Using QWebEnginePage.scripts() to hold the user scripts means + # we don't have to worry ourselves about where to inject the + # page but also means scripts hang around for the tab lifecycle. + # So clear them here. + scripts = self.scripts() + for script in scripts.toList(): + if script.name().startswith("GM-"): + log.greasemonkey.debug("Removing script: {}" + .format(script.name())) + removed = scripts.remove(script) + assert removed, script.name() + + def _add_script(script, injection_point): + new_script = QWebEngineScript() + new_script.setInjectionPoint(injection_point) + new_script.setWorldId(QWebEngineScript.MainWorld) + new_script.setSourceCode(script.code()) + new_script.setName("GM-{}".format(script.name)) + new_script.setRunsOnSubFrames(script.runs_on_sub_frames) + log.greasemonkey.debug("Adding script: {}" + .format(new_script.name())) + scripts.insert(new_script) + + greasemonkey = objreg.get('greasemonkey') + matching_scripts = greasemonkey.scripts_for(url) + for script in matching_scripts.start: + _add_script(script, QWebEngineScript.DocumentCreation) + for script in matching_scripts.end: + _add_script(script, QWebEngineScript.DocumentReady) + for script in matching_scripts.idle: + _add_script(script, QWebEngineScript.Deferred) diff --git a/qutebrowser/browser/webkit/http.py b/qutebrowser/browser/webkit/http.py index 08cad7a44..8997f4b0c 100644 --- a/qutebrowser/browser/webkit/http.py +++ b/qutebrowser/browser/webkit/http.py @@ -22,11 +22,11 @@ import os.path +from PyQt5.QtNetwork import QNetworkRequest + from qutebrowser.utils import log from qutebrowser.browser.webkit import rfc6266 -from PyQt5.QtNetwork import QNetworkRequest - def parse_content_disposition(reply): """Parse a content_disposition header. @@ -57,9 +57,7 @@ def parse_content_disposition(reply): is_inline = content_disposition.is_inline() # Then try to get filename from url if not filename: - path = reply.url().path() - if path is not None: - filename = path.rstrip('/') + filename = reply.url().path().rstrip('/') # If that fails as well, use a fallback if not filename: filename = 'qutebrowser-download' diff --git a/qutebrowser/browser/webkit/mhtml.py b/qutebrowser/browser/webkit/mhtml.py index ccdd03dad..67c8a5b7a 100644 --- a/qutebrowser/browser/webkit/mhtml.py +++ b/qutebrowser/browser/webkit/mhtml.py @@ -502,8 +502,8 @@ class _Downloader: This is needed if a download finishes before attaching its finished signal. """ - items = set((url, item) for url, item in self.pending_downloads - if item.done) + items = {(url, item) for url, item in self.pending_downloads + if item.done} log.downloads.debug("Zombie downloads: {}".format(items)) for url, item in items: self._finished(url, item) diff --git a/qutebrowser/browser/webkit/network/filescheme.py b/qutebrowser/browser/webkit/network/filescheme.py index a8cade1db..a971e3257 100644 --- a/qutebrowser/browser/webkit/network/filescheme.py +++ b/qutebrowser/browser/webkit/network/filescheme.py @@ -132,5 +132,6 @@ class FileSchemeHandler(schemehandler.SchemeHandler): data = dirbrowser_html(path) return networkreply.FixedDataNetworkReply( request, data, 'text/html', self.parent()) + return None except UnicodeEncodeError: return None diff --git a/qutebrowser/browser/webkit/network/networkmanager.py b/qutebrowser/browser/webkit/network/networkmanager.py index beaa690ca..a19687eb1 100644 --- a/qutebrowser/browser/webkit/network/networkmanager.py +++ b/qutebrowser/browser/webkit/network/networkmanager.py @@ -206,7 +206,7 @@ class NetworkManager(QNetworkAccessManager): # No @pyqtSlot here, see # https://github.com/qutebrowser/qutebrowser/issues/2213 - def on_ssl_errors(self, reply, errors): # pragma: no mccabe + def on_ssl_errors(self, reply, errors): # noqa: C901 pragma: no mccabe """Decide if SSL errors should be ignored or not. This slot is called on SSL/TLS errors by the self.sslErrors signal. diff --git a/qutebrowser/browser/webkit/network/networkreply.py b/qutebrowser/browser/webkit/network/networkreply.py index a4a4f59ca..22263c96b 100644 --- a/qutebrowser/browser/webkit/network/networkreply.py +++ b/qutebrowser/browser/webkit/network/networkreply.py @@ -34,7 +34,7 @@ class FixedDataNetworkReply(QNetworkReply): """QNetworkReply subclass for fixed data.""" - def __init__(self, request, fileData, mimeType, # flake8: disable=N803 + def __init__(self, request, fileData, mimeType, # noqa: N803 parent=None): """Constructor. diff --git a/qutebrowser/browser/webkit/rfc6266.py b/qutebrowser/browser/webkit/rfc6266.py index 1f71b23e5..f83413ee2 100644 --- a/qutebrowser/browser/webkit/rfc6266.py +++ b/qutebrowser/browser/webkit/rfc6266.py @@ -270,6 +270,7 @@ class _ContentDisposition: elif 'filename' in self.assocs: # XXX Reject non-ascii (parsed via qdtext) here? return self.assocs['filename'] + return None def is_inline(self): """Return if the file should be handled inline. diff --git a/qutebrowser/browser/webkit/webkitelem.py b/qutebrowser/browser/webkit/webkitelem.py index 2a1eafc9e..829052798 100644 --- a/qutebrowser/browser/webkit/webkitelem.py +++ b/qutebrowser/browser/webkit/webkitelem.py @@ -118,6 +118,8 @@ class WebKitElement(webelem.AbstractWebElement): def set_value(self, value): self._check_vanished() + if self._tab.is_deleted(): + raise webelem.OrphanedError("Tab containing element vanished") if self.is_content_editable(): log.webelem.debug("Filling {!r} via set_text.".format(self)) self._elem.setPlainText(value) diff --git a/qutebrowser/browser/webkit/webkittab.py b/qutebrowser/browser/webkit/webkittab.py index 8c63c9ef8..4609f08db 100644 --- a/qutebrowser/browser/webkit/webkittab.py +++ b/qutebrowser/browser/webkit/webkittab.py @@ -19,6 +19,7 @@ """Wrapper over our (QtWebKit) WebView.""" +import re import functools import xml.etree.ElementTree @@ -545,10 +546,15 @@ class WebKitElements(browsertab.AbstractElements): def find_id(self, elem_id, callback): def find_id_cb(elems): + """Call the real callback with the found elements.""" if not elems: callback(None) else: callback(elems[0]) + + # Escape non-alphanumeric characters in the selector + # https://www.w3.org/TR/CSS2/syndata.html#value-def-identifier + elem_id = re.sub(r'[^a-zA-Z0-9_-]', r'\\\g<0>', elem_id) self.find_css('#' + elem_id, find_id_cb) def find_focused(self, callback): diff --git a/qutebrowser/browser/webkit/webpage.py b/qutebrowser/browser/webkit/webpage.py index 7e1d991b9..89407fcdf 100644 --- a/qutebrowser/browser/webkit/webpage.py +++ b/qutebrowser/browser/webkit/webpage.py @@ -86,6 +86,21 @@ class BrowserPage(QWebPage): self.on_save_frame_state_requested) self.restoreFrameStateRequested.connect( self.on_restore_frame_state_requested) + self.loadFinished.connect( + functools.partial(self._inject_userjs, self.mainFrame())) + self.frameCreated.connect(self._connect_userjs_signals) + + @pyqtSlot('QWebFrame*') + def _connect_userjs_signals(self, frame): + """Connect userjs related signals to `frame`. + + Connect the signals used as triggers for injecting user + JavaScripts into the passed QWebFrame. + """ + log.greasemonkey.debug("Connecting to frame {} ({})" + .format(frame, frame.url().toDisplayString())) + frame.loadFinished.connect( + functools.partial(self._inject_userjs, frame)) def javaScriptPrompt(self, frame, js_msg, default): """Override javaScriptPrompt to use qutebrowser prompts.""" @@ -283,6 +298,38 @@ class BrowserPage(QWebPage): else: self.error_occurred = False + def _inject_userjs(self, frame): + """Inject user JavaScripts into the page. + + Args: + frame: The QWebFrame to inject the user scripts into. + """ + url = frame.url() + if url.isEmpty(): + url = frame.requestedUrl() + + log.greasemonkey.debug("_inject_userjs called for {} ({})" + .format(frame, url.toDisplayString())) + + greasemonkey = objreg.get('greasemonkey') + scripts = greasemonkey.scripts_for(url) + # QtWebKit has trouble providing us with signals representing + # page load progress at reasonable times, so we just load all + # scripts on the same event. + toload = scripts.start + scripts.end + scripts.idle + + if url.isEmpty(): + # This happens during normal usage like with view source but may + # also indicate a bug. + log.greasemonkey.debug("Not running scripts for frame with no " + "url: {}".format(frame)) + assert not toload, toload + + for script in toload: + if frame is self.mainFrame() or script.runs_on_sub_frames: + log.webview.debug('Running GM script: {}'.format(script.name)) + frame.evaluateJavaScript(script.code()) + @pyqtSlot('QWebFrame*', 'QWebPage::Feature') def _on_feature_permission_requested(self, frame, feature): """Ask the user for approval for geolocation/notifications.""" diff --git a/qutebrowser/commands/cmdutils.py b/qutebrowser/commands/cmdutils.py index 8111a1dd4..2f7af2f9f 100644 --- a/qutebrowser/commands/cmdutils.py +++ b/qutebrowser/commands/cmdutils.py @@ -61,7 +61,7 @@ def check_exclusive(flags, names): argstr)) -class register: # pylint: disable=invalid-name +class register: # noqa: N801,N806 pylint: disable=invalid-name """Decorator to register a new command handler. @@ -114,7 +114,7 @@ class register: # pylint: disable=invalid-name return func -class argument: # pylint: disable=invalid-name +class argument: # noqa: N801,N806 pylint: disable=invalid-name """Decorator to customize an argument for @cmdutils.register. diff --git a/qutebrowser/commands/command.py b/qutebrowser/commands/command.py index bbc79a0d8..b4ff1cde8 100644 --- a/qutebrowser/commands/command.py +++ b/qutebrowser/commands/command.py @@ -190,6 +190,7 @@ class Command: return True elif arg_info.win_id: return True + return False def _inspect_func(self): """Inspect the function to get useful informations from it. @@ -393,7 +394,7 @@ class Command: if isinstance(typ, tuple): raise TypeError("{}: Legacy tuple type annotation!".format( self.name)) - elif type(typ) is type(typing.Union): # flake8: disable=E721 + elif type(typ) is type(typing.Union): # noqa: E721 # this is... slightly evil, I know # We also can't use isinstance here because typing.Union doesn't # support that. diff --git a/qutebrowser/commands/runners.py b/qutebrowser/commands/runners.py index fadb6c063..890a0275e 100644 --- a/qutebrowser/commands/runners.py +++ b/qutebrowser/commands/runners.py @@ -95,7 +95,6 @@ class CommandParser: """Parse qutebrowser commandline commands. Attributes: - _partial_match: Whether to allow partial command matches. """ @@ -127,7 +126,7 @@ class CommandParser: new_cmd += ' ' return new_cmd - def _parse_all_gen(self, text, aliases=True, *args, **kwargs): + def _parse_all_gen(self, text, *args, aliases=True, **kwargs): """Split a command on ;; and parse all parts. If the first command in the commandline is a non-split one, it only diff --git a/qutebrowser/completion/completer.py b/qutebrowser/completion/completer.py index bc0e4991f..30a180554 100644 --- a/qutebrowser/completion/completer.py +++ b/qutebrowser/completion/completer.py @@ -35,6 +35,7 @@ class CompletionInfo: config = attr.ib() keyconf = attr.ib() + win_id = attr.ib() class Completer(QObject): @@ -43,6 +44,7 @@ class Completer(QObject): Attributes: _cmd: The statusbar Command object this completer belongs to. + _win_id: The id of the window that owns this object. _timer: The timer used to trigger the completion update. _last_cursor_pos: The old cursor position so we avoid double completion updates. @@ -50,9 +52,10 @@ class Completer(QObject): _last_completion_func: The completion function used for the last text. """ - def __init__(self, cmd, parent=None): + def __init__(self, *, cmd, win_id, parent=None): super().__init__(parent) self._cmd = cmd + self._win_id = win_id self._timer = QTimer() self._timer.setSingleShot(True) self._timer.setInterval(0) @@ -131,9 +134,7 @@ class Completer(QObject): return [], '', [] parser = runners.CommandParser() result = parser.parse(text, fallback=True, keep=True) - # pylint: disable=not-an-iterable parts = [x for x in result.cmdline if x] - # pylint: enable=not-an-iterable pos = self._cmd.cursorPosition() - len(self._cmd.prefix()) pos = min(pos, len(text)) # Qt treats 2-byte UTF-16 chars as 2 chars log.completion.debug('partitioning {} around position {}'.format(parts, @@ -152,8 +153,7 @@ class Completer(QObject): "partitioned: {} '{}' {}".format(prefix, center, postfix)) return prefix, center, postfix - # We should always return above - assert False, parts + raise utils.Unreachable("Not all parts consumed: {}".format(parts)) @pyqtSlot(str) def on_selection_changed(self, text): @@ -206,7 +206,7 @@ class Completer(QObject): log.completion.debug("Ignoring update because the length of " "the text is less than completion.min_chars.") elif (self._cmd.cursorPosition() == self._last_cursor_pos and - self._cmd.text() == self._last_text): + self._cmd.text() == self._last_text): log.completion.debug("Ignoring update because there were no " "changes.") else: @@ -247,10 +247,11 @@ class Completer(QObject): if func != self._last_completion_func: self._last_completion_func = func args = (x for x in before_cursor[1:] if not x.startswith('-')) - with debug.log_time(log.completion, - 'Starting {} completion'.format(func.__name__)): + with debug.log_time(log.completion, 'Starting {} completion' + .format(func.__name__)): info = CompletionInfo(config=config.instance, - keyconf=config.key_instance) + keyconf=config.key_instance, + win_id=self._win_id) model = func(*args, info=info) with debug.log_time(log.completion, 'Set completion model'): completion.set_model(model) diff --git a/qutebrowser/completion/completiondelegate.py b/qutebrowser/completion/completiondelegate.py index 6688a2dfa..b4f9c5a33 100644 --- a/qutebrowser/completion/completiondelegate.py +++ b/qutebrowser/completion/completiondelegate.py @@ -138,10 +138,10 @@ class CompletionItemDelegate(QStyledItemDelegate): self._painter.translate(text_rect.left(), text_rect.top()) self._get_textdoc(index) - self._draw_textdoc(text_rect) + self._draw_textdoc(text_rect, index.column()) self._painter.restore() - def _draw_textdoc(self, rect): + def _draw_textdoc(self, rect, col): """Draw the QTextDocument of an item. Args: @@ -156,7 +156,9 @@ class CompletionItemDelegate(QStyledItemDelegate): elif not self._opt.state & QStyle.State_Enabled: color = config.val.colors.completion.category.fg else: - color = config.val.colors.completion.fg + colors = config.val.colors.completion.fg + # if multiple colors are set, use different colors per column + color = colors[col % len(colors)] self._painter.setPen(color) ctx = QAbstractTextDocumentLayout.PaintContext() diff --git a/qutebrowser/completion/completionwidget.py b/qutebrowser/completion/completionwidget.py index 67864ce5f..29f4f6653 100644 --- a/qutebrowser/completion/completionwidget.py +++ b/qutebrowser/completion/completionwidget.py @@ -23,7 +23,7 @@ Defines a CompletionView which uses CompletionFiterModel and CompletionModel subclasses to provide completions. """ -from PyQt5.QtWidgets import QStyle, QTreeView, QSizePolicy, QStyleFactory +from PyQt5.QtWidgets import QTreeView, QSizePolicy, QStyleFactory from PyQt5.QtCore import pyqtSlot, pyqtSignal, Qt, QItemSelectionModel, QSize from qutebrowser.config import config @@ -152,12 +152,12 @@ class CompletionView(QTreeView): column_widths = self.model().column_widths pixel_widths = [(width * perc // 100) for perc in column_widths] - if self.verticalScrollBar().isVisible(): - delta = self.style().pixelMetric(QStyle.PM_ScrollBarExtent) + 5 - if pixel_widths[-1] > delta: - pixel_widths[-1] -= delta - else: - pixel_widths[-2] -= delta + delta = self.verticalScrollBar().sizeHint().width() + if pixel_widths[-1] > delta: + pixel_widths[-1] -= delta + else: + pixel_widths[-2] -= delta + for i, w in enumerate(pixel_widths): assert w >= 0, i self.setColumnWidth(i, w) @@ -180,6 +180,7 @@ class CompletionView(QTreeView): return self.model().last_item() else: return self.model().first_item() + while True: idx = self.indexAbove(idx) if upwards else self.indexBelow(idx) # wrap around if we arrived at beginning/end @@ -193,6 +194,8 @@ class CompletionView(QTreeView): # Item is a real item, not a category header -> success return idx + raise utils.Unreachable + def _next_category_idx(self, upwards): """Get the index of the previous/next category. @@ -222,30 +225,46 @@ class CompletionView(QTreeView): self.scrollTo(idx) return idx.child(0, 0) + raise utils.Unreachable + @cmdutils.register(instance='completion', modes=[usertypes.KeyMode.command], scope='window') @cmdutils.argument('which', choices=['next', 'prev', 'next-category', 'prev-category']) - def completion_item_focus(self, which): + @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'. + history: Navigate through command history if no text was typed. """ + if history: + status = objreg.get('status-command', scope='window', + window=self._win_id) + if (status.text() == ':' or status.history.is_browsing() or + not self._active): + if which == 'next': + status.command_history_next() + return + elif which == 'prev': + status.command_history_prev() + return + else: + raise cmdexc.CommandError("Can't combine --history with " + "{}!".format(which)) + if not self._active: return - selmodel = self.selectionModel() - if which == 'next': - idx = self._next_idx(upwards=False) - elif which == 'prev': - idx = self._next_idx(upwards=True) - elif which == 'next-category': - idx = self._next_category_idx(upwards=False) - elif which == 'prev-category': - idx = self._next_category_idx(upwards=True) - else: # pragma: no cover - raise ValueError("Invalid 'which' value {!r}".format(which)) + 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), + } + idx = indices[which] if not idx.isValid(): return diff --git a/qutebrowser/completion/models/completionmodel.py b/qutebrowser/completion/models/completionmodel.py index 398673200..aa4422d83 100644 --- a/qutebrowser/completion/models/completionmodel.py +++ b/qutebrowser/completion/models/completionmodel.py @@ -60,8 +60,6 @@ class CompletionModel(QAbstractItemModel): def add_category(self, cat): """Add a completion category to the model.""" self._categories.append(cat) - cat.layoutAboutToBeChanged.connect(self.layoutAboutToBeChanged) - cat.layoutChanged.connect(self.layoutChanged) def data(self, index, role=Qt.DisplayRole): """Return the item data for index. @@ -179,8 +177,13 @@ class CompletionModel(QAbstractItemModel): pattern: The filter pattern to set. """ log.completion.debug("Setting completion pattern '{}'".format(pattern)) + # WORKAROUND: + # layoutChanged is broken in PyQt 5.7.1, so we must use metaObject + # https://www.riverbankcomputing.com/pipermail/pyqt/2017-January/038483.html + self.metaObject().invokeMethod(self, "layoutAboutToBeChanged") for cat in self._categories: cat.set_pattern(pattern) + self.metaObject().invokeMethod(self, "layoutChanged") def first_item(self): """Return the index of the first child (non-category) in the model.""" diff --git a/qutebrowser/completion/models/configmodel.py b/qutebrowser/completion/models/configmodel.py index f1d706cd5..445a57a66 100644 --- a/qutebrowser/completion/models/configmodel.py +++ b/qutebrowser/completion/models/configmodel.py @@ -60,7 +60,8 @@ def value(optname, *_values, info): opt = info.config.get_opt(optname) default = opt.typ.to_str(opt.default) - cur_cat = listcategory.ListCategory("Current/Default", + cur_cat = listcategory.ListCategory( + "Current/Default", [(current, "Current value"), (default, "Default value")]) model.add_category(cur_cat) @@ -77,17 +78,26 @@ def bind(key, *, info): key: the key being bound. """ model = completionmodel.CompletionModel(column_widths=(20, 60, 20)) - cmd_text = info.keyconf.get_command(key, 'normal') + data = [] + cmd_text = info.keyconf.get_command(key, 'normal') if cmd_text: parser = runners.CommandParser() try: cmd = parser.parse(cmd_text).cmd except cmdexc.NoSuchCommandError: - data = [(cmd_text, 'Invalid command!', key)] + data.append((cmd_text, '(Current) Invalid command!', key)) else: - data = [(cmd_text, cmd.desc, key)] - model.add_category(listcategory.ListCategory("Current", data)) + data.append((cmd_text, '(Current) {}'.format(cmd.desc), key)) + + cmd_text = info.keyconf.get_command(key, 'normal', default=True) + if cmd_text: + parser = runners.CommandParser() + cmd = parser.parse(cmd_text).cmd + data.append((cmd_text, '(Default) {}'.format(cmd.desc), key)) + + if data: + model.add_category(listcategory.ListCategory("Current/Default", data)) cmdlist = util.get_cmd_completions(info, include_hidden=True, include_aliases=True) diff --git a/qutebrowser/completion/models/histcategory.py b/qutebrowser/completion/models/histcategory.py index b993b40de..57a2aa936 100644 --- a/qutebrowser/completion/models/histcategory.py +++ b/qutebrowser/completion/models/histcategory.py @@ -19,8 +19,6 @@ """A completion category that queries the SQL History store.""" -import re - from PyQt5.QtSql import QSqlQueryModel from qutebrowser.misc import sql @@ -36,21 +34,7 @@ class HistoryCategory(QSqlQueryModel): """Create a new History completion category.""" super().__init__(parent=parent) self.name = "History" - - # replace ' in timestamp-format to avoid breaking the query - timestamp_format = config.val.completion.timestamp_format - timefmt = ("strftime('{}', last_atime, 'unixepoch', 'localtime')" - .format(timestamp_format.replace("'", "`"))) - - self._query = sql.Query(' '.join([ - "SELECT url, title, {}".format(timefmt), - "FROM CompletionHistory", - # the incoming pattern will have literal % and _ escaped with '\' - # we need to tell sql to treat '\' as an escape character - "WHERE (url LIKE :pat escape '\\' or title LIKE :pat escape '\\')", - self._atime_expr(), - "ORDER BY last_atime DESC", - ]), forward_only=False) + self._query = None # advertise that this model filters by URL and title self.columns_to_filter = [0, 1] @@ -86,11 +70,36 @@ class HistoryCategory(QSqlQueryModel): # escape to treat a user input % or _ as a literal, not a wildcard pattern = pattern.replace('%', '\\%') pattern = pattern.replace('_', '\\_') - # treat spaces as wildcards to match any of the typed words - pattern = re.sub(r' +', '%', pattern) - pattern = '%{}%'.format(pattern) + words = ['%{}%'.format(w) for w in pattern.split(' ')] + + # build a where clause to match all of the words in any order + # given the search term "a b", the WHERE clause would be: + # ((url || title) LIKE '%a%') AND ((url || title) LIKE '%b%') + where_clause = ' AND '.join( + "(url || title) LIKE :{} escape '\\'".format(i) + for i in range(len(words))) + + # replace ' in timestamp-format to avoid breaking the query + timestamp_format = config.val.completion.timestamp_format + timefmt = ("strftime('{}', last_atime, 'unixepoch', 'localtime')" + .format(timestamp_format.replace("'", "`"))) + + if not self._query or len(words) != len(self._query.boundValues()): + # if the number of words changed, we need to generate a new query + # otherwise, we can reuse the prepared query for performance + self._query = sql.Query(' '.join([ + "SELECT url, title, {}".format(timefmt), + "FROM CompletionHistory", + # the incoming pattern will have literal % and _ escaped + # we need to tell sql to treat '\' as an escape character + 'WHERE ({})'.format(where_clause), + self._atime_expr(), + "ORDER BY last_atime DESC", + ]), forward_only=False) + with debug.log_time('sql', 'Running completion query'): - self._query.run(pat=pattern) + self._query.run(**{ + str(i): w for i, w in enumerate(words)}) self.setQuery(self._query) def removeRows(self, row, _count, _parent=None): diff --git a/qutebrowser/completion/models/miscmodels.py b/qutebrowser/completion/models/miscmodels.py index 28ff0ddac..22c9000c3 100644 --- a/qutebrowser/completion/models/miscmodels.py +++ b/qutebrowser/completion/models/miscmodels.py @@ -94,10 +94,11 @@ def session(*, info=None): # pylint: disable=unused-argument return model -def buffer(*, info=None): # pylint: disable=unused-argument - """A model to complete on open tabs across all windows. +def _buffer(skip_win_id=None): + """Helper to get the completion model for buffer/other_buffer. - Used for switching the buffer command. + Args: + skip_win_id: The id of the window to skip, or None to include all. """ def delete_buffer(data): """Close the selected tab.""" @@ -109,6 +110,8 @@ def buffer(*, info=None): # pylint: disable=unused-argument model = completionmodel.CompletionModel(column_widths=(6, 40, 54)) for win_id in objreg.window_registry: + if skip_win_id and win_id == skip_win_id: + continue tabbed_browser = objreg.get('tabbed-browser', scope='window', window=win_id) if tabbed_browser.shutting_down: @@ -120,19 +123,37 @@ def buffer(*, info=None): # pylint: disable=unused-argument tab.url().toDisplayString(), tabbed_browser.page_title(idx))) cat = listcategory.ListCategory("{}".format(win_id), tabs, - delete_func=delete_buffer) + delete_func=delete_buffer) model.add_category(cat) return model -def window(*, info=None): # pylint: disable=unused-argument +def buffer(*, info=None): # pylint: disable=unused-argument + """A model to complete on open tabs across all windows. + + Used for switching the buffer command. + """ + return _buffer() + + +def other_buffer(*, info): + """A model to complete on open tabs across all windows except the current. + + Used for the tab-take command. + """ + return _buffer(skip_win_id=info.win_id) + + +def window(*, info): """A model to complete on all open windows.""" model = completionmodel.CompletionModel(column_widths=(6, 30, 64)) windows = [] for win_id in objreg.window_registry: + if win_id == info.win_id: + continue tabbed_browser = objreg.get('tabbed-browser', scope='window', window=win_id) tab_titles = (tab.title() for tab in tabbed_browser.widgets()) diff --git a/qutebrowser/completion/models/urlmodel.py b/qutebrowser/completion/models/urlmodel.py index 617fa74b5..1d7a075eb 100644 --- a/qutebrowser/completion/models/urlmodel.py +++ b/qutebrowser/completion/models/urlmodel.py @@ -56,14 +56,17 @@ def url(*, info): """ model = completionmodel.CompletionModel(column_widths=(40, 50, 10)) - quickmarks = ((url, name) for (name, url) - in objreg.get('quickmark-manager').marks.items()) + quickmarks = [(url, name) for (name, url) + in objreg.get('quickmark-manager').marks.items()] bookmarks = objreg.get('bookmark-manager').marks.items() - model.add_category(listcategory.ListCategory( - 'Quickmarks', quickmarks, delete_func=_delete_quickmark, sort=False)) - model.add_category(listcategory.ListCategory( - 'Bookmarks', bookmarks, delete_func=_delete_bookmark, sort=False)) + if quickmarks: + model.add_category(listcategory.ListCategory( + 'Quickmarks', quickmarks, delete_func=_delete_quickmark, + sort=False)) + if bookmarks: + model.add_category(listcategory.ListCategory( + 'Bookmarks', bookmarks, delete_func=_delete_bookmark, sort=False)) if info.config.get('completion.web_history_max_items') != 0: hist_cat = histcategory.HistoryCategory(delete_func=_delete_history) diff --git a/qutebrowser/config/config.py b/qutebrowser/config/config.py index 802695b8e..c170d0705 100644 --- a/qutebrowser/config/config.py +++ b/qutebrowser/config/config.py @@ -38,7 +38,7 @@ key_instance = None change_filters = [] -class change_filter: # pylint: disable=invalid-name +class change_filter: # noqa: N801,N806 pylint: disable=invalid-name """Decorator to filter calls based on a config section/option matching. @@ -104,13 +104,17 @@ class change_filter: # pylint: disable=invalid-name if self._function: @functools.wraps(func) def wrapper(option=None): + """Call the underlying function.""" if self._check_match(option): return func() + return None else: @functools.wraps(func) def wrapper(wrapper_self, option=None): + """Call the underlying function.""" if self._check_match(option): return func(wrapper_self) + return None return wrapper @@ -162,10 +166,13 @@ class KeyConfig: cmd_to_keys[cmd].insert(0, key) return cmd_to_keys - def get_command(self, key, mode): + def get_command(self, key, mode, default=False): """Get the command for a given key (or None).""" key = self._prepare(key, mode) - bindings = self.get_bindings_for(mode) + if default: + bindings = dict(val.bindings.default[mode]) + else: + bindings = self.get_bindings_for(mode) return bindings.get(key, None) def bind(self, key, command, *, mode, save_yaml=False): @@ -257,7 +264,7 @@ class Config(QObject): """Set the given option to the given value.""" if not isinstance(objects.backend, objects.NoBackend): if objects.backend not in opt.backends: - raise configexc.BackendError(objects.backend) + raise configexc.BackendError(opt.name, objects.backend) opt.typ.to_py(value) # for validation self._values[opt.name] = opt.typ.from_obj(value) @@ -458,7 +465,8 @@ class ConfigContainer: def __setattr__(self, attr, value): """Set the given option in the config.""" if attr.startswith('_'): - return super().__setattr__(attr, value) + super().__setattr__(attr, value) + return name = self._join(attr) with self._handle_error('setting', name): diff --git a/qutebrowser/config/configcommands.py b/qutebrowser/config/configcommands.py index f659b14d7..7d9adb475 100644 --- a/qutebrowser/config/configcommands.py +++ b/qutebrowser/config/configcommands.py @@ -289,4 +289,7 @@ class ConfigCommands: writer = configfiles.ConfigPyWriter(options, bindings, commented=commented) - writer.write(filename) + try: + writer.write(filename) + except OSError as e: + raise cmdexc.CommandError(str(e)) diff --git a/qutebrowser/config/configdata.py b/qutebrowser/config/configdata.py index 143cbd49a..b265ab8fc 100644 --- a/qutebrowser/config/configdata.py +++ b/qutebrowser/config/configdata.py @@ -174,6 +174,7 @@ def _parse_yaml_backends(name, node): elif isinstance(node, dict): return _parse_yaml_backends_dict(name, node) _raise_invalid_node(name, 'backends', node) + raise utils.Unreachable def _read_yaml(yaml_data): diff --git a/qutebrowser/config/configdata.yml b/qutebrowser/config/configdata.yml index ba8c36857..a118a8b59 100644 --- a/qutebrowser/config/configdata.yml +++ b/qutebrowser/config/configdata.yml @@ -34,6 +34,9 @@ history_gap_interval: `:history`. Use -1 to disable separation. ignore_case: + renamed: search.ignore_case + +search.ignore_case: type: name: String valid_values: @@ -43,6 +46,11 @@ ignore_case: default: smart desc: When to find text on a page case-insensitively. +search.incremental: + type: Bool + default: True + desc: Find text on a page incrementally, renewing the search for each typed character. + new_instance_open_target: type: name: String @@ -79,6 +87,9 @@ new_instance_open_target_window: When `new_instance_open_target` is not set to `window`, this is ignored. session_default_name: + renamed: session.default_name + +session.default_name: type: name: SessionName none_ok: true @@ -88,6 +99,11 @@ session_default_name: If this is set to null, the session which was last loaded is saved. +session.lazy_restore: + type: Bool + default: false + desc: Load a restored tab as soon as it takes focus. + backend: type: name: String @@ -195,8 +211,10 @@ content.cache.size: none_ok: true minval: 0 maxval: maxint64 - desc: Size (in bytes) of the HTTP network cache. Null to use the default - value. + desc: >- + Size (in bytes) of the HTTP network cache. Null to use the default value. + + With QtWebEngine, the maximum supported value is 2147483647 (~2 GB). # Defaults from QWebSettings::QWebSettings() in # qtwebkit/Source/WebKit/qt/Api/qwebsettings.cpp @@ -1233,6 +1251,11 @@ tabs.padding: type: Padding desc: Padding (in pixels) around text for tabs. +tabs.persist_mode_on_change: + default: false + type: Bool + desc: Stay in insert/passthrough mode when switching tabs. + tabs.position: default: top type: Position @@ -1285,6 +1308,7 @@ tabs.title.format: - host - private - current_url + - protocol none_ok: true desc: | Format to use for the tab title. @@ -1299,8 +1323,9 @@ tabs.title.format: * `{scroll_pos}`: Page scroll position. * `{host}`: Host of the current web page. * `{backend}`: Either ''webkit'' or ''webengine'' - * `{private}` : Indicates when private mode is enabled. - * `{current_url}` : URL of the current web page. + * `{private}`: Indicates when private mode is enabled. + * `{current_url}`: URL of the current web page. + * `{protocol}`: Protocol (http/https/...) of the current web page. tabs.title.format_pinned: default: '{index}' @@ -1317,6 +1342,7 @@ tabs.title.format_pinned: - host - private - current_url + - protocol none_ok: true desc: Format to use for the tab title for pinned tabs. The same placeholders like for `tabs.title.format` are defined. @@ -1460,6 +1486,7 @@ window.title_format: - backend - private - current_url + - protocol default: '{perc}{title}{title_sep}qutebrowser' desc: | Format to use for the window title. The same placeholders like for @@ -1513,9 +1540,15 @@ zoom.text_only: ## colors colors.completion.fg: - default: white - type: QtColor - desc: Text color of the completion widget. + default: ["white", "white", "white"] + type: + name: ListOrValue + valtype: QtColor + desc: >- + Text color of the completion widget. + + May be a single color to use for all columns or a list of three colors, + one for each column. colors.completion.odd.bg: default: '#444444' @@ -2269,8 +2302,8 @@ bindings.default: command: : command-history-prev : command-history-next - : command-history-prev - : command-history-next + : completion-item-focus --history prev + : completion-item-focus --history next : completion-item-focus prev : completion-item-focus next : completion-item-focus next-category @@ -2280,6 +2313,7 @@ bindings.default: : completion-item-yank : completion-item-yank --sel : command-accept + : command-accept --rapid : rl-backward-char : rl-forward-char : rl-backward-word diff --git a/qutebrowser/config/configexc.py b/qutebrowser/config/configexc.py index 1199a9864..28b269dd5 100644 --- a/qutebrowser/config/configexc.py +++ b/qutebrowser/config/configexc.py @@ -35,9 +35,9 @@ class BackendError(Error): """Raised when this setting is unavailable with the current backend.""" - def __init__(self, backend): - super().__init__("This setting is not available with the {} " - "backend!".format(backend.name)) + def __init__(self, name, backend): + super().__init__("The {} setting is not available with the {} " + "backend!".format(name, backend.name)) class ValidationError(Error): diff --git a/qutebrowser/config/configfiles.py b/qutebrowser/config/configfiles.py index 27c898611..0e9572f55 100644 --- a/qutebrowser/config/configfiles.py +++ b/qutebrowser/config/configfiles.py @@ -136,7 +136,7 @@ class YamlConfig(QObject): with open(self._filename, 'r', encoding='utf-8') as f: yaml_data = utils.yaml_load(f) except FileNotFoundError: - return {} + return except OSError as e: desc = configexc.ConfigErrorDesc("While reading", e) raise configexc.ConfigFileErrors('autoconfig.yml', [desc]) diff --git a/qutebrowser/config/configinit.py b/qutebrowser/config/configinit.py index 6aaf40720..510245e2e 100644 --- a/qutebrowser/config/configinit.py +++ b/qutebrowser/config/configinit.py @@ -66,14 +66,14 @@ def early_init(args): configfiles.init() - objects.backend = get_backend(args) - for opt, val in args.temp_settings: try: config.instance.set_str(opt, val) except configexc.Error as e: message.error("set: {} - {}".format(e.__class__.__name__, e)) + objects.backend = get_backend(args) + configtypes.Font.monospace_fonts = config.val.fonts.monospace config.instance.changed.connect(_update_monospace_fonts) diff --git a/qutebrowser/config/configtypes.py b/qutebrowser/config/configtypes.py index 8d0af173b..71c32f59e 100644 --- a/qutebrowser/config/configtypes.py +++ b/qutebrowser/config/configtypes.py @@ -499,7 +499,7 @@ class ListOrValue(BaseType): _show_valtype = True - def __init__(self, valtype, none_ok=False, *args, **kwargs): + def __init__(self, valtype, *args, none_ok=False, **kwargs): super().__init__(none_ok) assert not isinstance(valtype, (List, ListOrValue)), valtype self.listtype = List(valtype, none_ok=none_ok, *args, **kwargs) @@ -963,7 +963,7 @@ class Font(BaseType): # Gets set when the config is initialized. monospace_fonts = None font_regex = re.compile(r""" - ^( + ( ( # style (?P + normal link to another page =20 - + -----=_qute-UUID diff --git a/tests/end2end/data/misc/pyeval_file.py b/tests/end2end/data/misc/pyeval_file.py new file mode 100644 index 000000000..e8ede9444 --- /dev/null +++ b/tests/end2end/data/misc/pyeval_file.py @@ -0,0 +1,23 @@ +# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et: + +# Copyright 2017 Florian Bruhin (The Compiler) +# +# This file is part of qutebrowser. +# +# qutebrowser is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# qutebrowser is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with qutebrowser. If not, see . + +"""Simple test file for :debug-pyeval.""" + +from qutebrowser.utils import message +message.info("Hello World") diff --git a/tests/end2end/features/conftest.py b/tests/end2end/features/conftest.py index 7f5b4e2a6..5b97ba2a4 100644 --- a/tests/end2end/features/conftest.py +++ b/tests/end2end/features/conftest.py @@ -439,14 +439,21 @@ def expect_message(quteproc, server, category, message): @bdd.then(bdd.parsers.re(r'(?Pregex )?"(?P[^"]+)" should ' - r'be logged')) -def should_be_logged(quteproc, server, is_regex, pattern): + r'be logged( with level (?P.*))?')) +def should_be_logged(quteproc, server, is_regex, pattern, loglevel): """Expect the given pattern on regex in the log.""" if is_regex: pattern = re.compile(pattern) else: pattern = pattern.replace('(port)', str(server.port)) - line = quteproc.wait_for(message=pattern) + + args = { + 'message': pattern, + } + if loglevel: + args['loglevel'] = getattr(logging, loglevel.upper()) + + line = quteproc.wait_for(**args) line.expected = True diff --git a/tests/end2end/features/downloads.feature b/tests/end2end/features/downloads.feature index 69f47603b..26e3421a1 100644 --- a/tests/end2end/features/downloads.feature +++ b/tests/end2end/features/downloads.feature @@ -242,15 +242,6 @@ Feature: Downloading things from a website. ## Wrong invocations - Scenario: :download with deprecated dest-old argument - When I run :download http://localhost:(port)/ deprecated-argument - Then the warning ":download [url] [dest] is deprecated - use :download --dest [dest] [url]" should be shown - - Scenario: Two destinations given - When I run :download --dest destination2 http://localhost:(port)/ destination1 - Then the warning ":download [url] [dest] is deprecated - use :download --dest [dest] [url]" should be shown - And the error "Can't give two destinations for the download." should be shown - Scenario: :download --mhtml with a URL given When I run :download --mhtml http://foobar/ Then the error "Can only download the current page as mhtml." should be shown @@ -536,7 +527,7 @@ Feature: Downloading things from a website. And I open data/downloads/download.bin without waiting And I wait for the download prompt for "*" And I run :prompt-accept (tmpdir)(dirsep)downloads - And I open data/downloads/download.bin without waiting + And I open data/downloads/download2.bin without waiting And I wait for the download prompt for "*" And I directly open the download And I open data/downloads/download.bin without waiting diff --git a/tests/end2end/features/editor.feature b/tests/end2end/features/editor.feature index f1b53e90d..15da4a6cd 100644 --- a/tests/end2end/features/editor.feature +++ b/tests/end2end/features/editor.feature @@ -115,6 +115,21 @@ Feature: Opening external editors And I run :click-element id qute-button Then the javascript message "text: foobar" should be logged + # Could not get signals working on Windows + # There's no guarantee that the tab gets deleted... + @posix @flaky + Scenario: Spawning an editor and closing the tab + When I set up a fake editor that waits + And I open data/editor.html + And I run :click-element id qute-textarea + And I wait for "Entering mode KeyMode.insert (reason: clicking input)" in the log + And I run :open-editor + And I set tabs.last_close to blank + And I run :tab-close + And I kill the waiting editor + Then the error "Edited element vanished" should be shown + + @qtwebengine_todo: Caret mode is not implemented yet Scenario: Spawning an editor in caret mode When I set up a fake editor returning "foobar" And I open data/editor.html @@ -140,3 +155,25 @@ Feature: Opening external editors And I wait for "Read back: bar" in the log And I run :click-element id qute-button Then the javascript message "text: bar" should be logged + + ## :edit-command + + Scenario: Edit a command and run it + When I run :set-cmd-text :message-info foo + And I set up a fake editor replacing "foo" by "bar" + And I run :edit-command --run + Then the message "bar" should be shown + And "Leaving mode KeyMode.command (reason: cmd accept)" should be logged + + Scenario: Edit a command and omit the start char + When I set up a fake editor returning "message-info foo" + And I run :edit-command + Then the error "command must start with one of :/?" should be shown + And "Leaving mode KeyMode.command *" should not be logged + + Scenario: Edit a command to be empty + When I run :set-cmd-text : + When I set up a fake editor returning empty text + And I run :edit-command + Then the error "command must start with one of :/?" should be shown + And "Leaving mode KeyMode.command *" should not be logged diff --git a/tests/end2end/features/history.feature b/tests/end2end/features/history.feature index aa126c4f7..00bd20403 100644 --- a/tests/end2end/features/history.feature +++ b/tests/end2end/features/history.feature @@ -5,8 +5,7 @@ Feature: Page history Make sure the global page history is saved correctly. Background: - Given I open about:blank - And I run :history-clear --force + Given I run :history-clear --force Scenario: Simple history saving When I open data/numbers/1.txt diff --git a/tests/end2end/features/javascript.feature b/tests/end2end/features/javascript.feature index a309d6187..3ccd50efb 100644 --- a/tests/end2end/features/javascript.feature +++ b/tests/end2end/features/javascript.feature @@ -123,3 +123,25 @@ Feature: Javascript stuff And I wait for "[*/data/javascript/windowsize.html:*] loaded" in the log And I run :tab-next Then the window sizes should be the same + + Scenario: Have a GreaseMonkey script run at page start + When I have a GreaseMonkey file saved for document-start with noframes unset + And I run :greasemonkey-reload + And I open data/hints/iframe.html + # This second reload is required in webengine < 5.8 for scripts + # registered to run at document-start, some sort of timing issue. + And I run :reload + Then the javascript message "Script is running on /data/hints/iframe.html" should be logged + + Scenario: Have a GreaseMonkey script running on frames + When I have a GreaseMonkey file saved for document-end with noframes unset + And I run :greasemonkey-reload + And I open data/hints/iframe.html + Then the javascript message "Script is running on /data/hints/html/wrapped.html" should be logged + + @flaky + Scenario: Have a GreaseMonkey script running on noframes + When I have a GreaseMonkey file saved for document-end with noframes set + And I run :greasemonkey-reload + And I open data/hints/iframe.html + Then the javascript message "Script is running on /data/hints/html/wrapped.html" should not be logged diff --git a/tests/end2end/features/marks.feature b/tests/end2end/features/marks.feature index 605bd3971..f7da07255 100644 --- a/tests/end2end/features/marks.feature +++ b/tests/end2end/features/marks.feature @@ -86,7 +86,7 @@ Feature: Setting positional marks And I wait until the scroll position changed to 10/10 Then the page should be scrolled to 10 10 - @qtwebengine_todo: Does not emit loaded signal for fragments? + @qtwebengine_skip: Does not emit loaded signal for fragments? Scenario: Jumping back after following a link When I hint with args "links normal" and follow s And I wait until data/marks.html#bottom is loaded diff --git a/tests/end2end/features/misc.feature b/tests/end2end/features/misc.feature index 701ff3feb..8f21b7421 100644 --- a/tests/end2end/features/misc.feature +++ b/tests/end2end/features/misc.feature @@ -9,6 +9,13 @@ Feature: Various utility commands. And I run :command-accept Then the message "Hello World" should be shown + Scenario: :set-cmd-text and :command-accept --rapid + When I run :set-cmd-text :message-info "Hello World" + And I run :command-accept --rapid + And I run :command-accept + Then the message "Hello World" should be shown + And the message "Hello World" should be shown + Scenario: :set-cmd-text with two commands When I run :set-cmd-text :message-info test ;; message-error error And I run :command-accept @@ -118,8 +125,8 @@ Feature: Various utility commands. And "No output or error" should be logged Scenario: :jseval --file using a file that doesn't exist as js-code - When I run :jseval --file nonexistentfile - Then the error "[Errno 2] No such file or directory: 'nonexistentfile'" should be shown + When I run :jseval --file /nonexistentfile + Then the error "[Errno 2] No such file or directory: '/nonexistentfile'" should be shown And "No output or error" should not be logged # :debug-webaction @@ -449,6 +456,11 @@ Feature: Various utility commands. And I run :click-element id qute-input Then "Entering mode KeyMode.insert (reason: clicking input)" should be logged + Scenario: Clicking an element by ID with dot + When I open data/click_element.html + And I run :click-element id foo.bar + Then the javascript message "id with dot" should be logged + Scenario: Clicking an element with tab target When I open data/click_element.html And I run :tab-only @@ -469,6 +481,17 @@ Feature: Various utility commands. And I run :command-accept Then the message "blah" should be shown + Scenario: Calling previous command with :completion-item-focus + When I run :set-cmd-text :message-info blah + And I wait for "Entering mode KeyMode.command (reason: *)" in the log + And I run :command-accept + And I wait for "blah" in the log + And I run :set-cmd-text : + And I wait for "Entering mode KeyMode.command (reason: *)" in the log + And I run :completion-item-focus prev --history + And I run :command-accept + Then the message "blah" should be shown + Scenario: Browsing through commands When I run :set-cmd-text :message-info blarg And I run :command-accept diff --git a/tests/end2end/features/qutescheme.feature b/tests/end2end/features/qutescheme.feature index e4ae20215..755c103e7 100644 --- a/tests/end2end/features/qutescheme.feature +++ b/tests/end2end/features/qutescheme.feature @@ -76,7 +76,7 @@ Feature: Special qute:// pages Scenario: Opening a link with qute://help/img/ When the documentation is up to date And I open qute://help/img/ without waiting - Then "OSError while handling qute://* URL" should be logged + Then "*Error while * qute://*" should be logged And "* url='qute://help/img'* LoadStatus.error" should be logged # :history @@ -99,26 +99,28 @@ Feature: Special qute:// pages # qute://settings Scenario: Focusing input fields in qute://settings and entering valid value - When I set ignore_case to never + When I set search.ignore_case to never And I open qute://settings # scroll to the right - the table does not fit in the default screen And I run :scroll-to-perc -x 100 - And I run :jseval document.getElementById('input-ignore_case').value = '' - And I run :click-element id input-ignore_case + And I run :jseval document.getElementById('input-search.ignore_case').value = '' + And I run :click-element id input-search.ignore_case And I wait for "Entering mode KeyMode.insert *" in the log And I press the keys "always" And I press the key "" # an explicit Tab to unfocus the input field seems to stabilize the tests And I press the key "" - And I wait for "Config option changed: ignore_case *" in the log - Then the option ignore_case should be set to always + And I wait for "Config option changed: search.ignore_case *" in the log + Then the option search.ignore_case should be set to always + # Sometimes, an unrelated value gets set + @flaky Scenario: Focusing input fields in qute://settings and entering invalid value When I open qute://settings # scroll to the right - the table does not fit in the default screen And I run :scroll-to-perc -x 100 - And I run :jseval document.getElementById('input-ignore_case').value = '' - And I run :click-element id input-ignore_case + And I run :jseval document.getElementById('input-search.ignore_case').value = '' + And I run :click-element id input-search.ignore_case And I wait for "Entering mode KeyMode.insert *" in the log And I press the keys "foo" And I press the key "" @@ -171,6 +173,15 @@ Feature: Special qute:// pages And I wait until qute://pyeval/ is loaded Then the page should contain the plaintext "ZeroDivisionError" + Scenario: Running :pyveal with --file using a file that exists as python code + When I run :debug-pyeval --file (testdata)/misc/pyeval_file.py + Then the message "Hello World" should be shown + And "pyeval output: No error" should be logged + + Scenario: Running :pyeval --file using a non existing file + When I run :debug-pyeval --file nonexistentfile + Then the error "[Errno 2] No such file or directory: 'nonexistentfile'" should be shown + Scenario: Running :pyeval with --quiet When I run :debug-pyeval --quiet 1+1 Then "pyeval output: 2" should be logged diff --git a/tests/end2end/features/search.feature b/tests/end2end/features/search.feature index 3778f963d..d1a00b5b6 100644 --- a/tests/end2end/features/search.feature +++ b/tests/end2end/features/search.feature @@ -22,7 +22,7 @@ Feature: Searching on a page Then "Bar" should be found Scenario: Searching with --reverse - When I set ignore_case to always + When I set search.ignore_case to always And I run :search -r foo And I wait for "search found foo with flags FindBackward" in the log Then "Foo" should be found @@ -52,28 +52,28 @@ Feature: Searching on a page And I wait for "search didn't find blub" in the log Then the warning "Text 'blub' not found on page!" should be shown - ## ignore_case + ## search.ignore_case - Scenario: Searching text with ignore_case = always - When I set ignore_case to always + Scenario: Searching text with search.ignore_case = always + When I set search.ignore_case to always And I run :search bar And I wait for "search found bar" in the log Then "Bar" should be found - Scenario: Searching text with ignore_case = never - When I set ignore_case to never + Scenario: Searching text with search.ignore_case = never + When I set search.ignore_case to never And I run :search bar And I wait for "search found bar with flags FindCaseSensitively" in the log Then "bar" should be found - Scenario: Searching text with ignore_case = smart (lower-case) - When I set ignore_case to smart + Scenario: Searching text with search.ignore_case = smart (lower-case) + When I set search.ignore_case to smart And I run :search bar And I wait for "search found bar" in the log Then "Bar" should be found - Scenario: Searching text with ignore_case = smart (upper-case) - When I set ignore_case to smart + Scenario: Searching text with search.ignore_case = smart (upper-case) + When I set search.ignore_case to smart And I run :search Foo And I wait for "search found Foo with flags FindCaseSensitively" in the log Then "Foo" should be found # even though foo was first @@ -81,7 +81,7 @@ Feature: Searching on a page ## :search-next Scenario: Jumping to next match - When I set ignore_case to always + When I set search.ignore_case to always And I run :search foo And I wait for "search found foo" in the log And I run :search-next @@ -89,7 +89,7 @@ Feature: Searching on a page Then "Foo" should be found Scenario: Jumping to next match with count - When I set ignore_case to always + When I set search.ignore_case to always And I run :search baz And I wait for "search found baz" in the log And I run :search-next with count 2 @@ -97,7 +97,7 @@ Feature: Searching on a page Then "BAZ" should be found Scenario: Jumping to next match with --reverse - When I set ignore_case to always + When I set search.ignore_case to always And I run :search --reverse foo And I wait for "search found foo with flags FindBackward" in the log And I run :search-next @@ -121,7 +121,7 @@ Feature: Searching on a page # https://github.com/qutebrowser/qutebrowser/issues/2438 Scenario: Jumping to next match after clearing - When I set ignore_case to always + When I set search.ignore_case to always And I run :search foo And I wait for "search found foo" in the log And I run :search @@ -132,7 +132,7 @@ Feature: Searching on a page ## :search-prev Scenario: Jumping to previous match - When I set ignore_case to always + When I set search.ignore_case to always And I run :search foo And I wait for "search found foo" in the log And I run :search-next @@ -142,7 +142,7 @@ Feature: Searching on a page Then "foo" should be found Scenario: Jumping to previous match with count - When I set ignore_case to always + When I set search.ignore_case to always And I run :search baz And I wait for "search found baz" in the log And I run :search-next @@ -154,7 +154,7 @@ Feature: Searching on a page Then "baz" should be found Scenario: Jumping to previous match with --reverse - When I set ignore_case to always + When I set search.ignore_case to always And I run :search --reverse foo And I wait for "search found foo with flags FindBackward" in the log And I run :search-next @@ -225,11 +225,15 @@ Feature: Searching on a page Then the following tabs should be open: - data/search.html (active) + # Following a link selected via JS doesn't work in Qt 5.10 anymore. + @qt!=5.10 Scenario: Follow a manually selected link When I run :jseval --file (testdata)/search_select.js And I run :follow-selected Then data/hello.txt should be loaded + # Following a link selected via JS doesn't work in Qt 5.10 anymore. + @qt!=5.10 Scenario: Follow a manually selected link in a new tab When I run :window-only And I run :jseval --file (testdata)/search_select.js diff --git a/tests/end2end/features/sessions.feature b/tests/end2end/features/sessions.feature index 5ec6e168a..50054c665 100644 --- a/tests/end2end/features/sessions.feature +++ b/tests/end2end/features/sessions.feature @@ -279,7 +279,7 @@ Feature: Saving and loading sessions Scenario: Saving session with --quiet When I run :session-save --quiet quiet_session - Then "Saved session quiet_session." should not be logged + Then "Saved session quiet_session." should be logged with level debug And the session quiet_session should exist Scenario: Saving session with --only-active-window diff --git a/tests/end2end/features/spawn.feature b/tests/end2end/features/spawn.feature index e2b5fdd5b..2a1ea0039 100644 --- a/tests/end2end/features/spawn.feature +++ b/tests/end2end/features/spawn.feature @@ -40,21 +40,21 @@ Feature: :spawn @posix Scenario: Running :spawn with userscript - When I open about:blank + When I open data/hello.txt And I run :spawn -u (testdata)/userscripts/open_current_url - And I wait until about:blank is loaded + And I wait until data/hello.txt is loaded Then the following tabs should be open: - - about:blank - - about:blank (active) + - data/hello.txt + - data/hello.txt (active) @windows Scenario: Running :spawn with userscript on Windows - When I open about:blank + When I open data/hello.txt And I run :spawn -u (testdata)/userscripts/open_current_url.bat - And I wait until about:blank is loaded + And I wait until data/hello.txt is loaded Then the following tabs should be open: - - about:blank - - about:blank (active) + - data/hello.txt + - data/hello.txt (active) @posix Scenario: Running :spawn with userscript that expects the stdin getting closed diff --git a/tests/end2end/features/tabs.feature b/tests/end2end/features/tabs.feature index 77487d33d..b38d87a6e 100644 --- a/tests/end2end/features/tabs.feature +++ b/tests/end2end/features/tabs.feature @@ -628,7 +628,8 @@ Feature: Tab management @qtwebkit_skip @qt<5.9 Scenario: Cloning a tab with a view-source URL - When I open view-source:http://localhost:(port) + When I open / + And I open view-source:http://localhost:(port) And I run :tab-clone Then the error "Can't serialize special URL!" should be shown @@ -760,6 +761,7 @@ Feature: Tab management - data/numbers/3.txt - data/numbers/2.txt + @flaky Scenario: Undo a tab closed after new tab opened When I open data/numbers/1.txt And I open data/numbers/2.txt in a new tab diff --git a/tests/end2end/features/test_editor_bdd.py b/tests/end2end/features/test_editor_bdd.py index eb937e0f2..260197a46 100644 --- a/tests/end2end/features/test_editor_bdd.py +++ b/tests/end2end/features/test_editor_bdd.py @@ -20,10 +20,14 @@ import sys import json import textwrap +import os +import signal import pytest_bdd as bdd bdd.scenarios('editor.feature') +from qutebrowser.utils import utils + @bdd.when(bdd.parsers.parse('I set up a fake editor replacing "{text}" by ' '"{replacement}"')) @@ -47,7 +51,7 @@ def set_up_editor_replacement(quteproc, server, tmpdir, text, replacement): @bdd.when(bdd.parsers.parse('I set up a fake editor returning "{text}"')) -def set_up_editor(quteproc, server, tmpdir, text): +def set_up_editor(quteproc, tmpdir, text): """Set up editor.command to a small python script inserting a text.""" script = tmpdir / 'script.py' script.write(textwrap.dedent(""" @@ -58,3 +62,42 @@ def set_up_editor(quteproc, server, tmpdir, text): """.format(text=text))) editor = json.dumps([sys.executable, str(script), '{}']) quteproc.set_setting('editor.command', editor) + + +@bdd.when(bdd.parsers.parse('I set up a fake editor returning empty text')) +def set_up_editor_empty(quteproc, tmpdir): + """Set up editor.command to a small python script inserting empty text.""" + set_up_editor(quteproc, tmpdir, "") + + +@bdd.when(bdd.parsers.parse('I set up a fake editor that waits')) +def set_up_editor_wait(quteproc, tmpdir): + """Set up editor.command to a small python script inserting a text.""" + assert not utils.is_windows + pidfile = tmpdir / 'editor_pid' + script = tmpdir / 'script.py' + script.write(textwrap.dedent(""" + import os + import sys + import time + import signal + + with open(r'{pidfile}', 'w') as f: + f.write(str(os.getpid())) + + signal.signal(signal.SIGUSR1, lambda s, f: sys.exit(0)) + time.sleep(100) + """.format(pidfile=pidfile))) + editor = json.dumps([sys.executable, str(script), '{}']) + quteproc.set_setting('editor.command', editor) + + +@bdd.when(bdd.parsers.parse('I kill the waiting editor')) +def kill_editor_wait(tmpdir): + """Kill the waiting editor.""" + pidfile = tmpdir / 'editor_pid' + pid = int(pidfile.read()) + # windows has no SIGUSR1, but we don't run this on windows anyways + # for posix, there IS a member so we need to ignore useless-suppression + # pylint: disable=no-member,useless-suppression + os.kill(pid, signal.SIGUSR1) diff --git a/tests/end2end/features/test_javascript_bdd.py b/tests/end2end/features/test_javascript_bdd.py index 9f6c021ce..631a422a5 100644 --- a/tests/end2end/features/test_javascript_bdd.py +++ b/tests/end2end/features/test_javascript_bdd.py @@ -17,6 +17,8 @@ # You should have received a copy of the GNU General Public License # along with qutebrowser. If not, see . +import os.path + import pytest_bdd as bdd bdd.scenarios('javascript.feature') @@ -29,3 +31,38 @@ def check_window_sizes(quteproc): hidden_size = hidden.message.split()[-1] visible_size = visible.message.split()[-1] assert hidden_size == visible_size + + +test_gm_script = r""" +// ==UserScript== +// @name qutebrowser test userscript +// @namespace invalid.org +// @include http://localhost:*/data/hints/iframe.html +// @include http://localhost:*/data/hints/html/wrapped.html +// @exclude ??? +// @run-at {stage} +// {frames} +// ==/UserScript== +console.log("Script is running on " + window.location.pathname); +""" + + +@bdd.when(bdd.parsers.parse("I have a GreaseMonkey file saved for {stage} " + "with noframes {frameset}")) +def create_greasemonkey_file(quteproc, stage, frameset): + script_path = os.path.join(quteproc.basedir, 'data', 'greasemonkey') + try: + os.mkdir(script_path) + except FileExistsError: + pass + file_path = os.path.join(script_path, 'test.user.js') + if frameset == "set": + frames = "@noframes" + elif frameset == "unset": + frames = "" + else: + raise ValueError("noframes can only be set or unset, " + "not {}".format(frameset)) + with open(file_path, 'w', encoding='utf-8') as f: + f.write(test_gm_script.format(stage=stage, + frames=frames)) diff --git a/tests/end2end/features/urlmarks.feature b/tests/end2end/features/urlmarks.feature index c171cbcd3..836af5c4f 100644 --- a/tests/end2end/features/urlmarks.feature +++ b/tests/end2end/features/urlmarks.feature @@ -89,9 +89,9 @@ Feature: quickmarks and bookmarks Then the bookmark file should not contain "http://localhost:*/data/numbers/5.txt " Scenario: Deleting the current page's bookmark if it doesn't exist - When I open about:blank + When I open data/hello.txt And I run :bookmark-del - Then the error "Bookmark 'about:blank' not found!" should be shown + Then the error "Bookmark 'http://localhost:(port)/data/hello.txt' not found!" should be shown Scenario: Deleting the current page's bookmark When I open data/numbers/6.txt @@ -212,9 +212,9 @@ Feature: quickmarks and bookmarks Then the quickmark file should not contain "eighteen http://localhost:*/data/numbers/18.txt " Scenario: Deleting the current page's quickmark if it has none - When I open about:blank + When I open data/hello.txt And I run :quickmark-del - Then the error "Quickmark for 'about:blank' not found!" should be shown + Then the error "Quickmark for 'http://localhost:(port)/data/hello.txt' not found!" should be shown Scenario: Deleting the current page's quickmark When I open data/numbers/19.txt diff --git a/tests/end2end/fixtures/quteprocess.py b/tests/end2end/fixtures/quteprocess.py index a2bbd81fd..a2947e5af 100644 --- a/tests/end2end/fixtures/quteprocess.py +++ b/tests/end2end/fixtures/quteprocess.py @@ -20,10 +20,10 @@ """Fixtures to run qutebrowser in a QProcess and communicate.""" import os +import os.path import re import sys import time -import os.path import datetime import logging import tempfile @@ -33,10 +33,10 @@ import json import yaml import pytest -from PyQt5.QtCore import pyqtSignal, QUrl +from PyQt5.QtCore import pyqtSignal, QUrl, qVersion from qutebrowser.misc import ipc -from qutebrowser.utils import log, utils, javascript +from qutebrowser.utils import log, utils, javascript, qtutils from helpers import utils as testutils from end2end.fixtures import testprocess @@ -44,74 +44,66 @@ from end2end.fixtures import testprocess instance_counter = itertools.count() -def is_ignored_qt_message(message): +def is_ignored_qt_message(pytestconfig, message): """Check if the message is listed in qt_log_ignore.""" - regexes = pytest.config.getini('qt_log_ignore') + regexes = pytestconfig.getini('qt_log_ignore') for regex in regexes: - if re.match(regex, message): + if re.search(regex, message): return True return False def is_ignored_lowlevel_message(message): """Check if we want to ignore a lowlevel process output.""" - if message.startswith('Xlib: sequence lost'): + ignored_messages = [ # https://travis-ci.org/qutebrowser/qutebrowser/jobs/157941720 # ??? - return True - elif ("_dl_allocate_tls_init: Assertion `listp->slotinfo[cnt].gen <= " - "GL(dl_tls_generation)' failed!" in message): + 'Xlib: sequence lost*', # Started appearing with Qt 5.8... # http://patchwork.sourceware.org/patch/10255/ - return True - elif message == 'getrlimit(RLIMIT_NOFILE) failed': - return True - # Travis CI containers don't have a /etc/machine-id - elif message.endswith('D-Bus library appears to be incorrectly set up; ' - 'failed to read machine uuid: Failed to open ' - '"/etc/machine-id": No such file or directory'): - return True - elif message == ('See the manual page for dbus-uuidgen to correct this ' - 'issue.'): - return True - # Travis CI macOS: - # 2017-09-11 07:32:56.191 QtWebEngineProcess[5455:28501] Couldn't set - # selectedTextBackgroundColor from default () - elif message.endswith("Couldn't set selectedTextBackgroundColor from " - "default ()"): - return True - # Mac Mini: - # <<<< VTVideoEncoderSelection >>>> - # VTSelectAndCreateVideoEncoderInstanceInternal: no video encoder found for - # 'avc1' - # - # [22:32:03.636] VTSelectAndCreateVideoEncoderInstanceInternal signalled - # err=-12908 (err) (Video encoder not available) at - # /SourceCache/CoreMedia_frameworks/CoreMedia-1562.240/Sources/ - # VideoToolbox/VTVideoEncoderSelection.c line 1245 - # - # [22:32:03.636] VTCompressionSessionCreate signalled err=-12908 (err) - # (Could not select and open encoder instance) at - # /SourceCache/CoreMedia_frameworks/CoreMedia-1562.240/Sources/ - # VideoToolbox/VTCompressionSession.c # line 946 - elif 'VTSelectAndCreateVideoEncoderInstanceInternal' in message: - return True - elif 'VTSelectAndCreateVideoEncoderInstanceInternal' in message: - return True - elif 'VTCompressionSessionCreate' in message: - return True - # During shutdown on AppVeyor: - # https://ci.appveyor.com/project/qutebrowser/qutebrowser/build/master-2089/job/or4tbct1oeqsfhfm - elif (message.startswith('QNetworkProxyFactory: factory 0x') and - message.endswith(' has returned an empty result set')): - return True - elif message == ' Error: No such file or directory': + ("*_dl_allocate_tls_init: Assertion `listp->slotinfo[cnt].gen <= " + "GL(dl_tls_generation)' failed!*"), + # ??? + 'getrlimit(RLIMIT_NOFILE) failed', + # Travis CI containers don't have a /etc/machine-id + ('*D-Bus library appears to be incorrectly set up; failed to read ' + 'machine uuid: Failed to open "/etc/machine-id": No such file or ' + 'directory'), + 'See the manual page for dbus-uuidgen to correct this issue.', + # Travis CI macOS: + # 2017-09-11 07:32:56.191 QtWebEngineProcess[5455:28501] Couldn't set + # selectedTextBackgroundColor from default () + "* Couldn't set selectedTextBackgroundColor from default ()", + # Mac Mini: + # <<<< VTVideoEncoderSelection >>>> + # VTSelectAndCreateVideoEncoderInstanceInternal: no video encoder + # found for 'avc1' + # + # [22:32:03.636] VTSelectAndCreateVideoEncoderInstanceInternal + # signalled err=-12908 (err) (Video encoder not available) at + # /SourceCache/CoreMedia_frameworks/CoreMedia-1562.240/Sources/ + # VideoToolbox/VTVideoEncoderSelection.c line 1245 + # + # [22:32:03.636] VTCompressionSessionCreate signalled err=-12908 (err) + # (Could not select and open encoder instance) at + # /SourceCache/CoreMedia_frameworks/CoreMedia-1562.240/Sources/ + # VideoToolbox/VTCompressionSession.c # line 946 + '*VTSelectAndCreateVideoEncoderInstanceInternal*', + '*VTSelectAndCreateVideoEncoderInstanceInternal*', + '*VTCompressionSessionCreate*', + # During shutdown on AppVeyor: + # https://ci.appveyor.com/project/qutebrowser/qutebrowser/build/master-2089/job/or4tbct1oeqsfhfm + 'QNetworkProxyFactory: factory 0x* has returned an empty result set', # Qt 5.10 with debug Chromium # [1016/155149.941048:WARNING:stack_trace_posix.cc(625)] Failed to open # file: /home/florian/#14687139 (deleted) # Error: No such file or directory - return True - return False + ' Error: No such file or directory', + # Qt 5.7.1 + 'qt.network.ssl: QSslSocket: cannot call unresolved function *', + ] + return any(testutils.pattern_match(pattern=pattern, value=message) + for pattern in ignored_messages) def is_ignored_chromium_message(line): @@ -147,6 +139,12 @@ def is_ignored_chromium_message(line): # channel message 'Invalid node channel message', + # Qt 5.9.3 + # [30217:30229:1124/141512.682110:ERROR: + # cert_verify_proc_openssl.cc(212)] + # X509 Verification error self signed certificate : 18 : 0 : 4 + 'X509 Verification error self signed certificate : 18 : 0 : 4', + # Not reproducible anymore? 'Running without the SUID sandbox! *', @@ -156,7 +154,7 @@ def is_ignored_chromium_message(line): 'Invalid node channel message *', # Makes tests fail on Quantumcross' machine ('CreatePlatformSocket() returned an error, errno=97: Address family' - 'not supported by protocol'), + 'not supported by protocol'), # Qt 5.9 with debug Chromium @@ -171,7 +169,7 @@ def is_ignored_chromium_message(line): # /tmp/pytest-of-florian/pytest-32/test_webengine_download_suffix0/ # downloads/download.bin: Operation not supported ('Could not set extended attribute user.xdg.* on file *: ' - 'Operation not supported'), + 'Operation not supported'), # [5947:5947:0605/192837.856931:ERROR:render_process_impl.cc(112)] # WebFrame LEAKED 1 TIMES 'WebFrame LEAKED 1 TIMES', @@ -209,7 +207,7 @@ class LogLine(testprocess.Line): expected: Whether the message was expected or not. """ - def __init__(self, data): + def __init__(self, pytestconfig, data): super().__init__(data) try: line = json.loads(data) @@ -231,7 +229,7 @@ class LogLine(testprocess.Line): self.traceback = line.get('traceback') self.message = line['message'] - self.expected = is_ignored_qt_message(self.message) + self.expected = is_ignored_qt_message(pytestconfig, self.message) self.use_color = False def __str__(self): @@ -301,14 +299,13 @@ class QuteProc(testprocess.Process): 'message'] def __init__(self, request, *, parent=None): - super().__init__(parent) + super().__init__(request, parent) self._ipc_socket = None self.basedir = None self._focus_ready = False self._load_ready = False self._instance_id = next(instance_counter) self._run_counter = itertools.count() - self.request = request def _is_ready(self, what): """Called by _parse_line if loading/focusing is done. @@ -359,9 +356,10 @@ class QuteProc(testprocess.Process): if not self._load_ready: log_line.waited_for = True self._is_ready('load') - elif log_line.category == 'misc' and any(testutils.pattern_match( - pattern=pattern, value=log_line.message) for pattern in - start_okay_messages_focus): + elif (log_line.category == 'misc' and any( + testutils.pattern_match(pattern=pattern, + value=log_line.message) + for pattern in start_okay_messages_focus)): self._is_ready('focus') elif (log_line.category == 'init' and log_line.module == 'standarddir' and @@ -373,11 +371,11 @@ class QuteProc(testprocess.Process): def _parse_line(self, line): try: - log_line = LogLine(line) + log_line = LogLine(self.request.config, line) except testprocess.InvalidLine: if not line.strip(): return None - elif (is_ignored_qt_message(line) or + elif (is_ignored_qt_message(self.request.config, line) or is_ignored_lowlevel_message(line) or is_ignored_chromium_message(line) or self.request.node.get_marker('no_invalid_lines')): @@ -422,10 +420,14 @@ class QuteProc(testprocess.Process): def _default_args(self): backend = 'webengine' if self.request.config.webengine else 'webkit' - return ['--debug', '--no-err-windows', '--temp-basedir', + args = ['--debug', '--no-err-windows', '--temp-basedir', '--json-logging', '--loglevel', 'vdebug', - '--backend', backend, '--debug-flag', 'no-sql-history', - 'about:blank'] + '--backend', backend, '--debug-flag', 'no-sql-history'] + if qVersion() == '5.7.1': + # https://github.com/qutebrowser/qutebrowser/issues/3163 + args += ['--qt-flag', 'disable-seccomp-filter-sandbox'] + args.append('about:blank') + return args def path_to_url(self, path, *, port=None, https=False): """Get a URL based on a filename for the localhost webserver. @@ -525,6 +527,7 @@ class QuteProc(testprocess.Process): super().before_test() self.send_cmd(':config-clear') self._init_settings() + self.clear_data() def _init_settings(self): """Adjust some qutebrowser settings after starting.""" @@ -680,20 +683,29 @@ class QuteProc(testprocess.Process): else: timeout = 5000 - # We really need the same representation that the webview uses in its - # __repr__ qurl = QUrl(url) if not qurl.isValid(): raise ValueError("Invalid URL {}: {}".format(url, qurl.errorString())) - url = utils.elide(qurl.toDisplayString(QUrl.EncodeUnicode), 100) - assert url - pattern = re.compile( - r"(load status for : LoadStatus\.{load_status}|fetch: " - r"PyQt5\.QtCore\.QUrl\('{url}'\) -> .*)".format( - load_status=re.escape(load_status), url=re.escape(url))) + if (qurl == QUrl('about:blank') and + not qtutils.version_check('5.10', compiled=False)): + # For some reason, we don't get a LoadStatus.success for + # about:blank sometimes. + # However, if we do this for Qt 5.10, we get general testsuite + # instability as site loads get reported with about:blank... + pattern = "Changing title for idx * to 'about:blank'" + else: + # We really need the same representation that the webview uses in + # its __repr__ + url = utils.elide(qurl.toDisplayString(QUrl.EncodeUnicode), 100) + assert url + + pattern = re.compile( + r"(load status for : LoadStatus\.{load_status}|fetch: " + r"PyQt5\.QtCore\.QUrl\('{url}'\) -> .*)".format( + load_status=re.escape(load_status), url=re.escape(url))) try: self.wait_for(message=pattern, timeout=timeout) diff --git a/tests/end2end/fixtures/test_quteprocess.py b/tests/end2end/fixtures/test_quteprocess.py index b537960f4..aa3fb5857 100644 --- a/tests/end2end/fixtures/test_quteprocess.py +++ b/tests/end2end/fixtures/test_quteprocess.py @@ -45,6 +45,10 @@ class FakeConfig: '--qute-delay': 0, '--color': True, '--verbose': False, + '--capture': None, + } + INI = { + 'qt_log_ignore': [], } def __init__(self): @@ -53,6 +57,9 @@ class FakeConfig: def getoption(self, name): return self.ARGS[name] + def getini(self, name): + return self.INI[name] + class FakeNode: @@ -222,8 +229,8 @@ def test_quteprocess_quitting(qtbot, quteproc_process): {'category': 'py.warnings'}, id='resourcewarning'), ]) -def test_log_line_parse(data, attrs): - line = quteprocess.LogLine(data) +def test_log_line_parse(pytestconfig, data, attrs): + line = quteprocess.LogLine(pytestconfig, data) for name, expected in attrs.items(): actual = getattr(line, name) assert actual == expected, name @@ -241,8 +248,8 @@ def test_log_line_parse(data, attrs): pytest.param( {'created': 86400, 'msecs': 0, 'levelname': 'DEBUG', 'name': 'foo', 'module': 'bar', 'funcName': 'qux', 'lineno': 10, 'levelno': 10, - 'message': 'quux', 'traceback': 'Traceback (most recent call ' - 'last):\n here be dragons'}, + 'message': 'quux', 'traceback': ('Traceback (most recent call ' + 'last):\n here be dragons')}, False, False, '{timestamp} DEBUG foo bar:qux:10 quux\n' 'Traceback (most recent call last):\n' @@ -283,9 +290,10 @@ def test_log_line_parse(data, attrs): '\033[36mfoo bar:qux:10\033[0m \033[37mquux\033[0m', id='expected error colorized'), ]) -def test_log_line_formatted(data, colorized, expect_error, expected): +def test_log_line_formatted(pytestconfig, + data, colorized, expect_error, expected): line = json.dumps(data) - record = quteprocess.LogLine(line) + record = quteprocess.LogLine(pytestconfig, line) record.expected = expect_error ts = datetime.datetime.fromtimestamp(data['created']).strftime('%H:%M:%S') ts += '.{:03.0f}'.format(data['msecs']) @@ -293,9 +301,9 @@ def test_log_line_formatted(data, colorized, expect_error, expected): assert record.formatted_str(colorized=colorized) == expected -def test_log_line_no_match(): +def test_log_line_no_match(pytestconfig): with pytest.raises(testprocess.InvalidLine): - quteprocess.LogLine("Hello World!") + quteprocess.LogLine(pytestconfig, "Hello World!") class TestClickElementByText: diff --git a/tests/end2end/fixtures/test_testprocess.py b/tests/end2end/fixtures/test_testprocess.py index 1811b7fb1..6ceb032af 100644 --- a/tests/end2end/fixtures/test_testprocess.py +++ b/tests/end2end/fixtures/test_testprocess.py @@ -51,8 +51,8 @@ class PythonProcess(testprocess.Process): """A testprocess which runs the given Python code.""" - def __init__(self): - super().__init__() + def __init__(self, request): + super().__init__(request) self.proc.setReadChannel(QProcess.StandardOutput) self.code = None @@ -103,22 +103,22 @@ class NoReadyPythonProcess(PythonProcess): @pytest.fixture -def pyproc(): - proc = PythonProcess() +def pyproc(request): + proc = PythonProcess(request) yield proc proc.terminate() @pytest.fixture -def quit_pyproc(): - proc = QuitPythonProcess() +def quit_pyproc(request): + proc = QuitPythonProcess(request) yield proc proc.terminate() @pytest.fixture -def noready_pyproc(): - proc = NoReadyPythonProcess() +def noready_pyproc(request): + proc = NoReadyPythonProcess(request) yield proc proc.terminate() @@ -149,9 +149,9 @@ def test_process_never_started(qtbot, quit_pyproc): quit_pyproc.after_test() -def test_wait_signal_raising(qtbot): +def test_wait_signal_raising(request, qtbot): """testprocess._wait_signal should raise by default.""" - proc = testprocess.Process() + proc = testprocess.Process(request) with pytest.raises(qtbot.TimeoutError): with proc._wait_signal(proc.proc.started, timeout=0): pass diff --git a/tests/end2end/fixtures/testprocess.py b/tests/end2end/fixtures/testprocess.py index a4b136193..3fb259e47 100644 --- a/tests/end2end/fixtures/testprocess.py +++ b/tests/end2end/fixtures/testprocess.py @@ -73,12 +73,11 @@ class Line: waited_for = attr.ib(False) -def _render_log(data, threshold=100): +def _render_log(data, *, verbose, threshold=100): """Shorten the given log without -v and convert to a string.""" data = [str(d) for d in data] is_exception = any('Traceback (most recent call last):' in line or 'Uncaught exception' in line for line in data) - verbose = pytest.config.getoption('--verbose') if len(data) > threshold and not verbose and not is_exception: msg = '[{} lines suppressed, use -v to show]'.format( len(data) - threshold) @@ -105,15 +104,17 @@ def pytest_runtest_makereport(item, call): # is actually a tuple. This is handled similarily in pytest-qt too. return - if pytest.config.getoption('--capture') == 'no': + if item.config.getoption('--capture') == 'no': # Already printed live return + verbose = item.config.getoption('--verbose') if quteproc_log is not None: report.longrepr.addsection("qutebrowser output", - _render_log(quteproc_log)) + _render_log(quteproc_log, verbose=verbose)) if server_log is not None: - report.longrepr.addsection("server output", _render_log(server_log)) + report.longrepr.addsection("server output", + _render_log(server_log, verbose=verbose)) class Process(QObject): @@ -128,6 +129,7 @@ class Process(QObject): _started: Whether the process was ever started. proc: The QProcess for the underlying process. exit_expected: Whether the process is expected to quit. + request: The request object for the current test. Signals: ready: Emitted when the server finished starting up. @@ -138,8 +140,9 @@ class Process(QObject): new_data = pyqtSignal(object) KEYS = ['data'] - def __init__(self, parent=None): + def __init__(self, request, parent=None): super().__init__(parent) + self.request = request self.captured_log = [] self._started = False self._invalid = [] @@ -150,7 +153,7 @@ class Process(QObject): def _log(self, line): """Add the given line to the captured log output.""" - if pytest.config.getoption('--capture') == 'no': + if self.request.config.getoption('--capture') == 'no': print(line) self.captured_log.append(line) @@ -225,6 +228,8 @@ class Process(QObject): """Start the process and wait until it started.""" self._start(args, env=env) self._started = True + verbose = self.request.config.getoption('--verbose') + timeout = 60 if 'CI' in os.environ else 20 for _ in range(timeout): with self._wait_signal(self.ready, timeout=1000, @@ -236,14 +241,15 @@ class Process(QObject): return # _start ensures it actually started, but it might quit shortly # afterwards - raise ProcessExited('\n' + _render_log(self.captured_log)) + raise ProcessExited('\n' + _render_log(self.captured_log, + verbose=verbose)) if blocker.signal_triggered: self._after_start() return raise WaitForTimeout("Timed out while waiting for process start.\n" + - _render_log(self.captured_log)) + _render_log(self.captured_log, verbose=verbose)) def _start(self, args, env): """Actually start the process.""" @@ -300,8 +306,16 @@ class Process(QObject): def terminate(self): """Clean up and shut down the process.""" - self.proc.terminate() - self.proc.waitForFinished() + if not self.is_running(): + return + + if quteutils.is_windows: + self.proc.kill() + else: + self.proc.terminate() + + ok = self.proc.waitForFinished() + assert ok def is_running(self): """Check if the process is currently running.""" @@ -327,13 +341,13 @@ class Process(QObject): if expected is None: return True elif isinstance(expected, regex_type): - return expected.match(value) + return expected.search(value) elif isinstance(value, (bytes, str)): return utils.pattern_match(pattern=expected, value=value) else: return value == expected - def _wait_for_existing(self, override_waited_for, **kwargs): + def _wait_for_existing(self, override_waited_for, after, **kwargs): """Check if there are any line in the history for wait_for. Return: either the found line or None. @@ -345,7 +359,15 @@ class Process(QObject): value = getattr(line, key) matches.append(self._match_data(value, expected)) - if all(matches) and (not line.waited_for or override_waited_for): + if after is None: + too_early = False + else: + too_early = ((line.timestamp, line.msecs) < + (after.timestamp, after.msecs)) + + if (all(matches) and + (not line.waited_for or override_waited_for) and + not too_early): # If we waited for this line, chances are we don't mean the # same thing the next time we use wait_for and it matches # this line again. @@ -363,7 +385,7 @@ class Process(QObject): __tracebackhide__ = lambda e: e.errisinstance(WaitForTimeout) message = kwargs.get('message', None) if message is not None: - elided = quteutils.elide(repr(message), 50) + elided = quteutils.elide(repr(message), 100) self._log("\n----> Waiting for {} in the log".format(elided)) spy = QSignalSpy(self.new_data) @@ -388,6 +410,8 @@ class Process(QObject): self._log("----> found it") return match + raise quteutils.Unreachable + def _wait_for_match(self, spy, kwargs): """Try matching the kwargs with the given QSignalSpy.""" for args in spy: @@ -422,7 +446,7 @@ class Process(QObject): pass def wait_for(self, timeout=None, *, override_waited_for=False, - do_skip=False, divisor=1, **kwargs): + do_skip=False, divisor=1, after=None, **kwargs): """Wait until a given value is found in the data. Keyword arguments to this function get interpreted as attributes of the @@ -435,6 +459,7 @@ class Process(QObject): again. do_skip: If set, call pytest.skip on a timeout. divisor: A factor to decrease the timeout by. + after: If it's an existing line, ensure it's after the given one. Return: The matched line. @@ -456,7 +481,8 @@ class Process(QObject): for key in kwargs: assert key in self.KEYS - existing = self._wait_for_existing(override_waited_for, **kwargs) + existing = self._wait_for_existing(override_waited_for, after, + **kwargs) if existing is not None: return existing else: diff --git a/tests/end2end/fixtures/webserver.py b/tests/end2end/fixtures/webserver.py index 85a6af070..93ef04f03 100644 --- a/tests/end2end/fixtures/webserver.py +++ b/tests/end2end/fixtures/webserver.py @@ -137,8 +137,8 @@ class WebserverProcess(testprocess.Process): KEYS = ['verb', 'path'] - def __init__(self, script, parent=None): - super().__init__(parent) + def __init__(self, request, script, parent=None): + super().__init__(request, parent) self._script = script self.port = utils.random_port() self.new_data.connect(self.new_request) @@ -172,19 +172,14 @@ class WebserverProcess(testprocess.Process): def _default_args(self): return [str(self.port)] - def cleanup(self): - """Clean up and shut down the process.""" - self.proc.terminate() - self.proc.waitForFinished() - @pytest.fixture(scope='session', autouse=True) -def server(qapp): +def server(qapp, request): """Fixture for an server object which ensures clean setup/teardown.""" - server = WebserverProcess('webserver_sub') + server = WebserverProcess(request, 'webserver_sub') server.start() yield server - server.cleanup() + server.terminate() @pytest.fixture(autouse=True) @@ -203,9 +198,9 @@ def ssl_server(request, qapp): This needs to be explicitly used in a test, and overwrites the server log used in that test. """ - server = WebserverProcess('webserver_sub_ssl') + server = WebserverProcess(request, 'webserver_sub_ssl') request.node._server_log = server.captured_log server.start() yield server server.after_test() - server.cleanup() + server.terminate() diff --git a/tests/end2end/fixtures/webserver_sub.py b/tests/end2end/fixtures/webserver_sub.py index 4ec929203..6e0b740ba 100644 --- a/tests/end2end/fixtures/webserver_sub.py +++ b/tests/end2end/fixtures/webserver_sub.py @@ -62,7 +62,7 @@ def send_data(path): data_dir = os.path.join(basedir, 'data') print(basedir) if os.path.isdir(os.path.join(data_dir, path)): - path = path + '/index.html' + path += '/index.html' return flask.send_from_directory(data_dir, path) @@ -215,7 +215,7 @@ def drip(): def generate_bytes(): for _ in range(numbytes): - yield u"*".encode('utf-8') + yield "*".encode('utf-8') time.sleep(pause) response = flask.Response(generate_bytes(), headers={ diff --git a/tests/end2end/test_dirbrowser.py b/tests/end2end/test_dirbrowser.py index 16da8d0bc..ea92e2ca2 100644 --- a/tests/end2end/test_dirbrowser.py +++ b/tests/end2end/test_dirbrowser.py @@ -195,7 +195,6 @@ def test_enter_folder_smoke(dir_layout, quteproc): @pytest.mark.parametrize('folder', DirLayout.layout_folders()) def test_enter_folder(dir_layout, quteproc, folder): - # pylint: disable=not-an-iterable quteproc.open_url(dir_layout.file_url()) quteproc.click_element_by_text(text=folder) expected_url = urlutils.file_url(dir_layout.path(folder)) @@ -208,4 +207,3 @@ def test_enter_folder(dir_layout, quteproc, folder): assert foldernames == folders filenames = [item.text for item in page.files] assert filenames == files - # pylint: enable=not-an-iterable diff --git a/tests/end2end/test_hints_html.py b/tests/end2end/test_hints_html.py index abc106505..6654e1388 100644 --- a/tests/end2end/test_hints_html.py +++ b/tests/end2end/test_hints_html.py @@ -71,8 +71,8 @@ def _parse_file(test_name): allowed_keys = {'target', 'qtwebengine_todo'} if not set(data.keys()).issubset(allowed_keys): raise InvalidFile(test_name, "expected keys {} but found {}".format( - ', '.join(allowed_keys), - ', '.join(set(data.keys())))) + ', '.join(allowed_keys), + ', '.join(set(data.keys())))) if 'target' not in data: raise InvalidFile(test_name, "'target' key not found") diff --git a/tests/end2end/test_invocations.py b/tests/end2end/test_invocations.py index c30768ddb..ef4808718 100644 --- a/tests/end2end/test_invocations.py +++ b/tests/end2end/test_invocations.py @@ -27,7 +27,7 @@ import re import pytest -from PyQt5.QtCore import QProcess +from PyQt5.QtCore import QProcess, qVersion def _base_args(config): @@ -37,6 +37,9 @@ def _base_args(config): args += ['--backend', 'webengine'] else: args += ['--backend', 'webkit'] + if qVersion() == '5.7.1': + # https://github.com/qutebrowser/qutebrowser/issues/3163 + args += ['--qt-flag', 'disable-seccomp-filter-sandbox'] args.append('about:blank') return args @@ -356,14 +359,14 @@ def test_qute_settings_persistence(short_tmpdir, request, quteproc_new): args = _base_args(request.config) + ['--basedir', str(short_tmpdir)] quteproc_new.start(args) quteproc_new.open_path( - 'qute://settings/set?option=ignore_case&value=always') - assert quteproc_new.get_setting('ignore_case') == 'always' + 'qute://settings/set?option=search.ignore_case&value=always') + assert quteproc_new.get_setting('search.ignore_case') == 'always' quteproc_new.send_cmd(':quit') quteproc_new.wait_for_quit() quteproc_new.start(args) - assert quteproc_new.get_setting('ignore_case') == 'always' + assert quteproc_new.get_setting('search.ignore_case') == 'always' @pytest.mark.no_xvfb diff --git a/tests/end2end/test_mhtml_e2e.py b/tests/end2end/test_mhtml_e2e.py index 4317271f5..012c2ea2a 100644 --- a/tests/end2end/test_mhtml_e2e.py +++ b/tests/end2end/test_mhtml_e2e.py @@ -20,8 +20,8 @@ """Test mhtml downloads based on sample files.""" import os -import re import os.path +import re import collections import pytest diff --git a/tests/helpers/fixtures.py b/tests/helpers/fixtures.py index a01c72788..715e0168d 100644 --- a/tests/helpers/fixtures.py +++ b/tests/helpers/fixtures.py @@ -35,19 +35,19 @@ import types import attr import pytest import py.path # pylint: disable=no-name-in-module +from PyQt5.QtCore import QEvent, QSize, Qt +from PyQt5.QtGui import QKeyEvent +from PyQt5.QtWidgets import QWidget, QHBoxLayout, QVBoxLayout +from PyQt5.QtNetwork import QNetworkCookieJar import helpers.stubs as stubsmod +import helpers.utils from qutebrowser.config import config, configdata, configtypes, configexc from qutebrowser.utils import objreg, standarddir from qutebrowser.browser.webkit import cookies from qutebrowser.misc import savemanager, sql from qutebrowser.keyinput import modeman -from PyQt5.QtCore import pyqtSignal, QEvent, QSize, Qt, QObject -from PyQt5.QtGui import QKeyEvent -from PyQt5.QtWidgets import QWidget, QHBoxLayout, QVBoxLayout -from PyQt5.QtNetwork import QNetworkCookieJar - class WinRegistryHelper: @@ -78,34 +78,9 @@ class WinRegistryHelper: del objreg.window_registry[win_id] -class CallbackChecker(QObject): - - """Check if a value provided by a callback is the expected one.""" - - got_result = pyqtSignal(object) - UNSET = object() - - def __init__(self, qtbot, parent=None): - super().__init__(parent) - self._qtbot = qtbot - self._result = self.UNSET - - def callback(self, result): - """Callback which can be passed to runJavaScript.""" - self._result = result - self.got_result.emit(result) - - def check(self, expected): - """Wait until the JS result arrived and compare it.""" - if self._result is self.UNSET: - with self._qtbot.waitSignal(self.got_result, timeout=2000): - pass - assert self._result == expected - - @pytest.fixture def callback_checker(qtbot): - return CallbackChecker(qtbot) + return helpers.utils.CallbackChecker(qtbot) class FakeStatusBar(QWidget): @@ -339,10 +314,12 @@ def qnam(qapp): @pytest.fixture -def webengineview(): +def webengineview(qtbot): """Get a QWebEngineView if QtWebEngine is available.""" QtWebEngineWidgets = pytest.importorskip('PyQt5.QtWebEngineWidgets') - return QtWebEngineWidgets.QWebEngineView() + view = QtWebEngineWidgets.QWebEngineView() + qtbot.add_widget(view) + return view @pytest.fixture @@ -480,6 +457,18 @@ def runtime_tmpdir(monkeypatch, tmpdir): return runtimedir +@pytest.fixture +def cache_tmpdir(monkeypatch, tmpdir): + """Set tmpdir/cache as the cachedir. + + Use this to avoid creating a 'real' cache dir (~/.cache/qute_test). + """ + cachedir = tmpdir / 'cache' + cachedir.ensure(dir=True) + monkeypatch.setattr(standarddir, 'cache', lambda: str(cachedir)) + return cachedir + + @pytest.fixture def redirect_webengine_data(data_tmpdir, monkeypatch): """Set XDG_DATA_HOME and HOME to a temp location. diff --git a/tests/helpers/logfail.py b/tests/helpers/logfail.py index 3d8e3afb8..ba7ed24b8 100644 --- a/tests/helpers/logfail.py +++ b/tests/helpers/logfail.py @@ -22,16 +22,7 @@ import logging import pytest - -try: - import pytest_catchlog as catchlog_mod -except ImportError: - # When using pytest for pyflakes/pep8/..., the plugin won't be available - # but conftest.py will still be loaded. - # - # However, LogFailHandler.emit will never be used in that case, so we just - # ignore the ImportError. - pass +import _pytest.logging class LogFailHandler(logging.Handler): @@ -50,8 +41,8 @@ class LogFailHandler(logging.Handler): return for h in root_logger.handlers: - if isinstance(h, catchlog_mod.LogCaptureHandler): - catchlog_handler = h + if isinstance(h, _pytest.logging.LogCaptureHandler): + capture_handler = h break else: # The LogCaptureHandler is not available anymore during fixture @@ -59,7 +50,7 @@ class LogFailHandler(logging.Handler): return if (logger.level == record.levelno or - catchlog_handler.level == record.levelno): + capture_handler.level == record.levelno): # caplog.at_level(...) was used with the level of this message, # i.e. it was expected. return diff --git a/tests/helpers/stubs.py b/tests/helpers/stubs.py index 878c9e166..3f6a23958 100644 --- a/tests/helpers/stubs.py +++ b/tests/helpers/stubs.py @@ -24,7 +24,7 @@ from unittest import mock import attr -from PyQt5.QtCore import pyqtSignal, QPoint, QProcess, QObject +from PyQt5.QtCore import pyqtSignal, QPoint, QProcess, QObject, QUrl from PyQt5.QtNetwork import (QNetworkRequest, QAbstractNetworkCache, QNetworkCacheMetaData) from PyQt5.QtWidgets import QCommonStyle, QLineEdit, QWidget, QTabBar @@ -127,17 +127,6 @@ class FakeQApplication: self.activeWindow = lambda: active_window -class FakeUrl: - - """QUrl stub which provides .path(), isValid() and host().""" - - def __init__(self, path=None, valid=True, host=None, url=None): - self.path = mock.Mock(return_value=path) - self.isValid = mock.Mock(returl_value=valid) - self.host = mock.Mock(returl_value=host) - self.url = mock.Mock(return_value=url) - - class FakeNetworkReply: """QNetworkReply stub which provides a Content-Disposition header.""" @@ -148,7 +137,7 @@ class FakeNetworkReply: def __init__(self, headers=None, url=None): if url is None: - url = FakeUrl() + url = QUrl() if headers is None: self.headers = {} else: @@ -244,7 +233,7 @@ class FakeWebTab(browsertab.AbstractTab): """Fake AbstractTab to use in tests.""" - def __init__(self, url=FakeUrl(), title='', tab_id=0, *, + def __init__(self, url=QUrl(), title='', tab_id=0, *, scroll_pos_perc=(0, 0), load_status=usertypes.LoadStatus.success, progress=0, can_go_back=None, can_go_forward=None): diff --git a/tests/helpers/test_logfail.py b/tests/helpers/test_logfail.py index b95dec1d6..48aaaa201 100644 --- a/tests/helpers/test_logfail.py +++ b/tests/helpers/test_logfail.py @@ -23,7 +23,6 @@ import logging import pytest -import pytest_catchlog def test_log_debug(): @@ -64,24 +63,3 @@ def test_log_expected_wrong_logger(caplog): with pytest.raises(pytest.fail.Exception): with caplog.at_level(logging.ERROR, logger): logging.error('foo') - - -@pytest.fixture -def skipping_fixture(): - pytest.skip("Skipping to test caplog workaround.") - - -def test_caplog_bug_workaround_1(caplog, skipping_fixture): - pass - - -def test_caplog_bug_workaround_2(): - """Make sure caplog_bug_workaround works correctly after a skipped test. - - There should be only one capturelog handler. - """ - caplog_handler = None - for h in logging.getLogger().handlers: - if isinstance(h, pytest_catchlog.LogCaptureHandler): - assert caplog_handler is None - caplog_handler = h diff --git a/tests/helpers/utils.py b/tests/helpers/utils.py index e6e3d37c8..82c07fbd2 100644 --- a/tests/helpers/utils.py +++ b/tests/helpers/utils.py @@ -27,6 +27,8 @@ import contextlib import pytest +from PyQt5.QtCore import QObject, pyqtSignal + from qutebrowser.utils import qtutils @@ -176,3 +178,28 @@ def abs_datapath(): @contextlib.contextmanager def nop_contextmanager(): yield + + +class CallbackChecker(QObject): + + """Check if a value provided by a callback is the expected one.""" + + got_result = pyqtSignal(object) + UNSET = object() + + def __init__(self, qtbot, parent=None): + super().__init__(parent) + self._qtbot = qtbot + self._result = self.UNSET + + def callback(self, result): + """Callback which can be passed to runJavaScript.""" + self._result = result + self.got_result.emit(result) + + def check(self, expected): + """Wait until the JS result arrived and compare it.""" + if self._result is self.UNSET: + with self._qtbot.waitSignal(self.got_result, timeout=2000): + pass + assert self._result == expected diff --git a/tests/unit/browser/test_hints.py b/tests/unit/browser/test_hints.py new file mode 100644 index 000000000..6792d1754 --- /dev/null +++ b/tests/unit/browser/test_hints.py @@ -0,0 +1,74 @@ +# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et: + +# Copyright 2017 Florian Bruhin (The Compiler) +# +# This file is part of qutebrowser. +# +# qutebrowser is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# qutebrowser is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with qutebrowser. If not, see . + +import string +import functools +import operator + +import pytest + +import qutebrowser.browser.hints + + +@pytest.mark.parametrize('min_len', [0, 3]) +@pytest.mark.parametrize('num_chars', [9]) +@pytest.mark.parametrize('num_elements', range(1, 26)) +def test_scattered_hints_count(win_registry, mode_manager, min_len, + num_chars, num_elements): + """Test scattered hints function. + + Tests many properties from an invocation of _hint_scattered, including + + 1. Hints must be unique + 2. There can only be two hint lengths, only 1 apart + 3. There are no unique prefixes for long hints, such as 'la' with no 'l' + """ + manager = qutebrowser.browser.hints.HintManager(0, 0) + chars = string.ascii_lowercase[:num_chars] + + hints = manager._hint_scattered(min_len, chars, + list(range(num_elements))) + + # Check if hints are unique + assert len(hints) == len(set(hints)) + + # Check if any hints are shorter than min_len + assert not any(x for x in hints if len(x) < min_len) + + # Check we don't have more than 2 link lengths + # Eg: 'a' 'bc' and 'def' cannot be in the same hint string + hint_lens = {len(h) for h in hints} + assert len(hint_lens) <= 2 + + if len(hint_lens) == 2: + # Check if hint_lens are more than 1 apart + # Eg: 'abc' and 'd' cannot be in the same hint sequence, but + # 'ab' and 'c' can + assert abs(functools.reduce(operator.sub, hint_lens)) <= 1 + + longest_hints = [x for x in hints if len(x) == max(hint_lens)] + + if min_len < max(hint_lens) - 1: + # Check if we have any unique prefixes. For example, 'la' + # alone, with no other 'l' + count_map = {} + for x in longest_hints: + prefix = x[:-1] + count_map[prefix] = count_map.get(prefix, 0) + 1 + assert all(e != 1 for e in count_map.values()) diff --git a/tests/unit/browser/test_history.py b/tests/unit/browser/test_history.py index 1b706331a..c5734d34a 100644 --- a/tests/unit/browser/test_history.py +++ b/tests/unit/browser/test_history.py @@ -153,13 +153,13 @@ def test_delete_url(hist, raw, escaped): 'url, atime, title, redirect, history_url, completion_url', [ ('http://www.example.com', 12346, 'the title', False, - 'http://www.example.com', 'http://www.example.com'), + 'http://www.example.com', 'http://www.example.com'), ('http://www.example.com', 12346, 'the title', True, - 'http://www.example.com', None), + 'http://www.example.com', None), ('http://www.example.com/sp ce', 12346, 'the title', False, - 'http://www.example.com/sp%20ce', 'http://www.example.com/sp ce'), + 'http://www.example.com/sp%20ce', 'http://www.example.com/sp ce'), ('https://user:pass@example.com', 12346, 'the title', False, - 'https://user@example.com', 'https://user@example.com'), + 'https://user@example.com', 'https://user@example.com'), ] ) def test_add_url(qtbot, hist, url, atime, title, redirect, history_url, diff --git a/tests/unit/browser/test_qutescheme.py b/tests/unit/browser/test_qutescheme.py index 6fdaad83c..e7c6cc9eb 100644 --- a/tests/unit/browser/test_qutescheme.py +++ b/tests/unit/browser/test_qutescheme.py @@ -118,7 +118,7 @@ class TestHistoryHandler: (72*60*60, 0) ]) def test_qutehistory_data(self, start_time_offset, expected_item_count, - now): + now): """Ensure qute://history/data returns correct items.""" start_time = now - start_time_offset url = QUrl("qute://history/data?start_time=" + str(start_time)) diff --git a/tests/unit/browser/test_tab.py b/tests/unit/browser/test_tab.py index a2a34d9ce..d67ee4c8f 100644 --- a/tests/unit/browser/test_tab.py +++ b/tests/unit/browser/test_tab.py @@ -20,6 +20,7 @@ import pytest from qutebrowser.browser import browsertab +from qutebrowser.utils import utils pytestmark = pytest.mark.usefixtures('redirect_webengine_data') @@ -54,7 +55,7 @@ def tab(request, qtbot, tab_registry, cookiejar_and_cache, mode_manager): 'qutebrowser.browser.webengine.webenginetab') tab_class = webenginetab.WebEngineTab else: - assert False + raise utils.Unreachable t = tab_class(win_id=0, mode_manager=mode_manager) qtbot.add_widget(t) @@ -67,7 +68,7 @@ class Zoom(browsertab.AbstractZoom): pass def factor(self): - assert False + raise utils.Unreachable class Tab(browsertab.AbstractTab): diff --git a/tests/unit/browser/webengine/test_webenginesettings.py b/tests/unit/browser/webengine/test_webenginesettings.py new file mode 100644 index 000000000..6c58e29af --- /dev/null +++ b/tests/unit/browser/webengine/test_webenginesettings.py @@ -0,0 +1,38 @@ +# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et: + +# Copyright 2017 Florian Bruhin (The Compiler) +# +# This file is part of qutebrowser. +# +# qutebrowser is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# qutebrowser is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with qutebrowser. If not, see . + +import pytest + +pytest.importorskip('PyQt5.QtWebEngineWidgets') + +from qutebrowser.browser.webengine import webenginesettings + + +@pytest.fixture(autouse=True) +def init_profiles(qapp, config_stub, cache_tmpdir, data_tmpdir): + webenginesettings._init_profiles() + + +def test_big_cache_size(config_stub): + """Make sure a too big cache size is handled correctly.""" + config_stub.val.content.cache.size = 2 ** 63 - 1 + webenginesettings._update_settings('content.cache.size') + + size = webenginesettings.default_profile.httpCacheMaximumSize() + assert size == 2 ** 31 - 1 diff --git a/tests/unit/browser/webkit/http/test_content_disposition.py b/tests/unit/browser/webkit/http/test_content_disposition.py index e1f78eb74..5aa25166e 100644 --- a/tests/unit/browser/webkit/http/test_content_disposition.py +++ b/tests/unit/browser/webkit/http/test_content_disposition.py @@ -581,7 +581,7 @@ class TestAttachment: header_checker.check_ignored('attachment; filename=bar foo=foo') def test_attmissingdelim3(self, header_checker): - """";" missing between disposition type and filename parameter. + """';' missing between disposition type and filename parameter. This is invalid, so UAs should ignore it. """ diff --git a/tests/unit/browser/webkit/test_webkitelem.py b/tests/unit/browser/webkit/test_webkitelem.py index c64756eb5..35b9c354b 100644 --- a/tests/unit/browser/webkit/test_webkitelem.py +++ b/tests/unit/browser/webkit/test_webkitelem.py @@ -29,7 +29,7 @@ import pytest from PyQt5.QtCore import QRect, QPoint, QUrl QWebElement = pytest.importorskip('PyQt5.QtWebKit').QWebElement -from qutebrowser.browser import webelem +from qutebrowser.browser import webelem, browsertab from qutebrowser.browser.webkit import webkitelem from qutebrowser.misc import objects from qutebrowser.utils import usertypes @@ -127,7 +127,9 @@ def get_webelem(geometry=None, frame=None, *, null=False, style=None, return style_dict[name] elem.styleProperty.side_effect = _style_property - wrapped = webkitelem.WebKitElement(elem, tab=None) + tab = mock.Mock(autospec=browsertab.AbstractTab) + tab.is_deleted.return_value = False + wrapped = webkitelem.WebKitElement(elem, tab=tab) return wrapped diff --git a/tests/unit/commands/test_cmdutils.py b/tests/unit/commands/test_cmdutils.py index e123ce2d2..ca751074d 100644 --- a/tests/unit/commands/test_cmdutils.py +++ b/tests/unit/commands/test_cmdutils.py @@ -103,7 +103,7 @@ class TestRegister: def test_lowercasing(self): """Make sure the function name is normalized correctly (uppercase).""" @cmdutils.register() - def Test(): # pylint: disable=invalid-name + def Test(): # noqa: N801,N806 pylint: disable=invalid-name """Blah.""" pass diff --git a/tests/unit/commands/test_userscripts.py b/tests/unit/commands/test_userscripts.py index 32f9a1bb9..3b6a69710 100644 --- a/tests/unit/commands/test_userscripts.py +++ b/tests/unit/commands/test_userscripts.py @@ -64,6 +64,7 @@ def runner(request, runtime_tmpdir): if (not utils.is_posix and request.param is userscripts._POSIXUserscriptRunner): pytest.skip("Requires a POSIX os") + raise utils.Unreachable else: return request.param() diff --git a/tests/unit/completion/test_completer.py b/tests/unit/completion/test_completer.py index a32241621..e25d4e5d1 100644 --- a/tests/unit/completion/test_completer.py +++ b/tests/unit/completion/test_completer.py @@ -65,7 +65,8 @@ def completer_obj(qtbot, status_command_stub, config_stub, monkeypatch, stubs, """Create the completer used for testing.""" monkeypatch.setattr(completer, 'QTimer', stubs.InstaTimer) config_stub.val.completion.show = 'auto' - return completer.Completer(status_command_stub, completion_widget_stub) + return completer.Completer(cmd=status_command_stub, win_id=0, + parent=completion_widget_stub) @pytest.fixture(autouse=True) @@ -159,7 +160,7 @@ def _set_cmd_prompt(cmd, txt): (':set general editor |', 'value', '', ['general', 'editor']), (':set general editor gv|', 'value', 'gv', ['general', 'editor']), (':set general editor "gvim -f"|', 'value', 'gvim -f', - ['general', 'editor']), + ['general', 'editor']), (':set general editor "gvim |', 'value', 'gvim', ['general', 'editor']), (':set general huh |', 'value', '', ['general', 'huh']), (':help |', 'helptopic', '', []), diff --git a/tests/unit/completion/test_completionmodel.py b/tests/unit/completion/test_completionmodel.py index 292349730..b0775428f 100644 --- a/tests/unit/completion/test_completionmodel.py +++ b/tests/unit/completion/test_completionmodel.py @@ -31,7 +31,8 @@ from qutebrowser.utils import qtutils from qutebrowser.commands import cmdexc -@hypothesis.given(strategies.lists(min_size=0, max_size=3, +@hypothesis.given(strategies.lists( + min_size=0, max_size=3, elements=strategies.integers(min_value=0, max_value=2**31))) def test_first_last_item(counts): """Test that first() and last() index to the first and last items.""" @@ -68,17 +69,17 @@ def test_count(counts): assert model.count() == sum(counts) -@hypothesis.given(strategies.text()) -def test_set_pattern(pat): +@hypothesis.given(pat=strategies.text()) +def test_set_pattern(pat, qtbot): """Validate the filtering and sorting results of set_pattern.""" model = completionmodel.CompletionModel() - cats = [mock.Mock(spec=['set_pattern', 'layoutChanged', - 'layoutAboutToBeChanged']) - for _ in range(3)] + cats = [mock.Mock(spec=['set_pattern']) for _ in range(3)] for c in cats: c.set_pattern = mock.Mock(spec=[]) model.add_category(c) - model.set_pattern(pat) + with qtbot.waitSignals([model.layoutAboutToBeChanged, model.layoutChanged], + order='strict'): + model.set_pattern(pat) for c in cats: c.set_pattern.assert_called_with(pat) diff --git a/tests/unit/completion/test_completionwidget.py b/tests/unit/completion/test_completionwidget.py index b03205da7..ba6e40e4a 100644 --- a/tests/unit/completion/test_completionwidget.py +++ b/tests/unit/completion/test_completionwidget.py @@ -45,7 +45,7 @@ def completionview(qtbot, status_command_stub, config_stub, win_registry, def test_set_model(completionview): """Ensure set_model actually sets the model and expands all categories.""" model = completionmodel.CompletionModel() - for i in range(3): + for _i in range(3): model.add_category(listcategory.ListCategory('', [('foo',)])) completionview.set_model(model) assert completionview.model() is model @@ -82,9 +82,9 @@ def test_maybe_update_geometry(completionview, config_stub, qtbot): ('next', [['Aa'], ['Ba']], ['Aa', 'Ba', 'Aa']), ('prev', [['Aa'], ['Ba']], ['Ba', 'Aa', 'Ba']), ('next', [['Aa', 'Ab', 'Ac'], ['Ba', 'Bb'], ['Ca']], - ['Aa', 'Ab', 'Ac', 'Ba', 'Bb', 'Ca', 'Aa']), + ['Aa', 'Ab', 'Ac', 'Ba', 'Bb', 'Ca', 'Aa']), ('prev', [['Aa', 'Ab', 'Ac'], ['Ba', 'Bb'], ['Ca']], - ['Ca', 'Bb', 'Ba', 'Ac', 'Ab', 'Aa', 'Ca']), + ['Ca', 'Bb', 'Ba', 'Ac', 'Ab', 'Aa', 'Ca']), ('next', [[], ['Ba', 'Bb']], ['Ba', 'Bb', 'Ba']), ('prev', [[], ['Ba', 'Bb']], ['Bb', 'Ba', 'Bb']), ('next', [[], [], ['Ca', 'Cb']], ['Ca', 'Cb', 'Ca']), @@ -102,9 +102,9 @@ def test_maybe_update_geometry(completionview, config_stub, qtbot): ('next-category', [['Aa'], ['Ba']], ['Aa', 'Ba', 'Aa']), ('prev-category', [['Aa'], ['Ba']], ['Ba', 'Aa', 'Ba']), ('next-category', [['Aa', 'Ab', 'Ac'], ['Ba', 'Bb'], ['Ca']], - ['Aa', 'Ba', 'Ca', 'Aa']), + ['Aa', 'Ba', 'Ca', 'Aa']), ('prev-category', [['Aa', 'Ab', 'Ac'], ['Ba', 'Bb'], ['Ca']], - ['Ca', 'Ba', 'Aa', 'Ca']), + ['Ca', 'Ba', 'Aa', 'Ca']), ('next-category', [[], ['Ba', 'Bb']], ['Ba', None, None]), ('prev-category', [[], ['Ba', 'Bb']], ['Ba', None, None]), ('next-category', [[], [], ['Ca', 'Cb']], ['Ca', None, None]), @@ -170,8 +170,9 @@ def test_completion_item_focus_fetch(completionview, qtbot): emitted. """ model = completionmodel.CompletionModel() - cat = mock.Mock(spec=['layoutChanged', 'layoutAboutToBeChanged', - 'canFetchMore', 'fetchMore', 'rowCount', 'index', 'data']) + cat = mock.Mock(spec=[ + 'layoutChanged', 'layoutAboutToBeChanged', 'canFetchMore', + 'fetchMore', 'rowCount', 'index', 'data']) cat.canFetchMore = lambda *_: True cat.rowCount = lambda *_: 2 cat.fetchMore = mock.Mock() diff --git a/tests/unit/completion/test_histcategory.py b/tests/unit/completion/test_histcategory.py index 834b3a5a3..e42f9b91f 100644 --- a/tests/unit/completion/test_histcategory.py +++ b/tests/unit/completion/test_histcategory.py @@ -61,7 +61,7 @@ def hist(init_sql, config_stub): ('foo bar', [('foo', ''), ('bar foo', ''), ('xfooyybarz', '')], - [('xfooyybarz', '')]), + [('bar foo', ''), ('xfooyybarz', '')]), ('foo%bar', [('foo%bar', ''), ('foo bar', ''), ('foobar', '')], @@ -78,6 +78,10 @@ def hist(init_sql, config_stub): ("can't", [("can't touch this", ''), ('a', '')], [("can't touch this", '')]), + + ("ample itle", + [('example.com', 'title'), ('example.com', 'nope')], + [('example.com', 'title')]), ]) def test_set_pattern(pattern, before, after, model_validator, hist): """Validate the filtering and sorting results of set_pattern.""" @@ -89,6 +93,38 @@ def test_set_pattern(pattern, before, after, model_validator, hist): model_validator.validate(after) +def test_set_pattern_repeated(model_validator, hist): + """Validate multiple subsequent calls to set_pattern.""" + hist.insert({'url': 'example.com/foo', 'title': 'title1', 'last_atime': 1}) + hist.insert({'url': 'example.com/bar', 'title': 'title2', 'last_atime': 1}) + hist.insert({'url': 'example.com/baz', 'title': 'title3', 'last_atime': 1}) + cat = histcategory.HistoryCategory() + model_validator.set_model(cat) + + cat.set_pattern('b') + model_validator.validate([ + ('example.com/bar', 'title2'), + ('example.com/baz', 'title3'), + ]) + + cat.set_pattern('ba') + model_validator.validate([ + ('example.com/bar', 'title2'), + ('example.com/baz', 'title3'), + ]) + + cat.set_pattern('ba ') + model_validator.validate([ + ('example.com/bar', 'title2'), + ('example.com/baz', 'title3'), + ]) + + cat.set_pattern('ba z') + model_validator.validate([ + ('example.com/baz', 'title3'), + ]) + + @pytest.mark.parametrize('max_items, before, after', [ (-1, [ ('a', 'a', '2017-04-16'), diff --git a/tests/unit/completion/test_models.py b/tests/unit/completion/test_models.py index 5276ffd2a..1a0fe57b0 100644 --- a/tests/unit/completion/test_models.py +++ b/tests/unit/completion/test_models.py @@ -73,6 +73,9 @@ def cmdutils_stub(monkeypatch, stubs): name='scroll', desc='Scroll the current tab in the given direction.', modes=()), + 'tab-close': stubs.FakeCommand( + name='tab-close', + desc='Close the current tab.'), }) @@ -101,9 +104,10 @@ def configdata_stub(monkeypatch, configdata_init): ), ), default={ - 'normal': { - '': 'quit' - } + 'normal': collections.OrderedDict([ + ('', 'quit'), + ('d', 'tab-close'), + ]) }, backends=[], raw_backends=None)), @@ -122,6 +126,7 @@ def configdata_stub(monkeypatch, configdata_init): ('', 'quit'), ('ZQ', 'quit'), ('I', 'invalid'), + ('d', 'scroll down'), ]) }, backends=[], @@ -186,7 +191,8 @@ def web_history_populated(web_history): @pytest.fixture def info(config_stub, key_config_stub): return completer.CompletionInfo(config=config_stub, - keyconf=key_config_stub) + keyconf=key_config_stub, + win_id=0) def test_command_completion(qtmodeltester, cmdutils_stub, configdata_stub, @@ -209,6 +215,7 @@ def test_command_completion(qtmodeltester, cmdutils_stub, configdata_stub, ('open', 'open a url', ''), ('q', "Alias for 'quit'", ''), ('quit', 'quit qutebrowser', 'ZQ, '), + ('tab-close', 'Close the current tab.', ''), ] }) @@ -233,7 +240,8 @@ def test_help_completion(qtmodeltester, cmdutils_stub, key_config_stub, "Commands": [ (':open', 'open a url', ''), (':quit', 'quit qutebrowser', 'ZQ, '), - (':scroll', 'Scroll the current tab in the given direction.', '') + (':scroll', 'Scroll the current tab in the given direction.', ''), + (':tab-close', 'Close the current tab.', ''), ], "Settings": [ ('aliases', 'Aliases for commands.', None), @@ -356,18 +364,62 @@ def test_url_completion(qtmodeltester, web_history_populated, }) +def test_url_completion_no_quickmarks(qtmodeltester, web_history_populated, + quickmark_manager_stub, bookmarks, info): + """Test that the quickmark category is gone with no quickmarks.""" + model = urlmodel.url(info=info) + model.set_pattern('') + qtmodeltester.data_display_may_return_none = True + qtmodeltester.check(model) + + _check_completions(model, { + "Bookmarks": [ + ('https://github.com', 'GitHub', None), + ('https://python.org', 'Welcome to Python.org', None), + ('http://qutebrowser.org', 'qutebrowser | qutebrowser', None), + ], + "History": [ + ('https://github.com', 'https://github.com', '2016-05-01'), + ('https://python.org', 'Welcome to Python.org', '2016-03-08'), + ('http://qutebrowser.org', 'qutebrowser', '2015-09-05'), + ], + }) + + +def test_url_completion_no_bookmarks(qtmodeltester, web_history_populated, + quickmarks, bookmark_manager_stub, info): + """Test that the bookmarks category is gone with no bookmarks.""" + model = urlmodel.url(info=info) + model.set_pattern('') + qtmodeltester.data_display_may_return_none = True + qtmodeltester.check(model) + + _check_completions(model, { + "Quickmarks": [ + ('https://wiki.archlinux.org', 'aw', None), + ('https://wikipedia.org', 'wiki', None), + ('https://duckduckgo.com', 'ddg', None), + ], + "History": [ + ('https://github.com', 'https://github.com', '2016-05-01'), + ('https://python.org', 'Welcome to Python.org', '2016-03-08'), + ('http://qutebrowser.org', 'qutebrowser', '2015-09-05'), + ], + }) + + @pytest.mark.parametrize('url, title, pattern, rowcount', [ ('example.com', 'Site Title', '', 1), ('example.com', 'Site Title', 'ex', 1), ('example.com', 'Site Title', 'am', 1), ('example.com', 'Site Title', 'com', 1), ('example.com', 'Site Title', 'ex com', 1), - ('example.com', 'Site Title', 'com ex', 0), + ('example.com', 'Site Title', 'com ex', 1), ('example.com', 'Site Title', 'ex foo', 0), ('example.com', 'Site Title', 'foo com', 0), ('example.com', 'Site Title', 'exm', 0), ('example.com', 'Site Title', 'Si Ti', 1), - ('example.com', 'Site Title', 'Ti Si', 0), + ('example.com', 'Site Title', 'Ti Si', 1), ('example.com', '', 'foo', 0), ('foo_bar', '', '_', 1), ('foobar', '', '_', 0), @@ -382,7 +434,7 @@ def test_url_completion_pattern(web_history, quickmark_manager_stub, model = urlmodel.url(info=info) model.set_pattern(pattern) # 2, 0 is History - assert model.rowCount(model.index(2, 0)) == rowcount + assert model.rowCount(model.index(0, 0)) == rowcount def test_url_completion_delete_bookmark(qtmodeltester, bookmarks, @@ -530,7 +582,33 @@ def test_tab_completion_delete(qtmodeltester, fake_web_tab, app_stub, QUrl('https://duckduckgo.com')] -def test_window_completion(qtmodeltester, fake_web_tab, tabbed_browser_stubs): +def test_other_buffer_completion(qtmodeltester, fake_web_tab, app_stub, + win_registry, tabbed_browser_stubs, info): + tabbed_browser_stubs[0].tabs = [ + fake_web_tab(QUrl('https://github.com'), 'GitHub', 0), + fake_web_tab(QUrl('https://wikipedia.org'), 'Wikipedia', 1), + fake_web_tab(QUrl('https://duckduckgo.com'), 'DuckDuckGo', 2), + ] + tabbed_browser_stubs[1].tabs = [ + fake_web_tab(QUrl('https://wiki.archlinux.org'), 'ArchWiki', 0), + ] + info.win_id = 1 + model = miscmodels.other_buffer(info=info) + model.set_pattern('') + qtmodeltester.data_display_may_return_none = True + qtmodeltester.check(model) + + _check_completions(model, { + '0': [ + ('0/1', 'https://github.com', 'GitHub'), + ('0/2', 'https://wikipedia.org', 'Wikipedia'), + ('0/3', 'https://duckduckgo.com', 'DuckDuckGo') + ], + }) + + +def test_window_completion(qtmodeltester, fake_web_tab, tabbed_browser_stubs, + info): tabbed_browser_stubs[0].tabs = [ fake_web_tab(QUrl('https://github.com'), 'GitHub', 0), fake_web_tab(QUrl('https://wikipedia.org'), 'Wikipedia', 1), @@ -540,7 +618,8 @@ def test_window_completion(qtmodeltester, fake_web_tab, tabbed_browser_stubs): fake_web_tab(QUrl('https://wiki.archlinux.org'), 'ArchWiki', 0) ] - model = miscmodels.window() + info.win_id = 1 + model = miscmodels.window(info=info) model.set_pattern('') qtmodeltester.data_display_may_return_none = True qtmodeltester.check(model) @@ -548,8 +627,7 @@ def test_window_completion(qtmodeltester, fake_web_tab, tabbed_browser_stubs): _check_completions(model, { 'Windows': [ ('0', 'window title - qutebrowser', - 'GitHub, Wikipedia, DuckDuckGo'), - ('1', 'window title - qutebrowser', 'ArchWiki') + 'GitHub, Wikipedia, DuckDuckGo'), ] }) @@ -564,11 +642,11 @@ def test_setting_option_completion(qtmodeltester, config_stub, _check_completions(model, { "Options": [ ('aliases', 'Aliases for commands.', '{"q": "quit"}'), - ('bindings.commands', 'Default keybindings', + ('bindings.commands', 'Default keybindings', ( '{"normal": {"": "quit", "ZQ": "quit", ' - '"I": "invalid"}}'), + '"I": "invalid", "d": "scroll down"}}')), ('bindings.default', 'Default keybindings', - '{"normal": {"": "quit"}}'), + '{"normal": {"": "quit", "d": "tab-close"}}'), ] }) @@ -589,14 +667,15 @@ def test_bind_completion(qtmodeltester, cmdutils_stub, config_stub, qtmodeltester.check(model) _check_completions(model, { - "Current": [ - ('quit', 'quit qutebrowser', 'ZQ'), + "Current/Default": [ + ('quit', '(Current) quit qutebrowser', 'ZQ'), ], "Commands": [ ('open', 'open a url', ''), ('q', "Alias for 'quit'", ''), ('quit', 'quit qutebrowser', 'ZQ, '), - ('scroll', 'Scroll the current tab in the given direction.', '') + ('scroll', 'Scroll the current tab in the given direction.', ''), + ('tab-close', 'Close the current tab.', ''), ], }) @@ -608,21 +687,22 @@ def test_bind_completion_invalid(cmdutils_stub, config_stub, key_config_stub, model.set_pattern('') _check_completions(model, { - "Current": [ - ('invalid', 'Invalid command!', 'I'), + "Current/Default": [ + ('invalid', '(Current) Invalid command!', 'I'), ], "Commands": [ ('open', 'open a url', ''), ('q', "Alias for 'quit'", ''), ('quit', 'quit qutebrowser', 'ZQ, '), - ('scroll', 'Scroll the current tab in the given direction.', '') + ('scroll', 'Scroll the current tab in the given direction.', ''), + ('tab-close', 'Close the current tab.', ''), ], }) -def test_bind_completion_no_current(qtmodeltester, cmdutils_stub, config_stub, +def test_bind_completion_no_binding(qtmodeltester, cmdutils_stub, config_stub, key_config_stub, configdata_stub, info): - """Test keybinding completion with no current binding.""" + """Test keybinding completion with no current or default binding.""" model = configmodel.bind('x', info=info) model.set_pattern('') qtmodeltester.data_display_may_return_none = True @@ -633,7 +713,30 @@ def test_bind_completion_no_current(qtmodeltester, cmdutils_stub, config_stub, ('open', 'open a url', ''), ('q', "Alias for 'quit'", ''), ('quit', 'quit qutebrowser', 'ZQ, '), - ('scroll', 'Scroll the current tab in the given direction.', '') + ('scroll', 'Scroll the current tab in the given direction.', ''), + ('tab-close', 'Close the current tab.', ''), + ], + }) + + +def test_bind_completion_changed(cmdutils_stub, config_stub, key_config_stub, + configdata_stub, info): + """Test command completion with a non-default command bound.""" + model = configmodel.bind('d', info=info) + model.set_pattern('') + + _check_completions(model, { + "Current/Default": [ + ('scroll down', + '(Current) Scroll the current tab in the given direction.', 'd'), + ('tab-close', '(Default) Close the current tab.', 'd'), + ], + "Commands": [ + ('open', 'open a url', ''), + ('q', "Alias for 'quit'", ''), + ('quit', 'quit qutebrowser', 'ZQ, '), + ('scroll', 'Scroll the current tab in the given direction.', ''), + ('tab-close', 'Close the current tab.', ''), ], }) diff --git a/tests/unit/config/test_config.py b/tests/unit/config/test_config.py index bf1969e8a..d8bf73700 100644 --- a/tests/unit/config/test_config.py +++ b/tests/unit/config/test_config.py @@ -385,7 +385,7 @@ class TestConfig: def test_get(self, conf): """Test conf.get() with a QColor (where get/get_obj is different).""" - assert conf.get('colors.completion.fg') == QColor('white') + assert conf.get('colors.completion.category.fg') == QColor('white') @pytest.mark.parametrize('value', [{}, {'normal': {'a': 'nop'}}]) def test_get_bindings(self, config_stub, conf, value): @@ -400,7 +400,7 @@ class TestConfig: assert not conf._mutables def test_get_obj_simple(self, conf): - assert conf.get_obj('colors.completion.fg') == 'white' + assert conf.get_obj('colors.completion.category.fg') == 'white' @pytest.mark.parametrize('option', ['content.headers.custom', 'keyhint.blacklist', @@ -591,7 +591,7 @@ class StyleObj(QObject): def __init__(self, stylesheet=None, parent=None): super().__init__(parent) if stylesheet is not None: - self.STYLESHEET = stylesheet # pylint: disable=invalid-name + self.STYLESHEET = stylesheet # noqa: N801,N806 pylint: disable=invalid-name self.rendered_stylesheet = None def setStyleSheet(self, stylesheet): diff --git a/tests/unit/config/test_configcommands.py b/tests/unit/config/test_configcommands.py index 40a69668a..4c0c833a1 100644 --- a/tests/unit/config/test_configcommands.py +++ b/tests/unit/config/test_configcommands.py @@ -130,8 +130,8 @@ class TestSet: def test_set_wrong_backend(self, commands, monkeypatch): monkeypatch.setattr(objects, 'backend', usertypes.Backend.QtWebEngine) with pytest.raises(cmdexc.CommandError, - match="This setting is not available with the " - "QtWebEngine backend!"): + match="The content.cookies.accept setting is not " + "available with the QtWebEngine backend!"): commands.set(0, 'content.cookies.accept', 'all') @pytest.mark.parametrize('option', ['?', 'url.auto_search']) @@ -281,7 +281,7 @@ class TestSource: def test_config_source(self, tmpdir, commands, config_stub, config_tmpdir, use_default_dir, clear): assert config_stub.val.content.javascript.enabled - config_stub.val.ignore_case = 'always' + config_stub.val.search.ignore_case = 'always' if use_default_dir: pyfile = config_tmpdir / 'config.py' @@ -295,7 +295,8 @@ class TestSource: commands.config_source(arg, clear=clear) assert not config_stub.val.content.javascript.enabled - assert config_stub.val.ignore_case == ('smart' if clear else 'always') + ignore_case = config_stub.val.search.ignore_case + assert ignore_case == ('smart' if clear else 'always') def test_errors(self, commands, config_tmpdir): pyfile = config_tmpdir / 'config.py' @@ -413,6 +414,11 @@ class TestWritePy: lines = confpy.read_text('utf-8').splitlines() assert '# Autogenerated config.py' in lines + def test_oserror(self, commands, tmpdir): + """Test writing to a directory which does not exist.""" + with pytest.raises(cmdexc.CommandError): + commands.config_write_py(str(tmpdir / 'foo' / 'config.py')) + class TestBind: diff --git a/tests/unit/config/test_configdata.py b/tests/unit/config/test_configdata.py index 7edb1b0d6..b7e960ac6 100644 --- a/tests/unit/config/test_configdata.py +++ b/tests/unit/config/test_configdata.py @@ -34,7 +34,7 @@ def test_init(config_stub): # configdata.init() is called by config_stub config_stub.val.aliases = {} assert isinstance(configdata.DATA, dict) - assert 'ignore_case' in configdata.DATA + assert 'search.ignore_case' in configdata.DATA def test_data(config_stub): diff --git a/tests/unit/config/test_configexc.py b/tests/unit/config/test_configexc.py index 03248731b..8fd99a9c7 100644 --- a/tests/unit/config/test_configexc.py +++ b/tests/unit/config/test_configexc.py @@ -49,8 +49,9 @@ def test_no_option_error_clash(): def test_backend_error(): - e = configexc.BackendError(usertypes.Backend.QtWebKit) - assert str(e) == "This setting is not available with the QtWebKit backend!" + e = configexc.BackendError('foo', usertypes.Backend.QtWebKit) + expected = "The foo setting is not available with the QtWebKit backend!" + assert str(e) == expected def test_desc_with_text(): diff --git a/tests/unit/config/test_configfiles.py b/tests/unit/config/test_configfiles.py index 9efbc6a4e..e195f720c 100644 --- a/tests/unit/config/test_configfiles.py +++ b/tests/unit/config/test_configfiles.py @@ -24,13 +24,12 @@ import unittest.mock import textwrap import pytest +from PyQt5.QtCore import QSettings from qutebrowser.config import (config, configfiles, configexc, configdata, configtypes) from qutebrowser.utils import utils, usertypes -from PyQt5.QtCore import QSettings - @pytest.fixture(autouse=True) def configdata_init(): @@ -57,8 +56,7 @@ def test_state_config(fake_save_manager, data_tmpdir, if insert: state['general']['newval'] = '23' - # WORKAROUND for https://github.com/PyCQA/pylint/issues/574 - if 'foobar' in (old_data or ''): # pylint: disable=superfluous-parens + if 'foobar' in (old_data or ''): assert state['general']['foobar'] == '42' state._save() @@ -106,10 +104,7 @@ class TestYaml: print(lines) - # WORKAROUND for https://github.com/PyCQA/pylint/issues/574 - # pylint: disable=superfluous-parens if 'magenta' in (old_config or ''): - # pylint: enable=superfluous-parens assert ' colors.hints.fg: magenta' in lines if insert: assert ' tabs.show: never' in lines @@ -218,7 +213,7 @@ class TestYaml: ('%', 'While parsing', 'while scanning a directive'), ('global: 42', 'While loading data', "'global' object is not a dict"), ('foo: 42', 'While loading data', - "Toplevel object does not contain 'global' key"), + "Toplevel object does not contain 'global' key"), ('42', 'While loading data', "Toplevel object is not a dict"), ]) def test_invalid(self, yaml, config_tmpdir, line, text, exception): @@ -323,8 +318,9 @@ class TestConfigPyModules: sys.path = old_path def test_bind_in_module(self, confpy, qbmodulepy, tmpdir): - qbmodulepy.write('def run(config):', - ' config.bind(",a", "message-info foo", mode="normal")') + qbmodulepy.write( + 'def run(config):', + ' config.bind(",a", "message-info foo", mode="normal")') confpy.write_qbmodule() confpy.read() expected = {'normal': {',a': 'message-info foo'}} diff --git a/tests/unit/config/test_configinit.py b/tests/unit/config/test_configinit.py index 354920258..79ac43219 100644 --- a/tests/unit/config/test_configinit.py +++ b/tests/unit/config/test_configinit.py @@ -143,11 +143,11 @@ class TestEarlyInit: suffix = ' (autoconfig.yml)' if config_py else '' if invalid_yaml == '42': error = ("While loading data{}: Toplevel object is not a dict" - .format(suffix)) + .format(suffix)) expected_errors.append(error) elif invalid_yaml == 'wrong-type': error = ("Error{}: Invalid value 'True' - expected a value of " - "type str but got bool.".format(suffix)) + "type str but got bool.".format(suffix)) expected_errors.append(error) elif invalid_yaml == 'unknown': error = ("While loading options{}: Unknown option " diff --git a/tests/unit/config/test_configtypes.py b/tests/unit/config/test_configtypes.py index 36988a0a7..556b3f9e2 100644 --- a/tests/unit/config/test_configtypes.py +++ b/tests/unit/config/test_configtypes.py @@ -340,13 +340,13 @@ class TestBaseType: @pytest.mark.parametrize('valid_values, completions', [ # Without description (['foo', 'bar'], - [('foo', ''), ('bar', '')]), + [('foo', ''), ('bar', '')]), # With description ([('foo', "foo desc"), ('bar', "bar desc")], - [('foo', "foo desc"), ('bar', "bar desc")]), + [('foo', "foo desc"), ('bar', "bar desc")]), # With mixed description ([('foo', "foo desc"), 'bar'], - [('foo', "foo desc"), ('bar', "")]), + [('foo', "foo desc"), ('bar', "")]), ]) def test_complete_without_desc(self, klass, valid_values, completions): """Test complete with valid_values set without description.""" @@ -489,9 +489,9 @@ class TestString: @pytest.mark.parametrize('valid_values, expected', [ (configtypes.ValidValues('one', 'two'), - [('one', ''), ('two', '')]), + [('one', ''), ('two', '')]), (configtypes.ValidValues(('1', 'one'), ('2', 'two')), - [('1', 'one'), ('2', 'two')]), + [('1', 'one'), ('2', 'two')]), ]) def test_complete_valid_values(self, klass, valid_values, expected): assert klass(valid_values=valid_values).complete() == expected @@ -1616,19 +1616,19 @@ class TestDict: else: d.to_py(val) - @hypothesis.given(val=strategies.dictionaries(strategies.text(min_size=1), - strategies.booleans())) + @hypothesis.given(val=strategies.dictionaries( + strategies.text(min_size=1, alphabet=strategies.characters( + # No control characters, surrogates, or codepoints encoded as + # surrogate + blacklist_categories=['Cc', 'Cs'], max_codepoint=0xFFFF)), + strategies.booleans())) def test_hypothesis(self, klass, val): d = klass(keytype=configtypes.String(), valtype=configtypes.Bool(), none_ok=True) - try: - converted = d.to_py(val) - expected = converted if converted else None - assert d.from_str(d.to_str(converted)) == expected - except configexc.ValidationError: - # Invalid unicode in the string, etc... - hypothesis.assume(False) + converted = d.to_py(val) + expected = converted if converted else None + assert d.from_str(d.to_str(converted)) == expected @hypothesis.given(val=strategies.dictionaries(strategies.text(min_size=1), strategies.booleans())) @@ -1853,14 +1853,14 @@ class TestProxy: ('system', configtypes.SYSTEM_PROXY), ('none', QNetworkProxy(QNetworkProxy.NoProxy)), ('socks://example.com/', - QNetworkProxy(QNetworkProxy.Socks5Proxy, 'example.com')), + QNetworkProxy(QNetworkProxy.Socks5Proxy, 'example.com')), ('socks5://foo:bar@example.com:2323', - QNetworkProxy(QNetworkProxy.Socks5Proxy, 'example.com', 2323, - 'foo', 'bar')), + QNetworkProxy(QNetworkProxy.Socks5Proxy, 'example.com', 2323, + 'foo', 'bar')), ('pac+http://example.com/proxy.pac', - pac.PACFetcher(QUrl('pac+http://example.com/proxy.pac'))), + pac.PACFetcher(QUrl('pac+http://example.com/proxy.pac'))), ('pac+file:///tmp/proxy.pac', - pac.PACFetcher(QUrl('pac+file:///tmp/proxy.pac'))), + pac.PACFetcher(QUrl('pac+file:///tmp/proxy.pac'))), ]) def test_to_py_valid(self, klass, val, expected): actual = klass().to_py(val) diff --git a/tests/unit/javascript/conftest.py b/tests/unit/javascript/conftest.py index b9f013ecf..c5c384c26 100644 --- a/tests/unit/javascript/conftest.py +++ b/tests/unit/javascript/conftest.py @@ -26,6 +26,7 @@ import logging import pytest import jinja2 +from PyQt5.QtCore import QUrl try: from PyQt5.QtWebKit import QWebSettings from PyQt5.QtWebKitWidgets import QWebPage @@ -34,6 +35,17 @@ except ImportError: QWebSettings = None QWebPage = None +try: + from PyQt5.QtWebEngineWidgets import (QWebEnginePage, + QWebEngineSettings, + QWebEngineScript) +except ImportError: + QWebEnginePage = None + QWebEngineSettings = None + QWebEngineScript = None + +import helpers.utils +import qutebrowser.utils.debug from qutebrowser.utils import utils @@ -68,9 +80,96 @@ else: """Fail tests on js console messages as they're used for errors.""" pytest.fail("js console ({}:{}): {}".format(source, line, msg)) +if QWebEnginePage is None: + TestWebEnginePage = None +else: + class TestWebEnginePage(QWebEnginePage): + + """QWebEnginePage which overrides javascript logging methods. + + Attributes: + _logger: The logger used for alerts. + """ + + def __init__(self, parent=None): + super().__init__(parent) + self._logger = logging.getLogger('js-tests') + + def javaScriptAlert(self, _frame, msg): + """Log javascript alerts.""" + self._logger.info("js alert: {}".format(msg)) + + def javaScriptConfirm(self, _frame, msg): + """Fail tests on js confirm() as that should never happen.""" + pytest.fail("js confirm: {}".format(msg)) + + def javaScriptPrompt(self, _frame, msg, _default): + """Fail tests on js prompt() as that should never happen.""" + pytest.fail("js prompt: {}".format(msg)) + + def javaScriptConsoleMessage(self, level, msg, line, source): + """Fail tests on js console messages as they're used for errors.""" + pytest.fail("[{}] js console ({}:{}): {}".format( + qutebrowser.utils.debug.qenum_key( + QWebEnginePage, level), source, line, msg)) + class JSTester: + """Common subclass providing basic functionality for all JS testers. + + Attributes: + webview: The webview which is used. + _qtbot: The QtBot fixture from pytest-qt. + _jinja_env: The jinja2 environment used to get templates. + """ + + def __init__(self, webview, qtbot): + self.webview = webview + self._qtbot = qtbot + loader = jinja2.FileSystemLoader(os.path.dirname(__file__)) + self._jinja_env = jinja2.Environment(loader=loader, autoescape=True) + + def load(self, path, **kwargs): + """Load and display the given jinja test data. + + Args: + path: The path to the test file, relative to the javascript/ + folder. + **kwargs: Passed to jinja's template.render(). + """ + template = self._jinja_env.get_template(path) + with self._qtbot.waitSignal(self.webview.loadFinished, + timeout=2000) as blocker: + self.webview.setHtml(template.render(**kwargs)) + assert blocker.args == [True] + + def load_file(self, path: str, force: bool = False): + """Load a file from disk. + + Args: + path: The string path from disk to load (relative to this file) + force: Whether to force loading even if the file is invalid. + """ + self.load_url(QUrl.fromLocalFile( + os.path.join(os.path.dirname(__file__), path)), force) + + def load_url(self, url: QUrl, force: bool = False): + """Load a given QUrl. + + Args: + url: The QUrl to load. + force: Whether to force loading even if the file is invalid. + """ + with self._qtbot.waitSignal(self.webview.loadFinished, + timeout=2000) as blocker: + self.webview.load(url) + if not force: + assert blocker.args == [True] + + +class JSWebKitTester(JSTester): + """Object returned by js_tester which provides test data and a webview. Attributes: @@ -80,11 +179,8 @@ class JSTester: """ def __init__(self, webview, qtbot): - self.webview = webview + super().__init__(webview, qtbot) self.webview.setPage(TestWebPage(self.webview)) - self._qtbot = qtbot - loader = jinja2.FileSystemLoader(os.path.dirname(__file__)) - self._jinja_env = jinja2.Environment(loader=loader, autoescape=True) def scroll_anchor(self, name): """Scroll the main frame to the given anchor.""" @@ -94,19 +190,6 @@ class JSTester: new_pos = page.mainFrame().scrollPosition() assert old_pos != new_pos - def load(self, path, **kwargs): - """Load and display the given test data. - - Args: - path: The path to the test file, relative to the javascript/ - folder. - **kwargs: Passed to jinja's template.render(). - """ - template = self._jinja_env.get_template(path) - with self._qtbot.waitSignal(self.webview.loadFinished) as blocker: - self.webview.setHtml(template.render(**kwargs)) - assert blocker.args == [True] - def run_file(self, filename): """Run a javascript file. @@ -134,7 +217,57 @@ class JSTester: return self.webview.page().mainFrame().evaluateJavaScript(source) +class JSWebEngineTester(JSTester): + + """Object returned by js_tester_webengine which provides a webview. + + Attributes: + webview: The webview which is used. + _qtbot: The QtBot fixture from pytest-qt. + _jinja_env: The jinja2 environment used to get templates. + """ + + def __init__(self, webview, qtbot): + super().__init__(webview, qtbot) + self.webview.setPage(TestWebEnginePage(self.webview)) + + def run_file(self, filename: str, expected) -> None: + """Run a javascript file. + + Args: + filename: The javascript filename, relative to + qutebrowser/javascript. + expected: The value expected return from the javascript execution + """ + source = utils.read_file(os.path.join('javascript', filename)) + self.run(source, expected) + + def run(self, source: str, expected, world=None) -> None: + """Run the given javascript source. + + Args: + source: The source to run as a string. + expected: The value expected return from the javascript execution + world: The scope the javascript will run in + """ + if world is None: + world = QWebEngineScript.ApplicationWorld + + callback_checker = helpers.utils.CallbackChecker(self._qtbot) + assert self.webview.settings().testAttribute( + QWebEngineSettings.JavascriptEnabled) + self.webview.page().runJavaScript(source, world, + callback_checker.callback) + callback_checker.check(expected) + + @pytest.fixture -def js_tester(webview, qtbot): - """Fixture to test javascript snippets.""" - return JSTester(webview, qtbot) +def js_tester_webkit(webview, qtbot): + """Fixture to test javascript snippets in webkit.""" + return JSWebKitTester(webview, qtbot) + + +@pytest.fixture +def js_tester_webengine(callback_checker, webengineview, qtbot): + """Fixture to test javascript snippets in webengine.""" + return JSWebEngineTester(webengineview, qtbot) diff --git a/tests/unit/javascript/position_caret/test_position_caret.py b/tests/unit/javascript/position_caret/test_position_caret.py index 7be62e3cc..fcfa5cf5d 100644 --- a/tests/unit/javascript/position_caret/test_position_caret.py +++ b/tests/unit/javascript/position_caret/test_position_caret.py @@ -65,9 +65,9 @@ class CaretTester: @pytest.fixture -def caret_tester(js_tester): +def caret_tester(js_tester_webkit): """Helper fixture to test caret browsing positions.""" - caret_tester = CaretTester(js_tester) + caret_tester = CaretTester(js_tester_webkit) # Showing webview here is necessary for test_scrolled_down_img to # succeed in some cases, see #1988 caret_tester.js.webview.show() diff --git a/tests/unit/javascript/stylesheet/green.css b/tests/unit/javascript/stylesheet/green.css new file mode 100644 index 000000000..b2d035810 --- /dev/null +++ b/tests/unit/javascript/stylesheet/green.css @@ -0,0 +1 @@ +body, :root {background-color: rgb(0, 255, 0);} diff --git a/tests/unit/javascript/stylesheet/none.css b/tests/unit/javascript/stylesheet/none.css new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/javascript/stylesheet/simple.html b/tests/unit/javascript/stylesheet/simple.html new file mode 100644 index 000000000..4073672a4 --- /dev/null +++ b/tests/unit/javascript/stylesheet/simple.html @@ -0,0 +1,4 @@ +{% extends "base.html" %} +{% block content %} +

Hello World!

+{% endblock %} diff --git a/tests/unit/javascript/stylesheet/simple.xml b/tests/unit/javascript/stylesheet/simple.xml new file mode 100644 index 000000000..f9073de69 --- /dev/null +++ b/tests/unit/javascript/stylesheet/simple.xml @@ -0,0 +1,5 @@ + + + + org.qutebrowser.qutebrowser + diff --git a/tests/unit/javascript/stylesheet/simple_bg_set_red.html b/tests/unit/javascript/stylesheet/simple_bg_set_red.html new file mode 100644 index 000000000..b40352340 --- /dev/null +++ b/tests/unit/javascript/stylesheet/simple_bg_set_red.html @@ -0,0 +1,9 @@ +{% extends "base.html" %} +{% block style %} +body { + background-color: rgb(255, 0, 0); +} +{% endblock %} +{% block content %} +

Hello World!

+{% endblock %} diff --git a/tests/unit/javascript/stylesheet/test_appendchild.js b/tests/unit/javascript/stylesheet/test_appendchild.js new file mode 100644 index 000000000..d1deadba6 --- /dev/null +++ b/tests/unit/javascript/stylesheet/test_appendchild.js @@ -0,0 +1,47 @@ +// Taken from acid3 bucket 5 +// https://github.com/w3c/web-platform-tests/blob/37cf5607a39357a0f213ab5df2e6b30499b0226f/acid/acid3/test.html#L2320 + +// test 65: bring in a couple of SVG files and some HTML files dynamically - preparation for later tests in this bucket +// NOTE FROM 2011 UPDATE: The svg.xml file still contains the SVG font, but it is no longer used +kungFuDeathGrip = document.createElement('p'); +kungFuDeathGrip.className = 'removed'; +var iframe, object; +// svg iframe +iframe = document.createElement('iframe'); +iframe.onload = function () { kungFuDeathGrip.title += '1' }; +iframe.src = "svg.xml"; +kungFuDeathGrip.appendChild(iframe); +// object iframe +object = document.createElement('object'); +object.onload = function () { kungFuDeathGrip.title += '2' }; +object.data = "svg.xml"; +kungFuDeathGrip.appendChild(object); +// xml iframe +iframe = document.createElement('iframe'); +iframe.onload = function () { kungFuDeathGrip.title += '3' }; +iframe.src = "empty.xml"; +kungFuDeathGrip.appendChild(iframe); +// html iframe +iframe = document.createElement('iframe'); +iframe.onload = function () { kungFuDeathGrip.title += '4' }; +iframe.src = "empty.html"; +kungFuDeathGrip.appendChild(iframe); +// html iframe +iframe = document.createElement('iframe'); +iframe.onload = function () { kungFuDeathGrip.title += '5' }; +iframe.src = "xhtml.1"; +kungFuDeathGrip.appendChild(iframe); +// html iframe +iframe = document.createElement('iframe'); +iframe.onload = function () { kungFuDeathGrip.title += '6' }; +iframe.src = "xhtml.2"; +kungFuDeathGrip.appendChild(iframe); +// html iframe +iframe = document.createElement('iframe'); +iframe.onload = function () { kungFuDeathGrip.title += '7' }; +iframe.src = "xhtml.3"; +kungFuDeathGrip.appendChild(iframe); +// add the lot to the document + +// Modified as we don't have a 'map' +document.getElementsByTagName('head')[0].appendChild(kungFuDeathGrip); diff --git a/tests/unit/javascript/stylesheet/test_stylesheet.py b/tests/unit/javascript/stylesheet/test_stylesheet.py new file mode 100644 index 000000000..083265db7 --- /dev/null +++ b/tests/unit/javascript/stylesheet/test_stylesheet.py @@ -0,0 +1,138 @@ +# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et: + +# Copyright 2017 Jay Kamat +# +# This file is part of qutebrowser. +# +# qutebrowser is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# qutebrowser is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with qutebrowser. If not, see . + +"""Tests for stylesheet.js.""" + +import os +import pytest + +QtWebEngineWidgets = pytest.importorskip("PyQt5.QtWebEngineWidgets") +QWebEngineProfile = QtWebEngineWidgets.QWebEngineProfile + +from qutebrowser.utils import javascript + +try: + from qutebrowser.browser.webengine import webenginesettings +except ImportError: + webenginesettings = None + + +DEFAULT_BODY_BG = "rgba(0, 0, 0, 0)" +GREEN_BODY_BG = "rgb(0, 255, 0)" +CSS_BODY_GREEN = "body {background-color: rgb(0, 255, 0);}" +CSS_BODY_RED = "body {background-color: rgb(255, 0, 0);}" + + +class StylesheetTester: + + """Helper class (for the stylesheet_tester fixture) for asserts. + + Attributes: + js: The js_tester fixture. + config_stub: The config stub object. + """ + + def __init__(self, js_tester, config_stub): + self.js = js_tester + self.config_stub = config_stub + + def init_stylesheet(self, css_file="green.css"): + """Initialize the stylesheet with a provided css file.""" + css_path = os.path.join(os.path.dirname(__file__), css_file) + self.config_stub.val.content.user_stylesheets = css_path + p = QWebEngineProfile.defaultProfile() + webenginesettings._init_stylesheet(p) + + def set_css(self, css): + """Set document style to `css` via stylesheet.js.""" + code = javascript.assemble('stylesheet', 'set_css', css) + self.js.run(code, None) + + def check_set(self, value, css_style="background-color", + document_element="document.body"): + """Check whether the css in ELEMENT is set to VALUE.""" + self.js.run("window.getComputedStyle({}, null)" + ".getPropertyValue('{}');" + .format(document_element, + javascript.string_escape(css_style)), value) + + def check_eq(self, one, two, true=True): + """Check if one and two are equal.""" + self.js.run("{} === {};".format(one, two), true) + + +@pytest.fixture +def stylesheet_tester(js_tester_webengine, config_stub): + """Helper fixture to test stylesheets.""" + ss_tester = StylesheetTester(js_tester_webengine, config_stub) + ss_tester.js.webview.show() + return ss_tester + + +@pytest.mark.parametrize('page', ['stylesheet/simple.html', + 'stylesheet/simple_bg_set_red.html']) +def test_set_delayed(stylesheet_tester, page): + """Test a delayed invocation of set_css.""" + stylesheet_tester.init_stylesheet("none.css") + stylesheet_tester.js.load(page) + stylesheet_tester.set_css("body {background-color: rgb(0, 255, 0);}") + stylesheet_tester.check_set("rgb(0, 255, 0)") + + +@pytest.mark.parametrize('page', ['stylesheet/simple.html', + 'stylesheet/simple_bg_set_red.html']) +def test_set_clear_bg(stylesheet_tester, page): + """Test setting and clearing the stylesheet.""" + stylesheet_tester.init_stylesheet() + stylesheet_tester.js.load('stylesheet/simple.html') + stylesheet_tester.check_set(GREEN_BODY_BG) + stylesheet_tester.set_css("") + stylesheet_tester.check_set(DEFAULT_BODY_BG) + + +def test_set_xml(stylesheet_tester): + """Test stylesheet is applied without altering xml files.""" + stylesheet_tester.init_stylesheet() + stylesheet_tester.js.load_file('stylesheet/simple.xml') + stylesheet_tester.check_set(GREEN_BODY_BG) + stylesheet_tester.check_eq('"html"', "document.documentElement.nodeName") + + +def test_set_svg(stylesheet_tester): + """Test stylesheet is applied for svg files.""" + stylesheet_tester.init_stylesheet() + stylesheet_tester.js.load_file('../../../misc/cheatsheet.svg') + stylesheet_tester.check_set(GREEN_BODY_BG, + document_element="document.documentElement") + stylesheet_tester.check_eq('"svg"', "document.documentElement.nodeName") + + +def test_set_error(stylesheet_tester): + """Test stylesheet modifies file not found error pages.""" + stylesheet_tester.init_stylesheet() + stylesheet_tester.js.load_file('non-existent.html', force=True) + stylesheet_tester.check_set(GREEN_BODY_BG) + + +def test_appendchild(stylesheet_tester): + stylesheet_tester.init_stylesheet() + stylesheet_tester.js.load('stylesheet/simple.html') + js_test_file_path = ('../../tests/unit/javascript/stylesheet/' + 'test_appendchild.js') + stylesheet_tester.js.run_file(js_test_file_path, {}) diff --git a/tests/unit/javascript/test_greasemonkey.py b/tests/unit/javascript/test_greasemonkey.py new file mode 100644 index 000000000..0f5fe476c --- /dev/null +++ b/tests/unit/javascript/test_greasemonkey.py @@ -0,0 +1,104 @@ +# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et: +# Copyright 2017 Florian Bruhin (The Compiler) + +# This file is part of qutebrowser. +# +# qutebrowser is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# qutebrowser is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with qutebrowser. If not, see . + +"""Tests for qutebrowser.browser.greasemonkey.""" + +import logging + +import pytest +import py.path # pylint: disable=no-name-in-module +from PyQt5.QtCore import QUrl + +from qutebrowser.browser import greasemonkey + +test_gm_script = """ +// ==UserScript== +// @name qutebrowser test userscript +// @namespace invalid.org +// @include http://localhost:*/data/title.html +// @match http://trolol* +// @exclude https://badhost.xxx/* +// @run-at document-start +// ==/UserScript== +console.log("Script is running."); +""" + +pytestmark = pytest.mark.usefixtures('data_tmpdir') + + +def _save_script(script_text, filename): + # pylint: disable=no-member + file_path = py.path.local(greasemonkey._scripts_dir()) / filename + # pylint: enable=no-member + file_path.write_text(script_text, encoding='utf-8', ensure=True) + + +def test_all(): + """Test that a script gets read from file, parsed and returned.""" + _save_script(test_gm_script, 'test.user.js') + + gm_manager = greasemonkey.GreasemonkeyManager() + assert (gm_manager.all_scripts()[0].name == + "qutebrowser test userscript") + + +@pytest.mark.parametrize("url, expected_matches", [ + # included + ('http://trololololololo.com/', 1), + # neither included nor excluded + ('http://aaaaaaaaaa.com/', 0), + # excluded + ('https://badhost.xxx/', 0), +]) +def test_get_scripts_by_url(url, expected_matches): + """Check Greasemonkey include/exclude rules work.""" + _save_script(test_gm_script, 'test.user.js') + gm_manager = greasemonkey.GreasemonkeyManager() + + scripts = gm_manager.scripts_for(QUrl(url)) + assert (len(scripts.start + scripts.end + scripts.idle) == + expected_matches) + + +def test_no_metadata(caplog): + """Run on all sites at document-end is the default.""" + _save_script("var nothing = true;\n", 'nothing.user.js') + + with caplog.at_level(logging.WARNING): + gm_manager = greasemonkey.GreasemonkeyManager() + + scripts = gm_manager.scripts_for(QUrl('http://notamatch.invalid/')) + assert len(scripts.start + scripts.end + scripts.idle) == 1 + assert len(scripts.end) == 1 + + +def test_bad_scheme(caplog): + """qute:// isn't in the list of allowed schemes.""" + _save_script("var nothing = true;\n", 'nothing.user.js') + + with caplog.at_level(logging.WARNING): + gm_manager = greasemonkey.GreasemonkeyManager() + + scripts = gm_manager.scripts_for(QUrl('qute://settings')) + assert len(scripts.start + scripts.end + scripts.idle) == 0 + + +def test_load_emits_signal(qtbot): + gm_manager = greasemonkey.GreasemonkeyManager() + with qtbot.wait_signal(gm_manager.scripts_reloaded): + gm_manager.load_scripts() diff --git a/tests/unit/keyinput/test_modeman.py b/tests/unit/keyinput/test_modeman.py index d00ce09f7..5185636a6 100644 --- a/tests/unit/keyinput/test_modeman.py +++ b/tests/unit/keyinput/test_modeman.py @@ -19,10 +19,10 @@ import pytest -from qutebrowser.utils import usertypes - from PyQt5.QtCore import Qt, QObject, pyqtSignal +from qutebrowser.utils import usertypes + class FakeKeyparser(QObject): diff --git a/tests/unit/misc/test_checkpyver.py b/tests/unit/misc/test_checkpyver.py index 6487eb263..b61fadc59 100644 --- a/tests/unit/misc/test_checkpyver.py +++ b/tests/unit/misc/test_checkpyver.py @@ -44,7 +44,7 @@ def test_python2(): pytest.skip("python2 not found") assert not proc.stdout stderr = proc.stderr.decode('utf-8') - assert re.match(TEXT, stderr), stderr + assert re.fullmatch(TEXT, stderr), stderr assert proc.returncode == 1 @@ -64,7 +64,7 @@ def test_patched_no_errwindow(capfd, monkeypatch): checkpyver.check_python_version() stdout, stderr = capfd.readouterr() assert not stdout - assert re.match(TEXT, stderr), stderr + assert re.fullmatch(TEXT, stderr), stderr def test_patched_errwindow(capfd, mocker, monkeypatch): diff --git a/tests/unit/misc/test_cmdhistory.py b/tests/unit/misc/test_cmdhistory.py index 7858831b6..0e6f9748e 100644 --- a/tests/unit/misc/test_cmdhistory.py +++ b/tests/unit/misc/test_cmdhistory.py @@ -84,8 +84,7 @@ def test_start_no_items(hist): def test_getitem(hist): """Test __getitem__.""" - for i in range(0, len(HISTORY)): - assert hist[i] == HISTORY[i] + assert hist[0] == HISTORY[0] def test_setitem(hist): @@ -129,14 +128,14 @@ def test_nextitem_previtem_chain(hist): def test_nextitem_index_error(hist): - """"Test nextitem() when _tmphist raises an IndexError.""" + """Test nextitem() when _tmphist raises an IndexError.""" hist.start('f') with pytest.raises(cmdhistory.HistoryEndReachedError): hist.nextitem() def test_previtem_index_error(hist): - """"Test previtem() when _tmphist raises an IndexError.""" + """Test previtem() when _tmphist raises an IndexError.""" hist.start('f') with pytest.raises(cmdhistory.HistoryEndReachedError): for _ in range(10): diff --git a/tests/unit/misc/test_crashdialog.py b/tests/unit/misc/test_crashdialog.py index 89ca342ee..f8768e806 100644 --- a/tests/unit/misc/test_crashdialog.py +++ b/tests/unit/misc/test_crashdialog.py @@ -47,6 +47,14 @@ Thread 0x00007fa135ac7700 (most recent call first): File "", line 1 in testfunc """ +WINDOWS_CRASH_TEXT = r""" +Windows fatal exception: access violation +_ +Current thread 0x000014bc (most recent call first): + File "qutebrowser\mainwindow\tabbedbrowser.py", line 468 in tabopen + File "qutebrowser\browser\shared.py", line 247 in get_tab +""" + INVALID_CRASH_TEXT = """ Hello world! """ @@ -56,6 +64,7 @@ Hello world! (VALID_CRASH_TEXT, 'Segmentation fault', 'testfunc'), (VALID_CRASH_TEXT_THREAD, 'Segmentation fault', 'testfunc'), (VALID_CRASH_TEXT_EMPTY, 'Aborted', ''), + (WINDOWS_CRASH_TEXT, 'Windows access violation', 'tabopen'), (INVALID_CRASH_TEXT, '', ''), ]) def test_parse_fatal_stacktrace(text, typ, func): diff --git a/tests/unit/misc/test_guiprocess.py b/tests/unit/misc/test_guiprocess.py index 674c250e5..78c55bf98 100644 --- a/tests/unit/misc/test_guiprocess.py +++ b/tests/unit/misc/test_guiprocess.py @@ -19,7 +19,6 @@ """Tests for qutebrowser.misc.guiprocess.""" -import json import logging import pytest @@ -27,6 +26,7 @@ from PyQt5.QtCore import QProcess, QIODevice from qutebrowser.misc import guiprocess from qutebrowser.utils import usertypes +from qutebrowser.browser import qutescheme @pytest.fixture() @@ -60,7 +60,7 @@ def test_start(proc, qtbot, message_mock, py_proc): proc.start(*argv) assert not message_mock.messages - assert bytes(proc._proc.readAll()).rstrip() == b'test' + assert qutescheme.spawn_output == proc._spawn_format(stdout="test") def test_start_verbose(proc, qtbot, message_mock, py_proc): @@ -77,7 +77,7 @@ def test_start_verbose(proc, qtbot, message_mock, py_proc): assert msgs[1].level == usertypes.MessageLevel.info assert msgs[0].text.startswith("Executing:") assert msgs[1].text == "Testprocess exited successfully." - assert bytes(proc._proc.readAll()).rstrip() == b'test' + assert qutescheme.spawn_output == proc._spawn_format(stdout="test") def test_start_env(monkeypatch, qtbot, py_proc): @@ -99,10 +99,9 @@ def test_start_env(monkeypatch, qtbot, py_proc): order='strict'): proc.start(*argv) - data = bytes(proc._proc.readAll()).decode('utf-8') - ret_env = json.loads(data) - assert 'QUTEBROWSER_TEST_1' in ret_env - assert 'QUTEBROWSER_TEST_2' in ret_env + data = qutescheme.spawn_output + assert 'QUTEBROWSER_TEST_1' in data + assert 'QUTEBROWSER_TEST_2' in data @pytest.mark.qt_log_ignore('QIODevice::read.*: WriteOnly device') @@ -225,3 +224,18 @@ def test_exit_successful_output(qtbot, proc, py_proc, stream): print("test", file=sys.{}) sys.exit(0) """.format(stream))) + + +def test_stdout_not_decodable(proc, qtbot, message_mock, py_proc): + """Test handling malformed utf-8 in stdout.""" + with qtbot.waitSignals([proc.started, proc.finished], timeout=10000, + order='strict'): + argv = py_proc(r""" + import sys + # Using \x81 because it's invalid in UTF-8 and CP1252 + sys.stdout.buffer.write(b"A\x81B") + sys.exit(0) + """) + proc.start(*argv) + assert not message_mock.messages + assert qutescheme.spawn_output == proc._spawn_format(stdout="A\ufffdB") diff --git a/tests/unit/misc/test_ipc.py b/tests/unit/misc/test_ipc.py index 281bd4ac4..fdb03cfd9 100644 --- a/tests/unit/misc/test_ipc.py +++ b/tests/unit/misc/test_ipc.py @@ -461,11 +461,11 @@ NEW_VERSION = str(ipc.PROTOCOL_VERSION + 1).encode('utf-8') (b'{"valid json without args": true}\n', 'Missing args'), (b'{"args": []}\n', 'Missing target_arg'), (b'{"args": [], "target_arg": null, "protocol_version": ' + OLD_VERSION + - b'}\n', 'incompatible version'), + b'}\n', 'incompatible version'), (b'{"args": [], "target_arg": null, "protocol_version": ' + NEW_VERSION + - b'}\n', 'incompatible version'), + b'}\n', 'incompatible version'), (b'{"args": [], "target_arg": null, "protocol_version": "foo"}\n', - 'invalid version'), + 'invalid version'), (b'{"args": [], "target_arg": null}\n', 'invalid version'), ]) def test_invalid_data(qtbot, ipc_server, connected_socket, caplog, data, msg): @@ -672,9 +672,9 @@ class TestSendOrListen: @pytest.mark.parametrize('has_error, exc_name, exc_msg', [ (True, 'SocketError', - 'Error while writing to running instance: Error string (error 0)'), + 'Error while writing to running instance: Error string (error 0)'), (False, 'AddressInUseError', - 'Error while listening to IPC server: Error string (error 8)'), + 'Error while listening to IPC server: Error string (error 8)'), ]) def test_address_in_use_error(self, qlocalserver_mock, qlocalsocket_mock, stubs, caplog, args, has_error, exc_name, @@ -737,7 +737,7 @@ class TestSendOrListen: 'pre_text: ', 'post_text: Maybe another instance is running but frozen?', ('exception text: Error while listening to IPC server: Error ' - 'string (error 4)'), + 'string (error 4)'), ] assert caplog.records[-1].msg == '\n'.join(error_msgs) diff --git a/tests/unit/misc/test_miscwidgets.py b/tests/unit/misc/test_miscwidgets.py index 3e2261cc0..1ee351a81 100644 --- a/tests/unit/misc/test_miscwidgets.py +++ b/tests/unit/misc/test_miscwidgets.py @@ -106,7 +106,7 @@ class TestFullscreenNotification: @pytest.mark.parametrize('bindings, text', [ ({'': 'fullscreen --leave'}, - "Press Escape to exit fullscreen."), + "Press Escape to exit fullscreen."), ({'': 'fullscreen'}, "Page is now fullscreen."), ({'a': 'fullscreen --leave'}, "Press a to exit fullscreen."), ({}, "Page is now fullscreen."), diff --git a/tests/unit/misc/test_msgbox.py b/tests/unit/misc/test_msgbox.py index cab72c251..783816011 100644 --- a/tests/unit/misc/test_msgbox.py +++ b/tests/unit/misc/test_msgbox.py @@ -20,12 +20,12 @@ import pytest -from qutebrowser.misc import msgbox -from qutebrowser.utils import utils - from PyQt5.QtCore import Qt from PyQt5.QtWidgets import QMessageBox, QWidget +from qutebrowser.misc import msgbox +from qutebrowser.utils import utils + @pytest.fixture(autouse=True) def patch_args(fake_args): diff --git a/tests/unit/misc/test_sessions.py b/tests/unit/misc/test_sessions.py index 771430d5b..b2cb8a3dd 100644 --- a/tests/unit/misc/test_sessions.py +++ b/tests/unit/misc/test_sessions.py @@ -170,7 +170,7 @@ class TestSaveAll: ]) def test_get_session_name(config_stub, sess_man, arg, config, current, expected): - config_stub.val.session_default_name = config + config_stub.val.session.default_name = config sess_man._current = current assert sess_man._get_session_name(arg) == expected diff --git a/tests/unit/misc/test_sql.py b/tests/unit/misc/test_sql.py index 89c442b2b..048826513 100644 --- a/tests/unit/misc/test_sql.py +++ b/tests/unit/misc/test_sql.py @@ -20,10 +20,11 @@ """Test the SQL API.""" import pytest -from qutebrowser.misc import sql from PyQt5.QtSql import QSqlError +from qutebrowser.misc import sql + pytestmark = pytest.mark.usefixtures('init_sql') @@ -156,13 +157,13 @@ def test_iter(): @pytest.mark.parametrize('rows, sort_by, sort_order, limit, result', [ ([{"a": 2, "b": 5}, {"a": 1, "b": 6}, {"a": 3, "b": 4}], 'a', 'asc', 5, - [(1, 6), (2, 5), (3, 4)]), + [(1, 6), (2, 5), (3, 4)]), ([{"a": 2, "b": 5}, {"a": 1, "b": 6}, {"a": 3, "b": 4}], 'a', 'desc', 3, - [(3, 4), (2, 5), (1, 6)]), + [(3, 4), (2, 5), (1, 6)]), ([{"a": 2, "b": 5}, {"a": 1, "b": 6}, {"a": 3, "b": 4}], 'b', 'desc', 2, - [(1, 6), (2, 5)]), + [(1, 6), (2, 5)]), ([{"a": 2, "b": 5}, {"a": 1, "b": 6}, {"a": 3, "b": 4}], 'a', 'asc', -1, - [(1, 6), (2, 5), (3, 4)]), + [(1, 6), (2, 5), (3, 4)]), ]) def test_select(rows, sort_by, sort_order, limit, result): table = sql.SqlTable('Foo', ['a', 'b']) diff --git a/tests/unit/scripts/test_check_coverage.py b/tests/unit/scripts/test_check_coverage.py index 6b18568c5..d7183111f 100644 --- a/tests/unit/scripts/test_check_coverage.py +++ b/tests/unit/scripts/test_check_coverage.py @@ -50,6 +50,7 @@ class CovtestHelper: def run(self): """Run pytest with coverage for the given module.py.""" coveragerc = str(self._testdir.tmpdir / 'coveragerc') + self._monkeypatch.delenv('PYTEST_ADDOPTS', raising=False) return self._testdir.runpytest('--cov=module', '--cov-config={}'.format(coveragerc), '--cov-report=xml', diff --git a/tests/unit/scripts/test_dictcli.py b/tests/unit/scripts/test_dictcli.py index e5408ef68..3471251ba 100644 --- a/tests/unit/scripts/test_dictcli.py +++ b/tests/unit/scripts/test_dictcli.py @@ -22,8 +22,8 @@ import py.path # pylint: disable=no-name-in-module import pytest from qutebrowser.browser.webengine import spell -from scripts import dictcli from qutebrowser.config import configdata +from scripts import dictcli def afrikaans(): diff --git a/tests/unit/scripts/test_importer.py b/tests/unit/scripts/test_importer.py index 9edab7600..7c9ada1c4 100644 --- a/tests/unit/scripts/test_importer.py +++ b/tests/unit/scripts/test_importer.py @@ -27,28 +27,22 @@ _samples = 'tests/unit/scripts/importer_sample' def qm_expected(input_format): """Read expected quickmark-formatted output.""" - with open( - os.path.join(_samples, input_format, 'quickmarks'), - 'r', - encoding='utf-8') as f: + with open(os.path.join(_samples, input_format, 'quickmarks'), + 'r', encoding='utf-8') as f: return f.read() def bm_expected(input_format): """Read expected bookmark-formatted output.""" - with open( - os.path.join(_samples, input_format, 'bookmarks'), - 'r', - encoding='utf-8') as f: + with open(os.path.join(_samples, input_format, 'bookmarks'), + 'r', encoding='utf-8') as f: return f.read() def search_expected(input_format): """Read expected search-formatted (config.py) output.""" - with open( - os.path.join(_samples, input_format, 'config_py'), - 'r', - encoding='utf-8') as f: + with open(os.path.join(_samples, input_format, 'config_py'), + 'r', encoding='utf-8') as f: return f.read() diff --git a/tests/unit/utils/test_debug.py b/tests/unit/utils/test_debug.py index 9b77b9628..b7b5cf3ca 100644 --- a/tests/unit/utils/test_debug.py +++ b/tests/unit/utils/test_debug.py @@ -90,8 +90,8 @@ class TestLogTime: assert len(caplog.records) == 1 - pattern = re.compile(r'^Foobar took ([\d.]*) seconds\.$') - match = pattern.match(caplog.records[0].msg) + pattern = re.compile(r'Foobar took ([\d.]*) seconds\.') + match = pattern.fullmatch(caplog.records[0].msg) assert match duration = float(match.group(1)) @@ -252,8 +252,8 @@ class TestGetAllObjects: root = QObject() o1 = self.Object('Object 1', root) - o2 = self.Object('Object 2', o1) # flake8: disable=F841 - o3 = self.Object('Object 3', root) # flake8: disable=F841 + o2 = self.Object('Object 2', o1) # noqa: F841 + o3 = self.Object('Object 3', root) # noqa: F841 expected = textwrap.dedent(""" Qt widgets - 2 objects: diff --git a/tests/unit/utils/test_error.py b/tests/unit/utils/test_error.py index 47a1c52d9..43ebc01b7 100644 --- a/tests/unit/utils/test_error.py +++ b/tests/unit/utils/test_error.py @@ -21,13 +21,12 @@ import logging import pytest +from PyQt5.QtCore import QTimer +from PyQt5.QtWidgets import QMessageBox from qutebrowser.utils import error, utils from qutebrowser.misc import ipc -from PyQt5.QtCore import QTimer -from PyQt5.QtWidgets import QMessageBox - class Error(Exception): diff --git a/tests/unit/utils/test_log.py b/tests/unit/utils/test_log.py index 147e760bb..fb2723cc6 100644 --- a/tests/unit/utils/test_log.py +++ b/tests/unit/utils/test_log.py @@ -25,8 +25,10 @@ import itertools import sys import warnings +import attr import pytest -import pytest_catchlog +import _pytest.logging +from PyQt5 import QtCore from qutebrowser.utils import log from qutebrowser.misc import utilcmds @@ -63,11 +65,11 @@ def restore_loggers(): while root_logger.handlers: h = root_logger.handlers[0] root_logger.removeHandler(h) - if not isinstance(h, pytest_catchlog.LogCaptureHandler): + if not isinstance(h, _pytest.logging.LogCaptureHandler): h.close() root_logger.setLevel(original_logging_level) for h in root_handlers: - if not isinstance(h, pytest_catchlog.LogCaptureHandler): + if not isinstance(h, _pytest.logging.LogCaptureHandler): # https://github.com/qutebrowser/qutebrowser/issues/856 root_logger.addHandler(h) logging._acquireLock() @@ -252,3 +254,22 @@ def test_ignore_py_warnings(caplog): assert len(caplog.records) == 1 msg = caplog.records[0].message.splitlines()[0] assert msg.endswith("UserWarning: not hidden") + + +class TestQtMessageHandler: + + @attr.s + class Context: + + """Fake QMessageLogContext.""" + + function = attr.ib(default=None) + category = attr.ib(default=None) + file = attr.ib(default=None) + line = attr.ib(default=None) + + def test_empty_message(self, caplog): + """Make sure there's no crash with an empty message.""" + log.qt_message_handler(QtCore.QtDebugMsg, self.Context(), "") + assert len(caplog.records) == 1 + assert caplog.records[0].msg == "Logged empty message!" diff --git a/tests/unit/utils/test_qtutils.py b/tests/unit/utils/test_qtutils.py index d580d677a..b817eeed0 100644 --- a/tests/unit/utils/test_qtutils.py +++ b/tests/unit/utils/test_qtutils.py @@ -17,6 +17,7 @@ # You should have received a copy of the GNU General Public License # along with qutebrowser. If not, see . + """Tests for qutebrowser.utils.qtutils.""" import io @@ -40,6 +41,7 @@ from qutebrowser.utils import qtutils, utils import overflow_test_cases +# pylint: disable=bad-continuation @pytest.mark.parametrize(['qversion', 'compiled', 'pyqt', 'version', 'exact', 'expected'], [ # equal versions @@ -61,6 +63,7 @@ import overflow_test_cases # all up-to-date ('5.4.0', '5.4.0', '5.4.0', '5.4.0', False, True), ]) +# pylint: enable=bad-continuation def test_version_check(monkeypatch, qversion, compiled, pyqt, version, exact, expected): """Test for version_check(). diff --git a/tests/unit/utils/test_standarddir.py b/tests/unit/utils/test_standarddir.py index 5a3f74a66..e13b5e917 100644 --- a/tests/unit/utils/test_standarddir.py +++ b/tests/unit/utils/test_standarddir.py @@ -20,9 +20,9 @@ """Tests for qutebrowser.utils.standarddir.""" import os +import os.path import sys import json -import os.path import types import textwrap import logging diff --git a/tests/unit/utils/test_urlutils.py b/tests/unit/utils/test_urlutils.py index cbfc31055..4d9b3ba0a 100644 --- a/tests/unit/utils/test_urlutils.py +++ b/tests/unit/utils/test_urlutils.py @@ -492,18 +492,14 @@ def test_filename_from_url(qurl, output): (QUrl('qute://'), None), (QUrl('qute://foobar'), None), (QUrl('mailto:nobody'), None), - (QUrl('ftp://example.com/'), - ('ftp', 'example.com', 21)), - (QUrl('ftp://example.com:2121/'), - ('ftp', 'example.com', 2121)), + (QUrl('ftp://example.com/'), ('ftp', 'example.com', 21)), + (QUrl('ftp://example.com:2121/'), ('ftp', 'example.com', 2121)), (QUrl('http://qutebrowser.org:8010/waterfall'), - ('http', 'qutebrowser.org', 8010)), - (QUrl('https://example.com/'), - ('https', 'example.com', 443)), - (QUrl('https://example.com:4343/'), - ('https', 'example.com', 4343)), + ('http', 'qutebrowser.org', 8010)), + (QUrl('https://example.com/'), ('https', 'example.com', 443)), + (QUrl('https://example.com:4343/'), ('https', 'example.com', 4343)), (QUrl('http://user:password@qutebrowser.org/foo?bar=baz#fish'), - ('http', 'qutebrowser.org', 80)), + ('http', 'qutebrowser.org', 80)), ]) def test_host_tuple(qurl, tpl): """Test host_tuple(). @@ -752,7 +748,7 @@ def test_data_url(): (QUrl('http://www.example.com/ä'), 'http://www.example.com/ä'), # Unicode only in TLD (looks like Qt shows Punycode with рф...) (QUrl('http://www.example.xn--p1ai'), - '(www.example.xn--p1ai) http://www.example.рф'), + '(www.example.xn--p1ai) http://www.example.рф'), # https://bugreports.qt.io/browse/QTBUG-60364 pytest.param(QUrl('http://www.xn--80ak6aa92e.com'), '(unparseable URL!) http://www.аррӏе.com', @@ -779,19 +775,19 @@ class TestProxyFromUrl: @pytest.mark.parametrize('url, expected', [ ('socks://example.com/', - QNetworkProxy(QNetworkProxy.Socks5Proxy, 'example.com')), + QNetworkProxy(QNetworkProxy.Socks5Proxy, 'example.com')), ('socks5://example.com', - QNetworkProxy(QNetworkProxy.Socks5Proxy, 'example.com')), + QNetworkProxy(QNetworkProxy.Socks5Proxy, 'example.com')), ('socks5://example.com:2342', - QNetworkProxy(QNetworkProxy.Socks5Proxy, 'example.com', 2342)), + QNetworkProxy(QNetworkProxy.Socks5Proxy, 'example.com', 2342)), ('socks5://foo@example.com', - QNetworkProxy(QNetworkProxy.Socks5Proxy, 'example.com', 0, 'foo')), + QNetworkProxy(QNetworkProxy.Socks5Proxy, 'example.com', 0, 'foo')), ('socks5://foo:bar@example.com', - QNetworkProxy(QNetworkProxy.Socks5Proxy, 'example.com', 0, 'foo', - 'bar')), + QNetworkProxy(QNetworkProxy.Socks5Proxy, 'example.com', 0, 'foo', + 'bar')), ('socks5://foo:bar@example.com:2323', - QNetworkProxy(QNetworkProxy.Socks5Proxy, 'example.com', 2323, - 'foo', 'bar')), + QNetworkProxy(QNetworkProxy.Socks5Proxy, 'example.com', 2323, + 'foo', 'bar')), ('direct://', QNetworkProxy(QNetworkProxy.NoProxy)), ]) def test_proxy_from_url_valid(self, url, expected): diff --git a/tests/unit/utils/test_utils.py b/tests/unit/utils/test_utils.py index f6eef7f4f..28837e93c 100644 --- a/tests/unit/utils/test_utils.py +++ b/tests/unit/utils/test_utils.py @@ -376,7 +376,7 @@ class TestKeyEventToString: ('', utils.KeyInfo(Qt.Key_X, Qt.ControlModifier, '')), ('', utils.KeyInfo(Qt.Key_X, Qt.MetaModifier, '')), ('', - utils.KeyInfo(Qt.Key_Y, Qt.ControlModifier | Qt.AltModifier, '')), + utils.KeyInfo(Qt.Key_Y, Qt.ControlModifier | Qt.AltModifier, '')), ('x', utils.KeyInfo(Qt.Key_X, Qt.NoModifier, 'x')), ('X', utils.KeyInfo(Qt.Key_X, Qt.ShiftModifier, 'X')), ('', utils.KeyInfo(Qt.Key_Escape, Qt.NoModifier, '')), @@ -863,7 +863,7 @@ class TestOpenFile: cmdline = '{} -c pass'.format(executable) utils.open_file('/foo/bar', cmdline) result = caplog.records[0].message - assert re.match( + assert re.fullmatch( r'Opening /foo/bar with \[.*python.*/foo/bar.*\]', result) @pytest.mark.not_frozen @@ -872,7 +872,7 @@ class TestOpenFile: cmdline = '{} -c pass {{}} raboof'.format(executable) utils.open_file('/foo/bar', cmdline) result = caplog.records[0].message - assert re.match( + assert re.fullmatch( r"Opening /foo/bar with \[.*python.*/foo/bar.*'raboof'\]", result) @pytest.mark.not_frozen @@ -882,7 +882,7 @@ class TestOpenFile: config_stub.val.downloads.open_dispatcher = cmdline utils.open_file('/foo/bar') result = caplog.records[1].message - assert re.match( + assert re.fullmatch( r"Opening /foo/bar with \[.*python.*/foo/bar.*\]", result) def test_system_default_application(self, caplog, config_stub, mocker): @@ -890,7 +890,7 @@ class TestOpenFile: new_callable=mocker.Mock) utils.open_file('/foo/bar') result = caplog.records[0].message - assert re.match( + assert re.fullmatch( r"Opening /foo/bar with the system application", result) m.assert_called_with(QUrl('file:///foo/bar')) diff --git a/tests/unit/utils/test_version.py b/tests/unit/utils/test_version.py index 3d01fcfb5..aa9df5419 100644 --- a/tests/unit/utils/test_version.py +++ b/tests/unit/utils/test_version.py @@ -467,12 +467,12 @@ def test_path_info(monkeypatch, equal): equal: Whether system data / data and system config / config are equal. """ patches = { - 'config': lambda auto=False: + 'config': lambda auto=False: ( 'AUTO CONFIG PATH' if auto and not equal - else 'CONFIG PATH', - 'data': lambda system=False: + else 'CONFIG PATH'), + 'data': lambda system=False: ( 'SYSTEM DATA PATH' if system and not equal - else 'DATA PATH', + else 'DATA PATH'), 'cache': lambda: 'CACHE PATH', 'runtime': lambda: 'RUNTIME PATH', } @@ -795,7 +795,7 @@ class FakeQSslSocket: def sslLibraryVersionString(self): """Fake for QSslSocket::sslLibraryVersionString().""" if self._version is None: - raise AssertionError("Got called with version None!") + raise utils.Unreachable("Got called with version None!") return self._version diff --git a/tests/unit/utils/usertypes/test_neighborlist.py b/tests/unit/utils/usertypes/test_neighborlist.py index 751f940a3..804ed51ed 100644 --- a/tests/unit/utils/usertypes/test_neighborlist.py +++ b/tests/unit/utils/usertypes/test_neighborlist.py @@ -19,10 +19,10 @@ """Tests for the NeighborList class.""" -from qutebrowser.utils import usertypes - import pytest +from qutebrowser.utils import usertypes + class TestInit: diff --git a/tests/unit/utils/usertypes/test_timer.py b/tests/unit/utils/usertypes/test_timer.py index d120d82e6..81ea6feae 100644 --- a/tests/unit/utils/usertypes/test_timer.py +++ b/tests/unit/utils/usertypes/test_timer.py @@ -19,11 +19,11 @@ """Tests for Timer.""" -from qutebrowser.utils import usertypes - import pytest from PyQt5.QtCore import QObject +from qutebrowser.utils import usertypes + class Parent(QObject): diff --git a/tox.ini b/tox.ini index 662496f88..5b8bc05b5 100644 --- a/tox.ini +++ b/tox.ini @@ -13,134 +13,29 @@ skipsdist = true setenv = QT_QPA_PLATFORM_PLUGIN_PATH={envdir}/Lib/site-packages/PyQt5/plugins/platforms PYTEST_QT_API=pyqt5 + pyqt{,56,571,58,59}: LINK_PYQT_SKIP=true + pyqt{,56,571,58,59}: QUTE_BDD_WEBENGINE=true + cov: PYTEST_ADDOPTS=--cov --cov-report xml --cov-report=html --cov-report= passenv = PYTHON DISPLAY XAUTHORITY HOME USERNAME USER CI TRAVIS XDG_* QUTE_* DOCKER +basepython = + py35: python3.5 + py36: python3.6 deps = -r{toxinidir}/requirements.txt -r{toxinidir}/misc/requirements/requirements-tests.txt + pyqt: -r{toxinidir}/misc/requirements/requirements-pyqt.txt + pyqt571: PyQt5==5.7.1 + pyqt58: PyQt5==5.8.2 + pyqt59: PyQt5==5.9.2 commands = {envpython} scripts/link_pyqt.py --tox {envdir} {envpython} -bb -m pytest {posargs:tests} - -# test envs with PyQt5 from PyPI - -[testenv:py35-pyqt56] -basepython = python3.5 -setenv = - {[testenv]setenv} - QUTE_BDD_WEBENGINE=true -passenv = {[testenv]passenv} -deps = - {[testenv]deps} - PyQt5==5.6 -commands = {envpython} -bb -m pytest {posargs:tests} - -[testenv:py35-pyqt571] -basepython = python3.5 -setenv = - {[testenv]setenv} - QUTE_BDD_WEBENGINE=true -passenv = {[testenv]passenv} -deps = - {[testenv]deps} - PyQt5==5.7.1 -commands = {envpython} -bb -m pytest {posargs:tests} - -[testenv:py36-pyqt571] -basepython = python3.6 -setenv = - {[testenv]setenv} - QUTE_BDD_WEBENGINE=true -passenv = {[testenv]passenv} -deps = - {[testenv]deps} - PyQt5==5.7.1 -commands = {envpython} -bb -m pytest {posargs:tests} - -[testenv:py35-pyqt58] -basepython = python3.5 -setenv = - {[testenv]setenv} - QUTE_BDD_WEBENGINE=true -passenv = {[testenv]passenv} -deps = - {[testenv]deps} - PyQt5==5.8.2 -commands = {envpython} -bb -m pytest {posargs:tests} - -[testenv:py36-pyqt58] -basepython = {env:PYTHON:python3.6} -setenv = - {[testenv]setenv} - QUTE_BDD_WEBENGINE=true -passenv = {[testenv]passenv} -deps = - {[testenv]deps} - PyQt5==5.8.2 -commands = {envpython} -bb -m pytest {posargs:tests} - -[testenv:py35-pyqt59] -basepython = python3.5 -setenv = - {[testenv]setenv} - QUTE_BDD_WEBENGINE=true -passenv = {[testenv]passenv} -deps = - {[testenv]deps} - PyQt5==5.9 -commands = {envpython} -bb -m pytest {posargs:tests} - -[testenv:py36-pyqt59] -basepython = {env:PYTHON:python3.6} -setenv = - {[testenv]setenv} - QUTE_BDD_WEBENGINE=true -passenv = {[testenv]passenv} -deps = - {[testenv]deps} - PyQt5==5.9 -commands = {envpython} -bb -m pytest {posargs:tests} - -# test envs with coverage - -[testenv:py35-pyqt59-cov] -basepython = python3.5 -setenv = - {[testenv]setenv} - QUTE_BDD_WEBENGINE=true -passenv = {[testenv]passenv} -deps = - {[testenv]deps} - PyQt5==5.9 -commands = - {envpython} -bb -m pytest --cov --cov-report xml --cov-report=html --cov-report= {posargs:tests} - {envpython} scripts/dev/check_coverage.py {posargs} - -[testenv:py36-pyqt59-cov] -basepython = python3.6 -setenv = - {[testenv]setenv} - QUTE_BDD_WEBENGINE=true -passenv = {[testenv]passenv} -deps = - {[testenv]deps} - PyQt5==5.9 -commands = - {envpython} -bb -m pytest --cov --cov-report xml --cov-report=html --cov-report= {posargs:tests} - {envpython} scripts/dev/check_coverage.py {posargs} + cov: {envpython} scripts/dev/check_coverage.py {posargs} # other envs [testenv:mkvenv] -basepython = python3 -commands = {envpython} scripts/link_pyqt.py --tox {envdir} -envdir = {toxinidir}/.venv -usedevelop = true -deps = - -r{toxinidir}/requirements.txt - -# This is used for Windows, since binary name is different -[testenv:mkvenv-win] -basepython = python.exe +basepython = {env:PYTHON:python3} commands = {envpython} scripts/link_pyqt.py --tox {envdir} envdir = {toxinidir}/.venv usedevelop = true @@ -157,7 +52,7 @@ deps = {[testenv:mkvenv]deps} # Virtualenv with PyQt5 from PyPI [testenv:mkvenv-pypi] -basepython = python3 +basepython = {env:PYTHON:python3} envdir = {toxinidir}/.venv commands = {envpython} -c "" usedevelop = true @@ -165,19 +60,9 @@ deps = -r{toxinidir}/requirements.txt -r{toxinidir}/misc/requirements/requirements-pyqt.txt -# This is used for Windows, since binary name is different -[testenv:mkvenv-win-pypi] -basepython = python.exe -commands = {envpython} -c "" -envdir = {toxinidir}/.venv -usedevelop = true -deps = - -r{toxinidir}/requirements.txt - -r{toxinidir}/misc/requirements/requirements-pyqt.txt - [testenv:misc] ignore_errors = true -basepython = python3 +basepython = {env:PYTHON:python3} # For global .gitignore files passenv = HOME deps = @@ -187,7 +72,7 @@ commands = {envpython} scripts/dev/misc_checks.py spelling [testenv:vulture] -basepython = python3 +basepython = {env:PYTHON:python3} deps = -r{toxinidir}/requirements.txt -r{toxinidir}/misc/requirements/requirements-vulture.txt @@ -196,23 +81,48 @@ setenv = PYTHONPATH={toxinidir} commands = {envpython} scripts/dev/run_vulture.py +[testenv:vulture-pyqtlink] +basepython = {env:PYTHON:python3} +deps = + -r{toxinidir}/requirements.txt + -r{toxinidir}/misc/requirements/requirements-vulture.txt +setenv = PYTHONPATH={toxinidir} +commands = + {envpython} scripts/link_pyqt.py --tox {envdir} + {[testenv:vulture]commands} + [testenv:pylint] basepython = {env:PYTHON:python3} ignore_errors = true passenv = deps = - {[testenv]deps} + -r{toxinidir}/requirements.txt + -r{toxinidir}/misc/requirements/requirements-tests.txt -r{toxinidir}/misc/requirements/requirements-pylint.txt -r{toxinidir}/misc/requirements/requirements-pyqt.txt commands = {envpython} -m pylint scripts qutebrowser --output-format=colorized --reports=no {posargs} {envpython} scripts/dev/run_pylint_on_tests.py {toxinidir} --output-format=colorized --reports=no {posargs} +[testenv:pylint-pyqtlink] +basepython = {env:PYTHON:python3} +ignore_errors = true +passenv = +deps = + -r{toxinidir}/requirements.txt + -r{toxinidir}/misc/requirements/requirements-tests.txt + -r{toxinidir}/misc/requirements/requirements-pylint.txt +commands = + {envpython} scripts/link_pyqt.py --tox {envdir} + {envpython} -m pylint scripts qutebrowser --output-format=colorized --reports=no {posargs} + {envpython} scripts/dev/run_pylint_on_tests.py {toxinidir} --output-format=colorized --reports=no {posargs} + [testenv:pylint-master] -basepython = python3 +basepython = {env:PYTHON:python3} passenv = {[testenv:pylint]passenv} deps = - {[testenv]deps} + -r{toxinidir}/requirements.txt + -r{toxinidir}/misc/requirements/requirements-tests.txt -r{toxinidir}/misc/requirements/requirements-pylint-master.txt commands = {envpython} scripts/link_pyqt.py --tox {envdir} @@ -220,7 +130,7 @@ commands = {envpython} scripts/dev/run_pylint_on_tests.py --output-format=colorized --reports=no {posargs} [testenv:flake8] -basepython = python3 +basepython = {env:PYTHON:python3} passenv = deps = -r{toxinidir}/requirements.txt @@ -229,7 +139,7 @@ commands = {envpython} -m flake8 {posargs:qutebrowser tests scripts} [testenv:pyroma] -basepython = python3 +basepython = {env:PYTHON:python3} passenv = deps = -r{toxinidir}/misc/requirements/requirements-pyroma.txt @@ -237,7 +147,7 @@ commands = {envdir}/bin/pyroma . [testenv:check-manifest] -basepython = python3 +basepython = {env:PYTHON:python3} passenv = deps = -r{toxinidir}/misc/requirements/requirements-check-manifest.txt @@ -245,7 +155,7 @@ commands = {envdir}/bin/check-manifest --ignore 'qutebrowser/git-commit-id,qutebrowser/html/doc,qutebrowser/html/doc/*,*/__pycache__' [testenv:docs] -basepython = python3 +basepython = {env:PYTHON:python3} whitelist_externals = git passenv = TRAVIS TRAVIS_PULL_REQUEST deps = @@ -271,6 +181,7 @@ commands = [testenv:eslint] # This is duplicated in travis_run.sh for Travis CI because we can't get tox in # the JavaScript environment easily. +basepython = python3 deps = whitelist_externals = eslint changedir = {toxinidir}/qutebrowser/javascript