diff options
-rw-r--r-- | common/patterns.js | 3 | ||||
-rwxr-xr-x | compute_scripts.awk | 15 | ||||
-rw-r--r-- | html/dialog.js | 10 | ||||
-rw-r--r-- | html/grid.css | 6 | ||||
-rw-r--r-- | html/item_list.html | 8 | ||||
-rw-r--r-- | html/item_list.js | 4 | ||||
-rw-r--r-- | html/item_preview.html | 2 | ||||
-rw-r--r-- | html/payload_create.html | 3 | ||||
-rw-r--r-- | html/payload_create.js | 5 | ||||
-rw-r--r-- | html/text_entry_list.html | 65 | ||||
-rw-r--r-- | html/text_entry_list.js | 321 | ||||
-rw-r--r-- | test/unit/test_item_list.py | 17 | ||||
-rw-r--r-- | test/unit/test_payload_create.py | 2 | ||||
-rw-r--r-- | test/unit/test_text_entry_list.py | 310 | ||||
-rw-r--r-- | test/unit/utils.py | 3 |
15 files changed, 743 insertions, 31 deletions
diff --git a/common/patterns.js b/common/patterns.js index 36cabfb..1398961 100644 --- a/common/patterns.js +++ b/common/patterns.js @@ -185,3 +185,6 @@ function* each_url_pattern(url) } } #EXPORT each_url_pattern + +#EXPORT "https://hydrillabugs.koszko.org/projects/haketilo/wiki/URL_patterns" \ + AS patterns_doc_url diff --git a/compute_scripts.awk b/compute_scripts.awk index 1db79e0..6235e19 100755 --- a/compute_scripts.awk +++ b/compute_scripts.awk @@ -119,8 +119,7 @@ BEGIN { function process_file(path, read_path, mode, line, result, line_part, directive, directive_args, - if_nesting, if_nesting_true, if_branch_processed, - additional_line_nr) { + if_nesting, if_nesting_true, if_branch_processed) { if (path in modes && modes[path] != mode) { printf "ERROR: File %s used multiple times in different contexts\n", path > "/dev/stderr" @@ -166,10 +165,10 @@ function process_file(path, read_path, mode, } if (result == 0) { if (!(path in appended_lines_counts) || \ - additional_line_nr == appended_lines_counts[path]) + additional_line_nr[path] == appended_lines_counts[path]) break - line = appended_lines[path,++additional_line_nr] + line = appended_lines[path,++additional_line_nr[path]] } if (line !~ /^#/) { @@ -188,8 +187,8 @@ function process_file(path, read_path, mode, } if (result == 0) { if (path in appended_lines_counts && \ - additional_line_nr < appended_lines_counts[path]) { - line_part = appended_lines[path,++additional_line_nr] + additional_line_nr[path] < appended_lines_counts[path]) { + line_part = appended_lines[path,++additional_line_nr[path]] } else { printf "ERROR: Unexpected EOF in %s\n", read_path > "/dev/stderr" @@ -646,7 +645,7 @@ function print_amalgamation(js_deps, js_deps_count, } function print_usage() { - printf "USAGE: %s compute_scripts.awk -- [-D PREPROCESSOR_DEFINITION]... [-M manifest/to/process/manifest.json]... [-H html/to/process.html]... [-J js/to/process.js]... [-A js/to/append/to.js:appended_code]... [--help|-h] [--output-dir=./build] [--write-js-deps] [--write-html-deps] [--output=files-to-copy|--output=amalgamate-js:js/to/process.js]\n", + printf "USAGE: %s compute_scripts.awk -- [-D PREPROCESSOR_DEFINITION]... [-M manifest/to/process/manifest.json]... [-H html/to/process.html]... [-J js/to/process.js]... [-A file/to/append/to.js:appended_code]... [--help|-h] [--output-dir=./build] [--write-js-deps] [--write-html-deps] [--output=files-to-copy|--output=amalgamate-js:js/to/process.js]\n", ARGV[0] > "/dev/stderr" } @@ -711,8 +710,6 @@ function main(i, j, path, letter, dir, max_line_nr, js_deps, js_deps_count, return 1 } - modes[path] = "js" - clear_array(tmp_lines) code = ARGV[i] sub(/^[^:]+:/, "", code) diff --git a/html/dialog.js b/html/dialog.js index c4bba5d..a2406e8 100644 --- a/html/dialog.js +++ b/html/dialog.js @@ -129,3 +129,13 @@ const ask = (ctx, ...msg) => show_dialog(ctx, "ask_buts", msg); const loader = (ctx, ...msg) => show_dialog(ctx, null, msg); #EXPORT loader + +/* + * Wrapper around target.addEventListener() that makes the requested callback + * only execute if dialog is not shown. + */ +function onevent(ctx, target, event, cb) +{ + target.addEventListener(event, e => !ctx.shown && cb(e)); +} +#EXPORT onevent diff --git a/html/grid.css b/html/grid.css index aa8fc80..20bb85e 100644 --- a/html/grid.css +++ b/html/grid.css @@ -49,7 +49,7 @@ grid-column: 1 / span 2; } -span.grid_col_1 { +span.grid_col_1, label.grid_col_1 { text-align: right; } @@ -61,11 +61,11 @@ div.grid_col_both { text-align: initial; } -.grid_2>span { +.grid_2>span, grid_2>label { margin-top: 0.75em; } -.grid_form>span { +.grid_form>span, .grid_form>label { margin-top: 1.5em; margin-left: 1em; margin-right: 1em; diff --git a/html/item_list.html b/html/item_list.html index 5d2a163..4e23868 100644 --- a/html/item_list.html +++ b/html/item_list.html @@ -57,6 +57,14 @@ .item_list>li.item_li_highlight { cursor: default; } + .item_list.list_disabled, + .item_list.list_disabled *, + .item_list.list_disabled .item_li_highlight { + -moz-user-select: none; + user-select: none; + opacity: 0.75; + cursor: not-allowed; + } .list_buttons { margin: 1em auto; text-align: center; diff --git a/html/item_list.js b/html/item_list.js index 34dec83..198e0f9 100644 --- a/html/item_list.js +++ b/html/item_list.js @@ -185,14 +185,14 @@ async function item_list(preview_cb, track_cb, remove_cb) function on_dialog_show(list_ctx) { - list_ctx.ul; // TODO: make ul non-selectable when dialog is shown + list_ctx.ul.classList.add("list_disabled"); 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.ul.classList.remove("list_disabled"); if (list_ctx.previewed_item !== null) list_ctx.preview_container.classList.remove("hide"); list_ctx.dialog_container.classList.add("hide"); diff --git a/html/item_preview.html b/html/item_preview.html index 6cd15a8..160c01d 100644 --- a/html/item_preview.html +++ b/html/item_preview.html @@ -39,7 +39,7 @@ #LOADCSS html/grid.css <style> .preview_main_div { - margin: 0.8em 0; + margin: 0.8em 0.8em; } </style> <template> diff --git a/html/payload_create.html b/html/payload_create.html index 062e41c..44fa4e2 100644 --- a/html/payload_create.html +++ b/html/payload_create.html @@ -40,9 +40,6 @@ #LOADCSS html/base.css #LOADCSS html/grid.css <style> - .payload_create_main_view { - margin: 0.8em; - } .payload_create_form { margin: 0 0.6em; } diff --git a/html/payload_create.js b/html/payload_create.js index a5f9854..c1563ae 100644 --- a/html/payload_create.js +++ b/html/payload_create.js @@ -46,10 +46,7 @@ #FROM html/DOM_helpers.js IMPORT clone_template #FROM common/sha256.js IMPORT sha256 -#FROM common/patterns.js IMPORT deconstruct_url - -const patterns_doc_url = - "https://hydrillabugs.koszko.org/projects/haketilo/wiki/URL_patterns"; +#FROM common/patterns.js IMPORT deconstruct_url, patterns_doc_url /* https://stackoverflow.com/questions/105034/how-to-create-a-guid-uuid */ /* This is a helper function used by uuidv4(). */ diff --git a/html/text_entry_list.html b/html/text_entry_list.html new file mode 100644 index 0000000..21e1604 --- /dev/null +++ b/html/text_entry_list.html @@ -0,0 +1,65 @@ +#IF !TEXT_ENTRY_LIST_LOADED +#DEFINE TEXT_ENTRY_LIST_LOADED +<!-- + SPDX-License-Identifier: GPL-3.0-or-later OR CC-BY-SA-4.0 + + List of editable entries. Used to make UI for management of repo URLs and + script allowing/blocking rules. + + 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> + .text_entry { + height: 3em; + padding: 0 0.5em; + } +</style> +<template> + <div id="text_entry" data-template="main_div"> + <div data-template="noneditable_view"> + <span data-template="text"></span> + <button data-template="remove_but">Remove</button> + </div> + <div data-template="editable_view" class="hide"> + <input data-template="input"> + <button data-template="add_but" class="hide">Add</button> + <button data-template="save_but">Save</button> + <button data-template="cancel_but">Cancel</button> + </div> + </div> + <div id="text_entry_list" data-template="main_div" class="grid_1"> + <div data-template="list_div" class="grid_1"></div> + <button data-template="new_but">New</button> + </div> +</template> +#ENDIF diff --git a/html/text_entry_list.js b/html/text_entry_list.js new file mode 100644 index 0000000..4af19fd --- /dev/null +++ b/html/text_entry_list.js @@ -0,0 +1,321 @@ +/** + * This file is part of Haketilo. + * + * Function: Driving a list of editable entries. Used to make UI for management + * of repo URLs and script allowing/blocking rules. + * + * Copyright (C) 2022 Wojtek Kosior + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * As additional permission under GNU GPL version 3 section 7, you + * may distribute forms of that code without the copy of the GNU + * GPL normally required by section 4, provided you include this + * license notice and, in case of non-source distribution, a URL + * through which recipients can access the Corresponding Source. + * If you modify file(s) with this exception, you may extend this + * exception to your version of the file(s), but you are not + * obligated to do so. If you do not wish to do so, delete this + * exception statement from your version. + * + * As a special exception to the GPL, any HTML file which merely + * makes function calls to this code, and for that purpose + * includes it by reference shall be deemed a separate work for + * copyright law purposes. If you modify this code, you may extend + * this exception to your version of the code, but you are not + * obligated to do so. If you do not wish to do so, delete this + * exception statement from your version. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + * + * I, Wojtek Kosior, thereby promise not to sue for violation of this file's + * license. Although I request that you do not make use of this code in a + * proprietary program, I am not going to enforce this in court. + */ + +#IMPORT html/dialog.js +#IMPORT common/indexeddb.js AS haketilodb + +#FROM html/DOM_helpers.js IMPORT clone_template +#FROM common/patterns.js IMPORT deconstruct_url, patterns_doc_url + +const coll = new Intl.Collator(); + +function Entry(text, list, entry_idx) { + Object.assign(this, clone_template("text_entry")); + + const editable = () => list.active_entry === this; + this.exists = () => text !== null; + + /* + * Called in the constructor when creating a completely new entry and as a + * result of clicking on an existing entry in the list. + */ + this.make_editable = () => { + if (editable()) + return; + + if (list.active_entry !== null) + list.active_entry.make_noneditable(); + + list.active_entry = this; + + this.editable_view.classList.remove("hide"); + this.noneditable_view.classList.add("hide"); + + this.input.value = text || ""; + } + + /* + * Called when 'Cancel' is clicked, when another entry becomes editable and + * when an entry ends being modified. + */ + this.make_noneditable = () => { + if (!editable()) + return; + + list.active_entry = null; + + if (!this.exists()) { + this.main_div.remove(); + return; + } + + this.editable_view.classList.add("hide"); + this.noneditable_view.classList.remove("hide"); + } + + /* + * The *_cb() calls are allowed to throw if an error occurs. It is expected + * *_cb() will show some dialog that will block other clicks until they + * returns/throw. + */ + + const add_clicked = async () => { + if (!editable() || this.exists()) + return; + + await list.create_cb(this.input.value); + this.make_noneditable(); + } + + /* + * Changing entry's text is not handled here, instead we wait for subsequent + * item removal and creation requests from the outside. + */ + const save_clicked = async () => { + if (!editable() || !this.exists()) + return; + + await list.replace_cb(text, this.input.value); + this.make_noneditable(); + } + + const enter_hit = () => add_clicked().then(save_clicked); + + /* + * Removing entry from the list is not handled here, instead we wait for + * subsequent item removal requests from the outside. + */ + const remove_clicked = async () => { + if (editable() || !this.exists()) + return; + + await list.remove_cb(text); + } + + /* + * Called from the outside after the entry got removed from the database. It + * is assumed entry_exists() is true when this is called. + */ + this.remove = () => { + if (editable()) { + text = null; + this.save_but.classList.add("hide"); + this.add_but.classList.remove("hide"); + } else { + this.main_div.remove(); + } + } + + if (this.exists()) { + this.text.innerText = text; + } else { + this.save_but.classList.add("hide"); + this.add_but.classList.remove("hide"); + this.make_editable(); + } + + for (const [node, cb] of [ + [this.save_but, save_clicked], + [this.add_but, add_clicked], + [this.remove_but, remove_clicked], + [this.cancel_but, this.make_noneditable], + [this.noneditable_view, this.make_editable], + ]) + dialog.onevent(list.dialog_ctx, node, "click", cb); + + dialog.onevent(list.dialog_ctx, this.input, "keypress", + e => (e.key === 'Enter') && enter_hit()); + + if (entry_idx > 0) { + const prev_text = list.shown_texts[entry_idx - 1]; + list.entries_by_text.get(prev_text).main_div.after(this.main_div); + } else { + if (!editable() && list.active_entry && !list.active_entry.exists()) + list.active_entry.main_div.after(this.main_div); + else + list.list_div.prepend(this.main_div); + } +} + +function TextEntryList(dialog_ctx, destroy_cb, + remove_cb, create_cb, replace_cb) { + Object.assign(this, {dialog_ctx, remove_cb, create_cb, replace_cb}); + Object.assign(this, clone_template("text_entry_list")); + this.ul = document.createElement("ul"); + this.shown_texts = []; + this.entries_by_text = new Map(); + this.active_entry = null; + + const find_entry_idx = text => { + let left = 0, right = this.shown_texts.length; + + while (left < right) { + const mid = (left + right) >> 1; + if (coll.compare(text, this.shown_texts[mid]) > 0) + left = mid + 1; + else /* <= 0 */ + right = mid; + } + + return left; + } + + this.remove = text => { + if (!this.entries_by_text.has(text)) + return; + + this.shown_texts.splice(find_entry_idx(text), 1); + this.entries_by_text.get(text).remove(); + this.entries_by_text.delete(text) + } + + this.add = text => { + if (this.entries_by_text.has(text)) + return; + + const idx = find_entry_idx(text); + this.entries_by_text.set(text, new Entry(text, this, idx)); + this.shown_texts.splice(idx, 0, text); + } + + this.destroy = () => { + this.main_div.remove(); + destroy_cb(); + } + + const add_new = () => new Entry(null, this, 0); + + dialog.onevent(dialog_ctx, this.new_but, "click", add_new); +} + +async function repo_list(dialog_ctx) { + let list; + + function validate_normalize_repo_url(repo_url) { + let error_msg; + + /* In the future we might also try making a test connection. */ + if (!/^https:\/\/[^/.]+\.[^/.]+/.test(repo_url)) + error_msg = "Provided URL is not valid."; + + if (!/^https:\/\//.test(repo_url)) + error_msg = "Repository URLs shoud use https:// schema."; + + if (error_msg) { + dialog.error(dialog_ctx, error_msg); + throw error_msg; + } + + return repo_url.replace(/\/*$/, "/"); + } + + async function remove_repo(repo_url) { + dialog.loader(dialog_ctx, "Removing repository..."); + + try { + await haketilodb.del_repo(repo_url); + var removing_ok = true; + } finally { + if (!removing_ok) + dialog.error(dialog_ctx, "Failed to remove repository :("); + + dialog.close(dialog_ctx); + } + } + + async function create_repo(repo_url) { + repo_url = validate_normalize_repo_url(repo_url); + + dialog.loader(dialog_ctx, "Adding repository..."); + + try { + await haketilodb.set_repo(repo_url); + var adding_ok = true; + } finally { + if (!adding_ok) + dialog.error(dialog_ctx, "Failed to add repository :("); + + dialog.close(dialog_ctx); + } + } + + async function replace_repo(old_repo_url, new_repo_url) { + if (old_repo_url === new_repo_url) + return; + + new_repo_url = validate_normalize_repo_url(new_repo_url); + + dialog.loader(dialog_ctx, "Replacing repository..."); + + try { + await haketilodb.set_repo(new_repo_url); + await haketilodb.del_repo(old_repo_url); + var replacing_ok = true; + } finally { + if (!replacing_ok) + dialog.error(dialog_ctx, "Failed to replace repository :("); + + dialog.close(dialog_ctx); + } + } + + function onchange(change) { + if (change.new_val) + list.add(change.key); + else + list.remove(change.key); + } + + dialog.loader(dialog_ctx, "Loading repositories..."); + const [tracking, items] = await haketilodb.track.repos(onchange); + dialog.close(dialog_ctx); + + list = new TextEntryList(dialog_ctx, () => haketilodb.untrack(tracking), + remove_repo, create_repo, replace_repo); + + items.forEach(item => list.add(item.url)); + + return list; +} +#EXPORT repo_list diff --git a/test/unit/test_item_list.py b/test/unit/test_item_list.py index 62ec84e..faef1c0 100644 --- a/test/unit/test_item_list.py +++ b/test/unit/test_item_list.py @@ -111,9 +111,6 @@ def test_item_list_ordering(driver, execute_in_page, item_type): '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)]), @@ -122,10 +119,10 @@ def test_item_list_ordering(driver, execute_in_page, item_type): 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)) + set(range(len(items))) ]): # On the last iteration, re-add ALL items but with changed names. - if len(to_include) == 16: + if len(to_include) == len(items): for it in items: it['long_name'] = f'somewhat renamed {it["long_name"]}' @@ -198,14 +195,15 @@ def test_item_list_displaying(driver, execute_in_page, item_type): } sample_data[item_type + 's'] = sample_data_dict(items) - preview_container, dialog_container = execute_in_page( + preview_container, dialog_container, ul = execute_in_page( f''' let list_ctx, sample_data = arguments[0]; async function create_list() {{ await haketilodb.save_items(sample_data); list_ctx = await {item_type}_list(); document.body.append(list_ctx.main_div); - return [list_ctx.preview_container, list_ctx.dialog_container]; + return [list_ctx.preview_container, list_ctx.dialog_container, + list_ctx.ul]; }} returnval(create_list()); ''', @@ -235,6 +233,7 @@ def test_item_list_displaying(driver, execute_in_page, item_type): # Check that item removal confirmation dialog is displayed correctly. execute_in_page('list_ctx.remove_but.click();') WebDriverWait(driver, 10).until(lambda _: dialog_container.is_displayed()) + assert 'list_disabled' in ul.get_attribute('class') assert not preview_container.is_displayed() msg = execute_in_page('returnval(list_ctx.dialog_ctx.msg.textContent);') assert msg == "Are you sure you want to delete 'item2'?" @@ -242,6 +241,7 @@ def test_item_list_displaying(driver, execute_in_page, item_type): # Check that previewing other item is impossible while dialog is open. execute_in_page('list_ctx.ul.children[0].click();') assert dialog_container.is_displayed() + assert 'list_disabled' in ul.get_attribute('class') assert not preview_container.is_displayed() # Check that queuing multiple removal confirmation dialogs is impossible. @@ -252,6 +252,7 @@ def test_item_list_displaying(driver, execute_in_page, item_type): execute_in_page('list_ctx.dialog_ctx.no_but.click();') WebDriverWait(driver, 10).until(lambda _: preview_container.is_displayed()) assert not dialog_container.is_displayed() + assert 'list_disabled' not in ul.get_attribute('class') assert execute_in_page('returnval(list_ctx.ul.children.length);') == 3 # Check that item removal works properly. @@ -268,6 +269,7 @@ def test_item_list_displaying(driver, execute_in_page, item_type): WebDriverWait(driver, 10).until(item_deleted) assert not dialog_container.is_displayed() assert not preview_container.is_displayed() + assert 'list_disabled' not in ul.get_attribute('class') execute_in_page('list_ctx.ul.children[1].click();') @@ -285,6 +287,7 @@ def test_item_list_displaying(driver, execute_in_page, item_type): sample_files['LICENSES/CC0-1.0.txt']['hash_key']) driver.find_element_by_link_text('LICENSES/CC0-1.0.txt').click() WebDriverWait(driver, 10).until(lambda _: dialog_container.is_displayed()) + assert 'list_disabled' in ul.get_attribute('class') assert not preview_container.is_displayed() msg = execute_in_page('returnval(list_ctx.dialog_ctx.msg.textContent);') diff --git a/test/unit/test_payload_create.py b/test/unit/test_payload_create.py index bda3293..569d088 100644 --- a/test/unit/test_payload_create.py +++ b/test/unit/test_payload_create.py @@ -83,7 +83,6 @@ def test_payload_create_normal_usage(driver, execute_in_page): """ A test case of normal usage of simple payload creation form. """ - clear_indexeddb(execute_in_page) execute_in_page(load_script('html/payload_create.js')) create_but, form_container, dialog_container = execute_in_page( @@ -189,7 +188,6 @@ def test_payload_create_errors(driver, execute_in_page): """ A test case of various error the simple payload form might show. """ - clear_indexeddb(execute_in_page) execute_in_page(load_script('html/payload_create.js')) create_but, dialog_container = execute_in_page( diff --git a/test/unit/test_text_entry_list.py b/test/unit/test_text_entry_list.py new file mode 100644 index 0000000..1951d53 --- /dev/null +++ b/test/unit/test_text_entry_list.py @@ -0,0 +1,310 @@ +# SPDX-License-Identifier: CC0-1.0 + +""" +Haketilo unit tests - list of editable entries +""" + +# 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 selenium.webdriver.common.keys import Keys +import inspect + +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 instantiate_list(to_return): + return inspect.stack()[1].frame.f_locals['execute_in_page']( + f''' + let dialog_ctx = dialog.make(() => {{}}, () => {{}}), list; + async function make_list() {{ + list = await repo_list(dialog_ctx); + document.body.append(list.main_div, dialog_ctx.main_div); + return [{', '.join(to_return)}]; + }} + returnval(make_list()); + ''') + +dialog_html_append = {'html/text_entry_list.html': '#INCLUDE html/dialog.html'} +dialog_html_test_ext_data = { + 'background_script': broker_js, + 'extra_html': ExtraHTML('html/text_entry_list.html', dialog_html_append), + 'navigate_to': 'html/text_entry_list.html' +} + +@pytest.mark.ext_data(dialog_html_test_ext_data) +@pytest.mark.usefixtures('webextension') +def test_text_entry_list_ordering(driver, execute_in_page): + """ + A test case of ordering of repo URLs in the list. + """ + execute_in_page(load_script('html/text_entry_list.js')) + + endings = ['hyd/', 'hydrilla/', 'Hydrilla/', 'HYDRILLA/', + 'test/', 'test^it/', 'Test^it/', 'TEST^IT/'] + + indexes_added = set() + + for iteration, to_include in enumerate([ + set([i for i in range(len(endings)) if is_prime(i)]), + set([i for i in range(len(endings)) + if not is_prime(i) and i & 1]), + set([i for i in range(len(endings)) if i % 3 == 0]), + set([i for i in range(len(endings)) + if i % 3 and not i & 1 and not is_prime(i)]), + set(range(len(endings))) + ]): + endings_to_include = [endings[i] for i in sorted(to_include)] + urls = [f'https://example.com/{e}' for e in endings_to_include] + + def add_urls(): + execute_in_page( + '''{ + async function add_urls(urls) { + for (const url of urls) + await haketilodb.set_repo(url); + } + returnval(add_urls(arguments[0])); + }''', + urls) + + def wait_for_completed(wait_id): + """ + We add an extra repo url to IndexedDB and wait for it to appear in + the DOM list. Once this happes, we know other operations must have + also finished. + """ + url = f'https://example.org/{iteration}/{wait_id}' + execute_in_page('returnval(haketilodb.set_repo(arguments[0]));', + url) + WebDriverWait(driver, 10).until(lambda _: url in list_div.text) + + def assert_order(indexes_present, empty_entry_expected=False): + entries_texts = execute_in_page( + ''' + returnval([...list.list_div.children].map(n => n.textContent)); + ''') + + if empty_entry_expected: + assert 'example' not in entries_texts[0] + entries_texts.pop(0) + + for i, et in zip(sorted(indexes_present), entries_texts): + assert f'https://example.com/{endings[i]}' in et + + for et in entries_texts[len(indexes_present):]: + assert 'example.org' in et + + add_urls() + + if iteration == 0: + list_div, new_entry_but = \ + instantiate_list(['list.list_div', 'list.new_but']) + + indexes_added.update(to_include) + wait_for_completed(0) + assert_order(indexes_added) + + execute_in_page( + '''{ + async function remove_urls(urls) { + for (const url of urls) + await haketilodb.del_repo(url); + } + returnval(remove_urls(arguments[0])); + }''', + urls) + wait_for_completed(1) + assert_order(indexes_added.difference(to_include)) + + # On the last iteration, add a new editable entry before re-additions. + if len(to_include) == len(endings): + new_entry_but.click() + add_urls() + wait_for_completed(2) + assert_order(indexes_added, empty_entry_expected=True) + else: + add_urls() + +def active(id): + return inspect.stack()[1].frame.f_locals['execute_in_page']\ + (f'returnval(list.active_entry.{id});') +def existing(id, entry_nr=0): + return inspect.stack()[1].frame.f_locals['execute_in_page']( + ''' + returnval(list.entries_by_text.get(list.shown_texts[arguments[0]])\ + [arguments[1]]); + ''', + entry_nr, id) + +@pytest.mark.ext_data(dialog_html_test_ext_data) +@pytest.mark.usefixtures('webextension') +def test_text_entry_list_editing(driver, execute_in_page): + """ + A test case of editing entries in repo URLs list. + """ + execute_in_page(load_script('html/text_entry_list.js')) + + execute_in_page( + ''' + let original_loader = dialog.loader, last_loader_msg; + dialog.loader = (ctx, ...msg) => { + last_loader_msg = msg; + return original_loader(ctx, ...msg); + } + ''') + last_loader_msg = lambda: execute_in_page('returnval(last_loader_msg);') + + list_div, new_entry_but = \ + instantiate_list(['list.list_div', 'list.new_but']) + + assert last_loader_msg() == ['Loading repositories...'] + assert execute_in_page('returnval(dialog_ctx.shown);') == False + + # Test adding new item. Submit via button click. + new_entry_but.click() + assert not active('noneditable_view').is_displayed() + assert not active('save_but').is_displayed() + assert active('add_but').is_displayed() + assert active('cancel_but').is_displayed() + active('input').send_keys('https://example.com///') + active('add_but').click() + WebDriverWait(driver, 10).until(lambda _: 'example.com/' in list_div.text) + assert execute_in_page('returnval(list.list_div.children.length);') == 1 + assert last_loader_msg() == ['Adding repository...'] + + assert not existing('editable_view').is_displayed() + assert existing('text').is_displayed() + assert existing('remove_but').is_displayed() + + # Test editing item. Submit via 'Enter' hit. + existing('text').click() + assert not active('noneditable_view').is_displayed() + assert not active('add_but').is_displayed() + assert active('save_but').is_displayed() + assert active('cancel_but').is_displayed() + assert active('input.value') == 'https://example.com/' + active('input').send_keys(Keys.BACKSPACE * 30 + 'https://example.org//' + + Keys.ENTER) + WebDriverWait(driver, 10).until(lambda _: 'example.org/' in list_div.text) + assert execute_in_page('returnval(list.list_div.children.length);') == 1 + assert last_loader_msg() == ['Replacing repository...'] + + # Test that clicking hidden buttons of item not being edited does nothing. + existing('add_but.click()') + existing('save_but.click()') + existing('cancel_but.click()') + assert execute_in_page('returnval(dialog_ctx.shown);') == False + assert execute_in_page('returnval(list.list_div.children.length);') == 1 + assert not existing('editable_view').is_displayed() + + # Test that clicking hidden buttons of item being edited does nothing. + existing('text').click() + active('remove_but.click()') + active('add_but.click()') + assert execute_in_page('returnval(dialog_ctx.shown);') == False + assert execute_in_page('returnval(list.list_div.children.length);') == 1 + assert not active('noneditable_view').is_displayed() + + # Test that creating a new entry makes the other one noneditable again. + new_entry_but.click() + assert existing('text').is_displayed() + + # Test that clicking hidden buttons of new item entry does nothing. + active('remove_but.click()') + active('save_but.click()') + assert execute_in_page('returnval(dialog_ctx.shown);') == False + assert execute_in_page('returnval(list.list_div.children.length);') == 2 + assert not active('noneditable_view').is_displayed() + + # Test that starting edit of another entry removes the new entry. + existing('text').click() + assert existing('editable_view').is_displayed() + assert execute_in_page('returnval(list.list_div.children.length);') == 1 + + # Test that starting edit of another entry cancels edit of the other entry. + new_entry_but.click() + active('input').send_keys('https://example.net' + Keys.ENTER) + WebDriverWait(driver, 10).until(lambda _: 'example.net/' in list_div.text) + assert execute_in_page('returnval(list.list_div.children.length);') == 2 + existing('text', 0).click() + assert existing('editable_view', 0).is_displayed() + assert not existing('editable_view', 1).is_displayed() + existing('text', 1).click() + assert not existing('editable_view', 0).is_displayed() + assert existing('editable_view', 1).is_displayed() + + # Test entry removal. + existing('remove_but', 0).click() + WebDriverWait(driver, 10).until(lambda _: 'mple.net/' not in list_div.text) + assert execute_in_page('returnval(list.list_div.children.length);') == 1 + assert last_loader_msg() == ['Removing repository...'] + +@pytest.mark.ext_data(dialog_html_test_ext_data) +@pytest.mark.usefixtures('webextension') +def test_text_entry_list_errors(driver, execute_in_page): + """ + A test case of error dialogs shown by repo URL list. + """ + execute_in_page(load_script('html/text_entry_list.js')) + + to_return = ['list.list_div', 'list.new_but', 'dialog_ctx.main_div'] + list_div, new_entry_but, dialog_div = instantiate_list(to_return) + + # Prepare one entry to use later. + new_entry_but.click() + active('input').send_keys('https://example.com' + Keys.ENTER) + + # Check invalid URL errors. + for clickable in (existing('text'), new_entry_but): + clickable.click() + active('input').send_keys(Keys.BACKSPACE * 30 + 'ws://example' + + Keys.ENTER) + assert 'Repository URLs shoud use https:// schema.' in dialog_div.text + execute_in_page('dialog.close(dialog_ctx);') + + active('input').send_keys(Keys.BACKSPACE * 30 + 'https://example' + + Keys.ENTER) + assert 'Provided URL is not valid.' in dialog_div.text + execute_in_page('dialog.close(dialog_ctx);') + + # Mock errors to force error messages to appear. + execute_in_page( + ''' + for (const action of ["set_repo", "del_repo"]) + haketilodb[action] = () => {throw "reckless, limitless scope";}; + ''') + + # Check database error dialogs. + def check_reported_failure(what): + fail = lambda _: f'Failed to {what} repository :(' in dialog_div.text + WebDriverWait(driver, 10).until(fail) + execute_in_page('dialog.close(dialog_ctx);') + + existing('text').click() + active('input').send_keys(Keys.BACKSPACE * 30 + 'https://example.org' + + Keys.ENTER) + check_reported_failure('replace') + + active('cancel_but').click() + existing('remove_but').click() + check_reported_failure('remove') + + new_entry_but.click() + active('input').send_keys('https://example.org' + Keys.ENTER) + check_reported_failure('add') diff --git a/test/unit/utils.py b/test/unit/utils.py index 255f89d..a35e587 100644 --- a/test/unit/utils.py +++ b/test/unit/utils.py @@ -122,3 +122,6 @@ def get_db_contents(execute_in_page): } returnval(get_database_contents()); }''') + +def is_prime(n): + return n > 1 and all([n % i != 0 for i in range(2, n)]) |