From 448820a11634de6ec356c77b8c7c0cf4937b344c Mon Sep 17 00:00:00 2001 From: Wojtek Kosior Date: Sat, 8 Jan 2022 14:47:39 +0100 Subject: work on UI components This commit introduces some HTML and javascript (and tests for it) to use in constructing the new UI. This is partial work that is not yet finished. --- common/lock.js | 2 +- compute_scripts.awk | 18 ++- copyright | 2 +- html/DOM_helpers.js | 2 +- html/base.css | 82 +++-------- html/dialog.html | 72 ++++++++++ html/dialog.js | 112 +++++++++++++++ html/grid.css | 75 ++++++++++ html/item_list.html | 61 +++++++++ html/item_list.js | 189 +++++++++++++++++++++++++ html/item_preview.html | 89 ++++++++++++ html/item_preview.js | 151 ++++++++++++++++++++ test/extension_crafting.py | 4 +- test/unit/conftest.py | 14 +- test/unit/test_basic.py | 19 ++- test/unit/test_default_policy_dialog.py | 50 +++++++ test/unit/test_dialog.py | 130 ++++++++++++++++++ test/unit/test_indexeddb.py | 36 +---- test/unit/test_item_list.py | 180 ++++++++++++++++++++++++ test/unit/test_item_preview.py | 235 ++++++++++++++++++++++++++++++++ test/unit/utils.py | 59 ++++++++ 21 files changed, 1465 insertions(+), 117 deletions(-) create mode 100644 html/dialog.html create mode 100644 html/dialog.js create mode 100644 html/grid.css create mode 100644 html/item_list.html create mode 100644 html/item_list.js create mode 100644 html/item_preview.html create mode 100644 html/item_preview.js create mode 100644 test/unit/test_default_policy_dialog.py create mode 100644 test/unit/test_dialog.py create mode 100644 test/unit/test_item_list.py create mode 100644 test/unit/test_item_preview.py create mode 100644 test/unit/utils.py 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 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 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 + + + + +#LOADCSS html/reset.css +#LOADCSS html/base.css + + +#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 . + * + * 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 + * + * 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 . + * + * 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 + + + + +#INCLUDE html/item_preview.html +#INCLUDE html/dialog.html + +#LOADCSS html/reset.css +#LOADCSS html/base.css +#LOADCSS html/grid.css + + +#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 . + * + * 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 + + + + +#LOADCSS html/reset.css +#LOADCSS html/base.css +#LOADCSS html/grid.css + + +#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 . + * + * 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(): '', '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 +# +# 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 +# +# 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(''), - '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 +# +# 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 +# +# 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 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 +# +# 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 . +# +# 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(''), + '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 -- cgit v1.2.3