# SPDX-FileCopyrightText: Freya Bruhin (The Compiler) <mail@qutebrowser.org>
#
# SPDX-License-Identifier: GPL-3.0-or-later

"""Tests for qutebrowser.utils.version."""

import io
import sys
import os
import pathlib
import subprocess
import contextlib
import logging
import textwrap
import datetime
import dataclasses
import importlib.metadata
import unittest.mock
from typing import Any

import pytest
import pytest_mock
import hypothesis
import hypothesis.strategies
from qutebrowser.qt import machinery
from qutebrowser.qt.core import PYQT_VERSION_STR, QUrl
from qutebrowser.qt.webenginecore import QWebEngineProfile

import qutebrowser
from qutebrowser.config import config, websettings
from qutebrowser.utils import version, usertypes, utils, standarddir
from qutebrowser.misc import pastebin, objects, elf, wmname
from qutebrowser.browser import pdfjs

try:
    from qutebrowser.browser.webengine import webenginesettings
except ImportError:
    webenginesettings = None


@pytest.mark.parametrize('os_release, expected', [
    # No file
    (None, None),
    # Invalid file
    ("\n# foo\n foo=bar=baz",
     version.DistributionInfo(id=None, parsed=version.Distribution.unknown,
                              pretty='Unknown')),
    # Archlinux
    ("""
        NAME="Arch Linux"
        PRETTY_NAME="Arch Linux"
        ID=arch
        ID_LIKE=archlinux
        ANSI_COLOR="0;36"
        HOME_URL="https://www.archlinux.org/"
        SUPPORT_URL="https://bbs.archlinux.org/"
        BUG_REPORT_URL="https://bugs.archlinux.org/"
     """,
     version.DistributionInfo(
         id='arch', parsed=version.Distribution.arch, pretty='Arch Linux')),
    # Ubuntu 14.04
    ("""
        NAME="Ubuntu"
        VERSION="14.04.5 LTS, Trusty Tahr"
        ID=ubuntu
        ID_LIKE=debian
        PRETTY_NAME="Ubuntu 14.04.5 LTS"
        VERSION_ID="14.04"
     """,
     version.DistributionInfo(
         id='ubuntu', parsed=version.Distribution.ubuntu, pretty='Ubuntu 14.04.5 LTS')),
    # Ubuntu 17.04
    ("""
        NAME="Ubuntu"
        VERSION="17.04 (Zesty Zapus)"
        ID=ubuntu
        ID_LIKE=debian
        PRETTY_NAME="Ubuntu 17.04"
        VERSION_ID="17.04"
     """,
     version.DistributionInfo(
         id='ubuntu', parsed=version.Distribution.ubuntu, pretty='Ubuntu 17.04')),
    # Debian Jessie
    ("""
        PRETTY_NAME="Debian GNU/Linux 8 (jessie)"
        NAME="Debian GNU/Linux"
        VERSION_ID="8"
        VERSION="8 (jessie)"
        ID=debian
     """,
     version.DistributionInfo(
         id='debian', parsed=version.Distribution.debian,
         pretty='Debian GNU/Linux 8 (jessie)')),
    # Void Linux
    ("""
        NAME="void"
        ID="void"
        DISTRIB_ID="void"
        PRETTY_NAME="void"
     """,
     version.DistributionInfo(
         id='void', parsed=version.Distribution.void, pretty='void')),
    # Gentoo
    ("""
        NAME=Gentoo
        ID=gentoo
        PRETTY_NAME="Gentoo/Linux"
     """,
     version.DistributionInfo(
         id='gentoo', parsed=version.Distribution.gentoo, pretty='Gentoo/Linux')),
    # Fedora
    ("""
        NAME=Fedora
        VERSION="25 (Twenty Five)"
        ID=fedora
        VERSION_ID=25
        PRETTY_NAME="Fedora 25 (Twenty Five)"
     """,
     version.DistributionInfo(
         id='fedora', parsed=version.Distribution.fedora,
         pretty='Fedora 25 (Twenty Five)')),
    # OpenSUSE
    ("""
        NAME="openSUSE Leap"
        VERSION="42.2"
        ID=opensuse
        ID_LIKE="suse"
        VERSION_ID="42.2"
        PRETTY_NAME="openSUSE Leap 42.2"
     """,
     version.DistributionInfo(
         id='opensuse', parsed=version.Distribution.opensuse,
         pretty='openSUSE Leap 42.2')),
    # Linux Mint
    ("""
        NAME="Linux Mint"
        VERSION="18.1 (Serena)"
        ID=linuxmint
        ID_LIKE=ubuntu
        PRETTY_NAME="Linux Mint 18.1"
        VERSION_ID="18.1"
     """,
     version.DistributionInfo(
         id='linuxmint', parsed=version.Distribution.linuxmint,
         pretty='Linux Mint 18.1')),
    # Manjaro
    ("""
        NAME="Manjaro Linux"
        ID=manjaro
        PRETTY_NAME="Manjaro Linux"
     """,
     version.DistributionInfo(
         id='manjaro', parsed=version.Distribution.manjaro, pretty='Manjaro Linux')),
    # Funtoo
    ("""
        ID="funtoo"
        NAME="Funtoo GNU/Linux"
        PRETTY_NAME="Linux"
     """,
     version.DistributionInfo(
         id='funtoo', parsed=version.Distribution.gentoo, pretty='Funtoo GNU/Linux')),
    # KDE neon
    ("""
        NAME="KDE neon"
        VERSION="5.20"
        ID=neon
        ID_LIKE="ubuntu debian"
        PRETTY_NAME="KDE neon User Edition 5.20"
        VARIANT="User Edition"
        VERSION_ID="20.04"
    """,
    version.DistributionInfo(
        id='neon', parsed=version.Distribution.neon,
        pretty='KDE neon User Edition 5.20')),
    # Archlinux ARM
    ("""
        NAME="Arch Linux ARM"
        PRETTY_NAME="Arch Linux ARM"
        ID=archarm
        ID_LIKE=arch
    """,
    version.DistributionInfo(
        id='archarm', parsed=version.Distribution.arch, pretty='Arch Linux ARM')),
    # Alpine
    ("""
        NAME="Alpine Linux"
        ID=alpine
        VERSION_ID=3.12_alpha20200122
        PRETTY_NAME="Alpine Linux edge"
    """,
    version.DistributionInfo(
        id='alpine', parsed=version.Distribution.alpine, pretty='Alpine Linux edge')),
    # EndeavourOS
    ("""
        NAME="EndeavourOS"
        PRETTY_NAME="EndeavourOS"
        ID=endeavouros
        ID_LIKE=arch
        BUILD_ID=rolling
        DOCUMENTATION_URL="https://endeavouros.com/wiki/"
        LOGO=endeavouros
    """,
    version.DistributionInfo(
        id='endeavouros', parsed=version.Distribution.arch, pretty='EndeavourOS')),
    # Manjaro ARM
    ("""
        NAME="Manjaro-ARM"
        ID=manjaro-arm
        ID_LIKE=manjaro arch
        PRETTY_NAME="Manjaro ARM"
    """,
    version.DistributionInfo(
        id='manjaro-arm', parsed=version.Distribution.manjaro, pretty='Manjaro ARM')),
    # Artix Linux
    ("""
        NAME="Artix Linux"
        PRETTY_NAME="Artix Linux"
        ID=artix
    """,
    version.DistributionInfo(
        id='artix', parsed=version.Distribution.arch, pretty='Artix Linux')),
    # NixOS
    ("""
        NAME=NixOS
        ID=nixos
        VERSION="21.03pre268206.536fe36e23a (Okapi)"
        VERSION_CODENAME=okapi
        VERSION_ID="21.03pre268206.536fe36e23a"
        PRETTY_NAME="NixOS 21.03 (Okapi)"
    """,
    version.DistributionInfo(
        id='nixos', parsed=version.Distribution.nixos, pretty='NixOS 21.03 (Okapi)')),
    # NixOS (fake fourth version component)
    ("""
        NAME=NixOS
        ID=nixos
        VERSION="21.05.20210402.1dead (Okapi)"
    """,
    version.DistributionInfo(
        id='nixos', parsed=version.Distribution.nixos, pretty='NixOS')),
    # SolusOS
    ("""
        NAME="Solus"
        VERSION="4.2"
        ID="solus"
        VERSION_CODENAME=fortitude
        VERSION_ID="4.2"
        PRETTY_NAME="Solus 4.2 Fortitude"
    """,
    version.DistributionInfo(
        id='solus', parsed=version.Distribution.solus, pretty='Solus 4.2 Fortitude')),
    # KDE Platform
    ("""
        NAME=KDE
        VERSION="5.12 (Flatpak runtime)"
        VERSION_ID="5.12"
        ID=org.kde.Platform
    """,
    version.DistributionInfo(
        id='org.kde.Platform', parsed=version.Distribution.kde_flatpak, pretty='KDE')),
    # No PRETTY_NAME
    ("""
        NAME="Tux"
        ID=tux
    """,
    version.DistributionInfo(
        id='tux', parsed=version.Distribution.unknown, pretty='Tux')),
    # Invalid multi-line value
    ("""
        ID=tux
        PRETTY_NAME="Multiline
        Text"
    """,
    version.DistributionInfo(
        id='tux', parsed=version.Distribution.unknown, pretty='Multiline')),
])
def test_distribution(tmp_path, monkeypatch, os_release, expected):
    os_release_file = tmp_path / 'os-release'
    if os_release is not None:
        os_release_file.write_text(textwrap.dedent(os_release), encoding="utf-8")
    monkeypatch.setenv('QUTE_FAKE_OS_RELEASE', str(os_release_file))

    assert version.distribution() == expected


@pytest.mark.parametrize('has_env', [True, False])
@pytest.mark.parametrize('has_file', [True, False])
def test_is_flatpak(monkeypatch, tmp_path, has_env, has_file):
    if has_env:
        monkeypatch.setenv('FLATPAK_ID', 'org.qutebrowser.qutebrowser')
    else:
        monkeypatch.delenv('FLATPAK_ID', raising=False)

    fake_info_path = tmp_path / '.flatpak_info'
    if has_file:
        lines = [
            "[Application]",
            "name=org.qutebrowser.qutebrowser",
            "runtime=runtime/org.kde.Platform/x86_64/5.15",
        ]
        fake_info_path.write_text('\n'.join(lines))
    else:
        assert not fake_info_path.exists()
    monkeypatch.setattr(version, '_FLATPAK_INFO_PATH', str(fake_info_path))

    assert version.is_flatpak() == (has_env or has_file)


class GitStrSubprocessFake:
    """Object returned by the git_str_subprocess_fake fixture.

    This provides a function which is used to patch _git_str_subprocess.

    Attributes:
        retval: The value to return when called. Needs to be set before func is
                called.
    """

    UNSET = object()

    def __init__(self):
        self.retval = self.UNSET

    def func(self, gitpath):
        """Function called instead of _git_str_subprocess.

        Checks whether the path passed is what we expected, and returns
        self.retval.
        """
        if self.retval is self.UNSET:
            raise ValueError("func got called without retval being set!")
        retval = self.retval
        self.retval = self.UNSET
        gitpath = pathlib.Path(gitpath).resolve()
        expected = pathlib.Path(qutebrowser.__file__).parent.parent
        assert gitpath == expected
        return retval


class TestGitStr:

    """Tests for _git_str()."""

    @pytest.fixture
    def commit_file_mock(self, mocker):
        """Fixture providing a mock for resources.read_file for git-commit-id.

        On fixture teardown, it makes sure it got called with git-commit-id as
        argument.
        """
        mocker.patch('qutebrowser.utils.version.subprocess',
                     side_effect=AssertionError)
        m = mocker.patch('qutebrowser.utils.version.resources.read_file')
        yield m
        m.assert_called_with('git-commit-id')

    @pytest.fixture
    def git_str_subprocess_fake(self, mocker, monkeypatch):
        """Fixture patching _git_str_subprocess with a GitStrSubprocessFake."""
        mocker.patch('qutebrowser.utils.version.subprocess',
                     side_effect=AssertionError)
        fake = GitStrSubprocessFake()
        monkeypatch.setattr(version, '_git_str_subprocess', fake.func)
        return fake

    def test_frozen_ok(self, commit_file_mock, monkeypatch):
        """Test with sys.frozen=True and a successful git-commit-id read."""
        monkeypatch.setattr(version.sys, 'frozen', True, raising=False)
        commit_file_mock.return_value = 'deadbeef'
        assert version._git_str() == 'deadbeef'

    def test_frozen_oserror(self, caplog, commit_file_mock, monkeypatch):
        """Test with sys.frozen=True and OSError when reading git-commit-id."""
        monkeypatch.setattr(version.sys, 'frozen', True, raising=False)
        commit_file_mock.side_effect = OSError
        with caplog.at_level(logging.ERROR, 'misc'):
            assert version._git_str() is None

    @pytest.mark.not_frozen
    def test_normal_successful(self, git_str_subprocess_fake):
        """Test with git returning a successful result."""
        git_str_subprocess_fake.retval = 'c0ffeebabe'
        assert version._git_str() == 'c0ffeebabe'

    @pytest.mark.frozen
    def test_normal_successful_frozen(self, git_str_subprocess_fake):
        """Test with git returning a successful result."""
        # The value is defined in scripts/freeze_tests.py.
        assert version._git_str() == 'fake-frozen-git-commit'

    def test_normal_error(self, commit_file_mock, git_str_subprocess_fake):
        """Test without repo (but git-commit-id)."""
        git_str_subprocess_fake.retval = None
        commit_file_mock.return_value = '1b4d1dea'
        assert version._git_str() == '1b4d1dea'

    def test_normal_path_oserror(self, mocker, git_str_subprocess_fake,
                                 caplog):
        """Test with things raising OSError."""
        m = mocker.patch('qutebrowser.utils.version.os')
        m.path.join.side_effect = OSError
        mocker.patch('qutebrowser.utils.version.resources.read_file',
                     side_effect=OSError)
        with caplog.at_level(logging.ERROR, 'misc'):
            assert version._git_str() is None

    @pytest.mark.not_frozen
    def test_normal_path_nofile(self, monkeypatch, caplog,
                                git_str_subprocess_fake, commit_file_mock):
        """Test with undefined __file__ but available git-commit-id."""
        monkeypatch.delattr(version, '__file__')
        commit_file_mock.return_value = '0deadcode'
        with caplog.at_level(logging.ERROR, 'misc'):
            assert version._git_str() == '0deadcode'
        assert caplog.messages == ["Error while getting git path"]


def _has_git():
    """Check if git is installed."""
    try:
        subprocess.run(['git', '--version'], stdout=subprocess.DEVNULL,
                       stderr=subprocess.DEVNULL, check=True)
    except (OSError, subprocess.CalledProcessError):
        return False
    else:
        return True


# Decorator for tests needing git, so they get skipped when it's unavailable.
needs_git = pytest.mark.skipif(not _has_git(), reason='Needs git installed.')


class TestGitStrSubprocess:

    """Tests for _git_str_subprocess."""

    @pytest.fixture
    def git_repo(self, tmp_path):
        """A fixture to create a temporary git repo.

        Some things are tested against a real repo so we notice if something in
        git would change, or we call git incorrectly.
        """
        def _git(*args):
            """Helper closure to call git."""
            env = os.environ.copy()
            env.update({
                'GIT_AUTHOR_NAME': 'qutebrowser testsuite',
                'GIT_AUTHOR_EMAIL': 'mail@qutebrowser.org',
                'GIT_AUTHOR_DATE': 'Thu  1 Jan 01:00:00 CET 1970',
                'GIT_COMMITTER_NAME': 'qutebrowser testsuite',
                'GIT_COMMITTER_EMAIL': 'mail@qutebrowser.org',
                'GIT_COMMITTER_DATE': 'Thu  1 Jan 01:00:00 CET 1970',
            })
            if utils.is_windows:
                # If we don't call this with shell=True it might fail under
                # some environments on Windows...
                # https://bugs.python.org/issue24493
                subprocess.run(
                    'git -C "{}" {}'.format(tmp_path, ' '.join(args)),
                    env=env, check=True, shell=True)
            else:
                subprocess.run(
                    ['git', '-C', str(tmp_path)] + list(args),
                    check=True, env=env)

        (tmp_path / 'file').write_text("Hello World!", encoding='utf-8')
        _git('init')
        _git('add', 'file')
        _git('commit', '-am', 'foo', '--no-verify', '--no-edit',
             '--no-post-rewrite', '--quiet', '--no-gpg-sign')
        _git('tag', 'foobar')
        return tmp_path

    @needs_git
    def test_real_git(self, git_repo):
        """Test with a real git repository."""
        def _get_git_setting(name, default):
            return subprocess.run(
                ['git', 'config', '--default', default, name],
                check=True,
                stdout=subprocess.PIPE,
                encoding='utf-8',
            ).stdout.strip()

        ret = version._git_str_subprocess(str(git_repo))
        branch_name = _get_git_setting('init.defaultBranch', 'master')
        abbrev_length = int(_get_git_setting('core.abbrev', '7'))
        expected_sha = '6e4b65a529c0ab78fb370c1527d5809f7436b8f3'[:abbrev_length]

        assert ret == f'{expected_sha} on {branch_name} (1970-01-01 01:00:00 +0100)'

    def test_missing_dir(self, tmp_path):
        """Test with a directory which doesn't exist."""
        ret = version._git_str_subprocess(str(tmp_path / 'does-not-exist'))
        assert ret is None

    @pytest.mark.parametrize('exc', [
        OSError,
        subprocess.CalledProcessError(1, 'foobar')
    ])
    def test_exception(self, exc, mocker, tmp_path):
        """Test with subprocess.run raising an exception.

        Args:
            exc: The exception to raise.
        """
        m = mocker.patch('qutebrowser.utils.version.os')
        m.path.isdir.return_value = True
        mocker.patch('qutebrowser.utils.version.subprocess.run',
                     side_effect=exc)
        ret = version._git_str_subprocess(str(tmp_path))
        assert ret is None


class ReleaseInfoFake:

    """An object providing fakes for glob.glob/open for test_release_info.

    Attributes:
        _files: The files which should be returned, or None if an exception
        should be raised. A {filename: [lines]} dict.
    """

    def __init__(self, files):
        self._files = files

    def glob_fake(self, pattern):
        """Fake for glob.glob.

        Verifies the arguments and returns the files listed in self._files, or
        a single fake file if an exception is expected.
        """
        assert pattern == '/etc/*-release'
        if self._files is None:
            return ['fake-file']
        else:
            return sorted(self._files)

    @contextlib.contextmanager
    def open_fake(self, filename, mode, encoding):
        """Fake for open().

        Verifies the arguments and returns a StringIO with the content listed
        in self._files.
        """
        assert mode == 'r'
        assert encoding == 'utf-8'
        if self._files is None:
            raise OSError
        yield io.StringIO(''.join(self._files[filename]))


@pytest.mark.parametrize('files, expected', [
    # no files -> no output
    ({}, []),
    # empty files are stripped
    ({'file': ['']}, []),
    ({'file': []}, []),
    # newlines at EOL are stripped
    (
        {'file1': ['foo\n', 'bar\n'], 'file2': ['baz\n']},
        [('file1', 'foo\nbar'), ('file2', 'baz')]
    ),
    # blacklisted lines
    (
        {'file': ['HOME_URL=example.com\n', 'NAME=FOO']},
        [('file', 'NAME=FOO')]
    ),
    # only blacklisted lines
    ({'file': ['HOME_URL=example.com']}, []),
    # broken file
    (None, []),
])
def test_release_info(files, expected, caplog, monkeypatch):
    """Test _release_info().

    Args:
        files: The file dict passed to ReleaseInfoFake.
        expected: The expected _release_info output.
    """
    fake = ReleaseInfoFake(files)
    monkeypatch.setattr(version.glob, 'glob', fake.glob_fake)
    monkeypatch.setattr(version, 'open', fake.open_fake, raising=False)
    with caplog.at_level(logging.ERROR, 'misc'):
        assert version._release_info() == expected
    if files is None:
        assert caplog.messages == ["Error while reading fake-file."]


@pytest.mark.parametrize('equal', [True, False])
def test_path_info(monkeypatch, equal):
    """Test _path_info().

    Args:
        equal: Whether system data / data and system config / config are equal.
    """
    patches = {
        'config': lambda auto=False: (
            'AUTO CONFIG PATH' if auto and not equal
            else 'CONFIG PATH'),
        'data': lambda system=False: (
            'SYSTEM DATA PATH' if system and not equal
            else 'DATA PATH'),
        'cache': lambda: 'CACHE PATH',
        'runtime': lambda: 'RUNTIME PATH',
    }

    for name, val in patches.items():
        monkeypatch.setattr(version.standarddir, name, val)

    pathinfo = version._path_info()

    assert pathinfo['config'] == 'CONFIG PATH'
    assert pathinfo['data'] == 'DATA PATH'
    assert pathinfo['cache'] == 'CACHE PATH'
    assert pathinfo['runtime'] == 'RUNTIME PATH'

    if equal:
        assert 'auto config' not in pathinfo
        assert 'system data' not in pathinfo
    else:
        assert pathinfo['auto config'] == 'AUTO CONFIG PATH'
        assert pathinfo['system data'] == 'SYSTEM DATA PATH'


class TestModuleVersions:

    """Tests for _module_versions() and ModuleInfo."""

    @pytest.fixture
    def import_fake(self, stubs, monkeypatch):
        """Fixture to patch imports using ImportFake."""
        fake = stubs.ImportFake(dict.fromkeys(version.MODULE_INFO, True), monkeypatch)
        fake.patch()
        return fake

    @pytest.fixture(autouse=True)
    def importlib_metadata_mock(
        self, mocker: pytest_mock.MockerFixture
    ) -> unittest.mock.Mock:
        return mocker.patch("importlib.metadata.version", return_value="4.5.6")

    def test_all_present(self, import_fake):
        """Test with all modules present in a fixed version."""
        expected = []
        for name in import_fake.modules:
            version.MODULE_INFO[name]._reset_cache()
            if '__version__' not in version.MODULE_INFO[name]._version_attributes:
                expected.append(f"{name}: 4.5.6")  # from importlib.metadata
            else:
                expected.append(f"{name}: 1.2.3")
        assert version._module_versions() == expected

    @pytest.mark.parametrize('module, expected', [
        ('colorama', 'colorama: no'),
        ('adblock', 'adblock: no'),
    ])
    def test_missing_module(self, module, expected, import_fake):
        """Test with a module missing.

        Args:
            module: The name of the missing module.
            expected: The expected text.
        """
        import_fake.modules[module] = False
        # Needed after mocking the module
        mod_info = version.MODULE_INFO[module]
        mod_info._reset_cache()

        idx = list(version.MODULE_INFO).index(module)
        assert version._module_versions()[idx] == expected

        for method_name, expected_result in [
            ("is_installed", False),
            ("is_usable", False),
            ("get_version", None),
            ("is_outdated", None)
        ]:
            method = getattr(mod_info, method_name)
            # With hot cache
            mod_info._initialize_info()
            assert method() == expected_result
            # With cold cache
            mod_info._reset_cache()
            assert method() == expected_result

    def test_outdated_adblock(self, import_fake):
        """Test that warning is shown when adblock module is outdated."""
        mod_info = version.MODULE_INFO["adblock"]
        fake_version = "0.1.0"

        # Needed after mocking version attribute
        mod_info._reset_cache()

        assert mod_info.min_version is not None
        assert fake_version < mod_info.min_version
        import_fake.version = fake_version

        assert mod_info.is_installed()
        assert mod_info.is_outdated()
        assert not mod_info.is_usable()

        expected = f"adblock: {fake_version} (< {mod_info.min_version}, outdated)"
        idx = list(version.MODULE_INFO).index("adblock")
        assert version._module_versions()[idx] == expected

    def test_importlib_not_found(self, importlib_metadata_mock: unittest.mock.Mock):
        """Test with no __version__ attribute and missing importlib.metadata."""
        assert not version.MODULE_INFO["jinja2"]._version_attributes  # sanity check
        importlib_metadata_mock.side_effect = importlib.metadata.PackageNotFoundError
        version.MODULE_INFO["jinja2"]._reset_cache()
        idx = list(version.MODULE_INFO).index("jinja2")
        assert version._module_versions()[idx] == "jinja2: unknown"

    @pytest.mark.parametrize('attribute, expected_modules', [
        ('VERSION', ['colorama']),
        ('SIP_VERSION_STR', ['PyQt5.sip', 'PyQt6.sip']),
        (None, []),
    ])
    def test_version_attribute(self, attribute, expected_modules, import_fake):
        """Test with a different version attribute.

        VERSION is tested for old colorama versions, and None to make sure
        things still work if some package suddenly doesn't have __version__.

        Args:
            attribute: The name of the version attribute.
            expected_modules: The expected modules with that attribute.
        """
        import_fake.version_attribute = attribute

        for mod_info in version.MODULE_INFO.values():
            # Invalidate the "version cache" since we just mocked some of the
            # attributes.
            mod_info._reset_cache()

        expected = []
        for name in import_fake.modules:
            mod_info = version.MODULE_INFO[name]
            if name in expected_modules:
                assert mod_info.get_version() == "1.2.3"
                expected.append(f"{name}: 1.2.3")
            else:
                assert mod_info.get_version() == "4.5.6"  # from importlib.metadata
                expected.append(f"{name}: 4.5.6")

        assert version._module_versions() == expected

    @pytest.mark.parametrize('name, has_version', [
        ('sip', False),
        ('colorama', True),
        # jinja2: removed in 3.3
        ('pygments', True),
        ('yaml', True),
        ('adblock', True),
        ('dataclasses', False),
        ('objc', True),
    ])
    def test_existing_attributes(self, name, has_version):
        """Check if all dependencies have an expected __version__ attribute.

        The aim of this test is to fail if modules suddenly don't have a
        __version__ attribute anymore in a newer version.

        Args:
            name: The name of the module to check.
            has_version: Whether a __version__ attribute is expected.
        """
        module = pytest.importorskip(name)
        assert hasattr(module, '__version__') == has_version

    def test_existing_sip_attribute(self):
        """Test if sip has a SIP_VERSION_STR attribute.

        The aim of this test is to fail if that gets missing in some future
        version of sip.
        """
        from qutebrowser.qt import sip
        assert isinstance(sip.SIP_VERSION_STR, str)


class TestOsInfo:

    """Tests for _os_info."""

    @pytest.mark.fake_os('linux')
    def test_linux_fake(self, monkeypatch):
        """Test with a fake Linux.

        No args because osver is set to '' if the OS is linux.
        """
        monkeypatch.setattr(version, '_release_info',
                            lambda: [('releaseinfo', 'Hello World')])
        ret = version._os_info()
        expected = ['OS Version: ', '',
                    '--- releaseinfo ---', 'Hello World']
        assert ret == expected

    @pytest.mark.fake_os('windows')
    def test_windows_fake(self, monkeypatch):
        """Test with a fake Windows."""
        monkeypatch.setattr(version.platform, 'win32_ver',
                            lambda: ('eggs', 'bacon', 'ham', 'spam'))
        ret = version._os_info()
        expected = ['OS Version: eggs, bacon, ham, spam']
        assert ret == expected

    @pytest.mark.fake_os('mac')
    @pytest.mark.parametrize('mac_ver, mac_ver_str', [
        (('x', ('', '', ''), 'y'), 'x, y'),
        (('', ('', '', ''), ''), ''),
        (('x', ('1', '2', '3'), 'y'), 'x, 1.2.3, y'),
    ])
    def test_mac_fake(self, monkeypatch, mac_ver, mac_ver_str):
        """Test with a fake macOS.

        Args:
            mac_ver: The tuple to set platform.mac_ver() to.
            mac_ver_str: The expected Mac version string in version._os_info().
        """
        monkeypatch.setattr(version.platform, 'mac_ver', lambda: mac_ver)
        ret = version._os_info()
        expected = ['OS Version: {}'.format(mac_ver_str)]
        assert ret == expected

    @pytest.mark.fake_os('posix')
    def test_posix_fake(self, monkeypatch):
        """Test with a fake posix platform."""
        uname_tuple = ('PosixOS', 'localhost', '1.0', '1.0', 'i386', 'i386')
        monkeypatch.setattr(version.platform, 'uname', lambda: uname_tuple)
        ret = version._os_info()
        expected = ['OS Version: PosixOS localhost 1.0 1.0 i386 i386']
        assert ret == expected

    @pytest.mark.fake_os('unknown')
    def test_unknown_fake(self):
        """Test with a fake unknown platform."""
        ret = version._os_info()
        expected = ['OS Version: ?']
        assert ret == expected

    @pytest.mark.linux
    def test_linux_real(self):
        """Make sure there are no exceptions with a real Linux."""
        version._os_info()

    @pytest.mark.windows
    def test_windows_real(self):
        """Make sure there are no exceptions with a real Windows."""
        version._os_info()

    @pytest.mark.mac
    def test_mac_real(self):
        """Make sure there are no exceptions with a real macOS."""
        version._os_info()

    @pytest.mark.posix
    def test_posix_real(self):
        """Make sure there are no exceptions with a real posix."""
        version._os_info()


class TestPDFJSVersion:

    """Tests for _pdfjs_version."""

    def test_not_found(self, mocker):
        mocker.patch('qutebrowser.utils.version.pdfjs.get_pdfjs_res_and_path',
                     side_effect=pdfjs.PDFJSNotFound('/build/pdf.js'))
        assert version._pdfjs_version() == 'no'

    def test_unknown(self, monkeypatch):
        monkeypatch.setattr(
            'qutebrowser.utils.version.pdfjs.get_pdfjs_res_and_path',
            lambda path: (b'foobar', None))
        assert version._pdfjs_version() == 'unknown (bundled)'

    @pytest.mark.parametrize('varname', [
        'PDFJS.version',  # v1.10.100 and older
        'var pdfjsVersion',  # v2.0.943
        'const pdfjsVersion',  # v2.5.207
    ])
    def test_known(self, monkeypatch, varname):
        pdfjs_code = textwrap.dedent("""
            // Initializing PDFJS global object (if still undefined)
            if (typeof PDFJS === 'undefined') {
              (typeof window !== 'undefined' ? window : this).PDFJS = {};
            }

            VARNAME = '1.2.109';
            PDFJS.build = '875588d';

            (function pdfjsWrapper() {
              // Use strict in our context only - users might not want it
              'use strict';
        """.replace('VARNAME', varname)).strip().encode('utf-8')
        monkeypatch.setattr(
            'qutebrowser.utils.version.pdfjs.get_pdfjs_res_and_path',
            lambda path: (pdfjs_code, '/foo/bar/pdf.js'))
        assert version._pdfjs_version() == '1.2.109 (/foo/bar/pdf.js)'

    def test_real_file(self, data_tmpdir):
        """Test against the real file if pdfjs was found."""
        if not pdfjs.is_available():
            pytest.skip("No pdfjs found")
        ver = version._pdfjs_version()
        assert ver.split()[0] not in ['no', 'unknown'], ver


class TestWebEngineVersions:

    @pytest.mark.parametrize('version, expected', [
        (
            version.WebEngineVersions(
                webengine=utils.VersionNumber(5, 15, 2),
                chromium=None,
                source='UA'),
            (
                "QtWebEngine 5.15.2\n"
                "  (source: UA)"
            ),
        ),
        (
            version.WebEngineVersions(
                webengine=utils.VersionNumber(5, 15, 2),
                chromium='87.0.4280.144',
                source='UA'),
            (
                "QtWebEngine 5.15.2\n"
                "  based on Chromium 87.0.4280.144\n"
                "  (source: UA)"
            ),
        ),
        (
            version.WebEngineVersions(
                webengine=utils.VersionNumber(5, 15, 2),
                chromium='87.0.4280.144',
                source='faked'),
            (
                "QtWebEngine 5.15.2\n"
                "  based on Chromium 87.0.4280.144\n"
                "  (source: faked)"
            ),
        ),
        (
            version.WebEngineVersions(
                webengine=utils.VersionNumber(5, 15, 2),
                chromium='87.0.4280.144',
                chromium_security='9000.1',
                source='faked'),
            (
                "QtWebEngine 5.15.2\n"
                "  based on Chromium 87.0.4280.144\n"
                "  with security patches up to 9000.1 (plus any distribution patches)\n"
                "  (source: faked)"
            ),
        ),
    ])
    def test_str(self, version, expected):
        assert str(version) == expected

    @pytest.mark.parametrize('version, expected', [
        (
            version.WebEngineVersions(
                webengine=utils.VersionNumber(5, 15, 2),
                chromium=None,
                source='test'),
            None,
        ),
        (
            version.WebEngineVersions(
                webengine=utils.VersionNumber(5, 15, 2),
                chromium='87.0.4280.144',
                source='test'),
            87,
        ),
    ])
    def test_chromium_major(self, version, expected):
        assert version.chromium_major == expected

    def test_from_ua(self):
        ua = websettings.UserAgent(
            os_info='X11; Linux x86_64',
            webkit_version='537.36',
            upstream_browser_key='Chrome',
            upstream_browser_version='83.0.4103.122',
            qt_key='QtWebEngine',
            qt_version='5.15.2',
        )
        expected = version.WebEngineVersions(
            webengine=utils.VersionNumber(5, 15, 2),
            chromium='83.0.4103.122',
            chromium_security='86.0.4240.183',
            source='UA',
        )
        assert version.WebEngineVersions.from_ua(ua) == expected

    def test_from_elf(self):
        elf_version = elf.Versions(webengine='5.15.2', chromium='83.0.4103.122')
        expected = version.WebEngineVersions(
            webengine=utils.VersionNumber(5, 15, 2),
            chromium='83.0.4103.122',
            chromium_security='86.0.4240.183',
            source='ELF',
        )
        assert version.WebEngineVersions.from_elf(elf_version) == expected

    @pytest.mark.parametrize('pyqt_version, chromium_version, security_version', [
        ('5.15.2', '83.0.4103.122', '86.0.4240.183'),
        ('5.15.3', '87.0.4280.144', '88.0.4324.150'),
        ('5.15.4', '87.0.4280.144', None),
        ('5.15.5', '87.0.4280.144', None),
        ('5.15.6', '87.0.4280.144', None),
        ('5.15.7', '87.0.4280.144', '94.0.4606.61'),
        ('6.2.0', '90.0.4430.228', '93.0.4577.63'),
        ('6.2.99', '90.0.4430.228', None),
        ('6.3.0', '94.0.4606.126', '99.0.4844.84'),
        ('6.99.0', None, None),
    ])
    def test_from_pyqt(self, freezer, pyqt_version, chromium_version, security_version):
        if freezer and utils.VersionNumber(5, 15, 3) <= utils.VersionNumber.parse(pyqt_version) < utils.VersionNumber(6):
            chromium_version = '83.0.4103.122'
            security_version = '86.0.4240.183'
            expected_pyqt_version = '5.15.2'
        else:
            expected_pyqt_version = pyqt_version

        expected = version.WebEngineVersions(
            webengine=utils.VersionNumber.parse(expected_pyqt_version),
            chromium=chromium_version,
            chromium_security=security_version,
            source='PyQt',
        )
        assert version.WebEngineVersions.from_pyqt(pyqt_version) == expected

    def test_real_chromium_version(self, qapp):
        """Compare the inferred Chromium version with the real one."""
        try:
            # pylint: disable=unused-import
            from qutebrowser.qt.webenginecore import (
                qWebEngineVersion,
                qWebEngineChromiumVersion,
            )
        except ImportError:
            pass
        else:
            pytest.skip("API available to get the real version")

        pyqt_webengine_version = version._get_pyqt_webengine_qt_version()
        if pyqt_webengine_version is None:
            if '.dev' in PYQT_VERSION_STR:
                pytest.skip("dev version of PyQt")

            try:
                from qutebrowser.qt.webenginecore import (
                    PYQT_WEBENGINE_VERSION_STR, PYQT_WEBENGINE_VERSION)
            except ImportError as e:
                # QtWebKit
                pytest.skip(str(e))

            if 0x060000 > PYQT_WEBENGINE_VERSION >= 0x050F02:
                # Starting with Qt 5.15.2, we can only do bad guessing anyways...
                pytest.skip("Could be QtWebEngine 5.15.2 or 5.15.3")

            pyqt_webengine_version = PYQT_WEBENGINE_VERSION_STR

        versions = version.WebEngineVersions.from_pyqt(pyqt_webengine_version)
        inferred = versions.chromium

        webenginesettings.init_user_agent()
        real = webenginesettings.parsed_user_agent.upstream_browser_version

        assert inferred == real

    def test_real_chromium_security_version(self, qapp):
        """Check the API for reading the chromium security patch version."""
        try:
            from qutebrowser.qt.webenginecore import (
                qWebEngineChromiumVersion,
                qWebEngineChromiumSecurityPatchVersion,
            )
        except ImportError:
            pytest.skip("Requires QtWebEngine 6.3+")

        base = utils.VersionNumber.parse(qWebEngineChromiumVersion())
        security = utils.VersionNumber.parse(qWebEngineChromiumSecurityPatchVersion())
        assert security >= base

    def test_chromium_version_dict(self, qapp):
        """Check if we infer the QtWebEngine versions properly.

        Note this test mostly tests that our overview in version.py (also
        intended for human readers) is accurate. The code we call here is never
        going to be called in real-life situations, as the API is available.
        """
        try:
            from qutebrowser.qt.webenginecore import (
                qWebEngineVersion,
                qWebEngineChromiumVersion,
                qWebEngineChromiumSecurityPatchVersion,
            )
        except ImportError:
            pytest.skip("Requires QtWebEngine 6.3+")

        print(version.qtwebengine_versions())  # useful when adding new versions
        inferred = version.WebEngineVersions.from_webengine(
            qWebEngineVersion(), source="API")

        assert inferred.chromium == qWebEngineChromiumVersion()
        assert inferred.chromium_security == qWebEngineChromiumSecurityPatchVersion()


class FakeQSslSocket:

    """Fake for the QSslSocket Qt class.

    Attributes:
        _version: What QSslSocket::sslLibraryVersionString() should return.
        _support: Whether SSL is supported.
    """

    def __init__(self, version=None, support=True):
        self._version = version
        self._support = support

    def supportsSsl(self):
        """Fake for QSslSocket::supportsSsl()."""
        return self._support

    def sslLibraryVersionString(self):
        """Fake for QSslSocket::sslLibraryVersionString()."""
        if self._version is None:
            raise utils.Unreachable("Got called with version None!")
        return self._version


_QTWE_USER_AGENT = ("Mozilla/5.0 (X11; Linux x86_64) "
                    "AppleWebKit/537.36 (KHTML, like Gecko) "
                    "QtWebEngine/5.14.0 Chrome/{} Safari/537.36")


class TestChromiumVersion:

    @pytest.fixture(autouse=True)
    def clear_parsed_ua(self, monkeypatch):
        pytest.importorskip('qutebrowser.qt.webenginewidgets')
        if webenginesettings is not None:
            # Not available with QtWebKit
            monkeypatch.setattr(webenginesettings, 'parsed_user_agent', None)

    def test_fake_ua(self, monkeypatch, caplog, patch_no_api):
        ver = '77.0.3865.98'
        webenginesettings._init_user_agent_str(_QTWE_USER_AGENT.format(ver))

        assert version.qtwebengine_versions().chromium == ver

    def test_prefers_saved_user_agent(self, monkeypatch, patch_no_api):
        webenginesettings._init_user_agent_str(_QTWE_USER_AGENT.format('87'))
        monkeypatch.setattr(QWebEngineProfile, "defaultProfile", lambda: 1/0)
        version.qtwebengine_versions()

    def test_unpatched(self, qapp, cache_tmpdir, data_tmpdir, config_stub):
        assert version.qtwebengine_versions().chromium is not None

    def test_avoided(self, monkeypatch):
        versions = version.qtwebengine_versions(avoid_init=True)
        assert versions.source in ['api', 'ELF', 'importlib', 'PyQt', 'Qt']

    @pytest.fixture
    def patch_no_api(self, monkeypatch):
        """Simulate no PyQt API for getting its version."""
        monkeypatch.delattr(
            qutebrowser.qt.webenginecore,
            "qWebEngineVersion",
            raising=False,
        )

    @pytest.fixture
    def patch_elf_fail(self, monkeypatch):
        """Simulate parsing the version from ELF to fail."""
        monkeypatch.setattr(elf, 'parse_webenginecore', lambda: None)

    @pytest.fixture
    def importlib_patcher(self, monkeypatch):
        """Patch the importlib module."""
        def _patch(*, qt, qt5, qt6):
            def _fake_version(name):
                if name == 'PyQtWebEngine-Qt':
                    outcome = qt
                elif name == 'PyQtWebEngine-Qt5':
                    outcome = qt5
                elif name == 'PyQt6-WebEngine-Qt6':
                    outcome = qt6
                else:
                    raise utils.Unreachable(name)

                if outcome is None:
                    raise importlib.metadata.PackageNotFoundError(name)
                return outcome

            monkeypatch.setattr(importlib.metadata, 'version', _fake_version)

        return _patch

    @pytest.fixture
    def patch_importlib_no_package(self, importlib_patcher):
        """Simulate importlib not finding PyQtWebEngine Qt packages."""
        importlib_patcher(qt=None, qt5=None, qt6=None)

    @pytest.mark.parametrize('patches, sources', [
        (['no_api'], ['ELF', 'importlib', 'PyQt', 'Qt']),
        (['no_api', 'elf_fail'], ['importlib', 'PyQt', 'Qt']),
        (['no_api', 'elf_fail', 'importlib_no_package'], ['PyQt', 'Qt']),
    ], ids=','.join)
    def test_simulated(self, request, patches, sources):
        """Test various simulated error conditions.

        This dynamically gets a list of fixtures (above) to do the patching. It then
        checks whether the version it got is from one of the expected sources. Depending
        on the environment this test is run in, some sources might fail "naturally",
        i.e. without any patching related to them.
        """
        for patch in patches:
            request.getfixturevalue(f'patch_{patch}')

        versions = version.qtwebengine_versions(avoid_init=True)
        assert versions.source in sources

    @pytest.mark.parametrize('qt, qt5, qt6, expected', [
        pytest.param(
            None, None, '6.3.0',
            utils.VersionNumber(6, 3),
            marks=pytest.mark.qt6_only,
        ),
        pytest.param(
            '5.15.3', '5.15.4', '6.3.0',
            utils.VersionNumber(6, 3),
            marks=pytest.mark.qt6_only,
        ),

        pytest.param(
            None, '5.15.4', None,
            utils.VersionNumber(5, 15, 4),
            marks=pytest.mark.qt5_only,
        ),
        pytest.param(
            '5.15.3', None, None,
            utils.VersionNumber(5, 15, 3),
            marks=pytest.mark.qt5_only,
        ),
        # -Qt5 takes precedence
        pytest.param(
            '5.15.3', '5.15.4', None,
            utils.VersionNumber(5, 15, 4),
            marks=pytest.mark.qt5_only,
        ),
    ])
    def test_importlib(self, qt, qt5, qt6, expected, patch_elf_fail, patch_no_api, importlib_patcher):
        """Test the importlib version logic with different Qt packages.

        With PyQtWebEngine 5.15.4, PyQtWebEngine-Qt was renamed to PyQtWebEngine-Qt5.
        """
        importlib_patcher(qt=qt, qt5=qt5, qt6=qt6)
        versions = version.qtwebengine_versions(avoid_init=True)
        assert versions.source == 'importlib'
        assert versions.webengine == expected

    @pytest.mark.parametrize('override', [
        utils.VersionNumber(5, 12, 10),
        utils.VersionNumber(5, 15, 3),
    ])
    @pytest.mark.parametrize('avoid_init', [True, False])
    def test_override(self, monkeypatch, override, avoid_init):
        monkeypatch.setenv('QUTE_QTWEBENGINE_VERSION_OVERRIDE', str(override))
        versions = version.qtwebengine_versions(avoid_init=avoid_init)
        assert versions.source == 'override'
        assert versions.webengine == override


class FakeExtensionInfo:
    def __init__(
        self,
        name: str,
        *,
        enabled: bool = False,
        installed: bool = False,
        loaded: bool = False,
        action_popup_url: QUrl = QUrl(),
    ) -> None:
        self._name = name
        self.enabled = enabled
        self.installed = installed
        self.loaded = loaded
        self.action_popup_url = action_popup_url

    def isEnabled(self) -> bool:
        return self.enabled

    def isInstalled(self) -> bool:
        return self.installed

    def isLoaded(self) -> bool:
        return self.loaded

    def name(self) -> str:
        return self._name

    def actionPopupUrl(self) -> QUrl:
        return self.action_popup_url

    def path(self) -> str:
        return f"{self._name}-path"

    def id(self) -> str:
        return f"{self._name}-id"


class FakeExtensionManager:

    def __init__(self, extensions: list[FakeExtensionInfo]) -> None:
        self._extensions = extensions

    def extensions(self) -> list[FakeExtensionInfo]:
        return self._extensions


class FakeExtensionProfile:

    def __init__(self, ext_manager: FakeExtensionManager) -> None:
        self._ext_manager = ext_manager

    def extensionManager(self) -> FakeExtensionManager:
        return self._ext_manager


@dataclasses.dataclass
class VersionParams:

    name: str
    gui_platform: str = 'GUI_PLATFORM'
    git_commit: bool = True
    frozen: bool = False
    qapp: bool = True
    with_webkit: bool = True
    known_distribution: bool = True
    ssl_support: bool = True
    autoconfig_loaded: bool = True
    config_py_loaded: bool = True


@pytest.mark.parametrize('params', [
    VersionParams('normal'),
    VersionParams('no-git-commit', git_commit=False),
    VersionParams('frozen', frozen=True),
    VersionParams('no-qapp', qapp=False),
    VersionParams('no-webkit', with_webkit=False),
    VersionParams('unknown-dist', known_distribution=False),
    VersionParams('no-ssl', ssl_support=False),
    VersionParams('no-autoconfig-loaded', autoconfig_loaded=False),
    VersionParams('no-config-py-loaded', config_py_loaded=False),
    VersionParams('xcb-platform', gui_platform='xcb'),
    VersionParams('wayland-platform', gui_platform='wayland'),
], ids=lambda param: param.name)
def test_version_info(params, stubs, monkeypatch, config_stub):
    """Test version.version_info()."""
    config.instance.config_py_loaded = params.config_py_loaded
    import_path = pathlib.Path('/IMPORTPATH').resolve()

    patches = {
        'qutebrowser.__file__': str(import_path / '__init__.py'),
        'qutebrowser.__version__': 'VERSION',
        '_git_str': lambda: ('GIT COMMIT' if params.git_commit else None),
        'platform.python_implementation': lambda: 'PYTHON IMPLEMENTATION',
        'platform.python_version': lambda: 'PYTHON VERSION',
        'sys.executable': 'EXECUTABLE PATH',
        'PYQT_VERSION_STR': 'PYQT VERSION',
        'earlyinit.qt_version': lambda: 'QT VERSION',
        '_module_versions': lambda: ['MODULE VERSION 1', 'MODULE VERSION 2'],
        '_pdfjs_version': lambda: 'PDFJS VERSION',
        'QSslSocket': FakeQSslSocket('SSL VERSION', params.ssl_support),
        'platform.platform': lambda: 'PLATFORM',
        'platform.architecture': lambda: ('ARCHITECTURE', ''),
        'wmname.x11_wm_name': lambda: 'X11 WM NAME',
        'wmname.wayland_compositor_name': lambda: 'WAYLAND COMPOSITOR NAME',
        '_os_info': lambda: ['OS INFO 1', 'OS INFO 2'],
        '_path_info': lambda: {'PATH DESC': 'PATH NAME'},
        'objects.qapp': (
            stubs.FakeQApplication(style='STYLE', platform_name=params.gui_platform)
            if params.qapp
            else None
        ),
        'qtutils.library_path': (lambda _loc: 'QT PATH'),
        'sql.version': lambda: 'SQLITE VERSION',
        '_uptime': lambda: datetime.timedelta(hours=1, minutes=23, seconds=45),
        'config.instance.yaml_loaded': params.autoconfig_loaded,
        'machinery.INFO': machinery.SelectionInfo(
            wrapper='QT WRAPPER',
            reason=machinery.SelectionReason.fake
        ),
    }

    version.opengl_info.cache_clear()
    monkeypatch.setenv('QUTE_FAKE_OPENGL', 'VENDOR, 1.0 VERSION')

    if not params.qapp:
        expected_gui_platform = None
    elif params.gui_platform == 'GUI_PLATFORM':
        expected_gui_platform = 'GUI_PLATFORM'
    elif params.gui_platform == 'xcb':
        expected_gui_platform = 'xcb (X11 WM NAME)'
    elif params.gui_platform == 'wayland':
        expected_gui_platform = 'wayland (WAYLAND COMPOSITOR NAME)'
    else:
        raise utils.Unreachable(params.gui_platform)

    substitutions = {
        'git_commit': '\nGit commit: GIT COMMIT' if params.git_commit else '',
        'style': '\nStyle: STYLE' if params.qapp else '',
        'platform_plugin': (
            f'\nQt Platform: {expected_gui_platform}' if params.qapp else ''
        ),
        'opengl': '\nOpenGL: VENDOR, 1.0 VERSION' if params.qapp else '',
        'qt': 'QT VERSION',
        'frozen': str(params.frozen),
        'import_path': import_path,
        'python_path': 'EXECUTABLE PATH',
        'uptime': "1:23:45",
        'autoconfig_loaded': "yes" if params.autoconfig_loaded else "no",
        'webextensions': "",  # overridden below if QtWebEngine is used
    }

    patches['qtwebengine_versions'] = (
        lambda avoid_init: version.WebEngineVersions(
            webengine=utils.VersionNumber(1, 2, 3),
            chromium=None,
            source='faked',
        )
    )

    if params.config_py_loaded:
        substitutions["config_py_loaded"] = "{} has been loaded".format(
            standarddir.config_py())
    else:
        substitutions["config_py_loaded"] = "no config.py was loaded"

    if params.with_webkit:
        patches['qWebKitVersion'] = lambda: 'WEBKIT VERSION'
        patches['objects.backend'] = usertypes.Backend.QtWebKit
        substitutions['backend'] = 'new QtWebKit (WebKit WEBKIT VERSION)'
    else:
        monkeypatch.delattr(version, 'qtutils.qWebKitVersion', raising=False)
        if machinery.IS_QT6:
            monkeypatch.setattr(
                webenginesettings,
                "default_profile",
                FakeExtensionProfile(FakeExtensionManager([FakeExtensionInfo("ext1")])),
            )
            substitutions['webextensions'] = (
                "\n"
                "WebExtensions:\n"
                "  ext1 (ext1-id)\n"
                "  [ ] enabled  [ ] loaded  [ ] installed\n"
                "  ext1-path\n"
            )
        patches['objects.backend'] = usertypes.Backend.QtWebEngine
        substitutions['backend'] = 'QtWebEngine 1.2.3\n  (source: faked)'

    if params.known_distribution:
        patches['distribution'] = lambda: version.DistributionInfo(
            parsed=version.Distribution.arch, pretty='LINUX DISTRIBUTION', id='arch')
        substitutions['linuxdist'] = ('\nLinux distribution: '
                                      'LINUX DISTRIBUTION (arch)')
        substitutions['osinfo'] = ''
    else:
        patches['distribution'] = lambda: None
        substitutions['linuxdist'] = ''
        substitutions['osinfo'] = 'OS INFO 1\nOS INFO 2\n'

    substitutions['ssl'] = 'SSL VERSION' if params.ssl_support else 'no'

    for name, val in patches.items():
        monkeypatch.setattr(f'qutebrowser.utils.version.{name}', val)

    if params.frozen:
        monkeypatch.setattr(sys, 'frozen', True, raising=False)
    else:
        monkeypatch.delattr(sys, 'frozen', raising=False)

    template = version._LOGO.lstrip('\n') + textwrap.dedent("""
        qutebrowser vVERSION{git_commit}
        Backend: {backend}
        Qt: {qt}

        PYTHON IMPLEMENTATION: PYTHON VERSION
        PyQt: PYQT VERSION

        Qt wrapper: QT WRAPPER (via fake)

        MODULE VERSION 1
        MODULE VERSION 2
        pdf.js: PDFJS VERSION
        sqlite: SQLITE VERSION
        QtNetwork SSL: {ssl}
        {webextensions}{style}{platform_plugin}{opengl}
        Platform: PLATFORM, ARCHITECTURE{linuxdist}
        Frozen: {frozen}
        Imported from {import_path}
        Using Python from {python_path}
        Qt library executable path: QT PATH, data path: QT PATH
        {osinfo}
        Paths:
        PATH DESC: PATH NAME

        Autoconfig loaded: {autoconfig_loaded}
        Config.py: {config_py_loaded}
        Uptime: {uptime}
    """.lstrip('\n'))

    expected = template.rstrip('\n').format(**substitutions)
    assert version.version_info() == expected


class TestOpenGLInfo:

    @pytest.fixture(autouse=True)
    def cache_clear(self):
        """Clear the lru_cache between tests."""
        version.opengl_info.cache_clear()

    def test_func(self, qapp):
        """Simply call version.opengl_info() and see if it doesn't crash."""
        pytest.importorskip("qutebrowser.qt.opengl")
        version.opengl_info()

    def test_func_fake(self, qapp, monkeypatch):
        monkeypatch.setenv('QUTE_FAKE_OPENGL', 'Outtel Inc., 3.0 Messiah 20.0')
        info = version.opengl_info()
        assert info.vendor == 'Outtel Inc.'
        assert info.version_str == '3.0 Messiah 20.0'
        assert info.version == (3, 0)
        assert info.vendor_specific == 'Messiah 20.0'

    @pytest.mark.parametrize('version_str, reason', [
        ('blah', 'missing space'),
        ('2,x blah', 'parsing int'),
    ])
    def test_parse_invalid(self, caplog, version_str, reason):
        with caplog.at_level(logging.WARNING):
            info = version.OpenGLInfo.parse(vendor="vendor",
                                            version=version_str)

        assert info.version is None
        assert info.vendor_specific is None
        assert info.vendor == 'vendor'
        assert info.version_str == version_str

        msg = "Failed to parse OpenGL version ({}): {}".format(
            reason, version_str)
        assert caplog.messages == [msg]

    @hypothesis.given(vendor=hypothesis.strategies.text(),
                      version_str=hypothesis.strategies.text())
    def test_parse_hypothesis(self, caplog, vendor, version_str):
        with caplog.at_level(logging.WARNING):
            info = version.OpenGLInfo.parse(vendor=vendor, version=version_str)

        assert info.vendor == vendor
        assert info.version_str == version_str
        assert vendor in str(info)
        assert version_str in str(info)

    @pytest.mark.parametrize('version_str, expected', [
        ("2.1 INTEL-10.36.26", (2, 1)),
        ("4.6 (Compatibility Profile) Mesa 20.0.7", (4, 6)),
        ("3.0 Mesa 20.0.7", (3, 0)),
        ("3.0 Mesa 20.0.6", (3, 0)),
        # Not from the wild, but can happen according to standards
        ("3.0.2 Mesa 20.0.6", (3, 0, 2)),
    ])
    def test_version(self, version_str, expected):
        info = version.OpenGLInfo.parse(vendor='vendor', version=version_str)
        assert info.version == expected

    def test_str_gles(self):
        info = version.OpenGLInfo(gles=True)
        assert str(info) == 'OpenGL ES'


@pytest.mark.skipif(
    not machinery.IS_QT6, reason="extensions are only available with Qt6"
)
class TestWebEngineExtensions:

    def test_qtwebkit(self, monkeypatch: pytest.MonkeyPatch) -> None:
        assert webenginesettings.default_profile is None  # -> default_qt_profile() used
        monkeypatch.setattr(version.objects, "backend", usertypes.Backend.QtWebKit)
        monkeypatch.setattr(webenginesettings, "default_qt_profile", lambda: 1 / 0)
        assert not version._webengine_extensions()

    def test_avoid_chromium_init(self, monkeypatch: pytest.MonkeyPatch) -> None:
        assert webenginesettings.default_profile is None  # -> default_qt_profile() used
        monkeypatch.setattr(version.objects, "backend", usertypes.Backend.QtWebEngine)
        monkeypatch.setattr(objects, "debug_flags", {"avoid-chromium-init"})
        monkeypatch.setattr(webenginesettings, "default_qt_profile", lambda: 1 / 0)
        assert version._webengine_extensions() == [
            "WebExtensions: unknown (avoiding init)"
        ]

    def test_no_extension_manager(self, monkeypatch: pytest.MonkeyPatch) -> None:
        assert webenginesettings.default_profile is None  # -> default_qt_profile() used
        monkeypatch.setattr(webenginesettings, "default_qt_profile", object)
        assert not version._webengine_extensions()

    @pytest.mark.parametrize("avoid_init", [True, False])
    def test_preexisting_profile(self, monkeypatch: pytest.MonkeyPatch, avoid_init: bool) -> None:
        """Test that we use the pre-existing profile if available."""
        monkeypatch.setattr(webenginesettings, "default_profile", FakeExtensionProfile(FakeExtensionManager([])))
        if avoid_init:
            monkeypatch.setattr(objects, "debug_flags", {"avoid-chromium-init"})

        result = version._webengine_extensions()
        assert result == ["WebExtensions: none"]

    @pytest.mark.parametrize(
        "extensions, expected",
        [
            pytest.param([], ["WebExtensions: none"], id="empty"),
            pytest.param(
                [FakeExtensionInfo("ext1")],
                [
                    "WebExtensions:",
                    "  ext1 (ext1-id)",
                    "  [ ] enabled  [ ] loaded  [ ] installed",
                    "  ext1-path",
                    "",
                ],
                id="single",
            ),
            pytest.param(
                [
                    FakeExtensionInfo("ext1", enabled=True),
                    FakeExtensionInfo(
                        "ext2", enabled=True, loaded=True, installed=True
                    ),
                ],
                [
                    "WebExtensions:",
                    "  ext1 (ext1-id)",
                    "  [x] enabled  [ ] loaded  [ ] installed",
                    "  ext1-path",
                    "",
                    "  ext2 (ext2-id)",
                    "  [x] enabled  [x] loaded  [x] installed",
                    "  ext2-path",
                    "",
                ],
                id="multiple",
            ),
            pytest.param(
                [
                    FakeExtensionInfo(
                        "ext", action_popup_url=QUrl("chrome-extension://ext")
                    )
                ],
                [
                    "WebExtensions:",
                    "  ext (ext-id)",
                    "  [ ] enabled  [ ] loaded  [ ] installed",
                    "  ext-path",
                    "  chrome-extension://ext",
                    "",
                ],
                id="with-url",
            ),
        ],
    )
    def test_extensions(
        self,
        monkeypatch: pytest.MonkeyPatch,
        extensions: list[FakeExtensionInfo],
        expected: list[str],
    ) -> None:
        monkeypatch.setattr(
            webenginesettings,
            "default_profile",
            FakeExtensionProfile(FakeExtensionManager(extensions)),
        )
        assert version._webengine_extensions() == expected


@pytest.fixture
def pbclient(stubs):
    http_stub = stubs.HTTPPostStub()
    client = pastebin.PastebinClient(http_stub)
    yield client
    version.pastebin_url = None


def test_pastebin_version(pbclient, message_mock, monkeypatch, qtbot):
    """Test version.pastebin_version() sets the url."""
    monkeypatch.setattr(version, 'version_info', lambda: 'dummy')
    monkeypatch.setattr(utils, 'log_clipboard', True)

    version.pastebin_version(pbclient)
    pbclient.success.emit("https://www.example.com/\n")

    msg = message_mock.getmsg(usertypes.MessageLevel.info)
    expected_text = "Version url https://www.example.com/ yanked to clipboard."
    assert msg.text == expected_text
    assert version.pastebin_url == "https://www.example.com/"


def test_pastebin_version_twice(pbclient, monkeypatch):
    """Test whether calling pastebin_version twice sends no data."""
    monkeypatch.setattr(version, 'version_info', lambda: 'dummy')

    version.pastebin_version(pbclient)
    pbclient.success.emit("https://www.example.com/\n")

    pbclient.url = None
    pbclient.data = None
    version.pastebin_url = "https://www.example.org/"

    version.pastebin_version(pbclient)
    assert pbclient.url is None
    assert pbclient.data is None
    assert version.pastebin_url == "https://www.example.org/"


def test_pastebin_version_error(pbclient, caplog, message_mock, monkeypatch):
    """Test version.pastebin_version() with errors."""
    monkeypatch.setattr(version, 'version_info', lambda: 'dummy')

    version.pastebin_url = None
    with caplog.at_level(logging.ERROR):
        version.pastebin_version(pbclient)
        pbclient._client.error.emit("test")

    assert version.pastebin_url is None

    msg = message_mock.getmsg(usertypes.MessageLevel.error)
    assert msg.text == "Failed to pastebin version info: test"


@pytest.mark.parametrize("platform, expected", [
    ("windows", "windows"),
    ("xcb", "xcb (X11 WM NAME)"),
    ("wayland", "wayland (WAYLAND COMPOSITOR NAME)"),
    ("wayland-egl", "wayland-egl (WAYLAND COMPOSITOR NAME)"),
])
def test_gui_platform_info(
    platform: str, expected: str, monkeypatch: pytest.MonkeyPatch, stubs: Any
) -> None:
    monkeypatch.setattr(
        version.objects,
        "qapp",
        stubs.FakeQApplication(platform_name=platform, style="STYLE"),
    )
    monkeypatch.setattr(version.wmname, "x11_wm_name", lambda: "X11 WM NAME")
    monkeypatch.setattr(
        version.wmname, "wayland_compositor_name", lambda: "WAYLAND COMPOSITOR NAME"
    )
    assert version.gui_platform_info() == expected


@pytest.mark.parametrize("platform", ["xcb", "wayland", "wayland-egl"])
def test_gui_platform_info_error(
    platform: str,
    monkeypatch: pytest.MonkeyPatch,
    mocker: pytest_mock.MockerFixture,
    stubs: Any,
) -> None:
    monkeypatch.setattr(
        version.objects,
        "qapp",
        stubs.FakeQApplication(platform_name=platform, style="STYLE"),
    )
    mocker.patch.object(wmname, "x11_wm_name", side_effect=wmname.Error("fake error"))
    mocker.patch.object(
        wmname, "wayland_compositor_name", side_effect=wmname.Error("fake error")
    )
    assert version.gui_platform_info() == f"{platform} (Error: fake error)"


def test_uptime(monkeypatch, qapp):
    """Test _uptime runs and check if microseconds are dropped."""
    monkeypatch.setattr(objects, 'qapp', qapp)

    launch_time = datetime.datetime(1, 1, 1, 1, 1, 1, 1)
    monkeypatch.setattr(qapp, "launch_time", launch_time, raising=False)

    class FakeDateTime(datetime.datetime):

        @classmethod
        def now(cls, tz=None):
            return datetime.datetime(1, 1, 1, 1, 1, 1, 2)

    monkeypatch.setattr(datetime, 'datetime', FakeDateTime)

    uptime_delta = version._uptime()
    assert uptime_delta == datetime.timedelta(0)
