diff options
Diffstat (limited to 'test')
-rw-r--r-- | test/script_loader.py | 16 | ||||
-rw-r--r-- | test/unit/test_indexeddb.py | 2 | ||||
-rw-r--r-- | test/unit/test_patterns_query_manager.py | 236 |
3 files changed, 246 insertions, 8 deletions
diff --git a/test/script_loader.py b/test/script_loader.py index edf8143..f66f9ae 100644 --- a/test/script_loader.py +++ b/test/script_loader.py @@ -43,10 +43,12 @@ def make_relative_path(path): script_cache = {} -def load_script(path): +def load_script(path, code_to_add=None): """ `path` is a .js file path in Haketilo sources. It may be absolute or - specified relative to Haketilo's project directory. + specified relative to Haketilo's project directory. `code_to_add` is + optional code to be appended to the end of the main file being imported. + it can contain directives like `#IMPORT`. Return a string containing script from `path` together with all other scripts it depends on. Dependencies are wrapped in the same way Haketilo's @@ -57,13 +59,15 @@ def load_script(path): a dependency to be substituted by a mocked value. """ path = make_relative_path(path) - if str(path) in script_cache: - return script_cache[str(path)] + key = f'{str(path)}:{code_to_add}' if code_to_add is not None else str(path) + if key in script_cache: + return script_cache[key] awk = subprocess.run(['awk', '-f', str(awk_script), '--', '-D', 'MOZILLA', - '-D', 'MV2', '--output=amalgamate-js:' + str(path)], + '-D', 'MV2', '-D', 'TEST', '-D', 'UNIT_TEST', + '--output=amalgamate-js:' + key], stdout=subprocess.PIPE, cwd=script_root, check=True) script = awk.stdout.decode() - script_cache[str(path)] = script + script_cache[key] = script return script diff --git a/test/unit/test_indexeddb.py b/test/unit/test_indexeddb.py index af60e1c..476690c 100644 --- a/test/unit/test_indexeddb.py +++ b/test/unit/test_indexeddb.py @@ -85,8 +85,6 @@ def test_haketilodb_save_remove(execute_in_page): # Mock some unwanted imports. execute_in_page( '''{ - initial_data = {}; - const broadcast_mock = {}; const nop = () => {}; for (const key in broadcast) diff --git a/test/unit/test_patterns_query_manager.py b/test/unit/test_patterns_query_manager.py new file mode 100644 index 0000000..8ae7c28 --- /dev/null +++ b/test/unit/test_patterns_query_manager.py @@ -0,0 +1,236 @@ +# SPDX-License-Identifier: CC0-1.0 + +""" +Haketilo unit tests - building pattern tree and putting it in a content script +""" + +# This file is part of Haketilo +# +# Copyright (C) 2021, Wojtek Kosior <koszko@koszko.org> +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the CC0 1.0 Universal License as published by +# the Creative Commons Corporation. +# +# 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 +# CC0 1.0 Universal License for more details. + +import pytest +import re +import json +from selenium.webdriver.support.ui import WebDriverWait + +from ..script_loader import load_script + +def simple_sample_mapping(patterns, fruit): + if type(patterns) is list: + payloads = dict([(p, {'identifier': fruit}) for p in patterns]) + else: + payloads = {patterns: {'identifier': fruit}} + return { + 'source_copyright': [], + 'type': 'mapping', + 'identifier': f'inject-{fruit}', + 'payloads': payloads + } + +content_script_re = re.compile(r'this.haketilo_pattern_tree = (.*);') +def extract_tree_data(content_script_text): + return json.loads(content_script_re.search(content_script_text)[1]) + +# Fields that are not relevant for testing are omitted from these mapping +# definitions. +sample_mappings = [simple_sample_mapping(pats, fruit) for pats, fruit in [ + (['https://gotmyowndoma.in/index.html', + 'http://gotmyowndoma.in/index.html'], 'banana'), + (['https://***.gotmyowndoma.in/index.html', + 'https://**.gotmyowndoma.in/index.html', + 'https://*.gotmyowndoma.in/index.html', + 'https://gotmyowndoma.in/index.html'], 'orange'), + ('https://gotmyowndoma.in/index.html/***', 'grape'), + ('http://gotmyowndoma.in/index.html/***', 'melon'), + ('https://gotmyowndoma.in/index.html', 'peach'), + ('https://gotmyowndoma.in/*', 'pear'), + ('https://gotmyowndoma.in/**', 'raspberry'), + ('https://gotmyowndoma.in/***', 'strawberry'), + ('https://***.gotmyowndoma.in/index.html', 'apple'), + ('https://***.gotmyowndoma.in/*', 'avocado'), + ('https://***.gotmyowndoma.in/**', 'papaya'), + ('https://***.gotmyowndoma.in/***', 'kiwi') +]] + +# Even though patterns_query_manager.js is normally meant to run from background +# page, tests can be as well performed running it from extension's bundled page. +@pytest.mark.get_page('https://gotmyowndoma.in') +def test_pqm_tree_building(driver, execute_in_page): + """ + patterns_query_manager.js tracks Haketilo's internal database and builds a + constantly-updated pattern tree based on its contents. Mock the database and + verify tree building works properly. + """ + execute_in_page(load_script('background/patterns_query_manager.js')) + # Mock IndexedDB and build patterns tree. + execute_in_page( + ''' + const initial_mappings = arguments[0] + let mappingchange; + function track_mock(cb) + { + mappingchange = cb; + + return [{}, initial_mappings]; + } + haketilodb.track_mappings = track_mock; + + let last_script; + let unregister_called = 0; + async function register_mock(injection) + { + await new Promise(resolve => setTimeout(resolve, 1)); + last_script = injection.js[0].code; + return {unregister: () => unregister_called++}; + } + browser = {contentScripts: {register: register_mock}}; + + returnval(start()); + ''', + sample_mappings[0:2]) + + found, tree, content_script, deregistrations = execute_in_page( + ''' + returnval([pqt.search(tree, arguments[0]).next().value, + tree, last_script, unregister_called]); + ''', + 'https://gotmyowndoma.in/index.html') + assert found == dict([(m['identifier'], m) for m in sample_mappings[0:2]]) + assert tree == extract_tree_data(content_script) + assert deregistrations == 0 + + def condition_mappings_added(driver): + last_script = execute_in_page('returnval(last_script);') + return all([m['identifier'] in last_script for m in sample_mappings]) + + execute_in_page( + ''' + for (const mapping of arguments[0]) { + mappingchange({ + identifier: mapping.identifier, + new_val: mapping + }); + } + ''', + sample_mappings[2:]) + WebDriverWait(driver, 10).until(condition_mappings_added) + + odd = [m['identifier'] for i, m in enumerate(sample_mappings) if i % 2] + even = [m['identifier'] for i, m in enumerate(sample_mappings) if 1 - i % 2] + + def condition_odd_removed(driver): + last_script = execute_in_page('returnval(last_script);') + return all([id not in last_script for id in odd]) + + def condition_all_removed(driver): + content_script = execute_in_page('returnval(last_script);') + return extract_tree_data(content_script) == {} + + execute_in_page( + ''' + arguments[0].forEach(identifier => mappingchange({identifier})); + ''', + odd) + + WebDriverWait(driver, 10).until(condition_odd_removed) + + execute_in_page( + ''' + arguments[0].forEach(identifier => mappingchange({identifier})); + ''', + even) + + WebDriverWait(driver, 10).until(condition_all_removed) + +content_js = ''' +let already_run = false; +this.haketilo_content_script_main = function() { + if (already_run) + return; + already_run = true; + document.documentElement.innerHTML = "<body><div id='tree-json'>"; + document.getElementById("tree-json").innerText = + JSON.stringify(this.haketilo_pattern_tree); +} +if (this.haketilo_pattern_tree !== undefined) + this.haketilo_content_script_main(); +''' + +def background_js(): + pqm_js = load_script('background/patterns_query_manager.js', + "#IMPORT background/broadcast_broker.js") + return pqm_js + '; broadcast_broker.start(); start();' + +@pytest.mark.ext_data({ + 'content_script': content_js, + 'background_script': background_js +}) +@pytest.mark.usefixtures('webextension') +def test_pqm_script_injection(driver, execute_in_page): + # Let's open a normal page in a second window. Window 0 will be used to make + # changed to IndexedDB and window 1 to test the working of content scripts. + driver.execute_script('window.open("about:blank", "_blank");') + windows = [*driver.window_handles] + assert len(windows) == 2 + + def run_content_script(): + driver.switch_to.window(windows[1]) + driver.get('https://gotmyowndoma.in/index.html') + windows[1] = driver.window_handles[1] + return driver.execute_script( + ''' + return (document.getElementById("tree-json") || {}).innerText; + ''') + + for attempt in range(10): + json_txt = run_content_script() + if json.loads(json_txt) == {}: + break; + assert attempt != 9 + + driver.switch_to.window(windows[0]) + execute_in_page(load_script('common/indexeddb.js')) + + sample_data = { + 'mappings': dict([(sm['identifier'], {'1.0': sm}) + for sm in sample_mappings]), + 'resources': {}, + 'files': {} + } + execute_in_page('returnval(save_items(arguments[0]));', sample_data) + + for attempt in range(10): + tree_json = run_content_script() + json.loads(tree_json) + if all([m['identifier'] in tree_json for m in sample_mappings]): + break + assert attempt != 9 + + driver.switch_to.window(windows[0]) + execute_in_page( + '''{ + const identifiers = arguments[0]; + async function remove_items() + { + const ctx = await start_items_transaction(["mappings"], {}); + for (const id of identifiers) + await remove_mapping(id, ctx); + await finalize_items_transaction(ctx); + } + returnval(remove_items()); + }''', + [sm['identifier'] for sm in sample_mappings]) + + for attempt in range(10): + if json.loads(run_content_script()) == {}: + break + assert attempt != 9 |