aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--common/lock.js2
-rwxr-xr-xcompute_scripts.awk18
-rw-r--r--copyright2
-rw-r--r--html/DOM_helpers.js2
-rw-r--r--html/base.css82
-rw-r--r--html/dialog.html72
-rw-r--r--html/dialog.js112
-rw-r--r--html/grid.css75
-rw-r--r--html/item_list.html61
-rw-r--r--html/item_list.js189
-rw-r--r--html/item_preview.html89
-rw-r--r--html/item_preview.js151
-rw-r--r--test/extension_crafting.py4
-rw-r--r--test/unit/conftest.py14
-rw-r--r--test/unit/test_basic.py19
-rw-r--r--test/unit/test_default_policy_dialog.py50
-rw-r--r--test/unit/test_dialog.py130
-rw-r--r--test/unit/test_indexeddb.py36
-rw-r--r--test/unit/test_item_list.py180
-rw-r--r--test/unit/test_item_preview.py235
-rw-r--r--test/unit/utils.py59
21 files changed, 1465 insertions, 117 deletions
diff --git a/common/lock.js b/common/lock.js
index 5a2d7c8..8dd2f5b 100644
--- a/common/lock.js
+++ b/common/lock.js
@@ -70,7 +70,7 @@ function _lock(lock, cb) {
function unlock(lock) {
if (lock.free)
- throw new Exception("Attempting to release a free lock");
+ throw new Error("Attempting to release a free lock");
if (lock.queue.length === 0) {
lock.free = true;
diff --git a/compute_scripts.awk b/compute_scripts.awk
index 2d7ea72..1db79e0 100755
--- a/compute_scripts.awk
+++ b/compute_scripts.awk
@@ -38,6 +38,8 @@ BEGIN {
directive_args_patterns["ENDIF"] = "^$"
directive_args_patterns["ELSE"] = "^$"
directive_args_patterns["ELIF"] = "^(NOT[[:space:]]+)?" identifier_re "$"
+ directive_args_patterns["DEFINE"] = "^" identifier_re "$"
+ directive_args_patterns["UNDEF"] = "^" identifier_re "$"
directive_args_patterns["ERROR"] = "^.*$"
directive_args_patterns["COPY"] = "^[^[:space:]]+$"
directive_args_patterns["INCLUDE"] = "^[^[:space:]]+$"
@@ -57,6 +59,8 @@ BEGIN {
directive_args_patterns["LOADJS"] = "^[^[:space:]]+$"
directive_args_patterns["LOADCSS"] = "^[^[:space:]]+$"
+
+ directive_args_patterns["LOADHTML"] = "^[^[:space:]]+$"
}
function validate_path(read_path, path, line) {
@@ -200,7 +204,7 @@ function process_file(path, read_path, mode,
sub(/[[:space:]].*$/, "", directive)
if (directive !~ \
- /^(IF|ENDIF|ELSE|ELIF|ERROR|INCLUDE|INCLUDE_VERBATIM|COPY_FILE)$/ &&
+ /^(IF|ENDIF|ELSE|ELIF|DEFINE|UNDEF|ERROR|INCLUDE|INCLUDE_VERBATIM|COPY_FILE)$/ &&
(mode != "js" || directive !~ /^(IMPORT|FROM|EXPORT)$/) &&
(mode != "html" || directive !~ /^(LOADJS|LOADCSS)$/) &&
(mode != "manifest" || directive !~ /^(LOADJS|LOADHTML)$/)) {
@@ -222,7 +226,7 @@ function process_file(path, read_path, mode,
if (directive == "IF") {
if (if_nesting_true == if_nesting) {
- if (if_condition_true(directive_args))
+ if (if_condition_true(directive_args, path))
if_nesting_true++
else
if_branch_processed = false
@@ -261,7 +265,7 @@ function process_file(path, read_path, mode,
}
if (if_nesting == if_nesting_true + 1 && !if_branch_processed &&
- if_condition_true(directive_args)) {
+ if_condition_true(directive_args, path)) {
if_nesting_true++
} else if (if_nesting == if_nesting_true) {
if_branch_processed = true
@@ -269,6 +273,10 @@ function process_file(path, read_path, mode,
}
} else if (if_nesting_true != if_nesting) {
continue
+ } else if (directive == "DEFINE") {
+ defines[path,directive_args]
+ } else if (directive == "UNDEF") {
+ delete defines[path,directive_args]
} else if (directive == "ERROR") {
printf "ERROR: File %s says: %s\n",
read_path, directive_args > "/dev/stderr"
@@ -332,7 +340,7 @@ function process_file(path, read_path, mode,
delete reading[read_path]
}
-function if_condition_true(directive_args,
+function if_condition_true(directive_args, path,
result, bool, first_iter, word, negated, alt) {
first_iter = true
@@ -344,7 +352,7 @@ function if_condition_true(directive_args,
negated = word ~ /^!/
sub(/^!/, "", word)
- bool = (word in defines) != negated
+ bool = (word in defines || (path,word) in defines) != negated
if (first_iter) {
result = bool
diff --git a/copyright b/copyright
index 08e1358..2541bde 100644
--- a/copyright
+++ b/copyright
@@ -84,7 +84,7 @@ Copyright: 2021 Wojtek Kosior <koszko@koszko.org>
License: CC0
Files: test/profiles.py test/script_loader.py test/unit/conftest.py
- test/extension_crafting.py
+ test/extension_crafting.py test/unit/utils.py
Copyright: 2021 Wojtek Kosior <koszko@koszko.org>
License: GPL-3+
Comment: Wojtek Kosior promises not to sue even in case of violations
diff --git a/html/DOM_helpers.js b/html/DOM_helpers.js
index 55320cb..88092e5 100644
--- a/html/DOM_helpers.js
+++ b/html/DOM_helpers.js
@@ -70,7 +70,7 @@ function clone_template(template_id)
while (to_process.length > 0) {
const element = to_process.pop();
- const template_key = element.getAttribute("data-template");
+ let template_key = element.getAttribute("data-template") || element.id;
if (template_key)
result_object[template_key] = element;
diff --git a/html/base.css b/html/base.css
index dde50e7..6085f5f 100644
--- a/html/base.css
+++ b/html/base.css
@@ -34,101 +34,51 @@ body {
overflow: auto;
}
-.bold, h2 {
+.bold, h1, h2, h3 {
font-weight: bold;
}
+h1, h2, h3 {
+ margin: 0.3em;
+ text-shadow: 0 0 0 #454;
+}
+
h2 {
- margin: 8px;
font-size: 120%;
}
h3 {
- padding: 5px;
font-size: 108%;
- text-shadow: 0 0 0 #454;
}
-textarea {
- font-family: monospace;
+li {
+ margin-top: 0.25em;
+ margin-bottom: 0.25em;
}
-input[type="checkbox"], input[type="radio"], .hide {
- display: none;
-}
-
-.camouflage {
- visibility: hidden;
+li:first-child {
+ margin-top: 0;
}
-.show_next:not(:checked)+* {
+.hide {
display: none;
}
-.show_hide_next2:not(:checked)+* {
- display: none;
-}
-
-.show_hide_next2:checked+*+* {
- display: none;
-}
-
-button, .button {
+button {
background-color: #4CAF50;
border: none;
- border-radius: 8px;
+ border-radius: 0.4em;
color: white;
text-align: center;
text-decoration: none;
display: inline-block;
- padding: 6px 12px;
- margin: 2px 0px;
+ padding: 0.4em 0.8em;
-moz-user-select: none;
user-select: none;
cursor: pointer;
- font: 400 15px sans-serif;
-}
-
-button.slimbutton, .button.slimbutton {
- padding: 2px 4px;
- margin: 0;
+ font: 400 0.9em sans-serif;
}
button:hover, .button:hover {
box-shadow: 0 6px 8px 0 rgba(0,0,0,0.24), 0 17px 50px 0 rgba(0,0,0,0.19);
}
-
-aside {
- background: #3f8dc6;
- margin: 5px 0;
- padding: 0.3em 1em;
- border-radius: 3px;
- color: #fff;
-}
-
-textarea: {
- resize: none;
-}
-
-.has_bottom_line::after, .has_upper_line::before {
- content: "";
- display: block;
- height: 8px;
- background: linear-gradient(transparent, #555);
-}
-
-.has_bottom_line::after {
- background: linear-gradient(#555, transparent);
-}
-
-.has_bottom_thin_line {
- border-bottom: dashed #4CAF50 1px;
-}
-
-.has_upper_thin_line {
- border-top: dashed #4CAF50 1px;
-}
-
-.nowrap {
- white-space: nowrap;
-}
diff --git a/html/dialog.html b/html/dialog.html
new file mode 100644
index 0000000..d4e69b9
--- /dev/null
+++ b/html/dialog.html
@@ -0,0 +1,72 @@
+#IF !DIALOG_LOADED
+#DEFINE DIALOG_LOADED
+<!--
+ SPDX-License-Identifier: GPL-3.0-or-later OR CC-BY-SA-4.0
+
+ Show an error/info/question dalog.
+
+ 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.
+ -->
+
+#LOADCSS html/reset.css
+#LOADCSS html/base.css
+<style>
+ .left_space {
+ margin-left: 3em;
+ }
+ .right_space {
+ margin-right: 3em;
+ }
+ .dialog_buts {
+ margin-right: auto;
+ margin-left: auto;
+ max-width: -moz-fit-content;
+ max-width: fit-content;
+ }
+ .dialog_msg {
+ margin-bottom: 2em;
+ text-align: center;
+ }
+ .dialog_main_div {
+ margin: 1.4em;
+ }
+</style>
+<template>
+ <div id="dialog" data-template="main_div" class="dialog_main_div">
+ <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>
+ <button data-template="no_but" class="left_space">No</button>
+ </div>
+ <div data-template="conf_buts" class="dialog_buts">
+ <button data-template="ok_but">Ok</button>
+ </div>
+ </div>
+</template>
+#ENDIF
diff --git a/html/dialog.js b/html/dialog.js
new file mode 100644
index 0000000..6345b2d
--- /dev/null
+++ b/html/dialog.js
@@ -0,0 +1,112 @@
+/**
+ * This file is part of Haketilo.
+ *
+ * Function: Showing and error/info/confirmation dialog.
+ *
+ * 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.
+ */
+
+#FROM html/DOM_helpers.js IMPORT clone_template
+#FROM common/lock.js IMPORT make_lock, lock, unlock
+
+function make(on_dialog_show, on_dialog_hide)
+{
+ const dialog_context = clone_template("dialog");
+ Object.assign(dialog_context, {
+ on_dialog_show,
+ on_dialog_hide,
+ shown: false,
+ queue: 0,
+ lock: make_lock(),
+ callback: null
+ });
+
+ for (const [id, val] of [["yes", true], ["no", false], ["ok", undefined]]) {
+ const but = dialog_context[`${id}_but`];
+ but.haketilo_dialog_result = val;
+ but.addEventListener("click", e => close_dialog(dialog_context, e));
+ }
+
+ return dialog_context;
+}
+#EXPORT make
+
+function close_dialog(dialog_context, event)
+{
+ if (dialog_context.queue > 0)
+ dialog_context.callback(event.target.haketilo_dialog_result);
+}
+
+async function show_dialog(dialog_context, shown_buts_id, msg)
+{
+ dialog_context.queue++;
+
+ if (!dialog_context.shown) {
+ dialog_context.shown = true;
+ dialog_context.on_dialog_show();
+ }
+
+ await lock(dialog_context.lock);
+
+ dialog_context.msg.innerText = msg;
+ for (const buts_id of ["ask_buts", "conf_buts"]) {
+ const action = buts_id == shown_buts_id ? "remove" : "add";
+ dialog_context[buts_id].classList[action]("hide");
+ }
+
+ const result = await new Promise(cb => dialog_context.callback = cb);
+
+ if (--dialog_context.queue == 0) {
+ dialog_context.shown = false;
+ dialog_context.on_dialog_hide();
+ }
+
+ unlock(dialog_context.lock);
+
+ return result;
+}
+
+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);
+#EXPORT ask
diff --git a/html/grid.css b/html/grid.css
new file mode 100644
index 0000000..59b5bb7
--- /dev/null
+++ b/html/grid.css
@@ -0,0 +1,75 @@
+/*
+ * SPDX-License-Identifier: GPL-3.0-or-later OR CC-BY-SA-4.0
+ *
+ * Styling for use with `display: grid`.
+ *
+ * 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.
+ */
+
+.grid_2 {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+}
+
+.grid_col_1 {
+ grid-column: 1 / span 1;
+}
+
+.grid_col_2 {
+ grid-column: 2 / span 1;
+}
+
+.grid_col_both {
+ grid-column: 1 / span 2;
+}
+
+span.grid_col_1 {
+ text-align: right;
+}
+
+.grid_col_both {
+ text-align: center;
+}
+
+div.grid_col_both {
+ text-align: initial;
+}
+
+.grid_2>span {
+ margin-top: 0.75em;
+}
+
+.grid_form>span {
+ margin-top: 1.5em;
+ margin-left: 1em;
+ margin-right: 1em;
+}
+
+.grid_form>span {
+ font-weight: bold;
+}
+
+span.grid_col_2 {
+ font-weight: initial;
+}
diff --git a/html/item_list.html b/html/item_list.html
new file mode 100644
index 0000000..41c7734
--- /dev/null
+++ b/html/item_list.html
@@ -0,0 +1,61 @@
+#IF !ITEM_LIST_LOADED
+#DEFINE ITEM_LIST_LOADED
+<!--
+ SPDX-License-Identifier: GPL-3.0-or-later OR CC-BY-SA-4.0
+
+ Show a list of resources/mappings.
+
+ 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/item_preview.html
+#INCLUDE html/dialog.html
+
+#LOADCSS html/reset.css
+#LOADCSS html/base.css
+#LOADCSS html/grid.css
+<style>
+ .item_li_highlight {
+ background-color: #c0c0c0;
+ }
+</style>
+<template>
+ <div id="item_list" data-template="main_div" class="grid_2">
+ <ul data-template="ul"></ul>
+ <div data-template="preview_container">
+ <!-- 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>
+ </div>
+</template>
+#ENDIF
diff --git a/html/item_list.js b/html/item_list.js
new file mode 100644
index 0000000..f6b9bd3
--- /dev/null
+++ b/html/item_list.js
@@ -0,0 +1,189 @@
+/**
+ * This file is part of Haketilo.
+ *
+ * Function: Showing a list of resources/mappings in a browser.
+ *
+ * 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 common/indexeddb.js AS haketilodb
+#IMPORT html/dialog.js
+
+#FROM html/item_preview.js IMPORT resource_preview, mapping_preview
+#FROM html/DOM_helpers.js IMPORT clone_template
+
+function preview_item(list_ctx, item, ignore_dialog=false)
+{
+ if (list_ctx.dialog_ctx.shown && !ignore_dialog)
+ return;
+
+ list_ctx.preview_ctx =
+ list_ctx.preview_cb(item.definition, list_ctx.preview_ctx);
+ list_ctx.preview_container
+ .prepend(list_ctx.preview_ctx.main_div);
+
+ if (list_ctx.previewed_item !== null)
+ list_ctx.previewed_item.li.classList.remove("item_li_highlight");
+
+ list_ctx.previewed_item = item;
+ item.li.classList.add("item_li_highlight");
+
+ list_ctx.preview_container.classList.remove("hide");
+}
+
+function insert_item(list_ctx, definition, idx)
+{
+ const li = document.createElement("li");
+ li.innerText = definition.long_name;
+ if (idx)
+ list_ctx.items[idx - 1].li.after(li);
+ else
+ list_ctx.ul.prepend(li);
+
+ const item = {definition, li};
+ list_ctx.items.splice(idx, 0, item);
+ list_ctx.by_identifier.set(definition.identifier, item);
+
+ li.addEventListener("click", () => preview_item(list_ctx, item));
+
+ return item;
+}
+
+const coll = new Intl.Collator();
+
+function item_cmp(def1, def2)
+{
+ return coll.compare(def1.long_name, def2.long_name) ||
+ coll.compare(def1.identifier, def2.identifier);
+}
+
+function find_item_idx(definition)
+{
+ /* Perform a binary search of item's (new or not) index in sorted array. */
+ let left = 0, right = list_ctx.items.length;
+
+ while (left < right) {
+ const mid = (left + right) >> 1;
+ if (item_cmp(definition, list_ctx.items[mid].definition) > 0)
+ left = mid + 1;
+ else /* <= 0 */
+ right = mid;
+ }
+
+ return left;
+}
+
+function item_changed(list_ctx, change)
+{
+
+ /* Remove item. */
+ const old_item = list_ctx.by_identifier.get(change.key);
+ if (old_item !== undefined) {
+ list_ctx.items.splice(find_item_idx(old_item.definition), 1);
+ list_ctx.by_identifier.delete(change.key);
+ old_item.li.remove();
+
+ if (list_ctx.previewed_item === old_item) {
+ list_ctx.preview_container.classList.add("hide");
+ list_ctx.previewed_item = null;
+ }
+ }
+
+ if (change.new_val === undefined)
+ return;
+
+ const new_item = insert_item(list_ctx, change.new_val,
+ find_item_idx(change.new_val));
+ if (list_ctx.previewed_item === old_item)
+ preview_item(list_ctx, new_item, true);
+}
+
+async function item_list(preview_cb, track_cb)
+{
+ const list_ctx = clone_template("item_list");
+
+ const [tracking, definitions] =
+ await track_cb(ch => item_changed(list_ctx, ch));
+
+ definitions.sort(item_cmp);
+
+ Object.assign(list_ctx, {
+ items: [],
+ by_identifier: new Map(),
+ tracking,
+ previewed_item: null,
+ preview_cb,
+ dialog_ctx: dialog.make(() => on_dialog_show(list_ctx),
+ () => on_dialog_hide(list_ctx))
+ });
+ list_ctx.dialog_container.append(list_ctx.dialog_ctx.main_div);
+
+ for (const def of definitions)
+ insert_item(list_ctx, def, list_ctx.items.length);
+
+ return list_ctx;
+}
+
+function on_dialog_show(list_ctx)
+{
+ list_ctx.ul;
+ list_ctx.preview_container.classList.add("hide");
+ list_ctx.dialog_container.classList.remove("hide");
+}
+
+function on_dialog_hide(list_ctx)
+{
+ list_ctx.ul;
+ list_ctx.preview_container.classList.remove("hide");
+ list_ctx.dialog_container.classList.add("hide");
+}
+
+const resource_list =
+ () => item_list(resource_preview, haketilodb.track.resources);
+#EXPORT resource_list
+
+const mapping_list =
+ () => item_list(mapping_preview, haketilodb.track.mappings);
+#EXPORT mapping_list
+
+function destroy_list(list_ctx)
+{
+ haketilodb.untrack(list_ctx.tracking);
+ list_ctx.main_div.remove();
+}
+#EXPORT destroy_list
diff --git a/html/item_preview.html b/html/item_preview.html
new file mode 100644
index 0000000..76c6da6
--- /dev/null
+++ b/html/item_preview.html
@@ -0,0 +1,89 @@
+#IF !ITEM_PREVIEW_LOADED
+#DEFINE ITEM_PREVIEW_LOADED
+<!--
+ SPDX-License-Identifier: GPL-3.0-or-later OR CC-BY-SA-4.0
+
+ Show preview of a resource/mapping.
+
+ 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.
+ -->
+
+#LOADCSS html/reset.css
+#LOADCSS html/base.css
+#LOADCSS html/grid.css
+<style>
+ .dialog_main_div {
+ margin: 1.4em;
+ }
+</style>
+<template>
+ <div id="resource_preview_div" 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>
+ <span data-template="identifier" class="grid_col_2">...</span>
+ <span class="grid_col_1">long name:</span>
+ <span data-template="long_name" class="grid_col_2">...</span>
+ <span class="grid_col_1">UUID:</span>
+ <span data-template="uuid" class="grid_col_2">...</span>
+ <span class="grid_col_1">version:</span>
+ <span data-template="version" class="grid_col_2">...</span>
+ <span class="grid_col_1">description:</span>
+ <span data-template="description" class="grid_col_2">...</span>
+ <span class="grid_col_1">dependencies:</span>
+ <span class="grid_col_2"><ul data-template="dependencies"></ul></span>
+ <span class="grid_col_1">scripts:</span>
+ <span class="grid_col_2"><ul data-template="scripts"></ul></span>
+ <span class="grid_col_1">source name:</span>
+ <span data-template="source_name" class="grid_col_2">...</span>
+ <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"
+ class="grid_2 grid_form">
+ <h3 class="grid_col_both">mapping preview</h3>
+ <span class="grid_col_1">identifier:</span>
+ <span data-template="identifier" class="grid_col_2">...</span>
+ <span class="grid_col_1">long name:</span>
+ <span data-template="long_name" class="grid_col_2">...</span>
+ <span class="grid_col_1">UUID:</span>
+ <span data-template="uuid" class="grid_col_2">...</span>
+ <span class="grid_col_1">version:</span>
+ <span data-template="version" class="grid_col_2">...</span>
+ <span class="grid_col_1">description:</span>
+ <span data-template="description" class="grid_col_2">...</span>
+ <span class="grid_col_both">payloads:</span>
+ <div data-template="payloads" class="grid_col_both grid_2"></div>
+ <span class="grid_col_1">source name:</span>
+ <span data-template="source_name" class="grid_col_2">...</span>
+ <span class="grid_col_1">copyright:</span>
+ <span class="grid_col_2"><ul data-template="copyright"></ul></span>
+ </div>
+</template>
+#ENDIF
diff --git a/html/item_preview.js b/html/item_preview.js
new file mode 100644
index 0000000..f59e30e
--- /dev/null
+++ b/html/item_preview.js
@@ -0,0 +1,151 @@
+/**
+ * This file is part of Haketilo.
+ *
+ * Function: Showing resource/mapping details in a browser.
+ *
+ * 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 common/indexeddb.js AS haketilodb
+#IMPORT html/dialog.js
+
+#FROM html/DOM_helpers.js IMPORT clone_template
+
+function populate_list(ul, items)
+{
+ for (const item of items) {
+ const li = document.createElement("li");
+ li.append(item);
+ ul.append(li);
+ }
+}
+
+async function file_link_clicked(preview_object, file_ref, event)
+{
+ event.preventDefault();
+
+ const db = await haketilodb.get();
+ const file = await haketilodb.idb_get(db.transaction("files"),
+ "files", file_ref.hash_key);
+ if (file === undefined) {
+ dialog.error(preview_object.dialog_context,
+ "File missing from Haketilo's inernal database :(");
+ } else {
+ const encoded_file = encodeURIComponent(file.contents);
+ open(`data:text/plain;charset=utf8,${encoded_file}`, '_blank');
+ }
+}
+
+function make_file_link(preview_object, file_ref)
+{
+ const a = document.createElement("a");
+ a.href = "javascript:void(0)";
+ a.innerText = file_ref.file;
+ a.addEventListener("click",
+ e => file_link_clicked(preview_object, file_ref, e));
+
+ return a;
+}
+
+function resource_preview(resource, preview_object, dialog_context)
+{
+ if (preview_object === undefined)
+ preview_object = clone_template("resource_preview_div");
+
+ preview_object.identifier.innerText = resource.identifier;
+ preview_object.long_name.innerText = resource.long_name;
+ preview_object.uuid.innerText = resource.uuid;
+ preview_object.version.innerText =
+ `${resource.version.join(".")}-${resource.revision}`;
+ preview_object.description.innerText = resource.description;
+ preview_object.source_name.innerText = resource.source_name;
+
+ [...preview_object.dependencies.childNodes].forEach(n => n.remove());
+ populate_list(preview_object.dependencies, resource.dependencies);
+
+ const link_maker = file_ref => make_file_link(preview_object, file_ref);
+
+ [...preview_object.scripts.childNodes].forEach(n => n.remove());
+ populate_list(preview_object.scripts, resource.scripts.map(link_maker));
+
+ [...preview_object.copyright.childNodes].forEach(n => n.remove());
+ populate_list(preview_object.copyright,
+ resource.source_copyright.map(link_maker));
+
+ preview_object.dialog_context = dialog_context;
+
+ return preview_object;
+}
+#EXPORT resource_preview
+
+function mapping_preview(mapping, preview_object, dialog_context)
+{
+ if (preview_object === undefined)
+ preview_object = clone_template("mapping_preview_div");
+
+ preview_object.identifier.innerText = mapping.identifier;
+ preview_object.long_name.innerText = mapping.long_name;
+ preview_object.uuid.innerText = mapping.uuid;
+ preview_object.version.innerText = mapping.version.join(".");
+ preview_object.description.innerText = mapping.description;
+ preview_object.source_name.innerText = mapping.source_name;
+
+ [...preview_object.payloads.childNodes].forEach(n => n.remove());
+ for (const [pattern, payload] of Object.entries(mapping.payloads).sort()) {
+ /* We use a non-breaking space because normal space would be ignored. */
+ const [nbsp, rarrow] = [160, 0x2192].map(n => String.fromCodePoint(n));
+ const texts = [`${pattern}${nbsp}`, `${rarrow} ${payload.identifier}`];
+ for (let i = 0; i < texts.length; i++) {
+ const span = document.createElement("span");
+ span.innerText = texts[i];
+ span.classList.add(`grid_col_${i + 1}`);
+ preview_object.payloads.append(span);
+ }
+ }
+
+ const link_maker = file_ref => make_file_link(preview_object, file_ref);
+
+ [...preview_object.copyright.childNodes].forEach(n => n.remove());
+ populate_list(preview_object.copyright,
+ mapping.source_copyright.map(link_maker));
+
+ preview_object.dialog_context = dialog_context;
+
+ return preview_object;
+}
+#EXPORT mapping_preview
diff --git a/test/extension_crafting.py b/test/extension_crafting.py
index 61f8530..efb2687 100644
--- a/test/extension_crafting.py
+++ b/test/extension_crafting.py
@@ -61,7 +61,7 @@ def manifest_template():
'<all_urls>',
'unlimitedStorage'
],
- 'content_security_policy': "default-src 'self'; script-src 'self' https://serve.scrip.ts;",
+ 'content_security_policy': "object-src 'none'; script-src 'self' https://serve.scrip.ts;",
'web_accessible_resources': ['testpage.html'],
'background': {
'persistent': True,
@@ -143,6 +143,8 @@ def make_extension(destination_dir,
content_script=default_content_script,
test_page=default_test_page,
extra_files={}, extra_html=[]):
+ if not hasattr(extra_html, '__iter__'):
+ extra_html = [extra_html]
manifest = manifest_template()
extension_id = '{%s}' % uuid4()
manifest['applications']['gecko']['id'] = extension_id
diff --git a/test/unit/conftest.py b/test/unit/conftest.py
index 48e66c1..9318f6e 100644
--- a/test/unit/conftest.py
+++ b/test/unit/conftest.py
@@ -66,15 +66,25 @@ def webextension(driver, request):
ext_data = request.node.get_closest_marker('ext_data')
if ext_data is None:
raise Exception('"webextension" fixture requires "ext_data" marker to be set')
+ ext_data = ext_data.args[0].copy()
+
+ navigate_to = ext_data.get('navigate_to')
+ if navigate_to is not None:
+ del ext_data['navigate_to']
- ext_path = make_extension(Path(driver.firefox_profile.path),
- **ext_data.args[0])
driver.get('https://gotmyowndoma.in/')
+ ext_path = make_extension(Path(driver.firefox_profile.path), **ext_data)
addon_id = driver.install_addon(str(ext_path), temporary=True)
WebDriverWait(driver, 10).until(
EC.url_matches('^moz-extension://.*')
)
+
+ if navigate_to is not None:
+ testpage_url = driver.execute_script('return window.location.href;')
+ driver.get(testpage_url.replace('testpage.html', navigate_to))
+
yield
+
close_all_but_one_window(driver)
driver.get('https://gotmyowndoma.in/')
driver.uninstall_addon(addon_id)
diff --git a/test/unit/test_basic.py b/test/unit/test_basic.py
index 612fe06..5f42f5d 100644
--- a/test/unit/test_basic.py
+++ b/test/unit/test_basic.py
@@ -58,15 +58,14 @@ def test_webextension(driver):
assert "Extension's options page for testing" in heading
@pytest.mark.ext_data({
- 'extra_html': [
- ExtraHTML(
- 'html/default_blocking_policy.html',
- {
- 'html/default_blocking_policy.js':
- 'document.body.innerHTML = `ski-ba-bop-ba ${typeof by_id}`;'
- }
- )
- ]
+ 'extra_html': ExtraHTML(
+ 'html/default_blocking_policy.html',
+ {
+ 'html/default_blocking_policy.js':
+ 'document.body.innerHTML = `ski-ba-bop-ba ${typeof by_id}`;'
+ }
+ ),
+ 'navigate_to': 'html/default_blocking_policy.html'
})
@pytest.mark.usefixtures('webextension')
def test_extra_html(driver):
@@ -74,7 +73,5 @@ def test_extra_html(driver):
A trivial test case of the facility for loading the Haketilo's HTML files
into test WebExtension for unit-testing.
"""
- driver.get(driver.execute_script('return window.location.href;')
- .replace('testpage.html', 'html/default_blocking_policy.html'))
assert driver.execute_script('return document.body.innerText') == \
'ski-ba-bop-ba function'
diff --git a/test/unit/test_default_policy_dialog.py b/test/unit/test_default_policy_dialog.py
new file mode 100644
index 0000000..992b487
--- /dev/null
+++ b/test/unit/test_default_policy_dialog.py
@@ -0,0 +1,50 @@
+# SPDX-License-Identifier: CC0-1.0
+
+"""
+Haketilo unit tests - default script blocking policy dialog
+"""
+
+# 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
+
+from ..extension_crafting import ExtraHTML
+from ..script_loader import load_script
+
+broker_js = lambda: load_script('background/broadcast_broker.js') + ';start();'
+
+@pytest.mark.ext_data({
+ 'background_script': broker_js,
+ 'extra_html': ExtraHTML(
+ 'html/default_blocking_policy.html',
+ {
+ 'html/default_blocking_policy.js':
+ 'init_default_policy_dialog();'
+ }
+ ),
+ 'navigate_to': 'html/default_blocking_policy.html'
+})
+@pytest.mark.usefixtures('webextension')
+def test_default_blocking_policy_dialog(driver, wait_elem_text):
+ """
+ A test case for the dialog that facilitates toggling the default policy of
+ script blocking.
+ """
+ wait_elem_text('current_policy_span', 'block')
+
+ driver.find_element_by_id('toggle_policy_but').click()
+ wait_elem_text('current_policy_span', 'allow')
+
+ driver.find_element_by_id('toggle_policy_but').click()
+ wait_elem_text('current_policy_span', 'block')
diff --git a/test/unit/test_dialog.py b/test/unit/test_dialog.py
new file mode 100644
index 0000000..384a889
--- /dev/null
+++ b/test/unit/test_dialog.py
@@ -0,0 +1,130 @@
+# SPDX-License-Identifier: CC0-1.0
+
+"""
+Haketilo unit tests - showing an error/info/question dalog
+"""
+
+# 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
+
+from ..extension_crafting import ExtraHTML
+from ..script_loader import load_script
+
+@pytest.mark.ext_data({
+ 'extra_html': ExtraHTML('html/dialog.html', {}),
+ 'navigate_to': 'html/dialog.html'
+})
+@pytest.mark.usefixtures('webextension')
+def test_dialog_show_close(driver, execute_in_page):
+ """
+ A test case of basing dialog showing/closing.
+ """
+ execute_in_page(load_script('html/dialog.js'))
+ 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);
+ ''')
+
+ 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)
+ ]):
+ cb_calls, is_shown = execute_in_page(
+ f'''
+ cb_calls = [];
+ call_prom = {dialog_function}(dialog_context,
+ `sample_text_${{arguments[0]}}`);
+ returnval([cb_calls, dialog_context.shown]);
+ ''',
+ i)
+ assert cb_calls == ['show']
+ assert is_shown == True
+
+ page_source = driver.page_source
+ 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()
+
+ 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));
+ }''')
+ assert cb_calls == ['show', 'hide']
+ assert result == expected_result
+ assert is_shown == False
+
+@pytest.mark.ext_data({
+ 'extra_html': ExtraHTML('html/dialog.html', {}),
+ 'navigate_to': 'html/dialog.html'
+})
+@pytest.mark.usefixtures('webextension')
+def test_dialog_queue(driver, execute_in_page):
+ """
+ A test case of queuing dialog display operations.
+ """
+ execute_in_page(load_script('html/dialog.js'))
+ execute_in_page(
+ '''
+ let cb_calls = [], call_proms = [];
+ const dialog_context = make(() => cb_calls.push("show"),
+ () => cb_calls.push("hide"));
+ document.body.append(dialog_context.main_div);
+ ''')
+
+ buts = driver.find_elements_by_tag_name('button')
+ buts = dict([(but.text, but) for but in buts])
+
+ for i in range(5):
+ cb_calls, is_shown, msg_elem = execute_in_page(
+ '''
+ call_proms.push(ask(dialog_context, "somequestion" + arguments[0]));
+ returnval([cb_calls, dialog_context.shown, dialog_context.msg]);
+ ''',
+ i)
+ assert cb_calls == ['show']
+ assert is_shown == True
+ assert msg_elem.text == 'somequestion0'
+
+ for i in range(5):
+ buts['Yes' if i & 1 else 'No'].click()
+ cb_calls, is_shown, msg_elem, result = execute_in_page(
+ '''{
+ const values_cb =
+ r => [cb_calls, dialog_context.shown, dialog_context.msg, r];
+ returnval(call_proms.splice(0, 1)[0].then(values_cb));
+ }''')
+ if i < 4:
+ assert cb_calls == ['show']
+ assert is_shown == True
+ assert msg_elem.text == f'somequestion{i + 1}'
+ else:
+ assert cb_calls == ['show', 'hide']
+ assert is_shown == False
+
+ assert result == bool(i & 1)
diff --git a/test/unit/test_indexeddb.py b/test/unit/test_indexeddb.py
index 447ee6e..9dfbe63 100644
--- a/test/unit/test_indexeddb.py
+++ b/test/unit/test_indexeddb.py
@@ -19,62 +19,41 @@ Haketilo unit tests - IndexedDB access
import pytest
import json
-from hashlib import sha256
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
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
indexeddb_js = lambda: load_script('common/indexeddb.js')
broker_js = lambda: load_script('background/broadcast_broker.js') + ';start();'
-def sample_file(contents):
- return {
- 'hash_key': f'sha256-{sha256(contents.encode()).digest().hex()}',
- 'contents': contents
- }
-
-sample_files = {
- 'report.spdx': sample_file('<!-- dummy report -->'),
- 'LICENSES/somelicense.txt': sample_file('Permission is granted...'),
- 'hello.js': sample_file('console.log("hello!");\n'),
- 'bye.js': sample_file('console.log("bye!");\n'),
- 'combined.js': sample_file('console.log("hello!\\nbye!");\n'),
- 'README.md': sample_file('# Python Frobnicator\n...')
-}
-
-sample_files_by_hash = dict([[file['hash_key'], file['contents']]
- for file in sample_files.values()])
-
# Sample resource definitions. They'd normally contain more fields but here we
# use simplified versions.
def make_sample_resource():
return {
'source_copyright': [
- file_ref('report.spdx'),
- file_ref('LICENSES/somelicense.txt')
+ sample_file_ref('report.spdx'),
+ sample_file_ref('LICENSES/somelicense.txt')
],
'type': 'resource',
'identifier': 'helloapple',
- 'scripts': [file_ref('hello.js'), file_ref('bye.js')]
+ 'scripts': [sample_file_ref('hello.js'), sample_file_ref('bye.js')]
}
def make_sample_mapping():
return {
'source_copyright': [
- file_ref('report.spdx'),
- file_ref('README.md')
+ sample_file_ref('report.spdx'),
+ sample_file_ref('README.md')
],
'type': 'mapping',
'identifier': 'helloapple'
}
-def file_ref(file_name):
- return {'file': file_name, 'hash_key': sample_files[file_name]['hash_key']}
-
def clear_indexeddb(execute_in_page):
execute_in_page(
'''{
@@ -168,7 +147,7 @@ def test_haketilodb_item_modifications(driver, execute_in_page):
# See if trying to add an item without providing all its files ends in an
# exception and aborts the transaction as it should.
- sample_item['scripts'].append(file_ref('combined.js'))
+ sample_item['scripts'].append(sample_file_ref('combined.js'))
incomplete_files = {**sample_files_by_hash}
incomplete_files.pop(sample_files['combined.js']['hash_key'])
exception = execute_in_page(
@@ -439,7 +418,6 @@ def test_haketilodb_track(driver, execute_in_page, wait_elem_text):
'''
function update_item(store_name, change)
{
- console.log('# update', ...arguments);
const elem_id = `${store_name}_${change.key}`;
let elem = document.getElementById(elem_id);
elem = elem || document.createElement("li");
diff --git a/test/unit/test_item_list.py b/test/unit/test_item_list.py
new file mode 100644
index 0000000..3aba006
--- /dev/null
+++ b/test/unit/test_item_list.py
@@ -0,0 +1,180 @@
+# SPDX-License-Identifier: CC0-1.0
+
+"""
+Haketilo unit tests - displaying list of resources/mappings
+"""
+
+# 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
+from selenium.webdriver.support.ui import WebDriverWait
+
+from ..extension_crafting import ExtraHTML
+from ..script_loader import load_script
+from .utils import sample_files, sample_files_by_hash, sample_file_ref, \
+ item_version_string
+
+broker_js = lambda: load_script('background/broadcast_broker.js') + ';start();'
+
+def make_sample_mapping(identifier, long_name):
+ return {
+ 'source_name': 'example-org-fixes-new',
+ 'source_copyright': [
+ sample_file_ref('report.spdx'),
+ sample_file_ref('LICENSES/CC0-1.0.txt')
+ ],
+ 'type': 'mapping',
+ 'identifier': identifier,
+ 'long_name': long_name,
+ 'uuid': '54d23bba-472e-42f5-9194-eaa24c0e3ee7',
+ 'version': [2022, 5, 10],
+ 'description': 'suckless something something',
+ 'payloads': {
+ 'https://example.org/a/*': {
+ 'identifier': 'some-KISS-resource'
+ },
+ 'https://example.org/t/*': {
+ 'identifier': 'another-KISS-resource'
+ }
+ }
+ }
+
+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')
+ ]
+ }
+
+@pytest.mark.ext_data({
+ 'background_script': broker_js,
+ 'extra_html': ExtraHTML('html/item_list.html', {}),
+ 'navigate_to': 'html/item_list.html'
+})
+@pytest.mark.usefixtures('webextension')
+@pytest.mark.parametrize('item_type', ['resource', 'mapping'])
+def test_item_list_ordering(driver, execute_in_page, item_type):
+ """
+ A test case of items list proper ordering.
+ """
+ execute_in_page(load_script('html/item_list.js'))
+
+ make_item = make_sample_resource if item_type == 'resource' \
+ else make_sample_mapping
+
+ # Choose sample long names so as to test automatic sorting of items.
+ long_names = ['sample', 'sample it', 'Sample it', 'SAMPLE IT',
+ 'test', 'test it', 'Test it', 'TEST IT']
+ # Let's operate on a reverse-sorted copy
+ long_names_reversed = [*long_names]
+ long_names_reversed.reverse()
+
+ items = [make_item(f'it_{hex(2 * i + copy)[-1]}', name)
+ for i, name in enumerate(long_names_reversed)
+ for copy in (1, 0)]
+ # When adding/updating items this item will be updated at the end and this
+ # last update will be used to verify that a set of opertions completed.
+ extra_item = make_item('extraitem', 'extra item')
+ extra_dict = {'extraitem': {item_version_string(extra_item): extra_item}}
+
+ # After this reversal items are sorted in the exact order they are expected
+ # to appear in the HTML list.
+ items.reverse()
+
+ sample_data = {
+ 'resources': {},
+ 'mappings': {},
+ 'files': sample_files_by_hash
+ }
+
+ def is_prime(n):
+ return n > 1 and all([n % i != 0 for i in range(2, n)])
+
+ indexes_added = set()
+ for iteration, to_include in enumerate([
+ set([i for i in range(len(items)) if is_prime(i)]),
+ set([i for i in range(len(items))
+ if not is_prime(i) and i & 1]),
+ set([i for i in range(len(items)) if i % 3 == 0]),
+ set([i for i in range(len(items))
+ if i % 3 and not i & 1 and not is_prime(i)]),
+ set(range(16))
+ ]):
+ # On the last iteration, re-add ALL items but with changed names.
+ if len(to_include) == 16:
+ for it in items:
+ it['long_name'] = f'somewhat renamed {it["long_name"]}'
+
+ items_to_inclue = [items[i] for i in sorted(to_include)]
+ sample_data[item_type + 's'] = \
+ dict([(it['identifier'], {item_version_string(it): it})
+ for it in items_to_inclue])
+ execute_in_page('returnval(haketilodb.save_items(arguments[0]));',
+ sample_data)
+
+ extra_item['long_name'] = f'{iteration} {extra_item["long_name"]}'
+ sample_data[item_type + 's'] = extra_dict
+ execute_in_page('returnval(haketilodb.save_items(arguments[0]));',
+ sample_data)
+
+ if iteration == 0:
+ execute_in_page(
+ f'''
+ let list_ctx, items = arguments[0];
+ async function create_list() {{
+ list_ctx = await {item_type}_list();
+ document.body.append(list_ctx.main_div);
+ }}
+ returnval(create_list());
+ ''')
+
+ def lis_ready(driver):
+ return extra_item['long_name'] == execute_in_page(
+ 'returnval(list_ctx.ul.firstElementChild.textContent);'
+ )
+
+ indexes_added.update(to_include)
+ WebDriverWait(driver, 10).until(lis_ready)
+
+ li_texts = execute_in_page(
+ '''
+ var lis = [...list_ctx.ul.children].slice(1);
+ returnval(lis.map(li => li.textContent));
+ ''')
+ assert li_texts == [items[i]['long_name'] for i in indexes_added]
+
+ preview_texts = execute_in_page(
+ '''{
+ const get_texts =
+ li => [li.click(), list_ctx.preview_container.textContent][1];
+ returnval(lis.map(get_texts));
+ }''')
+
+ for i, text in zip(sorted(indexes_added), preview_texts):
+ assert items[i]['identifier'] in text
+ assert items[i]['long_name'] in text
diff --git a/test/unit/test_item_preview.py b/test/unit/test_item_preview.py
new file mode 100644
index 0000000..887e4f4
--- /dev/null
+++ b/test/unit/test_item_preview.py
@@ -0,0 +1,235 @@
+# SPDX-License-Identifier: CC0-1.0
+
+"""
+Haketilo unit tests - displaying resources and mappings details
+"""
+
+# 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
+from selenium.webdriver.support.ui import WebDriverWait
+
+from ..extension_crafting import ExtraHTML
+from ..script_loader import load_script
+from .utils import *
+
+broker_js = lambda: load_script('background/broadcast_broker.js') + ';start();'
+
+def make_sample_mapping():
+ return {
+ 'source_name': 'example-org-fixes-new',
+ 'source_copyright': [
+ sample_file_ref('report.spdx'),
+ sample_file_ref('LICENSES/CC0-1.0.txt')
+ ],
+ 'type': 'mapping',
+ 'identifier': 'example-org-minimal',
+ 'long_name': 'Example.org Minimal',
+ 'uuid': '54d23bba-472e-42f5-9194-eaa24c0e3ee7',
+ 'version': [2022, 5, 10],
+ 'description': 'suckless something something',
+ 'payloads': {
+ 'https://example.org/a/*': {
+ 'identifier': 'some-KISS-resource'
+ },
+ 'https://example.org/t/*': {
+ 'identifier': 'another-KISS-resource'
+ }
+ }
+ }
+
+def make_sample_resource():
+ return {
+ 'source_name': 'hello',
+ 'source_copyright': [
+ sample_file_ref('report.spdx'),
+ sample_file_ref('LICENSES/CC0-1.0.txt')
+ ],
+ 'type': 'resource',
+ 'identifier': 'helloapple',
+ 'long_name': 'Hello Apple',
+ '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')
+ ]
+ }
+
+@pytest.mark.ext_data({
+ 'extra_html': ExtraHTML('html/item_preview.html', {}),
+ 'navigate_to': 'html/item_preview.html'
+})
+@pytest.mark.usefixtures('webextension')
+def test_resource_preview(driver, execute_in_page):
+ """
+ A test case of the resource preview display function.
+ """
+ execute_in_page(load_script('html/item_preview.js'))
+
+ sample_resource = make_sample_resource()
+
+ preview_div = execute_in_page(
+ '''
+ let preview_object = resource_preview(arguments[0]);
+ document.body.append(preview_object.main_div);
+ returnval(preview_object.main_div);
+ ''',
+ sample_resource)
+ text = preview_div.text
+
+ assert '...' not in text
+
+ for string in [
+ *filter(lambda v: type(v) is str, sample_resource.values()),
+ *sample_resource['dependencies'],
+ *[c['file'] for k in ('source_copyright', 'scripts')
+ for c in sample_resource[k]],
+ item_version_string(sample_resource, True)
+ ]:
+ assert string in text
+
+ sample_resource['identifier'] = 'hellopear'
+ sample_resource['long_name'] = 'Hello Pear'
+ sample_resource['description'] = 'greets a pear'
+ sample_resource['dependencies'] = ['hello-msg'],
+ for key in ('scripts', 'source_copyright'):
+ for file_ref in sample_resource[key]:
+ file_ref['file'] = file_ref['file'].replace('.', '_')
+
+ preview_div = execute_in_page(
+ '''
+ returnval(resource_preview(arguments[0], preview_object).main_div);
+ ''',
+ sample_resource)
+ text = preview_div.text
+
+ for string in ['...', 'pple', 'hello-message', 'report.spdx',
+ 'LICENSES/CC0-1.0.txt', 'hello.js', 'bye.js']:
+ assert string not in text
+
+ for string in ['hellopear', 'Hello Pear', 'hello-msg', 'greets a pear',
+ 'report_spdx', 'LICENSES/CC0-1_0_txt', 'hello_js', 'bye_js']:
+ assert string in text
+
+@pytest.mark.ext_data({
+ 'extra_html': ExtraHTML('html/item_preview.html', {}),
+ 'navigate_to': 'html/item_preview.html'
+})
+@pytest.mark.usefixtures('webextension')
+def test_mapping_preview(driver, execute_in_page):
+ """
+ A test case of the mapping preview display function.
+ """
+ execute_in_page(load_script('html/item_preview.js'))
+
+ sample_mapping = make_sample_mapping()
+
+ preview_div = execute_in_page(
+ '''
+ let preview_object = mapping_preview(arguments[0]);
+ document.body.append(preview_object.main_div);
+ returnval(preview_object.main_div);
+ ''',
+ sample_mapping)
+ text = preview_div.text
+
+ assert '...' not in text
+
+ for string in [
+ *filter(lambda v: type(v) is str, sample_mapping.values()),
+ *[p['identifier'] for p in sample_mapping['payloads'].values()],
+ *[c['file'] for c in sample_mapping['source_copyright']],
+ item_version_string(sample_mapping)
+ ]:
+ assert string in text
+
+ sample_mapping['identifier'] = 'example-org-bloated'
+ sample_mapping['long_name'] = 'Example.org Bloated',
+ sample_mapping['payloads'] = dict(
+ [(pat.replace('.org', '.com'), res_id)
+ for pat, res_id in sample_mapping['payloads'].items()]
+ )
+ for file_ref in sample_mapping['source_copyright']:
+ file_ref['file'] = file_ref['file'].replace('.', '_')
+
+ preview_div = execute_in_page(
+ '''
+ returnval(mapping_preview(arguments[0], preview_object).main_div);
+ ''',
+ sample_mapping)
+ text = preview_div.text
+
+ for string in ['...', 'inimal', 'example.org', 'report.spdx',
+ 'LICENSES/CC0-1.0.txt']:
+ assert string not in text
+
+ for string in ['example-org-bloated', 'Example.org Bloated', 'example.com',
+ 'report_spdx', 'LICENSES/CC0-1_0_txt']:
+ assert string in text
+
+@pytest.mark.ext_data({
+ 'background_script': broker_js,
+ 'extra_html': ExtraHTML('html/item_preview.html', {}),
+ 'navigate_to': 'html/item_preview.html'
+})
+@pytest.mark.usefixtures('webextension')
+def test_file_preview_link(driver, execute_in_page):
+ """
+ A test case of <a> links created by preview functions that allow a
+ referenced file to be previewed.
+ """
+ execute_in_page(load_script('html/item_preview.js'))
+ # Mock dialog
+ execute_in_page('dialog.error = (...args) => window.error_args = args;')
+
+ sample_resource = make_sample_resource()
+ sample_data = {
+ 'resources': {
+ sample_resource['identifier']: {
+ item_version_string(sample_resource): sample_resource
+ }
+ },
+ 'mappings': {
+ },
+ 'files': sample_files_by_hash
+ }
+ execute_in_page('returnval(haketilodb.save_items(arguments[0]));',
+ sample_data)
+
+ # Cause the "link" to `bye.js` to be invalid.
+ sample_resource['scripts'][1]['hash_key'] = 'dummy nonexistent key'
+
+ execute_in_page(
+ '''
+ let resource_preview_object =
+ resource_preview(arguments[0], undefined, "dummy dialog ctx");
+ document.body.append(resource_preview_object.main_div);
+ ''',
+ sample_resource)
+
+ window0 = driver.window_handles[0]
+ driver.find_element_by_link_text('hello.js').click()
+ WebDriverWait(driver, 10).until(lambda d: len(d.window_handles) > 1)
+ window1 = [wh for wh in driver.window_handles if wh != window0][0]
+ driver.switch_to.window(window1)
+ assert sample_files['hello.js']['contents'] in driver.page_source
+
+ driver.switch_to.window(window0)
+ driver.find_element_by_link_text('bye.js').click()
+ assert driver.execute_script('return window.error_args;') == \
+ ['dummy dialog ctx', "File missing from Haketilo's inernal database :("]
diff --git a/test/unit/utils.py b/test/unit/utils.py
new file mode 100644
index 0000000..e2d89b9
--- /dev/null
+++ b/test/unit/utils.py
@@ -0,0 +1,59 @@
+# SPDX-License-Identifier: GPL-3.0-or-later
+
+"""
+Various functions and objects that can be reused between unit tests
+"""
+
+# This file is part of Haketilo.
+#
+# 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 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
+# 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.
+
+from hashlib import sha256
+
+def sample_file(contents):
+ return {
+ 'hash_key': f'sha256-{sha256(contents.encode()).digest().hex()}',
+ 'contents': contents
+ }
+
+sample_files = {
+ 'report.spdx': sample_file('<!-- dummy report -->'),
+ 'LICENSES/somelicense.txt': sample_file('Permission is granted...'),
+ 'LICENSES/CC0-1.0.txt': sample_file('Dummy Commons...'),
+ 'hello.js': sample_file('console.log("uńićódę hello!");\n'),
+ 'bye.js': sample_file('console.log("bye!");\n'),
+ 'combined.js': sample_file('console.log("hello!\\nbye!");\n'),
+ 'README.md': sample_file('# Python Frobnicator\n...')
+}
+
+sample_files_by_hash = dict([[file['hash_key'], file['contents']]
+ for file in sample_files.values()])
+
+def sample_file_ref(file_name):
+ return {'file': file_name, 'hash_key': sample_files[file_name]['hash_key']}
+
+def item_version_string(definition, include_revision=False):
+ """
+ Given a resource or mapping definition, read its "version" property (and
+ also "revision" if applicable) and produce a corresponding version string.
+ """
+ ver = '.'.join([str(num) for num in definition['version']])
+ revision = definition.get('revision') if include_revision else None
+ return f'{ver}-{revision}' if revision is not None else ver