diff options
-rw-r--r-- | html/base.css | 2 | ||||
-rw-r--r-- | html/dialog.js | 64 | ||||
-rw-r--r-- | html/grid.css | 8 | ||||
-rw-r--r-- | html/item_preview.html | 34 | ||||
-rw-r--r-- | html/payload_create.html | 59 | ||||
-rw-r--r-- | html/payload_create.js | 31 | ||||
-rw-r--r-- | test/unit/test_payload_create.py | 211 | ||||
-rw-r--r-- | test/unit/utils.py | 5 |
8 files changed, 289 insertions, 125 deletions
diff --git a/html/base.css b/html/base.css index 6085f5f..e5e4660 100644 --- a/html/base.css +++ b/html/base.css @@ -34,7 +34,7 @@ body { overflow: auto; } -.bold, h1, h2, h3 { +.bold, h1, h2, h3, label { font-weight: bold; } diff --git a/html/dialog.js b/html/dialog.js index 22e8aa9..c4bba5d 100644 --- a/html/dialog.js +++ b/html/dialog.js @@ -42,7 +42,6 @@ */ #FROM html/DOM_helpers.js IMPORT clone_template -#FROM common/lock.js IMPORT make_lock, lock, try_lock, unlock function make(on_dialog_show, on_dialog_hide) { @@ -51,9 +50,7 @@ function make(on_dialog_show, on_dialog_hide) on_dialog_show, on_dialog_hide, shown: false, - queue: 0, - lock: make_lock(), - callback: null + queue: [], }); for (const [id, val] of [["yes", true], ["no", false], ["ok", undefined]]) { @@ -68,33 +65,30 @@ function make(on_dialog_show, on_dialog_hide) function close_dialog(dialog_context, event) { - if (event && event.target.parentElement.classList.contains("hide")) + if ((event && event.target.parentElement.classList.contains("hide")) || + dialog_context.queue.length === 0) return; - const result = event ? event.target.haketilo_dialog_result : undefined; + const [[shown_buts_id, msg, resolve]] = dialog_context.queue.splice(0, 1); - if (dialog_context.queue > 0) - dialog_context.callback(result); + if (dialog_context.queue.length > 0) { + process_queue_item(dialog_context); + } else { + dialog_context.shown = false; + try { + dialog_context.on_dialog_hide(); + } catch(e) { + console.error(e); + } + } + + resolve(event ? event.target.haketilo_dialog_result : undefined); } #EXPORT close_dialog AS close -async function show_dialog(dialog_context, shown_buts_id, msg) +function process_queue_item(dialog_context) { - dialog_context.queue++; - - if (!dialog_context.shown) { - dialog_context.shown = true; - dialog_context.on_dialog_show(); - } - - /* - * We want the dialog to be ready for calling close() right after - * show_dialog() gets called. For this, we want locking to happen - * synchronously if possible. If impossible (lock taken), this means dialog - * is already open, hence it's also ready for close()'ing. - */ - if (!try_lock(dialog_context.lock)) - await lock(dialog_context.lock); + const [shown_buts_id, msg, resolve] = dialog_context.queue[0]; [...dialog_context.msg.childNodes].forEach(n => n.remove()); dialog_context.msg.append(...msg); @@ -102,17 +96,25 @@ async function show_dialog(dialog_context, shown_buts_id, msg) 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); +async function show_dialog(dialog_context, shown_buts_id, msg) +{ + let resolve; + const result_prom = new Promise(cb => resolve = cb); + dialog_context.queue.push([shown_buts_id, msg, resolve]); - if (--dialog_context.queue == 0) { - dialog_context.shown = false; - dialog_context.on_dialog_hide(); + if (!dialog_context.shown) { + process_queue_item(dialog_context); + dialog_context.shown = true; + try { + dialog_context.on_dialog_show(); + } catch(e) { + console.error(e); + } } - unlock(dialog_context.lock); - - return result; + return await result_prom; } const error = (ctx, ...msg) => show_dialog(ctx, "conf_buts", msg); diff --git a/html/grid.css b/html/grid.css index 591772e..aa8fc80 100644 --- a/html/grid.css +++ b/html/grid.css @@ -70,11 +70,3 @@ div.grid_col_both { margin-left: 1em; margin-right: 1em; } - -.grid_form>span { - font-weight: bold; -} - -span.grid_col_2 { - font-weight: initial; -} diff --git a/html/item_preview.html b/html/item_preview.html index a00d299..6cd15a8 100644 --- a/html/item_preview.html +++ b/html/item_preview.html @@ -46,43 +46,43 @@ <div id="resource_preview" data-template="main_div" class="grid_2 grid_form preview_main_div"> <h3 class="grid_col_both">resource preview</h3> - <span class="grid_col_1">identifier:</span> + <label class="grid_col_1">identifier:</label> <span data-template="identifier" class="grid_col_2">...</span> - <span class="grid_col_1">long name:</span> + <label class="grid_col_1">long name:</label> <span data-template="long_name" class="grid_col_2">...</span> - <span class="grid_col_1">UUID:</span> + <label class="grid_col_1">UUID:</label> <span data-template="uuid" class="grid_col_2">...</span> - <span class="grid_col_1">version:</span> + <label class="grid_col_1">version:</label> <span data-template="version" class="grid_col_2">...</span> - <span class="grid_col_1">description:</span> + <label class="grid_col_1">description:</label> <span data-template="description" class="grid_col_2">...</span> - <span class="grid_col_1">dependencies:</span> + <label class="grid_col_1">dependencies:</label> <span class="grid_col_2"><ul data-template="dependencies"></ul></span> - <span class="grid_col_1">scripts:</span> + <label class="grid_col_1">scripts:</label> <span class="grid_col_2"><ul data-template="scripts"></ul></span> - <span class="grid_col_1">source name:</span> + <label class="grid_col_1">source name:</label> <span data-template="source_name" class="grid_col_2">...</span> - <span class="grid_col_1">copyright:</span> + <label class="grid_col_1">copyright:</label> <span class="grid_col_2"><ul data-template="copyright"></ul></span> </div> <div id="mapping_preview" data-template="main_div" class="grid_2 grid_form"> <h3 class="grid_col_both">mapping preview</h3> - <span class="grid_col_1">identifier:</span> + <label class="grid_col_1">identifier:</label> <span data-template="identifier" class="grid_col_2">...</span> - <span class="grid_col_1">long name:</span> + <label class="grid_col_1">long name:</label> <span data-template="long_name" class="grid_col_2">...</span> - <span class="grid_col_1">UUID:</span> + <label class="grid_col_1">UUID:</label> <span data-template="uuid" class="grid_col_2">...</span> - <span class="grid_col_1">version:</span> + <label class="grid_col_1">version:</label> <span data-template="version" class="grid_col_2">...</span> - <span class="grid_col_1">description:</span> + <label class="grid_col_1">description:</label> <span data-template="description" class="grid_col_2">...</span> - <span class="grid_col_both">payloads:</span> + <label class="grid_col_both">payloads:</label> <div data-template="payloads" class="grid_col_both grid_2"></div> - <span class="grid_col_1">source name:</span> + <label class="grid_col_1">source name:</label> <span data-template="source_name" class="grid_col_2">...</span> - <span class="grid_col_1">copyright:</span> + <label class="grid_col_1">copyright:</label> <span class="grid_col_2"><ul data-template="copyright"></ul></span> </div> </template> diff --git a/html/payload_create.html b/html/payload_create.html index 0f6927f..062e41c 100644 --- a/html/payload_create.html +++ b/html/payload_create.html @@ -43,29 +43,54 @@ .payload_create_main_view { margin: 0.8em; } - .payload_create_buts { + .payload_create_form { + margin: 0 0.6em; + } + .payload_create_form>* { + margin: 0.3em 0; + } + .payload_create_form>textarea { + resize: vertical; + height: 5em; + } + .payload_create_form>textarea.payload_create_script { + height: 18em; + font-family: monospace; + } + .payload_create_buts, .payload_create_heading { text-align: center; + margin: 0.5em 0; } </style> <template> <div id="payload_create" data-template="main_div" class="payload_create_main_view"> - <div data-template="form_container" class="grid_1"> - <h3>create_payload</h3> - <label> - identifier* (may only contain digits 0-9, lowercase letters a-z and hyphens '-'; will have 'local-' prepended): - </label> - <input data-template="identifier"> - <label>long name (defaults to the same as identifier):</label> - <input data-template="long_name"> - <label>description:</label> - <textarea data-template="description"></textarea> - <label>URL patterns* (put each on its own line):</label> - <textarea data-template="patterns">https://example.com/***</textarea> - <label>script to inject*:</label> - <textarea data-template="script">console.log("Hello, World!");</textarea> - <div class="payload_create_buts"> - <button data-template="create_but">Create</button> + <div data-template="form_container"> + <div class="grid_1 payload_create_form"> + <h3 class="payload_create_heading">Create payload</h3> + <span> + <label>identifier*</label> + (may only contain digits 0-9, lowercase letters a-z and hyphens '-'; + will have 'local-' prepended): + </span> + <input data-template="identifier"> + <span> + <label>long name</label> (defaults to the same as identifier): + </span> + <input data-template="long_name"> + <span><label>description</label>:</span> + <textarea data-template="description"></textarea> + <span> + <label><a data-template="patterns_link">URL patterns</a>*</label> + (put each on its own line): + </span> + <textarea data-template="patterns">https://example.com/***</textarea> + <span><label>script to inject*</label>:</span> + <textarea data-template="script" class="payload_create_script" + >console.log("Hello, World!");</textarea> + <div class="payload_create_buts"> + <button data-template="create_but">Create</button> + </div> </div> </div> <div data-template="dialog_container" class="hide"> diff --git a/html/payload_create.js b/html/payload_create.js index db63a82..a5f9854 100644 --- a/html/payload_create.js +++ b/html/payload_create.js @@ -48,6 +48,9 @@ #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"; + /* https://stackoverflow.com/questions/105034/how-to-create-a-guid-uuid */ /* This is a helper function used by uuidv4(). */ function uuid_replace_num(num) @@ -85,8 +88,6 @@ function collect_form_data(form_ctx) try { deconstruct_url(pattern); } catch(e) { - const patterns_doc_url = - "https://hydrillabugs.koszko.org/projects/haketilo/wiki/URL_patterns"; const patterns_doc_link = document.createElement("a"); patterns_doc_link.href = patterns_doc_url; patterns_doc_link.innerText = "here"; @@ -97,7 +98,7 @@ function collect_form_data(form_ctx) } if (pattern in payloads) - throw `Pattern '${pattern}' soecified multiple times!`; + throw `Pattern '${pattern}' specified multiple times!`; payloads[pattern] = {identifier}; } @@ -135,6 +136,15 @@ function collect_form_data(form_ctx) return {identifier, resource, mapping, files: {[hash_key]: script}}; } +function clear_form(form_ctx) +{ + form_ctx.identifier.value = ""; + form_ctx.long_name.value = ""; + form_ctx.description.value = ""; + form_ctx.patterns.value = "https://example.com/***"; + form_ctx.script.value = `console.log("Hello, World!");`; +} + async function save_payload(saving) { const db = await haketilodb.get(); @@ -146,7 +156,7 @@ async function save_payload(saving) if (!saving[`override_${type}`] && (await haketilodb.idb_get(tx_ctx.transaction, store_name, saving.identifier))) { - saving.ask_override = "resource"; + saving.ask_override = type; return; } } @@ -160,8 +170,8 @@ async function save_payload(saving) function override_question(saving) { return saving.ask_override === "resource" ? - `Resource '${saving.identifier}' alredy exists. Override?` : - `Mapping '${saving.identifier}' alredy exists. Override?`; + `Resource '${saving.identifier}' already exists. Override?` : + `Mapping '${saving.identifier}' already exists. Override?`; } async function create_clicked(form_ctx) @@ -169,16 +179,15 @@ async function create_clicked(form_ctx) if (form_ctx.dialog_ctx.shown) return; - dialog.loader(form_ctx.dialog_ctx, "Saving payload..."); - try { var saving = collect_form_data(form_ctx); } catch(e) { dialog.error(form_ctx.dialog_ctx, e); - dialog.close(form_ctx.dialog_ctx); return; } + dialog.loader(form_ctx.dialog_ctx, "Saving payload..."); + try { do { if (saving.ask_override) { @@ -197,9 +206,10 @@ async function create_clicked(form_ctx) } while (saving.ask_override); dialog.info(form_ctx.dialog_ctx, "Successfully saved payload!"); + clear_form(form_ctx); } catch(e) { console.error(e); - dialog.error(form_ctx.dialog_ctx, "Failed to save payload :(") + dialog.error(form_ctx.dialog_ctx, "Failed to save payload :("); } dialog.close(form_ctx.dialog_ctx); @@ -221,6 +231,7 @@ function payload_create_form() () => on_show_hide(form_ctx, "form")); form_ctx.dialog_container.prepend(form_ctx.dialog_ctx.main_div); + form_ctx.patterns_link.href = patterns_doc_url; form_ctx.create_but.addEventListener("click", () => create_clicked(form_ctx)); diff --git a/test/unit/test_payload_create.py b/test/unit/test_payload_create.py index cd08d43..bda3293 100644 --- a/test/unit/test_payload_create.py +++ b/test/unit/test_payload_create.py @@ -23,7 +23,7 @@ from selenium.webdriver.support.ui import WebDriverWait from ..extension_crafting import ExtraHTML from ..script_loader import load_script -from .utils import clear_indexeddb, get_db_contents, sample_files +from .utils import * broker_js = lambda: load_script('background/broadcast_broker.js') + ';start();' @@ -55,6 +55,23 @@ def fill_form_with_sample_data(execute_in_page, sample_data_override={}, {form_ctx}[key].value = value; ''', form_data) + return form_data + +cleared_form_inputs = { + 'identifier': '', + 'long_name': '', + 'description': '', + 'patterns': 'https://example.com/***', + 'script': 'console.log("Hello, World!");' +} +def assert_form_contents(execute_in_page, inputs=cleared_form_inputs): + inputs_keys = [*inputs.keys()] + values = execute_in_page( + 'returnval(arguments[0].map(i => form_ctx[i].value));', + inputs_keys + ) + for key, value in zip(inputs_keys, values): + assert inputs[key] == value @pytest.mark.ext_data({ 'background_script': broker_js, @@ -62,60 +79,174 @@ def fill_form_with_sample_data(execute_in_page, sample_data_override={}, 'navigate_to': 'html/payload_create.html' }) @pytest.mark.usefixtures('webextension') -def test_payload_create(driver, execute_in_page): +def test_payload_create_normal_usage(driver, execute_in_page): """ - A test case of creating a simple payload using a form. + 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, main_div = execute_in_page( + create_but, form_container, dialog_container = execute_in_page( ''' const form_ctx = payload_create_form(); document.body.append(form_ctx.main_div); - returnval([form_ctx.create_but, form_ctx.main_div]); + returnval([form_ctx.create_but, form_ctx.form_container, + form_ctx.dialog_container]); ''') - fill_form_with_sample_data(execute_in_page) + assert doc_url == \ + driver.find_element_by_link_text('URL patterns').get_attribute('href') + + assert form_container.is_displayed() + assert not dialog_container.is_displayed() + + assert_form_contents(execute_in_page) + + form_data = fill_form_with_sample_data(execute_in_page) create_but.click() + assert not form_container.is_displayed() + assert dialog_container.is_displayed() + def success_reported(driver): - return 'Successfully saved payload' in main_div.text + return 'Successfully saved payload' in dialog_container.text WebDriverWait(driver, 10).until(success_reported) + execute_in_page('form_ctx.dialog_ctx.ok_but.click();') + + assert form_container.is_displayed() + assert not dialog_container.is_displayed() + + def assert_db_contents(): + db_contents = get_db_contents(execute_in_page) + + assert uuidv4_re.match(db_contents['resources'][0]['uuid']) + + localid = f'local-{form_data["identifier"]}' + long_name = form_data['long_name'] or form_data['identifier'] + payloads = dict([(pat, {'identifier': localid}) + for pat in form_data['patterns'].split('\n') if pat]) + + assert db_contents['resources'] == [{ + 'source_name': localid, + 'source_copyright': [], + 'type': 'resource', + 'identifier': localid, + 'uuid': db_contents['resources'][0]['uuid'], + 'version': [1], + 'description': form_data['description'], + 'dependencies': [], + 'long_name': long_name, + 'scripts': [{ + 'file': 'payload.js', + 'hash_key': make_hash_key(form_data['script']) + }] + }] - db_contents = get_db_contents(execute_in_page) - - assert uuidv4_re.match(db_contents['resources'][0]['uuid']) - assert db_contents['resources'] == [{ - 'source_name': 'local-someid', - 'source_copyright': [], - 'type': 'resource', - 'identifier': 'local-someid', - 'long_name': 'Some Name', - 'uuid': db_contents['resources'][0]['uuid'], - 'version': [1], - 'description': 'blah blah blah', - 'dependencies': [], - 'scripts': [{ - 'file': 'payload.js', - 'hash_key': sample_files['hello.js']['hash_key'] + assert uuidv4_re.match(db_contents['mappings'][0]['uuid']) + assert db_contents['mappings'] == [{ + 'source_name': localid, + 'source_copyright': [], + 'type': 'mapping', + 'identifier': localid, + 'uuid': db_contents['mappings'][0]['uuid'], + 'version': [1], + 'description': form_data['description'], + 'long_name': long_name, + 'payloads': payloads }] - }] - - assert uuidv4_re.match(db_contents['mappings'][0]['uuid']) - assert db_contents['mappings'] == [{ - 'source_name': 'local-someid', - 'source_copyright': [], - 'type': 'mapping', - 'identifier': 'local-someid', - 'long_name': 'Some Name', - 'uuid': db_contents['mappings'][0]['uuid'], - 'version': [1], - 'description': 'blah blah blah', - 'payloads': { - 'http://example.com/***': {'identifier': 'local-someid'}, - 'https://*.example.org/**': {'identifier': 'local-someid'} - } - }] + + assert_db_contents() + + form_data = fill_form_with_sample_data(execute_in_page, { + 'long_name': '', + 'description': 'bam bam bam', + 'patterns': 'https://new.example.com/***', + 'script': sample_files['bye.js']['contents'] + }) + + create_but.click() + + for type in ('Resource', 'Mapping'): + def override_asked(driver): + return f"{type} 'local-someid' already exists. Override?" \ + in dialog_container.text + WebDriverWait(driver, 10).until(override_asked) + execute_in_page('form_ctx.dialog_ctx.yes_but.click();') + + assert_db_contents() + +doc_url = 'https://hydrillabugs.koszko.org/projects/haketilo/wiki/URL_patterns' + +@pytest.mark.ext_data({ + 'background_script': broker_js, + 'extra_html': ExtraHTML('html/payload_create.html', {}), + 'navigate_to': 'html/payload_create.html' +}) +@pytest.mark.usefixtures('webextension') +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( + ''' + const form_ctx = payload_create_form(); + document.body.append(form_ctx.main_div); + returnval([form_ctx.create_but, form_ctx.dialog_container]); + ''') + + for data_override, expected_msg in [ + ({'identifier': ''}, "The 'identifier' field is required!"), + ({'identifier': ':('}, 'Identifier may only contain '), + ({'script': ''}, "The 'script' field is required!"), + ({'patterns': ''}, "The 'URL patterns' field is required!"), + ({'patterns': ':d'}, "':d' is not a valid URL pattern. See here for more details."), + ({'patterns': '\n'.join(['http://example.com'] * 2)}, + "Pattern 'http://example.com' specified multiple times!") + ]: + # Attempt creating the payload + form_data = fill_form_with_sample_data(execute_in_page, data_override) + create_but.click() + # Verify the error message + assert expected_msg in dialog_container.text + + # Verify patterns documentation <a> link. + if expected_msg == {'patterns': ':d'}: + doc_link_elem = driver.find_element_by_link_text('here') + assert doc_link.get_attribute('href') == doc_url + + # Verify the form was NOT cleared upon failed saving. + execute_in_page('form_ctx.dialog_ctx.ok_but.click();') + assert_form_contents(execute_in_page, form_data) + + # Add a sample item and attempt overriding it. + fill_form_with_sample_data(execute_in_page) + create_but.click() + WebDriverWait(driver, 10).until(lambda _: 'Succes' in dialog_container.text) + execute_in_page('form_ctx.dialog_ctx.ok_but.click();') + + # Verify that denying override leads to saving failure. + form_data = fill_form_with_sample_data(execute_in_page) + create_but.click() + WebDriverWait(driver, 10).until(lambda _: 'Overri' in dialog_container.text) + execute_in_page('form_ctx.dialog_ctx.no_but.click();') + assert 'Failed to save payload :(' in dialog_container.text + execute_in_page('form_ctx.dialog_ctx.ok_but.click();') + assert_form_contents(execute_in_page, form_data) + + # Verify that IndexedDB errors get caught and reported as saving failures. + execute_in_page('haketilodb.get = async () => {throw "someerror";}') + form_data = fill_form_with_sample_data(execute_in_page, {'identifier': 'o'}) + create_but.click() + WebDriverWait(driver, 10).until(lambda _: 'Failed' in dialog_container.text) + execute_in_page('form_ctx.dialog_ctx.ok_but.click();') + assert_form_contents(execute_in_page, form_data) + + # Verify that the loading message gets shown during IndexedDB operations. + execute_in_page('haketilodb.get = () => new Promise(cb => null);') + create_but.click() + assert 'Saving payload...' in dialog_container.text diff --git a/test/unit/utils.py b/test/unit/utils.py index a61e215..255f89d 100644 --- a/test/unit/utils.py +++ b/test/unit/utils.py @@ -27,9 +27,12 @@ Various functions and objects that can be reused between unit tests from hashlib import sha256 +def make_hash_key(file_contents): + return f'sha256-{sha256(file_contents.encode()).digest().hex()}' + def sample_file(contents): return { - 'hash_key': f'sha256-{sha256(contents.encode()).digest().hex()}', + 'hash_key': make_hash_key(contents), 'contents': contents } |