diff options
-rw-r--r-- | common/indexeddb.js | 4 | ||||
-rw-r--r-- | common/lock.js | 13 | ||||
-rw-r--r-- | html/dialog.html | 4 | ||||
-rw-r--r-- | html/dialog.js | 33 | ||||
-rw-r--r-- | html/grid.css | 5 | ||||
-rw-r--r-- | html/item_list.html | 39 | ||||
-rw-r--r-- | html/item_list.js | 2 | ||||
-rw-r--r-- | html/item_preview.html | 8 | ||||
-rw-r--r-- | html/item_preview.js | 4 | ||||
-rw-r--r-- | html/payload_create.html | 76 | ||||
-rw-r--r-- | html/payload_create.js | 229 | ||||
-rw-r--r-- | test/unit/test_dialog.py | 45 | ||||
-rw-r--r-- | test/unit/test_indexeddb.py | 46 | ||||
-rw-r--r-- | test/unit/test_item_list.py | 42 | ||||
-rw-r--r-- | test/unit/test_payload_create.py | 121 | ||||
-rw-r--r-- | test/unit/utils.py | 53 |
16 files changed, 614 insertions, 110 deletions
diff --git a/common/indexeddb.js b/common/indexeddb.js index f9c407a..a18c9be 100644 --- a/common/indexeddb.js +++ b/common/indexeddb.js @@ -313,8 +313,8 @@ async function _save_items(resources, mappings, context) /* * Save given definition of a resource/mapping to IndexedDB. If the definition * (passed as `item`) references files that are not already present in - * IndexedDB, those files should be present as values of the `files_sha256` - * object with keys being of the form `sha256-<file's-sha256-sum>`. + * IndexedDB, those files should be provided as values of the `files' object + * used to create the transaction context. * * context should be one returned from start_items_transaction() and should be * later passed to finalize_transaction() so that files depended on are added to diff --git a/common/lock.js b/common/lock.js index 8dd2f5b..f577481 100644 --- a/common/lock.js +++ b/common/lock.js @@ -68,9 +68,20 @@ function _lock(lock, cb) { #EXPORT lock => new Promise(resolve => _lock(lock, resolve)) AS lock +function try_lock(lock) +{ + if (lock.free) { + lock.free = false; + return true; + } + + return false; +} +#EXPORT try_lock + function unlock(lock) { if (lock.free) - throw new Error("Attempting to release a free lock"); + throw new Exception("Attempting to release a free lock"); if (lock.queue.length === 0) { lock.free = true; diff --git a/html/dialog.html b/html/dialog.html index d4e69b9..090a76b 100644 --- a/html/dialog.html +++ b/html/dialog.html @@ -53,12 +53,12 @@ margin-bottom: 2em; text-align: center; } - .dialog_main_div { + .dialog_main_view { margin: 1.4em; } </style> <template> - <div id="dialog" data-template="main_div" class="dialog_main_div"> + <div id="dialog" data-template="main_div" class="dialog_main_view"> <div data-template="msg" class="dialog_msg"></div> <div data-template="ask_buts" class="dialog_buts"> <button data-template="yes_but" class="right_space">Yes</button> diff --git a/html/dialog.js b/html/dialog.js index 6345b2d..22e8aa9 100644 --- a/html/dialog.js +++ b/html/dialog.js @@ -42,7 +42,7 @@ */ #FROM html/DOM_helpers.js IMPORT clone_template -#FROM common/lock.js IMPORT make_lock, lock, unlock +#FROM common/lock.js IMPORT make_lock, lock, try_lock, unlock function make(on_dialog_show, on_dialog_hide) { @@ -68,9 +68,15 @@ function make(on_dialog_show, on_dialog_hide) function close_dialog(dialog_context, event) { + if (event && event.target.parentElement.classList.contains("hide")) + return; + + const result = event ? event.target.haketilo_dialog_result : undefined; + if (dialog_context.queue > 0) - dialog_context.callback(event.target.haketilo_dialog_result); + dialog_context.callback(result); } +#EXPORT close_dialog AS close async function show_dialog(dialog_context, shown_buts_id, msg) { @@ -81,11 +87,19 @@ async function show_dialog(dialog_context, shown_buts_id, msg) dialog_context.on_dialog_show(); } - await lock(dialog_context.lock); - - dialog_context.msg.innerText = msg; + /* + * We want the dialog to be ready for calling close() right after + * show_dialog() gets called. For this, we want locking to happen + * synchronously if possible. If impossible (lock taken), this means dialog + * is already open, hence it's also ready for close()'ing. + */ + if (!try_lock(dialog_context.lock)) + await lock(dialog_context.lock); + + [...dialog_context.msg.childNodes].forEach(n => n.remove()); + dialog_context.msg.append(...msg); for (const buts_id of ["ask_buts", "conf_buts"]) { - const action = buts_id == shown_buts_id ? "remove" : "add"; + const action = buts_id === shown_buts_id ? "remove" : "add"; dialog_context[buts_id].classList[action]("hide"); } @@ -101,12 +115,15 @@ async function show_dialog(dialog_context, shown_buts_id, msg) return result; } -const error = (ctx, msg) => show_dialog(ctx, "conf_buts", msg); +const error = (ctx, ...msg) => show_dialog(ctx, "conf_buts", msg); #EXPORT error /* info() and error() are the same for now, we might later change that. */ const info = error; #EXPORT info -const ask = (ctx, msg) => show_dialog(ctx, "ask_buts", msg); +const ask = (ctx, ...msg) => show_dialog(ctx, "ask_buts", msg); #EXPORT ask + +const loader = (ctx, ...msg) => show_dialog(ctx, null, msg); +#EXPORT loader diff --git a/html/grid.css b/html/grid.css index 59b5bb7..591772e 100644 --- a/html/grid.css +++ b/html/grid.css @@ -27,6 +27,11 @@ * proprietary program, I am not going to enforce this in court. */ +.grid_1 { + display: grid; + grid-template-columns: auto; +} + .grid_2 { display: grid; grid-template-columns: 1fr 1fr; diff --git a/html/item_list.html b/html/item_list.html index d082c5d..5d2a163 100644 --- a/html/item_list.html +++ b/html/item_list.html @@ -41,20 +41,41 @@ #LOADCSS html/base.css #LOADCSS html/grid.css <style> + .item_list { + overflow-y: scroll; + } + .item_list>li { + cursor: pointer; + padding: 0.15em 0.3em; + } + .list_main_view { + grid-template-columns: auto 1fr; + } .item_li_highlight { background-color: #c0c0c0; } + .item_list>li.item_li_highlight { + cursor: default; + } + .list_buttons { + margin: 1em auto; + text-align: center; + } </style> <template> - <div id="item_list" data-template="main_div" class="grid_2"> - <ul data-template="ul"></ul> - <div data-template="preview_container" class="hide"> - <!-- preview div will be dynamically inserted here --> - - <button data-template="remove_but">Remove</button> - <!--<button data-template="export_but">Export</button>--> - </div> - <div data-template="dialog_container" class="hide"> + <div id="item_list" data-template="main_div" class="grid_2 list_main_view"> + <ul data-template="ul" class="item_list"></ul> + <div> + <div data-template="preview_container" class="hide"> + <!-- preview div will be dynamically inserted here --> + <div class="list_buttons"> + <button data-template="remove_but">Remove</button> + <!--<button data-template="export_but">Export</button>--> + </div> + </div> + <div data-template="dialog_container" class="hide"> + <!-- dialog div will be dynamically inserted here --> + </div> </div> </div> </template> diff --git a/html/item_list.js b/html/item_list.js index 55e54fb..34dec83 100644 --- a/html/item_list.js +++ b/html/item_list.js @@ -185,7 +185,7 @@ async function item_list(preview_cb, track_cb, remove_cb) function on_dialog_show(list_ctx) { - list_ctx.ul; + list_ctx.ul; // TODO: make ul non-selectable when dialog is shown list_ctx.preview_container.classList.add("hide"); list_ctx.dialog_container.classList.remove("hide"); } diff --git a/html/item_preview.html b/html/item_preview.html index 76c6da6..a00d299 100644 --- a/html/item_preview.html +++ b/html/item_preview.html @@ -38,12 +38,12 @@ #LOADCSS html/base.css #LOADCSS html/grid.css <style> - .dialog_main_div { - margin: 1.4em; + .preview_main_div { + margin: 0.8em 0; } </style> <template> - <div id="resource_preview_div" data-template="main_div" + <div id="resource_preview" data-template="main_div" class="grid_2 grid_form preview_main_div"> <h3 class="grid_col_both">resource preview</h3> <span class="grid_col_1">identifier:</span> @@ -65,7 +65,7 @@ <span class="grid_col_1">copyright:</span> <span class="grid_col_2"><ul data-template="copyright"></ul></span> </div> - <div id="mapping_preview_div" data-template="main_div" + <div id="mapping_preview" data-template="main_div" class="grid_2 grid_form"> <h3 class="grid_col_both">mapping preview</h3> <span class="grid_col_1">identifier:</span> diff --git a/html/item_preview.js b/html/item_preview.js index 447b16a..474766c 100644 --- a/html/item_preview.js +++ b/html/item_preview.js @@ -85,7 +85,7 @@ function make_file_link(preview_object, file_ref) function resource_preview(resource, preview_object, dialog_context) { if (preview_object === undefined) - preview_object = clone_template("resource_preview_div"); + preview_object = clone_template("resource_preview"); preview_object.identifier.innerText = resource.identifier; preview_object.long_name.innerText = resource.long_name; @@ -116,7 +116,7 @@ function resource_preview(resource, preview_object, dialog_context) function mapping_preview(mapping, preview_object, dialog_context) { if (preview_object === undefined) - preview_object = clone_template("mapping_preview_div"); + preview_object = clone_template("mapping_preview"); preview_object.identifier.innerText = mapping.identifier; preview_object.long_name.innerText = mapping.long_name; diff --git a/html/payload_create.html b/html/payload_create.html new file mode 100644 index 0000000..0f6927f --- /dev/null +++ b/html/payload_create.html @@ -0,0 +1,76 @@ +#IF !PAYLOAD_CREATE_LOADED +#DEFINE PAYLOAD_CREATE_LOADED +<!-- + SPDX-License-Identifier: GPL-3.0-or-later OR CC-BY-SA-4.0 + + Simple site payload creation form. + + This file is part of Haketilo. + + Copyright (C) 2022 Wojtek Kosior <koszko@koszko.org> + + File is dual-licensed. You can choose either GPLv3+, CC BY-SA or both. + + 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 <https://www.gnu.org/licenses/>. + + I, Wojtek Kosior, thereby promise not to sue for violation of this file's + licenses. Although I request that you do not make use of this code in a + proprietary program, I am not going to enforce this in court. + --> + +<!-- + This is not a standalone page. This file is meant to be imported into other + HTML code. + --> + +#INCLUDE html/dialog.html + +#LOADCSS html/reset.css +#LOADCSS html/base.css +#LOADCSS html/grid.css +<style> + .payload_create_main_view { + margin: 0.8em; + } + .payload_create_buts { + text-align: center; + } +</style> +<template> + <div id="payload_create" data-template="main_div" + class="payload_create_main_view"> + <div data-template="form_container" class="grid_1"> + <h3>create_payload</h3> + <label> + identifier* (may only contain digits 0-9, lowercase letters a-z and hyphens '-'; will have 'local-' prepended): + </label> + <input data-template="identifier"> + <label>long name (defaults to the same as identifier):</label> + <input data-template="long_name"> + <label>description:</label> + <textarea data-template="description"></textarea> + <label>URL patterns* (put each on its own line):</label> + <textarea data-template="patterns">https://example.com/***</textarea> + <label>script to inject*:</label> + <textarea data-template="script">console.log("Hello, World!");</textarea> + <div class="payload_create_buts"> + <button data-template="create_but">Create</button> + </div> + </div> + <div data-template="dialog_container" class="hide"> + <!-- dialog div will be dynamically inserted here --> + </div> + </div> +</template> +#ENDIF diff --git a/html/payload_create.js b/html/payload_create.js new file mode 100644 index 0000000..db63a82 --- /dev/null +++ b/html/payload_create.js @@ -0,0 +1,229 @@ +/** + * This file is part of Haketilo. + * + * Function: Driving the site payload creation form. + * + * Copyright (C) 2022 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 <https://www.gnu.org/licenses/>. + * + * I, Wojtek Kosior, thereby promise not to sue for violation of this file's + * license. Although I request that you do not make use of this code in a + * proprietary program, I am not going to enforce this in court. + */ + +#IMPORT html/dialog.js +#IMPORT common/indexeddb.js AS haketilodb + +#FROM html/DOM_helpers.js IMPORT clone_template +#FROM common/sha256.js IMPORT sha256 +#FROM common/patterns.js IMPORT deconstruct_url + +/* https://stackoverflow.com/questions/105034/how-to-create-a-guid-uuid */ +/* This is a helper function used by uuidv4(). */ +function uuid_replace_num(num) +{ + const randbyte = crypto.getRandomValues(new Uint8Array(1))[0]; + return (num ^ randbyte & 15 >> num / 4).toString(16); +} + +/* Generate a new UUID. */ +function uuidv4() +{ + return ([1e7]+-1e3+-4e3+-8e3+-1e11).replace(/[018]/g, uuid_replace_num); +} + +function collect_form_data(form_ctx) +{ + const identifier_nonprepended = form_ctx.identifier.value; + if (!identifier_nonprepended) + throw "The 'identifier' field is required!"; + if (!/[-a-zA-Z]+/.test(identifier_nonprepended)) + throw "Identifier may only contain digits 0-9, lowercase letters a-z and hyphens '-'!" + const identifier = `local-${identifier_nonprepended}`; + + const long_name = form_ctx.long_name.value || identifier_nonprepended; + + const description = form_ctx.description.value; + + const url_patterns = form_ctx.patterns.value.split("\n").filter(i => i); + if (url_patterns.length === 0) + throw "The 'URL patterns' field is required!"; + + const payloads = {}; + + for (const pattern of url_patterns) { + try { + deconstruct_url(pattern); + } catch(e) { + const patterns_doc_url = + "https://hydrillabugs.koszko.org/projects/haketilo/wiki/URL_patterns"; + const patterns_doc_link = document.createElement("a"); + patterns_doc_link.href = patterns_doc_url; + patterns_doc_link.innerText = "here"; + const msg = document.createElement("span"); + msg.prepend(`'${pattern}' is not a valid URL pattern. See `, + patterns_doc_link, " for more details."); + throw msg; + } + + if (pattern in payloads) + throw `Pattern '${pattern}' soecified multiple times!`; + + payloads[pattern] = {identifier}; + } + + const script = form_ctx.script.value; + if (!script) + throw "The 'script' field is required!"; + const hash_key = `sha256-${sha256(script)}`; + + const resource = { + source_name: identifier, + source_copyright: [], + type: "resource", + identifier, + long_name, + uuid: uuidv4(), + version: [1], + description, + dependencies: [], + scripts: [{file: "payload.js", hash_key}] + }; + + const mapping = { + source_name: identifier, + source_copyright: [], + type: "mapping", + identifier, + long_name, + uuid: uuidv4(), + version: [1], + description, + payloads + }; + + return {identifier, resource, mapping, files: {[hash_key]: script}}; +} + +async function save_payload(saving) +{ + const db = await haketilodb.get(); + const tx_starter = haketilodb.start_items_transaction; + const tx_ctx = await tx_starter(["resources", "mappings"], saving.files); + + for (const [type, store_name] of + [["resource", "resources"], ["mapping", "mappings"]]) { + if (!saving[`override_${type}`] && + (await haketilodb.idb_get(tx_ctx.transaction, store_name, + saving.identifier))) { + saving.ask_override = "resource"; + return; + } + } + + await haketilodb.save_item(saving.resource, tx_ctx); + await haketilodb.save_item(saving.mapping, tx_ctx); + + return haketilodb.finalize_transaction(tx_ctx); +} + +function override_question(saving) +{ + return saving.ask_override === "resource" ? + `Resource '${saving.identifier}' alredy exists. Override?` : + `Mapping '${saving.identifier}' alredy exists. Override?`; +} + +async function create_clicked(form_ctx) +{ + if (form_ctx.dialog_ctx.shown) + return; + + dialog.loader(form_ctx.dialog_ctx, "Saving payload..."); + + try { + var saving = collect_form_data(form_ctx); + } catch(e) { + dialog.error(form_ctx.dialog_ctx, e); + dialog.close(form_ctx.dialog_ctx); + return; + } + + try { + do { + if (saving.ask_override) { + const override_prom = dialog.ask(form_ctx.dialog_ctx, + override_question(saving)); + dialog.loader(form_ctx.dialog_ctx, "Saving payload..."); + dialog.close(form_ctx.dialog_ctx); + if (!(await override_prom)) + throw "Saving would override existing data."; + + saving[`override_${saving.ask_override}`] = true; + delete saving.ask_override; + } + + await save_payload(saving); + } while (saving.ask_override); + + dialog.info(form_ctx.dialog_ctx, "Successfully saved payload!"); + } catch(e) { + console.error(e); + dialog.error(form_ctx.dialog_ctx, "Failed to save payload :(") + } + + dialog.close(form_ctx.dialog_ctx); +} + +function on_show_hide(form_ctx, what_to_show) +{ + for (const item_id of ["form", "dialog"]) { + const action = item_id === what_to_show ? "remove" : "add"; + form_ctx[`${item_id}_container`].classList[action]("hide"); + } +} + +function payload_create_form() +{ + const form_ctx = clone_template("payload_create"); + + form_ctx.dialog_ctx = dialog.make(() => on_show_hide(form_ctx, "dialog"), + () => on_show_hide(form_ctx, "form")); + form_ctx.dialog_container.prepend(form_ctx.dialog_ctx.main_div); + + form_ctx.create_but.addEventListener("click", + () => create_clicked(form_ctx)); + + return form_ctx; +} +#EXPORT payload_create_form diff --git a/test/unit/test_dialog.py b/test/unit/test_dialog.py index 384a889..63af79e 100644 --- a/test/unit/test_dialog.py +++ b/test/unit/test_dialog.py @@ -29,31 +29,35 @@ from ..script_loader import load_script @pytest.mark.usefixtures('webextension') def test_dialog_show_close(driver, execute_in_page): """ - A test case of basing dialog showing/closing. + A test case of basic dialog showing/closing. """ execute_in_page(load_script('html/dialog.js')) - execute_in_page( + buts = execute_in_page( ''' let cb_calls, call_prom; const dialog_context = make(() => cb_calls.push("show"), () => cb_calls.push("hide")); document.body.append(dialog_context.main_div); + const buts = {}; + for (const but of document.getElementsByTagName("button")) + buts[but.textContent] = but; + returnval(buts); ''') - buts = driver.find_elements_by_tag_name('button') - buts = dict([(but.text, but) for but in buts]) - - for i, (dialog_function, button_text, expected_result) in enumerate([ - ('info', 'Ok', None), - ('error', 'Ok', None), - ('ask', 'Yes', True), - ('ask', 'No', False) + for i, (dialog_function, but_text, hidden, expected_result) in enumerate([ + ('info', 'Ok', ['Yes', 'No'], None), + ('error', 'Ok', ['Yes', 'No'], None), + ('error', None, ['Yes', 'No'], None), + ('loader', None, ['Yes', 'No', 'Ok'], None), + ('ask', 'Yes', ['Ok'], True), + ('ask', None, ['Ok'], None), + ('ask', 'No', ['Ok'], False) ]): cb_calls, is_shown = execute_in_page( f''' cb_calls = []; call_prom = {dialog_function}(dialog_context, - `sample_text_${{arguments[0]}}`); + `sample_text_${{arguments[0]}}`); returnval([cb_calls, dialog_context.shown]); ''', i) @@ -64,14 +68,23 @@ def test_dialog_show_close(driver, execute_in_page): assert f'sample_text_{i}' in page_source assert f'sample_text_{i - 1}' not in page_source - assert any([not but.is_displayed() for but in buts.values()]) - - assert buts[button_text].is_displayed() - buts[button_text].click() + # Verify the right buttons are displayed. + for text, but in buts.items(): + if text in hidden: + assert not but.is_displayed() + # Verify clicking a hidden button does nothing. + execute_in_page('buts[arguments[0]].click();', text) + assert execute_in_page('returnval(cb_calls);') == cb_calls + else: + assert but.is_displayed() + + if but_text is None: + execute_in_page('close_dialog(dialog_context);') + else: + buts[but_text].click() cb_calls, result, is_shown = execute_in_page( '''{ - console.error(dialog_context.msg.textContent); const values_cb = r => [cb_calls, r, dialog_context.shown]; returnval(call_prom.then(values_cb)); }''') diff --git a/test/unit/test_indexeddb.py b/test/unit/test_indexeddb.py index 9dfbe63..0c0e7a0 100644 --- a/test/unit/test_indexeddb.py +++ b/test/unit/test_indexeddb.py @@ -6,7 +6,7 @@ Haketilo unit tests - IndexedDB access # This file is part of Haketilo # -# Copyright (C) 2021, Wojtek Kosior <koszko@koszko.org> +# Copyright (C) 2021,2022 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 @@ -25,7 +25,7 @@ from selenium.webdriver.support import expected_conditions as EC from selenium.common.exceptions import WebDriverException from ..script_loader import load_script -from .utils import sample_files, sample_files_by_hash, sample_file_ref +from .utils import * indexeddb_js = lambda: load_script('common/indexeddb.js') broker_js = lambda: load_script('background/broadcast_broker.js') + ';start();' @@ -54,48 +54,6 @@ def make_sample_mapping(): 'identifier': 'helloapple' } -def clear_indexeddb(execute_in_page): - execute_in_page( - '''{ - async function delete_db() { - if (db) { - db.close(); - db = null; - } - let resolve, reject; - const result = new Promise((...cbs) => [resolve, reject] = cbs); - const request = indexedDB.deleteDatabase("haketilo"); - [request.onsuccess, request.onerror] = [resolve, reject]; - await result; - } - - returnval(delete_db()); - }''' - ) - -def get_db_contents(execute_in_page): - # Facilitate retrieving all IndexedDB contents. - return execute_in_page( - '''{ - async function get_database_contents() - { - const db = await get_db(); - - const transaction = db.transaction(db.objectStoreNames); - const store_names_reqs = [...db.objectStoreNames] - .map(sn => [sn, transaction.objectStore(sn).getAll()]) - - const promises = store_names_reqs - .map(([_, req]) => wait_request(req)); - await Promise.all(promises); - - const result = {}; - store_names_reqs.forEach(([sn, req]) => result[sn] = req.result); - return result; - } - returnval(get_database_contents()); - }''') - def mock_broadcast(execute_in_page): execute_in_page( '''{ diff --git a/test/unit/test_item_list.py b/test/unit/test_item_list.py index e2e1af8..62ec84e 100644 --- a/test/unit/test_item_list.py +++ b/test/unit/test_item_list.py @@ -26,6 +26,27 @@ from .utils import * broker_js = lambda: load_script('background/broadcast_broker.js') + ';start();' +def make_sample_resource(identifier, long_name): + return { + 'source_name': 'hello', + 'source_copyright': [ + sample_file_ref('report.spdx'), + sample_file_ref('LICENSES/CC0-1.0.txt') + ], + 'type': 'resource', + 'identifier': identifier, + 'long_name': long_name, + 'uuid': 'a6754dcb-58d8-4b7a-a245-24fd7ad4cd68', + 'version': [2021, 11, 10], + 'revision': 1, + 'description': 'greets an apple', + 'dependencies': ['hello-message'], + 'scripts': [ + sample_file_ref('hello.js'), + sample_file_ref('bye.js') + ] + } + def make_sample_mapping(identifier, long_name): return { 'source_name': 'example-org-fixes-new', @@ -49,27 +70,6 @@ def make_sample_mapping(identifier, long_name): } } -def make_sample_resource(identifier, long_name): - return { - 'source_name': 'hello', - 'source_copyright': [ - sample_file_ref('report.spdx'), - sample_file_ref('LICENSES/CC0-1.0.txt') - ], - 'type': 'resource', - 'identifier': identifier, - 'long_name': long_name, - 'uuid': 'a6754dcb-58d8-4b7a-a245-24fd7ad4cd68', - 'version': [2021, 11, 10], - 'revision': 1, - 'description': 'greets an apple', - 'dependencies': ['hello-message'], - 'scripts': [ - sample_file_ref('hello.js'), - sample_file_ref('bye.js') - ] - } - def make_item(item_type, *args): return make_sample_resource(*args) if item_type == 'resource' \ else make_sample_mapping(*args) diff --git a/test/unit/test_payload_create.py b/test/unit/test_payload_create.py new file mode 100644 index 0000000..cd08d43 --- /dev/null +++ b/test/unit/test_payload_create.py @@ -0,0 +1,121 @@ +# SPDX-License-Identifier: CC0-1.0 + +""" +Haketilo unit tests - using a form to create simple site payload +""" + +# This file is part of Haketilo +# +# Copyright (C) 2022 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 +from selenium.webdriver.support.ui import WebDriverWait + +from ..extension_crafting import ExtraHTML +from ..script_loader import load_script +from .utils import clear_indexeddb, get_db_contents, sample_files + +broker_js = lambda: load_script('background/broadcast_broker.js') + ';start();' + +uuidv4_re = re.compile( + r'^[0-9A-F]{8}-[0-9A-F]{4}-4[0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12}$', + re.IGNORECASE +) + +sample_patterns = ''' +http://example.com/*** + +https://*.example.org/**''' + +sample_form_data = { + 'identifier': 'someid', + 'long_name': 'Some Name', + 'description': 'blah blah blah', + 'patterns': sample_patterns, + 'script': sample_files['hello.js']['contents'] +} + +def fill_form_with_sample_data(execute_in_page, sample_data_override={}, + form_ctx='form_ctx'): + form_data = sample_form_data.copy() + form_data.update(sample_data_override) + execute_in_page( + f''' + for (const [key, value] of Object.entries(arguments[0])) + {form_ctx}[key].value = value; + ''', + form_data) + +@pytest.mark.ext_data({ + 'background_script': broker_js, + 'extra_html': ExtraHTML('html/payload_create.html', {}), + 'navigate_to': 'html/payload_create.html' +}) +@pytest.mark.usefixtures('webextension') +def test_payload_create(driver, execute_in_page): + """ + A test case of creating a simple payload using a form. + """ + clear_indexeddb(execute_in_page) + execute_in_page(load_script('html/payload_create.js')) + + create_but, main_div = execute_in_page( + ''' + const form_ctx = payload_create_form(); + document.body.append(form_ctx.main_div); + returnval([form_ctx.create_but, form_ctx.main_div]); + ''') + + fill_form_with_sample_data(execute_in_page) + + create_but.click() + + def success_reported(driver): + return 'Successfully saved payload' in main_div.text + + WebDriverWait(driver, 10).until(success_reported) + + db_contents = get_db_contents(execute_in_page) + + assert uuidv4_re.match(db_contents['resources'][0]['uuid']) + assert db_contents['resources'] == [{ + 'source_name': 'local-someid', + 'source_copyright': [], + 'type': 'resource', + 'identifier': 'local-someid', + 'long_name': 'Some Name', + 'uuid': db_contents['resources'][0]['uuid'], + 'version': [1], + 'description': 'blah blah blah', + 'dependencies': [], + 'scripts': [{ + 'file': 'payload.js', + 'hash_key': sample_files['hello.js']['hash_key'] + }] + }] + + assert uuidv4_re.match(db_contents['mappings'][0]['uuid']) + assert db_contents['mappings'] == [{ + 'source_name': 'local-someid', + 'source_copyright': [], + 'type': 'mapping', + 'identifier': 'local-someid', + 'long_name': 'Some Name', + 'uuid': db_contents['mappings'][0]['uuid'], + 'version': [1], + 'description': 'blah blah blah', + 'payloads': { + 'http://example.com/***': {'identifier': 'local-someid'}, + 'https://*.example.org/**': {'identifier': 'local-someid'} + } + }] diff --git a/test/unit/utils.py b/test/unit/utils.py index b6b389f..a61e215 100644 --- a/test/unit/utils.py +++ b/test/unit/utils.py @@ -66,3 +66,56 @@ def sample_data_dict(items): """ return dict([(it['identifier'], {item_version_string(it): it}) for it in items]) + +def clear_indexeddb(execute_in_page): + """ + Remove Haketilo data from IndexedDB. If variables from common/indexeddb.js + are in the global scope, this function will handle closing the opened + database instance (if any). Otherwise, the caller is responsible for making + sure the database being deleted is not opened anywhere. + """ + execute_in_page( + '''{ + async function delete_db() { + if (typeof db !== "undefined" && db) { + db.close(); + db = null; + } + let resolve, reject; + const result = new Promise((...cbs) => [resolve, reject] = cbs); + const request = indexedDB.deleteDatabase("haketilo"); + [request.onsuccess, request.onerror] = [resolve, reject]; + await result; + } + + returnval(delete_db()); + }''' + ) + +def get_db_contents(execute_in_page): + """ + Retrieve all IndexedDB contents. It is expected that either variables from + common/indexeddb.js are in the global scope or common/indexeddb.js is + imported as haketilodb. + """ + return execute_in_page( + '''{ + async function get_database_contents() + { + const db_getter = + typeof haketilodb === "undefined" ? get_db : haketilodb.get; + const db = await db_getter(); + + const transaction = db.transaction(db.objectStoreNames); + const result = {}; + + for (const store_name of db.objectStoreNames) { + const req = transaction.objectStore(store_name).getAll(); + await new Promise(cb => req.onsuccess = cb); + result[store_name] = req.result; + } + + return result; + } + returnval(get_database_contents()); + }''') |