summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorWojtek Kosior <koszko@koszko.org>2022-01-13 10:15:12 +0100
committerWojtek Kosior <koszko@koszko.org>2022-01-13 10:15:12 +0100
commit5acb2499c1df14d6275b1ad9e139f02d1280cb9c (patch)
tree9407e99955a654cd04cbf96b8f28f5a6e7f36289
parentdd8de100acda322f2124c58163ecde6f1b37d61d (diff)
downloadbrowser-extension-5acb2499c1df14d6275b1ad9e139f02d1280cb9c.tar.gz
browser-extension-5acb2499c1df14d6275b1ad9e139f02d1280cb9c.zip
facilitate managing repository URLs in a list; minor other changes
-rw-r--r--common/patterns.js3
-rwxr-xr-xcompute_scripts.awk15
-rw-r--r--html/dialog.js10
-rw-r--r--html/grid.css6
-rw-r--r--html/item_list.html8
-rw-r--r--html/item_list.js4
-rw-r--r--html/item_preview.html2
-rw-r--r--html/payload_create.html3
-rw-r--r--html/payload_create.js5
-rw-r--r--html/text_entry_list.html65
-rw-r--r--html/text_entry_list.js321
-rw-r--r--test/unit/test_item_list.py17
-rw-r--r--test/unit/test_payload_create.py2
-rw-r--r--test/unit/test_text_entry_list.py310
-rw-r--r--test/unit/utils.py3
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)])