diff options
-rw-r--r-- | common/lock.js | 2 | ||||
-rwxr-xr-x | compute_scripts.awk | 18 | ||||
-rw-r--r-- | copyright | 2 | ||||
-rw-r--r-- | html/DOM_helpers.js | 2 | ||||
-rw-r--r-- | html/base.css | 82 | ||||
-rw-r--r-- | html/dialog.html | 72 | ||||
-rw-r--r-- | html/dialog.js | 112 | ||||
-rw-r--r-- | html/grid.css | 75 | ||||
-rw-r--r-- | html/item_list.html | 61 | ||||
-rw-r--r-- | html/item_list.js | 189 | ||||
-rw-r--r-- | html/item_preview.html | 89 | ||||
-rw-r--r-- | html/item_preview.js | 151 | ||||
-rw-r--r-- | test/extension_crafting.py | 4 | ||||
-rw-r--r-- | test/unit/conftest.py | 14 | ||||
-rw-r--r-- | test/unit/test_basic.py | 19 | ||||
-rw-r--r-- | test/unit/test_default_policy_dialog.py | 50 | ||||
-rw-r--r-- | test/unit/test_dialog.py | 130 | ||||
-rw-r--r-- | test/unit/test_indexeddb.py | 36 | ||||
-rw-r--r-- | test/unit/test_item_list.py | 180 | ||||
-rw-r--r-- | test/unit/test_item_preview.py | 235 | ||||
-rw-r--r-- | test/unit/utils.py | 59 |
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 @@ -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 |