From 58fe4c7d806359bed299f74ba56902ab396a6ed1 Mon Sep 17 00:00:00 2001 From: Wojtek Kosior Date: Tue, 14 Dec 2021 21:40:23 +0100 Subject: facilitate broadcasting messages to different execution contexts within the webextension --- background/broadcast_broker.js | 184 ++++++++++++++++++++++++++++++++++ common/broadcast.js | 113 +++++++++++++++++++++ common/connection_types.js | 4 +- copyright | 2 +- pytest.ini | 19 ++++ test/extension_crafting.py | 2 + test/unit/conftest.py | 44 +++++--- test/unit/test_basic.py | 7 +- test/unit/test_broadcast.py | 181 +++++++++++++++++++++++++++++++++ test/unit/test_indexeddb.py | 3 +- test/unit/test_patterns.py | 6 +- test/unit/test_patterns_query_tree.py | 9 +- 12 files changed, 551 insertions(+), 23 deletions(-) create mode 100644 background/broadcast_broker.js create mode 100644 common/broadcast.js create mode 100644 pytest.ini create mode 100644 test/unit/test_broadcast.py diff --git a/background/broadcast_broker.js b/background/broadcast_broker.js new file mode 100644 index 0000000..7af8769 --- /dev/null +++ b/background/broadcast_broker.js @@ -0,0 +1,184 @@ +/** + * This file is part of Haketilo. + * + * Function: Facilitate broadcasting messages between different execution + * contexts of the extension + * + * 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. + * + * As additional permission under GNU GPL version 3 section 7, you + * may distribute forms of that code without the copy of the GNU + * GPL normally required by section 4, provided you include this + * license notice and, in case of non-source distribution, a URL + * through which recipients can access the Corresponding Source. + * If you modify file(s) with this exception, you may extend this + * exception to your version of the file(s), but you are not + * obligated to do so. If you do not wish to do so, delete this + * exception statement from your version. + * + * As a special exception to the GPL, any HTML file which merely + * makes function calls to this code, and for that purpose + * includes it by reference shall be deemed a separate work for + * copyright law purposes. If you modify this code, you may extend + * this exception to your version of the code, but you are not + * obligated to do so. If you do not wish to do so, delete this + * exception statement from your version. + * + * 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. + */ + +/* + * IMPORTS_START + * IMPORT listen_for_connection + * IMPORT CONNECTION_TYPE + * IMPORTS_END + */ + +let next_id = 1; + +const listeners_by_channel = new Map(); + +function new_broadcast_listener(port) +{ + listener_ctx = {port, id: ++next_id, channels: new Set()}; + port.onMessage.addListener(msg => listener_command(msg, listener_ctx)); + port.onDisconnect.addListener(msg => listener_remove(msg, listener_ctx)); +} + +function listener_command(msg, listener_ctx) +{ + const [disposition, name] = msg; + + if (disposition === "subscribe") + subscribe_channel(name, listener_ctx); + else if (disposition === "unsubscribe") + unsubscribe_channel(name, listener_ctx); + else + throw `bad broadcast listener disposition '${disposition}'`; +} + +function subscribe_channel(channel_name, listener_ctx) +{ + if (!listeners_by_channel.has(channel_name)) + listeners_by_channel.set(channel_name, new Map()); + + listeners_by_channel.get(channel_name).set(listener_ctx.id, listener_ctx); + + listener_ctx.channels.add(channel_name); +} + +function unsubscribe_channel(channel_name, listener_ctx) +{ + const channel_listeners = + listeners_by_channel.get(channel_name) || new Map(); + channel_listeners.delete(listener_ctx.id); + + if (channel_listeners.size == 0) + listeners_by_channel.delete(channel_name); + + listener_ctx.channels.delete(channel_name); +} + +function remove_broadcast_listener(listener_ctx) +{ + for (const channel_name of [...listener_ctx.channels.keys()]) + unsubscribe_channel(channel_name, listener_ctx); +} + +function new_broadcast_sender(port) +{ + sender_ctx = {prepared_broadcasts: new Set()}; + port.onMessage.addListener(msg => sender_command(msg, sender_ctx)); + port.onDisconnect.addListener(msg => flush(sender_ctx)); +} + +function sender_command(msg, sender_ctx) +{ + const [disposition, name, value, timeout] = msg; + + if (disposition === "prepare") + prepare(sender_ctx, name, value, timeout) + else if (disposition === "discard") + sender_ctx.prepared_broadcasts = new Set(); + else if (disposition === "flush") + flush(sender_ctx); + else if (disposition === "broadcast") + broadcast(name, value); + else + throw `bad broadcast sender disposition '${disposition}'`; +} + +function prepare(sender_ctx, channel_name, value, timeout) +{ + broadcast_data = [channel_name, value]; + sender_ctx.prepared_broadcasts.add(broadcast_data); + + if (timeout === 0) + return; + + setTimeout(() => prepare_timeout_cb(sender_ctx, broadcast_data), timeout); +} + +function prepare_timeout_cb(sender_ctx, broadcast_data) +{ + if (sender_ctx.prepared_broadcasts.has(broadcast_data)) { + sender_ctx.prepared_broadcasts.delete(broadcast_data); + broadcast(...broadcast_data); + } +} + +function flush(sender_ctx) +{ + console.log('flushing', sender_ctx.prepared_broadcasts); + sender_ctx.prepared_broadcasts.forEach(nv => broadcast(...nv)); + sender_ctx.prepared_broadcasts = new Set(); +} + +function broadcast(channel_name, value) +{ + const listeners = listeners_by_channel.get(channel_name); + if (listeners == undefined) + return; + + for (const listener_ctx of [...listeners.values()]) { + try { + listener_ctx.port.postMessage([channel_name, value]); + } catch (e) { + console.error(e); + remove_broadcast_listener(listener_ctx); + } + } +} + +function remove_broadcast_sender(sender_ctx) +{ + sender_ctx.prepared_broadcasts.forEach(nv => broadcast(...nv)); +} + +function start_broadcast_broker() +{ + listen_for_connection(CONNECTION_TYPE.BROADCAST_SEND, new_broadcast_sender); + listen_for_connection(CONNECTION_TYPE.BROADCAST_LISTEN, + new_broadcast_listener); +} + +/* + * EXPORTS_START + * EXPORT start_broadcast_broker + * EXPORTS_END + */ diff --git a/common/broadcast.js b/common/broadcast.js new file mode 100644 index 0000000..bc18103 --- /dev/null +++ b/common/broadcast.js @@ -0,0 +1,113 @@ +/** + * This file is part of Haketilo. + * + * Function: Broadcast messages to different execution contexts of the extension + * + * 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. + * + * As additional permission under GNU GPL version 3 section 7, you + * may distribute forms of that code without the copy of the GNU + * GPL normally required by section 4, provided you include this + * license notice and, in case of non-source distribution, a URL + * through which recipients can access the Corresponding Source. + * If you modify file(s) with this exception, you may extend this + * exception to your version of the file(s), but you are not + * obligated to do so. If you do not wish to do so, delete this + * exception statement from your version. + * + * As a special exception to the GPL, any HTML file which merely + * makes function calls to this code, and for that purpose + * includes it by reference shall be deemed a separate work for + * copyright law purposes. If you modify this code, you may extend + * this exception to your version of the code, but you are not + * obligated to do so. If you do not wish to do so, delete this + * exception statement from your version. + * + * 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. + */ + +/* + * IMPORTS_START + * IMPORT CONNECTION_TYPE + * IMPORTS_END + */ + +function sender_connection() +{ + return { + port: browser.runtime.connect({name: CONNECTION_TYPE.BROADCAST_SEND}) + }; +} + +function out(sender_conn, channel_name, value) +{ + sender_conn.port.postMessage(["broadcast", channel_name, value]); +} + +function prepare(sender_conn, channel_name, value, timeout=5000) +{ + sender_conn.port.postMessage(["prepare", channel_name, value, timeout]); +} + +function discard(sender_conn) +{ + sender_conn.port.postMessage(["discard"]); +} + +function flush(sender_conn) +{ + sender_conn.port.postMessage(["flush"]); +} + +function listener_connection(cb) +{ + const conn = { + port: browser.runtime.connect({name: CONNECTION_TYPE.BROADCAST_LISTEN}) + }; + + conn.port.onMessage.addListener(cb); + + return conn; +} + +function subscribe(listener_conn, channel_name) +{ + listener_conn.port.postMessage(["subscribe", channel_name]); +} + +function unsubscribe(listener_conn, channel_name) +{ + listener_conn.port.postMessage(["unsubscribe", channel_name]); +} + +function close(conn) +{ + conn.port.disconnect(); +} + +const broadcast = { + sender_connection, out, prepare, discard, flush, + listener_connection, subscribe, unsubscribe, + close +}; + +/* + * EXPORTS_START + * EXPORT broadcast + * EXPORTS_END + */ diff --git a/common/connection_types.js b/common/connection_types.js index a571cb9..9747e5c 100644 --- a/common/connection_types.js +++ b/common/connection_types.js @@ -49,7 +49,9 @@ const CONNECTION_TYPE = { REMOTE_STORAGE : "0", PAGE_ACTIONS : "1", - ACTIVITY_INFO : "2" + ACTIVITY_INFO : "2", + BROADCAST_SEND: "3", + BROADCAST_LISTEN: "4" }; /* diff --git a/copyright b/copyright index 9c39134..a2e09d1 100644 --- a/copyright +++ b/copyright @@ -9,7 +9,7 @@ Comment: Wojtek Kosior promises not to sue even in case of violations of the license. Files: *.sh default_settings.json Makefile.in compute_scripts.awk - CHROMIUM_exports_init.js + CHROMIUM_exports_init.js pytest.ini Copyright: 2021 Wojtek Kosior License: CC0 diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..6c8afcc --- /dev/null +++ b/pytest.ini @@ -0,0 +1,19 @@ +#!/usr/bin/env pytest + +# 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 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. + +[pytest] +markers = + ext_data: define a custom testing extension for `webextension` fixture. + get_page: define a url the `driver` fixture should navigate the browser to. diff --git a/test/extension_crafting.py b/test/extension_crafting.py index 6f1800b..9b985b3 100644 --- a/test/extension_crafting.py +++ b/test/extension_crafting.py @@ -116,6 +116,8 @@ def make_extension(destination_dir, destination_path = destination_dir / f'{extension_id}.xpi' with zipfile.ZipFile(destination_path, 'x') as xpi: for filename, contents in files.items(): + if hasattr(contents, '__call__'): + contents = contents() xpi.writestr(filename, contents) return destination_path diff --git a/test/unit/conftest.py b/test/unit/conftest.py index e1c98a1..eec311c 100644 --- a/test/unit/conftest.py +++ b/test/unit/conftest.py @@ -27,6 +27,7 @@ Common fixtures for Haketilo unit tests import pytest from pathlib import Path +from selenium.webdriver.common.by import By from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC @@ -42,11 +43,24 @@ def proxy(): httpd.shutdown() @pytest.fixture(scope="package") -def driver(proxy): +def _driver(proxy): with firefox_safe_mode() as driver: yield driver driver.quit() +def close_all_but_one_window(driver): + while len(driver.window_handles) > 1: + driver.switch_to.window(driver.window_handles[-1]) + driver.close() + driver.switch_to.window(driver.window_handles[0]) + +@pytest.fixture() +def driver(_driver, request): + nav_target = request.node.get_closest_marker('get_page') + close_all_but_one_window(_driver) + _driver.get(nav_target.args[0] if nav_target else 'about:blank') + yield _driver + @pytest.fixture() def webextension(driver, request): ext_data = request.node.get_closest_marker('ext_data') @@ -58,7 +72,7 @@ def webextension(driver, request): 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") + EC.url_matches('^moz-extension://.*') ) yield driver.uninstall_addon(addon_id) @@ -115,22 +129,28 @@ def _execute_in_page_context(driver, script, args): raise e from None -@pytest.fixture(scope="package") -def execute_in_page(driver): - def do_execute(script, *args, **kwargs): - if 'page' in kwargs: - driver.get(kwargs['page']) +# Some fixtures here just define functions that operate on driver. We should +# consider making them into webdriver wrapper class methods. +@pytest.fixture() +def execute_in_page(driver): + def do_execute(script, *args): return _execute_in_page_context(driver, script, args) yield do_execute -@pytest.fixture(scope="package") +@pytest.fixture() def load_into_page(driver): - def do_load(path, import_dirs, *args, **kwargs): - if 'page' in kwargs: - driver.get(kwargs['page']) - + def do_load(path, import_dirs, *args): _execute_in_page_context(driver, load_script(path, import_dirs), args) yield do_load + +@pytest.fixture() +def wait_elem_text(driver): + def do_wait(id, text): + WebDriverWait(driver, 10).until( + EC.text_to_be_present_in_element((By.ID, id), text) + ) + + yield do_wait diff --git a/test/unit/test_basic.py b/test/unit/test_basic.py index 3b09cb6..ca956e7 100644 --- a/test/unit/test_basic.py +++ b/test/unit/test_basic.py @@ -31,18 +31,19 @@ def test_driver(driver): ) assert "Schrodinger's Document" in title +@pytest.mark.get_page('https://gotmyowndoma.in') def test_script_loader(execute_in_page, load_into_page): """ A trivial test case that verifies Haketilo's .js files can be properly loaded into a test page together with their dependencies. """ - load_into_page('common/stored_types.js', ['common'], - page='https://gotmyowndoma.in') + load_into_page('common/stored_types.js', ['common']) assert execute_in_page('returnval(TYPE_PREFIX.VAR);') == '_' @pytest.mark.ext_data({}) -def test_webextension(driver, webextension): +@pytest.mark.usefixtures('webextension') +def test_webextension(driver): """ A trivial test case that verifies a test WebExtension created and installed by the `webextension` fixture works and redirects specially-constructed URLs diff --git a/test/unit/test_broadcast.py b/test/unit/test_broadcast.py new file mode 100644 index 0000000..c8c19d1 --- /dev/null +++ b/test/unit/test_broadcast.py @@ -0,0 +1,181 @@ +# SPDX-License-Identifier: CC0-1.0 + +""" +Haketilo unit tests - message broadcasting +""" + +# 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 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 + +from ..script_loader import load_script + +def broker_js(): + return load_script('background/broadcast_broker.js', + ['common', 'background']) + ';start_broadcast_broker();' + +def broadcast_js(): + return load_script('common/broadcast.js', ['common']) + +test_page_html = ''' + + +

d0 (channel `somebodyoncetoldme`)

+
+

d1 (channel `worldisgonnarollme`)

+
+

d2 (both channels)

+
+''' + +@pytest.mark.ext_data({ + 'background_script': broker_js, + 'test_page': test_page_html, + 'extra_files': { + 'testpage.js': broadcast_js + } +}) +@pytest.mark.usefixtures('webextension') +def test_broadcast(driver, execute_in_page, wait_elem_text): + """ + A test that verifies the broadcasting system based on WebExtension messaging + API and implemented in `background/broadcast_broker.js` and + `common/broadcast.js` works correctly. + """ + + # The broadcast facility is meant to enable message distribution between + # multiple contexts (e.g. different tabs/windows). Let's open the same + # extension's test page in a second window. + driver.execute_script( + ''' + window.open(window.location.href, "_blank"); + window.open(window.location.href, "_blank"); + ''') + windows = [*driver.window_handles] + assert len(windows) == 3 + + # Let's first test if a simple message can be successfully broadcasted + driver.switch_to.window(windows[0]) + execute_in_page( + ''' + const divs = [0, 1, 2].map(n => document.getElementById("d" + n)); + let appender = n => (t => divs[n].append("\\n" + `[${t[0]}, ${t[1]}]`)); + let listener0 = broadcast.listener_connection(appender(0)); + broadcast.subscribe(listener0, "somebodyoncetoldme"); + ''') + + driver.switch_to.window(windows[1]) + execute_in_page( + ''' + let sender0 = broadcast.sender_connection(); + broadcast.out(sender0, "somebodyoncetoldme", "iaintthesharpesttool"); + ''') + + driver.switch_to.window(windows[0]) + wait_elem_text('d0', '[somebodyoncetoldme, iaintthesharpesttool]') + + # Let's add 2 more listeners + driver.switch_to.window(windows[0]) + execute_in_page( + ''' + let listener1 = broadcast.listener_connection(appender(1)); + broadcast.subscribe(listener1, "worldisgonnarollme"); + let listener2 = broadcast.listener_connection(appender(2)); + broadcast.subscribe(listener2, "worldisgonnarollme"); + broadcast.subscribe(listener2, "somebodyoncetoldme"); + ''') + + # Let's send one message to one channel and one to the other. Verify they + # were received by the rght listeners. + driver.switch_to.window(windows[1]) + execute_in_page( + ''' + broadcast.out(sender0, "somebodyoncetoldme", "intheshed"); + broadcast.out(sender0, "worldisgonnarollme", "shewaslooking"); + ''') + + driver.switch_to.window(windows[0]) + wait_elem_text('d0', 'intheshed') + wait_elem_text('d1', 'shewaslooking') + wait_elem_text('d2', 'intheshed') + wait_elem_text('d2', 'shewaslooking') + + text = execute_in_page('returnval(divs[0].innerText);') + assert 'shewaslooking' not in text + text = execute_in_page('returnval(divs[1].innerText);') + assert 'intheshed' not in text + + # Let's create a second sender in third window and use it to send messages + # with the 'prepare' feature. + driver.switch_to.window(windows[2]) + execute_in_page( + ''' + let sender1 = broadcast.sender_connection(); + broadcast.prepare(sender1, "somebodyoncetoldme", "kindadumb"); + broadcast.out(sender1, "worldisgonnarollme", "withherfinger"); + ''') + + driver.switch_to.window(windows[0]) + wait_elem_text('d1', 'withherfinger') + text = execute_in_page('returnval(divs[0].innerText);') + assert 'kindadumb' not in text + + driver.switch_to.window(windows[2]) + execute_in_page('broadcast.flush(sender1);') + + driver.switch_to.window(windows[0]) + wait_elem_text('d0', 'kindadumb') + + # Let's verify that prepare()'d messages are properly discarded when + # discard() is called. + driver.switch_to.window(windows[2]) + execute_in_page( + ''' + broadcast.prepare(sender1, "somebodyoncetoldme", "andherthumb"); + broadcast.discard(sender1); + broadcast.prepare(sender1, "somebodyoncetoldme", "andhermiddlefinger"); + broadcast.flush(sender1); + ''') + + driver.switch_to.window(windows[0]) + wait_elem_text('d0', 'andhermiddlefinger') + text = execute_in_page('returnval(divs[0].innerText);') + assert 'andherthumb' not in text + + # Let's verify prepare()'d messages are properly auto-flushed when the other + # end of the connection gets killed (e.g. because browser tab gets closed). + driver.switch_to.window(windows[2]) + execute_in_page( + ''' + broadcast.prepare(sender1, "worldisgonnarollme", "intheshape", 500); + ''') + driver.close() + + driver.switch_to.window(windows[0]) + wait_elem_text('d2', 'intheshape') + + # Verify listener's connection gets closed properly. + execute_in_page('broadcast.close(listener0); broadcast.close(listener1);') + + driver.switch_to.window(windows[1]) + execute_in_page('broadcast.out(sender0, "worldisgonnarollme", "ofanL");') + execute_in_page('broadcast.out(sender0, "somebodyoncetoldme", "forehead");') + + driver.switch_to.window(windows[0]) + wait_elem_text('d2', 'ofanL') + wait_elem_text('d2', 'forehead') + for i in (0, 1): + text = execute_in_page('returnval(divs[arguments[0]].innerText);', i) + assert 'ofanL' not in text + assert 'forehead' not in text diff --git a/test/unit/test_indexeddb.py b/test/unit/test_indexeddb.py index f1322fb..965f318 100644 --- a/test/unit/test_indexeddb.py +++ b/test/unit/test_indexeddb.py @@ -47,12 +47,13 @@ sample_files_by_hash = dict([[file['hash_key'], file['contents']] def file_ref(file_name): return {'file': file_name, 'hash_key': sample_files[file_name]['hash_key']} +@pytest.mark.get_page('https://gotmyowndoma.in') def test_save_remove_item(execute_in_page, indexeddb_code): """ indexeddb.js facilitates operating on Haketilo's internal database. Verify database operations work properly. """ - execute_in_page(indexeddb_code, page='https://gotmyowndoma.in') + execute_in_page(indexeddb_code) # Don't use Haketilo's default initial data. execute_in_page( '''{ diff --git a/test/unit/test_patterns.py b/test/unit/test_patterns.py index 802bf4e..99e1ed5 100644 --- a/test/unit/test_patterns.py +++ b/test/unit/test_patterns.py @@ -25,12 +25,13 @@ from ..script_loader import load_script def patterns_code(): yield load_script('common/patterns.js', ['common']) +@pytest.mark.get_page('https://gotmyowndoma.in') def test_regexes(execute_in_page, patterns_code): """ patterns.js contains regexes used for URL parsing. Verify they work properly. """ - execute_in_page(patterns_code, page='https://gotmyowndoma.in') + execute_in_page(patterns_code) valid_url = 'https://example.com/a/b?ver=1.2.3#heading2' valid_url_rest = 'example.com/a/b?ver=1.2.3#heading2' @@ -90,12 +91,13 @@ def test_regexes(execute_in_page, patterns_code): '@bad.url/') assert match is None +@pytest.mark.get_page('https://gotmyowndoma.in') def test_deconstruct_url(execute_in_page, patterns_code): """ patterns.js contains deconstruct_url() function that handles URL parsing. Verify it works properly. """ - execute_in_page(patterns_code, page='https://gotmyowndoma.in') + execute_in_page(patterns_code) deco = execute_in_page('returnval(deconstruct_url(arguments[0]));', 'https://eXaMpLe.com/a/b?ver=1.2.3#heading2') diff --git a/test/unit/test_patterns_query_tree.py b/test/unit/test_patterns_query_tree.py index e282592..a67e22f 100644 --- a/test/unit/test_patterns_query_tree.py +++ b/test/unit/test_patterns_query_tree.py @@ -25,13 +25,14 @@ from ..script_loader import load_script def patterns_tree_code(): yield load_script('common/patterns_query_tree.js', ['common']) +@pytest.mark.get_page('https://gotmyowndoma.in') def test_modify_branch(execute_in_page, patterns_tree_code): """ patterns_query_tree.js contains Pattern Tree data structure that allows arrays of string labels to be mapped to items. Verify operations modifying a single branch of such tree work properly. """ - execute_in_page(patterns_tree_code, page='https://gotmyowndoma.in') + execute_in_page(patterns_tree_code) execute_in_page( ''' let items_added; @@ -195,13 +196,14 @@ def test_modify_branch(execute_in_page, patterns_tree_code): } } +@pytest.mark.get_page('https://gotmyowndoma.in') def test_search_branch(execute_in_page, patterns_tree_code): """ patterns_query_tree.js contains Pattern Tree data structure that allows arrays of string labels to be mapped to items. Verify searching a single branch of such tree work properly. """ - execute_in_page(patterns_tree_code, page='https://gotmyowndoma.in') + execute_in_page(patterns_tree_code) execute_in_page( ''' const item_adder = item => (array => [...(array || []), item]); @@ -282,13 +284,14 @@ def test_search_branch(execute_in_page, patterns_tree_code): '\nresult:', result, file=sys.stderr) raise e from None +@pytest.mark.get_page('https://gotmyowndoma.in') def test_pattern_tree(execute_in_page, patterns_tree_code): """ patterns_query_tree.js contains Pattern Tree data structure that allows arrays of string labels to be mapped to items. Verify operations on entire such tree work properly. """ - execute_in_page(patterns_tree_code, page='https://gotmyowndoma.in') + execute_in_page(patterns_tree_code) # Perform tests with all possible patterns for a simple URL. url = 'https://example.com' -- cgit v1.2.3