summaryrefslogtreecommitdiffstats
path: root/toolkit/components/satchel/test
diff options
context:
space:
mode:
Diffstat (limited to 'toolkit/components/satchel/test')
-rw-r--r--toolkit/components/satchel/test/.eslintrc.js5
-rw-r--r--toolkit/components/satchel/test/FormHistoryTestUtils.jsm158
-rw-r--r--toolkit/components/satchel/test/browser/browser.ini9
-rw-r--r--toolkit/components/satchel/test/browser/browser_close_tab.js46
-rw-r--r--toolkit/components/satchel/test/browser/browser_popup_mouseover.js68
-rw-r--r--toolkit/components/satchel/test/browser/browser_privbrowsing_perwindowpb.js68
-rw-r--r--toolkit/components/satchel/test/mochitest.ini26
-rw-r--r--toolkit/components/satchel/test/parent_utils.js202
-rw-r--r--toolkit/components/satchel/test/satchel_common.js322
-rw-r--r--toolkit/components/satchel/test/subtst_form_submission_1.html20
-rw-r--r--toolkit/components/satchel/test/subtst_privbrowsing.html23
-rw-r--r--toolkit/components/satchel/test/test_bug_511615.html203
-rw-r--r--toolkit/components/satchel/test/test_bug_787624.html87
-rw-r--r--toolkit/components/satchel/test/test_datalist_readonly_change.html76
-rw-r--r--toolkit/components/satchel/test/test_datalist_shadow_dom.html123
-rw-r--r--toolkit/components/satchel/test/test_datalist_with_caching.html137
-rw-r--r--toolkit/components/satchel/test/test_form_autocomplete.html1139
-rw-r--r--toolkit/components/satchel/test/test_form_autocomplete_validation_at_input_event.html102
-rw-r--r--toolkit/components/satchel/test/test_form_autocomplete_with_list.html556
-rw-r--r--toolkit/components/satchel/test/test_form_submission.html524
-rw-r--r--toolkit/components/satchel/test/test_form_submission_cap.html84
-rw-r--r--toolkit/components/satchel/test/test_form_submission_cap2.html190
-rw-r--r--toolkit/components/satchel/test/test_input_valid_state_with_autocomplete.html158
-rw-r--r--toolkit/components/satchel/test/test_password_autocomplete.html139
-rw-r--r--toolkit/components/satchel/test/test_popup_direction.html60
-rw-r--r--toolkit/components/satchel/test/test_popup_enter_event.html90
-rw-r--r--toolkit/components/satchel/test/test_submit_on_keydown_enter.html122
-rw-r--r--toolkit/components/satchel/test/unit/asyncformhistory_expire.sqlitebin0 -> 98304 bytes
-rw-r--r--toolkit/components/satchel/test/unit/formhistory_1000.sqlitebin0 -> 164864 bytes
-rw-r--r--toolkit/components/satchel/test/unit/formhistory_CORRUPT.sqlite1
-rw-r--r--toolkit/components/satchel/test/unit/formhistory_apitest.sqlitebin0 -> 5120 bytes
-rw-r--r--toolkit/components/satchel/test/unit/formhistory_autocomplete.sqlitebin0 -> 72704 bytes
-rw-r--r--toolkit/components/satchel/test/unit/formhistory_v3.sqlitebin0 -> 5120 bytes
-rw-r--r--toolkit/components/satchel/test/unit/formhistory_v3v4.sqlitebin0 -> 6144 bytes
-rw-r--r--toolkit/components/satchel/test/unit/formhistory_v999a.sqlitebin0 -> 11264 bytes
-rw-r--r--toolkit/components/satchel/test/unit/formhistory_v999b.sqlitebin0 -> 8192 bytes
-rw-r--r--toolkit/components/satchel/test/unit/head_satchel.js226
-rw-r--r--toolkit/components/satchel/test/unit/test_async_expire.js136
-rw-r--r--toolkit/components/satchel/test/unit/test_autocomplete.js363
-rw-r--r--toolkit/components/satchel/test/unit/test_db_corrupt.js98
-rw-r--r--toolkit/components/satchel/test/unit/test_db_update_v4.js52
-rw-r--r--toolkit/components/satchel/test/unit/test_db_update_v4b.js46
-rw-r--r--toolkit/components/satchel/test/unit/test_db_update_v5.js29
-rw-r--r--toolkit/components/satchel/test/unit/test_db_update_v999a.js56
-rw-r--r--toolkit/components/satchel/test/unit/test_db_update_v999b.js74
-rw-r--r--toolkit/components/satchel/test/unit/test_history_api.js538
-rw-r--r--toolkit/components/satchel/test/unit/test_history_sources.js88
-rw-r--r--toolkit/components/satchel/test/unit/test_notify.js205
-rw-r--r--toolkit/components/satchel/test/unit/test_previous_result.js25
-rw-r--r--toolkit/components/satchel/test/unit/xpcshell.ini26
50 files changed, 6700 insertions, 0 deletions
diff --git a/toolkit/components/satchel/test/.eslintrc.js b/toolkit/components/satchel/test/.eslintrc.js
new file mode 100644
index 0000000000..845ed3f013
--- /dev/null
+++ b/toolkit/components/satchel/test/.eslintrc.js
@@ -0,0 +1,5 @@
+"use strict";
+
+module.exports = {
+ extends: ["plugin:mozilla/mochitest-test"],
+};
diff --git a/toolkit/components/satchel/test/FormHistoryTestUtils.jsm b/toolkit/components/satchel/test/FormHistoryTestUtils.jsm
new file mode 100644
index 0000000000..a806d2332f
--- /dev/null
+++ b/toolkit/components/satchel/test/FormHistoryTestUtils.jsm
@@ -0,0 +1,158 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const EXPORTED_SYMBOLS = ["FormHistoryTestUtils"];
+
+const { XPCOMUtils } = ChromeUtils.import(
+ "resource://gre/modules/XPCOMUtils.jsm"
+);
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ FormHistory: "resource://gre/modules/FormHistory.jsm",
+});
+
+/**
+ * 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.
+ */
+var FormHistoryTestUtils = {
+ makeListener(resolve, reject) {
+ let results = [];
+ return {
+ _results: results,
+ handleResult(result) {
+ results.push(result);
+ },
+ handleError(error) {
+ reject(error);
+ },
+ handleCompletion(errored) {
+ if (!errored) {
+ resolve(results);
+ }
+ },
+ };
+ },
+
+ /**
+ * 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 new Promise((resolve, reject) => {
+ FormHistory.update(
+ Object.assign({ fieldname }, { op: "bump", value, source }),
+ this.makeListener(resolve, reject)
+ );
+ });
+ }
+ },
+
+ /**
+ * 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 = {}) {
+ let results = await new Promise((resolve, reject) => {
+ FormHistory.count(
+ Object.assign({ fieldname }, filters),
+ this.makeListener(resolve, reject)
+ );
+ });
+ return results[0];
+ },
+
+ /**
+ * 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.
+ * @param {object} window The window containing the urlbar.
+ * @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 new Promise((resolve, reject) => {
+ FormHistory.update(changes, this.makeListener(resolve, reject));
+ });
+ },
+
+ /**
+ * 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) {
+ return new Promise((resolve, reject) => {
+ let baseChange = fieldname ? { fieldname } : {};
+ FormHistory.update(
+ Object.assign(baseChange, { op: "remove" }),
+ this.makeListener(resolve, reject)
+ );
+ });
+ },
+
+ /**
+ * Searches form history.
+ *
+ * @param {string} fieldname The field name.
+ * @param {array} filters Objects describing the search properties.
+ * @returns {Promise} Resolved once the operation is complete.
+ * @resolves {Array} Array of found form history entries.
+ */
+ search(fieldname, filters = {}) {
+ return new Promise((resolve, reject) => {
+ FormHistory.search(
+ null,
+ Object.assign({ fieldname }, filters),
+ this.makeListener(resolve, reject)
+ );
+ });
+ },
+
+ /**
+ * 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} Resolved once the operation is complete.
+ * @resolves {Array} Array of found form history entries.
+ */
+ autocomplete(searchString, fieldname, filters = {}) {
+ return new Promise((resolve, reject) => {
+ FormHistory.getAutoCompleteResults(
+ searchString,
+ Object.assign({ fieldname }, filters),
+ this.makeListener(resolve, reject)
+ );
+ });
+ },
+};
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..1d00bbf0ee
--- /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.import(
+ "resource://gre/modules/FormHistory.jsm"
+);
+
+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 new Promise(resolve =>
+ FormHistory.update([{ op: "remove" }, ...mockHistory], {
+ handleCompletion: resolve,
+ })
+ );
+ 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..3cd17f7707
--- /dev/null
+++ b/toolkit/components/satchel/test/browser/browser_popup_mouseover.js
@@ -0,0 +1,68 @@
+/* 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.import(
+ "resource://gre/modules/FormHistory.jsm"
+);
+
+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 new Promise(resolve =>
+ FormHistory.update([{ op: "remove" }, ...mockHistory], {
+ handleCompletion: resolve,
+ })
+ );
+ 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"
+ );
+ is(listItemElems.length, mockHistory.length, "ensure result length");
+ is(autoCompletePopup.mousedOverIndex, -1, "mousedOverIndex should be -1");
+
+ // navigate to the first item
+ await BrowserTestUtils.synthesizeKey("VK_DOWN", {}, browser);
+ is(autoCompletePopup.selectedIndex, 0, "selectedIndex should be 0");
+
+ // mouseover the second item
+ EventUtils.synthesizeMouseAtCenter(listItemElems[1], { type: "mouseover" });
+ await BrowserTestUtils.waitForCondition(() => {
+ return (autoCompletePopup.mousedOverIndex = 1);
+ });
+ ok(true, "mousedOverIndex changed");
+ is(
+ 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..2753d2050c
--- /dev/null
+++ b/toolkit/components/satchel/test/browser/browser_privbrowsing_perwindowpb.js
@@ -0,0 +1,68 @@
+/* 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.import(
+ "resource://gre/modules/FormHistory.jsm"
+);
+
+/** 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.loadURI(browser, testURI);
+ await BrowserTestUtils.browserLoaded(browser);
+
+ // Wait for the page to reload itself.
+ await BrowserTestUtils.browserLoaded(browser);
+
+ let count = 0;
+ let doneCounting = {};
+ doneCounting.promise = new Promise(
+ resolve => (doneCounting.resolve = resolve)
+ );
+ FormHistory.count(
+ { fieldname: "field", value: "value" },
+ {
+ handleResult(result) {
+ count = result;
+ },
+ handleError(error) {
+ Assert.ok(false, "Error occurred searching form history: " + error);
+ },
+ handleCompletion(num) {
+ if (aShouldValueExist) {
+ is(count, 1, "In non-PB mode, we add a single entry");
+ } else {
+ is(count, 0, "In PB mode, we don't add any entries");
+ }
+
+ doneCounting.resolve();
+ },
+ }
+ );
+ await doneCounting.promise;
+ }
+
+ 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..e0c8efa236
--- /dev/null
+++ b/toolkit/components/satchel/test/mochitest.ini
@@ -0,0 +1,26 @@
+[DEFAULT]
+skip-if = toolkit == 'android'
+support-files =
+ satchel_common.js
+ subtst_form_submission_1.html
+ subtst_privbrowsing.html
+ parent_utils.js
+
+[test_bug_511615.html]
+[test_bug_787624.html]
+[test_datalist_with_caching.html]
+[test_datalist_readonly_change.html]
+[test_datalist_shadow_dom.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]
+[test_form_submission.html]
+[test_form_submission_cap.html]
+[test_form_submission_cap2.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..5282c70eca
--- /dev/null
+++ b/toolkit/components/satchel/test/parent_utils.js
@@ -0,0 +1,202 @@
+/* eslint-env mozilla/frame-script */
+// assert is available to chrome scripts loaded via SpecialPowers.loadChromeScript.
+/* global assert */
+
+const { FormHistory } = ChromeUtils.import(
+ "resource://gre/modules/FormHistory.jsm"
+);
+const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
+const { ContentTaskUtils } = ChromeUtils.import(
+ "resource://testing-common/ContentTaskUtils.jsm"
+);
+
+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;
+ },
+
+ cleanUpFormHist(callback) {
+ FormHistory.update(
+ { op: "remove" },
+ {
+ handleCompletion: callback,
+ }
+ );
+ },
+
+ updateFormHistory(changes) {
+ let handler = {
+ handleError(error) {
+ assert.ok(false, error);
+ sendAsyncMessage("formHistoryUpdated", { ok: false });
+ },
+ handleCompletion(reason) {
+ if (!reason) {
+ sendAsyncMessage("formHistoryUpdated", { ok: true });
+ }
+ },
+ };
+ FormHistory.update(changes, handler);
+ },
+
+ popupshownListener() {
+ let results = this.getMenuEntries();
+ sendAsyncMessage("onpopupshown", { results });
+ },
+
+ countEntries(name, value) {
+ let obj = {};
+ if (name) {
+ obj.fieldname = name;
+ }
+ if (value) {
+ obj.value = value;
+ }
+
+ let count = 0;
+ let listener = {
+ handleResult(result) {
+ count = result;
+ },
+ handleError(error) {
+ assert.ok(false, error);
+ sendAsyncMessage("entriesCounted", { ok: false });
+ },
+ handleCompletion(reason) {
+ if (!reason) {
+ sendAsyncMessage("entriesCounted", { ok: true, count });
+ }
+ },
+ };
+
+ FormHistory.count(obj, listener);
+ },
+
+ checkRowCount(expectedCount, expectedFirstValue = null) {
+ 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: " + expectedCount + " First value: " + expectedFirstValue).then(
+ () => {
+ let results = this.getMenuEntries();
+ sendAsyncMessage("gotMenuChange", { results });
+ }
+ );
+ },
+
+ checkSelectedIndex(expectedIndex) {
+ ContentTaskUtils.waitForCondition(() => {
+ return (
+ gAutocompletePopup.popupOpen &&
+ gAutocompletePopup.selectedIndex === expectedIndex
+ );
+ }, "Checking selected index").then(() => {
+ sendAsyncMessage("gotSelectedIndex");
+ });
+ },
+
+ // 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() {
+ sendAsyncMessage("gotPopupState", {
+ open: gAutocompletePopup.popupOpen,
+ selectedIndex: gAutocompletePopup.selectedIndex,
+ direction: gAutocompletePopup.style.direction,
+ });
+ },
+
+ observe(subject, topic, data) {
+ assert.ok(topic === "satchel-storage-changed");
+ sendAsyncMessage("satchel-storage-changed", { subject: null, topic, data });
+ },
+
+ cleanup() {
+ gAutocompletePopup.removeEventListener(
+ "popupshown",
+ this._popupshownListener
+ );
+ this.cleanUpFormHist(() => {
+ sendAsyncMessage("cleanup-done");
+ });
+ },
+};
+
+ParentUtils._popupshownListener = ParentUtils.popupshownListener.bind(
+ ParentUtils
+);
+gAutocompletePopup.addEventListener(
+ "popupshown",
+ ParentUtils._popupshownListener
+);
+ParentUtils.cleanUpFormHist();
+
+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", () => {
+ 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..548209e0f0
--- /dev/null
+++ b/toolkit/components/satchel/test/satchel_common.js
@@ -0,0 +1,322 @@
+/* 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",
+ }],
+*/
+
+/* import-globals-from ../../../../testing/mochitest/tests/SimpleTest/SimpleTest.js */
+
+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 $_(formNum, name) {
+ let form = document.getElementById("form" + formNum);
+ if (!form) {
+ ok(false, "$_ couldn't find requested form " + formNum);
+ return null;
+ }
+
+ let element = form.elements.namedItem(name);
+ if (!element) {
+ ok(false, "$_ couldn't find requested element " + name);
+ return null;
+ }
+
+ // Note that namedItem is a bit stupid, and will prefer an
+ // |id| attribute over a |name| attribute when looking for
+ // the element.
+
+ if (element.hasAttribute("name") && element.getAttribute("name") != name) {
+ ok(false, "$_ got confused.");
+ return null;
+ }
+
+ return element;
+}
+
+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;
+}
+
+function checkArrayValues(actualValues, expectedValues, msg) {
+ is(
+ actualValues.length,
+ expectedValues.length,
+ "Checking array values: " + msg
+ );
+ for (let i = 0; i < expectedValues.length; i++) {
+ is(actualValues[i], expectedValues[i], msg + " Checking array entry #" + i);
+ }
+}
+
+var checkObserver = {
+ verifyStack: [],
+ callback: null,
+
+ init() {
+ gChromeScript.sendAsyncMessage("addObserver");
+ gChromeScript.addMessageListener(
+ "satchel-storage-changed",
+ this.observe.bind(this)
+ );
+ },
+
+ uninit() {
+ gChromeScript.sendAsyncMessage("removeObserver");
+ },
+
+ waitForChecks(callback) {
+ if (!this.verifyStack.length) {
+ callback();
+ } else {
+ this.callback = callback;
+ }
+ },
+
+ observe({ subject, topic, data }) {
+ if (data != "formhistory-add" && data != "formhistory-update") {
+ return;
+ }
+ ok(!!this.verifyStack.length, "checking if saved form data was expected");
+
+ // Make sure that every piece of data we expect to be saved is saved, and no
+ // more. Here it is assumed that for every entry satchel saves or modifies, a
+ // message is sent.
+ //
+ // We don't actually check the content of the message, but just that the right
+ // quantity of messages is received.
+ // - if there are too few messages, test will time out
+ // - if there are too many messages, test will error out here
+ //
+ let expected = this.verifyStack.shift();
+
+ countEntries(expected.name, expected.value, function(num) {
+ ok(num > 0, expected.message);
+ if (!checkObserver.verifyStack.length) {
+ let callback = checkObserver.callback;
+ checkObserver.callback = null;
+ callback();
+ }
+ });
+ },
+};
+
+function checkForSave(name, value, message) {
+ checkObserver.verifyStack.push({ name, value, message });
+}
+
+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();
+ });
+ });
+}
+
+function notifyMenuChanged(expectedCount, expectedFirstValue, then = null) {
+ return new Promise(resolve => {
+ gChromeScript.sendAsyncMessage("waitForMenuChange", {
+ expectedCount,
+ expectedFirstValue,
+ });
+ gChromeScript.addMessageListener("gotMenuChange", function changed({
+ results,
+ }) {
+ gChromeScript.removeMessageListener("gotMenuChange", changed);
+ gLastAutoCompleteResults = results;
+ if (then) {
+ then(results);
+ }
+ resolve(results);
+ });
+ });
+}
+
+function notifySelectedIndex(expectedIndex, then = null) {
+ return new Promise(resolve => {
+ gChromeScript.sendAsyncMessage("waitForSelectedIndex", { expectedIndex });
+ gChromeScript.addMessageListener("gotSelectedIndex", function changed() {
+ gChromeScript.removeMessageListener("gotSelectedIndex", changed);
+ if (then) {
+ then();
+ }
+ resolve();
+ });
+ });
+}
+
+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 promiseNoUnexpectedPopupShown() {
+ gPopupShownExpected = false;
+ listenForUnexpectedPopupShown();
+ SimpleTest.requestFlakyTimeout(
+ "Giving a chance for an unexpected popupshown to occur"
+ );
+ await new Promise(resolve => setTimeout(resolve, 1000));
+}
+
+/**
+ * Resolve at the next popupshown event for the autocomplete popup
+ * @returns {Promise} with the results
+ */
+function promiseACShown() {
+ gPopupShownExpected = true;
+ return new Promise(resolve => {
+ gPopupShownListener = ({ results }) => {
+ gPopupShownExpected = false;
+ resolve(results);
+ };
+ });
+}
+
+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");
+}
+
+function satchelCommonSetup() {
+ let chromeURL = SimpleTest.getTestFileURL("parent_utils.js");
+ gChromeScript = SpecialPowers.loadChromeScript(chromeURL);
+ gChromeScript.addMessageListener("onpopupshown", ({ results }) => {
+ gLastAutoCompleteResults = results;
+ if (gPopupShownListener) {
+ gPopupShownListener({ results });
+ }
+ });
+
+ SimpleTest.registerCleanupFunction(() => {
+ gChromeScript.sendAsyncMessage("cleanup");
+ return new Promise(resolve => {
+ gChromeScript.addMessageListener("cleanup-done", function done() {
+ gChromeScript.removeMessageListener("cleanup-done", done);
+ gChromeScript.destroy();
+ resolve();
+ });
+ });
+ });
+}
+
+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..c06c5e0ce3
--- /dev/null
+++ b/toolkit/components/satchel/test/subtst_form_submission_1.html
@@ -0,0 +1,20 @@
+<!DOCTYPE HTML>
+<html>
+
+<head>
+</head>
+
+<body>
+
+<form id="subform2" onsubmit="return false">
+ <input id="subtest2" type="text" name="subtest2">
+ <button type="submit">Submit</button>
+</form>
+
+<script>
+ // set the input's value (can't use a default value, as satchel will ignore it)
+ document.getElementById("subtest2").value = "subtestValue";
+</script>
+
+</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..f0f4f38ad4
--- /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 = document.getElementById("field");
+ field.value = "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..474ea3e1da
--- /dev/null
+++ b/toolkit/components/satchel/test/test_bug_511615.html
@@ -0,0 +1,203 @@
+<!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>
+
+<pre id="test">
+<script class="testbody" type="text/javascript">
+var resolvePopupShownListener;
+registerPopupShownListener(() => resolvePopupShownListener());
+
+function waitForNextPopup() {
+ return new Promise(resolve => { resolvePopupShownListener = resolve; });
+}
+
+/**
+ * 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;
+
+SimpleTest.requestFlakyTimeout("Must ensure that an event does not happen.");
+
+/**
+ * Checks that the popup does not open in response to the given function.
+ *
+ * @param {Function} triggerFn - function that triggers an event
+ * @returns {Promise}
+ */
+function expectPopupDoesNotOpen(triggerFn) {
+ let popupShown = waitForNextPopup();
+ triggerFn();
+ return Promise.race([
+ popupShown.then(() => Promise.reject("Popup opened unexpectedly.")),
+ new Promise(resolve => setTimeout(resolve, POPUP_RESPONSE_WAIT_TIME_MS)),
+ ]);
+}
+
+/**
+ * Checks that the selected index in the popup still matches the given value.
+ *
+ * @param {number} expectedIndex
+ * @returns {Promise}
+ */
+function checkSelectedIndexAfterResponseTime(expectedIndex) {
+ return new Promise(resolve => {
+ setTimeout(() => getPopupState(resolve), POPUP_RESPONSE_WAIT_TIME_MS);
+ }).then(popupState => {
+ is(popupState.open, true, "Popup should still be open.");
+ is(popupState.selectedIndex, expectedIndex, "Selected index should match.");
+ });
+}
+
+let input = $_(1, "field1");
+
+function doKeyUnprivileged(key) {
+ let keyName = "DOM_VK_" + key.toUpperCase();
+ let keycode, charcode, alwaysval;
+
+ if (key.length == 1) {
+ keycode = 0;
+ charcode = key.charCodeAt(0);
+ alwaysval = charcode;
+ } else {
+ keycode = KeyEvent[keyName];
+ if (!keycode) {
+ throw new Error("invalid keyname in test");
+ }
+ charcode = 0;
+ alwaysval = keycode;
+ }
+
+ let dnEvent = document.createEvent("KeyboardEvent");
+ let prEvent = document.createEvent("KeyboardEvent");
+ let upEvent = document.createEvent("KeyboardEvent");
+
+ /* eslint-disable no-multi-spaces */
+ dnEvent.initKeyEvent("keydown", true, true, null, false, false, false, false, alwaysval, 0);
+ prEvent.initKeyEvent("keypress", true, true, null, false, false, false, false, keycode, charcode);
+ upEvent.initKeyEvent("keyup", true, true, null, false, false, false, false, alwaysval, 0);
+ /* eslint-enable no-multi-spaces */
+
+ 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_task(async function test_initialize() {
+ 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 expectPopupDoesNotOpen(triggerFn);
+ }
+
+ // A privileged key press will actually open the popup.
+ let popupShown = waitForNextPopup();
+ synthesizeKey("KEY_ArrowDown");
+ await popupShown;
+
+ // 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.
+ let indexChanged = new Promise(resolve => notifySelectedIndex(0, resolve));
+ 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.");
+ }
+
+ // Close the popup.
+ input.blur();
+});
+</script>
+</pre>
+</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..6599e0c849
--- /dev/null
+++ b/toolkit/components/satchel/test/test_bug_787624.html
@@ -0,0 +1,87 @@
+<!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>
+
+<pre id="test">
+<script class="testbody" type="text/javascript">
+/** Test for Form History autocomplete Layout: Bug 787624 **/
+
+var resolvePopupShownListener;
+registerPopupShownListener(() => resolvePopupShownListener());
+
+function waitForNextPopup() {
+ return new Promise(resolve => { resolvePopupShownListener = resolve; });
+}
+
+add_task(async function test_popup_not_move_input() {
+ let input = $_(1, "field1");
+ let rect = input.getBoundingClientRect();
+
+ await new Promise(resolve => updateFormHistory([
+ { op: "remove" },
+ { op: "add", fieldname: "field1", value: "value1" },
+ { op: "add", fieldname: "field1", value: "value2" },
+ ], resolve));
+
+ let popupShown = waitForNextPopup();
+ input.focus();
+ synthesizeKey("KEY_ArrowDown", {});
+ await popupShown;
+
+ let newRect = input.getBoundingClientRect();
+ is(newRect.left, rect.left,
+ "autocomplete popup does not disturb the input position");
+ is(newRect.top, rect.top,
+ "autocomplete popup does not disturb the input position");
+});
+
+</script>
+</pre>
+</body>
+</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..7a575a29d6
--- /dev/null
+++ b/toolkit/components/satchel/test/test_datalist_readonly_change.html
@@ -0,0 +1,76 @@
+<!DOCTYPE HTML>
+<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="/tests/SimpleTest/AddTask.js"></script>
+<script src="satchel_common.js"></script>
+<link rel="stylesheet" href="/tests/SimpleTest/test.css" />
+<input readonly list="suggest" type="text" name="field1">
+<datalist id="suggest">
+ <option value="First"></option>
+ <option value="Second"></option>
+ <option value="Secomundo"></option>
+</datalist>
+<pre id="test">
+<script class="testbody">
+const input = document.querySelector("input");
+
+SimpleTest.waitForExplicitFinish();
+
+var expectingPopup = null;
+
+function expectPopup() {
+ info("expecting a popup");
+ return new Promise(resolve => {
+ expectingPopup = resolve;
+ });
+}
+
+var testNum = 0;
+
+function popupShownListener() {
+ info("popup shown for test " + testNum);
+ if (expectingPopup) {
+ expectingPopup();
+ expectingPopup = null;
+ } else {
+ ok(false, "Autocomplete popup not expected during test " + testNum);
+ }
+}
+
+registerPopupShownListener(popupShownListener);
+
+function checkMenuEntries(expectedValues) {
+ let actualValues = getMenuEntries();
+ is(actualValues.length, expectedValues.length, testNum + " Checking length of expected menu");
+ for (let i = 0; i < expectedValues.length; i++) {
+ is(actualValues[i], expectedValues[i], testNum + " Checking menu entry #" + i);
+ }
+}
+
+function oneTick() {
+ return new Promise(resolve => SimpleTest.executeSoon(resolve));
+}
+
+async function runTests() {
+ input.focus();
+ ok(input.readOnly, "Input should be readonly");
+ is(document.activeElement, input, "Input should be focused");
+ input.removeAttribute("readonly");
+ await oneTick(); // AttributeChanged takes control of the input again off a runnable...
+ isnot(input.readOnly, "Input should not be readonly");
+ is(document.activeElement, input, "Should still be focused");
+ synthesizeKey("KEY_ArrowDown");
+ await expectPopup();
+
+ checkMenuEntries(["First", "Second", "Secomundo"]);
+ synthesizeKey("KEY_ArrowDown");
+ synthesizeKey("KEY_Enter");
+ is(input.value, "First");
+ SimpleTest.finish();
+}
+
+SimpleTest.waitForFocus(runTests);
+
+</script>
+</pre>
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..1428b2f271
--- /dev/null
+++ b/toolkit/components/satchel/test/test_datalist_shadow_dom.html
@@ -0,0 +1,123 @@
+<!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>
+
+<pre id="test">
+<script class="testbody" type="text/javascript">
+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><Paste>
+`;
+
+let input = host.shadowRoot.querySelector("input");
+
+function setForm(value) {
+ input.value = value;
+ input.focus();
+}
+
+// Restore the form to the default state.
+function restoreForm() {
+ setForm("");
+}
+
+// Check for expected form data.
+function checkForm(expectedValue) {
+ let formID = input.parentNode.id;
+ is(input.value, expectedValue, "Checking " + formID + " input");
+}
+
+SimpleTest.waitForExplicitFinish();
+
+var expectingPopup = null;
+
+function expectPopup() {
+ info("expecting a popup");
+ return new Promise(resolve => {
+ expectingPopup = resolve;
+ });
+}
+
+var testNum = 0;
+
+function popupShownListener() {
+ info("popup shown for test " + testNum);
+ if (expectingPopup) {
+ expectingPopup();
+ expectingPopup = null;
+ } else {
+ ok(false, "Autocomplete popup not expected during test " + testNum);
+ }
+}
+
+function waitForMenuChange(expectedCount) {
+ return new Promise(resolve => {
+ notifyMenuChanged(expectedCount, null, resolve);
+ });
+}
+
+registerPopupShownListener(popupShownListener);
+
+function checkMenuEntries(expectedValues) {
+ let actualValues = getMenuEntries();
+ is(actualValues.length, expectedValues.length, testNum + " Checking length of expected menu");
+ for (let i = 0; i < expectedValues.length; i++) {
+ is(actualValues[i], expectedValues[i], testNum + " Checking menu entry #" + i);
+ }
+}
+
+async function runTests() {
+ testNum++;
+ restoreForm();
+ synthesizeKey("KEY_ArrowDown");
+ await expectPopup();
+
+ checkMenuEntries(["First", "Second", "Secomundo"]);
+ synthesizeKey("KEY_ArrowDown");
+ synthesizeKey("KEY_Enter");
+ checkForm("First");
+
+ testNum++;
+ restoreForm();
+ sendString("Sec");
+ synthesizeKey("KEY_ArrowDown");
+ await expectPopup();
+
+ testNum++;
+ checkMenuEntries(["Second", "Secomundo"]);
+ sendString("o");
+ await waitForMenuChange(2);
+
+ testNum++;
+ checkMenuEntries(["Second", "Secomundo"]);
+ synthesizeKey("KEY_ArrowDown");
+ synthesizeKey("KEY_Enter");
+ checkForm("Second");
+ SimpleTest.finish();
+}
+
+window.onpageshow = runTests;
+
+</script>
+</pre>
+</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..fe090ec81a
--- /dev/null
+++ b/toolkit/components/satchel/test/test_datalist_with_caching.html
@@ -0,0 +1,137 @@
+<!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>
+
+<pre id="test">
+<script class="testbody" type="text/javascript">
+let input = $_(1, "field1");
+
+function setupFormHistory(aCallback) {
+ updateFormHistory([
+ { op: "remove" },
+ { op: "add", fieldname: "field1", value: "Sec" },
+ ], () => {
+ aCallback();
+ });
+}
+
+function setForm(value) {
+ input.value = value;
+ input.focus();
+}
+
+// Restore the form to the default state.
+function restoreForm() {
+ setForm("");
+}
+
+// Check for expected form data.
+function checkForm(expectedValue) {
+ let formID = input.parentNode.id;
+ is(input.value, expectedValue, "Checking " + formID + " input");
+}
+
+SimpleTest.waitForExplicitFinish();
+
+var expectingPopup = null;
+
+function expectPopup() {
+ info("expecting a popup");
+ return new Promise(resolve => {
+ expectingPopup = resolve;
+ });
+}
+
+var testNum = 0;
+
+function popupShownListener() {
+ info("popup shown for test " + testNum);
+ if (expectingPopup) {
+ expectingPopup();
+ expectingPopup = null;
+ } else {
+ ok(false, "Autocomplete popup not expected during test " + testNum);
+ }
+}
+
+function waitForMenuChange(expectedCount) {
+ return new Promise(resolve => {
+ notifyMenuChanged(expectedCount, null, resolve);
+ });
+}
+
+registerPopupShownListener(popupShownListener);
+
+function checkMenuEntries(expectedValues) {
+ let actualValues = getMenuEntries();
+ is(actualValues.length, expectedValues.length, testNum + " Checking length of expected menu");
+ for (let i = 0; i < expectedValues.length; i++) {
+ is(actualValues[i], expectedValues[i], testNum + " Checking menu entry #" + i);
+ }
+}
+
+async function runTests() {
+ testNum++;
+ restoreForm();
+ synthesizeKey("KEY_ArrowDown");
+ await expectPopup();
+
+ checkMenuEntries(["Sec", "First", "Second", "Secomundo"]);
+ synthesizeKey("KEY_ArrowDown");
+ synthesizeKey("KEY_Enter");
+ checkForm("Sec");
+
+ testNum++;
+ restoreForm();
+ sendString("Sec");
+ synthesizeKey("KEY_ArrowDown");
+ await expectPopup();
+
+ testNum++;
+ checkMenuEntries(["Sec", "Second", "Secomundo"]);
+ sendString("o");
+ await waitForMenuChange(2);
+
+ testNum++;
+ checkMenuEntries(["Second", "Secomundo"]);
+ synthesizeKey("KEY_ArrowDown");
+ synthesizeKey("KEY_Enter");
+ checkForm("Second");
+ SimpleTest.finish();
+}
+
+function startTest() {
+ setupFormHistory(runTests);
+}
+
+window.onload = startTest;
+
+</script>
+</pre>
+</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..9c35eb20d9
--- /dev/null
+++ b/toolkit/components/satchel/test/test_form_autocomplete.html
@@ -0,0 +1,1139 @@
+<!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" onsubmit="return false;">
+ <input type="text" name="field1">
+ <button type="submit">Submit</button>
+ </form>
+
+ <!-- normal, basic form (new fieldname) -->
+ <form id="form2" onsubmit="return false;">
+ <input type="text" name="field2">
+ <button type="submit">Submit</button>
+ </form>
+
+ <!-- form with autocomplete=off on input -->
+ <form id="form3" onsubmit="return false;">
+ <input type="text" name="field2" autocomplete="off">
+ <button type="submit">Submit</button>
+ </form>
+
+ <!-- form with autocomplete=off on form -->
+ <form id="form4" autocomplete="off" onsubmit="return false;">
+ <input type="text" name="field2">
+ <button type="submit">Submit</button>
+ </form>
+
+ <!-- normal form for testing filtering -->
+ <form id="form5" onsubmit="return false;">
+ <input type="text" name="field3">
+ <button type="submit">Submit</button>
+ </form>
+
+ <!-- normal form for testing word boundary filtering -->
+ <form id="form6" onsubmit="return false;">
+ <input type="text" name="field4">
+ <button type="submit">Submit</button>
+ </form>
+
+ <!-- form with maxlength attribute on input -->
+ <form id="form7" onsubmit="return false;">
+ <input type="text" name="field5" maxlength="10">
+ <button type="submit">Submit</button>
+ </form>
+
+ <!-- form with input type='email' -->
+ <form id="form8" onsubmit="return false;">
+ <input type="email" name="field6">
+ <button type="submit">Submit</button>
+ </form>
+
+ <!-- form with input type='tel' -->
+ <form id="form9" onsubmit="return false;">
+ <input type="tel" name="field7">
+ <button type="submit">Submit</button>
+ </form>
+
+ <!-- form with input type='url' -->
+ <form id="form10" onsubmit="return false;">
+ <input type="url" name="field8">
+ <button type="submit">Submit</button>
+ </form>
+
+ <!-- form with input type='search' -->
+ <form id="form11" onsubmit="return false;">
+ <input type="search" name="field9">
+ <button type="submit">Submit</button>
+ </form>
+
+ <!-- form with input type='number' -->
+ <form id="form12" onsubmit="return false;">
+ <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" onsubmit="return false;">
+ <input type="text" name="searchbar-history">
+ <button type="submit">Submit</button>
+ </form>
+
+ <!-- form with input type='date' -->
+ <form id="form14" onsubmit="return false;">
+ <input type="date" name="field11">
+ <button type="submit">Submit</button>
+ </form>
+
+ <!-- form with input type='time' -->
+ <form id="form15" onsubmit="return false;">
+ <input type="time" name="field12">
+ <button type="submit">Submit</button>
+ </form>
+
+ <!-- form with input type='range' -->
+ <form id="form16" onsubmit="return false;">
+ <input type="range" name="field13" max="64">
+ <button type="submit">Submit</button>
+ </form>
+
+ <!-- form with input type='color' -->
+ <form id="form17" onsubmit="return false;">
+ <input type="color" name="field14">
+ <button type="submit">Submit</button>
+ </form>
+
+ <!-- form with input type='month' -->
+ <form id="form18" onsubmit="return false;">
+ <input type="month" name="field15">
+ <button type="submit">Submit</button>
+ </form>
+
+ <!-- form with input type='week' -->
+ <form id="form19" onsubmit="return false;">
+ <input type="week" name="field16">
+ <button type="submit">Submit</button>
+ </form>
+
+ <!-- form with input type='datetime-local' -->
+ <form id="form20" onsubmit="return false;">
+ <input type="datetime-local" name="field17">
+ <button type="submit">Submit</button>
+ </form>
+
+</div>
+
+<pre id="test">
+<script class="testbody" type="text/javascript">
+/** Test for Form History autocomplete **/
+
+SpecialPowers.pushPrefEnv({"set": [["security.allow_eval_with_system_principal", true],
+ ["dom.input_events.beforeinput.enabled", true]]});
+var kSetUserInputCancelable = SpecialPowers.getBoolPref("dom.input_event.allow_to_cancel_set_user_input");
+
+var input = $_(1, "field1");
+
+function setupFormHistory(aCallback) {
+ 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 drop down 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" },
+ ], aCallback);
+}
+
+function setForm(value) {
+ input.value = value;
+ input.focus();
+}
+
+// Restore the form to the default state.
+function restoreForm() {
+ setForm("");
+}
+
+// Check for expected form data.
+function checkForm(expectedValue) {
+ let formID = input.parentNode.id;
+ is(input.value, expectedValue, "Checking " + formID + " input");
+}
+
+var testNum = 0;
+var expectingPopup = false;
+
+function expectPopup() {
+ info("expecting popup for test " + testNum);
+ expectingPopup = true;
+}
+
+function popupShownListener() {
+ info("popup shown for test " + testNum);
+ if (expectingPopup) {
+ expectingPopup = false;
+ SimpleTest.executeSoon(runTest);
+ } else {
+ if (testNum == 253) {
+ // Bug 1420103
+ todo(false, "Autocomplete popup not expected during test " + testNum);
+ return;
+ }
+ ok(false, "Autocomplete popup not expected during test " + testNum);
+ }
+}
+
+registerPopupShownListener(popupShownListener);
+
+/*
+ * Main section of test...
+ *
+ * This is a bit hacky, as many operations happen asynchronously.
+ * Various mechanisms call runTests as a result of operations:
+ * - set expectingPopup to true, and the next test will occur when the autocomplete popup
+ * is shown
+ * - call waitForMenuChange(x) to run the next test when the autocomplete popup
+ * to have x items in it
+ * - addEntry calls runs the test when an entry has been added
+ * - some tests scroll the window. This is because the form fill controller happens to scroll
+ * the field into view near the end of the search, and there isn't any other good notification
+ * to listen to for when the search is complete.
+ * - some items still use setTimeout
+ */
+async function runTest() { // eslint-disable-line complexity
+ testNum++;
+
+ ok(true, "Starting test #" + testNum);
+
+ switch (testNum) {
+ case 1:
+ // Make sure initial form is empty.
+ checkForm("");
+ // Trigger autocomplete popup
+ expectPopup();
+ restoreForm();
+ synthesizeKey("KEY_ArrowDown");
+ break;
+
+ case 2:
+ checkMenuEntries(["value1", "value2", "value3", "value4"], testNum);
+ // Check first entry
+ synthesizeKey("KEY_ArrowDown");
+ checkForm(""); // value shouldn't update
+ synthesizeKey("KEY_Enter");
+ checkForm("value1");
+
+ // Trigger autocomplete popup
+ expectPopup();
+ restoreForm();
+ synthesizeKey("KEY_ArrowDown");
+ break;
+
+ case 3:
+ // Check second entry
+ synthesizeKey("KEY_ArrowDown");
+ synthesizeKey("KEY_ArrowDown");
+ synthesizeKey("KEY_Enter");
+ checkForm("value2");
+
+ // Trigger autocomplete popup
+ expectPopup();
+ restoreForm();
+ synthesizeKey("KEY_ArrowDown");
+ break;
+
+ case 4:
+ // Check third entry
+ synthesizeKey("KEY_ArrowDown");
+ synthesizeKey("KEY_ArrowDown");
+ synthesizeKey("KEY_ArrowDown");
+ synthesizeKey("KEY_Enter");
+ checkForm("value3");
+
+ // Trigger autocomplete popup
+ expectPopup();
+ restoreForm();
+ synthesizeKey("KEY_ArrowDown");
+ break;
+
+ case 5:
+ // Check fourth entry
+ synthesizeKey("KEY_ArrowDown");
+ synthesizeKey("KEY_ArrowDown");
+ synthesizeKey("KEY_ArrowDown");
+ synthesizeKey("KEY_ArrowDown");
+ synthesizeKey("KEY_Enter");
+ checkForm("value4");
+
+ // Trigger autocomplete popup
+ expectPopup();
+ restoreForm();
+ synthesizeKey("KEY_ArrowDown");
+ break;
+
+ case 6:
+ // Check first entry (wraparound)
+ synthesizeKey("KEY_ArrowDown");
+ synthesizeKey("KEY_ArrowDown");
+ synthesizeKey("KEY_ArrowDown");
+ synthesizeKey("KEY_ArrowDown");
+ synthesizeKey("KEY_ArrowDown"); // deselects
+ synthesizeKey("KEY_ArrowDown");
+ synthesizeKey("KEY_Enter");
+ checkForm("value1");
+
+ // Trigger autocomplete popup
+ expectPopup();
+ restoreForm();
+ synthesizeKey("KEY_ArrowDown");
+ break;
+
+ case 7:
+ // Check the last entry via arrow-up
+ synthesizeKey("KEY_ArrowUp");
+ synthesizeKey("KEY_Enter");
+ checkForm("value4");
+
+ // Trigger autocomplete popup
+ expectPopup();
+ restoreForm();
+ synthesizeKey("KEY_ArrowDown");
+ break;
+
+ case 8:
+ // Check the last entry via arrow-up
+ synthesizeKey("KEY_ArrowDown"); // select first entry
+ synthesizeKey("KEY_ArrowUp"); // selects nothing!
+ synthesizeKey("KEY_ArrowUp"); // select last entry
+ synthesizeKey("KEY_Enter");
+ checkForm("value4");
+
+ // Trigger autocomplete popup
+ expectPopup();
+ restoreForm();
+ synthesizeKey("KEY_ArrowDown");
+ break;
+
+ case 9:
+ // Check the last entry via arrow-up (wraparound)
+ synthesizeKey("KEY_ArrowDown");
+ synthesizeKey("KEY_ArrowUp"); // deselects
+ synthesizeKey("KEY_ArrowUp"); // last entry
+ synthesizeKey("KEY_ArrowUp");
+ synthesizeKey("KEY_ArrowUp");
+ synthesizeKey("KEY_ArrowUp"); // first entry
+ synthesizeKey("KEY_ArrowUp"); // deselects
+ synthesizeKey("KEY_ArrowUp"); // last entry
+ synthesizeKey("KEY_Enter");
+ checkForm("value4");
+
+ // Trigger autocomplete popup
+ expectPopup();
+ restoreForm();
+ synthesizeKey("KEY_ArrowDown");
+ break;
+
+ case 10:
+ // Set first entry w/o triggering autocomplete
+ synthesizeKey("KEY_ArrowDown");
+ synthesizeKey("KEY_ArrowRight");
+ checkForm("value1");
+
+ // Trigger autocomplete popup
+ expectPopup();
+ restoreForm();
+ synthesizeKey("KEY_ArrowDown");
+ break;
+
+ case 11:
+ // Set first entry w/o triggering autocomplete
+ synthesizeKey("KEY_ArrowDown");
+ synthesizeKey("KEY_ArrowLeft");
+ checkForm("value1");
+
+ // Trigger autocomplete popup
+ expectPopup();
+ restoreForm();
+ synthesizeKey("KEY_ArrowDown");
+ break;
+
+ case 12:
+ // Check first entry (page up)
+ synthesizeKey("KEY_ArrowDown");
+ synthesizeKey("KEY_ArrowDown");
+ synthesizeKey("KEY_PageUp");
+ synthesizeKey("KEY_Enter");
+ checkForm("value1");
+
+ // Trigger autocomplete popup
+ expectPopup();
+ restoreForm();
+ synthesizeKey("KEY_ArrowDown");
+ break;
+
+ case 13:
+ // Check last entry (page down)
+ synthesizeKey("KEY_ArrowDown");
+ synthesizeKey("KEY_PageDown");
+ synthesizeKey("KEY_Enter");
+ checkForm("value4");
+
+ // Trigger autocomplete popup
+ testNum = 49;
+ expectPopup();
+ restoreForm();
+ synthesizeKey("KEY_ArrowDown");
+ break;
+
+ /* Test removing entries from the dropdown */
+
+ case 50:
+ checkMenuEntries(["value1", "value2", "value3", "value4"], testNum);
+ // Delete the first entry (of 4)
+ setForm("value");
+ synthesizeKey("KEY_ArrowDown");
+
+ // 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.
+ if (SpecialPowers.OS == "Darwin") {
+ synthesizeKey("KEY_Backspace", {shiftKey: true});
+ } else {
+ synthesizeKey("KEY_Delete", {shiftKey: true});
+ }
+
+ // This tests that on OS X shift-backspace didn't delete the last character
+ // in the input (bug 480262).
+ waitForMenuChange(3);
+ break;
+
+ case 51:
+ checkForm("value");
+ countEntries("field1", "value1",
+ function(num) {
+ ok(!num, testNum + " checking that f1/v1 was deleted");
+ runTest();
+ });
+ break;
+
+ case 52:
+ synthesizeKey("KEY_Enter");
+ checkForm("value2");
+
+ // Trigger autocomplete popup
+ expectPopup();
+ restoreForm();
+ synthesizeKey("KEY_ArrowDown");
+ break;
+
+ case 53:
+ checkMenuEntries(["value2", "value3", "value4"], testNum);
+ // Check the new first entry (of 3)
+ synthesizeKey("KEY_ArrowDown");
+ synthesizeKey("KEY_Enter");
+ checkForm("value2");
+
+ // Trigger autocomplete popup
+ expectPopup();
+ restoreForm();
+ synthesizeKey("KEY_ArrowDown");
+ break;
+
+ case 54:
+ // Delete the second entry (of 3)
+ synthesizeKey("KEY_ArrowDown");
+ synthesizeKey("KEY_ArrowDown");
+ synthesizeKey("KEY_Delete", {shiftKey: true});
+ waitForMenuChange(2);
+ break;
+
+ case 55:
+ checkForm("");
+ countEntries("field1", "value3",
+ function(num) {
+ ok(!num, testNum + " checking that f1/v3 was deleted");
+ runTest();
+ });
+ break;
+
+ case 56:
+ synthesizeKey("KEY_Enter");
+ checkForm("value4");
+
+ // Trigger autocomplete popup
+ expectPopup();
+ restoreForm();
+ synthesizeKey("KEY_ArrowDown");
+ break;
+
+ case 57:
+ checkMenuEntries(["value2", "value4"], testNum);
+ // Check the new first entry (of 2)
+ synthesizeKey("KEY_ArrowDown");
+ synthesizeKey("KEY_Enter");
+ checkForm("value2");
+
+ // Trigger autocomplete popup
+ expectPopup();
+ restoreForm();
+ synthesizeKey("KEY_ArrowDown");
+ break;
+
+ case 58:
+ // Delete the last entry (of 2)
+ synthesizeKey("KEY_ArrowDown");
+ synthesizeKey("KEY_ArrowDown");
+ synthesizeKey("KEY_Delete", {shiftKey: true});
+ checkForm("");
+ waitForMenuChange(1);
+ break;
+
+ case 59:
+ countEntries("field1", "value4",
+ function(num) {
+ ok(!num, testNum + " checking that f1/v4 was deleted");
+ runTest();
+ });
+ break;
+
+ case 60:
+ synthesizeKey("KEY_Enter");
+ checkForm("value2");
+
+ // Trigger autocomplete popup
+ expectPopup();
+ restoreForm();
+ synthesizeKey("KEY_ArrowDown");
+ break;
+
+ case 61:
+ checkMenuEntries(["value2"], testNum);
+ // Check the new first entry (of 1)
+ synthesizeKey("KEY_ArrowDown");
+ synthesizeKey("KEY_Enter");
+ checkForm("value2");
+
+ // Trigger autocomplete popup
+ expectPopup();
+ restoreForm();
+ synthesizeKey("KEY_ArrowDown");
+ break;
+
+ case 62:
+ // Delete the only remaining entry
+ synthesizeKey("KEY_ArrowDown");
+ synthesizeKey("KEY_Delete", {shiftKey: true});
+ waitForMenuChange(0);
+ break;
+
+ case 63:
+ checkForm("");
+ countEntries("field1", "value2",
+ function(num) {
+ ok(!num, testNum + " checking that f1/v2 was deleted");
+ runTest();
+ });
+ break;
+
+ case 64:
+ // Look at form 2, trigger autocomplete popup
+ input = $_(2, "field2");
+ testNum = 99;
+ expectPopup();
+ restoreForm();
+ synthesizeKey("KEY_ArrowDown");
+ break;
+
+ /* Test entries with autocomplete=off */
+
+ case 100:
+ // Select first entry
+ synthesizeKey("KEY_ArrowDown");
+ synthesizeKey("KEY_Enter");
+ checkForm("value1");
+
+ // Look at form 3, try to trigger autocomplete popup
+ input = $_(3, "field2");
+ restoreForm();
+ // 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.
+ synthesizeKey("KEY_ArrowDown");
+ waitForScroll();
+ break;
+
+ case 101:
+ // Ensure there's no autocomplete dropdown (autocomplete=off is present)
+ synthesizeKey("KEY_ArrowDown");
+ synthesizeKey("KEY_Enter");
+ checkForm("");
+
+ // Look at form 4, try to trigger autocomplete popup
+ input = $_(4, "field2");
+ restoreForm();
+ synthesizeKey("KEY_ArrowDown");
+ waitForMenuChange(0);
+ break;
+
+ case 102:
+ // Ensure there's no autocomplete dropdown (autocomplete=off is present)
+ synthesizeKey("KEY_ArrowDown");
+ synthesizeKey("KEY_Enter");
+ checkForm("");
+
+ // Look at form 5, try to trigger autocomplete popup
+ input = $_(5, "field3");
+ restoreForm();
+ testNum = 199;
+ expectPopup();
+ input.focus();
+ sendChar("a");
+ break;
+
+ /* Test filtering as characters are typed. */
+
+ case 200:
+ checkMenuEntries(["a", "aa", "aaz", "aa\xe6", "az"], testNum);
+ input.focus();
+ sendChar("a");
+ waitForMenuChange(3);
+ break;
+
+ case 201:
+ checkMenuEntries(["aa", "aaz", "aa\xe6"], testNum);
+ input.focus();
+ sendChar("\xc6");
+ waitForMenuChange(1);
+ break;
+
+ case 202:
+ checkMenuEntries(["aa\xe6"], testNum);
+ synthesizeKey("KEY_Backspace");
+ waitForMenuChange(3);
+ break;
+
+ case 203:
+ checkMenuEntries(["aa", "aaz", "aa\xe6"], testNum);
+ synthesizeKey("KEY_Backspace");
+ waitForMenuChange(5);
+ break;
+
+ case 204:
+ checkMenuEntries(["a", "aa", "aaz", "aa\xe6", "az"], testNum);
+ input.focus();
+ sendChar("z");
+ waitForMenuChange(2);
+ break;
+
+ case 205:
+ checkMenuEntries(["az", "aaz"], testNum);
+ input.focus();
+ synthesizeKey("KEY_ArrowLeft");
+ expectPopup();
+ // Check case-insensitivity.
+ sendChar("A");
+ break;
+
+ case 206:
+ checkMenuEntries(["aaz"], testNum);
+ addEntry("field3", "aazq");
+ break;
+
+ case 207:
+ // check that results were cached
+ input.focus();
+ synthesizeKey("KEY_ArrowRight");
+ sendChar("q");
+ waitForMenuChange(0);
+ break;
+
+ case 208:
+ // check that results were cached
+ checkMenuEntries([], testNum);
+ addEntry("field3", "aazqq");
+ break;
+
+ case 209:
+ input.focus();
+ window.scrollTo(0, 0);
+ sendChar("q");
+ waitForMenuChange(0);
+ break;
+
+ case 210:
+ // check that empty results were cached - bug 496466
+ checkMenuEntries([], testNum);
+ synthesizeKey("KEY_Escape");
+
+ // Look at form 6, try to trigger autocomplete popup
+ input = $_(6, "field4");
+ restoreForm();
+ testNum = 249;
+ expectPopup();
+ input.focus();
+ sendChar("a");
+ break;
+
+ /* Test substring matches and word boundary bonuses */
+
+ case 250:
+ // alphabetical results for first character
+ checkMenuEntries(["aa a\xe6", "aba\xe6", "a\xe6"], testNum);
+ input.focus();
+
+ sendChar("\xe6");
+ waitForMenuChange(3, "a\xe6");
+ break;
+
+ case 251:
+ // prefix match comes first, then word boundary match
+ // followed by substring match
+ checkMenuEntries(["a\xe6", "aa a\xe6", "aba\xe6"], testNum);
+
+ restoreForm();
+ input.focus();
+ sendChar("b");
+ waitForMenuChange(1, "bc d\xe6");
+ break;
+
+ case 252:
+ checkMenuEntries(["bc d\xe6"], testNum);
+ input.focus();
+ sendChar(" ");
+ waitForMenuChange(1);
+ break;
+
+ case 253:
+ // check that trailing space has no effect after single char.
+ checkMenuEntries(["bc d\xe6"], testNum);
+ input.focus();
+ sendChar("\xc6");
+ waitForMenuChange(2);
+ break;
+
+ case 254:
+ // check multi-word substring matches
+ checkMenuEntries(["bc d\xe6", "aba\xe6"]);
+ input.focus();
+ expectPopup();
+ synthesizeKey("KEY_ArrowLeft");
+ sendChar("d");
+ break;
+
+ case 255:
+ // check inserting in multi-word searches
+ checkMenuEntries(["bc d\xe6"], testNum);
+ input.focus();
+ sendChar("z");
+ waitForMenuChange(0);
+ break;
+
+ case 256:
+ checkMenuEntries([], testNum);
+
+ // Look at form 7, try to trigger autocomplete popup
+ input = $_(7, "field5");
+ testNum = 299;
+ expectPopup();
+ restoreForm();
+ synthesizeKey("KEY_ArrowDown");
+ break;
+
+ case 300:
+ checkMenuEntries(["1", "12", "123", "1234"], testNum);
+ input.maxLength = 4;
+ expectPopup();
+ synthesizeKey("KEY_Escape");
+ synthesizeKey("KEY_ArrowDown");
+ break;
+
+ case 301:
+ checkMenuEntries(["1", "12", "123", "1234"], testNum);
+ input.maxLength = 3;
+ expectPopup();
+ synthesizeKey("KEY_Escape");
+ synthesizeKey("KEY_ArrowDown");
+ break;
+
+ case 302:
+ checkMenuEntries(["1", "12", "123"], testNum);
+ input.maxLength = 2;
+ expectPopup();
+ synthesizeKey("KEY_Escape");
+ synthesizeKey("KEY_ArrowDown");
+ break;
+
+ case 303:
+ checkMenuEntries(["1", "12"], testNum);
+ input.maxLength = 1;
+ expectPopup();
+ synthesizeKey("KEY_Escape");
+ synthesizeKey("KEY_ArrowDown");
+ break;
+
+ case 304:
+ checkMenuEntries(["1"], testNum);
+ input.maxLength = 0;
+ synthesizeKey("KEY_Escape");
+ synthesizeKey("KEY_ArrowDown");
+ waitForMenuChange(0);
+ break;
+
+ case 305:
+ checkMenuEntries([], testNum);
+ input.maxLength = 4;
+
+ // now again with a character typed
+ input.focus();
+ sendChar("1");
+ expectPopup();
+ synthesizeKey("KEY_Escape");
+ synthesizeKey("KEY_ArrowDown");
+ break;
+
+ case 306:
+ checkMenuEntries(["1", "12", "123", "1234"], testNum);
+ input.maxLength = 3;
+ expectPopup();
+ synthesizeKey("KEY_Escape");
+ synthesizeKey("KEY_ArrowDown");
+ break;
+
+ case 307:
+ checkMenuEntries(["1", "12", "123"], testNum);
+ input.maxLength = 2;
+ expectPopup();
+ synthesizeKey("KEY_Escape");
+ synthesizeKey("KEY_ArrowDown");
+ break;
+
+ case 308:
+ checkMenuEntries(["1", "12"], testNum);
+ input.maxLength = 1;
+ expectPopup();
+ synthesizeKey("KEY_Escape");
+ synthesizeKey("KEY_ArrowDown");
+ break;
+
+ case 309:
+ checkMenuEntries(["1"], testNum);
+ input.maxLength = 0;
+ synthesizeKey("KEY_Escape");
+ synthesizeKey("KEY_ArrowDown");
+ waitForMenuChange(0);
+ break;
+
+ case 310:
+ checkMenuEntries([], testNum);
+
+ input = $_(8, "field6");
+ testNum = 399;
+ expectPopup();
+ restoreForm();
+ synthesizeKey("KEY_ArrowDown");
+ break;
+
+ case 400:
+ case 401:
+ case 402:
+ case 403:
+ checkMenuEntries(["value"], testNum);
+ synthesizeKey("KEY_ArrowDown");
+ synthesizeKey("KEY_Enter");
+ checkForm("value");
+
+ if (testNum == 400) {
+ input = $_(9, "field7");
+ } else if (testNum == 401) {
+ input = $_(10, "field8");
+ } else if (testNum == 402) {
+ input = $_(11, "field9");
+ } else if (testNum == 403) {
+ todo(false, "Fix input type=number");
+ input = $_(12, "field10");
+ }
+
+ expectPopup();
+ restoreForm();
+ synthesizeKey("KEY_ArrowDown");
+ break;
+
+ case 404:
+ checkMenuEntries(["42"], testNum);
+ synthesizeKey("KEY_ArrowDown");
+ synthesizeKey("KEY_Enter");
+ checkForm("42");
+
+ input = $_(14, "field11");
+ restoreForm();
+ waitForMenuChange(0);
+ break;
+
+ case 405:
+ checkMenuEntries([]); // type=date with it's own control frame does not
+ // have a drop down menu for now
+ checkForm("");
+
+ input = $_(15, "field12");
+ restoreForm();
+ waitForMenuChange(0);
+ break;
+
+ case 406:
+ checkMenuEntries([]); // type=time with it's own control frame does not
+ // have a drop down menu for now
+ checkForm("");
+
+ input = $_(16, "field13");
+ restoreForm();
+ synthesizeKey("KEY_ArrowDown");
+ waitForMenuChange(0);
+ break;
+
+ case 407:
+ checkMenuEntries([]); // type=range does not have a drop down menu
+ synthesizeKey("KEY_ArrowDown");
+ synthesizeKey("KEY_Enter");
+ checkForm("30"); // default (midway between minimum (0) and maximum (64)) - step
+
+ input = $_(17, "field14");
+ restoreForm();
+ waitForMenuChange(0);
+ break;
+
+ case 408:
+ checkMenuEntries([]); // type=color does not have a drop down menu
+ checkForm("#000000"); // default color value
+
+ input = $_(18, "field15");
+ restoreForm();
+ expectPopup();
+ synthesizeKey("KEY_ArrowDown");
+ break;
+
+ case 409:
+ checkMenuEntries(["2016-08"]);
+ synthesizeKey("KEY_ArrowDown");
+ synthesizeKey("KEY_Enter");
+ checkForm("2016-08");
+
+ input = $_(19, "field16");
+ restoreForm();
+ expectPopup();
+ synthesizeKey("KEY_ArrowDown");
+ break;
+
+ case 410:
+ checkMenuEntries(["2016-W32"]);
+ synthesizeKey("KEY_ArrowDown");
+ synthesizeKey("KEY_Enter");
+ checkForm("2016-W32");
+
+ input = $_(20, "field17");
+ restoreForm();
+ expectPopup();
+ synthesizeKey("KEY_ArrowDown");
+ break;
+
+ case 411:
+ checkMenuEntries(["2016-10-21T10:10"]);
+ synthesizeKey("KEY_ArrowDown");
+ synthesizeKey("KEY_Enter");
+ checkForm("2016-10-21T10:10");
+
+ addEntry("field1", "value1");
+ break;
+
+ case 412:
+ input = $_(1, "field1");
+ // Go to test 500.
+ testNum = 499;
+
+ expectPopup();
+ restoreForm();
+ synthesizeKey("KEY_ArrowDown");
+ break;
+
+ // Check that the input event is fired.
+ case 500: {
+ let beforeInputFired = false;
+ input.addEventListener("beforeinput", (event) => {
+ beforeInputFired = true;
+ ok(event instanceof InputEvent,
+ `${testNum} "beforeinput" event should be dispatched with InputEvent interface`);
+ ok(event.bubbles, `${testNum} "beforeinput" event should bubble`);
+ is(event.cancelable, kSetUserInputCancelable,
+ `${testNum} "beforeinput" event for "insertReplacementText" should be cancelable unless it's suppressed by the pref`);
+ is(event.inputType, "insertReplacementText",
+ `${testNum} inputType of "beforeinput" event should be "insertReplacementText"`);
+ is(event.data, "value1",
+ `${testNum} data of "beforeinput" event should be "value1"`);
+ is(event.dataTransfer, null,
+ `${testNum} dataTransfer of "beforeinput" event should be null`);
+ is(event.getTargetRanges().length, 0,
+ `${testNum} getTargetRanges() of "beforeinput" event should empty array`);
+ is(input.value, "", `${testNum} input value should've not been modified yet at "beforeinput" event`);
+ }, {once: true});
+ let inputFired = false;
+ input.addEventListener("input", (event) => {
+ inputFired = true;
+ ok(event instanceof InputEvent,
+ `${testNum} "input" event should be dispatched with InputEvent interface`);
+ ok(event.bubbles, `${testNum} "input" event should bubble`);
+ ok(!event.cancelable, `${testNum} "input" event shouldn't be cancelable`);
+ is(event.inputType, "insertReplacementText",
+ `${testNum} inputType of "input" event should be "insertReplacementText"`);
+ is(event.data, "value1",
+ `${testNum} data of "input" event should be "value1"`);
+ is(event.dataTransfer, null,
+ `${testNum} dataTransfer of "input" event should be null`);
+ is(event.getTargetRanges().length, 0,
+ `${testNum} getTargetRanges() of "input" event should empty array`);
+ is(input.value, "value1", `${testNum} input value should've already been modified at "input" event`);
+ }, {once: true});
+
+ synthesizeKey("KEY_ArrowDown");
+ checkForm("");
+ synthesizeKey("KEY_Enter");
+ ok(beforeInputFired, `${testNum} "beforeinput" event should have been fired`);
+ ok(inputFired, `${testNum} "input" event should have been fired`);
+ checkForm("value1");
+
+ expectPopup();
+ restoreForm();
+ synthesizeKey("KEY_ArrowDown");
+ break;
+ }
+ // Check that canceling the beforeinput event cancels autocompletion.
+ case 501: {
+ await SpecialPowers.pushPrefEnv({
+ set: [["dom.input_event.allow_to_cancel_set_user_input", true]],
+ });
+ input.addEventListener("beforeinput", (event) => { event.preventDefault(); }, {once: true});
+ let inputFired = false;
+ input.addEventListener("input", () => { inputFired = true; }, {once: true});
+
+ restoreForm();
+ synthesizeKey("KEY_ArrowDown");
+ checkForm("");
+ synthesizeKey("KEY_Enter");
+ ok(!inputFired, `${testNum} "input" event should not have been fired since "beforeinput" was canceled`);
+ checkForm("");
+
+ await SpecialPowers.pushPrefEnv({
+ clear: [["dom.input_event.allow_to_cancel_set_user_input"]],
+ });
+
+ // Go to test 500.
+ testNum = 599;
+ setTimeout(runTest, 100);
+ break;
+ }
+ case 600:
+ // check we don't show autocomplete for searchbar-history
+ input = $_(13, "searchbar-history");
+
+ // Trigger autocomplete popup
+ checkForm("");
+ restoreForm();
+ synthesizeKey("KEY_ArrowDown");
+ waitForMenuChange(0);
+ break;
+
+ case 601:
+ checkMenuEntries([], testNum);
+ input.blur();
+ SimpleTest.finish();
+ return;
+
+ default:
+ ok(false, "Unexpected invocation of test #" + testNum);
+ SimpleTest.finish();
+ }
+}
+
+function addEntry(name, value) {
+ updateFormHistory({ op: "add", fieldname: name, value }, runTest);
+}
+
+// Runs the next test when scroll event occurs
+function waitForScroll() {
+ addEventListener("scroll", function listener() {
+ if (!window.pageYOffset) {
+ return;
+ }
+
+ removeEventListener("scroll", listener, false);
+ setTimeout(runTest, 100);
+ }, false);
+}
+
+function waitForMenuChange(expectedCount, expectedFirstValue) {
+ notifyMenuChanged(expectedCount, expectedFirstValue, runTest);
+}
+
+function checkMenuEntries(expectedValues, testNumber) {
+ let actualValues = getMenuEntries();
+ is(actualValues.length, expectedValues.length, testNumber + " Checking length of expected menu");
+ for (let i = 0; i < expectedValues.length; i++) {
+ is(actualValues[i], expectedValues[i], testNumber + " Checking menu entry #" + i);
+ }
+}
+
+function startTest() {
+ setupFormHistory(function() {
+ runTest();
+ });
+}
+
+SimpleTest.waitForFocus(startTest);
+SimpleTest.waitForExplicitFinish();
+SimpleTest.requestFlakyTimeout("untriaged");
+</script>
+</pre>
+</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..e7170dbf06
--- /dev/null
+++ b/toolkit/components/satchel/test/test_form_autocomplete_validation_at_input_event.html
@@ -0,0 +1,102 @@
+<!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>
+
+<pre id="test">
+<script class="testbody" type="text/javascript">
+
+SimpleTest.waitForExplicitFinish();
+SimpleTest.waitForFocus(async () => {
+ function promiseInitFormHistory() {
+ return new Promise(resolve => {
+ updateFormHistory([
+ { op: "remove" },
+ { op: "add", fieldname: "field1", value: "1234" },
+ { op: "add", fieldname: "field1", value: "12345" },
+ ], resolve);
+ });
+ }
+ await promiseInitFormHistory();
+
+ let input = document.querySelector("input[name=field1]");
+ input.focus();
+
+ let resolver;
+ function onPopup() {
+ if (resolver) {
+ resolver();
+ }
+ }
+ registerPopupShownListener(onPopup);
+
+ function promiseOpenPopup() {
+ return new Promise(resolve => {
+ resolver = resolve;
+ });
+ }
+ let waitForOpenPopup = promiseOpenPopup();
+ let inputFired = false;
+ ok(true, "Typing first character to open popup...");
+ input.addEventListener("input", (aEvent) => {
+ inputFired = true;
+ is(aEvent.inputType, "insertText", 'Typing "1" should cause an "input" event whose inputType is "insertText"');
+ is(input.validity.valid, false, 'Typing "1" should mark it as "invalid"');
+ is(input.matches(":invalid"), true, 'Typing "1" should cause matching with ":invalid" pseudo-class');
+ }, {once: true, capture: true});
+ synthesizeKey("1");
+ ok(inputFired, 'Typing "1" should cause one "input" event');
+ await waitForOpenPopup;
+
+ inputFired = false;
+ input.addEventListener("input", (aEvent) => {
+ inputFired = true;
+ is(aEvent.inputType, "insertReplacementText", 'Selecting valid value should cause an "input" event whose inputType is "insertReplacementText"');
+ is(input.validity.valid, true, 'Selecting valid value should mark it as "valid"');
+ is(input.matches(":invalid"), false, 'Selecting valid value should cause not matching with ":invalid" pseudo-class');
+ }, {once:true, capture:true});
+ synthesizeKey("KEY_ArrowDown");
+ synthesizeKey("KEY_Enter");
+ ok(inputFired, 'Selecting valid item should cause an "input" event');
+
+ ok(true, "Reopening popup...");
+ waitForOpenPopup = promiseOpenPopup();
+ synthesizeKey("KEY_ArrowDown");
+ await waitForOpenPopup;
+
+ inputFired = false;
+ input.addEventListener("input", (aEvent) => {
+ inputFired = true;
+ is(aEvent.inputType, "insertReplacementText", 'Selecting invalid value should cause an "input" event whose inputType is "insertReplacementText"');
+ is(input.validity.valid, false, 'Selecting invalid value should mark it as "invalid"');
+ is(input.matches(":invalid"), true, 'Selecting invalid value should cause 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');
+
+ SimpleTest.finish();
+});
+</script>
+</pre>
+</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..1019df7f49
--- /dev/null
+++ b/toolkit/components/satchel/test/test_form_autocomplete_with_list.html
@@ -0,0 +1,556 @@
+<!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>
+
+ <!-- form with autocomplete=off on input -->
+ <form id="form3" onsubmit="return false;">
+ <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" onsubmit="return false;">
+ <input list="suggest" type="text" name="field2">
+ <button type="submit">Submit</button>
+ </form>
+
+ <datalist id="suggest">
+ <option value="Google" label="PASS1">FAIL</option>
+ <option value="Reddit">PASS2</option>
+ <option value="final"></option>
+ </datalist>
+</div>
+
+<pre id="test">
+<script class="testbody" type="text/javascript">
+/* eslint-disable complexity */
+
+/** Test for Form History autocomplete **/
+
+var input = $_(1, "field1");
+
+function setupFormHistory(aCallback) {
+ updateFormHistory([
+ { op: "remove" },
+ { op: "add", fieldname: "field1", value: "historyvalue" },
+ { op: "add", fieldname: "field2", value: "othervalue" },
+ ], aCallback);
+}
+
+function setForm(value) {
+ input.value = value;
+ input.focus();
+}
+
+// Restore the form to the default state.
+function restoreForm() {
+ setForm("");
+}
+
+// Check for expected form data.
+function checkForm(expectedValue) {
+ let formID = input.parentNode.id;
+ is(input.value, expectedValue, "Checking " + formID + " input");
+}
+
+var testNum = 0;
+var prevValue;
+var expectingPopup = false;
+var kSetUserInputCancelable = SpecialPowers.getBoolPref("dom.input_event.allow_to_cancel_set_user_input");
+
+function expectPopup() {
+ info("expecting popup for test " + testNum);
+ expectingPopup = true;
+}
+
+function popupShownListener() {
+ info("popup shown for test " + testNum);
+ if (expectingPopup) {
+ expectingPopup = false;
+ SimpleTest.executeSoon(runTest);
+ } else {
+ ok(false, "Autocomplete popup not expected during test " + testNum);
+ }
+}
+
+registerPopupShownListener(popupShownListener);
+
+/*
+* Main section of test...
+*
+* This is a bit hacky, as many operations happen asynchronously.
+* Various mechanisms call runTests as a result of operations:
+* - set expectingPopup to true, and the next test will occur when the autocomplete popup is shown
+* - call waitForMenuChange(x) to run the next test when the autocomplete popup
+* to have x items in it
+*/
+async function runTest() {
+ testNum++;
+ let datalist;
+
+ info("Starting test #" + testNum);
+
+ switch (testNum) {
+ case 1:
+ // Make sure initial form is empty.
+ checkForm("");
+ // Trigger autocomplete popup
+ expectPopup();
+ restoreForm();
+ synthesizeKey("KEY_ArrowDown");
+ break;
+ case 2:
+ checkMenuEntries(["historyvalue", "PASS1", "PASS2", "final"], testNum);
+ // Check first entry
+ synthesizeKey("KEY_ArrowDown");
+ checkForm(""); // value shouldn't update
+ synthesizeKey("KEY_Enter");
+ checkForm("historyvalue");
+
+ // Trigger autocomplete popup
+ expectPopup();
+ restoreForm();
+ synthesizeKey("KEY_ArrowDown");
+ break;
+
+ case 3:
+ // Check second entry
+ synthesizeKey("KEY_ArrowDown");
+ synthesizeKey("KEY_ArrowDown");
+ synthesizeKey("KEY_Enter");
+ checkForm("Google");
+
+ // Trigger autocomplete popup
+ expectPopup();
+ restoreForm();
+ synthesizeKey("KEY_ArrowDown");
+ break;
+
+ case 4:
+ // Check third entry
+ synthesizeKey("KEY_ArrowDown");
+ synthesizeKey("KEY_ArrowDown");
+ synthesizeKey("KEY_ArrowDown");
+ synthesizeKey("KEY_Enter");
+ checkForm("Reddit");
+
+ // Trigger autocomplete popup
+ expectPopup();
+ restoreForm();
+ synthesizeKey("KEY_ArrowDown");
+ break;
+
+ case 5:
+ // Check fourth entry
+ synthesizeKey("KEY_ArrowDown");
+ synthesizeKey("KEY_ArrowDown");
+ synthesizeKey("KEY_ArrowDown");
+ synthesizeKey("KEY_ArrowDown");
+ synthesizeKey("KEY_Enter");
+ checkForm("final");
+ expectPopup();
+ restoreForm();
+ synthesizeKey("KEY_ArrowDown");
+ break;
+
+ case 6:
+ // Delete the first entry (of 3)
+ synthesizeKey("KEY_ArrowDown");
+ synthesizeKey("KEY_Delete", {shiftKey: true});
+ waitForMenuChange(3);
+ break;
+
+ case 7:
+ checkForm("");
+ countEntries("field1", "historyvalue",
+ function(num) {
+ ok(!num, testNum + " checking that form history value was deleted");
+ runTest();
+ });
+ break;
+
+ case 8:
+ synthesizeKey("KEY_Enter");
+ checkForm("Google");
+
+ // Trigger autocomplete popup
+ expectPopup();
+ restoreForm();
+ synthesizeKey("KEY_ArrowDown");
+ break;
+
+ case 9:
+ // Test deletion
+ checkMenuEntries(["PASS1", "PASS2", "final"], testNum);
+ // Check the new first entry (of 3)
+ synthesizeKey("KEY_ArrowDown");
+ synthesizeKey("KEY_Enter");
+ checkForm("Google");
+
+ expectPopup();
+ restoreForm();
+ synthesizeKey("KEY_ArrowDown");
+ break;
+
+ case 10:
+ // Test autocompletion of datalists with cached results.
+ sendString("PAS");
+ waitForMenuChange(2);
+ break;
+
+ case 11:
+ // Continuation of test 10
+ sendString("S1");
+ waitForMenuChange(1);
+ break;
+
+ case 12:
+ synthesizeKey("KEY_ArrowDown");
+ synthesizeKey("KEY_Enter");
+ checkForm("Google");
+
+ // Trigger autocomplete popup
+ // Look at form 3, try to trigger autocomplete popup
+ input.value = "";
+ input = $_(3, "field2");
+ testNum = 99;
+ expectPopup();
+ restoreForm();
+ synthesizeKey("KEY_ArrowDown");
+ break;
+
+ case 100:
+ checkMenuEntries(["PASS1", "PASS2", "final"], testNum);
+ // Check first entry
+ synthesizeKey("KEY_ArrowDown");
+ checkForm(""); // value shouldn't update
+ synthesizeKey("KEY_Enter");
+ checkForm("Google");
+
+ // Trigger autocomplete popup
+ expectPopup();
+ restoreForm();
+ synthesizeKey("KEY_ArrowDown");
+ break;
+
+ case 101:
+ // Check second entry
+ synthesizeKey("KEY_ArrowDown");
+ synthesizeKey("KEY_ArrowDown");
+ synthesizeKey("KEY_Enter");
+ checkForm("Reddit");
+
+ // Trigger autocomplete popup
+ expectPopup();
+ restoreForm();
+ synthesizeKey("KEY_ArrowDown");
+ break;
+
+ case 102:
+ // Check third entry
+ synthesizeKey("KEY_ArrowDown");
+ synthesizeKey("KEY_ArrowDown");
+ synthesizeKey("KEY_ArrowDown");
+ synthesizeKey("KEY_Enter");
+ checkForm("final");
+
+ // Trigger autocomplete popup
+ expectPopup();
+ restoreForm();
+ synthesizeKey("KEY_ArrowDown");
+ break;
+
+ case 103:
+ checkMenuEntries(["PASS1", "PASS2", "final"], testNum);
+ // Check first entry
+ synthesizeKey("KEY_ArrowDown");
+ checkForm(""); // value shouldn't update
+ synthesizeKey("KEY_Enter");
+ checkForm("Google");
+
+ // Trigger autocomplete popup
+ expectPopup();
+ restoreForm();
+ synthesizeKey("KEY_ArrowDown");
+ break;
+
+ case 104:
+ // Check second entry
+ synthesizeKey("KEY_ArrowDown");
+ synthesizeKey("KEY_ArrowDown");
+ synthesizeKey("KEY_Enter");
+ checkForm("Reddit");
+
+ // Trigger autocomplete popup
+ expectPopup();
+ restoreForm();
+ synthesizeKey("KEY_ArrowDown");
+ break;
+
+ case 105:
+ // Check third entry
+ synthesizeKey("KEY_ArrowDown");
+ synthesizeKey("KEY_ArrowDown");
+ synthesizeKey("KEY_ArrowDown");
+ synthesizeKey("KEY_Enter");
+ checkForm("final");
+
+ testNum = 199;
+ expectPopup();
+ restoreForm();
+ synthesizeKey("KEY_ArrowDown");
+ break;
+
+ // Test dynamic updates.
+ // For some reasons, 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.
+ case 200:
+ // Removing the second element while on the first then going down and
+ // push enter. Value should be one from the third suggesion.
+ synthesizeKey("KEY_ArrowDown");
+ datalist = document.getElementById("suggest");
+ let toRemove = datalist.children[1];
+ datalist.removeChild(toRemove);
+
+ SimpleTest.executeSoon(function() {
+ synthesizeKey("KEY_ArrowDown");
+ synthesizeKey("KEY_ArrowDown");
+ synthesizeKey("KEY_Enter");
+ checkForm("final");
+
+ // Restore the element.
+ datalist.insertBefore(toRemove, datalist.children[1]);
+ expectPopup();
+ restoreForm();
+ synthesizeKey("KEY_ArrowDown");
+ });
+ break;
+
+ case 201:
+ // 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 = document.getElementById("suggest");
+ let added = new Option("Foo");
+ datalist.insertBefore(added, datalist.children[1]);
+ waitForMenuChange(4);
+ break;
+
+ case 202:
+ synthesizeKey("KEY_ArrowDown");
+ synthesizeKey("KEY_ArrowDown");
+ synthesizeKey("KEY_Enter");
+ checkForm("Foo");
+
+ // Remove the element.
+ datalist = document.getElementById("suggest");
+ datalist.removeChild(datalist.children[1]);
+ waitForMenuChange(0);
+ break;
+
+ case 203:
+ // Change the first element value attribute.
+ restoreForm();
+ datalist = document.getElementById("suggest");
+ prevValue = datalist.children[0].value;
+ datalist.children[0].value = "foo";
+ expectPopup();
+ break;
+
+ case 204:
+ synthesizeKey("KEY_ArrowDown");
+ synthesizeKey("KEY_Enter");
+ checkForm("foo");
+
+ datalist = document.getElementById("suggest");
+ datalist.children[0].value = prevValue;
+ waitForMenuChange(0);
+ break;
+
+ case 205:
+ // Change the textContent to update the value attribute.
+ restoreForm();
+ datalist = document.getElementById("suggest");
+ prevValue = datalist.children[0].getAttribute("value");
+ datalist.children[0].removeAttribute("value");
+ datalist.children[0].textContent = "foobar";
+ expectPopup();
+ break;
+
+ case 206:
+ synthesizeKey("KEY_ArrowDown");
+ synthesizeKey("KEY_Enter");
+ checkForm("foobar");
+
+ datalist = document.getElementById("suggest");
+ datalist.children[0].setAttribute("value", prevValue);
+ testNum = 299;
+ waitForMenuChange(0);
+ break;
+
+ // Tests for filtering (or not).
+ case 300:
+ // Filters with first letter of the word.
+ restoreForm();
+ sendString("f");
+ expectPopup();
+ break;
+
+ case 301:
+ synthesizeKey("KEY_ArrowDown");
+ synthesizeKey("KEY_Enter");
+ checkForm("final");
+ expectPopup();
+ restoreForm();
+ synthesizeKey("KEY_ArrowDown");
+ break;
+
+ case 302:
+ // Filter with a letter in the middle of the word.
+ sendString("in");
+ waitForMenuChange(1);
+ break;
+
+ case 303:
+ // Continuation of test 302.
+ synthesizeKey("KEY_ArrowDown");
+ synthesizeKey("KEY_Enter");
+ checkForm("final");
+ expectPopup();
+ restoreForm();
+ synthesizeKey("KEY_ArrowDown");
+ break;
+
+ case 304:
+ // Filter is disabled with mozNoFilter.
+ input.setAttribute("mozNoFilter", "true");
+ sendString("f");
+ waitForMenuChange(3); // no change
+ break;
+
+ case 305:
+ // Continuation of test 304.
+ synthesizeKey("KEY_ArrowDown");
+ synthesizeKey("KEY_Enter");
+ checkForm("Google");
+ input.removeAttribute("mozNoFilter");
+ testNum = 399;
+ expectPopup();
+ restoreForm();
+ synthesizeKey("KEY_ArrowDown");
+ break;
+
+ case 400: {
+ let beforeInputFired = false;
+ input.addEventListener("beforeinput", (event) => {
+ beforeInputFired = true;
+ ok(event instanceof InputEvent,
+ `${testNum} "beforeinput" event should be dispatched with InputEvent interface`);
+ ok(event.bubbles, `${testNum} "beforeinput" event should bubble`);
+ is(event.cancelable, kSetUserInputCancelable, `${testNum} "beforeinput" event for "insertReplacementText" should be cancelable unless it's suppressed`);
+ is(event.inputType, "insertReplacementText", `${testNum} inputType of "beforeinput" event should be "insertReplacementText"`);
+ is(event.data, "Google", `${testNum} data of "beforeinput" event should be "Google"`);
+ is(event.dataTransfer, null, `${testNum} dataTransfer of "beforeinput" event should be null`);
+ is(event.getTargetRanges().length, 0, `${testNum} getTargetRanges() of "beforeinput" event should be empty array`);
+ }, {once: true});
+ let inputFired = false;
+ input.addEventListener("input", function(event) {
+ inputFired = true;
+ ok(event instanceof InputEvent,
+ `${testNum} "input" event should be dispatched with InputEvent interface`);
+ ok(event.bubbles, `${testNum} "input" event should bubble`);
+ ok(!event.cancelable, `${testNum} "input" event should be cancelable`);
+ is(event.inputType, "insertReplacementText", `${testNum} inputType of "input" event should be "insertReplacementText"`);
+ is(event.data, "Google", `${testNum} data of "input" event should be "Google"`);
+ is(event.dataTransfer, null, `${testNum} dataTransfer of "input" event should be null`);
+ is(event.getTargetRanges().length, 0, `${testNum} getTargetRanges() of "input" event should be empty array`);
+ checkForm("Google");
+ }, {once: true});
+
+ synthesizeKey("KEY_ArrowDown");
+ checkForm("");
+ synthesizeKey("KEY_Enter");
+ ok(beforeInputFired, `${testNum} "beforeinput" event should've been fired`);
+ ok(inputFired, `${testNum} "input" event should've been fired`);
+
+ expectPopup();
+ restoreForm();
+ synthesizeKey("KEY_ArrowDown");
+ break;
+ }
+
+ case 401: {
+ await SpecialPowers.pushPrefEnv({
+ set: [["dom.input_event.allow_to_cancel_set_user_input", true]],
+ });
+ input.addEventListener("beforeinput", (event) => { event.preventDefault(); }, {once: true});
+ let inputFired = false;
+ input.addEventListener("input", () => { inputFired = true; }, {once: true});
+
+ restoreForm();
+ synthesizeKey("KEY_ArrowDown");
+ checkForm("");
+ synthesizeKey("KEY_Enter");
+ ok(!inputFired, `${testNum} "input" event should not have been fired since "beforeinput" was canceled`);
+ checkForm("");
+
+ input.blur();
+ await SpecialPowers.pushPrefEnv({
+ clear: [["dom.input_event.allow_to_cancel_set_user_input"]],
+ });
+ SimpleTest.finish();
+ break;
+ }
+
+ default:
+ ok(false, "Unexpected invocation of test #" + testNum);
+ SimpleTest.finish();
+ }
+}
+
+function waitForMenuChange(expectedCount) {
+ notifyMenuChanged(expectedCount, null, runTest);
+}
+
+function checkMenuEntries(expectedValues, testNumber) {
+ let actualValues = getMenuEntries();
+ is(actualValues.length, expectedValues.length, testNumber + " Checking length of expected menu");
+ for (let i = 0; i < expectedValues.length; i++) {
+ is(actualValues[i], expectedValues[i], testNumber + " Checking menu entry #" + i);
+ }
+}
+
+async function startTest() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["dom.input_events.beforeinput.enabled", true]],
+ });
+
+ setupFormHistory(runTest);
+}
+
+window.onload = startTest;
+
+SimpleTest.waitForExplicitFinish();
+</script>
+</pre>
+</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..b5df2e5780
--- /dev/null
+++ b/toolkit/components/satchel/test/test_form_submission.html
@@ -0,0 +1,524 @@
+<!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. ===== -->
+
+ <!-- autocomplete=off (case-insensitive token) for input -->
+ <form id="form1" onsubmit="return checkSubmit(1)">
+ <input type="text" name="test1" autocomplete=" oFf ">
+ <button type="submit">Submit</button>
+ </form>
+
+ <!-- autocomplete=off for form -->
+ <form id="form2" onsubmit="return checkSubmit(2)" autocomplete="oFf">
+ <input type="text" name="test1">
+ <button type="submit">Submit</button>
+ </form>
+
+ <!-- don't save type=hidden -->
+ <form id="form3" onsubmit="return checkSubmit(3)">
+ <input type="hidden" name="test1">
+ <button type="submit">Submit</button>
+ </form>
+
+ <!-- don't save type=checkbox -->
+ <form id="form4" onsubmit="return checkSubmit(4)">
+ <input type="checkbox" name="test1">
+ <button type="submit">Submit</button>
+ </form>
+
+ <!-- Don't save empty values. -->
+ <form id="form5" onsubmit="return checkSubmit(5)">
+ <input type="text" name="test1" value="originalValue">
+ <button type="submit">Submit</button>
+ </form>
+
+ <!-- Don't save unchanged values. -->
+ <form id="form6" onsubmit="return checkSubmit(6)">
+ <input type="text" name="test1" value="dontSaveThis">
+ <button type="submit">Submit</button>
+ </form>
+
+ <!-- Don't save unchanged values. (.value not touched) -->
+ <form id="form7" onsubmit="return checkSubmit(7)">
+ <input type="text" name="test1" value="dontSaveThis">
+ <button type="submit">Submit</button>
+ </form>
+
+ <!-- No field name or ID. -->
+ <form id="form8" onsubmit="return checkSubmit(8)">
+ <input type="text">
+ <button type="submit">Submit</button>
+ </form>
+
+ <!-- Nothing to save! -->
+ <form id="form9" onsubmit="return checkSubmit(9)">
+ <button type="submit">Submit</button>
+ </form>
+
+ <!-- input with name too long (300 chars.) -->
+ <form id="form10" onsubmit="return checkSubmit(10)">
+ <input type="text" name="12345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890">
+ <button type="submit">Submit</button>
+ </form>
+
+ <!-- input with value too long (300 chars.) -->
+ <form id="form11" onsubmit="return checkSubmit(11)">
+ <input type="text" name="test1">
+ <button type="submit">Submit</button>
+ </form>
+
+ <!-- input with value of one space (which should be trimmed) -->
+ <form id="form12" onsubmit="return checkSubmit(12)">
+ <input type="text" name="test1">
+ <button type="submit">Submit</button>
+ </form>
+
+ <!-- password field -->
+ <form id="form13" onsubmit="return checkSubmit(13)">
+ <input type="password" name="test1">
+ <button type="submit">Submit</button>
+ </form>
+
+ <!-- password field (type toggled to password and back after pageload) -->
+ <form id="form14" onsubmit="return checkSubmit(14)">
+ <input type="text" name="test1">
+ <button type="submit">Submit</button>
+ </form>
+
+ <!-- input with sensitive data (16 digit credit card number) -->
+ <form id="form15" onsubmit="return checkSubmit(15)">
+ <script type="text/javascript">
+ var form = document.getElementById("form15");
+ for (let i = 0; i != 10; i++) {
+ let input = document.createElement("input");
+ input.type = "text";
+ input.name = "test" + (i + 1);
+ form.appendChild(input);
+ }
+ </script>
+ <button type="submit">Submit</button>
+ </form>
+
+ <!-- input with sensitive data (15 digit credit card number) -->
+ <form id="form16" onsubmit="return checkSubmit(16)">
+ <script type="text/javascript">
+ form = document.getElementById("form16");
+ for (let i = 0; i != 10; i++) {
+ let input = document.createElement("input");
+ input.type = "text";
+ input.name = "test" + (i + 1);
+ form.appendChild(input);
+ }
+ </script>
+ <button type="submit">Submit</button>
+ </form>
+
+ <!-- input with sensitive data (19 digit credit card number) -->
+ <form id="form17" onsubmit="return checkSubmit(17)">
+ <input type="text" name="test1">
+ <button type="submit">Submit</button>
+ </form>
+
+ <!-- input with sensitive data (16 digit hyphenated credit card number) -->
+ <form id="form18" onsubmit="return checkSubmit(18)">
+ <input type="text" name="test1">
+ <button type="submit">Submit</button>
+ </form>
+
+ <!-- input with sensitive data (15 digit whitespace-separated credit card number) -->
+ <form id="form19" onsubmit="return checkSubmit(19)">
+ <input type="text" name="test1">
+ <button type="submit">Submit</button>
+ </form>
+
+ <!-- Don't save values if the form is invalid. -->
+ <form id="form20" onsubmit="return checkSubmit(20);">
+ <input type='email' name='test1' oninvalid="return checkSubmit(20);">
+ <button type='submit'>Submit</button>
+ </form>
+
+ <!-- Don't save values if the form is invalid. -->
+ <form id="form21" onsubmit="return checkSubmit(21);">
+ <input type='email' value='foo' oninvalid="return checkSubmit(21);">
+ <input type='text' name='test1'>
+ <button type='submit'>Submit</button>
+ </form>
+
+ <!-- Don't save values if the input name is 'searchbar-history' -->
+ <form id="form22" onsubmit="return checkSubmit(22);">
+ <input type='text' name='searchbar-history'>
+ <button type='submit'>Submit</button>
+ </form>
+
+ <!-- autocomplete=cc-csc (case-insensitive token) for input -->
+ <form id="form23" onsubmit="return checkSubmit(23)">
+ <input type="text" name="test1" autocomplete=" cc-CSC ">
+ <button type="submit">Submit</button>
+ </form>
+
+ <!-- autocomplete=new-password (case-insensitive token) for input -->
+ <form id="form24" onsubmit="return checkSubmit(24)">
+ <input type="text" name="test1" autocomplete=" NEW-password ">
+ <button type="submit">Submit</button>
+ </form>
+
+ <!-- ===== Things that should be saved ===== -->
+
+ <!-- Form 100 is submitted into an iframe, not declared here. -->
+
+ <!-- input with no default value -->
+ <form id="form101" onsubmit="return checkSubmit(101)">
+ <input type="text" name="test1">
+ <button type="submit">Submit</button>
+ </form>
+
+ <!-- input with a default value -->
+ <form id="form102" onsubmit="return checkSubmit(102)">
+ <input type="text" name="test2" value="originalValue">
+ <button type="submit">Submit</button>
+ </form>
+
+ <!-- input uses id but not name -->
+ <form id="form103" onsubmit="return checkSubmit(103)">
+ <input type="text" id="test3">
+ <button type="submit">Submit</button>
+ </form>
+
+ <!-- input with leading and trailing space -->
+ <form id="form104" onsubmit="return checkSubmit(104)">
+ <input type="text" name="test4">
+ <button type="submit">Submit</button>
+ </form>
+
+ <!-- input with leading and trailing whitespace -->
+ <form id="form105" onsubmit="return checkSubmit(105)">
+ <input type="text" name="test5">
+ <button type="submit">Submit</button>
+ </form>
+
+ <!-- input that looks like sensitive data but doesn't
+ satisfy the requirements (incorrect length) -->
+ <form id="form106" onsubmit="return checkSubmit(106)">
+ <input type="text" name="test6">
+ <button type="submit">Submit</button>
+ </form>
+
+ <!-- input that looks like sensitive data but doesn't
+ satisfy the requirements (Luhn check fails for 16 chars) -->
+ <form id="form107" onsubmit="return checkSubmit(107)">
+ <script type="text/javascript">
+ form = document.getElementById("form107");
+ for (let i = 0; i != 10; i++) {
+ let input = document.createElement("input");
+ input.type = "text";
+ input.name = "test7_" + (i + 1);
+ form.appendChild(input);
+ }
+ </script>
+ <button type="submit">Submit</button>
+ </form>
+
+ <!-- input that looks like sensitive data but doesn't
+ satisfy the requirements (Luhn check fails for 15 chars) -->
+ <form id="form108" onsubmit="return checkSubmit(108)">
+ <script type="text/javascript">
+ form = document.getElementById("form108");
+ for (let i = 0; i != 10; i++) {
+ let input = document.createElement("input");
+ input.type = "text";
+ input.name = "test8_" + (i + 1);
+ form.appendChild(input);
+ }
+ </script>
+ <button type="submit">Submit</button>
+ </form>
+
+ <!-- form data submitted through HTTPS, when browser.formfill.saveHttpsForms is true -->
+ <form id="form109" action="https://www.example.com/" onsubmit="return checkSubmit(109)">
+ <input type="text" name="test9">
+ <button type="submit">Submit</button>
+ </form>
+
+</div>
+<pre id="test">
+<script class="testbody" type="text/javascript">
+/* eslint-disable complexity */
+
+var numSubmittedForms = 0;
+
+var 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 checkInitialState() {
+ countEntries(null, null,
+ function(num) {
+ ok(!num, "checking for initially empty storage");
+ startTest();
+ });
+}
+
+function startTest() {
+ // Fill in values for the various fields. We could just set the <input>'s
+ // value attribute, but we don't save default form values (and we want to
+ // ensure unsaved values are because of autocomplete=off or whatever).
+ $_(1, "test1").value = "dontSaveThis";
+ $_(2, "test1").value = "dontSaveThis";
+ $_(3, "test1").value = "dontSaveThis";
+ $_(4, "test1").value = "dontSaveThis";
+ $_(5, "test1").value = "";
+ $_(6, "test1").value = "dontSaveThis";
+ // Form 7 deliberately left untouched.
+ // Form 8 has an input with no name or input attribute.
+ let input = document.getElementById("form8").elements[0];
+ is(input.type, "text", "checking we got unidentified input");
+ input.value = "dontSaveThis";
+ // Form 9 has nothing to modify.
+ $_(10, "12345678901234567890123456789012345678901234567890123456789012345678901234567890123456" +
+ "789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456" +
+ "789012345678901234567890123456789012345678901234567890123456789012345678901234567890").value
+ = "dontSaveThis";
+ $_(11, "test1").value = "123456789012345678901234567890123456789012345678901234567890123456789" +
+ "012345678901234567890123456789012345678901234567890123456789012345678" +
+ "901234567890123456789012345678901234567890123456789012345678901234567" +
+ "89012345678901234567890123456789012345678901234567890";
+ $_(12, "test1").value = " ";
+ $_(13, "test1").value = "dontSaveThis";
+ $_(14, "test1").type = "password";
+ $_(14, "test1").value = "dontSaveThis";
+ // Set it back to type=text to simulate a password visibility toggle.
+ $_(14, "test1").type = "text";
+
+ let testData = ccNumbers.valid16;
+ for (let i = 0; i != testData.length; i++) {
+ $_(15, "test" + (i + 1)).value = testData[i];
+ }
+
+ testData = ccNumbers.valid15;
+ for (let i = 0; i != testData.length; i++) {
+ $_(16, "test" + (i + 1)).value = testData[i];
+ }
+ $_(17, "test1").value = "6799990100000000019";
+ $_(18, "test1").value = "0000-0000-0080-4609";
+ $_(19, "test1").value = "0000 0000 0222 331";
+ $_(20, "test1").value = "dontSaveThis";
+ $_(21, "test1").value = "dontSaveThis";
+ $_(22, "searchbar-history").value = "dontSaveThis";
+ $_(23, "test1").value = "987";
+ $_(24, "test1").value = "s3cr3t";
+
+ $_(101, "test1").value = "savedValue";
+ $_(102, "test2").value = "savedValue";
+ $_(103, "test3").value = "savedValue";
+ $_(104, "test4").value = " trimTrailingAndLeadingSpace ";
+ $_(105, "test5").value = "\t trimTrailingAndLeadingWhitespace\t ";
+ $_(106, "test6").value = "55555555555544445553"; // passes luhn but too long
+
+ testData = ccNumbers.invalid16;
+ for (let i = 0; i != testData.length; i++) {
+ $_(107, "test7_" + (i + 1)).value = testData[i];
+ }
+
+ testData = ccNumbers.invalid15;
+ for (let i = 0; i != testData.length; i++) {
+ $_(108, "test8_" + (i + 1)).value = testData[i];
+ }
+
+ $_(109, "test9").value = "savedValue";
+
+ // submit the first form.
+ let button = getFormSubmitButton(1);
+ button.click();
+}
+
+
+/* exported checkSubmit */
+// Called by each form's onsubmit handler.
+function checkSubmit(formNum) {
+ ok(true, "form " + formNum + " submitted");
+ numSubmittedForms++;
+
+ // Check for expected storage state.
+ switch (formNum) {
+ // Test 1-24 should not save anything.
+ case 1:
+ case 2:
+ case 3:
+ case 4:
+ case 5:
+ case 6:
+ case 7:
+ case 8:
+ case 9:
+ case 10:
+ case 11:
+ case 12:
+ case 13:
+ case 14:
+ case 15:
+ case 16:
+ case 17:
+ case 18:
+ case 19:
+ case 20:
+ case 21:
+ case 22:
+ case 23:
+ case 24:
+ countEntries(null, null,
+ function(num) {
+ ok(!num, "checking for empty storage");
+ submitForm(formNum);
+ });
+ return false;
+ case 100:
+ checkForSave("subtest2", "subtestValue", "checking saved subtest value");
+ break;
+ case 101:
+ checkForSave("test1", "savedValue", "checking saved value");
+ break;
+ case 102:
+ checkForSave("test2", "savedValue", "checking saved value");
+ break;
+ case 103:
+ checkForSave("test3", "savedValue", "checking saved value");
+ break;
+ case 104:
+ checkForSave("test4", "trimTrailingAndLeadingSpace",
+ "checking saved value is trimmed on both sides");
+ break;
+ case 105:
+ checkForSave("test5", "trimTrailingAndLeadingWhitespace",
+ "checking saved value is trimmed on both sides");
+ break;
+ case 106:
+ checkForSave("test6", "55555555555544445553", "checking saved value");
+ break;
+ case 107:
+ for (let i = 0; i != ccNumbers.invalid16.length; i++) {
+ checkForSave("test7_" + (i + 1), ccNumbers.invalid16[i], "checking saved value");
+ }
+ break;
+ case 108:
+ for (let i = 0; i != ccNumbers.invalid15.length; i++) {
+ checkForSave("test8_" + (i + 1), ccNumbers.invalid15[i], "checking saved value");
+ }
+ break;
+ case 109:
+ checkForSave("test9", "savedValue", "checking saved value");
+ break;
+ default:
+ ok(false, "Unexpected form submission");
+ break;
+ }
+
+ return submitForm(formNum);
+}
+
+function submitForm(formNum) {
+ // End the test now on SeaMonkey.
+ if (formNum == 20 && navigator.userAgent.match(/ SeaMonkey\//)) {
+ checkObserver.uninit();
+ is(numSubmittedForms, 20, "Ensuring all forms were submitted.");
+
+ todo(false, "Skipping remaining checks on SeaMonkey ftb. (Bug 589471)");
+ // finish(), yet let the test actually end first, to be safe.
+ SimpleTest.executeSoon(SimpleTest.finish);
+
+ return false; // return false to cancel current form submission
+ }
+
+ // End the test at the last form.
+ if (formNum == 109) {
+ is(numSubmittedForms, 34, "Ensuring all forms were submitted.");
+ checkObserver.uninit();
+ SimpleTest.executeSoon(SimpleTest.finish);
+ return false; // return false to cancel current form submission
+ }
+
+ // This timeout is here so that button.click() is never called before this
+ // function returns. If button.click() is called before returning, a long
+ // chain of submits will happen recursively since the submit is dispatched
+ // immediately.
+ //
+ // This in itself is fine, but if there are errors in the code, mochitests
+ // will in some cases give you "server too busy", which is hard to debug!
+ //
+ setTimeout(function() {
+ checkObserver.waitForChecks(function() {
+ let nextFormNum = formNum == 24 ? 100 : (formNum + 1);
+
+ // Submit the next form. Special cases are Forms 21 and 100, which happen
+ // from an HTTPS domain in an iframe.
+ if (nextFormNum == 100) {
+ ok(true, "submitting iframe test " + nextFormNum);
+
+ // Need to call checkSubmit first, as the iframe's document can be in another
+ // process and won't be able to notify back before the submit occurs.
+ checkSubmit(100);
+
+ let browsingContext = SpecialPowers.unwrap(
+ SpecialPowers.wrap(document.getElementById("iframe")).browsingContext);
+ SpecialPowers.spawn(browsingContext, [], () => {
+ /* eslint-disable no-undef */
+ content.document.querySelectorAll("button")[0].click();
+ /* eslint-enable no-undef */
+ });
+ } else {
+ let button = getFormSubmitButton(nextFormNum);
+ button.click();
+ }
+ });
+ }, 0);
+
+ return false; // cancel current form submission
+}
+
+checkObserver.init();
+
+window.onload = checkInitialState;
+
+SimpleTest.waitForExplicitFinish();
+
+</script>
+</pre>
+</body>
+</html>
diff --git a/toolkit/components/satchel/test/test_form_submission_cap.html b/toolkit/components/satchel/test/test_form_submission_cap.html
new file mode 100644
index 0000000000..bd34e7b03f
--- /dev/null
+++ b/toolkit/components/satchel/test/test_form_submission_cap.html
@@ -0,0 +1,84 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>Satchel Test for Form Submisstion Field Cap</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" onsubmit="return checkSubmit(1)">
+ <button type="submit">Submit</button>
+ </form>
+
+</div>
+<pre id="test">
+<script class="testbody" type="text/javascript">
+/* Test for bug 492701.
+ Save only the first MAX_FIELDS_SAVED changed fields in a form.
+ Generate numInputFields = MAX_FIELDS_SAVED + 1 fields, change all values,
+ and test that only MAX_FIELDS_SAVED are actually saved and that
+ field # numInputFields was not saved.
+*/
+
+var numSubmittedForms = 0;
+var numInputFields = 101;
+
+function checkInitialState() {
+ countEntries(null, null,
+ function(num) {
+ ok(!num, "checking for initially empty storage");
+ startTest();
+ });
+}
+
+function startTest() {
+ let form = document.getElementById("form1");
+ for (let i = 1; i <= numInputFields; i++) {
+ let newField = document.createElement("input");
+ newField.setAttribute("type", "text");
+ newField.setAttribute("name", "test" + i);
+ form.appendChild(newField);
+ }
+
+ // Fill in values for the various fields. We could just set the <input>'s
+ // value attribute, but we don't save default form values (and we want to
+ // ensure unsaved values are because of autocomplete=off or whatever).
+ for (let i = 1; i <= numInputFields; i++) {
+ $_(1, "test" + i).value = i;
+ }
+
+ // submit the first form.
+ let button = getFormSubmitButton(1);
+ button.click();
+}
+
+/* exported checkSubmit */
+// Called by each form's onsubmit handler.
+function checkSubmit(formNum) {
+ ok(true, "form " + formNum + " submitted");
+ numSubmittedForms++;
+
+ // check that the first (numInputFields - 1) CHANGED fields are saved
+ for (let i = 1; i < numInputFields; i++) { // check all but last
+ checkForSave("test" + i, i, "checking saved value " + i);
+ }
+
+ // End the test.
+ is(numSubmittedForms, 1, "Ensuring all forms were submitted.");
+ SimpleTest.finish();
+ return false; // return false to cancel current form submission
+}
+
+
+window.onload = checkInitialState;
+
+SimpleTest.waitForExplicitFinish();
+
+</script>
+</pre>
+</body>
+</html>
diff --git a/toolkit/components/satchel/test/test_form_submission_cap2.html b/toolkit/components/satchel/test/test_form_submission_cap2.html
new file mode 100644
index 0000000000..1e31e64ca7
--- /dev/null
+++ b/toolkit/components/satchel/test/test_form_submission_cap2.html
@@ -0,0 +1,190 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>Satchel Test for Form Submisstion Field Cap</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">
+<!--
+ Test for bug 492701.
+ Only change field # numInputFields (= MAX_FIELDS_SAVED + 1)
+ and test that it is actually saved and the other (unmodified) ones are not.
+-->
+ <form id="form1" onsubmit="return checkSubmit(1)">
+ <input type="text" name="test1" value="1">
+ <input type="text" name="test2" value="2">
+ <input type="text" name="test3" value="3">
+ <input type="text" name="test4" value="4">
+ <input type="text" name="test5" value="5">
+ <input type="text" name="test6" value="6">
+ <input type="text" name="test7" value="7">
+ <input type="text" name="test8" value="8">
+ <input type="text" name="test9" value="9">
+ <input type="text" name="test10" value="10">
+ <input type="text" name="test11" value="11">
+ <input type="text" name="test12" value="12">
+ <input type="text" name="test13" value="13">
+ <input type="text" name="test14" value="14">
+ <input type="text" name="test15" value="15">
+ <input type="text" name="test16" value="16">
+ <input type="text" name="test17" value="17">
+ <input type="text" name="test18" value="18">
+ <input type="text" name="test19" value="19">
+ <input type="text" name="test20" value="20">
+ <input type="text" name="test21" value="21">
+ <input type="text" name="test22" value="22">
+ <input type="text" name="test23" value="23">
+ <input type="text" name="test24" value="24">
+ <input type="text" name="test25" value="25">
+ <input type="text" name="test26" value="26">
+ <input type="text" name="test27" value="27">
+ <input type="text" name="test28" value="28">
+ <input type="text" name="test29" value="29">
+ <input type="text" name="test30" value="30">
+ <input type="text" name="test31" value="31">
+ <input type="text" name="test32" value="32">
+ <input type="text" name="test33" value="33">
+ <input type="text" name="test34" value="34">
+ <input type="text" name="test35" value="35">
+ <input type="text" name="test36" value="36">
+ <input type="text" name="test37" value="37">
+ <input type="text" name="test38" value="38">
+ <input type="text" name="test39" value="39">
+ <input type="text" name="test40" value="40">
+ <input type="text" name="test41" value="41">
+ <input type="text" name="test42" value="42">
+ <input type="text" name="test43" value="43">
+ <input type="text" name="test44" value="44">
+ <input type="text" name="test45" value="45">
+ <input type="text" name="test46" value="46">
+ <input type="text" name="test47" value="47">
+ <input type="text" name="test48" value="48">
+ <input type="text" name="test49" value="49">
+ <input type="text" name="test50" value="50">
+ <input type="text" name="test51" value="51">
+ <input type="text" name="test52" value="52">
+ <input type="text" name="test53" value="53">
+ <input type="text" name="test54" value="54">
+ <input type="text" name="test55" value="55">
+ <input type="text" name="test56" value="56">
+ <input type="text" name="test57" value="57">
+ <input type="text" name="test58" value="58">
+ <input type="text" name="test59" value="59">
+ <input type="text" name="test60" value="60">
+ <input type="text" name="test61" value="61">
+ <input type="text" name="test62" value="62">
+ <input type="text" name="test63" value="63">
+ <input type="text" name="test64" value="64">
+ <input type="text" name="test65" value="65">
+ <input type="text" name="test66" value="66">
+ <input type="text" name="test67" value="67">
+ <input type="text" name="test68" value="68">
+ <input type="text" name="test69" value="69">
+ <input type="text" name="test70" value="70">
+ <input type="text" name="test71" value="71">
+ <input type="text" name="test72" value="72">
+ <input type="text" name="test73" value="73">
+ <input type="text" name="test74" value="74">
+ <input type="text" name="test75" value="75">
+ <input type="text" name="test76" value="76">
+ <input type="text" name="test77" value="77">
+ <input type="text" name="test78" value="78">
+ <input type="text" name="test79" value="79">
+ <input type="text" name="test80" value="80">
+ <input type="text" name="test81" value="81">
+ <input type="text" name="test82" value="82">
+ <input type="text" name="test83" value="83">
+ <input type="text" name="test84" value="84">
+ <input type="text" name="test85" value="85">
+ <input type="text" name="test86" value="86">
+ <input type="text" name="test87" value="87">
+ <input type="text" name="test88" value="88">
+ <input type="text" name="test89" value="89">
+ <input type="text" name="test90" value="90">
+ <input type="text" name="test91" value="91">
+ <input type="text" name="test92" value="92">
+ <input type="text" name="test93" value="93">
+ <input type="text" name="test94" value="94">
+ <input type="text" name="test95" value="95">
+ <input type="text" name="test96" value="96">
+ <input type="text" name="test97" value="97">
+ <input type="text" name="test98" value="98">
+ <input type="text" name="test99" value="99">
+ <input type="text" name="test100" value="100">
+ <input type="text" name="test101" value="101">
+ <button type="submit">Submit</button>
+ </form>
+
+</div>
+<pre id="test">
+<script class="testbody" type="text/javascript">
+var numSubmittedForms = 0;
+var numInputFields = 101;
+
+function checkInitialState() {
+ countEntries(null, null,
+ function(num) {
+ ok(!num, "checking for initially empty storage");
+ startTest();
+ });
+}
+
+function startTest() {
+ // Fill in values for the various fields. We could just set the <input>'s
+ // value attribute, but we don't save default form values (and we want to
+ // ensure unsaved values are because of autocomplete=off or whatever).
+ $_(1, "test" + numInputFields).value = numInputFields + " changed";
+
+ // submit the first form.
+ let button = getFormSubmitButton(1);
+ button.click();
+}
+
+// Make sure that the first (numInputFields - 1) were not saved (as they were not changed).
+// Call done() when finished.
+function checkCountEntries(formNum, index, done) {
+ countEntries("test" + index, index,
+ function(num) {
+ ok(!num, "checking unsaved value " + index);
+ if (index < numInputFields) {
+ checkCountEntries(formNum, index + 1, done);
+ } else {
+ done(formNum);
+ }
+ });
+}
+
+/* exported checkSubmit */
+// Called by each form's onsubmit handler.
+function checkSubmit(formNum) {
+ ok(true, "form " + formNum + " submitted");
+ numSubmittedForms++;
+
+ // make sure that the field # numInputFields was saved
+ checkForSave("test" + numInputFields,
+ numInputFields + " changed",
+ "checking saved value " + numInputFields);
+
+ checkCountEntries(formNum, 1, checkSubmitCounted);
+
+ return false; // cancel current form submission
+}
+
+function checkSubmitCounted(formNum) {
+ is(numSubmittedForms, 1, "Ensuring all forms were submitted.");
+ SimpleTest.finish();
+ return false;
+}
+
+window.onload = checkInitialState;
+
+SimpleTest.waitForExplicitFinish();
+
+</script>
+</pre>
+</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..96921cc2d0
--- /dev/null
+++ b/toolkit/components/satchel/test/test_input_valid_state_with_autocomplete.html
@@ -0,0 +1,158 @@
+<!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>
+
+<pre id="test">
+<script class="testbody">
+var input = document.querySelector("input[name=field1]");
+
+async function runTest() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["dom.input_events.beforeinput.enabled", true]],
+ });
+ const kSetUserInputCancelable = SpecialPowers.getBoolPref("dom.input_event.allow_to_cancel_set_user_input");
+
+ let resolveFunc = null;
+ function onPopup() {
+ if (resolveFunc) {
+ resolveFunc();
+ resolveFunc = null;
+ }
+ }
+ registerPopupShownListener(onPopup);
+ function promisePopup() {
+ return new Promise(resolve => {
+ resolveFunc = resolve;
+ });
+ }
+
+ let beforeInputFired = false;
+ let inputFired = false;
+ let waitForPopup = promisePopup();
+ input.focus();
+ input.addEventListener("beforeinput", (event) => {
+ ok(!beforeInputFired, '"input" event should be fired only once at typing');
+ beforeInputFired = true;
+ ok(event.cancelable,
+ '"beforeinput" event for "insertText" should be cancelable');
+ is(event.inputType, "insertText",
+ "inputType of \"beforeinput\" event should be \"insertText\"");
+ ok(input.validity.valid,
+ "Should be valid immediately before inserting a character");
+ ok(!input.matches(":invalid"),
+ "Shouldn't match \":invalid\" immediately before inserting a character");
+ }, {once: true});
+ input.addEventListener("input", (event) => {
+ ok(!inputFired, '"input" event should be fired only once at typing');
+ inputFired = true;
+ is(event.inputType, "insertText",
+ "inputType of \"input\" event should be \"insertText\"");
+ ok(!input.validity.valid,
+ "Should be invalid immediately after inserting a character");
+ ok(input.matches(":invalid"),
+ "Should match \":invalid\" immediately after inserting a character");
+ }, {once: true});
+ beforeInputFired = false;
+ inputFired = false;
+ synthesizeKey("e");
+ ok(beforeInputFired, '"beforeinput" event should have been fired at typing "e"');
+ ok(inputFired, '"input" event should have been fired at typing "e"');
+
+ await waitForPopup;
+ synthesizeKey("KEY_ArrowDown");
+ input.addEventListener("beforeinput", (event) => {
+ ok(!beforeInputFired, '"input" event should be fired only once at typing');
+ beforeInputFired = true;
+ is(event.cancelable, kSetUserInputCancelable,
+ `"beforeinput" event for "insertReplacementText" should be cancelable unless it's suppressed by the pref`);
+ is(event.inputType, "insertReplacementText",
+ "inputType of \"beforeinput\" event should be \"insertReplacementText\"");
+ ok(!input.validity.valid,
+ "Should be invalid immediately before selecting valid item in autocomplete list");
+ ok(input.matches(":invalid"),
+ "Should match \":invalid\" immediately before selecting valid item in autocomplete list");
+ }, {once: true});
+ input.addEventListener("input", (event) => {
+ ok(!inputFired, '"input" event should be fired only once at typing');
+ inputFired = true;
+ is(event.inputType, "insertReplacementText",
+ "inputType of \"input\" event should be \"insertReplacementText\"");
+ ok(input.validity.valid,
+ "Should be 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});
+ beforeInputFired = false;
+ inputFired = false;
+ 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');
+
+ waitForPopup = promisePopup();
+ synthesizeKey("KEY_Backspace");
+ await waitForPopup;
+ synthesizeKey("KEY_ArrowDown");
+ synthesizeKey("KEY_ArrowDown");
+ input.addEventListener("beforeinput", (event) => {
+ ok(!beforeInputFired, '"input" event should be fired only once at typing');
+ beforeInputFired = true;
+ is(event.cancelable, kSetUserInputCancelable,
+ `"beforeinput" event for "insertReplacementText" should be cancelable unless it's suppressed by the pref`);
+ is(event.inputType, "insertReplacementText",
+ "inputType of \"beforeinput\" event should be \"insertReplacementText\"");
+ ok(input.validity.valid,
+ "Should be 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 should be fired only once at typing');
+ inputFired = true;
+ is(event.inputType, "insertReplacementText",
+ "inputType of \"input\" event should be \"insertReplacementText\"");
+ ok(!input.validity.valid,
+ "Should be invalid immediately after selecting invalid item in autocomplete list");
+ ok(input.matches(":invalid"),
+ "Should match \":invalid\" immediately after selecting invalid item in autocomplete list");
+ }, {once: true});
+ beforeInputFired = false;
+ inputFired = false;
+ 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');
+
+ SimpleTest.finish();
+}
+
+SimpleTest.waitForExplicitFinish();
+SimpleTest.waitForFocus(() => {
+ updateFormHistory([
+ { op: "remove" },
+ { op: "add", fieldname: "field1", value: "email@example.com" },
+ { op: "add", fieldname: "field1", value: "email@example.com." },
+ ], runTest);
+});
+</script>
+</pre>
+</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..c683007193
--- /dev/null
+++ b/toolkit/components/satchel/test/test_password_autocomplete.html
@@ -0,0 +1,139 @@
+<!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>
+
+<pre id="test">
+<script class="testbody" type="text/javascript">
+var resolvePopupShownListener;
+registerPopupShownListener(() => resolvePopupShownListener());
+
+function waitForNextPopup() {
+ return new Promise(resolve => { resolvePopupShownListener = resolve; });
+}
+
+/**
+ * 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;
+
+SimpleTest.requestFlakyTimeout("Must ensure that an event does not happen.");
+
+/**
+ * Checks that the popup does not open in response to the given function.
+ *
+ * @param {Function} triggerFn - function that triggers an event
+ * @returns {Promise}
+ */
+function expectPopupDoesNotOpen(triggerFn) {
+ let popupShown = waitForNextPopup();
+ triggerFn();
+ return Promise.race([
+ popupShown.then(() => Promise.reject("Popup opened unexpectedly.")),
+ new Promise(resolve => setTimeout(resolve, POPUP_RESPONSE_WAIT_TIME_MS)),
+ ]);
+}
+
+add_task(async function test_initialize() {
+ 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.
+ $_(1, "field1").type = "password";
+ $_(2, "field1").type = "password";
+
+ 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_secure_noFormHistoryOrWarning() {
+ let input = $_(1, "field1");
+
+ // 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"),
+ ]) {
+ ok(true, "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 expectPopupDoesNotOpen(triggerFn);
+ }
+
+ // Close the popup.
+ input.blur();
+});
+
+add_task(async function test_insecure_focusWarning() {
+ // Form 2 has an insecure action so should show the warning even if password manager is disabled.
+ let input = $_(2, "field1");
+ let shownPromise = waitForNextPopup();
+ input.focus();
+ await shownPromise;
+
+ ok(getMenuEntries()[0].includes("Logins entered here could be compromised"),
+ "Check warning is first");
+
+ // Close the popup
+ input.blur();
+});
+</script>
+</pre>
+</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..412b75df52
--- /dev/null
+++ b/toolkit/components/satchel/test/test_popup_direction.html
@@ -0,0 +1,60 @@
+<!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>
+
+<pre id="test">
+<script class="testbody" type="text/javascript">
+var resolvePopupShownListener;
+registerPopupShownListener(() => resolvePopupShownListener());
+
+function waitForNextPopup() {
+ return new Promise(resolve => { resolvePopupShownListener = resolve; });
+}
+
+add_task(async function test_popup_direction() {
+ let input = $_(1, "field1");
+
+ await new Promise(resolve => updateFormHistory([
+ { op: "remove" },
+ { op: "add", fieldname: "field1", value: "value1" },
+ { op: "add", fieldname: "field1", value: "value2" },
+ ], resolve));
+
+ for (let direction of ["ltr", "rtl"]) {
+ document.getElementById("content").style.direction = direction;
+
+ let popupShown = waitForNextPopup();
+ input.focus();
+ synthesizeKey("KEY_ArrowDown");
+ await popupShown;
+
+ let popupState = await new Promise(resolve => getPopupState(resolve));
+ is(popupState.direction, direction, "Direction should match.");
+
+ // Close the popup.
+ input.blur();
+ }
+});
+
+</script>
+</pre>
+</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..5125f73997
--- /dev/null
+++ b/toolkit/components/satchel/test/test_popup_enter_event.html
@@ -0,0 +1,90 @@
+<!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>
+
+<pre id="test">
+<script class="testbody">
+var form = document.getElementById("form1");
+var input = $_(1, "field1");
+var expectedValue = "value1";
+
+function setupFormHistory(aCallback) {
+ updateFormHistory([
+ { op: "remove" },
+ { op: "add", fieldname: "field1", value: "value1" },
+ ], aCallback);
+}
+
+registerPopupShownListener(popupShownListener);
+
+function handleEnter(evt) {
+ if (evt.keyCode != KeyEvent.DOM_VK_RETURN) {
+ return;
+ }
+
+ info("RETURN received for phase: " + evt.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");
+ }
+}
+
+function popupShownListener(evt) {
+ 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
+}
+
+function runTest() {
+ SpecialPowers.addSystemEventListener(input, "keypress", handleEnter, true);
+ form.addEventListener("submit", function submitCallback(evt) {
+ is(input.value, expectedValue, "Check input value in the submit handler");
+ evt.preventDefault();
+
+ input.removeEventListener("keypress", handleEnter, true);
+ form.removeEventListener("submit", submitCallback);
+
+ SimpleTest.finish();
+ });
+
+ // Focus the input before adjusting.value so that the caret goes to the end
+ // (since OS X doesn't show the dropdown otherwise).
+ input.focus();
+ input.value = "value";
+ input.focus();
+ synthesizeKey("KEY_ArrowDown");
+}
+
+function startTest() {
+ setupFormHistory(function() {
+ runTest();
+ });
+}
+
+window.onload = startTest;
+
+SimpleTest.waitForExplicitFinish();
+</script>
+</pre>
+</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..b02b6229a6
--- /dev/null
+++ b/toolkit/components/satchel/test/test_submit_on_keydown_enter.html
@@ -0,0 +1,122 @@
+<!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" action="javascript:handleSubmit()">
+ <input type="text" name="field1">
+ <button type="submit">Submit</button>
+ </form>
+</div>
+
+<pre id="test">
+<script class="testbody">
+var form = document.getElementById("form1");
+var input = $_(1, "field1");
+var expectedValue = "value1";
+var beforeInputFired = false;
+
+function handleSubmit() { // eslint-disable-line no-unused-vars
+ info("Submit");
+ ok(false, "The form should not be submitted");
+ input.removeEventListener("beforeinput", handleBeforeInput, true);
+ input.removeEventListener("input", handleInput, true);
+ SimpleTest.finish();
+}
+
+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(aEvent) {
+ info("Input");
+ ok(beforeInputFired, '"beforeinput" event should have been fired');
+ is(input.value, expectedValue, "Check input value");
+ ok(aEvent instanceof InputEvent,
+ '"input" event should be dispatched with InputEvent interface');
+ is(aEvent.cancelable, false,
+ '"input" event should be never cancelable');
+ is(aEvent.bubbles, true,
+ '"input" event should always bubble');
+ is(aEvent.inputType, "insertReplacementText",
+ 'inputType of "input" event should be "insertReplacementText"');
+ is(aEvent.data, expectedValue,
+ `data of "input" event should be "${expectedValue}"`);
+ is(aEvent.dataTransfer, null,
+ 'dataTransfer of "input" event should be null');
+ is(aEvent.getTargetRanges().length, 0,
+ 'getTargetRanges() of "input" event should return empty array');
+ input.removeEventListener("beforeinput", handleBeforeInput, true);
+ input.removeEventListener("input", handleInput, true);
+ SimpleTest.finish();
+}
+
+async function runTest() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["dom.input_events.beforeinput.enabled", 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);
+
+ registerPopupShownListener(() => {
+ synthesizeKey("KEY_ArrowDown");
+ synthesizeKey("KEY_Enter"); // select the first entry in the popup
+ });
+
+ // Focus the input before adjusting.value so that the caret goes to the end
+ // (since OS X doesn't show the dropdown otherwise).
+ input.focus();
+ input.value = "value";
+ input.focus();
+ synthesizeKey("KEY_ArrowDown");
+}
+
+function startTest() {
+ updateFormHistory([
+ { op: "remove" },
+ { op: "add", fieldname: "field1", value: "value1" },
+ ], runTest);
+}
+
+window.onload = startTest;
+
+SimpleTest.waitForExplicitFinish();
+</script>
+</pre>
+</body>
+</html>
diff --git a/toolkit/components/satchel/test/unit/asyncformhistory_expire.sqlite b/toolkit/components/satchel/test/unit/asyncformhistory_expire.sqlite
new file mode 100644
index 0000000000..07b43c2096
--- /dev/null
+++ b/toolkit/components/satchel/test/unit/asyncformhistory_expire.sqlite
Binary files differ
diff --git a/toolkit/components/satchel/test/unit/formhistory_1000.sqlite b/toolkit/components/satchel/test/unit/formhistory_1000.sqlite
new file mode 100644
index 0000000000..5eeab074fd
--- /dev/null
+++ b/toolkit/components/satchel/test/unit/formhistory_1000.sqlite
Binary files differ
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
new file mode 100644
index 0000000000..00daf03c27
--- /dev/null
+++ b/toolkit/components/satchel/test/unit/formhistory_apitest.sqlite
Binary files differ
diff --git a/toolkit/components/satchel/test/unit/formhistory_autocomplete.sqlite b/toolkit/components/satchel/test/unit/formhistory_autocomplete.sqlite
new file mode 100644
index 0000000000..724cff73f6
--- /dev/null
+++ b/toolkit/components/satchel/test/unit/formhistory_autocomplete.sqlite
Binary files differ
diff --git a/toolkit/components/satchel/test/unit/formhistory_v3.sqlite b/toolkit/components/satchel/test/unit/formhistory_v3.sqlite
new file mode 100644
index 0000000000..e0e8fe2468
--- /dev/null
+++ b/toolkit/components/satchel/test/unit/formhistory_v3.sqlite
Binary files differ
diff --git a/toolkit/components/satchel/test/unit/formhistory_v3v4.sqlite b/toolkit/components/satchel/test/unit/formhistory_v3v4.sqlite
new file mode 100644
index 0000000000..8eab177e97
--- /dev/null
+++ b/toolkit/components/satchel/test/unit/formhistory_v3v4.sqlite
Binary files differ
diff --git a/toolkit/components/satchel/test/unit/formhistory_v999a.sqlite b/toolkit/components/satchel/test/unit/formhistory_v999a.sqlite
new file mode 100644
index 0000000000..216bce4a34
--- /dev/null
+++ b/toolkit/components/satchel/test/unit/formhistory_v999a.sqlite
Binary files differ
diff --git a/toolkit/components/satchel/test/unit/formhistory_v999b.sqlite b/toolkit/components/satchel/test/unit/formhistory_v999b.sqlite
new file mode 100644
index 0000000000..fe400d04a1
--- /dev/null
+++ b/toolkit/components/satchel/test/unit/formhistory_v999b.sqlite
Binary files differ
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..ba00c0bdbf
--- /dev/null
+++ b/toolkit/components/satchel/test/unit/head_satchel.js
@@ -0,0 +1,226 @@
+/* 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.import(
+ "resource://gre/modules/XPCOMUtils.jsm"
+);
+XPCOMUtils.defineLazyModuleGetters(this, {
+ FormHistory: "resource://gre/modules/FormHistory.jsm",
+ FormHistoryTestUtils: "resource://testing-common/FormHistoryTestUtils.jsm",
+ OS: "resource://gre/modules/osfile.jsm",
+ Services: "resource://gre/modules/Services.jsm",
+ Sqlite: "resource://gre/modules/Sqlite.jsm",
+});
+
+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) {
+ let results = [];
+ FormHistory.search(terms, params, {
+ handleResult: result => results.push(result),
+ handleError(error) {
+ do_throw("Error occurred searching form history: " + error);
+ },
+ handleCompletion(reason) {
+ if (!reason) {
+ iter.next(results);
+ }
+ },
+ });
+}
+
+// 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;
+ }
+
+ let count = 0;
+ FormHistory.count(obj, {
+ handleResult: result => (count = result),
+ handleError(error) {
+ do_throw("Error occurred searching form history: " + error);
+ },
+ handleCompletion(reason) {
+ if (!reason) {
+ then(count);
+ }
+ },
+ });
+}
+
+// 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, {
+ handleError(error) {
+ do_throw("Error occurred updating form history: " + error);
+ },
+ handleCompletion(reason) {
+ if (!reason) {
+ then();
+ }
+ },
+ });
+}
+
+function promiseUpdate(change) {
+ return new Promise((resolve, reject) => {
+ FormHistory.update(change, {
+ handleError(error) {
+ this._error = error;
+ },
+ handleCompletion(reason) {
+ if (reason) {
+ reject(this._error);
+ } else {
+ resolve();
+ }
+ },
+ });
+ });
+}
+
+/**
+ * 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.overwriteExisting]
+ * Whether to overwrite an existing file.
+ * @returns {string} path to the copied file.
+ */
+async function copyToProfile(
+ aFilename,
+ aDestFilename,
+ { overwriteExisting = false } = {}
+) {
+ let curDir = await OS.File.getCurrentDirectory();
+ let srcPath = OS.Path.join(curDir, aFilename);
+ Assert.ok(await OS.File.exists(srcPath), "Database file found");
+
+ // Ensure that our file doesn't exist already.
+ let destPath = OS.Path.join(OS.Constants.Path.profileDir, aDestFilename);
+ let exists = await OS.File.exists(destPath);
+ if (exists) {
+ if (overwriteExisting) {
+ await OS.file.remove(destPath);
+ } else {
+ throw new Error("The file should not exist");
+ }
+ }
+ await OS.File.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..69185ef4e5
--- /dev/null
+++ b/toolkit/components/satchel/test/unit/test_async_expire.js
@@ -0,0 +1,136 @@
+/* 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.defineModuleGetter(
+ this,
+ "TestUtils",
+ "resource://testing-common/TestUtils.jsm"
+);
+
+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..8576aeef81
--- /dev/null
+++ b/toolkit/components/satchel/test/unit/test_autocomplete.js
@@ -0,0 +1,363 @@
+/* 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);
+ },
+ }
+ );
+ });
+});
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..5822807a5c
--- /dev/null
+++ b/toolkit/components/satchel/test/unit/test_db_corrupt.js
@@ -0,0 +1,98 @@
+/* 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."
+ );
+
+ FormHistory.count(
+ {},
+ {
+ handleResult(aNumEntries) {
+ Assert.ok(aNumEntries == 0);
+ FormHistory.count(
+ { fieldname: "name-A", value: "value-A" },
+ {
+ handleResult(aNumEntries2) {
+ Assert.ok(aNumEntries2 == 0);
+ run_next_test();
+ },
+ handleError(aError2) {
+ do_throw(
+ "DB initialized after reading a corrupt DB file found an entry."
+ );
+ },
+ }
+ );
+ },
+ handleError(aError) {
+ 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..41af3124c3
--- /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..abb72ca3a8
--- /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..fed5ff968e
--- /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..ff4dc389aa
--- /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..b2a4b2b9d7
--- /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..001f248734
--- /dev/null
+++ b/toolkit/components/satchel/test/unit/test_history_api.js
@@ -0,0 +1,538 @@
+/* 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 { Promise } = ChromeUtils.import("resource://gre/modules/Promise.jsm");
+
+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 = Promise.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.
+ deferred = Promise.defer();
+ await FormHistory.count(
+ { fieldname: null, value: null },
+ {
+ handleResult: result => checkNotExists(result),
+ handleError(error) {
+ do_throw("Error occurred searching form history: " + error);
+ },
+ handleCompletion(reason) {
+ if (!reason) {
+ deferred.resolve();
+ }
+ },
+ }
+ );
+ await deferred.promise;
+
+ // ===== 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" }),
+ function(err) {
+ return err.result == Ci.mozIStorageError.MISUSE;
+ },
+ "bumping when form history is disabled should fail"
+ );
+ await Assert.rejects(
+ promiseUpdate({ op: "add", fieldname: "field5", value: "value5" }),
+ function(err) {
+ return err.result == Ci.mozIStorageError.MISUSE;
+ },
+ "Adding when form history is disabled should fail"
+ );
+ await Assert.rejects(
+ promiseUpdate([
+ { op: "update", fieldname: "field5", value: "value5" },
+ { op: "remove", fieldname: "field5", value: "value5" },
+ ]),
+ function(err) {
+ return err.result == Ci.mozIStorageError.MISUSE;
+ },
+ "mixed operations when form history is disabled should fail"
+ );
+ await Assert.rejects(
+ promiseUpdate([
+ null,
+ undefined,
+ "",
+ 1,
+ {},
+ { op: "remove", fieldname: "field5", value: "value5" },
+ ]),
+ function(err) {
+ return err.result == Ci.mozIStorageError.MISUSE;
+ },
+ "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..5c72bcdd8f
--- /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..b4468ced69
--- /dev/null
+++ b/toolkit/components/satchel/test/unit/test_notify.js
@@ -0,0 +1,205 @@
+/*
+ * Test suite for satchel notifications
+ *
+ * Tests notifications dispatched when modifying form history.
+ *
+ */
+
+ChromeUtils.defineModuleGetter(
+ this,
+ "setTimeout",
+ "resource://gre/modules/Timer.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ this,
+ "Preferences",
+ "resource://gre/modules/Preferences.jsm"
+);
+
+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_task(async function setup() {
+ 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 new Promise(res => {
+ FormHistory.update(
+ {
+ op: "remove",
+ fieldname: entry1[0],
+ value: entry1[1],
+ guid,
+ },
+ {
+ handleError(error) {
+ do_throw("Error occurred updating form history: " + error);
+ },
+ handleCompletion(reason) {
+ if (!reason) {
+ res();
+ }
+ },
+ }
+ );
+ });
+ 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 new Promise(res => {
+ FormHistory.update(
+ {
+ op: "remove",
+ firstUsedStart: 10,
+ firstUsedEnd: cutoffDate * 1000,
+ },
+ {
+ handleCompletion(reason) {
+ if (!reason) {
+ res();
+ }
+ },
+ handleErrors(error) {
+ do_throw("Error occurred updating form history: " + error);
+ },
+ }
+ );
+ });
+ 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..389430ddc5
--- /dev/null
+++ b/toolkit/components/satchel/test/unit/xpcshell.ini
@@ -0,0 +1,26 @@
+[DEFAULT]
+head = head_satchel.js
+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]
+[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]