From c699b6409e98fe64a70417a18b6e335b4c60f86d Mon Sep 17 00:00:00 2001 From: Wojtek Kosior Date: Mon, 13 Dec 2021 17:58:29 +0100 Subject: facilitate creating and installing WebExtensions during tests It is now possible to more conveniently test WebExtension APIs code by wrapping it into a test WebExtension and temporarily installing in the driven browser. --- test/extension_crafting.py | 121 +++++++++++++++++++++++++++++++++++++++++++++ test/misc_constants.py | 3 ++ test/profiles.py | 26 +++++++++- test/unit/conftest.py | 31 ++++++++++-- test/unit/test_basic.py | 17 ++++++- 5 files changed, 189 insertions(+), 9 deletions(-) create mode 100644 test/extension_crafting.py (limited to 'test') diff --git a/test/extension_crafting.py b/test/extension_crafting.py new file mode 100644 index 0000000..6f1800b --- /dev/null +++ b/test/extension_crafting.py @@ -0,0 +1,121 @@ +# SPDX-License-Identifier: GPL-3.0-or-later + +""" +Making temporary WebExtensions for use in the test suite +""" + +# This file is part of Haketilo. +# +# Copyright (C) 2021 Wojtek Kosior +# +# This program 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. +# +# This program 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 this program. If not, see . +# +# I, Wojtek Kosior, thereby promise not to sue for violation of this file's +# license. Although I request that you do not make use this code in a +# proprietary program, I am not going to enforce this in court. + +import json +import zipfile +from pathlib import Path +from uuid import uuid4 + +from .misc_constants import * + +class ManifestTemplateValueToFill: + pass + +def manifest_template(): + return { + 'manifest_version': 2, + 'name': 'Haketilo test extension', + 'version': '1.0', + 'applications': { + 'gecko': { + 'id': ManifestTemplateValueToFill(), + 'strict_min_version': '60.0' + } + }, + 'permissions': [ + 'contextMenus', + 'webRequest', + 'webRequestBlocking', + 'activeTab', + 'notifications', + 'sessions', + 'storage', + 'tabs', + '', + 'unlimitedStorage' + ], + 'web_accessible_resources': ['testpage.html'], + 'background': { + 'persistent': True, + 'scripts': ['__open_test_page.js', 'background.js'] + }, + 'content_scripts': [ + { + 'run_at': 'document_start', + 'matches': [''], + 'match_about_blank': True, + 'all_frames': True, + 'js': ['content.js'] + } + ] + } + +default_background_script = '' +default_content_script = '' +default_test_page = ''' + + + + Extension's options page for testing + + +

Extension's options page for testing

+ + +''' + +open_test_page_script = '''(() => { +const page_url = browser.runtime.getURL("testpage.html"); +const execute_details = { + code: `window.location.href=${JSON.stringify(page_url)};` +}; +browser.tabs.query({currentWindow: true, active: true}) + .then(t => browser.tabs.executeScript(t.id, execute_details)); +})();''' + +def make_extension(destination_dir, + background_script=default_background_script, + content_script=default_content_script, + test_page=default_test_page, + extra_files={}): + manifest = manifest_template() + extension_id = '{%s}' % uuid4() + manifest['applications']['gecko']['id'] = extension_id + files = { + 'manifest.json' : json.dumps(manifest), + '__open_test_page.js': open_test_page_script, + 'background.js' : background_script, + 'content.js' : content_script, + 'testpage.html' : test_page, + **extra_files + } + destination_path = destination_dir / f'{extension_id}.xpi' + with zipfile.ZipFile(destination_path, 'x') as xpi: + for filename, contents in files.items(): + xpi.writestr(filename, contents) + + return destination_path diff --git a/test/misc_constants.py b/test/misc_constants.py index 22432a6..b3e9e32 100644 --- a/test/misc_constants.py +++ b/test/misc_constants.py @@ -41,6 +41,9 @@ default_proxy_port = 1337 default_cert_dir = here / 'certs' +default_extension_uuid = 'a1291446-be95-48ad-a4c6-a475e389399b' +default_haketilo_id = '{6fe13369-88e9-440f-b837-5012fb3bedec}' + mime_types = { "7z": "application/x-7z-compressed", "oga": "audio/ogg", "abw": "application/x-abiword", "ogv": "video/ogg", diff --git a/test/profiles.py b/test/profiles.py index 1530aea..795a0db 100755 --- a/test/profiles.py +++ b/test/profiles.py @@ -27,7 +27,8 @@ Browser profiles and Selenium driver initialization from selenium import webdriver from selenium.webdriver.firefox.options import Options -import time +import json +from shutil import rmtree from .misc_constants import * @@ -35,7 +36,8 @@ class HaketiloFirefox(webdriver.Firefox): """ This wrapper class around selenium.webdriver.Firefox adds a `loaded_scripts` instance property that gets resetted to an empty array every time the - `get()` method is called. + `get()` method is called and also facilitates removing the temporary + profile directory after Firefox quits. """ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -48,6 +50,11 @@ class HaketiloFirefox(webdriver.Firefox): self.reset_loaded_scripts() super().get(*args, **kwargs) + def quit(self, *args, **kwargs): + profile_path = self.firefox_profile.path + super().quit(*args, **kwargs) + rmtree(profile_path, ignore_errors=True) + def set_profile_proxy(profile, proxy_host, proxy_port): """ Create a Firefox profile that uses the specified HTTP proxy for all @@ -67,6 +74,20 @@ def set_profile_proxy(profile, proxy_host, proxy_port): def set_profile_console_logging(profile): profile.set_preference('devtools.console.stdout.content', True) +# The function below seems not to work for extensions that are +# temporarily-installed in Firefox safe mode. Testing is needed to see if it +# works with non-temporary extensions (without safe mode). +def set_webextension_uuid(profile, extension_id, uuid=default_extension_uuid): + """ + Firefox would normally assign a unique, random UUID to installed extension. + This UUID is needed to easily navigate to extension's settings page (and + other extension's pages). Since there's no way to learn such UUID with + current WebDriver implementation, this function works around this by telling + Firefox to use a predefined UUID for a certain extension. + """ + profile.set_preference('extensions.webextensions.uuids', + json.dumps({extension_id: uuid})) + def firefox_safe_mode(firefox_binary=default_firefox_binary, proxy_host=default_proxy_host, proxy_port=default_proxy_port): @@ -97,6 +118,7 @@ def firefox_with_profile(firefox_binary=default_firefox_binary, profile = webdriver.FirefoxProfile(profile_dir) set_profile_proxy(profile, proxy_host, proxy_port) set_profile_console_logging(profile) + set_webextension_uuid(profile, default_haketilo_id) return HaketiloFirefox(firefox_profile=profile, firefox_binary=firefox_binary) diff --git a/test/unit/conftest.py b/test/unit/conftest.py index 1500006..e1c98a1 100644 --- a/test/unit/conftest.py +++ b/test/unit/conftest.py @@ -26,10 +26,14 @@ Common fixtures for Haketilo unit tests # proprietary program, I am not going to enforce this in court. import pytest +from pathlib import Path +from selenium.webdriver.support.ui import WebDriverWait +from selenium.webdriver.support import expected_conditions as EC -from ..profiles import firefox_safe_mode -from ..server import do_an_internet -from ..script_loader import load_script +from ..profiles import firefox_safe_mode +from ..server import do_an_internet +from ..script_loader import load_script +from ..extension_crafting import make_extension @pytest.fixture(scope="package") def proxy(): @@ -43,6 +47,23 @@ def driver(proxy): yield driver driver.quit() +@pytest.fixture() +def webextension(driver, request): + ext_data = request.node.get_closest_marker('ext_data') + if ext_data is None: + raise Exception('"webextension" fixture requires "ext_data" marker to be set') + + ext_path = make_extension(Path(driver.firefox_profile.path), + **ext_data.args[0]) + driver.get('https://gotmyowndoma.in/') + addon_id = driver.install_addon(str(ext_path), temporary=True) + WebDriverWait(driver, 10).until( + EC.title_contains("Extension's options page for testing") + ) + yield + driver.uninstall_addon(addon_id) + ext_path.unlink() + script_injecting_script = '''\ /* * Selenium by default executes scripts in some weird one-time context. We want @@ -63,8 +84,8 @@ window.arguments = arguments[1]; document.body.append(script_elem); /* - * To ease debugging, we want this script to forward signal all exceptions from - * the injectee. + * To ease debugging, we want this script to signal all exceptions from the + * injectee. */ try { if (window.haketilo_selenium_exception !== false) diff --git a/test/unit/test_basic.py b/test/unit/test_basic.py index cbe5c8c..3b09cb6 100644 --- a/test/unit/test_basic.py +++ b/test/unit/test_basic.py @@ -26,8 +26,9 @@ def test_driver(driver): """ for proto in ['http://', 'https://']: driver.get(proto + 'gotmyowndoma.in') - element = driver.find_element_by_tag_name('title') - title = driver.execute_script('return arguments[0].innerText;', element) + title = driver.execute_script( + 'return document.getElementsByTagName("title")[0].innerText;' + ) assert "Schrodinger's Document" in title def test_script_loader(execute_in_page, load_into_page): @@ -39,3 +40,15 @@ def test_script_loader(execute_in_page, load_into_page): page='https://gotmyowndoma.in') assert execute_in_page('returnval(TYPE_PREFIX.VAR);') == '_' + +@pytest.mark.ext_data({}) +def test_webextension(driver, webextension): + """ + A trivial test case that verifies a test WebExtension created and installed + by the `webextension` fixture works and redirects specially-constructed URLs + to its test page. + """ + heading = driver.execute_script( + 'return document.getElementsByTagName("h1")[0].innerText;' + ) + assert "Extension's options page for testing" in heading -- cgit v1.2.3