qutebrowser/tests/unit/javascript/test_greasemonkey.py

578 lines
18 KiB
Python

# SPDX-FileCopyrightText: Florian Bruhin (The Compiler) <mail@qutebrowser.org>
#
# SPDX-License-Identifier: GPL-3.0-or-later
"""Tests for qutebrowser.browser.greasemonkey."""
import textwrap
import logging
import pathlib
import pytest
from qutebrowser.qt.core import QUrl
from qutebrowser.utils import usertypes, version
from qutebrowser.browser import greasemonkey
from qutebrowser.misc import objects
test_gm_script = r"""
// ==UserScript==
// @name qutebrowser test userscript
// @namespace invalid.org
// @include http://localhost:*/data/title.html
// @match http://*.trolol.com/*
// @exclude https://badhost.xxx/*
// @run-at document-start
// ==/UserScript==
console.log("Script is running.");
"""
pytestmark = [
pytest.mark.usefixtures('data_tmpdir'),
pytest.mark.usefixtures('config_tmpdir')
]
@pytest.fixture
def gm_manager(monkeypatch) -> greasemonkey.GreasemonkeyManager:
gmm = greasemonkey.GreasemonkeyManager()
monkeypatch.setattr(greasemonkey, "gm_manager", gmm)
return gmm
@pytest.fixture
def wrong_path_setup():
wrong_path = _scripts_dir() / "test1.user.js"
wrong_path.mkdir()
_save_script(test_gm_script, "test2.user.js")
def _scripts_dir() -> pathlib.Path:
p = pathlib.Path(greasemonkey._scripts_dirs()[0])
p.mkdir(exist_ok=True)
return p
def _save_script(script_text: str, filename: str) -> None:
file_path = _scripts_dir() / filename
file_path.write_text(script_text, encoding='utf-8')
def test_all(gm_manager):
"""Test that a script gets read from file, parsed and returned."""
name = "qutebrowser test userscript"
_save_script(test_gm_script, 'test.user.js')
result = gm_manager.load_scripts()
assert len(result.successful) == 1
assert result.successful[0].name == name
assert not result.errors
all_scripts = gm_manager.all_scripts()
assert len(all_scripts) == 1
assert all_scripts[0].name == name
@pytest.mark.parametrize("header, expected", [
# defaults
(
[],
{
"name": "test.user.js",
"namespace": None,
"includes": ['*'],
"matches": [],
"excludes": [],
"run_at": None,
"runs_on_sub_frames": True,
"jsworld": "main",
}
),
# include/exclude/match
(
["@include https://example.org"],
{
"includes": ['https://example.org'],
"excludes": [],
"matches": [],
}
),
(
["@include https://example.org", "@include https://example.com"],
{
"includes": ['https://example.org', 'https://example.com'],
"excludes": [],
"matches": [],
}
),
(
["@match https://example.org"],
{"includes": [], "excludes": [], "matches": ['https://example.org']}
),
(
["@match https://example.org", "@exclude_match https://example.com"],
{
"includes": [],
"excludes": ['https://example.com'],
"matches": ['https://example.org'],
}
),
(
["@exclude https://example.org"],
{"includes": ['*'], "excludes": ['https://example.org'], "matches": []}
),
(
["@exclude https://example.org", "@exclude_match https://example.com"],
{
"includes": ['*'],
"excludes": ['https://example.org', 'https://example.com'],
"matches": [],
}
),
# name / namespace
(["@name testfoo"], {"name": "testfoo", "namespace": None}),
(["@namespace testbar"], {"name": "test.user.js", "namespace": "testbar"}),
(
["@name testfoo", "@namespace testbar"],
{"name": "testfoo", "namespace": "testbar"},
),
# description
(
["@description Replace ads by cat pictures"],
{"description": "Replace ads by cat pictures"},
),
# noframes
(["@noframes"], {"runs_on_sub_frames": False}),
(["@noframes blabla"], {"runs_on_sub_frames": False}), # FIXME intended?
# requires
(["@require stuff.js"], {"requires": ["stuff.js"]}),
# qute-js-world
(["@qute-js-world main"], {"jsworld": "main"}),
])
def test_attributes(header, expected):
lines = [
"// ==UserScript==",
*(f'// {line}' for line in header),
"// ==/UserScript==",
"console.log('Hello World')",
]
source = "\n".join(lines)
print(source)
script = greasemonkey.GreasemonkeyScript.parse(source, filename="test.user.js")
actual = {k: getattr(script, k) for k in expected}
assert actual == expected
def test_load_error(gm_manager, wrong_path_setup):
"""Test behavior when a script fails loading."""
name = "qutebrowser test userscript"
result = gm_manager.load_scripts()
assert len(result.successful) == 1
assert result.successful[0].name == name
assert len(result.errors) == 1
assert result.errors[0][0] == "test1.user.js"
all_scripts = gm_manager.all_scripts()
assert len(all_scripts) == 1
assert all_scripts[0].name == name
@pytest.mark.parametrize("url, expected_matches", [
# included
('http://trolol.com/', 1),
# neither included nor excluded
('http://aaaaaaaaaa.com/', 0),
# excluded
('https://badhost.xxx/', 0),
])
def test_get_scripts_by_url(gm_manager, url, expected_matches):
"""Check Greasemonkey include/exclude rules work."""
_save_script(test_gm_script, 'test.user.js')
gm_manager.load_scripts()
scripts = gm_manager.scripts_for(QUrl(url))
assert len(scripts.start + scripts.end + scripts.idle) == expected_matches
@pytest.mark.parametrize("url, expected_matches", [
# included
('https://github.com/qutebrowser/qutebrowser/', 1),
# neither included nor excluded
('http://aaaaaaaaaa.com/', 0),
# excluded takes priority
('http://github.com/foo', 0),
])
def test_regex_includes_scripts_for(gm_manager, url, expected_matches):
"""Ensure our GM @*clude support supports regular expressions."""
gh_dark_example = textwrap.dedent(r"""
// ==UserScript==
// @include /^https?://((gist|guides|help|raw|status|developer)\.)?github\.com/((?!generated_pages\/preview).)*$/
// @exclude /https?://github\.com/foo/
// @run-at document-start
// ==/UserScript==
""")
_save_script(gh_dark_example, 'test.user.js')
gm_manager.load_scripts()
scripts = gm_manager.scripts_for(QUrl(url))
assert len(scripts.start + scripts.end + scripts.idle) == expected_matches
def test_no_metadata(gm_manager, 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.load_scripts()
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_no_name():
"""Ensure that GreaseMonkeyScripts must have a name."""
msg = "@name key required or pass filename to init."
with pytest.raises(ValueError, match=msg):
greasemonkey.GreasemonkeyScript([("something", "else")], "")
def test_no_name_with_fallback():
"""Ensure that script's name can fallback to the provided filename."""
script = greasemonkey.GreasemonkeyScript(
[("something", "else")], "", filename=r"C:\COM1")
assert script
assert script.name == r"C:\COM1"
@pytest.mark.parametrize('properties, inc_counter, expected', [
([("name", "gorilla")], False, "GM-gorilla"),
([("namespace", "apes"), ("name", "gorilla")], False, "GM-apes/gorilla"),
([("name", "gorilla")], True, "GM-gorilla-2"),
([("namespace", "apes"), ("name", "gorilla")], True, "GM-apes/gorilla-2"),
])
def test_full_name(properties, inc_counter, expected):
script = greasemonkey.GreasemonkeyScript(properties, code="")
if inc_counter:
script.dedup_suffix += 1
assert script.full_name() == expected
def test_bad_scheme(gm_manager, 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.load_scripts()
scripts = gm_manager.scripts_for(QUrl('qute://settings'))
assert len(scripts.start + scripts.end + scripts.idle) == 0
def test_load_emits_signal(gm_manager, qtbot):
gm_manager.load_scripts()
with qtbot.wait_signal(gm_manager.scripts_reloaded):
gm_manager.load_scripts()
def test_utf8_bom(gm_manager):
"""Make sure UTF-8 BOMs are stripped from scripts.
If we don't strip them, we'll have a BOM in the middle of the file, causing
QtWebEngine to not catch the "// ==UserScript==" line.
"""
script = textwrap.dedent("""
\N{BYTE ORDER MARK}// ==UserScript==
// @name qutebrowser test userscript
// ==/UserScript==
""".lstrip('\n'))
_save_script(script, 'bom.user.js')
gm_manager.load_scripts()
scripts = gm_manager.all_scripts()
assert len(scripts) == 1
script = scripts[0]
assert '// ==UserScript==' in script.code().splitlines()
class TestForceDocumentEnd:
def _get_script(self, *, namespace, name):
source = textwrap.dedent("""
// ==UserScript==
// @namespace {}
// @name {}
// ==/UserScript==
""".format(namespace, name))
_save_script(source, 'force.user.js')
gm_manager = greasemonkey.GreasemonkeyManager()
gm_manager.load_scripts()
scripts = gm_manager.all_scripts()
assert len(scripts) == 1
return scripts[0]
@pytest.mark.parametrize('namespace, name, force', [
('http://userstyles.org', 'foobar', True),
('https://github.com/ParticleCore', 'Iridium', True),
('https://github.com/ParticleCore', 'Foo', False),
('https://example.org', 'Iridium', False),
])
def test_matching(self, monkeypatch, namespace, name, force):
"""Test matching based on namespace/name."""
monkeypatch.setattr(objects, 'backend', usertypes.Backend.QtWebEngine)
script = self._get_script(namespace=namespace, name=name)
assert script.needs_document_end_workaround() == force
@pytest.mark.parametrize('namespace, name', [
('http://userstyles.org', 'foobar'),
('https://github.com/ParticleCore', 'Iridium'),
('https://github.com/ParticleCore', 'Foo'),
('https://example.org', 'Iridium'),
])
def test_webkit(self, monkeypatch, namespace, name):
monkeypatch.setattr(objects, 'backend', usertypes.Backend.QtWebKit)
script = self._get_script(namespace=namespace, name=name)
assert not script.needs_document_end_workaround()
def test_required_scripts_are_included(gm_manager, download_stub, tmp_path):
test_require_script = textwrap.dedent("""
// ==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
// @require http://localhost/test.js
// ==/UserScript==
console.log("Script is running.");
""")
_save_script(test_require_script, 'requiring.user.js')
(tmp_path / 'test.js').write_text('REQUIRED SCRIPT', encoding='UTF-8')
gm_manager.load_scripts()
assert len(gm_manager._in_progress_dls) == 1
for download in gm_manager._in_progress_dls:
download.finished.emit()
scripts = gm_manager.all_scripts()
assert len(scripts) == 1
assert "REQUIRED SCRIPT" in scripts[0].code()
# Additionally check that the base script is still being parsed correctly
assert "Script is running." in scripts[0].code()
assert scripts[0].excludes
def test_window_isolation(js_tester, request):
"""Check that greasemonkey scripts get a shadowed global scope."""
# Change something in the global scope
setup_script = "window.$ = 'global'"
# Greasemonkey script to report back on its scope.
test_gm_script = greasemonkey.GreasemonkeyScript.parse(
textwrap.dedent("""
// ==UserScript==
// @name scopetest
// ==/UserScript==
// Check the thing the page set is set to the expected type
result.push(window.$);
result.push($);
// Now overwrite it
window.$ = 'shadowed';
// And check everything is how the script would expect it to be
// after just writing to the "global" scope
result.push(window.$);
result.push($);
""")
)
# The compiled source of that scripts with some additional setup
# bookending it.
test_script = "\n".join([
"""
const result = [];
""",
test_gm_script.code(),
"""
// Now check that the actual global scope has
// not been overwritten
result.push(window.$);
result.push($);
// And return our findings
result;
"""
])
# What we expect the script to report back.
expected = ["global", "global", "shadowed", "shadowed", "global", "global"]
# The JSCore in 602.1 doesn't fully support Proxy.
xfail = False
if (js_tester.tab.backend == usertypes.Backend.QtWebKit and
version.qWebKitVersion() == '602.1'):
expected[-1] = 'shadowed'
expected[-2] = 'shadowed'
xfail = True
js_tester.run(setup_script)
js_tester.run(test_script, expected=expected)
if xfail:
pytest.xfail("Broken on WebKit 602.1")
def test_shared_window_proxy(js_tester):
"""Check that all scripts have access to the same window proxy."""
# Greasemonkey script to add a property to the window proxy.
test_script_a = greasemonkey.GreasemonkeyScript.parse(
textwrap.dedent("""
// ==UserScript==
// @name a
// ==/UserScript==
// Set a value from script a
window.$ = 'test';
""")
).code()
# Greasemonkey script to retrieve a property from the window proxy.
test_script_b = greasemonkey.GreasemonkeyScript.parse(
textwrap.dedent("""
// ==UserScript==
// @name b
// ==/UserScript==
// Check that the value is accessible from script b
return [window.$, $];
""")
).code()
js_tester.run(test_script_a)
js_tester.run(test_script_b, expected=["test", "test"])
@pytest.mark.parametrize("run_at, start, end, idle, with_warning", [
("document-start", True, False, False, False),
("document-end", False, True, False, False),
("document-idle", False, False, True, False),
("", False, True, False, False),
("bla", False, True, False, True),
])
def test_run_at(gm_manager, run_at, start, end, idle, with_warning, caplog):
script = greasemonkey.GreasemonkeyScript.parse(
textwrap.dedent(f"""
// ==UserScript==
// @name run-at-tester
// @run-at {run_at}
// ==/UserScript==
return document.readyState;
""")
)
if with_warning:
with caplog.at_level(logging.WARNING):
gm_manager.add_script(script)
msg = ("Script run-at-tester has invalid run-at defined, defaulting to "
"document-end")
assert caplog.messages == [msg]
else:
gm_manager.add_script(script)
assert gm_manager._run_start == ([script] if start else [])
assert gm_manager._run_end == ([script] if end else [])
assert gm_manager._run_idle == ([script] if idle else [])
@pytest.mark.parametrize("scripts, expected", [
([], "No Greasemonkey scripts loaded"),
(
[greasemonkey.GreasemonkeyScript(properties={}, code="", filename="test")],
"Loaded Greasemonkey scripts:\n\ntest",
),
(
[
greasemonkey.GreasemonkeyScript(properties={}, code="", filename="test1"),
greasemonkey.GreasemonkeyScript(properties={}, code="", filename="test2"),
],
"Loaded Greasemonkey scripts:\n\ntest1\ntest2",
),
])
def test_load_results_successful(scripts, expected):
results = greasemonkey.LoadResults()
results.successful = scripts
assert results.successful_str() == expected
@pytest.mark.parametrize("errors, expected", [
([], None),
(
[("test", "could not frobnicate")],
"Greasemonkey scripts failed to load:\n\ntest: could not frobnicate",
),
(
[("test1", "could not frobnicate"), ("test2", "frobnicator borked")],
(
"Greasemonkey scripts failed to load:\n\n"
"test1: could not frobnicate\n"
"test2: frobnicator borked"
)
),
])
def test_load_results_errors(errors, expected):
results = greasemonkey.LoadResults()
results.errors = errors
assert results.error_str() == expected
@pytest.mark.parametrize("quiet", [False, True])
def test_greasemonkey_reload(gm_manager, quiet, message_mock):
_save_script(test_gm_script, 'test.user.js')
assert not gm_manager.all_scripts()
greasemonkey.greasemonkey_reload(quiet=quiet)
assert gm_manager.all_scripts()
if quiet:
assert not message_mock.messages
else:
msg = 'Loaded Greasemonkey scripts:\n\nqutebrowser test userscript'
assert message_mock.getmsg().text == msg
@pytest.mark.parametrize("quiet", [False, True])
def test_greasemonkey_reload_errors(gm_manager, caplog, message_mock, wrong_path_setup,
quiet):
assert not gm_manager.all_scripts()
with caplog.at_level(logging.ERROR):
greasemonkey.greasemonkey_reload(quiet=quiet)
assert len(gm_manager.all_scripts()) == 1
text = "Greasemonkey scripts failed to load:\n\ntest1.user.js"
msg = message_mock.messages[-1]
assert msg.level == usertypes.MessageLevel.error
assert msg.text.startswith(text)
def test_init(monkeypatch, message_mock):
monkeypatch.setattr(greasemonkey, "gm_manager", None)
_save_script(test_gm_script, 'test.user.js')
greasemonkey.init()
assert not message_mock.messages
assert len(greasemonkey.gm_manager.all_scripts()) == 1
def test_init_errors(monkeypatch, message_mock, wrong_path_setup, caplog):
monkeypatch.setattr(greasemonkey, "gm_manager", None)
with caplog.at_level(logging.ERROR):
greasemonkey.init()
assert len(greasemonkey.gm_manager.all_scripts()) == 1
msg = message_mock.getmsg(usertypes.MessageLevel.error)
text = "Greasemonkey scripts failed to load:\n\ntest1.user.js"
assert msg.text.startswith(text)