diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 19:33:14 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 19:33:14 +0000 |
commit | 36d22d82aa202bb199967e9512281e9a53db42c9 (patch) | |
tree | 105e8c98ddea1c1e4784a60a5a6410fa416be2de /toolkit/components/satchel/test | |
parent | Initial commit. (diff) | |
download | firefox-esr-36d22d82aa202bb199967e9512281e9a53db42c9.tar.xz firefox-esr-36d22d82aa202bb199967e9512281e9a53db42c9.zip |
Adding upstream version 115.7.0esr.upstream/115.7.0esr
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'toolkit/components/satchel/test')
51 files changed, 5636 insertions, 0 deletions
diff --git a/toolkit/components/satchel/test/FormHistoryTestUtils.sys.mjs b/toolkit/components/satchel/test/FormHistoryTestUtils.sys.mjs new file mode 100644 index 0000000000..f17b952b90 --- /dev/null +++ b/toolkit/components/satchel/test/FormHistoryTestUtils.sys.mjs @@ -0,0 +1,106 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + FormHistory: "resource://gre/modules/FormHistory.sys.mjs", +}); + +/** + * Provides a js-friendly promise-based API around FormHistory, and utils. + * + * Note: This is not a 100% complete implementation, it is intended for quick + * additions and check, thus further changes may be necessary for different + * use-cases. + */ +export var FormHistoryTestUtils = { + /** + * Adds values to form history. + * + * @param {string} fieldname The field name. + * @param {Array} additions Array of entries describing the values to add. + * Each entry can either be a string, or an object with the shape + * { value, source}. + * @returns {Promise} Resolved once the operation is complete. + */ + async add(fieldname, additions = []) { + // Additions are made one by one, so multiple identical entries are properly + // applied. + additions = additions.map(v => (typeof v == "string" ? { value: v } : v)); + for (let { value, source } of additions) { + await lazy.FormHistory.update( + Object.assign({ fieldname }, { op: "bump", value, source }) + ); + } + }, + + /** + * Counts values from form history. + * + * @param {string} fieldname The field name. + * @param {Array} filters Objects describing the search properties. + * @returns {number} The number of entries found. + */ + async count(fieldname, filters = {}) { + return lazy.FormHistory.count(Object.assign({ fieldname }, filters)); + }, + + /** + * Removes values from form history. + * If you want to remove all history, use clear() instead. + * + * @param {string} fieldname The field name. + * @param {Array} removals Array of entries describing the values to add. + * Each entry can either be a string, or an object with the shape + * { value, source}. If source is specified, only the source relation will + * be removed, while the global form history value persists. + * @returns {Promise} Resolved once the operation is complete. + */ + remove(fieldname, removals) { + let changes = removals.map(v => { + let criteria = typeof v == "string" ? { value: v } : v; + return Object.assign({ fieldname, op: "remove" }, criteria); + }); + return lazy.FormHistory.update(changes); + }, + + /** + * Removes all values from form history. + * If you want to remove individual values, use remove() instead. + * + * @param {string} fieldname The field name whose history should be cleared. + * Can be omitted to clear all form history. + * @returns {Promise} Resolved once the operation is complete. + */ + clear(fieldname) { + let baseChange = fieldname ? { fieldname } : {}; + return lazy.FormHistory.update(Object.assign(baseChange, { op: "remove" })); + }, + + /** + * Searches form history. + * + * @param {string} fieldname The field name. + * @param {Array} filters Objects describing the search properties. + * @returns {Promise<Array>} Resolves an array of found form history entries. + */ + search(fieldname, filters = {}) { + return lazy.FormHistory.search(null, Object.assign({ fieldname }, filters)); + }, + + /** + * Gets autocomplete results from form history. + * + * @param {string} searchString The search string. + * @param {string} fieldname The field name. + * @param {Array} filters Objects describing the search properties. + * @returns {Promise<Array>} Resolves an array of found form history entries. + */ + autocomplete(searchString, fieldname, filters = {}) { + return lazy.FormHistory.getAutoCompleteResults( + searchString, + Object.assign({ fieldname }, filters) + ); + }, +}; diff --git a/toolkit/components/satchel/test/browser/browser.ini b/toolkit/components/satchel/test/browser/browser.ini new file mode 100644 index 0000000000..6e50b45e7e --- /dev/null +++ b/toolkit/components/satchel/test/browser/browser.ini @@ -0,0 +1,9 @@ +[DEFAULT] +support-files = + !/toolkit/components/satchel/test/subtst_privbrowsing.html + +[browser_close_tab.js] +[browser_popup_mouseover.js] +skip-if = verify +[browser_privbrowsing_perwindowpb.js] +skip-if = verify diff --git a/toolkit/components/satchel/test/browser/browser_close_tab.js b/toolkit/components/satchel/test/browser/browser_close_tab.js new file mode 100644 index 0000000000..37962d37d8 --- /dev/null +++ b/toolkit/components/satchel/test/browser/browser_close_tab.js @@ -0,0 +1,46 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +const { FormHistory } = ChromeUtils.importESModule( + "resource://gre/modules/FormHistory.sys.mjs" +); + +add_task(async function test() { + const url = `data:text/html,<input type="text" name="field1">`; + + // Open a dummy tab. + await BrowserTestUtils.withNewTab( + { gBrowser, url }, + async function (browser) {} + ); + + await BrowserTestUtils.withNewTab( + { gBrowser, url }, + async function (browser) { + const { autoCompletePopup } = browser; + const mockHistory = [{ op: "add", fieldname: "field1", value: "value1" }]; + + await FormHistory.update([{ op: "remove" }, ...mockHistory]); + await SpecialPowers.spawn(browser, [], async function () { + const input = content.document.querySelector("input"); + + input.focus(); + }); + + // show popup + await BrowserTestUtils.synthesizeKey("VK_DOWN", {}, browser); + await TestUtils.waitForCondition(() => { + return autoCompletePopup.popupOpen; + }); + + gBrowser.removeCurrentTab(); + + await TestUtils.waitForCondition(() => { + return !autoCompletePopup.popupOpen; + }); + + Assert.ok(!autoCompletePopup.popupOpen, "Ensure the popup is closed."); + } + ); +}); diff --git a/toolkit/components/satchel/test/browser/browser_popup_mouseover.js b/toolkit/components/satchel/test/browser/browser_popup_mouseover.js new file mode 100644 index 0000000000..2b293ab983 --- /dev/null +++ b/toolkit/components/satchel/test/browser/browser_popup_mouseover.js @@ -0,0 +1,81 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +const { FormHistory } = ChromeUtils.importESModule( + "resource://gre/modules/FormHistory.sys.mjs" +); + +add_task(async function test() { + const url = `data:text/html,<input type="text" name="field1">`; + await BrowserTestUtils.withNewTab( + { gBrowser, url }, + async function (browser) { + const { + autoCompletePopup, + autoCompletePopup: { richlistbox: itemsBox }, + } = browser; + const mockHistory = [ + { op: "add", fieldname: "field1", value: "value1" }, + { op: "add", fieldname: "field1", value: "value2" }, + { op: "add", fieldname: "field1", value: "value3" }, + { op: "add", fieldname: "field1", value: "value4" }, + ]; + + await FormHistory.update([{ op: "remove" }, ...mockHistory]); + await SpecialPowers.spawn(browser, [], async function () { + const input = content.document.querySelector("input"); + + input.focus(); + }); + + // show popup + await BrowserTestUtils.synthesizeKey("VK_DOWN", {}, browser); + await BrowserTestUtils.waitForCondition(() => { + return autoCompletePopup.popupOpen; + }); + const listItemElems = itemsBox.querySelectorAll( + ".autocomplete-richlistitem" + ); + Assert.equal( + listItemElems.length, + mockHistory.length, + "ensure result length" + ); + Assert.equal( + autoCompletePopup.mousedOverIndex, + -1, + "mousedOverIndex should be -1" + ); + + // navigate to the first item + await BrowserTestUtils.synthesizeKey("VK_DOWN", {}, browser); + Assert.equal( + autoCompletePopup.selectedIndex, + 0, + "selectedIndex should be 0" + ); + + // mouseover the second item + EventUtils.synthesizeMouseAtCenter(listItemElems[1], { + type: "mouseover", + }); + await BrowserTestUtils.waitForCondition(() => { + return (autoCompletePopup.mousedOverIndex = 1); + }); + Assert.ok(true, "mousedOverIndex changed"); + Assert.equal( + autoCompletePopup.selectedIndex, + 0, + "selectedIndex should not be changed by mouseover" + ); + + // close popup + await SpecialPowers.spawn(browser, [], async function () { + const input = content.document.querySelector("input"); + + input.blur(); + }); + } + ); +}); diff --git a/toolkit/components/satchel/test/browser/browser_privbrowsing_perwindowpb.js b/toolkit/components/satchel/test/browser/browser_privbrowsing_perwindowpb.js new file mode 100644 index 0000000000..9614fd8f15 --- /dev/null +++ b/toolkit/components/satchel/test/browser/browser_privbrowsing_perwindowpb.js @@ -0,0 +1,49 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +var { FormHistory } = ChromeUtils.importESModule( + "resource://gre/modules/FormHistory.sys.mjs" +); + +/** Test for Bug 472396 */ +add_task(async function test() { + // initialization + let windowsToClose = []; + let testURI = + "http://example.com/tests/toolkit/components/satchel/test/subtst_privbrowsing.html"; + + async function doTest(aShouldValueExist, aWindow) { + let browser = aWindow.gBrowser.selectedBrowser; + BrowserTestUtils.loadURIString(browser, testURI); + await BrowserTestUtils.browserLoaded(browser); + + // Wait for the page to reload itself. + await BrowserTestUtils.browserLoaded(browser); + + let count = await FormHistory.count({ fieldname: "field", value: "value" }); + + if (aShouldValueExist) { + Assert.equal(count, 1, "In non-PB mode, we add a single entry"); + } else { + Assert.equal(count, 0, "In PB mode, we don't add any entries"); + } + } + + function testOnWindow(aOptions, aCallback) { + return BrowserTestUtils.openNewBrowserWindow(aOptions).then(win => { + windowsToClose.push(win); + return win; + }); + } + + await testOnWindow({ private: true }).then(aWin => doTest(false, aWin)); + + // Test when not on private mode after visiting a site on private + // mode. The form history should not exist. + await testOnWindow({}).then(aWin => doTest(true, aWin)); + + await Promise.all( + windowsToClose.map(win => BrowserTestUtils.closeWindow(win)) + ); +}); diff --git a/toolkit/components/satchel/test/mochitest.ini b/toolkit/components/satchel/test/mochitest.ini new file mode 100644 index 0000000000..54c05a16c6 --- /dev/null +++ b/toolkit/components/satchel/test/mochitest.ini @@ -0,0 +1,30 @@ +[DEFAULT] +skip-if = toolkit == 'android' +support-files = + satchel_common.js + subtst_form_submission_1.html + subtst_privbrowsing.html + parent_utils.js + +[test_capture_limit.html] +[test_bug_511615.html] +[test_bug_787624.html] +[test_history_datalist_duplicates.html] +[test_datalist_dynamic.html] +[test_datalist_with_caching.html] +[test_datalist_readonly_change.html] +[test_datalist_shadow_dom.html] +[test_datalist_attribute_change.html] +[test_form_autocomplete.html] +skip-if = (verify && debug && (os == 'win')) || (os == 'mac') # Bug 1514249 +[test_form_autocomplete_validation_at_input_event.html] +[test_form_autocomplete_with_list.html] +skip-if = + http3 +[test_form_submission.html] +[test_input_valid_state_with_autocomplete.html] +[test_password_autocomplete.html] +scheme = https +[test_popup_direction.html] +[test_popup_enter_event.html] +[test_submit_on_keydown_enter.html] diff --git a/toolkit/components/satchel/test/parent_utils.js b/toolkit/components/satchel/test/parent_utils.js new file mode 100644 index 0000000000..a46db9a63c --- /dev/null +++ b/toolkit/components/satchel/test/parent_utils.js @@ -0,0 +1,194 @@ +/* eslint-env mozilla/chrome-script */ + +const { FormHistory } = ChromeUtils.importESModule( + "resource://gre/modules/FormHistory.sys.mjs" +); +const { ContentTaskUtils } = ChromeUtils.importESModule( + "resource://testing-common/ContentTaskUtils.sys.mjs" +); +const { TestUtils } = ChromeUtils.importESModule( + "resource://testing-common/TestUtils.sys.mjs" +); + +var gAutocompletePopup = + Services.ww.activeWindow.document.getElementById("PopupAutoComplete"); +assert.ok(gAutocompletePopup, "Got autocomplete popup"); + +var ParentUtils = { + getMenuEntries() { + let entries = []; + let numRows = gAutocompletePopup.view.matchCount; + for (let i = 0; i < numRows; i++) { + entries.push(gAutocompletePopup.view.getValueAt(i)); + } + return entries; + }, + + cleanUpFormHistory() { + return FormHistory.update({ op: "remove" }); + }, + + updateFormHistory(changes) { + FormHistory.update(changes).then( + () => { + sendAsyncMessage("formHistoryUpdated", { ok: true }); + }, + error => { + sendAsyncMessage("formHistoryUpdated", { ok: false }); + assert.ok(false, error); + } + ); + }, + + popupshownListener() { + let results = this.getMenuEntries(); + sendAsyncMessage("onpopupshown", { results }); + }, + + countEntries(name, value) { + let obj = {}; + if (name) { + obj.fieldname = name; + } + if (value) { + obj.value = value; + } + + FormHistory.count(obj).then( + count => { + sendAsyncMessage("entriesCounted", { ok: true, count }); + }, + error => { + assert.ok(false, error); + sendAsyncMessage("entriesCounted", { ok: false }); + } + ); + }, + + async checkRowCount(expectedCount, expectedFirstValue = null) { + await ContentTaskUtils.waitForCondition(() => { + // This may be called before gAutocompletePopup has initialised + // which causes it to throw + try { + return ( + gAutocompletePopup.view.matchCount === expectedCount && + (!expectedFirstValue || + expectedCount <= 1 || + gAutocompletePopup.view.getValueAt(0) === expectedFirstValue) + ); + } catch (e) { + return false; + } + }, `Waiting for row count change to ${expectedCount}, first value: ${expectedFirstValue}.`); + return this.getMenuEntries(); + }, + + async checkSelectedIndex(expectedIndex) { + await ContentTaskUtils.waitForCondition( + () => + gAutocompletePopup.popupOpen && + gAutocompletePopup.selectedIndex === expectedIndex, + "Checking selected index" + ); + }, + + // Tests using this function need to flip pref for exceptional use of + // `new Function` / `eval()`. + // See test_autofill_and_ordinal_forms.html for example. + testMenuEntry(index, statement) { + ContentTaskUtils.waitForCondition(() => { + let el = gAutocompletePopup.richlistbox.getItemAtIndex(index); + let testFunc = new Services.ww.activeWindow.Function( + "el", + `return ${statement}` + ); + return gAutocompletePopup.popupOpen && el && testFunc(el); + }, "Testing menu entry").then(() => { + sendAsyncMessage("menuEntryTested"); + }); + }, + + getPopupState() { + function reply() { + sendAsyncMessage("gotPopupState", { + open: gAutocompletePopup.popupOpen, + selectedIndex: gAutocompletePopup.selectedIndex, + direction: gAutocompletePopup.style.direction, + }); + } + // If the popup state is stable, we can reply immediately. However, if + // it's showing or hiding, we should wait its finish and then, send the + // reply. + if ( + gAutocompletePopup.state == "open" || + gAutocompletePopup.state == "closed" + ) { + reply(); + return; + } + const stablerState = + gAutocompletePopup.state == "showing" ? "open" : "closed"; + TestUtils.waitForCondition( + () => gAutocompletePopup.state == stablerState, + `Waiting for autocomplete popup getting "${stablerState}" state` + ).then(reply); + }, + + observe(_subject, topic, data) { + // This function can be called after SimpleTest.finish(). + // Do not write assertions here, they will lead to intermittent failures. + sendAsyncMessage("satchel-storage-changed", { subject: null, topic, data }); + }, + + async cleanup() { + gAutocompletePopup.removeEventListener( + "popupshown", + this._popupshownListener + ); + await this.cleanUpFormHistory(); + }, +}; + +ParentUtils._popupshownListener = + ParentUtils.popupshownListener.bind(ParentUtils); +gAutocompletePopup.addEventListener( + "popupshown", + ParentUtils._popupshownListener +); +ParentUtils.cleanUpFormHistory(); + +addMessageListener("updateFormHistory", msg => { + ParentUtils.updateFormHistory(msg.changes); +}); + +addMessageListener("countEntries", ({ name, value }) => { + ParentUtils.countEntries(name, value); +}); + +addMessageListener( + "waitForMenuChange", + ({ expectedCount, expectedFirstValue }) => + ParentUtils.checkRowCount(expectedCount, expectedFirstValue) +); + +addMessageListener("waitForSelectedIndex", ({ expectedIndex }) => + ParentUtils.checkSelectedIndex(expectedIndex) +); +addMessageListener("waitForMenuEntryTest", ({ index, statement }) => { + ParentUtils.testMenuEntry(index, statement); +}); + +addMessageListener("getPopupState", () => { + ParentUtils.getPopupState(); +}); + +addMessageListener("addObserver", () => { + Services.obs.addObserver(ParentUtils, "satchel-storage-changed"); +}); +addMessageListener("removeObserver", () => { + Services.obs.removeObserver(ParentUtils, "satchel-storage-changed"); +}); + +addMessageListener("cleanup", async () => { + await ParentUtils.cleanup(); +}); diff --git a/toolkit/components/satchel/test/satchel_common.js b/toolkit/components/satchel/test/satchel_common.js new file mode 100644 index 0000000000..2590cb4d15 --- /dev/null +++ b/toolkit/components/satchel/test/satchel_common.js @@ -0,0 +1,323 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/* eslint + "no-unused-vars": ["error", { + vars: "local", + args: "none", + }], +*/ + +var gPopupShownExpected = false; +var gPopupShownListener; +var gLastAutoCompleteResults; +var gChromeScript; + +const TelemetryFilterPropsAC = Object.freeze({ + category: "form_autocomplete", + method: "show", + object: "logins", +}); + +/* + * Returns the element with the specified |name| attribute. + */ +function getFormElementByName(formNum, name) { + const formElement = document.querySelector( + `#form${formNum} [name="${name}"]` + ); + + if (!formElement) { + ok(false, `getFormElementByName: Couldn't find specified CSS selector.`); + return null; + } + + return formElement; +} + +function registerPopupShownListener(listener) { + if (gPopupShownListener) { + ok(false, "got too many popupshownlisteners"); + return; + } + gPopupShownListener = listener; +} + +function getMenuEntries() { + if (!gLastAutoCompleteResults) { + throw new Error("no autocomplete results"); + } + + let results = gLastAutoCompleteResults; + gLastAutoCompleteResults = null; + return results; +} + +class StorageEventsObserver { + promisesToResolve = []; + + constructor() { + gChromeScript.sendAsyncMessage("addObserver"); + gChromeScript.addMessageListener( + "satchel-storage-changed", + this.observe.bind(this) + ); + } + + async cleanup() { + await gChromeScript.sendQuery("removeObserver"); + } + + observe({ subject, topic, data }) { + this.promisesToResolve.shift()?.({ subject, topic, data }); + } + + promiseNextStorageEvent() { + return new Promise(resolve => this.promisesToResolve.push(resolve)); + } +} + +function getFormSubmitButton(formNum) { + let form = $("form" + formNum); // by id, not name + ok(form != null, "getting form " + formNum); + + // we can't just call form.submit(), because that doesn't seem to + // invoke the form onsubmit handler. + let button = form.firstChild; + while (button && button.type != "submit") { + button = button.nextSibling; + } + ok(button != null, "getting form submit button"); + + return button; +} + +// Count the number of entries with the given name and value, and call then(number) +// when done. If name or value is null, then the value of that field does not matter. +function countEntries(name, value, then = null) { + return new Promise(resolve => { + gChromeScript.sendAsyncMessage("countEntries", { name, value }); + gChromeScript.addMessageListener("entriesCounted", function counted(data) { + gChromeScript.removeMessageListener("entriesCounted", counted); + if (!data.ok) { + ok(false, "Error occurred counting form history"); + SimpleTest.finish(); + return; + } + + if (then) { + then(data.count); + } + resolve(data.count); + }); + }); +} + +// Wrapper around FormHistory.update which handles errors. Calls then() when done. +function updateFormHistory(changes, then = null) { + return new Promise(resolve => { + gChromeScript.sendAsyncMessage("updateFormHistory", { changes }); + gChromeScript.addMessageListener( + "formHistoryUpdated", + function updated({ ok }) { + gChromeScript.removeMessageListener("formHistoryUpdated", updated); + if (!ok) { + ok(false, "Error occurred updating form history"); + SimpleTest.finish(); + return; + } + + if (then) { + then(); + } + resolve(); + } + ); + }); +} + +async function notifyMenuChanged(expectedCount, expectedFirstValue) { + gLastAutoCompleteResults = await gChromeScript.sendQuery( + "waitForMenuChange", + { expectedCount, expectedFirstValue } + ); + return gLastAutoCompleteResults; +} + +function notifySelectedIndex(expectedIndex) { + return gChromeScript.sendQuery("waitForSelectedIndex", { expectedIndex }); +} + +function testMenuEntry(index, statement) { + return new Promise(resolve => { + gChromeScript.sendAsyncMessage("waitForMenuEntryTest", { + index, + statement, + }); + gChromeScript.addMessageListener("menuEntryTested", function changed() { + gChromeScript.removeMessageListener("menuEntryTested", changed); + resolve(); + }); + }); +} + +function getPopupState(then = null) { + return new Promise(resolve => { + gChromeScript.sendAsyncMessage("getPopupState"); + gChromeScript.addMessageListener("gotPopupState", function listener(state) { + gChromeScript.removeMessageListener("gotPopupState", listener); + if (then) { + then(state); + } + resolve(state); + }); + }); +} + +function listenForUnexpectedPopupShown() { + gPopupShownListener = function onPopupShown() { + if (!gPopupShownExpected) { + ok(false, "Unexpected autocomplete popupshown event"); + } + }; +} + +async function popupBy(triggerFn) { + gPopupShownExpected = true; + const promise = new Promise(resolve => { + gPopupShownListener = ({ results }) => { + gPopupShownExpected = false; + resolve(results); + }; + }); + if (triggerFn) { + triggerFn(); + } + return promise; +} + +async function noPopupBy(triggerFn) { + gPopupShownExpected = false; + listenForUnexpectedPopupShown(); + SimpleTest.requestFlakyTimeout( + "Giving a chance for an unexpected popupshown to occur" + ); + if (triggerFn) { + await triggerFn(); + } + await new Promise(resolve => setTimeout(resolve, 500)); +} + +async function popupByArrowDown() { + return popupBy(() => { + synthesizeKey("KEY_Escape"); // in case popup is already open + synthesizeKey("KEY_ArrowDown"); + }); +} + +async function noPopupByArrowDown() { + await noPopupBy(() => { + synthesizeKey("KEY_Escape"); // in case popup is already open + synthesizeKey("KEY_ArrowDown"); + }); +} + +function checkACTelemetryEvent(actualEvent, input, augmentedExtra) { + ok( + parseInt(actualEvent[4], 10) > 0, + "elapsed time is a positive integer after converting from a string" + ); + let expectedExtra = { + acFieldName: SpecialPowers.wrap(input).getAutocompleteInfo().fieldName, + typeWasPassword: SpecialPowers.wrap(input).hasBeenTypePassword ? "1" : "0", + fieldType: input.type, + stringLength: input.value.length + "", + ...augmentedExtra, + }; + isDeeply(actualEvent[5], expectedExtra, "Check event extra object"); +} + +let gStorageEventsObserver; + +function promiseNextStorageEvent() { + return gStorageEventsObserver.promiseNextStorageEvent(); +} + +function satchelCommonSetup() { + let chromeURL = SimpleTest.getTestFileURL("parent_utils.js"); + gChromeScript = SpecialPowers.loadChromeScript(chromeURL); + gChromeScript.addMessageListener("onpopupshown", ({ results }) => { + gLastAutoCompleteResults = results; + if (gPopupShownListener) { + gPopupShownListener({ results }); + } + }); + + gStorageEventsObserver = new StorageEventsObserver(); + + SimpleTest.registerCleanupFunction(async () => { + await gStorageEventsObserver.cleanup(); + await gChromeScript.sendQuery("cleanup"); + gChromeScript.destroy(); + }); +} + +function add_named_task(name, fn) { + add_task( + { + [name]() { + return fn(); + }, + }[name] + ); +} + +function preventSubmitOnForms() { + for (const form of document.querySelectorAll("form")) { + form.onsubmit = e => e.preventDefault(); + } +} + +/** + * Press requested keys and assert input's value + * + * @param {HTMLInputElement} input + * @param {string | Array} keys + * @param {string} expectedValue + */ +function assertValueAfterKeys(input, keys, expectedValue) { + if (!Array.isArray(keys)) { + keys = [keys]; + } + for (const key of keys) { + synthesizeKey(key); + } + + is(input.value, expectedValue, "input value"); +} + +function assertAutocompleteItems(...expectedValues) { + const actualValues = getMenuEntries(); + isDeeply(actualValues, expectedValues, "expected autocomplete list"); +} + +function deleteSelectedAutocompleteItem() { + synthesizeKey("KEY_Delete", { shiftKey: true }); +} + +async function openPopupOn( + inputOrSelector, + { inputValue = "", expectPopup = true } = {} +) { + const input = + typeof inputOrSelector == "string" + ? document.querySelector(inputOrSelector) + : inputOrSelector; + input.value = inputValue; + input.focus(); + const items = await (expectPopup ? popupByArrowDown() : noPopupByArrowDown()); + return { input, items }; +} + +satchelCommonSetup(); diff --git a/toolkit/components/satchel/test/subtst_form_submission_1.html b/toolkit/components/satchel/test/subtst_form_submission_1.html new file mode 100644 index 0000000000..c1b902d6bc --- /dev/null +++ b/toolkit/components/satchel/test/subtst_form_submission_1.html @@ -0,0 +1,15 @@ +<!DOCTYPE HTML> +<html> + +<head> +</head> + +<body> + +<form> + <input id="subtest2" type="text" name="subtest2"> + <button type="submit">Submit</button> +</form> + +</body> +</html> diff --git a/toolkit/components/satchel/test/subtst_privbrowsing.html b/toolkit/components/satchel/test/subtst_privbrowsing.html new file mode 100644 index 0000000000..a61da1a714 --- /dev/null +++ b/toolkit/components/satchel/test/subtst_privbrowsing.html @@ -0,0 +1,23 @@ +<html> +<head> + <meta charset=UTF-8> + <title>Subtest for bug 472396</title> + <script> + /* exported submitForm */ + function submitForm() { + if (!location.search.includes("field")) { + let form = document.getElementById("form"); + let field = SpecialPowers.wrap(document.getElementById("field")); + field.setUserInput("value"); + form.submit(); + } + } + </script> +</head> +<body onload="submitForm();"> + <h2>Subtest for bug 472396</h2> + <form id="form"> + <input name="field" id="field"> + </form> +</body> +</html> diff --git a/toolkit/components/satchel/test/test_bug_511615.html b/toolkit/components/satchel/test/test_bug_511615.html new file mode 100644 index 0000000000..2d67c5468b --- /dev/null +++ b/toolkit/components/satchel/test/test_bug_511615.html @@ -0,0 +1,179 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>Test for Form History Autocomplete Untrusted Events: Bug 511615</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/EventUtils.js"></script> + <script type="text/javascript" src="satchel_common.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" /> +</head> +<body> +Test for Form History Autocomplete Untrusted Events: Bug 511615 +<p id="display"></p> + +<!-- we presumably can't hide the content for this test. --> +<div id="content"> + <!-- normal, basic form --> + <form id="form1" onsubmit="return false;"> + <input type="text" name="field1"> + <button type="submit">Submit</button> + </form> +</div> + +<script> +/** + * Indicates the time to wait before checking that the state of the autocomplete + * popup, including whether it is open, has not changed in response to events. + * + * Manual testing on a fast machine revealed that 80ms was still unreliable, + * while 100ms detected a simulated failure reliably. Unfortunately, this means + * that to take into account slower machines we should use a larger value. + * + * Note that if a machine takes more than this time to show the popup, this + * would not cause a failure, conversely the machine would not be able to detect + * whether the test should have failed. In other words, this use of timeouts is + * never expected to cause intermittent failures with test automation. + */ +const POPUP_RESPONSE_WAIT_TIME_MS = 200; + +/** + * Checks that the selected index in the popup still matches the given value. + * + * @param {number} expectedIndex + * @returns {Promise} + */ +async function checkSelectedIndexAfterResponseTime(expectedIndex) { + const popupState = await new Promise(resolve => { + setTimeout(() => getPopupState(resolve), POPUP_RESPONSE_WAIT_TIME_MS); + }); + is(popupState.open, true, "Popup should still be open."); + is(popupState.selectedIndex, expectedIndex, "Selected index should match."); +} + +const input = document.querySelector("#form1 > input"); + +function doKeyUnprivileged(key) { + const keyName = "DOM_VK_" + key.toUpperCase(); + let keycode, charcode, alwaysval; + + if (key.length == 1) { + [keycode, charcode, alwaysval] = [0, key.charCodeAt(0), charcode]; + } else { + [keycode, charcode, alwaysval] = [KeyEvent[keyName], 0, keycode]; + if (!keycode) { + throw new Error("invalid keyname in test"); + } + } + + const dnEvent = new KeyboardEvent("keydown", { + bubbles: true, + cancelable: true, + view: null, + keyCode: alwaysval, + charCode: 0, + }); + const prEvent = new KeyboardEvent("keypress", { + bubbles: true, + cancelable: true, + view: null, + keyCode: keycode, + charCode: charcode, + }); + const upEvent = new KeyboardEvent("keyup", { + bubbles: true, + cancelable: true, + view: null, + keyCode: alwaysval, + charCode: 0, + }); + + input.dispatchEvent(dnEvent); + input.dispatchEvent(prEvent); + input.dispatchEvent(upEvent); +} + +function doClickWithMouseEventUnprivileged() { + let dnEvent = document.createEvent("MouseEvent"); + let upEvent = document.createEvent("MouseEvent"); + let ckEvent = document.createEvent("MouseEvent"); + + /* eslint-disable no-multi-spaces, max-len */ + dnEvent.initMouseEvent("mousedown", true, true, window, 1, 0, 0, 0, 0, false, false, false, false, 0, null); + upEvent.initMouseEvent("mouseup", true, true, window, 1, 0, 0, 0, 0, false, false, false, false, 0, null); + ckEvent.initMouseEvent("mouseclick", true, true, window, 1, 0, 0, 0, 0, false, false, false, false, 0, null); + /* eslint-enable no-multi-spaces, max-len */ + + input.dispatchEvent(dnEvent); + input.dispatchEvent(upEvent); + input.dispatchEvent(ckEvent); +} + +add_setup(async () => { + await new Promise(resolve => updateFormHistory([ + { op: "remove" }, + { op: "add", fieldname: "field1", value: "value1" }, + { op: "add", fieldname: "field1", value: "value2" }, + { op: "add", fieldname: "field1", value: "value3" }, + { op: "add", fieldname: "field1", value: "value4" }, + { op: "add", fieldname: "field1", value: "value5" }, + { op: "add", fieldname: "field1", value: "value6" }, + { op: "add", fieldname: "field1", value: "value7" }, + { op: "add", fieldname: "field1", value: "value8" }, + { op: "add", fieldname: "field1", value: "value9" }, + ], resolve)); +}); + +add_task(async function test_untrusted_events_ignored() { + // The autocomplete popup should not open from untrusted events. + for (let triggerFn of [ + () => input.focus(), + () => input.click(), + () => doClickWithMouseEventUnprivileged(), + () => doKeyUnprivileged("down"), + () => doKeyUnprivileged("page_down"), + () => doKeyUnprivileged("return"), + () => doKeyUnprivileged("v"), + () => doKeyUnprivileged(" "), + () => doKeyUnprivileged("back_space"), + ]) { + // We must wait for the entire timeout for each individual test, because the + // next event in the list might prevent the popup from opening. + await noPopupBy(triggerFn); + } + + // A privileged key press will actually open the popup. + await openPopupOn(input); + + // The selected autocomplete item should not change from untrusted events. + for (let triggerFn of [ + () => doKeyUnprivileged("down"), + () => doKeyUnprivileged("page_down"), + ]) { + triggerFn(); + await checkSelectedIndexAfterResponseTime(-1); + } + + // A privileged key press will actually change the selected index. + const indexChanged = notifySelectedIndex(0); + synthesizeKey("KEY_ArrowDown"); + await indexChanged; + + // The selected autocomplete item should not change and it should not be + // possible to use it from untrusted events. + for (let triggerFn of [ + () => doKeyUnprivileged("down"), + () => doKeyUnprivileged("page_down"), + () => doKeyUnprivileged("right"), + () => doKeyUnprivileged(" "), + () => doKeyUnprivileged("back_space"), + () => doKeyUnprivileged("back_space"), + () => doKeyUnprivileged("return"), + ]) { + triggerFn(); + await checkSelectedIndexAfterResponseTime(0); + is(input.value, "", "The selected item should not have been used."); + } +}); +</script> +</body> +</html> diff --git a/toolkit/components/satchel/test/test_bug_787624.html b/toolkit/components/satchel/test/test_bug_787624.html new file mode 100644 index 0000000000..13af53a477 --- /dev/null +++ b/toolkit/components/satchel/test/test_bug_787624.html @@ -0,0 +1,73 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>Test for Layout of Form History Autocomplete: Bug 787624</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/EventUtils.js"></script> + <script type="text/javascript" src="satchel_common.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" /> + <style> + .container { + border: 1px solid #333; + width: 80px; + height: 26px; + position: absolute; + z-index: 2; + } + + .subcontainer { + width: 100%; + overflow: hidden; + } + + .subcontainer input { + width: 120px; + margin: 2px 6px; + padding-right: 4px; + border: none; + height: 22px; + z-index: 1; + outline: 1px dashed #555 + } + </style> +</head> +<body> +Form History Layout test: form field autocomplete: Bug 787624 +<p id="display"></p> + +<!-- we presumably can't hide the content for this test. --> +<div id="content"> + <!-- in this form, the input field is partially hidden and can scroll --> + <div class="container"> + <div class="subcontainer"> + <form id="form1" onsubmit="return false;"> + <input type="text" name="field1"> + <button type="submit">Submit</button> + </form> + </div> + </div> +</div> + +<script> +add_setup(async () => { + await updateFormHistory([ + { op: "remove" }, + { op: "add", fieldname: "field1", value: "value1" }, + { op: "add", fieldname: "field1", value: "value2" }, + ]); +}); + +add_task(async function test_popup_not_move_input() { + const input = document.querySelector("#form1 > input"); + const rect = input.getBoundingClientRect(); + await openPopupOn(input); + const newRect = input.getBoundingClientRect(); + isDeeply( + [newRect.left, newRect.top], + [rect.left, rect.top], + "popup does not disturb the input position"); +}); + +</script> +</body> +</html> diff --git a/toolkit/components/satchel/test/test_capture_limit.html b/toolkit/components/satchel/test/test_capture_limit.html new file mode 100644 index 0000000000..8591544016 --- /dev/null +++ b/toolkit/components/satchel/test/test_capture_limit.html @@ -0,0 +1,61 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>Form History capture no more than 100 changes</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script type="text/javascript" src="satchel_common.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" /> +</head> +<body> +<p id="display"></p> +<div id="content" style="display: none"> + <form id="form1"> + <button type="submit">Submit</button> + </form> +</div> +<script> + +add_setup(async () => { + const count = await countEntries(null, null); + ok(!count, "initial storage is empty"); +}); + +add_task(async function captureLimit() { + // Capture no more than 100 fields per submit. See FormHistoryChild.jsm. + const inputsCount = 100 + 2; + const form = document.getElementById("form1"); + for (let i = 1; i <= inputsCount; i++) { + const newField = document.createElement("input"); + newField.setAttribute("type", "text"); + newField.setAttribute("name", "test" + i); + form.appendChild(newField); + if( i != 50) { + SpecialPowers.wrap(newField).setUserInput(i); + } + } + + form.addEventListener("submit", e => e.preventDefault(), { once: true }); + const storageUpdated = promiseNextStorageEvent(); + getFormSubmitButton(1).click(); + await storageUpdated; + + for (let i = 1; i <= inputsCount; i++) { // check all but last + const historyEntries = await countEntries("test" + i, i); + + switch(i) { + case 50: + is(historyEntries, 0, `no history saved for input ${i} because user didn't modify it`); + break; + case 102: + is(historyEntries, 0, `no history saved for input ${i} because form already captured 100 fields`); + break; + default: + is(historyEntries, 1, `history saved for input ${i}`); + break; + } + } +}); + +</script> +</body> +</html> diff --git a/toolkit/components/satchel/test/test_datalist_attribute_change.html b/toolkit/components/satchel/test/test_datalist_attribute_change.html new file mode 100644 index 0000000000..f9c774f8d0 --- /dev/null +++ b/toolkit/components/satchel/test/test_datalist_attribute_change.html @@ -0,0 +1,53 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>Test for Form History / Attribute change with datalist entries: Bug 1767250</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/EventUtils.js"></script> + <script type="text/javascript" src="satchel_common.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" /> +</head> +<body> +<div id="content"> + + <form> + <input list="suggest" type="button" name="input" id="input" /> + <datalist id="suggest"> + <option value="Mozilla"> + <option value="Firefox"> + <option value="Thunderbird"> + </datalist> + </form> + +</div> + +<script> + +add_task(async function test_dropdown_shown_when_type_attribute_changed() { + const input = document.getElementById("input"); + input.addEventListener("click", () => input.setAttribute("type", "text")); + + is(input.type, "button", "Input type is initially button."); + + synthesizeMouseAtCenter(input, { button: input, type: "mousedown" }, window); + synthesizeMouseAtCenter(input, { button: input, type: "mouseup" }, window); + + await SimpleTest.promiseWaitForCondition(() => input.type === "text", "Input type changed to text."); + + is(document.activeElement, input, "Text input is focused."); + // In the course of fixing Bug 1767250, we discovered that the focus ring was not shown although the element was focused. + // We decided to refer fixing this to a later bug, This is tracked in Bug 1788698. + // ok(input.matches(":focus-visible"), "Outer focus ring is shown."); + + await openPopupOn(input); + + isDeeply( + getMenuEntries(), + ["Mozilla", "Firefox", "Thunderbird"], + "Datalist shown after changing input type from button to text."); + input.removeEventListener("click", () => input.setAttribute("type", "text")); +}); + +</script> +</body> +</html> diff --git a/toolkit/components/satchel/test/test_datalist_dynamic.html b/toolkit/components/satchel/test/test_datalist_dynamic.html new file mode 100644 index 0000000000..95fc3eb589 --- /dev/null +++ b/toolkit/components/satchel/test/test_datalist_dynamic.html @@ -0,0 +1,80 @@ +<!DOCTYPE HTML> +<html> +<title>Dynamic change datalist</title> +<script src="/tests/SimpleTest/SimpleTest.js"></script> +<script src="/tests/SimpleTest/EventUtils.js"></script> +<script src="satchel_common.js"></script> +<link rel="stylesheet" href="/tests/SimpleTest/test.css" /> +<input list="suggest" type="text" name="field1"> +<datalist id="suggest"> + <option value="a1"> + <option value="a2"> + <option value="ab1"> + <option value="ab2"> +</datalist> +<script> +const { TestUtils } = SpecialPowers.Cu.import("resource://testing-common/TestUtils.jsm"); + +const DATALIST_DATA = { + "a": ["a1", "a2", "ab1", "ab2"], + "ab": ["ab1", "ab2", "abc1", "abc2"], + "abc": ["abc1", "abc2", "abcd1", "abcd2"], + "abcd": ["abcd1", "abcd2", "abcde1", "abcde2", "abcde3"] +}; + +add_task(async function() { + const input = document.querySelector("input"); + const datalist = document.querySelector("datalist"); + + async function inputHandler() { + const options = DATALIST_DATA[input.value] || []; + + await TestUtils.waitForTick(); + + while (datalist.firstChild) { + datalist.firstChild.remove(); + } + + for (const option of options) { + const element = document.createElement("option"); + element.setAttribute("value", option); + datalist.appendChild(element); + } + } + + await SimpleTest.promiseFocus(); + + input.addEventListener("input", inputHandler); + + input.focus(); + synthesizeKey("a"); + synthesizeKey("b"); + synthesizeKey("c"); + is(input.value, "abc", "<input>'s value has to be abc for initial data"); + await notifyMenuChanged(4); + let values = getMenuEntries(); + is(values.length, 4, "expected count of datalist popup"); + for (let i = 0; i < values.length; i++) { + is(values[i], DATALIST_DATA[input.value][i], "expected data #" + i); + } + + let promise = notifyMenuChanged(5); + synthesizeKey("d"); + is(input.value, "abcd", "<input>'s value has to be abcd for next test"); + synthesizeKey("KEY_ArrowDown"); + await promise; + + values = getMenuEntries(); + is(values.length, 5, "expected count of datalist popup"); + for (let i = 0; i < values.length; i++) { + is(values[i], DATALIST_DATA[input.value][i], "expected data #" + i); + } + + synthesizeKey("KEY_ArrowDown"); + synthesizeKey("KEY_Enter"); + is(input.value, "abcd1", "<input>'s value has to set abcd1"); + + input.removeEventListener("input", inputHandler); +}); +</script> +</html> diff --git a/toolkit/components/satchel/test/test_datalist_readonly_change.html b/toolkit/components/satchel/test/test_datalist_readonly_change.html new file mode 100644 index 0000000000..986ef0cb7e --- /dev/null +++ b/toolkit/components/satchel/test/test_datalist_readonly_change.html @@ -0,0 +1,41 @@ +<!DOCTYPE HTML> +<html> + +<head> + <title>Dynamic change to readonly doesn't prevent datalist to keep working</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/EventUtils.js"></script> + <script src="satchel_common.js"></script> + <link rel="stylesheet" href="/tests/SimpleTest/test.css" /> +</head> + +<body> + <input readonly list="suggest" type="text" name="field1"> + <datalist id="suggest"> + <option value="First"></option> + <option value="Second"></option> + <option value="Secomundo"></option> + </datalist> +<script> + +add_task(async function readonly() { + const { input } = await openPopupOn("input", { expectPopup: false }); + ok(input.readOnly, "Input should be readonly"); + + input.removeAttribute("readonly"); + await new Promise(resolve => SimpleTest.executeSoon(resolve)); + // AttributeChanged takes control of the input again off a runnable... + ok(!input.readOnly, "Input should not be readonly"); + is(document.activeElement, input, "Should still be focused"); + await openPopupOn(input); + assertAutocompleteItems("First", "Second", "Secomundo"); + assertValueAfterKeys( + input, + ["KEY_ArrowDown", "KEY_Enter"], + "First"); +}); + +</script> +</body> + +</html> diff --git a/toolkit/components/satchel/test/test_datalist_shadow_dom.html b/toolkit/components/satchel/test/test_datalist_shadow_dom.html new file mode 100644 index 0000000000..e2baeccb34 --- /dev/null +++ b/toolkit/components/satchel/test/test_datalist_shadow_dom.html @@ -0,0 +1,55 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>Test for datalist in Shadow DOM</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/EventUtils.js"></script> + <script type="text/javascript" src="satchel_common.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" /> +</head> +<body> +<p id="display"></p> +<div id="content"> + <div id="host"></div> +</div> + +<script> + +const host = document.getElementById("host"); +host.attachShadow({ mode: "open" }).innerHTML = ` + <form id="form1" onsubmit="return false;"> + <input list="suggest" type="text" name="field1"> + <button type="submit">Submit</button> + </form> + <datalist id="suggest"> + <option value="First"></option> + <option value="Second"></option> + <option value="Secomundo"></option> + </datalist> +`; + +add_task(async function fill_in_shadow() { + const { input } = await openPopupOn(host.shadowRoot.querySelector("input")); + assertAutocompleteItems("First", "Second", "Secomundo"); + assertValueAfterKeys( + input, + ["KEY_ArrowDown", "KEY_Enter"], + "First"); +}); + +add_task(async function filter_and_fill_in_shadow() { + const { input } = await openPopupOn(host.shadowRoot.querySelector("input")); + sendString("Sec"); + await notifyMenuChanged(2); + assertAutocompleteItems("Second", "Secomundo"); + sendString("o"); + await notifyMenuChanged(2); + assertValueAfterKeys( + input, + ["KEY_ArrowDown", "KEY_Enter"], + "Second"); +}); + +</script> +</body> +</html> diff --git a/toolkit/components/satchel/test/test_datalist_with_caching.html b/toolkit/components/satchel/test/test_datalist_with_caching.html new file mode 100644 index 0000000000..9366d69ff6 --- /dev/null +++ b/toolkit/components/satchel/test/test_datalist_with_caching.html @@ -0,0 +1,64 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>Test for Form History Autocomplete</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/EventUtils.js"></script> + <script type="text/javascript" src="satchel_common.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" /> +</head> +<body> +Form History test: form field autocomplete +<p id="display"></p> + +<!-- we presumably can't hide the content for this test. --> +<div id="content"> + + <!-- normal, basic form --> + <form id="form1" onsubmit="return false;"> + <input list="suggest" type="text" name="field1"> + <button type="submit">Submit</button> + </form> + + <datalist id="suggest"> + <option value="First"></option> + <option value="Second"></option> + <option value="Secomundo"></option> + </datalist> +</div> + +<script> +add_setup(async () => { + await updateFormHistory([ + { op: "remove" }, + { op: "add", fieldname: "field1", value: "Sec" }, + ]); +}); + +add_task(async function datalist_with_caching() { + const { input } = await openPopupOn("#form1 > input"); + assertAutocompleteItems("Sec", "First", "Second", "Secomundo"); + assertValueAfterKeys( + input, + ["KEY_ArrowDown", "KEY_Enter"], + "Sec"); +}); + +add_task(async function filtering_datalist_with_caching() { + const { input } = await openPopupOn("#form1 > input"); + sendString("Sec"); + await notifyMenuChanged(3); + assertAutocompleteItems("Sec", "Second", "Secomundo"); + + sendString("o"); + await notifyMenuChanged(2); + assertAutocompleteItems("Second", "Secomundo"); + assertValueAfterKeys( + input, + ["KEY_ArrowDown", "KEY_Enter"], + "Second"); +}); + +</script> +</body> +</html> diff --git a/toolkit/components/satchel/test/test_form_autocomplete.html b/toolkit/components/satchel/test/test_form_autocomplete.html new file mode 100644 index 0000000000..d18ac03647 --- /dev/null +++ b/toolkit/components/satchel/test/test_form_autocomplete.html @@ -0,0 +1,695 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>Test for Form History Autocomplete</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/EventUtils.js"></script> + <script type="text/javascript" src="satchel_common.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" /> +</head> +<body> +Form History test: form field autocomplete +<p id="display"></p> + +<!-- We presumably can't hide the content for this test. The large top padding is to allow + listening for scrolls to occur. --> +<div id="content" style="padding-top: 20000px;"> + + <!-- normal, basic form --> + <form id="form1"> + <input type="text" name="field1"> + <button type="submit">Submit</button> + </form> + + <!-- normal, basic form (new fieldname) --> + <form id="form2"> + <input type="text" name="field2"> + <button type="submit">Submit</button> + </form> + + <!-- form with autocomplete=off on input --> + <form id="form3"> + <input type="text" name="field2" autocomplete="off"> + <button type="submit">Submit</button> + </form> + + <!-- form with autocomplete=off on form --> + <form id="form4" autocomplete="off"> + <input type="text" name="field2"> + <button type="submit">Submit</button> + </form> + + <!-- normal form for testing filtering --> + <form id="form5"> + <input type="text" name="field3"> + <button type="submit">Submit</button> + </form> + + <!-- normal form for testing word boundary filtering --> + <form id="form6"> + <input type="text" name="field4"> + <button type="submit">Submit</button> + </form> + + <!-- form with maxlength attribute on input --> + <form id="form7"> + <input type="text" name="field5" maxlength="10"> + <button type="submit">Submit</button> + </form> + + <!-- form with input type='email' --> + <form id="form8"> + <input type="email" name="field6"> + <button type="submit">Submit</button> + </form> + + <!-- form with input type='tel' --> + <form id="form9"> + <input type="tel" name="field7"> + <button type="submit">Submit</button> + </form> + + <!-- form with input type='url' --> + <form id="form10"> + <input type="url" name="field8"> + <button type="submit">Submit</button> + </form> + + <!-- form with input type='search' --> + <form id="form11"> + <input type="search" name="field9"> + <button type="submit">Submit</button> + </form> + + <!-- form with input type='number' --> + <form id="form12"> + <input type="text" name="field10"> <!-- TODO: change back to type=number --> + <button type="submit">Submit</button> + </form> + + <!-- normal, basic form (with fieldname='searchbar-history') --> + <form id="form13"> + <input type="text" name="searchbar-history"> + <button type="submit">Submit</button> + </form> + + <!-- form with input type='date' --> + <form id="form14"> + <input type="date" name="field11"> + <button type="submit">Submit</button> + </form> + + <!-- form with input type='time' --> + <form id="form15"> + <input type="time" name="field12"> + <button type="submit">Submit</button> + </form> + + <!-- form with input type='range' --> + <form id="form16"> + <input type="range" name="field13" max="64"> + <button type="submit">Submit</button> + </form> + + <!-- form with input type='color' --> + <form id="form17"> + <input type="color" name="field14"> + <button type="submit">Submit</button> + </form> + + <!-- form with input type='month' --> + <form id="form18"> + <input type="month" name="field15"> + <button type="submit">Submit</button> + </form> + + <!-- form with input type='week' --> + <form id="form19"> + <input type="week" name="field16"> + <button type="submit">Submit</button> + </form> + + <!-- form with input type='datetime-local' --> + <form id="form20"> + <input type="datetime-local" name="field17"> + <button type="submit">Submit</button> + </form> + +</div> + +<script> +async function addEntry(fieldname, value) { + await updateFormHistory({ op: "add", fieldname, value }); +} + +preventSubmitOnForms(); +SpecialPowers.pushPrefEnv({"set": [["security.allow_eval_with_system_principal", true]]}); + +add_setup(async () => { + await updateFormHistory([ + { op: "remove" }, + { op: "add", fieldname: "field1", value: "value1" }, + { op: "add", fieldname: "field1", value: "value2" }, + { op: "add", fieldname: "field1", value: "value3" }, + { op: "add", fieldname: "field1", value: "value4" }, + { op: "add", fieldname: "field2", value: "value1" }, + { op: "add", fieldname: "field3", value: "a" }, + { op: "add", fieldname: "field3", value: "aa" }, + { op: "add", fieldname: "field3", value: "aaz" }, + { op: "add", fieldname: "field3", value: "aa\xe6" }, // 0xae == latin ae pair (0xc6 == AE) + { op: "add", fieldname: "field3", value: "az" }, + { op: "add", fieldname: "field3", value: "z" }, + { op: "add", fieldname: "field4", value: "a\xe6" }, + { op: "add", fieldname: "field4", value: "aa a\xe6" }, + { op: "add", fieldname: "field4", value: "aba\xe6" }, + { op: "add", fieldname: "field4", value: "bc d\xe6" }, + { op: "add", fieldname: "field5", value: "1" }, + { op: "add", fieldname: "field5", value: "12" }, + { op: "add", fieldname: "field5", value: "123" }, + { op: "add", fieldname: "field5", value: "1234" }, + { op: "add", fieldname: "field6", value: "value" }, + { op: "add", fieldname: "field7", value: "value" }, + { op: "add", fieldname: "field8", value: "value" }, + { op: "add", fieldname: "field9", value: "value" }, + { op: "add", fieldname: "field10", value: "42" }, + // not used, since type=date doesn't have autocomplete currently + { op: "add", fieldname: "field11", value: "2010-10-10" }, + // not used, since type=time doesn't have autocomplete currently + { op: "add", fieldname: "field12", value: "21:21" }, + // not used, since type=range doesn't have a dropdown menu + { op: "add", fieldname: "field13", value: "32" }, + // not used, since type=color doesn't have autocomplete currently + { op: "add", fieldname: "field14", value: "#ffffff" }, + { op: "add", fieldname: "field15", value: "2016-08" }, + { op: "add", fieldname: "field16", value: "2016-W32" }, + { op: "add", fieldname: "field17", value: "2016-10-21T10:10" }, + { op: "add", fieldname: "searchbar-history", value: "blacklist test" }, + ]); +}); + +add_task(async function use_1st_item() { + const { input } = await openPopupOn("#form1 > input"); + + assertAutocompleteItems("value1", "value2", "value3", "value4"); + assertValueAfterKeys(input, "KEY_ArrowDown", ""); + assertValueAfterKeys(input, "KEY_Enter", "value1"); +}); + +add_task(async function use_2nd_item() { + const { input } = await openPopupOn("#form1 > input"); + assertValueAfterKeys( + input, + ["KEY_ArrowDown", "KEY_ArrowDown", "KEY_Enter"], + "value2"); +}); + +add_task(async function use_3rd_item() { + const { input } = await openPopupOn("#form1 > input"); + assertValueAfterKeys( + input, + ["KEY_ArrowDown", "KEY_ArrowDown", "KEY_ArrowDown", "KEY_Enter"], + "value3"); +}); + +add_task(async function use_4th_item() { + const { input } = await openPopupOn("#form1 > input"); + assertValueAfterKeys( + input, + ["KEY_ArrowDown", "KEY_ArrowDown", "KEY_ArrowDown", "KEY_ArrowDown", "KEY_Enter"], + "value4"); +}); + +add_task(async function use_1st_item_wrap_around() { + const { input } = await openPopupOn("#form1 > input"); + assertValueAfterKeys( + input, + ["KEY_ArrowDown", "KEY_ArrowDown", "KEY_ArrowDown", "KEY_ArrowDown", + "KEY_ArrowDown", "KEY_ArrowDown", "KEY_Enter"], + "value1"); +}); + +add_task(async function use_last_item_via_arrow_up() { + const { input } = await openPopupOn("#form1 > input"); + assertValueAfterKeys( + input, + ["KEY_ArrowUp", "KEY_Enter"], + "value4"); +}); + +add_task(async function use_last_item_via_arrow_up_from_selected_1st() { + const { input } = await openPopupOn("#form1 > input"); + assertValueAfterKeys( + input, + ["KEY_ArrowDown", "KEY_ArrowUp", "KEY_ArrowUp", "KEY_Enter"], + "value4"); +}); + +add_task(async function test9() { + const { input } = await openPopupOn("#form1 > input"); + assertValueAfterKeys( + input, + ["KEY_ArrowDown", "KEY_ArrowUp", "KEY_ArrowUp", "KEY_ArrowUp", "KEY_ArrowUp", + "KEY_ArrowUp", "KEY_ArrowUp", "KEY_ArrowUp", "KEY_Enter"], + "value4"); +}); + +add_task(async function select_1st_item_without_autocomplete() { + const { input } = await openPopupOn("#form1 > input"); + assertValueAfterKeys( + input, + ["KEY_ArrowDown", "KEY_ArrowRight"], + "value1"); +}); + +add_task(async function set_first_item_without_autocomplete() { + const { input } = await openPopupOn("#form1 > input"); + assertValueAfterKeys( + input, + ["KEY_ArrowDown", "KEY_ArrowLeft"], + "value1"); +}); + +add_task(async function use_1st_item_with_page_up() { + const { input } = await openPopupOn("#form1 > input"); + assertValueAfterKeys( + input, + ["KEY_ArrowDown", "KEY_ArrowDown", "KEY_PageUp", "KEY_Enter"], + "value1"); +}); + +add_task(async function use_last_item_with_page_down() { + const { input } = await openPopupOn("#form1 > input"); + assertValueAfterKeys( + input, + ["KEY_ArrowDown", "KEY_PageDown", "KEY_Enter"], + "value4"); +}); + +add_task(async function delete_1st_item() { + assertAutocompleteItems("value1", "value2", "value3", "value4"); + + const { input } = await openPopupOn("#form1 > input", { inputValue: "value" }); + synthesizeKey("KEY_ArrowDown"); + + // Tests that on OS X shift-backspace didn't delete the last character + // in the input (bug 480262). + // On OS X, shift-backspace and shift-delete work, just delete does not. + // On Win/Linux, shift-backspace does not work, delete and shift-delete do. + synthesizeKey(SpecialPowers.OS == "Darwin" ? "KEY_Backspace" : "KEY_Delete", { shiftKey: true }); + assertValueAfterKeys(input, [], "value"); + await notifyMenuChanged(3); + is(await countEntries("field1", "value1"), 0, "field1:value1 item deleted"); + assertValueAfterKeys(input, ["KEY_Enter"], "value2"); + assertAutocompleteItems("value2", "value3", "value4"); +}); + +add_task(async function use_1st_item_of_3() { + const { input } = await openPopupOn("#form1 > input"); + assertValueAfterKeys( + input, + ["KEY_ArrowDown", "KEY_Enter"], + "value2"); +}); + +add_task(async function delete_2nd_item() { + const { input } = await openPopupOn("#form1 > input"); + synthesizeKey("KEY_ArrowDown"); + synthesizeKey("KEY_ArrowDown"); + deleteSelectedAutocompleteItem(); + await notifyMenuChanged(2); + assertValueAfterKeys(input, [], ""); + is(await countEntries("field1", "value3"), 0, "field1:value3 item deleted"); + assertValueAfterKeys(input, ["KEY_Enter"], "value4"); + assertAutocompleteItems("value2", "value4"); +}); + +add_task(async function use_1st_item_of_2() { + const { input } = await openPopupOn("#form1 > input"); + assertValueAfterKeys( + input, + ["KEY_ArrowDown", "KEY_Enter"], + "value2"); +}); + +add_task(async function delete_last_item_of_2() { + const { input } = await openPopupOn("#form1 > input"); + synthesizeKey("KEY_ArrowDown"); + synthesizeKey("KEY_ArrowDown"); + deleteSelectedAutocompleteItem(); + await notifyMenuChanged(1); + assertValueAfterKeys(input, [], ""); + is(await countEntries("field1", "value4"), 0, "field1/value4 item deleted"); + assertAutocompleteItems("value2"); + assertValueAfterKeys(input, "KEY_Enter", "value2"); +}); + +add_task(async function use_1st_item_of_1() { + const { input } = await openPopupOn("#form1 > input"); + assertValueAfterKeys( + input, + ["KEY_ArrowDown", "KEY_Enter"], + "value2"); +}); + +add_task(async function delete_only_item() { + const { input } = await openPopupOn("#form1 > input"); + synthesizeKey("KEY_ArrowDown"); + deleteSelectedAutocompleteItem(); + await notifyMenuChanged(0); + is(await countEntries("field1", "value2"), 0, "field1/value2 item deleted"); + assertValueAfterKeys(input, [], ""); +}); + +add_task(async function form2_fills() { + const { input } = await openPopupOn("#form2 > input"); + assertValueAfterKeys( + input, + ["KEY_ArrowDown", "KEY_Enter"], + "value1"); +}); + +add_task(async function form3_autocomplete_off() { + // Look at form 3, try to trigger autocomplete popup + // Sometimes, this will fail if scrollTo(0, 0) is called, so that doesn't + // happen here. Fortunately, a different input is used from the last test, + // so a scroll should still occur. + const scroll = new Promise(resolve => { + addEventListener("scroll", function listener() { + if (!window.pageYOffset) { + return; + } + + removeEventListener("scroll", listener, false); + resolve(); + }, false); + }); + const { input } = await openPopupOn("#form3 > input", { expectPopup: false }); + await scroll; + // Ensure there's no autocomplete dropdown (autocomplete=off is present) + assertValueAfterKeys( + input, + ["KEY_ArrowDown", "KEY_Enter"], + ""); +}); + +add_task(async function form4_autocomplete_off() { + const { input } = await openPopupOn("#form4 > input", { expectPopup: false }); + await notifyMenuChanged(0); + + // Ensure there's no autocomplete dropdown (autocomplete=off is present) + assertValueAfterKeys( + input, + ["KEY_ArrowDown", "KEY_Enter"], + ""); +}); + +add_task(async function filtering_form5() { + const { input } = await openPopupOn("#form5 > input"); + sendChar("a"); + await notifyMenuChanged(5); + assertAutocompleteItems("a", "aa", "aaz", "aa\xe6", "az"); + sendChar("a"); + await notifyMenuChanged(3); + assertAutocompleteItems("aa", "aaz", "aa\xe6"); + sendChar("\xc6"); + await notifyMenuChanged(1); + assertAutocompleteItems("aa\xe6"); + synthesizeKey("KEY_Backspace"); + await notifyMenuChanged(3); + assertAutocompleteItems("aa", "aaz", "aa\xe6"); + synthesizeKey("KEY_Backspace"); + await notifyMenuChanged(5); + assertAutocompleteItems("a", "aa", "aaz", "aa\xe6", "az"); + input.focus(); + sendChar("z"); + await notifyMenuChanged(2); + assertAutocompleteItems("az", "aaz"); + synthesizeKey("KEY_ArrowLeft"); + // Check case-insensitivity. + sendChar("A"); + await notifyMenuChanged(1); + assertAutocompleteItems("aaz"); + await addEntry("field3", "aazq"); + // check that results were cached + input.focus(); + synthesizeKey("KEY_ArrowRight"); + sendChar("q"); + await notifyMenuChanged(0); + // check that results were cached + assertAutocompleteItems(); + await addEntry("field3", "aazqq"); + input.focus(); + window.scrollTo(0, 0); + sendChar("q"); + await notifyMenuChanged(0); + // check that empty results were cached - bug 496466 + assertAutocompleteItems(); + synthesizeKey("KEY_Escape"); +}); + +add_task(async function filtering_form6_part1() { + await openPopupOn("#form6 > input"); + sendChar("a"); + await notifyMenuChanged(3); + + // Test substring matches and word boundary bonuses + // alphabetical results for first character + assertAutocompleteItems("aa a\xe6", "aba\xe6", "a\xe6"); + sendChar("\xe6"); + await notifyMenuChanged(3, "a\xe6"); + + // prefix match comes first, then word boundary match + // followed by substring match + assertAutocompleteItems("a\xe6", "aa a\xe6", "aba\xe6"); +}); + +add_task(async function filtering_form6_part2() { + await openPopupOn("#form6 > input"); + sendChar("b"); + await notifyMenuChanged(1, "bc d\xe6"); + assertAutocompleteItems("bc d\xe6"); + sendChar(" "); + await notifyMenuChanged(1); + + // check that trailing space has no effect after single char. + assertAutocompleteItems("bc d\xe6"); + sendChar("\xc6"); + await notifyMenuChanged(2); + + // check multi-word substring matches + assertAutocompleteItems("bc d\xe6", "aba\xe6"); + synthesizeKey("KEY_ArrowLeft"); + sendChar("d"); + await notifyMenuChanged(1); + + // check inserting in multi-word searches + assertAutocompleteItems("bc d\xe6"); + sendChar("z"); + await notifyMenuChanged(0); + assertAutocompleteItems(); +}); + +add_task(async function input_maxLength() { + let { input } = await openPopupOn("#form7 > input"); + await notifyMenuChanged(4); + assertAutocompleteItems("1", "12", "123", "1234"); + + input.maxLength = 4; + input = (await openPopupOn("#form7 > input")).input; + await notifyMenuChanged(4); + assertAutocompleteItems("1", "12", "123", "1234"); + + input.maxLength = 3; + input = (await openPopupOn("#form7 > input")).input; + await notifyMenuChanged(3); + assertAutocompleteItems("1", "12", "123"); + + input.maxLength = 2; + input = (await openPopupOn("#form7 > input")).input; + await notifyMenuChanged(2); + assertAutocompleteItems("1", "12"); + + input.maxLength = 1; + input = (await openPopupOn("#form7 > input")).input; + await notifyMenuChanged(1); + assertAutocompleteItems("1"); + + input.maxLength = 0; + synthesizeKey("KEY_Escape"); + synthesizeKey("KEY_ArrowDown"); + await notifyMenuChanged(0); + assertAutocompleteItems(); + + input.maxLength = 4; +}); + +add_task(async function input_maxLength_with_character_typed() { + let { input } = await openPopupOn("#form7 > input"); + sendChar("1"); + await notifyMenuChanged(4); + assertAutocompleteItems("1", "12", "123", "1234"); + + input.maxLength = 3; + input = (await openPopupOn("#form7 > input")).input; + assertAutocompleteItems("1", "12", "123"); + + input.maxLength = 2; + input = (await openPopupOn("#form7 > input")).input; + assertAutocompleteItems("1", "12"); + + input.maxLength = 1; + input = (await openPopupOn("#form7 > input")).input; + assertAutocompleteItems("1"); + + input.maxLength = 0; + synthesizeKey("KEY_Escape"); + synthesizeKey("KEY_ArrowDown"); + await notifyMenuChanged(0); + assertAutocompleteItems(); +}); + +for (const formId of ["form8", "form9", "form10", "form11"]) { + add_named_task(formId, async () => { + const { input } = await openPopupOn(`#${formId} > input`); + assertAutocompleteItems("value"); + assertValueAfterKeys(input, ["KEY_ArrowDown", "KEY_Enter"], "value"); + }); +} + +add_task(async function form12() { + const { input } = await openPopupOn("#form12 > input"); + assertAutocompleteItems("42"); + assertValueAfterKeys( + input, + ["KEY_ArrowDown", "KEY_Enter"], + "42"); +}); + +add_task(async function form14() { + const { input } = await openPopupOn("#form14 > input", { expectPopup: false }); + await notifyMenuChanged(0); + // type=date with it's own control frame does not have a dropdown menu + assertAutocompleteItems(); + assertValueAfterKeys(input, [], ""); +}); + +add_task(async function form15() { + const { input } = await openPopupOn("#form15 > input", { expectPopup: false }); + await notifyMenuChanged(0); + // type=time with it's own control frame does not have a dropdown menu + assertAutocompleteItems(); + assertValueAfterKeys(input, [], ""); +}); + +add_task(async function form16() { + const { input } = await openPopupOn("#form16 > input", { expectPopup: false }); + await notifyMenuChanged(0); + // type=range does not have a dropdown menu + assertAutocompleteItems(); + // default (midway between minimum (0) and maximum (64)) - step + assertValueAfterKeys(input, [], "31"); +}); + +add_task(async function form17() { + const { input } = await openPopupOn("#form17 > input", { expectPopup: false }); + await notifyMenuChanged(0); + // type=color does not have a dropdown menu + assertAutocompleteItems(); + // default color value + assertValueAfterKeys(input, [], "#000000"); +}); + +add_task(async function form18() { + const { input } = await openPopupOn("#form18 > input"); + assertAutocompleteItems("2016-08"); + assertValueAfterKeys( + input, + ["KEY_ArrowDown", "KEY_Enter"], + "2016-08"); +}); + +add_task(async function form19() { + const { input } = await openPopupOn("#form19 > input"); + assertAutocompleteItems("2016-W32"); + assertValueAfterKeys( + input, + ["KEY_ArrowDown", "KEY_Enter"], + "2016-W32"); +}); + +add_task(async function form20() { + const { input } = await openPopupOn("#form20 > input", { expectPopup: false }); + await notifyMenuChanged(0); + // type=datetime-local with it's own control frame does not have a dropdown menu + assertAutocompleteItems(); + assertValueAfterKeys(input, [], ""); +}); + +add_task(async function input_event_fired() { + await addEntry("field1", "value1"); + const { input } = await openPopupOn("#form1 > input"); + + let beforeInputFired = false; + input.addEventListener("beforeinput", (e) => { + beforeInputFired = true; + ok(e instanceof InputEvent, "beforeinput event has InputEvent interface"); + ok(e.bubbles, "beforeinput event should bubble"); + is(e.cancelable, SpecialPowers.getBoolPref("dom.input_event.allow_to_cancel_set_user_input"), + "beforeinput event for insertReplacementText should be cancelable unless it's suppressed by the pref"); + is(e.inputType, "insertReplacementText", + "inputType of beforeinput event should be insertReplacementText"); + is(e.data, "value1", "data of beforeinput event should be value1"); + is(e.dataTransfer, null, "dataTransfer of beforeinput event should be null"); + is(e.getTargetRanges().length, 0, + "getTargetRanges() of beforeinput event should empty array"); + is(input.value, "", "input value should've not been modified yet at beforeinput event"); + }, { once: true }); + + let inputFired = false; + input.addEventListener("input", (e) => { + inputFired = true; + ok(e instanceof InputEvent, "input event has InputEvent interface"); + ok(e.bubbles, "input event should bubble"); + ok(!e.cancelable, "input event shouldn't be cancelable"); + is(e.inputType, "insertReplacementText", + "inputType of input event should be insertReplacementText"); + is(e.data, "value1","data of input event should be value1"); + is(e.dataTransfer, null, "dataTransfer of input event should be null"); + is(e.getTargetRanges().length, 0, + "getTargetRanges() of input event should empty array"); + is(input.value, "value1", "input value should've already been modified at input event"); + }, { once: true }); + + assertValueAfterKeys(input, "KEY_ArrowDown", ""); + assertValueAfterKeys(input, "KEY_Enter", "value1"); + ok(beforeInputFired, "beforeinput event should have been fired"); + ok(inputFired, "input event should have been fired"); +}); + +add_task(async function cancelling_beforeinput_cancels_autocompletion() { + const { input } = await openPopupOn("#form1 > input"); + await SpecialPowers.pushPrefEnv({ + set: [["dom.input_event.allow_to_cancel_set_user_input", true]], + }); + input.addEventListener("beforeinput", (e) => e.preventDefault(), { once: true }); + + let inputFired = false; + input.addEventListener("input", () => inputFired = true, { once: true }); + + assertValueAfterKeys(input, "KEY_ArrowDown", ""); + assertValueAfterKeys(input, "KEY_Enter", ""); + ok(!inputFired, "no input event when beforeinput is canceled"); + + await SpecialPowers.pushPrefEnv({ + clear: [["dom.input_event.allow_to_cancel_set_user_input"]], + }); +}); + +add_task(async function no_autocomplete_for_searchbar_history() { + await openPopupOn("#form13 > input", { expectPopup: false }); + await notifyMenuChanged(0); + assertAutocompleteItems(); +}); +</script> +</body> +</html> diff --git a/toolkit/components/satchel/test/test_form_autocomplete_validation_at_input_event.html b/toolkit/components/satchel/test/test_form_autocomplete_validation_at_input_event.html new file mode 100644 index 0000000000..7df6e97f27 --- /dev/null +++ b/toolkit/components/satchel/test/test_form_autocomplete_validation_at_input_event.html @@ -0,0 +1,83 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>Test for validation has been done before "input" event</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/EventUtils.js"></script> + <script type="text/javascript" src="satchel_common.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" /> + <style> + input:invalid { + background-color: red; + } + </style> +</head> +<body> +<p id="display"></p> + +<div id="content"> + <form> + <input type="text" name="field1" pattern="[0-9]{4}"> + </form> +</div> + +<script> + +add_setup(async () => { + await updateFormHistory([ + { op: "remove" }, + { op: "add", fieldname: "field1", value: "1234" }, + { op: "add", fieldname: "field1", value: "12345" }, + ]); +}); + +add_task(async function input_events() { + const { input } = await openPopupOn("input[name=field1]"); + synthesizeKey("KEY_Escape"); + + let inputFired = false; + info("Typing first character to open popup..."); + input.addEventListener("input", e => { + inputFired = true; + is(e.inputType, "insertText", "Typing '1' produces 'input' event with inputType='insertText'"); + is(input.validity.valid, false, "Typing '1' marks it as 'invalid'"); + is(input.matches(":invalid"), true, "Typing '1' causes matching with ':invalid' pseudo-class"); + }, { once: true, capture: true }); + synthesizeKey("1"); + ok(inputFired, "Typing '1' causes one 'input' event"); +}); + +add_task(async function input_events_for_valid_selection() { + const { input } = await openPopupOn("input[name=field1]"); + + let inputFired = false; + input.addEventListener("input", e => { + inputFired = true; + is(e.inputType, "insertReplacementText", "Selecting valid value causes an 'input' event with inputType='insertReplacementText'"); + is(input.validity.valid, true, "Selecting valid value marks it as 'valid'"); + is(input.matches(":invalid"), false, "Selecting valid value causes not matching with ':invalid' pseudo-class"); + }, { once: true, capture: true }); + synthesizeKey("KEY_ArrowDown"); + synthesizeKey("KEY_Enter"); + ok(inputFired, "Selecting valid item causes an 'input' event"); +}); + +add_task(async function input_events_for_invalid_selection() { + const { input } = await openPopupOn("input[name=field1]"); + + let inputFired = false; + input.addEventListener("input", e => { + inputFired = true; + is(e.inputType, "insertReplacementText", "Selecting invalid value causes an 'input' event with inputType='insertReplacementText'"); + is(input.validity.valid, false, "Selecting invalid value marks it as 'invalid'"); + is(input.matches(":invalid"), true, "Selecting invalid value causes matching with ':invalid' pseudo-class"); + }, { once: true, capture: true }); + synthesizeKey("KEY_ArrowDown"); + synthesizeKey("KEY_ArrowDown"); + synthesizeKey("KEY_Enter"); + ok(inputFired, "Selecting invalid item should cause an 'input' event"); +}); + +</script> +</body> +</html> diff --git a/toolkit/components/satchel/test/test_form_autocomplete_with_list.html b/toolkit/components/satchel/test/test_form_autocomplete_with_list.html new file mode 100644 index 0000000000..b903af5246 --- /dev/null +++ b/toolkit/components/satchel/test/test_form_autocomplete_with_list.html @@ -0,0 +1,402 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>Test for Form History Autocomplete</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/EventUtils.js"></script> + <script type="text/javascript" src="satchel_common.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" /> +</head> +<body> +Form History test: form field autocomplete +<p id="display"></p> + +<!-- we presumably can't hide the content for this test. --> +<div id="content"> + <datalist id="suggest"> + <option value="Google" label="PASS1">FAIL</option> + <option value="Reddit">PASS2</option> + <option value="final"></option> + </datalist> + + <!-- normal, basic form --> + <form id="form1"> + <input list="suggest" type="text" name="field1"> + <button type="submit">Submit</button> + </form> + + <!-- form with autocomplete=off on input --> + <form id="form3"> + <input list="suggest" type="text" name="field2" autocomplete="off"> + <button type="submit">Submit</button> + </form> + + <!-- form with autocomplete=off on form --> + <form id="form4" autocomplete="off"> + <input list="suggest" type="text" name="field2"> + <button type="submit">Submit</button> + </form> + + <!-- yet another normal, basic form, with 2 history entries --> + <form id="form5"> + <input list="suggest" type="text" name="field3"> + <button type="submit">Submit</button> + </form> + + <form id="show_datalist_for_text_inputs_only"> + <input list="suggest" type="button" popup="false" /> + <input list="suggest" type="checkbox" popup="false" /> + <input list="suggest" type="color" popup="false" /> + <input list="suggest" type="date" popup="false" /> + <input list="suggest" type="datetime-local" popup="false" /> + <input list="suggest" type="email" popup="true" /> + <input list="suggest" type="file" popup="false" /> + <input list="suggest" type="image" popup="false" /> + <input list="suggest" type="month" popup="true" /> + <input list="suggest" type="number" popup="false" /> + <input list="suggest" type="password" popup="true" /> + <input list="suggest" type="radio" popup="false" /> + <input list="suggest" type="range" popup="false" /> + <input list="suggest" type="reset" popup="false" /> + <input list="suggest" type="search" popup="true" /> + <input list="suggest" type="submit" popup="false" /> + <input list="suggest" type="tel" popup="true" /> + <input list="suggest" type="text" popup="true" /> + <input list="suggest" type="time" popup="false" /> + <input list="suggest" type="url" popup="true" /> + <input list="suggest" type="week" popup="true" /> + </form> +</div> + +<script> + +preventSubmitOnForms(); + +add_setup(async () => { + await updateFormHistory([ + { op: "remove" }, + { op: "add", fieldname: "field1", value: "historyvalue" }, + { op: "add", fieldname: "field2", value: "othervalue" }, + { op: "add", fieldname: "field3", value: "history1" }, + { op: "add", fieldname: "field3", value: "history2" }, + ]); +}); + +add_task(async function no_changes_when_opening_popup(){ + const { input } = await openPopupOn("#form1 > input"); + assertValueAfterKeys(input, [], ""); +}); + +add_task(async function use_1st_entry() { + const { input } = await openPopupOn("#form1 > input"); + assertAutocompleteItems("historyvalue", "PASS1", "PASS2", "final"); + assertValueAfterKeys( + input, + ["KEY_ArrowDown", "KEY_Enter"], + "historyvalue"); +}); + +add_task(async function use_2nd_entry(){ + const { input } = await openPopupOn("#form1 > input"); + assertValueAfterKeys( + input, + ["KEY_ArrowDown", "KEY_ArrowDown", "KEY_Enter"], + "Google"); +}); + +add_task(async function use_3rd_entry(){ + const { input } = await openPopupOn("#form1 > input"); + assertValueAfterKeys( + input, + ["KEY_ArrowDown", "KEY_ArrowDown", "KEY_ArrowDown", "KEY_Enter"], + "Reddit"); +}); + +add_task(async function use_4th_entry(){ + const { input } = await openPopupOn("#form1 > input"); + assertValueAfterKeys( + input, + ["KEY_ArrowDown", "KEY_ArrowDown", "KEY_ArrowDown", "KEY_ArrowDown", "KEY_Enter"], + "final"); +}); + +add_task(async function delete_1st_entry(){ + const { input } = await openPopupOn("#form1 > input"); + assertValueAfterKeys(input, "KEY_ArrowDown", ""); + deleteSelectedAutocompleteItem(); + await notifyMenuChanged(3); + is(await countEntries("field1", "historyvalue"), 0, "item is absent"); +}); + +add_task(async function can_use_next_item_after_deletion(){ + const { input } = await openPopupOn("#form1 > input"); + assertAutocompleteItems("PASS1", "PASS2", "final"); + assertValueAfterKeys(input, ["KEY_ArrowDown", "KEY_Enter"], "Google"); +}); + +add_task(async function autocomplete_on_datalist_with_cached_results(){ + const { input } = await openPopupOn("#form1 > input"); + sendString("PAS"); + await notifyMenuChanged(2); + sendString("S1"); + await notifyMenuChanged(1); + assertValueAfterKeys( + input, + ["KEY_ArrowDown", "KEY_Enter"], + "Google"); +}); + +add_task(async function fills_with_autocomplete_off_on_form(){ + const { input } = await openPopupOn("#form4 > input"); + assertValueAfterKeys( + input, + ["KEY_ArrowDown", "KEY_Enter"], + "Google"); + assertAutocompleteItems("PASS1", "PASS2", "final"); +}); + +add_task(async function use_1st_entry_with_autocomplete_off_on_form(){ + const { input } = await openPopupOn("#form4 > input"); + assertValueAfterKeys( + input, + ["KEY_ArrowDown", "KEY_Enter"], + "Google"); +}); + +add_task(async function use_2nd_entry_with_autocomplete_off_on_form(){ + const { input } = await openPopupOn("#form4 > input"); + assertValueAfterKeys( + input, + ["KEY_ArrowDown", "KEY_ArrowDown", "KEY_Enter"], + "Reddit"); +}); + +add_task(async function use_3rd_entry_with_autocomplete_off_on_form(){ + const { input } = await openPopupOn("#form4 > input"); + assertValueAfterKeys( + input, + ["KEY_ArrowDown", "KEY_ArrowDown", "KEY_ArrowDown", "KEY_Enter"], + "final"); +}); + +add_task(async function fills_with_autocomplete_off_on_input(){ + const { input } = await openPopupOn("#form3 > input"); + assertValueAfterKeys( + input, + ["KEY_ArrowDown", "KEY_Enter"], + "Google"); + assertAutocompleteItems("PASS1", "PASS2", "final"); +}); + +add_task(async function use_1st_entry_with_autocomplete_off_on_input(){ + const { input } = await openPopupOn("#form3 > input"); + assertValueAfterKeys( + input, + ["KEY_ArrowDown", "KEY_Enter"], + "Google"); +}); + +add_task(async function use_2nd_entry_with_autocomplete_off_on_input(){ + const { input } = await openPopupOn("#form3 > input"); + assertValueAfterKeys( + input, + ["KEY_ArrowDown", "KEY_ArrowDown", "KEY_Enter"], + "Reddit"); +}); + +add_task(async function use_3rd_entry_with_autocomplete_off_on_input(){ + const { input } = await openPopupOn("#form3 > input"); + assertValueAfterKeys( + input, + ["KEY_ArrowDown", "KEY_ArrowDown", "KEY_ArrowDown", "KEY_Enter"], + "final"); +}); + +add_task(async function remove_item_from_datalist(){ + // When there is an update of the list, the selection is lost so we need to + // go down like if we were at the beginning of the list again. + // + // Removing the second element while on the first then going down and + // push enter. Value should be one from the third suggesion. + const { input } = await openPopupOn("#form3 > input"); + synthesizeKey("KEY_ArrowDown"); + const datalist = document.getElementById("suggest"); + const toRemove = datalist.children[1]; + datalist.removeChild(toRemove); + + assertValueAfterKeys( + input, + ["KEY_ArrowDown", "KEY_ArrowDown", "KEY_Enter"], + "final"); + + // Restore the element. + datalist.insertBefore(toRemove, datalist.children[1]); +}); + +add_task(async function add_item_to_datalist(){ + const { input } = await openPopupOn("#form3 > input"); + const datalist = document.getElementById("suggest"); + + // Adding an attribute after the first one while on the first then going + // down and push enter. Value should be the on from the new suggestion. + synthesizeKey("KEY_ArrowDown"); + datalist.insertBefore(new Option("New value"), datalist.children[1]); + await notifyMenuChanged(4); + assertValueAfterKeys( + input, + ["KEY_ArrowDown", "KEY_ArrowDown", "KEY_Enter"], + "New value"); +}); + +add_task(async function change_datalist_option_value(){ + const datalist = document.getElementById("suggest"); + + // Remove the element. + datalist.removeChild(datalist.children[1]); + await notifyMenuChanged(0); + + // Change the first element value attribute. + const prevValue = datalist.children[0].value; + datalist.children[0].value = "foo"; + + const { input } = await openPopupOn("#form3 > input"); + assertValueAfterKeys( + input, + ["KEY_ArrowDown", "KEY_Enter"], + "foo"); + + datalist.children[0].value = prevValue; + await notifyMenuChanged(0); +}); + +add_task(async function change_datalist_option_text_content(){ + const datalist = document.getElementById("suggest"); + const prevValue = datalist.children[0].getAttribute("value"); + datalist.children[0].removeAttribute("value"); + datalist.children[0].textContent = "foobar"; + const { input } = await openPopupOn("#form3 > input"); + + assertValueAfterKeys( + input, + ["KEY_ArrowDown", "KEY_Enter"], + "foobar"); + + datalist.children[0].setAttribute("value", prevValue); + await notifyMenuChanged(0); +}); + +add_task(async function filters_with_1st_letter(){ + const { input } = await openPopupOn("#form3 > input"); + sendString("f"); + assertValueAfterKeys( + input, + ["KEY_ArrowDown", "KEY_ArrowDown", "KEY_ArrowDown", "KEY_Enter"], + "final"); +}); + +add_task(async function filters_with_letter_in_the_middle(){ + const { input } = await openPopupOn("#form3 > input"); + sendString("in"); + assertValueAfterKeys( + input, + ["KEY_ArrowDown", "KEY_ArrowDown", "KEY_ArrowDown", "KEY_Enter"], + "final"); +}); + +add_task(async function input_events(){ + const { input } = await openPopupOn("#form3 > input"); + + let beforeInputFired = false; + input.addEventListener("beforeinput", (event) => { + beforeInputFired = true; + ok(event instanceof InputEvent, "beforeinput event has InputEvent interface"); + ok(event.bubbles, "beforeinput event should bubble"); + is( + event.cancelable, + SpecialPowers.getBoolPref("dom.input_event.allow_to_cancel_set_user_input"), + "beforeinput event for insertReplacementText should be cancelable if not suppressed"); + is(event.inputType, "insertReplacementText", "inputType of beforeinput event should be insertReplacementText"); + is(event.data, "Google", "data of beforeinput event should be Google"); + is(event.dataTransfer, null, "dataTransfer of beforeinput event should be null"); + is(event.getTargetRanges().length, 0, "getTargetRanges() of beforeinput event should be empty array"); + }, { once: true }); + + let inputFired = false; + input.addEventListener("input", function(event) { + inputFired = true; + ok(event instanceof InputEvent, + "input event should be dispatched with InputEvent interface"); + ok(event.bubbles, "input event should bubble"); + ok(!event.cancelable, "input event should be cancelable"); + is(event.inputType, "insertReplacementText", "inputType of input event should be insertReplacementText"); + is(event.data, "Google", "data of input event should be Google"); + is(event.dataTransfer, null, "dataTransfer of input event should be null"); + is(event.getTargetRanges().length, 0, "getTargetRanges() of input event should be empty array"); + }, { once: true }); + + assertValueAfterKeys(input, ["KEY_ArrowDown", "KEY_Enter"], "Google"); + ok(beforeInputFired, "beforeinput event should've been fired"); + ok(inputFired, "input event should've been fired"); +}); + +add_task(async function cancelled_beforeinput_event(){ + const { input } = await openPopupOn("#form3 > input"); + await SpecialPowers.pushPrefEnv({ + set: [["dom.input_event.allow_to_cancel_set_user_input", true]], + }); + input.addEventListener("beforeinput", e => e.preventDefault(), { once: true }); + let inputFired = false; + input.addEventListener("input", () => inputFired = true, { once: true }); + + assertValueAfterKeys(input, "KEY_ArrowDown", ""); + assertValueAfterKeys(input, "KEY_Enter", ""); + ok(!inputFired, "no input event when beforeinput is canceled"); + + input.blur(); + await SpecialPowers.pushPrefEnv({ + clear: [["dom.input_event.allow_to_cancel_set_user_input"]], + }); +}); + +add_task(async function attempt_to_delete_datalist_entries(){ + const { input } = await openPopupOn("#form5 > input"); + assertAutocompleteItems("history1", "history2", "PASS1", "PASS2", "final"); + + assertValueAfterKeys( + input, + ["KEY_ArrowDown", "KEY_ArrowDown", "KEY_ArrowDown"], + ""); + + deleteSelectedAutocompleteItem(); + synthesizeKey("KEY_ArrowUp"); + deleteSelectedAutocompleteItem(); + await notifyMenuChanged(4); + assertAutocompleteItems("history1", "PASS1", "PASS2", "final"); + // Delete the second entry, that is the 1st entry of datalist. + // This has no effect. + deleteSelectedAutocompleteItem(); + + // Delete the first entry, that is the 1nd entry of history. + synthesizeKey("KEY_ArrowUp"); + deleteSelectedAutocompleteItem(); + await notifyMenuChanged(3); + + assertAutocompleteItems("PASS1", "PASS2", "final"); + assertValueAfterKeys(input, "KEY_Enter", "Google"); +}); + +for (const input of document + .querySelectorAll("form#show_datalist_for_text_inputs_only input")) { + const expectPopup = input.getAttribute("popup") == "true"; + add_named_task( + `datalist_is_${expectPopup ? "" : "not_"}shown_for_${input.type}_input`, + async () => { + input.focus(); + is(document.activeElement, input, "Input is focused."); + await (expectPopup ? popupByArrowDown() : noPopupByArrowDown()); + } + ); +} +</script> +</body> +</html> diff --git a/toolkit/components/satchel/test/test_form_submission.html b/toolkit/components/satchel/test/test_form_submission.html new file mode 100644 index 0000000000..d1c0542609 --- /dev/null +++ b/toolkit/components/satchel/test/test_form_submission.html @@ -0,0 +1,598 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>Satchel Test for Form Submisstion</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script type="text/javascript" src="satchel_common.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" /> +</head> +<body> +<p id="display"></p> +<iframe id="iframe" src="https://example.com/tests/toolkit/components/satchel/test/subtst_form_submission_1.html"></iframe> +<div id="content" style="display: none"> + + <!-- ===== Things that should not be saved. ===== --> + + <form purpose="nothing stored for input autocomplete=off (case-insensitive token)" + id="form1"> + <input type="text" name="test1" autocomplete=" oFf "> + <button type="submit">Submit</button> + </form> + + <form purpose="nothing stored for form autocomplete=off" + id="form2" autocomplete="oFf"> + <input type="text" name="test1"> + <button type="submit">Submit</button> + </form> + + <form purpose="nothing stored for type=hidden" + id="form3"> + <input type="hidden" name="test1"> + <button type="submit">Submit</button> + </form> + + <form purpose="nothing stored for type=checkbox" + id="form4"> + <input type="checkbox" name="test1"> + <button type="submit">Submit</button> + </form> + + <form purpose="nothing stored for empty values." + id="form5"> + <input type="text" name="test1" value="originalValue"> + <button type="submit">Submit</button> + </form> + + <form purpose="nothing stored for unchanged values when set by a script." + id="form6"> + <input type="text" name="test1" value="dontSaveThis"> + <button type="submit">Submit</button> + </form> + + <form purpose="nothing stored for unchanged values. (.value not touched)" + id="form7"> + <input type="text" name="test1" value="dontSaveThis"> + <button type="submit">Submit</button> + </form> + + <form purpose="nothing stored for no field name or ID" + id="form8"> + <input type="text"> + <button type="submit">Submit</button> + </form> + + <form purpose="nothing stored for nothing to save" + id="form9"> + <button type="submit">Submit</button> + </form> + + <form purpose="nothing stored for input with name too long (300 chars.)" + id="form10"> + <input type="text" name="12345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890"> + <button type="submit">Submit</button> + </form> + + <form purpose="nothing stored for input with value too long (300 chars.)" + id="form11"> + <input type="text" name="test1"> + <button type="submit">Submit</button> + </form> + + <form purpose="nothing stored for input with value of one space (which should be trimmed)" + id="form12"> + <input type="text" name="test1"> + <button type="submit">Submit</button> + </form> + + <form purpose="nothing stored for password field" + id="form13"> + <input type="password" name="test1"> + <button type="submit">Submit</button> + </form> + + <form purpose="nothing stored for password field (type toggled to password and back after pageload)" + id="form14"> + <input type="text" name="test1"> + <button type="submit">Submit</button> + </form> + + <form purpose="nothing stored for input with sensitive data (16 digit credit card number)" + id="form15"> + <script type="text/javascript"> + let form = document.getElementById("form15"); + for (let i = 0; i < 10; i++) { + const input = form.appendChild(document.createElement("input")); + input.type = "text"; + input.name = "test" + (i + 1); + } + </script> + <button type="submit">Submit</button> + </form> + + <form purpose="nothing stored for input with sensitive data (15 digit credit card number)" + id="form16"> + <script type="text/javascript"> + form = document.getElementById("form16"); + for (let i = 0; i < 10; i++) { + const input = form.appendChild(document.createElement("input")); + input.type = "text"; + input.name = "test" + (i + 1); + } + </script> + <button type="submit">Submit</button> + </form> + + <form purpose="nothing stored for input with sensitive data (19 digit credit card number)" + id="form17"> + <input type="text" name="test1"> + <button type="submit">Submit</button> + </form> + + <form purpose="nothing stored for input with sensitive data (16 digit hyphenated credit card number)" + id="form18"> + <input type="text" name="test1"> + <button type="submit">Submit</button> + </form> + + <form purpose="nothing stored for input with sensitive data (15 digit whitespace-separated credit card number)" + id="form19"> + <input type="text" name="test1"> + <button type="submit">Submit</button> + </form> + + <form purpose="nothing stored for the invalid form" + id="form20"> + <input type='email' name='test1'> + <button type='submit'>Submit</button> + </form> + + <form purpose="nothing stored for the invalid form" + id="form21"> + <input type='email' value='foo'> + <input type='text' name='test1'> + <button type='submit'>Submit</button> + </form> + + <form purpose="nothing stored for the input with name 'searchbar-history'" + id="form22"> + <input type='text' name='searchbar-history'> + <button type='submit'>Submit</button> + </form> + + <form purpose="nothing stored for input autocomplete=cc-csc (case-insensitive token)" + id="form23"> + <input type="text" name="test1" autocomplete=" cc-CSC "> + <button type="submit">Submit</button> + </form> + + <form purpose="nothing stored for input autocomplete=new-password (case-insensitive token)" + id="form24"> + <input type="text" name="test1" autocomplete=" NEW-password "> + <button type="submit">Submit</button> + </form> + + <form purpose="nothing stored after user input followed by reset button click" + id="form25"> + <input type="text" name="test1" defaultValue="do not save me" value="do not save me either"> + <button type="submit">Submit</button> + <button type="reset">Reset</button> + </form> + + <form purpose="nothing stored after user input changed by a script" + id="form26"> + <input type="text" name="test1" defaultValue="do not save me" value="do not save me either"> + <button type="submit">Submit</button> + </form> + + <form purpose="nothing stored for input autocomplete=one-time-code" + id="form27"> + <input type="text" name="test1" autocomplete="one-time-code"> + <button type="submit">Submit</button> + </form> + + <!-- ===== Things that should be saved ===== --> + + <!-- Form 100 is submitted into an iframe, not declared here. --> + + <form purpose="saved input with no default value" + id="form101"> + <input type="text" name="test1"> + <button type="submit">Submit</button> + </form> + + <form purpose="saved input with a default value" + id="form102"> + <input type="text" name="test2" value="originalValue"> + <button type="submit">Submit</button> + </form> + + <form purpose="saved input with id and not name" + id="form103"> + <input type="text" name="test3"> + <button type="submit">Submit</button> + </form> + + <form purpose="saved input with leading and trailing space" + id="form104"> + <input type="text" name="test4"> + <button type="submit">Submit</button> + </form> + + <form purpose="saved input with leading and trailing whitespace" + id="form105"> + <input type="text" name="test5"> + <button type="submit">Submit</button> + </form> + + <form purpose="saved input that looks like sensitive data but doesn't satisfy the requirements (incorrect length)" + id="form106"> + <input type="text" name="test6"> + <button type="submit">Submit</button> + </form> + + <form purpose="input that looks like sensitive data but doesn't satisfy the requirements (Luhn check fails for 16 chars)" + id="form107"> + <script type="text/javascript"> + form = document.getElementById("form107"); + for (let i = 0; i < 10; i++) { + let input = form.appendChild(document.createElement("input")); + input.type = "text"; + input.name = "test7_" + (i + 1); + } + </script> + <button type="submit">Submit</button> + </form> + + <form purpose="input that looks like sensitive data but doesn't satisfy the requirements (Luhn check fails for 15 chars)" + id="form108"> + <script type="text/javascript"> + form = document.getElementById("form108"); + for (let i = 0; i != 10; i++) { + let input = form.appendChild(document.createElement("input")); + input.type = "text"; + input.name = "test8_" + (i + 1); + } + </script> + <button type="submit">Submit</button> + </form> + + <form purpose="form data submitted through HTTPS, when browser.formfill.saveHttpsForms is true" + id="form109" action="https://www.example.com/"> + <input type="text" name="test9"> + <button type="submit">Submit</button> + </form> + +</div> +<script> +/* eslint-disable complexity */ + +const ccNumbers = { + valid15: [ + "930771457288760", "474915027480942", + "924894781317325", "714816113937185", + "790466087343106", "474320195408363", + "219211148122351", "633038472250799", + "354236732906484", "095347810189325", + ], + valid16: [ + "3091269135815020", "5471839082338112", + "0580828863575793", "5015290610002932", + "9465714503078607", "4302068493801686", + "2721398408985465", "6160334316984331", + "8643619970075142", "0218246069710785", + ], + invalid15: [ + "526931005800649", "724952425140686", + "379761391174135", "030551436468583", + "947377014076746", "254848023655752", + "226871580283345", "708025346034339", + "917585839076788", "918632588027666", + ], + invalid16: [ + "9946177098017064", "4081194386488872", + "3095975979578034", "3662215692222536", + "6723210018630429", "4411962856225025", + "8276996369036686", "4449796938248871", + "3350852696538147", "5011802870046957", + ], +}; + +function setUserInput(formNumber, inputName, value) { + const input = SpecialPowers.wrap(getFormElementByName(formNumber, inputName)); + input.setUserInput(value); +} + +function setScriptInput(formNumber, inputName, value) { + getFormElementByName(formNumber, inputName).value = value; +} + +function checkSubmitDoesNotSave(formNumber, inputName, value) { + return new Promise((resolve, reject) => { + const form = document.getElementById("form" + formNumber); + form.addEventListener("submit", async e => { + const historyEntriesCount = await countEntries(null, null); + ok(!historyEntriesCount, form.getAttribute("purpose")); + resolve(); + }, { once: true }); + + getFormSubmitButton(formNumber).click(); + }); +} + +function checkInvalidFirstInputDoesNotSave(formNumber, value) { + return new Promise((resolve, reject) => { + const form = document.getElementById("form" + formNumber); + const input = form.querySelector("input"); + input.addEventListener("invalid", async e => { + const historyEntriesCount = await countEntries(null, null); + ok(!historyEntriesCount, form.getAttribute("purpose")); + resolve(); + }, { once: true}); + + getFormSubmitButton(formNumber).click(); + }); +} + +async function checkSubmitSaves(formNumber, inputName, interactiveValue, savedValue, storageEventData = "formhistory-add") { + setUserInput(formNumber, inputName, interactiveValue); + const form = document.getElementById("form" + formNumber); + const storageEventPromise = promiseNextStorageEvent(); + + getFormSubmitButton(formNumber).click(); + + const storageEvent = await storageEventPromise; + isDeeply(storageEvent, { + subject: null, + topic: "satchel-storage-changed", + data: storageEventData + }, "expected storage event"); + + const historyEntriesCount = await countEntries(inputName, savedValue); + is(historyEntriesCount, 1, form.getAttribute("purpose")); +} + +preventSubmitOnForms(); + +add_setup(async () => { + await updateFormHistory([ + { op: "remove" }, + ]); + const historyEntriesCount = await countEntries(null, null); + ok(!historyEntriesCount, "checking for initially empty storage"); +}); + +add_task(async function form1_does_not_save() { + setUserInput(1, "test1", "dontSaveThis"); + await checkSubmitDoesNotSave(1); +}); + +add_task(async function form2_does_not_save() { + setUserInput(2, "test1", "dontSaveThis"); + await checkSubmitDoesNotSave(2); +}); + +add_task(async function form3_does_not_save() { + setUserInput(3, "test1", "dontSaveThis"); + await checkSubmitDoesNotSave(3); +}); + +add_task(async function form4_does_not_save() { + setUserInput(4, "test1", "dontSaveThis"); + await checkSubmitDoesNotSave(4); +}); + +add_task(async function form5_does_not_save() { + setUserInput(5, "test1", ""); + await checkSubmitDoesNotSave(5); +}); + +add_task(async function form6_does_not_save() { + setScriptInput(6, "test1", "dontSaveThis"); + await checkSubmitDoesNotSave(6); +}); + +add_task(async function form7_does_not_save() { + // Form 7 deliberately left untouched. + await checkSubmitDoesNotSave(7); +}); + +add_task(async function form8_does_not_save() { + // Form 8 has an input with no name or input attribute. + const input = SpecialPowers.wrap(document.getElementById("form8").elements[0]); + is(input.type, "text", "checking we got unidentified input"); + input.setUserInput("dontSaveThis"); + await checkSubmitDoesNotSave(8); +}); + +add_task(async function form9_does_not_save() { + // Form 9 has nothing to modify. + await checkSubmitDoesNotSave(9); +}); + +add_task(async function form10_does_not_save() { + setUserInput(10, + "12345678901234567890123456789012345678901234567890123456789012345678901234567890123456" + + "789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456" + + "789012345678901234567890123456789012345678901234567890123456789012345678901234567890", + "dontSaveThis"); + await checkSubmitDoesNotSave(10); +}); + +add_task(async function form11_does_not_save() { + setUserInput(11, "test1", + "123456789012345678901234567890123456789012345678901234567890123456789" + + "012345678901234567890123456789012345678901234567890123456789012345678" + + "901234567890123456789012345678901234567890123456789012345678901234567" + + "89012345678901234567890123456789012345678901234567890"); + await checkSubmitDoesNotSave(11); +}); + +add_task(async function form12_does_not_save() { + setUserInput(12, "test1", " "); + await checkSubmitDoesNotSave(12); +}); + +add_task(async function form13_does_not_save() { + setUserInput(13, "test1", "dontSaveThis"); + await checkSubmitDoesNotSave(13); +}); + +add_task(async function form14_does_not_save() { + const input = SpecialPowers.wrap(document.getElementById("form14").elements[0]); + input.type = "password"; + input.setUserInput("dontSaveThis"); + // Set it back to type=text to simulate a password visibility toggle. + input.type = "text"; + await checkSubmitDoesNotSave(14); +}); + +add_task(async function form15_does_not_save() { + const testData = ccNumbers.valid16; + for (let i = 0; i < testData.length; i++) { + setUserInput(15, "test" + (i + 1), testData[i]); + } + await checkSubmitDoesNotSave(15); +}); + +add_task(async function form16_does_not_save() { + const testData = ccNumbers.valid15; + for (let i = 0; i < testData.length; i++) { + setUserInput(16, "test" + (i + 1), testData[i]); + } + await checkSubmitDoesNotSave(16); +}); + +add_task(async function form17_does_not_save() { + setUserInput(17, "test1", "6799990100000000019"); + await checkSubmitDoesNotSave(17); +}); + +add_task(async function form18_does_not_save() { + setUserInput(18, "test1", "0000-0000-0080-4609"); + await checkSubmitDoesNotSave(18); +}); + +add_task(async function form19_does_not_save() { + setUserInput(19, "test1", "0000 0000 0222 331"); + await checkSubmitDoesNotSave(19); +}); + +add_task(async function form20_does_not_save() { + setUserInput(20, "test1", "dontSaveThis"); + await checkInvalidFirstInputDoesNotSave(20, "invalid"); +}); + +add_task(async function form21_does_not_save() { + setUserInput(21, "test1", "dontSaveThis"); + await checkInvalidFirstInputDoesNotSave(21, "invalid"); +}); + +add_task(async function form22_does_not_save() { + setUserInput(22, "searchbar-history", "dontSaveThis"); + await checkSubmitDoesNotSave(22); +}); + +add_task(async function form23_does_not_save() { + setUserInput(23, "test1", "987"); + await checkSubmitDoesNotSave(23); +}); + +add_task(async function form24_does_not_save() { + setUserInput(24, "test1", "s3cr3t"); + await checkSubmitDoesNotSave(24); +}); + +add_task(async function form25_does_not_save() { + setUserInput(25, "test1", "s3cr3t"); + document.querySelector("form[id=form25] button[type=reset]").click(); + await checkSubmitDoesNotSave(25); +}); + +add_task(async function form26_does_not_save() { + setUserInput(26, "test1", "s3cr3t"); + document.querySelector("form[id=form26] input[name=test1]").value = "script changed me"; + await checkSubmitDoesNotSave(26); +}); + +add_task(async function form27_does_not_save() { + setUserInput(27, "test1", "123456"); + await checkSubmitDoesNotSave(27); +}); + +add_task(async function form100_saves() { + const iframe = SpecialPowers.wrap(document.getElementById("iframe")); + const browsingContext = SpecialPowers.unwrap(iframe.browsingContext); + const storageEventPromise = promiseNextStorageEvent(); + await SpecialPowers.spawn(browsingContext, [], () => { + /* eslint-disable no-undef */ + const input = SpecialPowers.wrap(content.document.getElementById("subtest2")); + input.setUserInput("subtestValue"); + // This will prevent endless loop of tests + for (const form of content.document.forms) { + /* eslint-disable-next-line mozilla/balanced-listeners */ + form.addEventListener("submit", e => e.preventDefault()); + } + content.document.querySelector("button").click(); + /* eslint-enable no-undef */ + }); + + const storageEvent = await storageEventPromise; + isDeeply(storageEvent, { + subject: null, + topic: "satchel-storage-changed", + data: "formhistory-add" + }, "expected storage event"); + + const historyEntriesCount = await countEntries("subtest2", "subtestValue"); + is(historyEntriesCount, 1, "saved from iframe"); +}); + +add_task(async function form101_saves() { + await checkSubmitSaves(101, "test1", "savedValue", "savedValue"); +}); + +add_task(async function form102_saves() { + await checkSubmitSaves(102, "test2", "savedValue", "savedValue"); +}); + +add_task(async function form103_saves() { + await checkSubmitSaves(103, "test3", "savedValue", "savedValue"); +}); + +add_task(async function form104_saves() { + await checkSubmitSaves(104, "test4", " trimTrailingAndLeadingSpace ", "trimTrailingAndLeadingSpace"); +}); + +add_task(async function form105_saves() { + await checkSubmitSaves(105, "test5", "\t trimTrailingAndLeadingWhitespace\t ", "trimTrailingAndLeadingWhitespace"); +}); + +add_task(async function form106_saves() { + // passes luhn but too long + await checkSubmitSaves(106, "test6", "55555555555544445553", "55555555555544445553"); +}); + +add_task(async function form107_saves() { + for (let i = 0; i != ccNumbers.invalid16.length; i++) { + const name = "test7_" + (i + 1); + const value = ccNumbers.invalid16[i]; + await checkSubmitSaves(107, name, value, value, i != 0 ? "formhistory-update" : "formhistory-add"); + } +}); + +add_task(async function form108_saves() { + for (let i = 0; i != ccNumbers.invalid15.length; i++) { + const name = "test8_" + (i + 1); + const value = ccNumbers.invalid15[i]; + await checkSubmitSaves(108, name, value, value, i != 0 ? "formhistory-update" : "formhistory-add"); + } +}); + +add_task(async function form109_saves() { + setUserInput(109, "test9", "savedValue"); + await checkSubmitSaves(109, "test9", "savedValue", "savedValue"); +}); + +</script> +</body> +</html> diff --git a/toolkit/components/satchel/test/test_history_datalist_duplicates.html b/toolkit/components/satchel/test/test_history_datalist_duplicates.html new file mode 100644 index 0000000000..395405396a --- /dev/null +++ b/toolkit/components/satchel/test/test_history_datalist_duplicates.html @@ -0,0 +1,55 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>Test for Form History / DataList Duplicate Autocomplete Entries: Bug 1263588</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/EventUtils.js"></script> + <script type="text/javascript" src="satchel_common.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" /> +</head> +<body> +<div id="content"> + + <!-- normal, basic form --> + <form id="form1" onsubmit="return false;"> + <input list="suggest" type="text" name="field1" id="field1"> + <button type="submit">Submit</button> + <datalist id="suggest"> + <option value="Mozilla"> + <option value="Firefox"> + <option value="Relay is awesome"> + </datalist> + </form> + +</div> + +<script> + +add_setup(async () => { + await updateFormHistory([ + { op: "add", fieldname: "field1", value: "Mozilla" }, + { op: "add", fieldname: "field1", value: "Firefox" }, + { op: "add", fieldname: "field1", value: "Relay" }, + ]); +}); + +add_task(async function test_all_entries_are_unique_and_ordered() { + await triggerAutofillAndCheckEntriesValue("",["Relay", "Mozilla", "Firefox", "Relay is awesome"]); +}); + +add_task(async function test_duplicate_entries_are_shown_once() { + await triggerAutofillAndCheckEntriesValue("f", ["Firefox"]); +}); + +add_task(async function test_non_duplicate_entries_are_shown_once() { + await triggerAutofillAndCheckEntriesValue("rel",["Relay", "Relay is awesome"]); +}); + +async function triggerAutofillAndCheckEntriesValue(inputValue, expectedValues) { + await openPopupOn("#form1 > input", { inputValue }); + isDeeply(getMenuEntries(), expectedValues, "Matching deduplicated autocomplete list entries with expected values."); +} + +</script> +</body> +</html> diff --git a/toolkit/components/satchel/test/test_input_valid_state_with_autocomplete.html b/toolkit/components/satchel/test/test_input_valid_state_with_autocomplete.html new file mode 100644 index 0000000000..196ca59765 --- /dev/null +++ b/toolkit/components/satchel/test/test_input_valid_state_with_autocomplete.html @@ -0,0 +1,125 @@ +<!DOCTYPE html> +<html> +<head> + <meta charset="utf-8"> + <title>Test for valid state with autocomplete</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/EventUtils.js"></script> + <script type="text/javascript" src="satchel_common.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" /> + <style> + input:invalid { + border: red 1px solid; + } + </style> +</head> +<body> +<p id="display"></p> + +<div id="content"> + <form id="form1"> + <input type="email" name="field1"> + <button type="submit">Submit</button> + </form> +</div> + +<script> + +add_setup(async () => { + await updateFormHistory([ + { op: "remove" }, + { op: "add", fieldname: "field1", value: "email@example.com" }, + { op: "add", fieldname: "field1", value: "email@example.com." }, + ]); +}); + +add_task(async function datalist_with_caching() { + const kSetUserInputCancelable = SpecialPowers.getBoolPref("dom.input_event.allow_to_cancel_set_user_input"); + const { input } = await openPopupOn("input[name=field1]"); + synthesizeKey("KEY_Escape"); + + let beforeInputFired = false; + input.addEventListener("beforeinput", e => { + beforeInputFired = true; + ok(e.cancelable, "'beforeinput' event for 'insertText' is cancelable"); + is(e.inputType, "insertText", "inputType of 'beforeinput' event is 'insertText'"); + ok(input.validity.valid, "Valid immediately before inserting a character"); + ok(!input.matches(":invalid"), + "Shouldn't match ':invalid' immediately before inserting a character"); + }, { once: true }); + + let inputFired = false; + input.addEventListener("input", e => { + inputFired = true; + is(e.inputType, "insertText", "inputType of 'input' event should be 'insertText'"); + ok(!input.validity.valid, "Invalid immediately after inserting a character"); + ok(input.matches(":invalid"), + "Should match ':invalid' immediately after inserting a character"); + }, { once: true }); + synthesizeKey("e"); + ok(beforeInputFired, "'beforeinput' event fired at typing 'e'"); + ok(inputFired, "'input' event fired at typing 'e'"); + + await notifyMenuChanged(2); + inputFired = false; + beforeInputFired = false; + input.addEventListener("beforeinput", e => { + beforeInputFired = true; + is(e.cancelable, kSetUserInputCancelable, + "'beforeinput' event for 'insertReplacementText' is cancelable unless it's suppressed by the pref"); + is(e.inputType, "insertReplacementText", + "inputType of 'beforeinput' event is 'insertReplacementText'"); + ok(!input.validity.valid, + "Invalid immediately before selecting valid item in autocomplete list"); + ok(input.matches(":invalid"), + "Matches ':invalid' immediately before selecting valid item in autocomplete list"); + }, { once: true }); + input.addEventListener("input", e => { + inputFired = true; + is(e.inputType, "insertReplacementText", + "inputType of 'input' event is 'insertReplacementText'"); + ok(input.validity.valid, + "Valid immediately after selecting valid item in autocomplete list"); + ok(!input.matches(":invalid"), + "Shouldn't match ':invalid' immediately after selecting valid item in autocomplete list"); + }, { once: true }); + synthesizeKey("KEY_ArrowDown"); + synthesizeKey("KEY_Enter"); // Select valid item + ok(beforeInputFired, "'beforeinput' event should have been fired at selecting valid item"); + ok(inputFired, "'input' event should have been fired at selecting valid item"); + + inputFired = false; + beforeInputFired = false; + synthesizeKey("KEY_Backspace"); + await notifyMenuChanged(2); + synthesizeKey("KEY_ArrowDown"); + synthesizeKey("KEY_ArrowDown"); + input.addEventListener("beforeinput", e => { + ok(!beforeInputFired, '"input" event should be fired only once at typing'); + beforeInputFired = true; + is(e.cancelable, kSetUserInputCancelable, + `"beforeinput" event for "insertReplacementText" is cancelable unless it's suppressed by the pref`); + is(e.inputType, "insertReplacementText", + "inputType of 'beforeinput' event is 'insertReplacementText'"); + ok(input.validity.valid, + "Valid immediately before selecting invalid item in autocomplete list"); + ok(!input.matches(":invalid"), + "Shouldn't match ':invalid' immediately after selecting invalid item in autocomplete list"); + }, { once: true }); + input.addEventListener("input", (event) => { + ok(!inputFired, '"input" event is fired only once at typing'); + inputFired = true; + is(event.inputType, "insertReplacementText", + "inputType of 'input' event is 'insertReplacementText'"); + ok(!input.validity.valid, + "Invalid immediately after selecting invalid item in autocomplete list"); + ok(input.matches(":invalid"), + "Matches ':invalid' immediately after selecting invalid item in autocomplete list"); + }, { once: true }); + synthesizeKey("KEY_Enter"); // Select invalid item + ok(beforeInputFired, "'beforeinput' event should have been fired at selecting invalid item"); + ok(inputFired, "'input' event should have been fired at selecting invalid item"); +}); + +</script> +</body> diff --git a/toolkit/components/satchel/test/test_password_autocomplete.html b/toolkit/components/satchel/test/test_password_autocomplete.html new file mode 100644 index 0000000000..d24f556d9e --- /dev/null +++ b/toolkit/components/satchel/test/test_password_autocomplete.html @@ -0,0 +1,89 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>Test for form history on type=password</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/EventUtils.js"></script> + <script type="text/javascript" src="satchel_common.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" /> +</head> +<body> + Test for form history on type=password + (based on test_bug_511615.html) +<p id="display"></p> + +<!-- we presumably can't hide the content for this test. --> +<div id="content"> + <datalist id="datalist1"> + <option>value10</option> + <option>value11</option> + <option>value12</option> + </datalist> + <form id="form1" onsubmit="return false;"> + <!-- Don't set the type to password until rememberSignons is false since we + want to test when rememberSignons is false. --> + <input type="to-be-password" name="field1" list="datalist1"> + <button type="submit">Submit</button> + </form> + <!-- Same as form1 but with an insecure HTTP action --> + <form id="form2" onsubmit="return false;" action="http://mochi.test/"> + <input type="to-be-password" name="field1" list="datalist1"> + <button type="submit">Submit</button> + </form> +</div> + +<script> + +add_setup(async () => { + await SpecialPowers.pushPrefEnv({set: [["signon.rememberSignons", false]]}); + + is(window.location.protocol, "https:", "This test must run on HTTPS"); + + // Now that rememberSignons is false, create the password fields. + document.querySelector("#form1 > input").type = "password"; + document.querySelector("#form2 > input").type = "password"; + + await updateFormHistory([ + { op: "remove" }, + { op: "add", fieldname: "field1", value: "value1" }, + { op: "add", fieldname: "field1", value: "value2" }, + { op: "add", fieldname: "field1", value: "value3" }, + { op: "add", fieldname: "field1", value: "value4" }, + { op: "add", fieldname: "field1", value: "value5" }, + { op: "add", fieldname: "field1", value: "value6" }, + { op: "add", fieldname: "field1", value: "value7" }, + { op: "add", fieldname: "field1", value: "value8" }, + { op: "add", fieldname: "field1", value: "value9" }, + ]); +}); + +add_task(async function test_secure_noFormHistoryOrWarning() { + const input = document.querySelector("#form1 input"); + + // The autocomplete popup should not open under any circumstances on + // type=password with password manager disabled. + for (let triggerFn of [ + () => input.focus(), + () => input.click(), + () => synthesizeKey("KEY_ArrowDown"), + () => synthesizeKey("KEY_PageDown"), + () => synthesizeKey("KEY_Enter"), + () => sendString("v "), + () => synthesizeKey("KEY_Backspace"), + ]) { + info("Testing: " + triggerFn.toString()); + // We must wait for the entire timeout for each individual test, because the + // next event in the list might prevent the popup from opening. + await noPopupBy(triggerFn); + } +}); + +add_task(async function test_insecure_focusWarning() { + // Form 2 has an insecure action so should show the warning even if password manager is disabled. + await openPopupOn("#form2 > input"); + ok(getMenuEntries()[0].includes("Logins entered here could be compromised"), + "Check warning is first"); +}); +</script> +</body> +</html> diff --git a/toolkit/components/satchel/test/test_popup_direction.html b/toolkit/components/satchel/test/test_popup_direction.html new file mode 100644 index 0000000000..6b2fa010d6 --- /dev/null +++ b/toolkit/components/satchel/test/test_popup_direction.html @@ -0,0 +1,44 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>Test for Popup Direction</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/EventUtils.js"></script> + <script type="text/javascript" src="satchel_common.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" /> +</head> +<body> +Test for Popup Direction +<p id="display"></p> + +<!-- we presumably can't hide the content for this test. --> +<div id="content"> + <!-- normal, basic form --> + <form id="form1" onsubmit="return false;"> + <input type="text" name="field1"> + <button type="submit">Submit</button> + </form> +</div> + +<script> + +add_setup(async () => { + await updateFormHistory([ + { op: "remove" }, + { op: "add", fieldname: "field1", value: "value1" }, + { op: "add", fieldname: "field1", value: "value2" }, + ]); +}); + +add_task(async function test_popup_direction() { + for (const direction of ["ltr", "rtl"]) { + document.getElementById("content").style.direction = direction; + await openPopupOn("#form1 > input"); + const popupState = await getPopupState(); + is(popupState.direction, direction, "Direction should match."); + } +}); + +</script> +</body> +</html> diff --git a/toolkit/components/satchel/test/test_popup_enter_event.html b/toolkit/components/satchel/test/test_popup_enter_event.html new file mode 100644 index 0000000000..6150a8a57a --- /dev/null +++ b/toolkit/components/satchel/test/test_popup_enter_event.html @@ -0,0 +1,70 @@ +<!DOCTYPE html> +<html> +<head> + <meta charset="utf-8"> + <title>Test for events while the form history popup is open</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/EventUtils.js"></script> + <script type="text/javascript" src="satchel_common.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" /> +</head> +<body> +Form History test: Test for events while the form history popup is open +<p id="display"></p> + +<div id="content"> + <form id="form1"> + <input type="text" name="field1"> + <button type="submit">Submit</button> + </form> +</div> + +<script> + +add_setup(async () => { + await updateFormHistory([ + { op: "remove" }, + { op: "add", fieldname: "field1", value: "value1" }, + ]); +}); + +add_task(async function popupEnterEvent() { + const form = document.querySelector("#form1"); + const input = form.querySelector("input"); + const expectedValue = "value1"; + + function handleEnter(e) { + if (e.keyCode != KeyEvent.DOM_VK_RETURN) { + return; + } + + info("RETURN received for phase: " + e.eventPhase); + if (input.value == expectedValue) { + ok(true, "RETURN should be received when the popup is closed"); + is(input.value, expectedValue, "Check input value when enter is pressed the 2nd time"); + info("form should submit with the default handler"); + } else { + ok(false, "RETURN keypress shouldn't have been received when a popup item is selected"); + } + } + + const submitTested = new Promise(resolve => { + SpecialPowers.addSystemEventListener(input, "keypress", handleEnter, true); + form.addEventListener("submit", e => { + e.preventDefault(); + is(input.value, expectedValue, "Check input value in the submit handler"); + SpecialPowers.removeSystemEventListener(input, "keypress", handleEnter, true); + resolve(); + }, { once: true }); + }); + + await openPopupOn(input, { inputValue: "value" }); + synthesizeKey("KEY_ArrowDown"); + synthesizeKey("KEY_Enter"); // select the first entry in the popup + synthesizeKey("KEY_Enter"); // try to submit the form with the filled value + await submitTested; +}); + +</script> +</body> +</html> diff --git a/toolkit/components/satchel/test/test_submit_on_keydown_enter.html b/toolkit/components/satchel/test/test_submit_on_keydown_enter.html new file mode 100644 index 0000000000..f5879a2b5e --- /dev/null +++ b/toolkit/components/satchel/test/test_submit_on_keydown_enter.html @@ -0,0 +1,108 @@ +<!DOCTYPE html> +<html> +<head> + <meta charset="utf-8"> + <title>Test for events while the form history popup is open</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/EventUtils.js"></script> + <script type="text/javascript" src="satchel_common.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" /> +</head> +<body> +Form History test: Test for keydown handler submitting the form +<p id="display"></p> + +<div id="content"> + <form id="form1"> + <input type="text" name="field1"> + <button type="submit">Submit</button> + </form> +</div> + +<script> + +add_setup(async () => { + await updateFormHistory([ + { op: "remove" }, + { op: "add", fieldname: "field1", value: "value1" }, + ]); +}); + +add_task(async function submitOnKeydownEnter() { + const form = document.querySelector("#form1"); + const input = form.querySelector("input"); + const expectedValue = "value1"; + let beforeInputFired = false; + const submitTested = new Promise(resolve => { + + function handleBeforeInput(aEvent) { + info("BeforeInput"); + beforeInputFired = true; + is(input.value, "value", "The value should've not been modified yet"); + ok(aEvent instanceof InputEvent, + '"beforeinput" event should be dispatched with InputEvent interface'); + is(aEvent.cancelable, SpecialPowers.getBoolPref("dom.input_event.allow_to_cancel_set_user_input"), + `"beforeinput" event should be cancelable unless it's supporessed by the pref`); + is(aEvent.bubbles, true, + '"beforeinput" event should always bubble'); + is(aEvent.inputType, "insertReplacementText", + 'inputType of "beforeinput" event should be "insertReplacementText"'); + is(aEvent.data, expectedValue, + `data of "beforeinput" event should be "${expectedValue}"`); + is(aEvent.dataTransfer, null, + 'dataTransfer of "beforeinput" event should be null'); + is(aEvent.getTargetRanges().length, 0, + 'getTargetRanges() of "beforeinput" event should return empty array'); + } + + function handleInput(e) { + info("Input"); + ok(beforeInputFired, '"beforeinput" event should have been fired'); + is(input.value, expectedValue, "Check input value"); + ok(e instanceof InputEvent, + '"input" event should be dispatched with InputEvent interface'); + is(e.cancelable, false, '"input" event should be never cancelable'); + is(e.bubbles, true, '"input" event should always bubble'); + is(e.inputType, "insertReplacementText", 'inputType of "input" event'); + is(e.data, expectedValue, "data of input event"); + is(e.dataTransfer, null, 'dataTransfer of "input" event'); + is(e.getTargetRanges().length, 0, + 'getTargetRanges() of "input" event should return empty array'); + removeEventListeners(); + resolve(); + } + + function removeEventListeners() { + input.removeEventListener("beforeinput", handleBeforeInput, true); + input.removeEventListener("input", handleInput, true); + } + + input.addEventListener("beforeinput", handleBeforeInput, true); + input.addEventListener("input", handleInput, true); + input.addEventListener("keydown", function handleEnterDown(e) { + if (e.keyCode != KeyEvent.DOM_VK_RETURN) { + return; + } + info("Enter KeyDown"); + input.removeEventListener("keydown", handleEnterDown, true); + form.submit(); + }, true); + form.addEventListener("submit", () => { + info("Submit"); + ok(false, "The form should not be submitted"); + removeEventListeners(); + resolve(); + }, { once: true }); + }); + + await openPopupOn(input, { inputValue: "value" }); + + synthesizeKey("KEY_ArrowDown"); + synthesizeKey("KEY_Enter"); // select the first entry in the popup + + await submitTested; +}); + +</script> +</body> +</html> diff --git a/toolkit/components/satchel/test/unit/asyncformhistory_expire.sqlite b/toolkit/components/satchel/test/unit/asyncformhistory_expire.sqlite Binary files differnew file mode 100644 index 0000000000..07b43c2096 --- /dev/null +++ b/toolkit/components/satchel/test/unit/asyncformhistory_expire.sqlite diff --git a/toolkit/components/satchel/test/unit/formhistory_1000.sqlite b/toolkit/components/satchel/test/unit/formhistory_1000.sqlite Binary files differnew file mode 100644 index 0000000000..5eeab074fd --- /dev/null +++ b/toolkit/components/satchel/test/unit/formhistory_1000.sqlite diff --git a/toolkit/components/satchel/test/unit/formhistory_CORRUPT.sqlite b/toolkit/components/satchel/test/unit/formhistory_CORRUPT.sqlite new file mode 100644 index 0000000000..5f7498bfc2 --- /dev/null +++ b/toolkit/components/satchel/test/unit/formhistory_CORRUPT.sqlite @@ -0,0 +1 @@ +BACON diff --git a/toolkit/components/satchel/test/unit/formhistory_apitest.sqlite b/toolkit/components/satchel/test/unit/formhistory_apitest.sqlite Binary files differnew file mode 100644 index 0000000000..00daf03c27 --- /dev/null +++ b/toolkit/components/satchel/test/unit/formhistory_apitest.sqlite diff --git a/toolkit/components/satchel/test/unit/formhistory_autocomplete.sqlite b/toolkit/components/satchel/test/unit/formhistory_autocomplete.sqlite Binary files differnew file mode 100644 index 0000000000..724cff73f6 --- /dev/null +++ b/toolkit/components/satchel/test/unit/formhistory_autocomplete.sqlite diff --git a/toolkit/components/satchel/test/unit/formhistory_v3.sqlite b/toolkit/components/satchel/test/unit/formhistory_v3.sqlite Binary files differnew file mode 100644 index 0000000000..e0e8fe2468 --- /dev/null +++ b/toolkit/components/satchel/test/unit/formhistory_v3.sqlite diff --git a/toolkit/components/satchel/test/unit/formhistory_v3v4.sqlite b/toolkit/components/satchel/test/unit/formhistory_v3v4.sqlite Binary files differnew file mode 100644 index 0000000000..8eab177e97 --- /dev/null +++ b/toolkit/components/satchel/test/unit/formhistory_v3v4.sqlite diff --git a/toolkit/components/satchel/test/unit/formhistory_v999a.sqlite b/toolkit/components/satchel/test/unit/formhistory_v999a.sqlite Binary files differnew file mode 100644 index 0000000000..216bce4a34 --- /dev/null +++ b/toolkit/components/satchel/test/unit/formhistory_v999a.sqlite diff --git a/toolkit/components/satchel/test/unit/formhistory_v999b.sqlite b/toolkit/components/satchel/test/unit/formhistory_v999b.sqlite Binary files differnew file mode 100644 index 0000000000..fe400d04a1 --- /dev/null +++ b/toolkit/components/satchel/test/unit/formhistory_v999b.sqlite diff --git a/toolkit/components/satchel/test/unit/head_satchel.js b/toolkit/components/satchel/test/unit/head_satchel.js new file mode 100644 index 0000000000..631878c7d0 --- /dev/null +++ b/toolkit/components/satchel/test/unit/head_satchel.js @@ -0,0 +1,195 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/* eslint + "no-unused-vars": ["error", { + vars: "local", + args: "none", + }], +*/ + +const CURRENT_SCHEMA = 5; +const PR_HOURS = 60 * 60 * 1000000; + +var { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); + +ChromeUtils.defineESModuleGetters(this, { + FormHistory: "resource://gre/modules/FormHistory.sys.mjs", + FormHistoryTestUtils: + "resource://testing-common/FormHistoryTestUtils.sys.mjs", + Sqlite: "resource://gre/modules/Sqlite.sys.mjs", +}); + +do_get_profile(); + +// Send the profile-after-change notification to the form history component to ensure +// that it has been initialized. +var formHistoryStartup = Cc[ + "@mozilla.org/satchel/form-history-startup;1" +].getService(Ci.nsIObserver); +formHistoryStartup.observe(null, "profile-after-change", null); + +function getDBVersion(dbfile) { + let dbConnection = Services.storage.openDatabase(dbfile); + let version = dbConnection.schemaVersion; + dbConnection.close(); + + return version; +} + +async function getDBSchemaVersion(path) { + let db = await Sqlite.openConnection({ path }); + try { + return await db.getSchemaVersion(); + } finally { + await db.close(); + } +} + +function getFormHistoryDBVersion() { + let profileDir = do_get_profile(); + // Cleanup from any previous tests or failures. + let dbFile = profileDir.clone(); + dbFile.append("formhistory.sqlite"); + return getDBVersion(dbFile); +} + +const isGUID = /[A-Za-z0-9\+\/]{16}/; + +// Find form history entries. +function searchEntries(terms, params, iter) { + FormHistory.search(terms, params).then( + results => iter.next(results), + error => do_throw("Error occurred searching form history: " + error) + ); +} + +// Count the number of entries with the given name and value, and call then(number) +// when done. If name or value is null, then the value of that field does not matter. +function countEntries(name, value, then) { + let obj = {}; + if (name !== null) { + obj.fieldname = name; + } + if (value !== null) { + obj.value = value; + } + + FormHistory.count(obj).then( + count => { + then(count); + }, + error => { + do_throw("Error occurred searching form history: " + error); + } + ); +} + +// Perform a single form history update and call then() when done. +function updateEntry(op, name, value, then) { + let obj = { op }; + if (name !== null) { + obj.fieldname = name; + } + if (value !== null) { + obj.value = value; + } + updateFormHistory(obj, then); +} + +// Add a single form history entry with the current time and call then() when done. +function addEntry(name, value, then) { + let now = Date.now() * 1000; + updateFormHistory( + { + op: "add", + fieldname: name, + value, + timesUsed: 1, + firstUsed: now, + lastUsed: now, + }, + then + ); +} + +function promiseCountEntries(name, value, checkFn = () => {}) { + return new Promise(resolve => { + countEntries(name, value, function (result) { + checkFn(result); + resolve(result); + }); + }); +} + +function promiseUpdateEntry(op, name, value) { + return new Promise(res => { + updateEntry(op, name, value, res); + }); +} + +function promiseAddEntry(name, value) { + return new Promise(res => { + addEntry(name, value, res); + }); +} + +// Wrapper around FormHistory.update which handles errors. Calls then() when done. +function updateFormHistory(changes, then) { + FormHistory.update(changes).then(then, error => { + do_throw("Error occurred updating form history: " + error); + }); +} + +function promiseUpdate(change) { + return FormHistory.update(change); +} + +/** + * Logs info to the console in the standard way (includes the filename). + * + * @param {string} aMessage + * The message to log to the console. + */ +function do_log_info(aMessage) { + print("TEST-INFO | " + _TEST_FILE + " | " + aMessage); +} + +/** + * Copies a test file into the profile folder. + * + * @param {string} aFilename + * The name of the file to copy. + * @param {string} aDestFilename + * The name of the file to copy. + * @param {object} [options] + * @param {object} [options.overwriteExisting] + * Whether to overwrite an existing file. + * @returns {string} path to the copied file. + */ +async function copyToProfile( + aFilename, + aDestFilename, + { overwriteExisting = false } = {} +) { + let curDir = Services.dirsvc.get("CurWorkD", Ci.nsIFile).path; + let srcPath = PathUtils.join(curDir, aFilename); + Assert.ok(await IOUtils.exists(srcPath), "Database file found"); + + // Ensure that our file doesn't exist already. + let destPath = PathUtils.join(PathUtils.profileDir, aDestFilename); + let exists = await IOUtils.exists(destPath); + if (exists) { + if (overwriteExisting) { + await IOUtils.remove(destPath); + } else { + throw new Error("The file should not exist"); + } + } + await IOUtils.copy(srcPath, destPath); + info(`Copied ${aFilename} to ${destPath}`); + return destPath; +} diff --git a/toolkit/components/satchel/test/unit/test_async_expire.js b/toolkit/components/satchel/test/unit/test_async_expire.js new file mode 100644 index 0000000000..5fe796d864 --- /dev/null +++ b/toolkit/components/satchel/test/unit/test_async_expire.js @@ -0,0 +1,134 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +ChromeUtils.defineESModuleGetters(this, { + TestUtils: "resource://testing-common/TestUtils.sys.mjs", +}); + +function promiseExpiration() { + let promise = TestUtils.topicObserved( + "satchel-storage-changed", + (subject, data) => { + return data == "formhistory-expireoldentries"; + } + ); + + // We can't easily fake a "daily idle" event, so for testing purposes form + // history listens for another notification to trigger an immediate + // expiration. + Services.obs.notifyObservers(null, "formhistory-expire-now"); + + return promise; +} + +add_task(async function () { + // ===== test init ===== + let testfile = do_get_file("asyncformhistory_expire.sqlite"); + let profileDir = do_get_profile(); + + // Cleanup from any previous tests or failures. + let dbFile = profileDir.clone(); + dbFile.append("formhistory.sqlite"); + if (dbFile.exists()) { + dbFile.remove(false); + } + + testfile.copyTo(profileDir, "formhistory.sqlite"); + Assert.ok(dbFile.exists()); + + // We're going to clear this at the end, so it better have the default value now. + Assert.ok(!Services.prefs.prefHasUserValue("browser.formfill.expire_days")); + + // Sanity check initial state + Assert.equal(508, await promiseCountEntries(null, null)); + Assert.ok((await promiseCountEntries("name-A", "value-A")) > 0); // lastUsed == distant past + Assert.ok((await promiseCountEntries("name-B", "value-B")) > 0); // lastUsed == distant future + + Assert.equal(CURRENT_SCHEMA, getDBVersion(dbFile)); + + // Add a new entry + Assert.equal(0, await promiseCountEntries("name-C", "value-C")); + await promiseAddEntry("name-C", "value-C"); + Assert.equal(1, await promiseCountEntries("name-C", "value-C")); + + // Update some existing entries to have ages relative to when the test runs. + let now = 1000 * Date.now(); + let updateLastUsed = (results, age) => { + let lastUsed = now - age * 24 * PR_HOURS; + + let changes = []; + for (let result of results) { + changes.push({ op: "update", lastUsed, guid: result.guid }); + } + + return changes; + }; + + let results = await FormHistory.search(["guid"], { lastUsed: 181 }); + await promiseUpdate(updateLastUsed(results, 181)); + + results = await FormHistory.search(["guid"], { lastUsed: 179 }); + await promiseUpdate(updateLastUsed(results, 179)); + + results = await FormHistory.search(["guid"], { lastUsed: 31 }); + await promiseUpdate(updateLastUsed(results, 31)); + + results = await FormHistory.search(["guid"], { lastUsed: 29 }); + await promiseUpdate(updateLastUsed(results, 29)); + + results = await FormHistory.search(["guid"], { lastUsed: 9999 }); + await promiseUpdate(updateLastUsed(results, 11)); + + results = await FormHistory.search(["guid"], { lastUsed: 9 }); + await promiseUpdate(updateLastUsed(results, 9)); + + Assert.ok((await promiseCountEntries("name-A", "value-A")) > 0); + Assert.ok((await promiseCountEntries("181DaysOld", "foo")) > 0); + Assert.ok((await promiseCountEntries("179DaysOld", "foo")) > 0); + Assert.equal(509, await promiseCountEntries(null, null)); + + // 2 entries are expected to expire. + await promiseExpiration(); + + Assert.equal(0, await promiseCountEntries("name-A", "value-A")); + Assert.equal(0, await promiseCountEntries("181DaysOld", "foo")); + Assert.ok((await promiseCountEntries("179DaysOld", "foo")) > 0); + Assert.equal(507, await promiseCountEntries(null, null)); + + // And again. No change expected. + await promiseExpiration(); + + Assert.equal(507, await promiseCountEntries(null, null)); + + // Set formfill pref to 30 days. + Services.prefs.setIntPref("browser.formfill.expire_days", 30); + + Assert.ok((await promiseCountEntries("179DaysOld", "foo")) > 0); + Assert.ok((await promiseCountEntries("bar", "31days")) > 0); + Assert.ok((await promiseCountEntries("bar", "29days")) > 0); + Assert.equal(507, await promiseCountEntries(null, null)); + + await promiseExpiration(); + + Assert.equal(0, await promiseCountEntries("179DaysOld", "foo")); + Assert.equal(0, await promiseCountEntries("bar", "31days")); + Assert.ok((await promiseCountEntries("bar", "29days")) > 0); + Assert.equal(505, await promiseCountEntries(null, null)); + + // Set override pref to 10 days and expire. This expires a large batch of + // entries, and should trigger a VACCUM to reduce file size. + Services.prefs.setIntPref("browser.formfill.expire_days", 10); + + Assert.ok((await promiseCountEntries("bar", "29days")) > 0); + Assert.ok((await promiseCountEntries("9DaysOld", "foo")) > 0); + Assert.equal(505, await promiseCountEntries(null, null)); + + await promiseExpiration(); + + Assert.equal(0, await promiseCountEntries("bar", "29days")); + Assert.ok((await promiseCountEntries("9DaysOld", "foo")) > 0); + Assert.ok((await promiseCountEntries("name-B", "value-B")) > 0); + Assert.ok((await promiseCountEntries("name-C", "value-C")) > 0); + Assert.equal(3, await promiseCountEntries(null, null)); +}); diff --git a/toolkit/components/satchel/test/unit/test_autocomplete.js b/toolkit/components/satchel/test/unit/test_autocomplete.js new file mode 100644 index 0000000000..81557f6630 --- /dev/null +++ b/toolkit/components/satchel/test/unit/test_autocomplete.js @@ -0,0 +1,388 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +var fac; + +var numRecords, timeGroupingSize, now; + +const DEFAULT_EXPIRE_DAYS = 180; + +function padLeft(number, length) { + let str = number + ""; + while (str.length < length) { + str = "0" + str; + } + return str; +} + +function getFormExpiryDays() { + if (Services.prefs.prefHasUserValue("browser.formfill.expire_days")) { + return Services.prefs.getIntPref("browser.formfill.expire_days"); + } + return DEFAULT_EXPIRE_DAYS; +} + +function run_test() { + // ===== test init ===== + let testfile = do_get_file("formhistory_autocomplete.sqlite"); + let profileDir = Services.dirsvc.get("ProfD", Ci.nsIFile); + + // Cleanup from any previous tests or failures. + let destFile = profileDir.clone(); + destFile.append("formhistory.sqlite"); + if (destFile.exists()) { + destFile.remove(false); + } + + testfile.copyTo(profileDir, "formhistory.sqlite"); + + fac = Cc["@mozilla.org/satchel/form-autocomplete;1"].getService( + Ci.nsIFormAutoComplete + ); + + timeGroupingSize = + Services.prefs.getIntPref("browser.formfill.timeGroupingSize") * + 1000 * + 1000; + + run_next_test(); +} + +add_test(function test0() { + let maxTimeGroupings = Services.prefs.getIntPref( + "browser.formfill.maxTimeGroupings" + ); + let bucketSize = Services.prefs.getIntPref("browser.formfill.bucketSize"); + + // ===== Tests with constant timesUsed and varying lastUsed date ===== + // insert 2 records per bucket to check alphabetical sort within + now = 1000 * Date.now(); + numRecords = Math.ceil(maxTimeGroupings / bucketSize) * 2; + + let changes = []; + for (let i = 0; i < numRecords; i += 2) { + let useDate = now - (i / 2) * bucketSize * timeGroupingSize; + + changes.push({ + op: "add", + fieldname: "field1", + value: "value" + padLeft(numRecords - 1 - i, 2), + timesUsed: 1, + firstUsed: useDate, + lastUsed: useDate, + }); + changes.push({ + op: "add", + fieldname: "field1", + value: "value" + padLeft(numRecords - 2 - i, 2), + timesUsed: 1, + firstUsed: useDate, + lastUsed: useDate, + }); + } + + updateFormHistory(changes, run_next_test); +}); + +add_test(function test1() { + do_log_info("Check initial state is as expected"); + + countEntries(null, null, function () { + countEntries("field1", null, function (count) { + Assert.ok(count > 0); + run_next_test(); + }); + }); +}); + +add_test(function test2() { + do_log_info("Check search contains all entries"); + + fac.autoCompleteSearchAsync("field1", "", null, null, null, { + onSearchCompletion(aResults) { + Assert.equal(numRecords, aResults.matchCount); + run_next_test(); + }, + }); +}); + +add_test(function test3() { + do_log_info("Check search result ordering with empty search term"); + + let lastFound = numRecords; + fac.autoCompleteSearchAsync("field1", "", null, null, null, { + onSearchCompletion(aResults) { + for (let i = 0; i < numRecords; i += 2) { + Assert.equal( + parseInt(aResults.getValueAt(i + 1).substr(5), 10), + --lastFound + ); + Assert.equal( + parseInt(aResults.getValueAt(i).substr(5), 10), + --lastFound + ); + } + run_next_test(); + }, + }); +}); + +add_test(function test4() { + do_log_info('Check search result ordering with "v"'); + + let lastFound = numRecords; + fac.autoCompleteSearchAsync("field1", "v", null, null, null, { + onSearchCompletion(aResults) { + for (let i = 0; i < numRecords; i += 2) { + Assert.equal( + parseInt(aResults.getValueAt(i + 1).substr(5), 10), + --lastFound + ); + Assert.equal( + parseInt(aResults.getValueAt(i).substr(5), 10), + --lastFound + ); + } + run_next_test(); + }, + }); +}); + +const timesUsedSamples = 20; + +add_test(function test5() { + do_log_info("Begin tests with constant use dates and varying timesUsed"); + + let changes = []; + for (let i = 0; i < timesUsedSamples; i++) { + let timesUsed = timesUsedSamples - i; + let change = { + op: "add", + fieldname: "field2", + value: "value" + (timesUsedSamples - 1 - i), + timesUsed: timesUsed * timeGroupingSize, + firstUsed: now, + lastUsed: now, + }; + changes.push(change); + } + updateFormHistory(changes, run_next_test); +}); + +add_test(function test6() { + do_log_info("Check search result ordering with empty search term"); + + let lastFound = timesUsedSamples; + fac.autoCompleteSearchAsync("field2", "", null, null, null, { + onSearchCompletion(aResults) { + for (let i = 0; i < timesUsedSamples; i++) { + Assert.equal( + parseInt(aResults.getValueAt(i).substr(5), 10), + --lastFound + ); + } + run_next_test(); + }, + }); +}); + +add_test(function test7() { + do_log_info('Check search result ordering with "v"'); + + let lastFound = timesUsedSamples; + fac.autoCompleteSearchAsync("field2", "v", null, null, null, { + onSearchCompletion(aResults) { + for (let i = 0; i < timesUsedSamples; i++) { + Assert.equal( + parseInt(aResults.getValueAt(i).substr(5), 10), + --lastFound + ); + } + run_next_test(); + }, + }); +}); + +add_test(function test8() { + do_log_info( + 'Check that "senior citizen" entries get a bonus (browser.formfill.agedBonus)' + ); + + let agedDate = + 1000 * (Date.now() - getFormExpiryDays() * 24 * 60 * 60 * 1000); + + let changes = []; + changes.push({ + op: "add", + fieldname: "field3", + value: "old but not senior", + timesUsed: 100, + firstUsed: agedDate + 60 * 1000 * 1000, + lastUsed: now, + }); + changes.push({ + op: "add", + fieldname: "field3", + value: "senior citizen", + timesUsed: 100, + firstUsed: agedDate - 60 * 1000 * 1000, + lastUsed: now, + }); + updateFormHistory(changes, run_next_test); +}); + +add_test(function test9() { + fac.autoCompleteSearchAsync("field3", "", null, null, null, { + onSearchCompletion(aResults) { + Assert.equal(aResults.getValueAt(0), "senior citizen"); + Assert.equal(aResults.getValueAt(1), "old but not senior"); + run_next_test(); + }, + }); +}); + +add_test(function test10() { + do_log_info("Check entries that are really old or in the future"); + + let changes = []; + changes.push({ + op: "add", + fieldname: "field4", + value: "date of 0", + timesUsed: 1, + firstUsed: 0, + lastUsed: 0, + }); + changes.push({ + op: "add", + fieldname: "field4", + value: "in the future 1", + timesUsed: 1, + firstUsed: 0, + lastUsed: now * 2, + }); + changes.push({ + op: "add", + fieldname: "field4", + value: "in the future 2", + timesUsed: 1, + firstUsed: now * 2, + lastUsed: now * 2, + }); + updateFormHistory(changes, run_next_test); +}); + +add_test(function test11() { + fac.autoCompleteSearchAsync("field4", "", null, null, null, { + onSearchCompletion(aResults) { + Assert.equal(aResults.matchCount, 3); + run_next_test(); + }, + }); +}); + +var syncValues = ["sync1", "sync1a", "sync2", "sync3"]; + +add_test(function test12() { + do_log_info("Check old synchronous api"); + + let changes = []; + for (let value of syncValues) { + changes.push({ op: "add", fieldname: "field5", value }); + } + updateFormHistory(changes, run_next_test); +}); + +add_test(function test_token_limit_DB() { + function test_token_limit_previousResult(previousResult) { + do_log_info( + "Check that the number of tokens used in a search is not capped to " + + "MAX_SEARCH_TOKENS when using a previousResult" + ); + // This provide more accuracy since performance is less of an issue. + // Search for a string where the first 10 tokens match the previous value but the 11th does not + // when re-using a previous result. + fac.autoCompleteSearchAsync( + "field_token_cap", + "a b c d e f g h i j .", + null, + previousResult, + null, + { + onSearchCompletion(aResults) { + Assert.equal( + aResults.matchCount, + 0, + "All search tokens should be used with previous results" + ); + run_next_test(); + }, + } + ); + } + + do_log_info( + "Check that the number of tokens used in a search is capped to MAX_SEARCH_TOKENS " + + "for performance when querying the DB" + ); + let changes = []; + changes.push({ + op: "add", + fieldname: "field_token_cap", + // value with 15 unique tokens + value: "a b c d e f g h i j k l m n o", + timesUsed: 1, + firstUsed: 0, + lastUsed: 0, + }); + updateFormHistory(changes, () => { + // Search for a string where the first 10 tokens match the value above but the 11th does not + // (which would prevent the result from being returned if the 11th term was used). + fac.autoCompleteSearchAsync( + "field_token_cap", + "a b c d e f g h i j .", + null, + null, + null, + { + onSearchCompletion(aResults) { + Assert.equal( + aResults.matchCount, + 1, + "Only the first MAX_SEARCH_TOKENS tokens " + + "should be used for DB queries" + ); + test_token_limit_previousResult(aResults); + }, + } + ); + }); +}); + +add_test(async function can_search_escape_marker() { + await promiseUpdate({ + op: "add", + fieldname: "field1", + value: "/* Further reading */ test", + timesUsed: 1, + firstUsed: now, + lastUsed: now, + }); + + fac.autoCompleteSearchAsync( + "field1", + "/* Further reading */ t", + null, + null, + null, + { + onSearchCompletion(aResults) { + Assert.equal(1, aResults.matchCount); + run_next_test(); + }, + } + ); +}); diff --git a/toolkit/components/satchel/test/unit/test_db_corrupt.js b/toolkit/components/satchel/test/unit/test_db_corrupt.js new file mode 100644 index 0000000000..b53b5cd6d0 --- /dev/null +++ b/toolkit/components/satchel/test/unit/test_db_corrupt.js @@ -0,0 +1,80 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +var bakFile; + +function run_test() { + // ===== test init ===== + let testfile = do_get_file("formhistory_CORRUPT.sqlite"); + let profileDir = Services.dirsvc.get("ProfD", Ci.nsIFile); + + // Cleanup from any previous tests or failures. + let destFile = profileDir.clone(); + destFile.append("formhistory.sqlite"); + if (destFile.exists()) { + destFile.remove(false); + } + + bakFile = profileDir.clone(); + bakFile.append("formhistory.sqlite.corrupt"); + if (bakFile.exists()) { + bakFile.remove(false); + } + + testfile.copyTo(profileDir, "formhistory.sqlite"); + run_next_test(); +} + +add_test(function test_corruptFormHistoryDB_lazyCorruptInit1() { + do_log_info("ensure FormHistory backs up a corrupt DB on initialization."); + + // DB init is done lazily so the DB shouldn't be created yet. + Assert.ok(!bakFile.exists()); + // Doing any request to the DB should create it. + countEntries(null, null, run_next_test); +}); + +add_test(function test_corruptFormHistoryDB_lazyCorruptInit2() { + Assert.ok(bakFile.exists()); + bakFile.remove(false); + run_next_test(); +}); + +add_test(function test_corruptFormHistoryDB_emptyInit() { + do_log_info( + "test that FormHistory initializes an empty DB in place of corrupt DB." + ); + + (async function () { + let count = await FormHistory.count({}); + Assert.equal(count, 0); + count = await FormHistory.count({ fieldname: "name-A", value: "value-A" }); + Assert.equal(count, 0); + run_next_test(); + })().catch(error => { + do_throw("DB initialized after reading a corrupt DB file is not empty."); + }); +}); + +add_test(function test_corruptFormHistoryDB_addEntry() { + do_log_info("test adding an entry to the empty DB."); + + updateEntry("add", "name-A", "value-A", function () { + countEntries("name-A", "value-A", function (count) { + Assert.ok(count == 1); + run_next_test(); + }); + }); +}); + +add_test(function test_corruptFormHistoryDB_removeEntry() { + do_log_info("test removing an entry to the empty DB."); + + updateEntry("remove", "name-A", "value-A", function () { + countEntries("name-A", "value-A", function (count) { + Assert.ok(count == 0); + run_next_test(); + }); + }); +}); diff --git a/toolkit/components/satchel/test/unit/test_db_update_v4.js b/toolkit/components/satchel/test/unit/test_db_update_v4.js new file mode 100644 index 0000000000..e55890a830 --- /dev/null +++ b/toolkit/components/satchel/test/unit/test_db_update_v4.js @@ -0,0 +1,52 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +add_task(async function () { + let testnum = 0; + + try { + // ===== test init ===== + let testfile = do_get_file("formhistory_v3.sqlite"); + let profileDir = Services.dirsvc.get("ProfD", Ci.nsIFile); + + // Cleanup from any previous tests or failures. + let destFile = profileDir.clone(); + destFile.append("formhistory.sqlite"); + if (destFile.exists()) { + destFile.remove(false); + } + + testfile.copyTo(profileDir, "formhistory.sqlite"); + Assert.equal(3, getDBVersion(testfile)); + + Assert.ok(destFile.exists()); + + // ===== 1 ===== + testnum++; + + destFile = profileDir.clone(); + destFile.append("formhistory.sqlite"); + let dbConnection = Services.storage.openUnsharedDatabase(destFile); + + // Do something that will cause FormHistory to access and upgrade the + // database + await FormHistory.count({}); + + // check for upgraded schema. + Assert.equal(CURRENT_SCHEMA, getDBVersion(destFile)); + + // Check that the index was added + Assert.ok(dbConnection.tableExists("moz_deleted_formhistory")); + dbConnection.close(); + + // check for upgraded schema. + Assert.equal(CURRENT_SCHEMA, getDBVersion(destFile)); + + // check that an entry still exists + let num = await promiseCountEntries("name-A", "value-A"); + Assert.ok(num > 0); + } catch (e) { + throw new Error(`FAILED in test #${testnum} -- ${e}`); + } +}); diff --git a/toolkit/components/satchel/test/unit/test_db_update_v4b.js b/toolkit/components/satchel/test/unit/test_db_update_v4b.js new file mode 100644 index 0000000000..83fdf4a072 --- /dev/null +++ b/toolkit/components/satchel/test/unit/test_db_update_v4b.js @@ -0,0 +1,46 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +add_task(async function () { + let testnum = 0; + + try { + // ===== test init ===== + let testfile = do_get_file("formhistory_v3v4.sqlite"); + let profileDir = Services.dirsvc.get("ProfD", Ci.nsIFile); + + // Cleanup from any previous tests or failures. + let destFile = profileDir.clone(); + destFile.append("formhistory.sqlite"); + if (destFile.exists()) { + destFile.remove(false); + } + + testfile.copyTo(profileDir, "formhistory.sqlite"); + Assert.equal(3, getDBVersion(testfile)); + + // ===== 1 ===== + testnum++; + + destFile = profileDir.clone(); + destFile.append("formhistory.sqlite"); + let dbConnection = Services.storage.openUnsharedDatabase(destFile); + + // Do something that will cause FormHistory to access and upgrade the + // database + await FormHistory.count({}); + + // check for upgraded schema. + Assert.equal(CURRENT_SCHEMA, getDBVersion(destFile)); + + // Check that the index was added + Assert.ok(dbConnection.tableExists("moz_deleted_formhistory")); + dbConnection.close(); + + // check that an entry still exists + Assert.ok((await promiseCountEntries("name-A", "value-A")) > 0); + } catch (e) { + throw new Error(`FAILED in test #${testnum} -- ${e}`); + } +}); diff --git a/toolkit/components/satchel/test/unit/test_db_update_v5.js b/toolkit/components/satchel/test/unit/test_db_update_v5.js new file mode 100644 index 0000000000..f546d4a0f5 --- /dev/null +++ b/toolkit/components/satchel/test/unit/test_db_update_v5.js @@ -0,0 +1,29 @@ +/* Any copyright is dedicated to the Public Domain. + * https://creativecommons.org/publicdomain/zero/1.0/ */ + +add_task(async function () { + let destPath = await copyToProfile( + "formhistory_v3.sqlite", + "formhistory.sqlite" + ); + Assert.equal(3, await getDBSchemaVersion(destPath)); + + // Do something that will cause FormHistory to access and upgrade the + // database + await FormHistory.count({}); + + // check for upgraded schema. + Assert.equal(CURRENT_SCHEMA, await getDBSchemaVersion(destPath)); + + // Check that the source tables were added. + let db = await Sqlite.openConnection({ path: destPath }); + try { + Assert.ok(db.tableExists("moz_sources")); + Assert.ok(db.tableExists("moz_sources_to_history")); + } finally { + await db.close(); + } + // check that an entry still exists + let num = await promiseCountEntries("name-A", "value-A"); + Assert.ok(num > 0); +}); diff --git a/toolkit/components/satchel/test/unit/test_db_update_v999a.js b/toolkit/components/satchel/test/unit/test_db_update_v999a.js new file mode 100644 index 0000000000..5bb151e33b --- /dev/null +++ b/toolkit/components/satchel/test/unit/test_db_update_v999a.js @@ -0,0 +1,56 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/* + * This test uses a formhistory.sqlite with schema version set to 999 (a + * future version). This exercies the code that allows using a future schema + * version as long as the expected columns are present. + * + * Part A tests this when the columns do match, so the DB is used. + * Part B tests this when the columns do *not* match, so the DB is reset. + */ + +add_task(async function () { + let testnum = 0; + + try { + // ===== test init ===== + let testfile = do_get_file("formhistory_v999a.sqlite"); + let profileDir = Services.dirsvc.get("ProfD", Ci.nsIFile); + + // Cleanup from any previous tests or failures. + let destFile = profileDir.clone(); + destFile.append("formhistory.sqlite"); + if (destFile.exists()) { + destFile.remove(false); + } + + testfile.copyTo(profileDir, "formhistory.sqlite"); + Assert.equal(999, getDBVersion(testfile)); + + // ===== 1 ===== + testnum++; + // Check for expected contents. + Assert.ok((await promiseCountEntries(null, null)) > 0); + Assert.equal(1, await promiseCountEntries("name-A", "value-A")); + Assert.equal(1, await promiseCountEntries("name-B", "value-B")); + Assert.equal(1, await promiseCountEntries("name-C", "value-C1")); + Assert.equal(1, await promiseCountEntries("name-C", "value-C2")); + Assert.equal(1, await promiseCountEntries("name-E", "value-E")); + + // check for downgraded schema. + Assert.equal(CURRENT_SCHEMA, getDBVersion(destFile)); + + // ===== 2 ===== + testnum++; + // Exercise adding and removing a name/value pair + Assert.equal(0, await promiseCountEntries("name-D", "value-D")); + await promiseUpdateEntry("add", "name-D", "value-D"); + Assert.equal(1, await promiseCountEntries("name-D", "value-D")); + await promiseUpdateEntry("remove", "name-D", "value-D"); + Assert.equal(0, await promiseCountEntries("name-D", "value-D")); + } catch (e) { + throw new Error(`FAILED in test #${testnum} -- ${e}`); + } +}); diff --git a/toolkit/components/satchel/test/unit/test_db_update_v999b.js b/toolkit/components/satchel/test/unit/test_db_update_v999b.js new file mode 100644 index 0000000000..d33996c204 --- /dev/null +++ b/toolkit/components/satchel/test/unit/test_db_update_v999b.js @@ -0,0 +1,74 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/* + * This test uses a formhistory.sqlite with schema version set to 999 (a + * future version). This exercies the code that allows using a future schema + * version as long as the expected columns are present. + * + * Part A tests this when the columns do match, so the DB is used. + * Part B tests this when the columns do *not* match, so the DB is reset. + */ + +add_task(async function () { + let testnum = 0; + + try { + // ===== test init ===== + let testfile = do_get_file("formhistory_v999b.sqlite"); + let profileDir = Services.dirsvc.get("ProfD", Ci.nsIFile); + + // Cleanup from any previous tests or failures. + let destFile = profileDir.clone(); + destFile.append("formhistory.sqlite"); + if (destFile.exists()) { + destFile.remove(false); + } + + let bakFile = profileDir.clone(); + bakFile.append("formhistory.sqlite.corrupt"); + if (bakFile.exists()) { + bakFile.remove(false); + } + + testfile.copyTo(profileDir, "formhistory.sqlite"); + Assert.equal(999, getDBVersion(destFile)); + + // ===== 1 ===== + testnum++; + + // Open the DB, ensure that a backup of the corrupt DB is made. + // DB init is done lazily so the DB shouldn't be created yet. + Assert.ok(!bakFile.exists()); + // Doing any request to the DB should create it. + await promiseCountEntries("", ""); + + Assert.ok(bakFile.exists()); + bakFile.remove(false); + + // ===== 2 ===== + testnum++; + // File should be empty + Assert.ok(!(await promiseCountEntries(null, null))); + Assert.equal(0, await promiseCountEntries("name-A", "value-A")); + // check for current schema. + Assert.equal(CURRENT_SCHEMA, getDBVersion(destFile)); + + // ===== 3 ===== + testnum++; + // Try adding an entry + await promiseUpdateEntry("add", "name-A", "value-A"); + Assert.equal(1, await promiseCountEntries(null, null)); + Assert.equal(1, await promiseCountEntries("name-A", "value-A")); + + // ===== 4 ===== + testnum++; + // Try removing an entry + await promiseUpdateEntry("remove", "name-A", "value-A"); + Assert.equal(0, await promiseCountEntries(null, null)); + Assert.equal(0, await promiseCountEntries("name-A", "value-A")); + } catch (e) { + throw new Error(`FAILED in test #${testnum} -- ${e}`); + } +}); diff --git a/toolkit/components/satchel/test/unit/test_history_api.js b/toolkit/components/satchel/test/unit/test_history_api.js new file mode 100644 index 0000000000..e02d4e54f6 --- /dev/null +++ b/toolkit/components/satchel/test/unit/test_history_api.js @@ -0,0 +1,518 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +var testnum = 0; +var dbConnection; // used for deleted table tests + +const { PromiseUtils } = ChromeUtils.importESModule( + "resource://gre/modules/PromiseUtils.sys.mjs" +); + +function countDeletedEntries(expected) { + return new Promise((resolve, reject) => { + let stmt = dbConnection.createAsyncStatement( + "SELECT COUNT(*) AS numEntries FROM moz_deleted_formhistory" + ); + stmt.executeAsync({ + handleResult(resultSet) { + Assert.equal( + expected, + resultSet.getNextRow().getResultByName("numEntries") + ); + resolve(); + }, + handleError(error) { + do_throw("Error occurred counting deleted entries: " + error); + reject(); + }, + handleCompletion() { + stmt.finalize(); + }, + }); + }); +} + +function checkTimeDeleted(guid, checkFunction) { + return new Promise((resolve, reject) => { + let stmt = dbConnection.createAsyncStatement( + "SELECT timeDeleted FROM moz_deleted_formhistory WHERE guid = :guid" + ); + stmt.params.guid = guid; + stmt.executeAsync({ + handleResult(resultSet) { + checkFunction(resultSet.getNextRow().getResultByName("timeDeleted")); + resolve(); + }, + handleError(error) { + do_throw("Error occurred getting deleted entries: " + error); + reject(); + }, + handleCompletion() { + stmt.finalize(); + }, + }); + }); +} + +function promiseUpdateEntry(op, name, value) { + let change = { op }; + if (name !== null) { + change.fieldname = name; + } + if (value !== null) { + change.value = value; + } + return promiseUpdate(change); +} + +add_task(async function () { + let oldSupportsDeletedTable = FormHistory._supportsDeletedTable; + FormHistory._supportsDeletedTable = true; + + try { + // ===== test init ===== + let testfile = do_get_file("formhistory_apitest.sqlite"); + let profileDir = Services.dirsvc.get("ProfD", Ci.nsIFile); + + // Cleanup from any previous tests or failures. + let destFile = profileDir.clone(); + destFile.append("formhistory.sqlite"); + if (destFile.exists()) { + destFile.remove(false); + } + + testfile.copyTo(profileDir, "formhistory.sqlite"); + + function checkExists(num) { + Assert.ok(num > 0); + } + function checkNotExists(num) { + Assert.ok(num == 0); + } + + // ===== 1 ===== + // Check initial state is as expected + testnum++; + await promiseCountEntries("name-A", null, checkExists); + await promiseCountEntries("name-B", null, checkExists); + await promiseCountEntries("name-C", null, checkExists); + await promiseCountEntries("name-D", null, checkExists); + await promiseCountEntries("name-A", "value-A", checkExists); + await promiseCountEntries("name-B", "value-B1", checkExists); + await promiseCountEntries("name-B", "value-B2", checkExists); + await promiseCountEntries("name-C", "value-C", checkExists); + await promiseCountEntries("name-D", "value-D", checkExists); + // time-A/B/C/D checked below. + + // Delete anything from the deleted table + let dbFile = Services.dirsvc.get("ProfD", Ci.nsIFile).clone(); + dbFile.append("formhistory.sqlite"); + dbConnection = Services.storage.openUnsharedDatabase(dbFile); + + let deferred = PromiseUtils.defer(); + + let stmt = dbConnection.createAsyncStatement( + "DELETE FROM moz_deleted_formhistory" + ); + stmt.executeAsync({ + handleResult(resultSet) {}, + handleError(error) { + do_throw("Error occurred counting deleted all entries: " + error); + }, + handleCompletion() { + stmt.finalize(); + deferred.resolve(); + }, + }); + await deferred.promise; + + // ===== 2 ===== + // Test looking for nonexistent / bogus data. + testnum++; + await promiseCountEntries("blah", null, checkNotExists); + await promiseCountEntries("", null, checkNotExists); + await promiseCountEntries("name-A", "blah", checkNotExists); + await promiseCountEntries("name-A", "", checkNotExists); + await promiseCountEntries("name-A", null, checkExists); + await promiseCountEntries("blah", "value-A", checkNotExists); + await promiseCountEntries("", "value-A", checkNotExists); + await promiseCountEntries(null, "value-A", checkExists); + + // Cannot use promiseCountEntries when name and value are null + // because it treats null values as not set + // and here a search should be done explicity for null. + let count = await FormHistory.count({ fieldname: null, value: null }); + checkNotExists(count); + + // ===== 3 ===== + // Test removeEntriesForName with a single matching value + testnum++; + await promiseUpdateEntry("remove", "name-A", null); + + await promiseCountEntries("name-A", "value-A", checkNotExists); + await promiseCountEntries("name-B", "value-B1", checkExists); + await promiseCountEntries("name-B", "value-B2", checkExists); + await promiseCountEntries("name-C", "value-C", checkExists); + await promiseCountEntries("name-D", "value-D", checkExists); + await countDeletedEntries(1); + + // ===== 4 ===== + // Test removeEntriesForName with multiple matching values + testnum++; + await promiseUpdateEntry("remove", "name-B", null); + + await promiseCountEntries("name-A", "value-A", checkNotExists); + await promiseCountEntries("name-B", "value-B1", checkNotExists); + await promiseCountEntries("name-B", "value-B2", checkNotExists); + await promiseCountEntries("name-C", "value-C", checkExists); + await promiseCountEntries("name-D", "value-D", checkExists); + await countDeletedEntries(3); + + // ===== 5 ===== + // Test removing by time range (single entry, not surrounding entries) + testnum++; + await promiseCountEntries("time-A", null, checkExists); // firstUsed=1000, lastUsed=1000 + await promiseCountEntries("time-B", null, checkExists); // firstUsed=1000, lastUsed=1099 + await promiseCountEntries("time-C", null, checkExists); // firstUsed=1099, lastUsed=1099 + await promiseCountEntries("time-D", null, checkExists); // firstUsed=2001, lastUsed=2001 + await promiseUpdate({ + op: "remove", + firstUsedStart: 1050, + firstUsedEnd: 2000, + }); + + await promiseCountEntries("time-A", null, checkExists); + await promiseCountEntries("time-B", null, checkExists); + await promiseCountEntries("time-C", null, checkNotExists); + await promiseCountEntries("time-D", null, checkExists); + await countDeletedEntries(4); + + // ===== 6 ===== + // Test removing by time range (multiple entries) + testnum++; + await promiseUpdate({ + op: "remove", + firstUsedStart: 1000, + firstUsedEnd: 2000, + }); + + await promiseCountEntries("time-A", null, checkNotExists); + await promiseCountEntries("time-B", null, checkNotExists); + await promiseCountEntries("time-C", null, checkNotExists); + await promiseCountEntries("time-D", null, checkExists); + await countDeletedEntries(6); + + // ===== 7 ===== + // test removeAllEntries + testnum++; + await promiseUpdateEntry("remove", null, null); + + await promiseCountEntries("name-C", null, checkNotExists); + await promiseCountEntries("name-D", null, checkNotExists); + await promiseCountEntries("name-C", "value-C", checkNotExists); + await promiseCountEntries("name-D", "value-D", checkNotExists); + + await promiseCountEntries(null, null, checkNotExists); + await countDeletedEntries(6); + + // ===== 8 ===== + // Add a single entry back + testnum++; + await promiseUpdateEntry("add", "newname-A", "newvalue-A"); + await promiseCountEntries("newname-A", "newvalue-A", checkExists); + + // ===== 9 ===== + // Remove the single entry + testnum++; + await promiseUpdateEntry("remove", "newname-A", "newvalue-A"); + await promiseCountEntries("newname-A", "newvalue-A", checkNotExists); + + // ===== 10 ===== + // Add a single entry + testnum++; + await promiseUpdateEntry("add", "field1", "value1"); + await promiseCountEntries("field1", "value1", checkExists); + + let processFirstResult = function processResults(results) { + // Only handle the first result + if (results.length) { + let result = results[0]; + return [ + result.timesUsed, + result.firstUsed, + result.lastUsed, + result.guid, + ]; + } + return undefined; + }; + + let results = await FormHistory.search( + ["timesUsed", "firstUsed", "lastUsed"], + { fieldname: "field1", value: "value1" } + ); + let [timesUsed, firstUsed, lastUsed] = processFirstResult(results); + Assert.equal(1, timesUsed); + Assert.ok(firstUsed > 0); + Assert.ok(lastUsed > 0); + await promiseCountEntries(null, null, num => Assert.equal(num, 1)); + + // ===== 11 ===== + // Add another single entry + testnum++; + await promiseUpdateEntry("add", "field1", "value1b"); + await promiseCountEntries("field1", "value1", checkExists); + await promiseCountEntries("field1", "value1b", checkExists); + await promiseCountEntries(null, null, num => Assert.equal(num, 2)); + + // ===== 12 ===== + // Update a single entry + testnum++; + + results = await FormHistory.search(["guid"], { + fieldname: "field1", + value: "value1", + }); + let guid = processFirstResult(results)[3]; + + await promiseUpdate({ op: "update", guid, value: "modifiedValue" }); + await promiseCountEntries("field1", "modifiedValue", checkExists); + await promiseCountEntries("field1", "value1", checkNotExists); + await promiseCountEntries("field1", "value1b", checkExists); + await promiseCountEntries(null, null, num => Assert.equal(num, 2)); + + // ===== 13 ===== + // Add a single entry with times + testnum++; + await promiseUpdate({ + op: "add", + fieldname: "field2", + value: "value2", + timesUsed: 20, + firstUsed: 100, + lastUsed: 500, + }); + + results = await FormHistory.search(["timesUsed", "firstUsed", "lastUsed"], { + fieldname: "field2", + value: "value2", + }); + [timesUsed, firstUsed, lastUsed] = processFirstResult(results); + + Assert.equal(20, timesUsed); + Assert.equal(100, firstUsed); + Assert.equal(500, lastUsed); + await promiseCountEntries(null, null, num => Assert.equal(num, 3)); + + // ===== 14 ===== + // Bump an entry, which updates its lastUsed field + testnum++; + await promiseUpdate({ + op: "bump", + fieldname: "field2", + value: "value2", + timesUsed: 20, + firstUsed: 100, + lastUsed: 500, + }); + results = await FormHistory.search(["timesUsed", "firstUsed", "lastUsed"], { + fieldname: "field2", + value: "value2", + }); + [timesUsed, firstUsed, lastUsed] = processFirstResult(results); + Assert.equal(21, timesUsed); + Assert.equal(100, firstUsed); + Assert.ok(lastUsed > 500); + await promiseCountEntries(null, null, num => Assert.equal(num, 3)); + + // ===== 15 ===== + // Bump an entry that does not exist + testnum++; + await promiseUpdate({ + op: "bump", + fieldname: "field3", + value: "value3", + timesUsed: 10, + firstUsed: 50, + lastUsed: 400, + }); + results = await FormHistory.search(["timesUsed", "firstUsed", "lastUsed"], { + fieldname: "field3", + value: "value3", + }); + [timesUsed, firstUsed, lastUsed] = processFirstResult(results); + Assert.equal(10, timesUsed); + Assert.equal(50, firstUsed); + Assert.equal(400, lastUsed); + await promiseCountEntries(null, null, num => Assert.equal(num, 4)); + + // ===== 16 ===== + // Bump an entry with a guid + testnum++; + results = await FormHistory.search(["guid"], { + fieldname: "field3", + value: "value3", + }); + guid = processFirstResult(results)[3]; + await promiseUpdate({ + op: "bump", + guid, + timesUsed: 20, + firstUsed: 55, + lastUsed: 400, + }); + results = await FormHistory.search(["timesUsed", "firstUsed", "lastUsed"], { + fieldname: "field3", + value: "value3", + }); + [timesUsed, firstUsed, lastUsed] = processFirstResult(results); + Assert.equal(11, timesUsed); + Assert.equal(50, firstUsed); + Assert.ok(lastUsed > 400); + await promiseCountEntries(null, null, num => Assert.equal(num, 4)); + + // ===== 17 ===== + // Remove an entry + testnum++; + await countDeletedEntries(7); + + results = await FormHistory.search(["guid"], { + fieldname: "field1", + value: "value1b", + }); + guid = processFirstResult(results)[3]; + + await promiseUpdate({ op: "remove", guid }); + await promiseCountEntries("field1", "modifiedValue", checkExists); + await promiseCountEntries("field1", "value1b", checkNotExists); + await promiseCountEntries(null, null, num => Assert.equal(num, 3)); + + await countDeletedEntries(8); + await checkTimeDeleted(guid, timeDeleted => Assert.ok(timeDeleted > 10000)); + + // ===== 18 ===== + // Add yet another single entry + testnum++; + await promiseUpdate({ + op: "add", + fieldname: "field4", + value: "value4", + timesUsed: 5, + firstUsed: 230, + lastUsed: 600, + }); + await promiseCountEntries(null, null, num => Assert.equal(num, 4)); + + // ===== 19 ===== + // Remove an entry by time + testnum++; + await promiseUpdate({ + op: "remove", + firstUsedStart: 60, + firstUsedEnd: 250, + }); + await promiseCountEntries("field1", "modifiedValue", checkExists); + await promiseCountEntries("field2", "value2", checkNotExists); + await promiseCountEntries("field3", "value3", checkExists); + await promiseCountEntries("field4", "value4", checkNotExists); + await promiseCountEntries(null, null, num => Assert.equal(num, 2)); + await countDeletedEntries(10); + + // ===== 20 ===== + // Bump multiple existing entries at once + testnum++; + + await promiseUpdate([ + { + op: "add", + fieldname: "field5", + value: "value5", + timesUsed: 5, + firstUsed: 230, + lastUsed: 600, + }, + { + op: "add", + fieldname: "field6", + value: "value6", + timesUsed: 12, + firstUsed: 430, + lastUsed: 700, + }, + ]); + await promiseCountEntries(null, null, num => Assert.equal(num, 4)); + + await promiseUpdate([ + { op: "bump", fieldname: "field5", value: "value5" }, + { op: "bump", fieldname: "field6", value: "value6" }, + ]); + results = await FormHistory.search( + ["fieldname", "timesUsed", "firstUsed", "lastUsed"], + {} + ); + + Assert.equal(6, results[2].timesUsed); + Assert.equal(13, results[3].timesUsed); + Assert.equal(230, results[2].firstUsed); + Assert.equal(430, results[3].firstUsed); + Assert.ok(results[2].lastUsed > 600); + Assert.ok(results[3].lastUsed > 700); + + await promiseCountEntries(null, null, num => Assert.equal(num, 4)); + + // ===== 21 ===== + // Check update fails if form history is disabled and the operation is not a + // pure removal. + testnum++; + Services.prefs.setBoolPref("browser.formfill.enable", false); + + // Cannot use arrow functions, see bug 1237961. + await Assert.rejects( + promiseUpdate({ op: "bump", fieldname: "field5", value: "value5" }), + /Form history is disabled, only remove operations are allowed/, + "bumping when form history is disabled should fail" + ); + await Assert.rejects( + promiseUpdate({ op: "add", fieldname: "field5", value: "value5" }), + /Form history is disabled, only remove operations are allowed/, + "Adding when form history is disabled should fail" + ); + await Assert.rejects( + promiseUpdate([ + { op: "update", fieldname: "field5", value: "value5" }, + { op: "remove", fieldname: "field5", value: "value5" }, + ]), + /Form history is disabled, only remove operations are allowed/, + "mixed operations when form history is disabled should fail" + ); + await Assert.rejects( + promiseUpdate([ + null, + undefined, + "", + 1, + {}, + { op: "remove", fieldname: "field5", value: "value5" }, + ]), + /Form history is disabled, only remove operations are allowed/, + "Invalid entries when form history is disabled should fail" + ); + + // Remove should work though. + await promiseUpdate([ + { op: "remove", fieldname: "field5", value: null }, + { op: "remove", fieldname: null, value: null }, + ]); + Services.prefs.clearUserPref("browser.formfill.enable"); + } catch (e) { + throw new Error(`FAILED in test #${testnum} -- ${e}`); + } finally { + FormHistory._supportsDeletedTable = oldSupportsDeletedTable; + dbConnection.asyncClose(do_test_finished); + } +}); + +function run_test() { + return run_next_test(); +} diff --git a/toolkit/components/satchel/test/unit/test_history_sources.js b/toolkit/components/satchel/test/unit/test_history_sources.js new file mode 100644 index 0000000000..a598a457be --- /dev/null +++ b/toolkit/components/satchel/test/unit/test_history_sources.js @@ -0,0 +1,88 @@ +/* Any copyright is dedicated to the Public Domain. + * https://creativecommons.org/publicdomain/zero/1.0/ */ + +// Tests source usage in the form history API. + +add_task(async function () { + // Shorthands to improve test readability. + const count = FormHistoryTestUtils.count.bind(FormHistoryTestUtils); + async function search(fieldname, filters) { + let results1 = (await FormHistoryTestUtils.search(fieldname, filters)).map( + f => f.value + ); + // Check autocomplete returns the same value. + let results2 = ( + await FormHistoryTestUtils.autocomplete("va", fieldname, filters) + ).map(f => f.text); + Assert.deepEqual(results1, results2); + return results1; + } + + info("Sanity checks"); + Assert.equal(await count("field-A"), 0); + Assert.equal(await count("field-B"), 0); + await FormHistoryTestUtils.add("field-A", [{ value: "value-A" }]); + Assert.equal(await FormHistoryTestUtils.count("field-A"), 1); + Assert.deepEqual(await search("field-A"), ["value-A"]); + await FormHistoryTestUtils.remove("field-A", [{ value: "value-A" }]); + Assert.equal(await count("field-A"), 0); + + info("Test source for field-A"); + await FormHistoryTestUtils.add("field-A", [ + { value: "value-A", source: "test" }, + ]); + Assert.equal(await count("field-A"), 1); + Assert.deepEqual(await search("field-A"), ["value-A"]); + Assert.equal(await count("field-A", { source: "test" }), 1); + Assert.deepEqual(await search("field-A", { source: "test" }), ["value-A"]); + Assert.equal(await count("field-A", { source: "test2" }), 0); + Assert.deepEqual(await search("field-A", { source: "test2" }), []); + + info("Test source for field-B"); + await FormHistoryTestUtils.add("field-B", [ + { value: "value-B", source: "test" }, + ]); + Assert.equal(await count("field-B", { source: "test" }), 1); + Assert.equal(await count("field-B", { source: "test2" }), 0); + + info("Remove source"); + await FormHistoryTestUtils.add("field-B", [ + { value: "value-B", source: "test2" }, + ]); + Assert.equal(await count("field-B", { source: "test2" }), 1); + Assert.deepEqual(await search("field-B", { source: "test2" }), ["value-B"]); + await FormHistoryTestUtils.remove("field-B", [{ source: "test2" }]); + Assert.equal(await count("field-B", { source: "test2" }), 0); + Assert.deepEqual(await search("field-B", { source: "test2" }), []); + Assert.equal(await count("field-A"), 1); + Assert.deepEqual(await search("field-A"), ["value-A"]); + Assert.deepEqual(await search("field-A", { source: "test" }), ["value-A"]); + info("The other source should be untouched"); + Assert.equal(await count("field-B", { source: "test" }), 1); + Assert.deepEqual(await search("field-B", { source: "test" }), ["value-B"]); + Assert.equal(await count("field-B"), 1); + Assert.deepEqual(await search("field-B"), ["value-B"]); + + info("Clear field-A"); + await FormHistoryTestUtils.clear("field-A"); + Assert.equal(await count("field-A", { source: "test" }), 0); + Assert.equal(await count("field-A", { source: "test2" }), 0); + Assert.equal(await count("field-A"), 0); + Assert.equal(await count("field-B", { source: "test" }), 1); + Assert.equal(await count("field-B", { source: "test2" }), 0); + Assert.equal(await count("field-B"), 1); + + info("Clear All"); + await FormHistoryTestUtils.clear(); + Assert.equal(await count("field-B", { source: "test" }), 0); + Assert.equal(await count("field-B"), 0); + Assert.deepEqual(await search("field-A"), []); + Assert.deepEqual(await search("field-A", { source: "test" }), []); + Assert.deepEqual(await search("field-B"), []); + Assert.deepEqual(await search("field-B", { source: "test" }), []); + + info("Check there's no orphan sources"); + let db = await FormHistory.db; + let rows = await db.execute(`SELECT count(*) FROM moz_sources`); + Assert.equal(rows[0].getResultByIndex(0), 0, "There should be no orphans"); +}); diff --git a/toolkit/components/satchel/test/unit/test_notify.js b/toolkit/components/satchel/test/unit/test_notify.js new file mode 100644 index 0000000000..94be56f3f5 --- /dev/null +++ b/toolkit/components/satchel/test/unit/test_notify.js @@ -0,0 +1,171 @@ +/* + * Test suite for satchel notifications + * + * Tests notifications dispatched when modifying form history. + * + */ + +ChromeUtils.defineESModuleGetters(this, { + Preferences: "resource://gre/modules/Preferences.sys.mjs", + setTimeout: "resource://gre/modules/Timer.sys.mjs", +}); + +const TestObserver = { + observed: [], + QueryInterface: ChromeUtils.generateQI([ + "nsIObserver", + "nsISupportsWeakReference", + ]), + observe(subject, topic, data) { + if (subject instanceof Ci.nsISupportsString) { + subject = subject.toString(); + } + this.observed.push({ subject, topic, data }); + }, + reset() { + this.observed = []; + }, +}; + +const entry1 = ["entry1", "value1"]; +const entry2 = ["entry2", "value2"]; +const entry3 = ["entry3", "value3"]; + +add_setup(async () => { + await promiseUpdateEntry("remove", null, null); + const count = await promiseCountEntries(null, null); + Assert.ok(!count, "Checking initial DB is empty"); + + // Add the observer + Services.obs.addObserver(TestObserver, "satchel-storage-changed"); +}); + +add_task(async function addAndUpdateEntry() { + // Add + await promiseUpdateEntry("add", entry1[0], entry1[1]); + Assert.equal(TestObserver.observed.length, 1); + let { subject, data } = TestObserver.observed[0]; + Assert.equal(data, "formhistory-add"); + Assert.ok(isGUID.test(subject)); + + let count = await promiseCountEntries(entry1[0], entry1[1]); + Assert.equal(count, 1); + + // Update + TestObserver.reset(); + + await promiseUpdateEntry("update", entry1[0], entry1[1]); + Assert.equal(TestObserver.observed.length, 1); + ({ subject, data } = TestObserver.observed[0]); + Assert.equal(data, "formhistory-update"); + Assert.ok(isGUID.test(subject)); + + count = await promiseCountEntries(entry1[0], entry1[1]); + Assert.equal(count, 1); + + // Clean-up + await promiseUpdateEntry("remove", null, null); +}); + +add_task(async function removeEntry() { + TestObserver.reset(); + await promiseUpdateEntry("add", entry1[0], entry1[1]); + const guid = TestObserver.observed[0].subject; + TestObserver.reset(); + + await FormHistory.update({ + op: "remove", + fieldname: entry1[0], + value: entry1[1], + guid, + }); + Assert.equal(TestObserver.observed.length, 1); + const { subject, data } = TestObserver.observed[0]; + Assert.equal(data, "formhistory-remove"); + Assert.ok(isGUID.test(subject)); + + const count = await promiseCountEntries(entry1[0], entry1[1]); + Assert.equal(count, 0, "doesn't exist after remove"); +}); + +add_task(async function removeAllEntries() { + await promiseAddEntry(entry1[0], entry1[1]); + await promiseAddEntry(entry2[0], entry2[1]); + await promiseAddEntry(entry3[0], entry3[1]); + TestObserver.reset(); + + await promiseUpdateEntry("remove", null, null); + Assert.equal(TestObserver.observed.length, 3); + for (const notification of TestObserver.observed) { + const { subject, data } = notification; + Assert.equal(data, "formhistory-remove"); + Assert.ok(isGUID.test(subject)); + } + + const count = await promiseCountEntries(null, null); + Assert.equal(count, 0); +}); + +add_task(async function removeEntriesForName() { + await promiseAddEntry(entry1[0], entry1[1]); + await promiseAddEntry(entry2[0], entry2[1]); + await promiseAddEntry(entry3[0], entry3[1]); + TestObserver.reset(); + + await promiseUpdateEntry("remove", entry2[0], null); + Assert.equal(TestObserver.observed.length, 1); + const { subject, data } = TestObserver.observed[0]; + Assert.equal(data, "formhistory-remove"); + Assert.ok(isGUID.test(subject)); + + let count = await promiseCountEntries(entry2[0], entry2[1]); + Assert.equal(count, 0); + + count = await promiseCountEntries(null, null); + Assert.equal(count, 2, "the other entries are still there"); + + // Clean-up + await promiseUpdateEntry("remove", null, null); +}); + +add_task(async function removeEntriesByTimeframe() { + let timerPrecision = Preferences.get("privacy.reduceTimerPrecision"); + Preferences.set("privacy.reduceTimerPrecision", false); + + registerCleanupFunction(function () { + Preferences.set("privacy.reduceTimerPrecision", timerPrecision); + }); + + await promiseAddEntry(entry1[0], entry1[1]); + await promiseAddEntry(entry2[0], entry2[1]); + + const cutoffDate = Date.now(); + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + await new Promise(res => setTimeout(res, 10)); + + await promiseAddEntry(entry3[0], entry3[1]); + TestObserver.reset(); + + await FormHistory.update({ + op: "remove", + firstUsedStart: 10, + firstUsedEnd: cutoffDate * 1000, + }); + Assert.equal(TestObserver.observed.length, 2); + for (const notification of TestObserver.observed) { + const { subject, data } = notification; + Assert.equal(data, "formhistory-remove"); + Assert.ok(isGUID.test(subject)); + } + + const count = await promiseCountEntries(null, null); + Assert.equal(count, 1, "entry2 should still be there"); + + // Clean-up + await promiseUpdateEntry("remove", null, null); +}); + +add_task(async function teardown() { + await promiseUpdateEntry("remove", null, null); + Services.obs.removeObserver(TestObserver, "satchel-storage-changed"); +}); diff --git a/toolkit/components/satchel/test/unit/test_previous_result.js b/toolkit/components/satchel/test/unit/test_previous_result.js new file mode 100644 index 0000000000..a782832db7 --- /dev/null +++ b/toolkit/components/satchel/test/unit/test_previous_result.js @@ -0,0 +1,25 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +var aaaListener = { + onSearchResult(search, result) { + Assert.equal(result.searchString, "aaa"); + do_test_finished(); + }, +}; + +var aaListener = { + onSearchResult(search, result) { + Assert.equal(result.searchString, "aa"); + search.startSearch("aaa", "", result, aaaListener); + }, +}; + +function run_test() { + do_test_pending(); + let search = Cc[ + "@mozilla.org/autocomplete/search;1?name=form-history" + ].getService(Ci.nsIAutoCompleteSearch); + search.startSearch("aa", "", null, aaListener); +} diff --git a/toolkit/components/satchel/test/unit/xpcshell.ini b/toolkit/components/satchel/test/unit/xpcshell.ini new file mode 100644 index 0000000000..98d63dee25 --- /dev/null +++ b/toolkit/components/satchel/test/unit/xpcshell.ini @@ -0,0 +1,28 @@ +[DEFAULT] +head = head_satchel.js +tags = condprof +skip-if = toolkit == 'android' +support-files = + asyncformhistory_expire.sqlite + formhistory_1000.sqlite + formhistory_CORRUPT.sqlite + formhistory_apitest.sqlite + formhistory_autocomplete.sqlite + formhistory_v3.sqlite + formhistory_v3v4.sqlite + formhistory_v999a.sqlite + formhistory_v999b.sqlite + +[test_async_expire.js] +[test_autocomplete.js] +[test_db_corrupt.js] +[test_db_update_v4.js] +[test_db_update_v4b.js] +[test_db_update_v5.js] +skip-if = condprof # Bug 1769154 - not supported +[test_db_update_v999a.js] +[test_db_update_v999b.js] +[test_history_api.js] +[test_history_sources.js] +[test_notify.js] +[test_previous_result.js] |