summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorWojtek Kosior <koszko@koszko.org>2022-01-10 23:38:56 +0100
committerWojtek Kosior <koszko@koszko.org>2022-01-10 23:38:56 +0100
commit19304cd1ae4e4ba4f6dcf4f1db14de1e4e70c250 (patch)
tree2e7e6f904ad16f9402827a7bc215a419de5c2656
parent38650a8102fe0841617cd80f3a6e45b1f5f62fd5 (diff)
downloadbrowser-extension-19304cd1ae4e4ba4f6dcf4f1db14de1e4e70c250.tar.gz
browser-extension-19304cd1ae4e4ba4f6dcf4f1db14de1e4e70c250.zip
improve item list styling; add payload creation form; exend dialog mechanism
-rw-r--r--common/indexeddb.js4
-rw-r--r--common/lock.js13
-rw-r--r--html/dialog.html4
-rw-r--r--html/dialog.js33
-rw-r--r--html/grid.css5
-rw-r--r--html/item_list.html39
-rw-r--r--html/item_list.js2
-rw-r--r--html/item_preview.html8
-rw-r--r--html/item_preview.js4
-rw-r--r--html/payload_create.html76
-rw-r--r--html/payload_create.js229
-rw-r--r--test/unit/test_dialog.py45
-rw-r--r--test/unit/test_indexeddb.py46
-rw-r--r--test/unit/test_item_list.py42
-rw-r--r--test/unit/test_payload_create.py121
-rw-r--r--test/unit/utils.py53
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());
+ }''')