summaryrefslogtreecommitdiffstats
path: root/toolkit/components/satchel
diff options
context:
space:
mode:
authorMatt A. Tobin <mattatobin@localhost.localdomain>2018-02-02 04:16:08 -0500
committerMatt A. Tobin <mattatobin@localhost.localdomain>2018-02-02 04:16:08 -0500
commit5f8de423f190bbb79a62f804151bc24824fa32d8 (patch)
tree10027f336435511475e392454359edea8e25895d /toolkit/components/satchel
parent49ee0794b5d912db1f95dce6eb52d781dc210db5 (diff)
downloadUXP-5f8de423f190bbb79a62f804151bc24824fa32d8.tar
UXP-5f8de423f190bbb79a62f804151bc24824fa32d8.tar.gz
UXP-5f8de423f190bbb79a62f804151bc24824fa32d8.tar.lz
UXP-5f8de423f190bbb79a62f804151bc24824fa32d8.tar.xz
UXP-5f8de423f190bbb79a62f804151bc24824fa32d8.zip
Add m-esr52 at 52.6.0
Diffstat (limited to 'toolkit/components/satchel')
-rw-r--r--toolkit/components/satchel/AutoCompletePopup.jsm317
-rw-r--r--toolkit/components/satchel/FormHistory.jsm1119
-rw-r--r--toolkit/components/satchel/FormHistoryStartup.js146
-rw-r--r--toolkit/components/satchel/formSubmitListener.js190
-rw-r--r--toolkit/components/satchel/jar.mn7
-rw-r--r--toolkit/components/satchel/moz.build44
-rw-r--r--toolkit/components/satchel/nsFormAutoComplete.js624
-rw-r--r--toolkit/components/satchel/nsFormAutoCompleteResult.jsm187
-rw-r--r--toolkit/components/satchel/nsFormFillController.cpp1382
-rw-r--r--toolkit/components/satchel/nsFormFillController.h125
-rw-r--r--toolkit/components/satchel/nsFormHistory.js894
-rw-r--r--toolkit/components/satchel/nsIFormAutoComplete.idl47
-rw-r--r--toolkit/components/satchel/nsIFormFillController.idl56
-rw-r--r--toolkit/components/satchel/nsIFormHistory.idl74
-rw-r--r--toolkit/components/satchel/nsIInputListAutoComplete.idl17
-rw-r--r--toolkit/components/satchel/nsInputListAutoComplete.js64
-rw-r--r--toolkit/components/satchel/satchel.manifest10
-rw-r--r--toolkit/components/satchel/test/.eslintrc.js7
-rw-r--r--toolkit/components/satchel/test/browser/.eslintrc.js7
-rw-r--r--toolkit/components/satchel/test/browser/browser.ini5
-rw-r--r--toolkit/components/satchel/test/browser/browser_privbrowsing_perwindowpb.js63
-rw-r--r--toolkit/components/satchel/test/mochitest.ini19
-rw-r--r--toolkit/components/satchel/test/parent_utils.js149
-rw-r--r--toolkit/components/satchel/test/satchel_common.js274
-rw-r--r--toolkit/components/satchel/test/subtst_form_submission_1.html38
-rw-r--r--toolkit/components/satchel/test/subtst_privbrowsing.html22
-rw-r--r--toolkit/components/satchel/test/test_bug_511615.html194
-rw-r--r--toolkit/components/satchel/test/test_bug_787624.html88
-rw-r--r--toolkit/components/satchel/test/test_datalist_with_caching.html139
-rw-r--r--toolkit/components/satchel/test/test_form_autocomplete.html1076
-rw-r--r--toolkit/components/satchel/test/test_form_autocomplete_with_list.html506
-rw-r--r--toolkit/components/satchel/test/test_form_submission.html537
-rw-r--r--toolkit/components/satchel/test/test_form_submission_cap.html85
-rw-r--r--toolkit/components/satchel/test/test_form_submission_cap2.html190
-rw-r--r--toolkit/components/satchel/test/test_password_autocomplete.html107
-rw-r--r--toolkit/components/satchel/test/test_popup_direction.html61
-rw-r--r--toolkit/components/satchel/test/test_popup_enter_event.html86
-rw-r--r--toolkit/components/satchel/test/unit/.eslintrc.js7
-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 -> 6144 bytes
-rw-r--r--toolkit/components/satchel/test/unit/formhistory_v999b.sqlitebin0 -> 4096 bytes
-rw-r--r--toolkit/components/satchel/test/unit/head_satchel.js102
-rw-r--r--toolkit/components/satchel/test/unit/perf_autocomplete.js140
-rw-r--r--toolkit/components/satchel/test/unit/test_async_expire.js168
-rw-r--r--toolkit/components/satchel/test/unit/test_autocomplete.js266
-rw-r--r--toolkit/components/satchel/test/unit/test_db_corrupt.js89
-rw-r--r--toolkit/components/satchel/test/unit/test_db_update_v4.js60
-rw-r--r--toolkit/components/satchel/test/unit/test_db_update_v4b.js58
-rw-r--r--toolkit/components/satchel/test/unit/test_db_update_v999a.js75
-rw-r--r--toolkit/components/satchel/test/unit/test_db_update_v999b.js92
-rw-r--r--toolkit/components/satchel/test/unit/test_history_api.js457
-rw-r--r--toolkit/components/satchel/test/unit/test_notify.js158
-rw-r--r--toolkit/components/satchel/test/unit/test_previous_result.js25
-rw-r--r--toolkit/components/satchel/test/unit/xpcshell.ini26
-rw-r--r--toolkit/components/satchel/towel5
61 files changed, 10685 insertions, 0 deletions
diff --git a/toolkit/components/satchel/AutoCompletePopup.jsm b/toolkit/components/satchel/AutoCompletePopup.jsm
new file mode 100644
index 000000000..7604e7bd5
--- /dev/null
+++ b/toolkit/components/satchel/AutoCompletePopup.jsm
@@ -0,0 +1,317 @@
+/* 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";
+
+const Cc = Components.classes;
+const Ci = Components.interfaces;
+const Cu = Components.utils;
+
+this.EXPORTED_SYMBOLS = [ "AutoCompletePopup" ];
+
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+Cu.import("resource://gre/modules/Services.jsm");
+
+// AutoCompleteResultView is an abstraction around a list of results
+// we got back up from browser-content.js. It implements enough of
+// nsIAutoCompleteController and nsIAutoCompleteInput to make the
+// richlistbox popup work.
+var AutoCompleteResultView = {
+ // nsISupports
+ QueryInterface: XPCOMUtils.generateQI([Ci.nsIAutoCompleteController,
+ Ci.nsIAutoCompleteInput]),
+
+ // Private variables
+ results: [],
+
+ // nsIAutoCompleteController
+ get matchCount() {
+ return this.results.length;
+ },
+
+ getValueAt(index) {
+ return this.results[index].value;
+ },
+
+ getLabelAt(index) {
+ // Unused by richlist autocomplete - see getCommentAt.
+ return "";
+ },
+
+ getCommentAt(index) {
+ // The richlist autocomplete popup uses comment for its main
+ // display of an item, which is why we're returning the label
+ // here instead.
+ return this.results[index].label;
+ },
+
+ getStyleAt(index) {
+ return this.results[index].style;
+ },
+
+ getImageAt(index) {
+ return this.results[index].image;
+ },
+
+ handleEnter: function(aIsPopupSelection) {
+ AutoCompletePopup.handleEnter(aIsPopupSelection);
+ },
+
+ stopSearch: function() {},
+
+ searchString: "",
+
+ // nsIAutoCompleteInput
+ get controller() {
+ return this;
+ },
+
+ get popup() {
+ return null;
+ },
+
+ _focus() {
+ AutoCompletePopup.requestFocus();
+ },
+
+ // Internal JS-only API
+ clearResults: function() {
+ this.results = [];
+ },
+
+ setResults: function(results) {
+ this.results = results;
+ },
+};
+
+this.AutoCompletePopup = {
+ MESSAGES: [
+ "FormAutoComplete:SelectBy",
+ "FormAutoComplete:GetSelectedIndex",
+ "FormAutoComplete:SetSelectedIndex",
+ "FormAutoComplete:MaybeOpenPopup",
+ "FormAutoComplete:ClosePopup",
+ "FormAutoComplete:Disconnect",
+ "FormAutoComplete:RemoveEntry",
+ "FormAutoComplete:Invalidate",
+ ],
+
+ init: function() {
+ for (let msg of this.MESSAGES) {
+ Services.mm.addMessageListener(msg, this);
+ }
+ },
+
+ uninit: function() {
+ for (let msg of this.MESSAGES) {
+ Services.mm.removeMessageListener(msg, this);
+ }
+ },
+
+ handleEvent: function(evt) {
+ switch (evt.type) {
+ case "popupshowing": {
+ this.sendMessageToBrowser("FormAutoComplete:PopupOpened");
+ break;
+ }
+
+ case "popuphidden": {
+ AutoCompleteResultView.clearResults();
+ this.sendMessageToBrowser("FormAutoComplete:PopupClosed");
+ // adjustHeight clears the height from the popup so that
+ // we don't have a big shrink effect if we closed with a
+ // large list, and then open on a small one.
+ this.openedPopup.adjustHeight();
+ this.openedPopup = null;
+ this.weakBrowser = null;
+ evt.target.removeEventListener("popuphidden", this);
+ evt.target.removeEventListener("popupshowing", this);
+ break;
+ }
+ }
+ },
+
+ // Along with being called internally by the receiveMessage handler,
+ // this function is also called directly by the login manager, which
+ // uses a single message to fill in the autocomplete results. See
+ // "RemoteLogins:autoCompleteLogins".
+ showPopupWithResults: function({ browser, rect, dir, results }) {
+ if (!results.length || this.openedPopup) {
+ // We shouldn't ever be showing an empty popup, and if we
+ // already have a popup open, the old one needs to close before
+ // we consider opening a new one.
+ return;
+ }
+
+ let window = browser.ownerDocument.defaultView;
+ let tabbrowser = window.gBrowser;
+ if (Services.focus.activeWindow != window ||
+ tabbrowser.selectedBrowser != browser) {
+ // We were sent a message from a window or tab that went into the
+ // background, so we'll ignore it for now.
+ return;
+ }
+
+ this.weakBrowser = Cu.getWeakReference(browser);
+ this.openedPopup = browser.autoCompletePopup;
+ this.openedPopup.hidden = false;
+ // don't allow the popup to become overly narrow
+ this.openedPopup.setAttribute("width", Math.max(100, rect.width));
+ this.openedPopup.style.direction = dir;
+
+ AutoCompleteResultView.setResults(results);
+ this.openedPopup.view = AutoCompleteResultView;
+ this.openedPopup.selectedIndex = -1;
+
+ if (results.length) {
+ // Reset fields that were set from the last time the search popup was open
+ this.openedPopup.mInput = AutoCompleteResultView;
+ this.openedPopup.showCommentColumn = false;
+ this.openedPopup.showImageColumn = false;
+ this.openedPopup.addEventListener("popuphidden", this);
+ this.openedPopup.addEventListener("popupshowing", this);
+ this.openedPopup.openPopupAtScreenRect("after_start", rect.left, rect.top,
+ rect.width, rect.height, false,
+ false);
+ this.openedPopup.invalidate();
+ } else {
+ this.closePopup();
+ }
+ },
+
+ invalidate(results) {
+ if (!this.openedPopup) {
+ return;
+ }
+
+ if (!results.length) {
+ this.closePopup();
+ } else {
+ AutoCompleteResultView.setResults(results);
+ this.openedPopup.invalidate();
+ }
+ },
+
+ closePopup() {
+ if (this.openedPopup) {
+ // Note that hidePopup() closes the popup immediately,
+ // so popuphiding or popuphidden events will be fired
+ // and handled during this call.
+ this.openedPopup.hidePopup();
+ }
+ },
+
+ removeLogin(login) {
+ Services.logins.removeLogin(login);
+ },
+
+ receiveMessage: function(message) {
+ if (!message.target.autoCompletePopup) {
+ // Returning false to pacify ESLint, but this return value is
+ // ignored by the messaging infrastructure.
+ return false;
+ }
+
+ switch (message.name) {
+ case "FormAutoComplete:SelectBy": {
+ if (this.openedPopup) {
+ this.openedPopup.selectBy(message.data.reverse, message.data.page);
+ }
+ break;
+ }
+
+ case "FormAutoComplete:GetSelectedIndex": {
+ if (this.openedPopup) {
+ return this.openedPopup.selectedIndex;
+ }
+ // If the popup was closed, then the selection
+ // has not changed.
+ return -1;
+ }
+
+ case "FormAutoComplete:SetSelectedIndex": {
+ let { index } = message.data;
+ if (this.openedPopup) {
+ this.openedPopup.selectedIndex = index;
+ }
+ break;
+ }
+
+ case "FormAutoComplete:MaybeOpenPopup": {
+ let { results, rect, dir } = message.data;
+ this.showPopupWithResults({ browser: message.target, rect, dir,
+ results });
+ break;
+ }
+
+ case "FormAutoComplete:Invalidate": {
+ let { results } = message.data;
+ this.invalidate(results);
+ break;
+ }
+
+ case "FormAutoComplete:ClosePopup": {
+ this.closePopup();
+ break;
+ }
+
+ case "FormAutoComplete:Disconnect": {
+ // The controller stopped controlling the current input, so clear
+ // any cached data. This is necessary cause otherwise we'd clear data
+ // only when starting a new search, but the next input could not support
+ // autocomplete and it would end up inheriting the existing data.
+ AutoCompleteResultView.clearResults();
+ break;
+ }
+ }
+ // Returning false to pacify ESLint, but this return value is
+ // ignored by the messaging infrastructure.
+ return false;
+ },
+
+ /**
+ * Despite its name, this handleEnter is only called when the user clicks on
+ * one of the items in the popup since the popup is rendered in the parent process.
+ * The real controller's handleEnter is called directly in the content process
+ * for other methods of completing a selection (e.g. using the tab or enter
+ * keys) since the field with focus is in that process.
+ */
+ handleEnter(aIsPopupSelection) {
+ if (this.openedPopup) {
+ this.sendMessageToBrowser("FormAutoComplete:HandleEnter", {
+ selectedIndex: this.openedPopup.selectedIndex,
+ isPopupSelection: aIsPopupSelection,
+ });
+ }
+ },
+
+ /**
+ * If a browser exists that AutoCompletePopup knows about,
+ * sends it a message. Otherwise, this is a no-op.
+ *
+ * @param {string} msgName
+ * The name of the message to send.
+ * @param {object} data
+ * The optional data to send with the message.
+ */
+ sendMessageToBrowser(msgName, data) {
+ let browser = this.weakBrowser ? this.weakBrowser.get()
+ : null;
+ if (browser) {
+ browser.messageManager.sendAsyncMessage(msgName, data);
+ }
+ },
+
+ stopSearch: function() {},
+
+ /**
+ * Sends a message to the browser requesting that the input
+ * that the AutoCompletePopup is open for be focused.
+ */
+ requestFocus: function() {
+ if (this.openedPopup) {
+ this.sendMessageToBrowser("FormAutoComplete:Focus");
+ }
+ },
+}
diff --git a/toolkit/components/satchel/FormHistory.jsm b/toolkit/components/satchel/FormHistory.jsm
new file mode 100644
index 000000000..3d4a9fc43
--- /dev/null
+++ b/toolkit/components/satchel/FormHistory.jsm
@@ -0,0 +1,1119 @@
+/* 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/. */
+
+/**
+ * FormHistory
+ *
+ * Used to store values that have been entered into forms which may later
+ * be used to automatically fill in the values when the form is visited again.
+ *
+ * search(terms, queryData, callback)
+ * Look up values that have been previously stored.
+ * terms - array of terms to return data for
+ * queryData - object that contains the query terms
+ * The query object contains properties for each search criteria to match, where the value
+ * of the property specifies the value that term must have. For example,
+ * { term1: value1, term2: value2 }
+ * callback - callback that is called when results are available or an error occurs.
+ * The callback is passed a result array containing each found entry. Each element in
+ * the array is an object containing a property for each search term specified by 'terms'.
+ * count(queryData, callback)
+ * Find the number of stored entries that match the given criteria.
+ * queryData - array of objects that indicate the query. See the search method for details.
+ * callback - callback that is called when results are available or an error occurs.
+ * The callback is passed the number of found entries.
+ * update(changes, callback)
+ * Write data to form history storage.
+ * changes - an array of changes to be made. If only one change is to be made, it
+ * may be passed as an object rather than a one-element array.
+ * Each change object is of the form:
+ * { op: operation, term1: value1, term2: value2, ... }
+ * Valid operations are:
+ * add - add a new entry
+ * update - update an existing entry
+ * remove - remove an entry
+ * bump - update the last accessed time on an entry
+ * The terms specified allow matching of one or more specific entries. If no terms
+ * are specified then all entries are matched. This means that { op: "remove" } is
+ * used to remove all entries and clear the form history.
+ * callback - callback that is called when results have been stored.
+ * getAutoCompeteResults(searchString, params, callback)
+ * Retrieve an array of form history values suitable for display in an autocomplete list.
+ * Returns an mozIStoragePendingStatement that can be used to cancel the operation if
+ * needed.
+ * searchString - the string to search for, typically the entered value of a textbox
+ * params - zero or more filter arguments:
+ * fieldname - form field name
+ * agedWeight
+ * bucketSize
+ * expiryDate
+ * maxTimeGroundings
+ * timeGroupingSize
+ * prefixWeight
+ * boundaryWeight
+ * callback - callback that is called with the array of results. Each result in the array
+ * is an object with four arguments:
+ * text, textLowerCase, frecency, totalScore
+ * schemaVersion
+ * This property holds the version of the database schema
+ *
+ * Terms:
+ * guid - entry identifier. For 'add', a guid will be generated.
+ * fieldname - form field name
+ * value - form value
+ * timesUsed - the number of times the entry has been accessed
+ * firstUsed - the time the the entry was first created
+ * lastUsed - the time the entry was last accessed
+ * firstUsedStart - search for entries created after or at this time
+ * firstUsedEnd - search for entries created before or at this time
+ * lastUsedStart - search for entries last accessed after or at this time
+ * lastUsedEnd - search for entries last accessed before or at this time
+ * newGuid - a special case valid only for 'update' and allows the guid for
+ * an existing record to be updated. The 'guid' term is the only
+ * other term which can be used (ie, you can not also specify a
+ * fieldname, value etc) and indicates the guid of the existing
+ * record that should be updated.
+ *
+ * In all of the above methods, the callback argument should be an object with
+ * handleResult(result), handleFailure(error) and handleCompletion(reason) functions.
+ * For search and getAutoCompeteResults, result is an object containing the desired
+ * properties. For count, result is the integer count. For, update, handleResult is
+ * not called. For handleCompletion, reason is either 0 if successful or 1 if
+ * an error occurred.
+ */
+
+this.EXPORTED_SYMBOLS = ["FormHistory"];
+
+const Cc = Components.classes;
+const Ci = Components.interfaces;
+const Cr = Components.results;
+
+Components.utils.import("resource://gre/modules/XPCOMUtils.jsm");
+Components.utils.import("resource://gre/modules/Services.jsm");
+Components.utils.import("resource://gre/modules/AppConstants.jsm");
+
+XPCOMUtils.defineLazyServiceGetter(this, "uuidService",
+ "@mozilla.org/uuid-generator;1",
+ "nsIUUIDGenerator");
+
+const DB_SCHEMA_VERSION = 4;
+const DAY_IN_MS = 86400000; // 1 day in milliseconds
+const MAX_SEARCH_TOKENS = 10;
+const NOOP = function noop() {};
+
+var supportsDeletedTable = AppConstants.platform == "android";
+
+var Prefs = {
+ initialized: false,
+
+ get debug() { this.ensureInitialized(); return this._debug; },
+ get enabled() { this.ensureInitialized(); return this._enabled; },
+ get expireDays() { this.ensureInitialized(); return this._expireDays; },
+
+ ensureInitialized: function() {
+ if (this.initialized)
+ return;
+
+ this.initialized = true;
+
+ this._debug = Services.prefs.getBoolPref("browser.formfill.debug");
+ this._enabled = Services.prefs.getBoolPref("browser.formfill.enable");
+ this._expireDays = Services.prefs.getIntPref("browser.formfill.expire_days");
+ }
+};
+
+function log(aMessage) {
+ if (Prefs.debug) {
+ Services.console.logStringMessage("FormHistory: " + aMessage);
+ }
+}
+
+function sendNotification(aType, aData) {
+ if (typeof aData == "string") {
+ let strWrapper = Cc["@mozilla.org/supports-string;1"].
+ createInstance(Ci.nsISupportsString);
+ strWrapper.data = aData;
+ aData = strWrapper;
+ }
+ else if (typeof aData == "number") {
+ let intWrapper = Cc["@mozilla.org/supports-PRInt64;1"].
+ createInstance(Ci.nsISupportsPRInt64);
+ intWrapper.data = aData;
+ aData = intWrapper;
+ }
+ else if (aData) {
+ throw Components.Exception("Invalid type " + (typeof aType) + " passed to sendNotification",
+ Cr.NS_ERROR_ILLEGAL_VALUE);
+ }
+
+ Services.obs.notifyObservers(aData, "satchel-storage-changed", aType);
+}
+
+/**
+ * Current database schema
+ */
+
+const dbSchema = {
+ tables : {
+ moz_formhistory : {
+ "id" : "INTEGER PRIMARY KEY",
+ "fieldname" : "TEXT NOT NULL",
+ "value" : "TEXT NOT NULL",
+ "timesUsed" : "INTEGER",
+ "firstUsed" : "INTEGER",
+ "lastUsed" : "INTEGER",
+ "guid" : "TEXT",
+ },
+ moz_deleted_formhistory: {
+ "id" : "INTEGER PRIMARY KEY",
+ "timeDeleted" : "INTEGER",
+ "guid" : "TEXT"
+ }
+ },
+ indices : {
+ moz_formhistory_index : {
+ table : "moz_formhistory",
+ columns : [ "fieldname" ]
+ },
+ moz_formhistory_lastused_index : {
+ table : "moz_formhistory",
+ columns : [ "lastUsed" ]
+ },
+ moz_formhistory_guid_index : {
+ table : "moz_formhistory",
+ columns : [ "guid" ]
+ },
+ }
+};
+
+/**
+ * Validating and processing API querying data
+ */
+
+const validFields = [
+ "fieldname",
+ "value",
+ "timesUsed",
+ "firstUsed",
+ "lastUsed",
+ "guid",
+];
+
+const searchFilters = [
+ "firstUsedStart",
+ "firstUsedEnd",
+ "lastUsedStart",
+ "lastUsedEnd",
+];
+
+function validateOpData(aData, aDataType) {
+ let thisValidFields = validFields;
+ // A special case to update the GUID - in this case there can be a 'newGuid'
+ // field and of the normally valid fields, only 'guid' is accepted.
+ if (aDataType == "Update" && "newGuid" in aData) {
+ thisValidFields = ["guid", "newGuid"];
+ }
+ for (let field in aData) {
+ if (field != "op" && thisValidFields.indexOf(field) == -1) {
+ throw Components.Exception(
+ aDataType + " query contains an unrecognized field: " + field,
+ Cr.NS_ERROR_ILLEGAL_VALUE);
+ }
+ }
+ return aData;
+}
+
+function validateSearchData(aData, aDataType) {
+ for (let field in aData) {
+ if (field != "op" && validFields.indexOf(field) == -1 && searchFilters.indexOf(field) == -1) {
+ throw Components.Exception(
+ aDataType + " query contains an unrecognized field: " + field,
+ Cr.NS_ERROR_ILLEGAL_VALUE);
+ }
+ }
+}
+
+function makeQueryPredicates(aQueryData, delimiter = ' AND ') {
+ return Object.keys(aQueryData).map(function(field) {
+ if (field == "firstUsedStart") {
+ return "firstUsed >= :" + field;
+ } else if (field == "firstUsedEnd") {
+ return "firstUsed <= :" + field;
+ } else if (field == "lastUsedStart") {
+ return "lastUsed >= :" + field;
+ } else if (field == "lastUsedEnd") {
+ return "lastUsed <= :" + field;
+ }
+ return field + " = :" + field;
+ }).join(delimiter);
+}
+
+/**
+ * Storage statement creation and parameter binding
+ */
+
+function makeCountStatement(aSearchData) {
+ let query = "SELECT COUNT(*) AS numEntries FROM moz_formhistory";
+ let queryTerms = makeQueryPredicates(aSearchData);
+ if (queryTerms) {
+ query += " WHERE " + queryTerms;
+ }
+ return dbCreateAsyncStatement(query, aSearchData);
+}
+
+function makeSearchStatement(aSearchData, aSelectTerms) {
+ let query = "SELECT " + aSelectTerms.join(", ") + " FROM moz_formhistory";
+ let queryTerms = makeQueryPredicates(aSearchData);
+ if (queryTerms) {
+ query += " WHERE " + queryTerms;
+ }
+
+ return dbCreateAsyncStatement(query, aSearchData);
+}
+
+function makeAddStatement(aNewData, aNow, aBindingArrays) {
+ let query = "INSERT INTO moz_formhistory (fieldname, value, timesUsed, firstUsed, lastUsed, guid) " +
+ "VALUES (:fieldname, :value, :timesUsed, :firstUsed, :lastUsed, :guid)";
+
+ aNewData.timesUsed = aNewData.timesUsed || 1;
+ aNewData.firstUsed = aNewData.firstUsed || aNow;
+ aNewData.lastUsed = aNewData.lastUsed || aNow;
+ return dbCreateAsyncStatement(query, aNewData, aBindingArrays);
+}
+
+function makeBumpStatement(aGuid, aNow, aBindingArrays) {
+ let query = "UPDATE moz_formhistory SET timesUsed = timesUsed + 1, lastUsed = :lastUsed WHERE guid = :guid";
+ let queryParams = {
+ lastUsed : aNow,
+ guid : aGuid,
+ };
+
+ return dbCreateAsyncStatement(query, queryParams, aBindingArrays);
+}
+
+function makeRemoveStatement(aSearchData, aBindingArrays) {
+ let query = "DELETE FROM moz_formhistory";
+ let queryTerms = makeQueryPredicates(aSearchData);
+
+ if (queryTerms) {
+ log("removeEntries");
+ query += " WHERE " + queryTerms;
+ } else {
+ log("removeAllEntries");
+ // Not specifying any fields means we should remove all entries. We
+ // won't need to modify the query in this case.
+ }
+
+ return dbCreateAsyncStatement(query, aSearchData, aBindingArrays);
+}
+
+function makeUpdateStatement(aGuid, aNewData, aBindingArrays) {
+ let query = "UPDATE moz_formhistory SET ";
+ let queryTerms = makeQueryPredicates(aNewData, ', ');
+
+ if (!queryTerms) {
+ throw Components.Exception("Update query must define fields to modify.",
+ Cr.NS_ERROR_ILLEGAL_VALUE);
+ }
+
+ query += queryTerms + " WHERE guid = :existing_guid";
+ aNewData["existing_guid"] = aGuid;
+
+ return dbCreateAsyncStatement(query, aNewData, aBindingArrays);
+}
+
+function makeMoveToDeletedStatement(aGuid, aNow, aData, aBindingArrays) {
+ if (supportsDeletedTable) {
+ let query = "INSERT INTO moz_deleted_formhistory (guid, timeDeleted)";
+ let queryTerms = makeQueryPredicates(aData);
+
+ if (aGuid) {
+ query += " VALUES (:guid, :timeDeleted)";
+ } else {
+ // TODO: Add these items to the deleted items table once we've sorted
+ // out the issues from bug 756701
+ if (!queryTerms)
+ return undefined;
+
+ query += " SELECT guid, :timeDeleted FROM moz_formhistory WHERE " + queryTerms;
+ }
+
+ aData.timeDeleted = aNow;
+
+ return dbCreateAsyncStatement(query, aData, aBindingArrays);
+ }
+
+ return null;
+}
+
+function generateGUID() {
+ // string like: "{f60d9eac-9421-4abc-8491-8e8322b063d4}"
+ let uuid = uuidService.generateUUID().toString();
+ let raw = ""; // A string with the low bytes set to random values
+ let bytes = 0;
+ for (let i = 1; bytes < 12 ; i+= 2) {
+ // Skip dashes
+ if (uuid[i] == "-")
+ i++;
+ let hexVal = parseInt(uuid[i] + uuid[i + 1], 16);
+ raw += String.fromCharCode(hexVal);
+ bytes++;
+ }
+ return btoa(raw);
+}
+
+/**
+ * Database creation and access
+ */
+
+var _dbConnection = null;
+XPCOMUtils.defineLazyGetter(this, "dbConnection", function() {
+ let dbFile;
+
+ try {
+ dbFile = Services.dirsvc.get("ProfD", Ci.nsIFile).clone();
+ dbFile.append("formhistory.sqlite");
+ log("Opening database at " + dbFile.path);
+
+ _dbConnection = Services.storage.openUnsharedDatabase(dbFile);
+ dbInit();
+ } catch (e) {
+ if (e.result != Cr.NS_ERROR_FILE_CORRUPTED)
+ throw e;
+ dbCleanup(dbFile);
+ _dbConnection = Services.storage.openUnsharedDatabase(dbFile);
+ dbInit();
+ }
+
+ return _dbConnection;
+});
+
+
+var dbStmts = new Map();
+
+/*
+ * dbCreateAsyncStatement
+ *
+ * Creates a statement, wraps it, and then does parameter replacement
+ */
+function dbCreateAsyncStatement(aQuery, aParams, aBindingArrays) {
+ if (!aQuery)
+ return null;
+
+ let stmt = dbStmts.get(aQuery);
+ if (!stmt) {
+ log("Creating new statement for query: " + aQuery);
+ stmt = dbConnection.createAsyncStatement(aQuery);
+ dbStmts.set(aQuery, stmt);
+ }
+
+ if (aBindingArrays) {
+ let bindingArray = aBindingArrays.get(stmt);
+ if (!bindingArray) {
+ // first time using a particular statement in update
+ bindingArray = stmt.newBindingParamsArray();
+ aBindingArrays.set(stmt, bindingArray);
+ }
+
+ if (aParams) {
+ let bindingParams = bindingArray.newBindingParams();
+ for (let field in aParams) {
+ bindingParams.bindByName(field, aParams[field]);
+ }
+ bindingArray.addParams(bindingParams);
+ }
+ } else if (aParams) {
+ for (let field in aParams) {
+ stmt.params[field] = aParams[field];
+ }
+ }
+
+ return stmt;
+}
+
+/**
+ * dbInit
+ *
+ * Attempts to initialize the database. This creates the file if it doesn't
+ * exist, performs any migrations, etc.
+ */
+function dbInit() {
+ log("Initializing Database");
+
+ if (!_dbConnection.tableExists("moz_formhistory")) {
+ dbCreate();
+ return;
+ }
+
+ // When FormHistory is released, we will no longer support the various schema versions prior to
+ // this release that nsIFormHistory2 once did.
+ let version = _dbConnection.schemaVersion;
+ if (version < 3) {
+ throw Components.Exception("DB version is unsupported.",
+ Cr.NS_ERROR_FILE_CORRUPTED);
+ } else if (version != DB_SCHEMA_VERSION) {
+ dbMigrate(version);
+ }
+}
+
+function dbCreate() {
+ log("Creating DB -- tables");
+ for (let name in dbSchema.tables) {
+ let table = dbSchema.tables[name];
+ let tSQL = Object.keys(table).map(col => [col, table[col]].join(" ")).join(", ");
+ log("Creating table " + name + " with " + tSQL);
+ _dbConnection.createTable(name, tSQL);
+ }
+
+ log("Creating DB -- indices");
+ for (let name in dbSchema.indices) {
+ let index = dbSchema.indices[name];
+ let statement = "CREATE INDEX IF NOT EXISTS " + name + " ON " + index.table +
+ "(" + index.columns.join(", ") + ")";
+ _dbConnection.executeSimpleSQL(statement);
+ }
+
+ _dbConnection.schemaVersion = DB_SCHEMA_VERSION;
+}
+
+function dbMigrate(oldVersion) {
+ log("Attempting to migrate from version " + oldVersion);
+
+ if (oldVersion > DB_SCHEMA_VERSION) {
+ log("Downgrading to version " + DB_SCHEMA_VERSION);
+ // User's DB is newer. Sanity check that our expected columns are
+ // present, and if so mark the lower version and merrily continue
+ // on. If the columns are borked, something is wrong so blow away
+ // the DB and start from scratch. [Future incompatible upgrades
+ // should switch to a different table or file.]
+
+ if (!dbAreExpectedColumnsPresent()) {
+ throw Components.Exception("DB is missing expected columns",
+ Cr.NS_ERROR_FILE_CORRUPTED);
+ }
+
+ // Change the stored version to the current version. If the user
+ // runs the newer code again, it will see the lower version number
+ // and re-upgrade (to fixup any entries the old code added).
+ _dbConnection.schemaVersion = DB_SCHEMA_VERSION;
+ return;
+ }
+
+ // Note that migration is currently performed synchronously.
+ _dbConnection.beginTransaction();
+
+ try {
+ for (let v = oldVersion + 1; v <= DB_SCHEMA_VERSION; v++) {
+ this.log("Upgrading to version " + v + "...");
+ Migrators["dbMigrateToVersion" + v]();
+ }
+ } catch (e) {
+ this.log("Migration failed: " + e);
+ this.dbConnection.rollbackTransaction();
+ throw e;
+ }
+
+ _dbConnection.schemaVersion = DB_SCHEMA_VERSION;
+ _dbConnection.commitTransaction();
+
+ log("DB migration completed.");
+}
+
+var Migrators = {
+ /*
+ * Updates the DB schema to v3 (bug 506402).
+ * Adds deleted form history table.
+ */
+ dbMigrateToVersion4: function dbMigrateToVersion4() {
+ if (!_dbConnection.tableExists("moz_deleted_formhistory")) {
+ let table = dbSchema.tables["moz_deleted_formhistory"];
+ let tSQL = Object.keys(table).map(col => [col, table[col]].join(" ")).join(", ");
+ _dbConnection.createTable("moz_deleted_formhistory", tSQL);
+ }
+ }
+};
+
+/**
+ * dbAreExpectedColumnsPresent
+ *
+ * Sanity check to ensure that the columns this version of the code expects
+ * are present in the DB we're using.
+ */
+function dbAreExpectedColumnsPresent() {
+ for (let name in dbSchema.tables) {
+ let table = dbSchema.tables[name];
+ let query = "SELECT " +
+ Object.keys(table).join(", ") +
+ " FROM " + name;
+ try {
+ let stmt = _dbConnection.createStatement(query);
+ // (no need to execute statement, if it compiled we're good)
+ stmt.finalize();
+ } catch (e) {
+ return false;
+ }
+ }
+
+ log("verified that expected columns are present in DB.");
+ return true;
+}
+
+/**
+ * dbCleanup
+ *
+ * Called when database creation fails. Finalizes database statements,
+ * closes the database connection, deletes the database file.
+ */
+function dbCleanup(dbFile) {
+ log("Cleaning up DB file - close & remove & backup");
+
+ // Create backup file
+ let backupFile = dbFile.leafName + ".corrupt";
+ Services.storage.backupDatabaseFile(dbFile, backupFile);
+
+ dbClose(false);
+ dbFile.remove(false);
+}
+
+function dbClose(aShutdown) {
+ log("dbClose(" + aShutdown + ")");
+
+ if (aShutdown) {
+ sendNotification("formhistory-shutdown", null);
+ }
+
+ // Connection may never have been created if say open failed but we still
+ // end up calling dbClose as part of the rest of dbCleanup.
+ if (!_dbConnection) {
+ return;
+ }
+
+ log("dbClose finalize statements");
+ for (let stmt of dbStmts.values()) {
+ stmt.finalize();
+ }
+
+ dbStmts = new Map();
+
+ let closed = false;
+ _dbConnection.asyncClose(() => closed = true);
+
+ if (!aShutdown) {
+ let thread = Services.tm.currentThread;
+ while (!closed) {
+ thread.processNextEvent(true);
+ }
+ }
+}
+
+/**
+ * updateFormHistoryWrite
+ *
+ * Constructs and executes database statements from a pre-processed list of
+ * inputted changes.
+ */
+function updateFormHistoryWrite(aChanges, aCallbacks) {
+ log("updateFormHistoryWrite " + aChanges.length);
+
+ // pass 'now' down so that every entry in the batch has the same timestamp
+ let now = Date.now() * 1000;
+
+ // for each change, we either create and append a new storage statement to
+ // stmts or bind a new set of parameters to an existing storage statement.
+ // stmts and bindingArrays are updated when makeXXXStatement eventually
+ // calls dbCreateAsyncStatement.
+ let stmts = [];
+ let notifications = [];
+ let bindingArrays = new Map();
+
+ for (let change of aChanges) {
+ let operation = change.op;
+ delete change.op;
+ let stmt;
+ switch (operation) {
+ case "remove":
+ log("Remove from form history " + change);
+ let delStmt = makeMoveToDeletedStatement(change.guid, now, change, bindingArrays);
+ if (delStmt && stmts.indexOf(delStmt) == -1)
+ stmts.push(delStmt);
+ if ("timeDeleted" in change)
+ delete change.timeDeleted;
+ stmt = makeRemoveStatement(change, bindingArrays);
+ notifications.push([ "formhistory-remove", change.guid ]);
+ break;
+ case "update":
+ log("Update form history " + change);
+ let guid = change.guid;
+ delete change.guid;
+ // a special case for updating the GUID - the new value can be
+ // specified in newGuid.
+ if (change.newGuid) {
+ change.guid = change.newGuid
+ delete change.newGuid;
+ }
+ stmt = makeUpdateStatement(guid, change, bindingArrays);
+ notifications.push([ "formhistory-update", guid ]);
+ break;
+ case "bump":
+ log("Bump form history " + change);
+ if (change.guid) {
+ stmt = makeBumpStatement(change.guid, now, bindingArrays);
+ notifications.push([ "formhistory-update", change.guid ]);
+ } else {
+ change.guid = generateGUID();
+ stmt = makeAddStatement(change, now, bindingArrays);
+ notifications.push([ "formhistory-add", change.guid ]);
+ }
+ break;
+ case "add":
+ log("Add to form history " + change);
+ change.guid = generateGUID();
+ stmt = makeAddStatement(change, now, bindingArrays);
+ notifications.push([ "formhistory-add", change.guid ]);
+ break;
+ default:
+ // We should've already guaranteed that change.op is one of the above
+ throw Components.Exception("Invalid operation " + operation,
+ Cr.NS_ERROR_ILLEGAL_VALUE);
+ }
+
+ // As identical statements are reused, only add statements if they aren't already present.
+ if (stmt && stmts.indexOf(stmt) == -1) {
+ stmts.push(stmt);
+ }
+ }
+
+ for (let stmt of stmts) {
+ stmt.bindParameters(bindingArrays.get(stmt));
+ }
+
+ let handlers = {
+ handleCompletion : function(aReason) {
+ if (aReason == Ci.mozIStorageStatementCallback.REASON_FINISHED) {
+ for (let [notification, param] of notifications) {
+ // We're either sending a GUID or nothing at all.
+ sendNotification(notification, param);
+ }
+ }
+
+ if (aCallbacks && aCallbacks.handleCompletion) {
+ aCallbacks.handleCompletion(aReason == Ci.mozIStorageStatementCallback.REASON_FINISHED ? 0 : 1);
+ }
+ },
+ handleError : function(aError) {
+ if (aCallbacks && aCallbacks.handleError) {
+ aCallbacks.handleError(aError);
+ }
+ },
+ handleResult : NOOP
+ };
+
+ dbConnection.executeAsync(stmts, stmts.length, handlers);
+}
+
+/**
+ * Functions that expire entries in form history and shrinks database
+ * afterwards as necessary initiated by expireOldEntries.
+ */
+
+/**
+ * expireOldEntriesDeletion
+ *
+ * Removes entries from database.
+ */
+function expireOldEntriesDeletion(aExpireTime, aBeginningCount) {
+ log("expireOldEntriesDeletion(" + aExpireTime + "," + aBeginningCount + ")");
+
+ FormHistory.update([
+ {
+ op: "remove",
+ lastUsedEnd : aExpireTime,
+ }], {
+ handleCompletion: function() {
+ expireOldEntriesVacuum(aExpireTime, aBeginningCount);
+ },
+ handleError: function(aError) {
+ log("expireOldEntriesDeletionFailure");
+ }
+ });
+}
+
+/**
+ * expireOldEntriesVacuum
+ *
+ * Counts number of entries removed and shrinks database as necessary.
+ */
+function expireOldEntriesVacuum(aExpireTime, aBeginningCount) {
+ FormHistory.count({}, {
+ handleResult: function(aEndingCount) {
+ if (aBeginningCount - aEndingCount > 500) {
+ log("expireOldEntriesVacuum");
+
+ let stmt = dbCreateAsyncStatement("VACUUM");
+ stmt.executeAsync({
+ handleResult : NOOP,
+ handleError : function(aError) {
+ log("expireVacuumError");
+ },
+ handleCompletion : NOOP
+ });
+ }
+
+ sendNotification("formhistory-expireoldentries", aExpireTime);
+ },
+ handleError: function(aError) {
+ log("expireEndCountFailure");
+ }
+ });
+}
+
+this.FormHistory = {
+ get enabled() {
+ return Prefs.enabled;
+ },
+
+ search : function formHistorySearch(aSelectTerms, aSearchData, aCallbacks) {
+ // if no terms selected, select everything
+ aSelectTerms = (aSelectTerms) ? aSelectTerms : validFields;
+ validateSearchData(aSearchData, "Search");
+
+ let stmt = makeSearchStatement(aSearchData, aSelectTerms);
+
+ let handlers = {
+ handleResult : function(aResultSet) {
+ for (let row = aResultSet.getNextRow(); row; row = aResultSet.getNextRow()) {
+ let result = {};
+ for (let field of aSelectTerms) {
+ result[field] = row.getResultByName(field);
+ }
+
+ if (aCallbacks && aCallbacks.handleResult) {
+ aCallbacks.handleResult(result);
+ }
+ }
+ },
+
+ handleError : function(aError) {
+ if (aCallbacks && aCallbacks.handleError) {
+ aCallbacks.handleError(aError);
+ }
+ },
+
+ handleCompletion : function searchCompletionHandler(aReason) {
+ if (aCallbacks && aCallbacks.handleCompletion) {
+ aCallbacks.handleCompletion(aReason == Ci.mozIStorageStatementCallback.REASON_FINISHED ? 0 : 1);
+ }
+ }
+ };
+
+ stmt.executeAsync(handlers);
+ },
+
+ count : function formHistoryCount(aSearchData, aCallbacks) {
+ validateSearchData(aSearchData, "Count");
+ let stmt = makeCountStatement(aSearchData);
+ let handlers = {
+ handleResult : function countResultHandler(aResultSet) {
+ let row = aResultSet.getNextRow();
+ let count = row.getResultByName("numEntries");
+ if (aCallbacks && aCallbacks.handleResult) {
+ aCallbacks.handleResult(count);
+ }
+ },
+
+ handleError : function(aError) {
+ if (aCallbacks && aCallbacks.handleError) {
+ aCallbacks.handleError(aError);
+ }
+ },
+
+ handleCompletion : function searchCompletionHandler(aReason) {
+ if (aCallbacks && aCallbacks.handleCompletion) {
+ aCallbacks.handleCompletion(aReason == Ci.mozIStorageStatementCallback.REASON_FINISHED ? 0 : 1);
+ }
+ }
+ };
+
+ stmt.executeAsync(handlers);
+ },
+
+ update : function formHistoryUpdate(aChanges, aCallbacks) {
+ // Used to keep track of how many searches have been started. When that number
+ // are finished, updateFormHistoryWrite can be called.
+ let numSearches = 0;
+ let completedSearches = 0;
+ let searchFailed = false;
+
+ function validIdentifier(change) {
+ // The identifier is only valid if one of either the guid or the (fieldname/value) are set
+ return Boolean(change.guid) != Boolean(change.fieldname && change.value);
+ }
+
+ if (!("length" in aChanges))
+ aChanges = [aChanges];
+
+ let isRemoveOperation = aChanges.every(change => change && change.op && change.op == "remove");
+ if (!Prefs.enabled && !isRemoveOperation) {
+ if (aCallbacks && aCallbacks.handleError) {
+ aCallbacks.handleError({
+ message: "Form history is disabled, only remove operations are allowed",
+ result: Ci.mozIStorageError.MISUSE
+ });
+ }
+ if (aCallbacks && aCallbacks.handleCompletion) {
+ aCallbacks.handleCompletion(1);
+ }
+ return;
+ }
+
+ for (let change of aChanges) {
+ switch (change.op) {
+ case "remove":
+ validateSearchData(change, "Remove");
+ continue;
+ case "update":
+ if (validIdentifier(change)) {
+ validateOpData(change, "Update");
+ if (change.guid) {
+ continue;
+ }
+ } else {
+ throw Components.Exception(
+ "update op='update' does not correctly reference a entry.",
+ Cr.NS_ERROR_ILLEGAL_VALUE);
+ }
+ break;
+ case "bump":
+ if (validIdentifier(change)) {
+ validateOpData(change, "Bump");
+ if (change.guid) {
+ continue;
+ }
+ } else {
+ throw Components.Exception(
+ "update op='bump' does not correctly reference a entry.",
+ Cr.NS_ERROR_ILLEGAL_VALUE);
+ }
+ break;
+ case "add":
+ if (change.guid) {
+ throw Components.Exception(
+ "op='add' cannot contain field 'guid'. Either use op='update' " +
+ "explicitly or make 'guid' undefined.",
+ Cr.NS_ERROR_ILLEGAL_VALUE);
+ } else if (change.fieldname && change.value) {
+ validateOpData(change, "Add");
+ }
+ break;
+ default:
+ throw Components.Exception(
+ "update does not recognize op='" + change.op + "'",
+ Cr.NS_ERROR_ILLEGAL_VALUE);
+ }
+
+ numSearches++;
+ let changeToUpdate = change;
+ FormHistory.search(
+ [ "guid" ],
+ {
+ fieldname : change.fieldname,
+ value : change.value
+ }, {
+ foundResult : false,
+ handleResult : function(aResult) {
+ if (this.foundResult) {
+ log("Database contains multiple entries with the same fieldname/value pair.");
+ if (aCallbacks && aCallbacks.handleError) {
+ aCallbacks.handleError({
+ message :
+ "Database contains multiple entries with the same fieldname/value pair.",
+ result : 19 // Constraint violation
+ });
+ }
+
+ searchFailed = true;
+ return;
+ }
+
+ this.foundResult = true;
+ changeToUpdate.guid = aResult["guid"];
+ },
+
+ handleError : function(aError) {
+ if (aCallbacks && aCallbacks.handleError) {
+ aCallbacks.handleError(aError);
+ }
+ },
+
+ handleCompletion : function(aReason) {
+ completedSearches++;
+ if (completedSearches == numSearches) {
+ if (!aReason && !searchFailed) {
+ updateFormHistoryWrite(aChanges, aCallbacks);
+ }
+ else if (aCallbacks && aCallbacks.handleCompletion) {
+ aCallbacks.handleCompletion(1);
+ }
+ }
+ }
+ });
+ }
+
+ if (numSearches == 0) {
+ // We don't have to wait for any statements to return.
+ updateFormHistoryWrite(aChanges, aCallbacks);
+ }
+ },
+
+ getAutoCompleteResults: function getAutoCompleteResults(searchString, params, aCallbacks) {
+ // only do substring matching when the search string contains more than one character
+ let searchTokens;
+ let where = ""
+ let boundaryCalc = "";
+ if (searchString.length > 1) {
+ searchTokens = searchString.split(/\s+/);
+
+ // build up the word boundary and prefix match bonus calculation
+ boundaryCalc = "MAX(1, :prefixWeight * (value LIKE :valuePrefix ESCAPE '/') + (";
+ // for each word, calculate word boundary weights for the SELECT clause and
+ // add word to the WHERE clause of the query
+ let tokenCalc = [];
+ let searchTokenCount = Math.min(searchTokens.length, MAX_SEARCH_TOKENS);
+ for (let i = 0; i < searchTokenCount; i++) {
+ tokenCalc.push("(value LIKE :tokenBegin" + i + " ESCAPE '/') + " +
+ "(value LIKE :tokenBoundary" + i + " ESCAPE '/')");
+ where += "AND (value LIKE :tokenContains" + i + " ESCAPE '/') ";
+ }
+ // add more weight if we have a traditional prefix match and
+ // multiply boundary bonuses by boundary weight
+ boundaryCalc += tokenCalc.join(" + ") + ") * :boundaryWeight)";
+ } else if (searchString.length == 1) {
+ where = "AND (value LIKE :valuePrefix ESCAPE '/') ";
+ boundaryCalc = "1";
+ delete params.prefixWeight;
+ delete params.boundaryWeight;
+ } else {
+ where = "";
+ boundaryCalc = "1";
+ delete params.prefixWeight;
+ delete params.boundaryWeight;
+ }
+
+ params.now = Date.now() * 1000; // convert from ms to microseconds
+
+ /* Three factors in the frecency calculation for an entry (in order of use in calculation):
+ * 1) average number of times used - items used more are ranked higher
+ * 2) how recently it was last used - items used recently are ranked higher
+ * 3) additional weight for aged entries surviving expiry - these entries are relevant
+ * since they have been used multiple times over a large time span so rank them higher
+ * The score is then divided by the bucket size and we round the result so that entries
+ * with a very similar frecency are bucketed together with an alphabetical sort. This is
+ * to reduce the amount of moving around by entries while typing.
+ */
+
+ let query = "/* do not warn (bug 496471): can't use an index */ " +
+ "SELECT value, " +
+ "ROUND( " +
+ "timesUsed / MAX(1.0, (lastUsed - firstUsed) / :timeGroupingSize) * " +
+ "MAX(1.0, :maxTimeGroupings - (:now - lastUsed) / :timeGroupingSize) * "+
+ "MAX(1.0, :agedWeight * (firstUsed < :expiryDate)) / " +
+ ":bucketSize "+
+ ", 3) AS frecency, " +
+ boundaryCalc + " AS boundaryBonuses " +
+ "FROM moz_formhistory " +
+ "WHERE fieldname=:fieldname " + where +
+ "ORDER BY ROUND(frecency * boundaryBonuses) DESC, UPPER(value) ASC";
+
+ let stmt = dbCreateAsyncStatement(query, params);
+
+ // Chicken and egg problem: Need the statement to escape the params we
+ // pass to the function that gives us the statement. So, fix it up now.
+ if (searchString.length >= 1)
+ stmt.params.valuePrefix = stmt.escapeStringForLIKE(searchString, "/") + "%";
+ if (searchString.length > 1) {
+ let searchTokenCount = Math.min(searchTokens.length, MAX_SEARCH_TOKENS);
+ for (let i = 0; i < searchTokenCount; i++) {
+ let escapedToken = stmt.escapeStringForLIKE(searchTokens[i], "/");
+ stmt.params["tokenBegin" + i] = escapedToken + "%";
+ stmt.params["tokenBoundary" + i] = "% " + escapedToken + "%";
+ stmt.params["tokenContains" + i] = "%" + escapedToken + "%";
+ }
+ } else {
+ // no additional params need to be substituted into the query when the
+ // length is zero or one
+ }
+
+ let pending = stmt.executeAsync({
+ handleResult : function (aResultSet) {
+ for (let row = aResultSet.getNextRow(); row; row = aResultSet.getNextRow()) {
+ let value = row.getResultByName("value");
+ let frecency = row.getResultByName("frecency");
+ let entry = {
+ text : value,
+ textLowerCase : value.toLowerCase(),
+ frecency : frecency,
+ totalScore : Math.round(frecency * row.getResultByName("boundaryBonuses"))
+ };
+ if (aCallbacks && aCallbacks.handleResult) {
+ aCallbacks.handleResult(entry);
+ }
+ }
+ },
+
+ handleError : function (aError) {
+ if (aCallbacks && aCallbacks.handleError) {
+ aCallbacks.handleError(aError);
+ }
+ },
+
+ handleCompletion : function (aReason) {
+ if (aCallbacks && aCallbacks.handleCompletion) {
+ aCallbacks.handleCompletion(aReason == Ci.mozIStorageStatementCallback.REASON_FINISHED ? 0 : 1);
+ }
+ }
+ });
+ return pending;
+ },
+
+ get schemaVersion() {
+ return dbConnection.schemaVersion;
+ },
+
+ // This is used only so that the test can verify deleted table support.
+ get _supportsDeletedTable() {
+ return supportsDeletedTable;
+ },
+ set _supportsDeletedTable(val) {
+ supportsDeletedTable = val;
+ },
+
+ // The remaining methods are called by FormHistoryStartup.js
+ updatePrefs: function updatePrefs() {
+ Prefs.initialized = false;
+ },
+
+ expireOldEntries: function expireOldEntries() {
+ log("expireOldEntries");
+
+ // Determine how many days of history we're supposed to keep.
+ // Calculate expireTime in microseconds
+ let expireTime = (Date.now() - Prefs.expireDays * DAY_IN_MS) * 1000;
+
+ sendNotification("formhistory-beforeexpireoldentries", expireTime);
+
+ FormHistory.count({}, {
+ handleResult: function(aBeginningCount) {
+ expireOldEntriesDeletion(expireTime, aBeginningCount);
+ },
+ handleError: function(aError) {
+ log("expireStartCountFailure");
+ }
+ });
+ },
+
+ shutdown: function shutdown() { dbClose(true); }
+};
+
+// Prevent add-ons from redefining this API
+Object.freeze(FormHistory);
diff --git a/toolkit/components/satchel/FormHistoryStartup.js b/toolkit/components/satchel/FormHistoryStartup.js
new file mode 100644
index 000000000..05b654560
--- /dev/null
+++ b/toolkit/components/satchel/FormHistoryStartup.js
@@ -0,0 +1,146 @@
+/* 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 Cc = Components.classes;
+const Ci = Components.interfaces;
+
+Components.utils.import("resource://gre/modules/XPCOMUtils.jsm");
+Components.utils.import("resource://gre/modules/Services.jsm");
+
+XPCOMUtils.defineLazyModuleGetter(this, "FormHistory",
+ "resource://gre/modules/FormHistory.jsm");
+
+function FormHistoryStartup() { }
+
+FormHistoryStartup.prototype = {
+ classID: Components.ID("{3A0012EB-007F-4BB8-AA81-A07385F77A25}"),
+
+ QueryInterface: XPCOMUtils.generateQI([
+ Ci.nsIObserver,
+ Ci.nsISupportsWeakReference,
+ Ci.nsIFrameMessageListener
+ ]),
+
+ observe: function(subject, topic, data) {
+ switch (topic) {
+ case "nsPref:changed":
+ FormHistory.updatePrefs();
+ break;
+ case "idle-daily":
+ case "formhistory-expire-now":
+ FormHistory.expireOldEntries();
+ break;
+ case "profile-before-change":
+ FormHistory.shutdown();
+ break;
+ case "profile-after-change":
+ this.init();
+ default:
+ break;
+ }
+ },
+
+ inited: false,
+ pendingQuery: null,
+
+ init: function()
+ {
+ if (this.inited)
+ return;
+ this.inited = true;
+
+ Services.prefs.addObserver("browser.formfill.", this, true);
+
+ // triggers needed service cleanup and db shutdown
+ Services.obs.addObserver(this, "profile-before-change", true);
+ Services.obs.addObserver(this, "formhistory-expire-now", true);
+
+ let messageManager = Cc["@mozilla.org/globalmessagemanager;1"].
+ getService(Ci.nsIMessageListenerManager);
+ messageManager.loadFrameScript("chrome://satchel/content/formSubmitListener.js", true);
+ messageManager.addMessageListener("FormHistory:FormSubmitEntries", this);
+
+ // For each of these messages, we could receive them from content,
+ // or we might receive them from the ppmm if the searchbar is
+ // having its history queried.
+ for (let manager of [messageManager, Services.ppmm]) {
+ manager.addMessageListener("FormHistory:AutoCompleteSearchAsync", this);
+ manager.addMessageListener("FormHistory:RemoveEntry", this);
+ }
+ },
+
+ receiveMessage: function(message) {
+ switch (message.name) {
+ case "FormHistory:FormSubmitEntries": {
+ let entries = message.data;
+ let changes = entries.map(function(entry) {
+ return {
+ op : "bump",
+ fieldname : entry.name,
+ value : entry.value,
+ }
+ });
+
+ FormHistory.update(changes);
+ break;
+ }
+
+ case "FormHistory:AutoCompleteSearchAsync": {
+ let { id, searchString, params } = message.data;
+
+ if (this.pendingQuery) {
+ this.pendingQuery.cancel();
+ this.pendingQuery = null;
+ }
+
+ let mm;
+ if (message.target instanceof Ci.nsIMessageListenerManager) {
+ // The target is the PPMM, meaning that the parent process
+ // is requesting FormHistory data on the searchbar.
+ mm = message.target;
+ } else {
+ // Otherwise, the target is a <xul:browser>.
+ mm = message.target.messageManager;
+ }
+
+ let results = [];
+ let processResults = {
+ handleResult: aResult => {
+ results.push(aResult);
+ },
+ handleCompletion: aReason => {
+ // Check that the current query is still the one we created. Our
+ // query might have been canceled shortly before completing, in
+ // that case we don't want to call the callback anymore.
+ if (query == this.pendingQuery) {
+ this.pendingQuery = null;
+ if (!aReason) {
+ mm.sendAsyncMessage("FormHistory:AutoCompleteSearchResults",
+ { id, results });
+ }
+ }
+ }
+ };
+
+ let query = FormHistory.getAutoCompleteResults(searchString, params,
+ processResults);
+ this.pendingQuery = query;
+ break;
+ }
+
+ case "FormHistory:RemoveEntry": {
+ let { inputName, value } = message.data;
+ FormHistory.update({
+ op: "remove",
+ fieldname: inputName,
+ value,
+ });
+ break;
+ }
+
+ }
+ }
+};
+
+this.NSGetFactory = XPCOMUtils.generateNSGetFactory([FormHistoryStartup]);
diff --git a/toolkit/components/satchel/formSubmitListener.js b/toolkit/components/satchel/formSubmitListener.js
new file mode 100644
index 000000000..ec2c18f6c
--- /dev/null
+++ b/toolkit/components/satchel/formSubmitListener.js
@@ -0,0 +1,190 @@
+/* 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/. */
+
+(function() {
+
+var Cc = Components.classes;
+var Ci = Components.interfaces;
+
+Components.utils.import("resource://gre/modules/XPCOMUtils.jsm");
+Components.utils.import("resource://gre/modules/Services.jsm");
+Components.utils.import("resource://gre/modules/PrivateBrowsingUtils.jsm");
+
+var satchelFormListener = {
+ QueryInterface : XPCOMUtils.generateQI([Ci.nsIFormSubmitObserver,
+ Ci.nsIDOMEventListener,
+ Ci.nsIObserver,
+ Ci.nsISupportsWeakReference]),
+
+ debug : true,
+ enabled : true,
+ saveHttpsForms : true,
+
+ init : function() {
+ Services.obs.addObserver(this, "earlyformsubmit", false);
+ Services.prefs.addObserver("browser.formfill.", this, false);
+ this.updatePrefs();
+ addEventListener("unload", this, false);
+ },
+
+ updatePrefs : function () {
+ this.debug = Services.prefs.getBoolPref("browser.formfill.debug");
+ this.enabled = Services.prefs.getBoolPref("browser.formfill.enable");
+ this.saveHttpsForms = Services.prefs.getBoolPref("browser.formfill.saveHttpsForms");
+ },
+
+ // Implements the Luhn checksum algorithm as described at
+ // http://wikipedia.org/wiki/Luhn_algorithm
+ isValidCCNumber : function (ccNumber) {
+ // Remove dashes and whitespace
+ ccNumber = ccNumber.replace(/[\-\s]/g, '');
+
+ let len = ccNumber.length;
+ if (len != 9 && len != 15 && len != 16)
+ return false;
+
+ if (!/^\d+$/.test(ccNumber))
+ return false;
+
+ let total = 0;
+ for (let i = 0; i < len; i++) {
+ let ch = parseInt(ccNumber[len - i - 1]);
+ if (i % 2 == 1) {
+ // Double it, add digits together if > 10
+ ch *= 2;
+ if (ch > 9)
+ ch -= 9;
+ }
+ total += ch;
+ }
+ return total % 10 == 0;
+ },
+
+ log : function (message) {
+ if (!this.debug)
+ return;
+ dump("satchelFormListener: " + message + "\n");
+ Services.console.logStringMessage("satchelFormListener: " + message);
+ },
+
+ /* ---- dom event handler ---- */
+
+ handleEvent: function(e) {
+ switch (e.type) {
+ case "unload":
+ Services.obs.removeObserver(this, "earlyformsubmit");
+ Services.prefs.removeObserver("browser.formfill.", this);
+ break;
+
+ default:
+ this.log("Oops! Unexpected event: " + e.type);
+ break;
+ }
+ },
+
+ /* ---- nsIObserver interface ---- */
+
+ observe : function (subject, topic, data) {
+ if (topic == "nsPref:changed")
+ this.updatePrefs();
+ else
+ this.log("Oops! Unexpected notification: " + topic);
+ },
+
+ /* ---- nsIFormSubmitObserver interfaces ---- */
+
+ notify : function(form, domWin, actionURI, cancelSubmit) {
+ try {
+ // Even though the global context is for a specific browser, we
+ // can receive observer events from other tabs! Ensure this event
+ // is about our content.
+ if (domWin.top != content)
+ return;
+ if (!this.enabled)
+ return;
+
+ if (PrivateBrowsingUtils.isContentWindowPrivate(domWin))
+ return;
+
+ this.log("Form submit observer notified.");
+
+ if (!this.saveHttpsForms) {
+ if (actionURI.schemeIs("https"))
+ return;
+ if (form.ownerDocument.documentURIObject.schemeIs("https"))
+ return;
+ }
+
+ if (form.hasAttribute("autocomplete") &&
+ form.getAttribute("autocomplete").toLowerCase() == "off")
+ return;
+
+ let entries = [];
+ for (let i = 0; i < form.elements.length; i++) {
+ let input = form.elements[i];
+ if (!(input instanceof Ci.nsIDOMHTMLInputElement))
+ continue;
+
+ // Only use inputs that hold text values (not including type="password")
+ if (!input.mozIsTextField(true))
+ continue;
+
+ // Bug 394612: If Login Manager marked this input, don't save it.
+ // The login manager will deal with remembering it.
+
+ // Don't save values when autocomplete=off is present.
+ if (input.hasAttribute("autocomplete") &&
+ input.getAttribute("autocomplete").toLowerCase() == "off")
+ continue;
+
+ let value = input.value.trim();
+
+ // Don't save empty or unchanged values.
+ if (!value || value == input.defaultValue.trim())
+ continue;
+
+ // Don't save credit card numbers.
+ if (this.isValidCCNumber(value)) {
+ this.log("skipping saving a credit card number");
+ continue;
+ }
+
+ let name = input.name || input.id;
+ if (!name)
+ continue;
+
+ if (name == 'searchbar-history') {
+ this.log('addEntry for input name "' + name + '" is denied')
+ continue;
+ }
+
+ // Limit stored data to 200 characters.
+ if (name.length > 200 || value.length > 200) {
+ this.log("skipping input that has a name/value too large");
+ continue;
+ }
+
+ // Limit number of fields stored per form.
+ if (entries.length >= 100) {
+ this.log("not saving any more entries for this form.");
+ break;
+ }
+
+ entries.push({ name: name, value: value });
+ }
+
+ if (entries.length) {
+ this.log("sending entries to parent process for form " + form.id);
+ sendAsyncMessage("FormHistory:FormSubmitEntries", entries);
+ }
+ }
+ catch (e) {
+ this.log("notify failed: " + e);
+ }
+ }
+};
+
+satchelFormListener.init();
+
+})();
diff --git a/toolkit/components/satchel/jar.mn b/toolkit/components/satchel/jar.mn
new file mode 100644
index 000000000..4b37d5dc5
--- /dev/null
+++ b/toolkit/components/satchel/jar.mn
@@ -0,0 +1,7 @@
+# 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/.
+
+toolkit.jar:
+% content satchel %content/satchel/
+ content/satchel/formSubmitListener.js
diff --git a/toolkit/components/satchel/moz.build b/toolkit/components/satchel/moz.build
new file mode 100644
index 000000000..239f412bc
--- /dev/null
+++ b/toolkit/components/satchel/moz.build
@@ -0,0 +1,44 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# 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/.
+
+MOCHITEST_MANIFESTS += ['test/mochitest.ini']
+XPCSHELL_TESTS_MANIFESTS += ['test/unit/xpcshell.ini']
+BROWSER_CHROME_MANIFESTS += ['test/browser/browser.ini']
+
+XPIDL_SOURCES += [
+ 'nsIFormAutoComplete.idl',
+ 'nsIFormFillController.idl',
+ 'nsIFormHistory.idl',
+ 'nsIInputListAutoComplete.idl',
+]
+
+XPIDL_MODULE = 'satchel'
+
+SOURCES += [
+ 'nsFormFillController.cpp',
+]
+
+LOCAL_INCLUDES += [
+ '../build',
+]
+
+EXTRA_COMPONENTS += [
+ 'FormHistoryStartup.js',
+ 'nsFormAutoComplete.js',
+ 'nsFormHistory.js',
+ 'nsInputListAutoComplete.js',
+ 'satchel.manifest',
+]
+
+EXTRA_JS_MODULES += [
+ 'AutoCompletePopup.jsm',
+ 'FormHistory.jsm',
+ 'nsFormAutoCompleteResult.jsm',
+]
+
+FINAL_LIBRARY = 'xul'
+
+JAR_MANIFESTS += ['jar.mn']
diff --git a/toolkit/components/satchel/nsFormAutoComplete.js b/toolkit/components/satchel/nsFormAutoComplete.js
new file mode 100644
index 000000000..aa090479a
--- /dev/null
+++ b/toolkit/components/satchel/nsFormAutoComplete.js
@@ -0,0 +1,624 @@
+/* vim: set ts=4 sts=4 sw=4 et tw=80: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const { classes: Cc, interfaces: Ci, results: Cr, utils: Cu } = Components;
+
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+Cu.import("resource://gre/modules/Services.jsm");
+
+XPCOMUtils.defineLazyModuleGetter(this, "BrowserUtils",
+ "resource://gre/modules/BrowserUtils.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "Deprecated",
+ "resource://gre/modules/Deprecated.jsm");
+
+function isAutocompleteDisabled(aField) {
+ if (aField.autocomplete !== "") {
+ return aField.autocomplete === "off";
+ }
+
+ return aField.form && aField.form.autocomplete === "off";
+}
+
+/**
+ * An abstraction to talk with the FormHistory database over
+ * the message layer. FormHistoryClient will take care of
+ * figuring out the most appropriate message manager to use,
+ * and what things to send.
+ *
+ * It is assumed that nsFormAutoComplete will only ever use
+ * one instance at a time, and will not attempt to perform more
+ * than one search request with the same instance at a time.
+ * However, nsFormAutoComplete might call remove() any number of
+ * times with the same instance of the client.
+ *
+ * @param Object with the following properties:
+ *
+ * formField (DOM node):
+ * A DOM node that we're requesting form history for.
+ *
+ * inputName (string):
+ * The name of the input to do the FormHistory look-up
+ * with. If this is searchbar-history, then formField
+ * needs to be null, otherwise constructing will throw.
+ */
+function FormHistoryClient({ formField, inputName }) {
+ if (formField && inputName != this.SEARCHBAR_ID) {
+ let window = formField.ownerDocument.defaultView;
+ let topDocShell = window.QueryInterface(Ci.nsIInterfaceRequestor)
+ .getInterface(Ci.nsIDocShell)
+ .sameTypeRootTreeItem
+ .QueryInterface(Ci.nsIDocShell);
+ this.mm = topDocShell.QueryInterface(Ci.nsIInterfaceRequestor)
+ .getInterface(Ci.nsIContentFrameMessageManager);
+ } else {
+ if (inputName == this.SEARCHBAR_ID) {
+ if (formField) {
+ throw new Error("FormHistoryClient constructed with both a " +
+ "formField and an inputName. This is not " +
+ "supported, and only empty results will be " +
+ "returned.");
+ }
+ }
+ this.mm = Services.cpmm;
+ }
+
+ this.inputName = inputName;
+ this.id = FormHistoryClient.nextRequestID++;
+}
+
+FormHistoryClient.prototype = {
+ SEARCHBAR_ID: "searchbar-history",
+
+ // It is assumed that nsFormAutoComplete only uses / cares about
+ // one FormHistoryClient at a time, and won't attempt to have
+ // multiple in-flight searches occurring with the same FormHistoryClient.
+ // We use an ID number per instantiated FormHistoryClient to make
+ // sure we only respond to messages that were meant for us.
+ id: 0,
+ callback: null,
+ inputName: "",
+ mm: null,
+
+ /**
+ * Query FormHistory for some results.
+ *
+ * @param searchString (string)
+ * The string to search FormHistory for. See
+ * FormHistory.getAutoCompleteResults.
+ * @param params (object)
+ * An Object with search properties. See
+ * FormHistory.getAutoCompleteResults.
+ * @param callback
+ * A callback function that will take a single
+ * argument (the found entries).
+ */
+ requestAutoCompleteResults(searchString, params, callback) {
+ this.mm.sendAsyncMessage("FormHistory:AutoCompleteSearchAsync", {
+ id: this.id,
+ searchString,
+ params,
+ });
+
+ this.mm.addMessageListener("FormHistory:AutoCompleteSearchResults",
+ this);
+ this.callback = callback;
+ },
+
+ /**
+ * Cancel an in-flight results request. This ensures that the
+ * callback that requestAutoCompleteResults was passed is never
+ * called from this FormHistoryClient.
+ */
+ cancel() {
+ this.clearListeners();
+ },
+
+ /**
+ * Remove an item from FormHistory.
+ *
+ * @param value (string)
+ *
+ * The value to remove for this particular
+ * field.
+ */
+ remove(value) {
+ this.mm.sendAsyncMessage("FormHistory:RemoveEntry", {
+ inputName: this.inputName,
+ value,
+ });
+ },
+
+ // Private methods
+
+ receiveMessage(msg) {
+ let { id, results } = msg.data;
+ if (id != this.id) {
+ return;
+ }
+ if (!this.callback) {
+ Cu.reportError("FormHistoryClient received message with no " +
+ "callback");
+ return;
+ }
+ this.callback(results);
+ this.clearListeners();
+ },
+
+ clearListeners() {
+ this.mm.removeMessageListener("FormHistory:AutoCompleteSearchResults",
+ this);
+ this.callback = null;
+ },
+};
+
+FormHistoryClient.nextRequestID = 1;
+
+
+function FormAutoComplete() {
+ this.init();
+}
+
+/**
+ * FormAutoComplete
+ *
+ * Implements the nsIFormAutoComplete interface in the main process.
+ */
+FormAutoComplete.prototype = {
+ classID : Components.ID("{c11c21b2-71c9-4f87-a0f8-5e13f50495fd}"),
+ QueryInterface : XPCOMUtils.generateQI([Ci.nsIFormAutoComplete, Ci.nsISupportsWeakReference]),
+
+ _prefBranch : null,
+ _debug : true, // mirrors browser.formfill.debug
+ _enabled : true, // mirrors browser.formfill.enable preference
+ _agedWeight : 2,
+ _bucketSize : 1,
+ _maxTimeGroupings : 25,
+ _timeGroupingSize : 7 * 24 * 60 * 60 * 1000 * 1000,
+ _expireDays : null,
+ _boundaryWeight : 25,
+ _prefixWeight : 5,
+
+ // Only one query via FormHistoryClient is performed at a time, and the
+ // most recent FormHistoryClient which will be stored in _pendingClient
+ // while the query is being performed. It will be cleared when the query
+ // finishes, is cancelled, or an error occurs. If a new query occurs while
+ // one is already pending, the existing one is cancelled.
+ _pendingClient : null,
+
+ init : function() {
+ // Preferences. Add observer so we get notified of changes.
+ this._prefBranch = Services.prefs.getBranch("browser.formfill.");
+ this._prefBranch.addObserver("", this.observer, true);
+ this.observer._self = this;
+
+ this._debug = this._prefBranch.getBoolPref("debug");
+ this._enabled = this._prefBranch.getBoolPref("enable");
+ this._agedWeight = this._prefBranch.getIntPref("agedWeight");
+ this._bucketSize = this._prefBranch.getIntPref("bucketSize");
+ this._maxTimeGroupings = this._prefBranch.getIntPref("maxTimeGroupings");
+ this._timeGroupingSize = this._prefBranch.getIntPref("timeGroupingSize") * 1000 * 1000;
+ this._expireDays = this._prefBranch.getIntPref("expire_days");
+ },
+
+ observer : {
+ _self : null,
+
+ QueryInterface : XPCOMUtils.generateQI([Ci.nsIObserver,
+ Ci.nsISupportsWeakReference]),
+
+ observe : function (subject, topic, data) {
+ let self = this._self;
+ if (topic == "nsPref:changed") {
+ let prefName = data;
+ self.log("got change to " + prefName + " preference");
+
+ switch (prefName) {
+ case "agedWeight":
+ self._agedWeight = self._prefBranch.getIntPref(prefName);
+ break;
+ case "debug":
+ self._debug = self._prefBranch.getBoolPref(prefName);
+ break;
+ case "enable":
+ self._enabled = self._prefBranch.getBoolPref(prefName);
+ break;
+ case "maxTimeGroupings":
+ self._maxTimeGroupings = self._prefBranch.getIntPref(prefName);
+ break;
+ case "timeGroupingSize":
+ self._timeGroupingSize = self._prefBranch.getIntPref(prefName) * 1000 * 1000;
+ break;
+ case "bucketSize":
+ self._bucketSize = self._prefBranch.getIntPref(prefName);
+ break;
+ case "boundaryWeight":
+ self._boundaryWeight = self._prefBranch.getIntPref(prefName);
+ break;
+ case "prefixWeight":
+ self._prefixWeight = self._prefBranch.getIntPref(prefName);
+ break;
+ default:
+ self.log("Oops! Pref not handled, change ignored.");
+ }
+ }
+ }
+ },
+
+ // AutoCompleteE10S needs to be able to call autoCompleteSearchAsync without
+ // going through IDL in order to pass a mock DOM object field.
+ get wrappedJSObject() {
+ return this;
+ },
+
+ /*
+ * log
+ *
+ * Internal function for logging debug messages to the Error Console
+ * window
+ */
+ log : function (message) {
+ if (!this._debug)
+ return;
+ dump("FormAutoComplete: " + message + "\n");
+ Services.console.logStringMessage("FormAutoComplete: " + message);
+ },
+
+ /*
+ * autoCompleteSearchAsync
+ *
+ * aInputName -- |name| attribute from the form input being autocompleted.
+ * aUntrimmedSearchString -- current value of the input
+ * aField -- nsIDOMHTMLInputElement being autocompleted (may be null if from chrome)
+ * aPreviousResult -- previous search result, if any.
+ * aDatalistResult -- results from list=datalist for aField.
+ * aListener -- nsIFormAutoCompleteObserver that listens for the nsIAutoCompleteResult
+ * that may be returned asynchronously.
+ */
+ autoCompleteSearchAsync : function (aInputName,
+ aUntrimmedSearchString,
+ aField,
+ aPreviousResult,
+ aDatalistResult,
+ aListener) {
+ function sortBytotalScore (a, b) {
+ return b.totalScore - a.totalScore;
+ }
+
+ // Guard against void DOM strings filtering into this code.
+ if (typeof aInputName === "object") {
+ aInputName = "";
+ }
+ if (typeof aUntrimmedSearchString === "object") {
+ aUntrimmedSearchString = "";
+ }
+
+ let client = new FormHistoryClient({ formField: aField, inputName: aInputName });
+
+ // If we have datalist results, they become our "empty" result.
+ let emptyResult = aDatalistResult ||
+ new FormAutoCompleteResult(client, [],
+ aInputName,
+ aUntrimmedSearchString,
+ null);
+ if (!this._enabled) {
+ if (aListener) {
+ aListener.onSearchCompletion(emptyResult);
+ }
+ return;
+ }
+
+ // don't allow form inputs (aField != null) to get results from search bar history
+ if (aInputName == 'searchbar-history' && aField) {
+ this.log('autoCompleteSearch for input name "' + aInputName + '" is denied');
+ if (aListener) {
+ aListener.onSearchCompletion(emptyResult);
+ }
+ return;
+ }
+
+ if (aField && isAutocompleteDisabled(aField)) {
+ this.log('autoCompleteSearch not allowed due to autcomplete=off');
+ if (aListener) {
+ aListener.onSearchCompletion(emptyResult);
+ }
+ return;
+ }
+
+ this.log("AutoCompleteSearch invoked. Search is: " + aUntrimmedSearchString);
+ let searchString = aUntrimmedSearchString.trim().toLowerCase();
+
+ // reuse previous results if:
+ // a) length greater than one character (others searches are special cases) AND
+ // b) the the new results will be a subset of the previous results
+ if (aPreviousResult && aPreviousResult.searchString.trim().length > 1 &&
+ searchString.indexOf(aPreviousResult.searchString.trim().toLowerCase()) >= 0) {
+ this.log("Using previous autocomplete result");
+ let result = aPreviousResult;
+ let wrappedResult = result.wrappedJSObject;
+ wrappedResult.searchString = aUntrimmedSearchString;
+
+ // Leaky abstraction alert: it would be great to be able to split
+ // this code between nsInputListAutoComplete and here but because of
+ // the way we abuse the formfill autocomplete API in e10s, we have
+ // to deal with the <datalist> results here as well (and down below
+ // in mergeResults).
+ // If there were datalist results result is a FormAutoCompleteResult
+ // as defined in nsFormAutoCompleteResult.jsm with the entire list
+ // of results in wrappedResult._values and only the results from
+ // form history in wrappedResult.entries.
+ // First, grab the entire list of old results.
+ let allResults = wrappedResult._labels;
+ let datalistResults, datalistLabels;
+ if (allResults) {
+ // We have datalist results, extract them from the values array.
+ // Both allResults and values arrays are in the form of:
+ // |--wR.entries--|
+ // <history entries><datalist entries>
+ let oldLabels = allResults.slice(wrappedResult.entries.length);
+ let oldValues = wrappedResult._values.slice(wrappedResult.entries.length);
+
+ datalistLabels = [];
+ datalistResults = [];
+ for (let i = 0; i < oldLabels.length; ++i) {
+ if (oldLabels[i].toLowerCase().includes(searchString)) {
+ datalistLabels.push(oldLabels[i]);
+ datalistResults.push(oldValues[i]);
+ }
+ }
+ }
+
+ let searchTokens = searchString.split(/\s+/);
+ // We have a list of results for a shorter search string, so just
+ // filter them further based on the new search string and add to a new array.
+ let entries = wrappedResult.entries;
+ let filteredEntries = [];
+ for (let i = 0; i < entries.length; i++) {
+ let entry = entries[i];
+ // Remove results that do not contain the token
+ // XXX bug 394604 -- .toLowerCase can be wrong for some intl chars
+ if (searchTokens.some(tok => entry.textLowerCase.indexOf(tok) < 0))
+ continue;
+ this._calculateScore(entry, searchString, searchTokens);
+ this.log("Reusing autocomplete entry '" + entry.text +
+ "' (" + entry.frecency +" / " + entry.totalScore + ")");
+ filteredEntries.push(entry);
+ }
+ filteredEntries.sort(sortBytotalScore);
+ wrappedResult.entries = filteredEntries;
+
+ // If we had datalistResults, re-merge them back into the filtered
+ // entries.
+ if (datalistResults) {
+ filteredEntries = filteredEntries.map(elt => elt.text);
+
+ let comments = new Array(filteredEntries.length + datalistResults.length).fill("");
+ comments[filteredEntries.length] = "separator";
+
+ // History entries don't have labels (their labels would be read
+ // from their values). Pad out the labels array so the datalist
+ // results (which do have separate values and labels) line up.
+ datalistLabels = new Array(filteredEntries.length).fill("").concat(datalistLabels);
+ wrappedResult._values = filteredEntries.concat(datalistResults);
+ wrappedResult._labels = datalistLabels;
+ wrappedResult._comments = comments;
+ }
+
+ if (aListener) {
+ aListener.onSearchCompletion(result);
+ }
+ } else {
+ this.log("Creating new autocomplete search result.");
+
+ // Start with an empty list.
+ let result = aDatalistResult ?
+ new FormAutoCompleteResult(client, [], aInputName, aUntrimmedSearchString, null) :
+ emptyResult;
+
+ let processEntry = (aEntries) => {
+ if (aField && aField.maxLength > -1) {
+ result.entries =
+ aEntries.filter(function (el) { return el.text.length <= aField.maxLength; });
+ } else {
+ result.entries = aEntries;
+ }
+
+ if (aDatalistResult && aDatalistResult.matchCount > 0) {
+ result = this.mergeResults(result, aDatalistResult);
+ }
+
+ if (aListener) {
+ aListener.onSearchCompletion(result);
+ }
+ }
+
+ this.getAutoCompleteValues(client, aInputName, searchString, processEntry);
+ }
+ },
+
+ mergeResults(historyResult, datalistResult) {
+ let values = datalistResult.wrappedJSObject._values;
+ let labels = datalistResult.wrappedJSObject._labels;
+ let comments = new Array(values.length).fill("");
+
+ // historyResult will be null if form autocomplete is disabled. We
+ // still want the list values to display.
+ let entries = historyResult.wrappedJSObject.entries;
+ let historyResults = entries.map(entry => entry.text);
+ let historyComments = new Array(entries.length).fill("");
+
+ // now put the history results above the datalist suggestions
+ let finalValues = historyResults.concat(values);
+ let finalLabels = historyResults.concat(labels);
+ let finalComments = historyComments.concat(comments);
+
+ // This is ugly: there are two FormAutoCompleteResult classes in the
+ // tree, one in a module and one in this file. Datalist results need to
+ // use the one defined in the module but the rest of this file assumes
+ // that we use the one defined here. To get around that, we explicitly
+ // import the module here, out of the way of the other uses of
+ // FormAutoCompleteResult.
+ let {FormAutoCompleteResult} = Cu.import("resource://gre/modules/nsFormAutoCompleteResult.jsm", {});
+ return new FormAutoCompleteResult(datalistResult.searchString,
+ Ci.nsIAutoCompleteResult.RESULT_SUCCESS,
+ 0, "", finalValues, finalLabels,
+ finalComments, historyResult);
+ },
+
+ stopAutoCompleteSearch : function () {
+ if (this._pendingClient) {
+ this._pendingClient.cancel();
+ this._pendingClient = null;
+ }
+ },
+
+ /*
+ * Get the values for an autocomplete list given a search string.
+ *
+ * client - a FormHistoryClient instance to perform the search with
+ * fieldName - fieldname field within form history (the form input name)
+ * searchString - string to search for
+ * callback - called when the values are available. Passed an array of objects,
+ * containing properties for each result. The callback is only called
+ * when successful.
+ */
+ getAutoCompleteValues : function (client, fieldName, searchString, callback) {
+ let params = {
+ agedWeight: this._agedWeight,
+ bucketSize: this._bucketSize,
+ expiryDate: 1000 * (Date.now() - this._expireDays * 24 * 60 * 60 * 1000),
+ fieldname: fieldName,
+ maxTimeGroupings: this._maxTimeGroupings,
+ timeGroupingSize: this._timeGroupingSize,
+ prefixWeight: this._prefixWeight,
+ boundaryWeight: this._boundaryWeight
+ }
+
+ this.stopAutoCompleteSearch();
+ client.requestAutoCompleteResults(searchString, params, (entries) => {
+ this._pendingClient = null;
+ callback(entries);
+ });
+ this._pendingClient = client;
+ },
+
+ /*
+ * _calculateScore
+ *
+ * entry -- an nsIAutoCompleteResult entry
+ * aSearchString -- current value of the input (lowercase)
+ * searchTokens -- array of tokens of the search string
+ *
+ * Returns: an int
+ */
+ _calculateScore : function (entry, aSearchString, searchTokens) {
+ let boundaryCalc = 0;
+ // for each word, calculate word boundary weights
+ for (let token of searchTokens) {
+ boundaryCalc += (entry.textLowerCase.indexOf(token) == 0);
+ boundaryCalc += (entry.textLowerCase.indexOf(" " + token) >= 0);
+ }
+ boundaryCalc = boundaryCalc * this._boundaryWeight;
+ // now add more weight if we have a traditional prefix match and
+ // multiply boundary bonuses by boundary weight
+ boundaryCalc += this._prefixWeight *
+ (entry.textLowerCase.
+ indexOf(aSearchString) == 0);
+ entry.totalScore = Math.round(entry.frecency * Math.max(1, boundaryCalc));
+ }
+
+}; // end of FormAutoComplete implementation
+
+// nsIAutoCompleteResult implementation
+function FormAutoCompleteResult(client,
+ entries,
+ fieldName,
+ searchString,
+ messageManager) {
+ this.client = client;
+ this.entries = entries;
+ this.fieldName = fieldName;
+ this.searchString = searchString;
+ this.messageManager = messageManager;
+}
+
+FormAutoCompleteResult.prototype = {
+ QueryInterface : XPCOMUtils.generateQI([Ci.nsIAutoCompleteResult,
+ Ci.nsISupportsWeakReference]),
+
+ // private
+ client : null,
+ entries : null,
+ fieldName : null,
+
+ _checkIndexBounds : function (index) {
+ if (index < 0 || index >= this.entries.length)
+ throw Components.Exception("Index out of range.", Cr.NS_ERROR_ILLEGAL_VALUE);
+ },
+
+ // Allow autoCompleteSearch to get at the JS object so it can
+ // modify some readonly properties for internal use.
+ get wrappedJSObject() {
+ return this;
+ },
+
+ // Interfaces from idl...
+ searchString : "",
+ errorDescription : "",
+ get defaultIndex() {
+ if (this.entries.length == 0)
+ return -1;
+ return 0;
+ },
+ get searchResult() {
+ if (this.entries.length == 0)
+ return Ci.nsIAutoCompleteResult.RESULT_NOMATCH;
+ return Ci.nsIAutoCompleteResult.RESULT_SUCCESS;
+ },
+ get matchCount() {
+ return this.entries.length;
+ },
+
+ getValueAt : function (index) {
+ this._checkIndexBounds(index);
+ return this.entries[index].text;
+ },
+
+ getLabelAt: function(index) {
+ return this.getValueAt(index);
+ },
+
+ getCommentAt : function (index) {
+ this._checkIndexBounds(index);
+ return "";
+ },
+
+ getStyleAt : function (index) {
+ this._checkIndexBounds(index);
+ return "";
+ },
+
+ getImageAt : function (index) {
+ this._checkIndexBounds(index);
+ return "";
+ },
+
+ getFinalCompleteValueAt : function (index) {
+ return this.getValueAt(index);
+ },
+
+ removeValueAt : function (index, removeFromDB) {
+ this._checkIndexBounds(index);
+
+ let [removedEntry] = this.entries.splice(index, 1);
+
+ if (removeFromDB) {
+ this.client.remove(removedEntry.text);
+ }
+ }
+};
+
+this.NSGetFactory = XPCOMUtils.generateNSGetFactory([FormAutoComplete]);
diff --git a/toolkit/components/satchel/nsFormAutoCompleteResult.jsm b/toolkit/components/satchel/nsFormAutoCompleteResult.jsm
new file mode 100644
index 000000000..07ef15fca
--- /dev/null
+++ b/toolkit/components/satchel/nsFormAutoCompleteResult.jsm
@@ -0,0 +1,187 @@
+/* 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.EXPORTED_SYMBOLS = [ "FormAutoCompleteResult" ];
+
+const Ci = Components.interfaces;
+const Cr = Components.results;
+
+Components.utils.import("resource://gre/modules/XPCOMUtils.jsm");
+
+this.FormAutoCompleteResult =
+ function FormAutoCompleteResult(searchString,
+ searchResult,
+ defaultIndex,
+ errorDescription,
+ values,
+ labels,
+ comments,
+ prevResult) {
+ this.searchString = searchString;
+ this._searchResult = searchResult;
+ this._defaultIndex = defaultIndex;
+ this._errorDescription = errorDescription;
+ this._values = values;
+ this._labels = labels;
+ this._comments = comments;
+ this._formHistResult = prevResult;
+
+ if (prevResult) {
+ this.entries = prevResult.wrappedJSObject.entries;
+ } else {
+ this.entries = [];
+ }
+}
+
+FormAutoCompleteResult.prototype = {
+
+ // The user's query string
+ searchString: "",
+
+ // The result code of this result object, see |get searchResult| for possible values.
+ _searchResult: 0,
+
+ // The default item that should be entered if none is selected
+ _defaultIndex: 0,
+
+ // The reason the search failed
+ _errorDescription: "",
+
+ /**
+ * A reference to the form history nsIAutocompleteResult that we're wrapping.
+ * We use this to forward removeEntryAt calls as needed.
+ */
+ _formHistResult: null,
+
+ entries: null,
+
+ get wrappedJSObject() {
+ return this;
+ },
+
+ /**
+ * @return the result code of this result object, either:
+ * RESULT_IGNORED (invalid searchString)
+ * RESULT_FAILURE (failure)
+ * RESULT_NOMATCH (no matches found)
+ * RESULT_SUCCESS (matches found)
+ */
+ get searchResult() {
+ return this._searchResult;
+ },
+
+ /**
+ * @return the default item that should be entered if none is selected
+ */
+ get defaultIndex() {
+ return this._defaultIndex;
+ },
+
+ /**
+ * @return the reason the search failed
+ */
+ get errorDescription() {
+ return this._errorDescription;
+ },
+
+ /**
+ * @return the number of results
+ */
+ get matchCount() {
+ return this._values.length;
+ },
+
+ _checkIndexBounds : function (index) {
+ if (index < 0 || index >= this._values.length) {
+ throw Components.Exception("Index out of range.", Cr.NS_ERROR_ILLEGAL_VALUE);
+ }
+ },
+
+ /**
+ * Retrieves a result
+ * @param index the index of the result requested
+ * @return the result at the specified index
+ */
+ getValueAt: function(index) {
+ this._checkIndexBounds(index);
+ return this._values[index];
+ },
+
+ getLabelAt: function(index) {
+ this._checkIndexBounds(index);
+ return this._labels[index] || this._values[index];
+ },
+
+ /**
+ * Retrieves a comment (metadata instance)
+ * @param index the index of the comment requested
+ * @return the comment at the specified index
+ */
+ getCommentAt: function(index) {
+ this._checkIndexBounds(index);
+ return this._comments[index];
+ },
+
+ /**
+ * Retrieves a style hint specific to a particular index.
+ * @param index the index of the style hint requested
+ * @return the style hint at the specified index
+ */
+ getStyleAt: function(index) {
+ this._checkIndexBounds(index);
+
+ if (this._formHistResult && index < this._formHistResult.matchCount) {
+ return "fromhistory";
+ }
+
+ if (this._formHistResult &&
+ this._formHistResult.matchCount > 0 &&
+ index == this._formHistResult.matchCount) {
+ return "datalist-first";
+ }
+
+ return null;
+ },
+
+ /**
+ * Retrieves an image url.
+ * @param index the index of the image url requested
+ * @return the image url at the specified index
+ */
+ getImageAt: function(index) {
+ this._checkIndexBounds(index);
+ return "";
+ },
+
+ /**
+ * Retrieves a result
+ * @param index the index of the result requested
+ * @return the result at the specified index
+ */
+ getFinalCompleteValueAt: function(index) {
+ return this.getValueAt(index);
+ },
+
+ /**
+ * Removes a result from the resultset
+ * @param index the index of the result to remove
+ */
+ removeValueAt: function(index, removeFromDatabase) {
+ this._checkIndexBounds(index);
+ // Forward the removeValueAt call to the underlying result if we have one
+ // Note: this assumes that the form history results were added to the top
+ // of our arrays.
+ if (removeFromDatabase && this._formHistResult &&
+ index < this._formHistResult.matchCount) {
+ // Delete the history result from the DB
+ this._formHistResult.removeValueAt(index, true);
+ }
+ this._values.splice(index, 1);
+ this._labels.splice(index, 1);
+ this._comments.splice(index, 1);
+ },
+
+ // nsISupports
+ QueryInterface: XPCOMUtils.generateQI([Ci.nsIAutoCompleteResult])
+};
diff --git a/toolkit/components/satchel/nsFormFillController.cpp b/toolkit/components/satchel/nsFormFillController.cpp
new file mode 100644
index 000000000..d70036635
--- /dev/null
+++ b/toolkit/components/satchel/nsFormFillController.cpp
@@ -0,0 +1,1382 @@
+/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* vim: set ts=8 sts=2 et sw=2 tw=80: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#include "nsFormFillController.h"
+
+#include "mozilla/dom/Element.h"
+#include "mozilla/dom/Event.h" // for nsIDOMEvent::InternalDOMEvent()
+#include "nsIFormAutoComplete.h"
+#include "nsIInputListAutoComplete.h"
+#include "nsIAutoCompleteSimpleResult.h"
+#include "nsString.h"
+#include "nsReadableUtils.h"
+#include "nsIServiceManager.h"
+#include "nsIInterfaceRequestor.h"
+#include "nsIInterfaceRequestorUtils.h"
+#include "nsIDocShellTreeItem.h"
+#include "nsPIDOMWindow.h"
+#include "nsIWebNavigation.h"
+#include "nsIContentViewer.h"
+#include "nsIDOMKeyEvent.h"
+#include "nsIDOMDocument.h"
+#include "nsIDOMElement.h"
+#include "nsIFormControl.h"
+#include "nsIDocument.h"
+#include "nsIContent.h"
+#include "nsIPresShell.h"
+#include "nsRect.h"
+#include "nsIDOMHTMLFormElement.h"
+#include "nsILoginManager.h"
+#include "nsIDOMMouseEvent.h"
+#include "mozilla/ModuleUtils.h"
+#include "nsToolkitCompsCID.h"
+#include "nsEmbedCID.h"
+#include "nsIDOMNSEditableElement.h"
+#include "nsContentUtils.h"
+#include "nsILoadContext.h"
+#include "nsIFrame.h"
+#include "nsIScriptSecurityManager.h"
+#include "nsFocusManager.h"
+
+using namespace mozilla::dom;
+
+NS_IMPL_CYCLE_COLLECTION(nsFormFillController,
+ mController, mLoginManager, mFocusedPopup, mDocShells,
+ mPopups, mLastListener, mLastFormAutoComplete)
+
+NS_INTERFACE_MAP_BEGIN_CYCLE_COLLECTION(nsFormFillController)
+ NS_INTERFACE_MAP_ENTRY_AMBIGUOUS(nsISupports, nsIFormFillController)
+ NS_INTERFACE_MAP_ENTRY(nsIFormFillController)
+ NS_INTERFACE_MAP_ENTRY(nsIAutoCompleteInput)
+ NS_INTERFACE_MAP_ENTRY(nsIAutoCompleteSearch)
+ NS_INTERFACE_MAP_ENTRY(nsIDOMEventListener)
+ NS_INTERFACE_MAP_ENTRY(nsIFormAutoCompleteObserver)
+ NS_INTERFACE_MAP_ENTRY(nsIMutationObserver)
+NS_INTERFACE_MAP_END
+
+NS_IMPL_CYCLE_COLLECTING_ADDREF(nsFormFillController)
+NS_IMPL_CYCLE_COLLECTING_RELEASE(nsFormFillController)
+
+
+
+nsFormFillController::nsFormFillController() :
+ mFocusedInput(nullptr),
+ mFocusedInputNode(nullptr),
+ mListNode(nullptr),
+ mTimeout(50),
+ mMinResultsForPopup(1),
+ mMaxRows(0),
+ mContextMenuFiredBeforeFocus(false),
+ mDisableAutoComplete(false),
+ mCompleteDefaultIndex(false),
+ mCompleteSelectedIndex(false),
+ mForceComplete(false),
+ mSuppressOnInput(false)
+{
+ mController = do_GetService("@mozilla.org/autocomplete/controller;1");
+ MOZ_ASSERT(mController);
+}
+
+nsFormFillController::~nsFormFillController()
+{
+ if (mListNode) {
+ mListNode->RemoveMutationObserver(this);
+ mListNode = nullptr;
+ }
+ if (mFocusedInputNode) {
+ MaybeRemoveMutationObserver(mFocusedInputNode);
+ mFocusedInputNode = nullptr;
+ mFocusedInput = nullptr;
+ }
+ RemoveForDocument(nullptr);
+
+ // Remove ourselves as a focus listener from all cached docShells
+ uint32_t count = mDocShells.Length();
+ for (uint32_t i = 0; i < count; ++i) {
+ nsCOMPtr<nsPIDOMWindowOuter> window = GetWindowForDocShell(mDocShells[i]);
+ RemoveWindowListeners(window);
+ }
+}
+
+////////////////////////////////////////////////////////////////////////
+//// nsIMutationObserver
+//
+
+void
+nsFormFillController::AttributeChanged(nsIDocument* aDocument,
+ mozilla::dom::Element* aElement,
+ int32_t aNameSpaceID,
+ nsIAtom* aAttribute, int32_t aModType,
+ const nsAttrValue* aOldValue)
+{
+ if ((aAttribute == nsGkAtoms::type || aAttribute == nsGkAtoms::readonly ||
+ aAttribute == nsGkAtoms::autocomplete) &&
+ aNameSpaceID == kNameSpaceID_None) {
+ nsCOMPtr<nsIDOMHTMLInputElement> focusedInput(mFocusedInput);
+ // Reset the current state of the controller, unconditionally.
+ StopControllingInput();
+ // Then restart based on the new values. We have to delay this
+ // to avoid ending up in an endless loop due to re-registering our
+ // mutation observer (which would notify us again for *this* event).
+ nsCOMPtr<nsIRunnable> event =
+ mozilla::NewRunnableMethod<nsCOMPtr<nsIDOMHTMLInputElement>>
+ (this, &nsFormFillController::MaybeStartControllingInput, focusedInput);
+ NS_DispatchToCurrentThread(event);
+ }
+
+ if (mListNode && mListNode->Contains(aElement)) {
+ RevalidateDataList();
+ }
+}
+
+void
+nsFormFillController::ContentAppended(nsIDocument* aDocument,
+ nsIContent* aContainer,
+ nsIContent* aChild,
+ int32_t aIndexInContainer)
+{
+ if (mListNode && mListNode->Contains(aContainer)) {
+ RevalidateDataList();
+ }
+}
+
+void
+nsFormFillController::ContentInserted(nsIDocument* aDocument,
+ nsIContent* aContainer,
+ nsIContent* aChild,
+ int32_t aIndexInContainer)
+{
+ if (mListNode && mListNode->Contains(aContainer)) {
+ RevalidateDataList();
+ }
+}
+
+void
+nsFormFillController::ContentRemoved(nsIDocument* aDocument,
+ nsIContent* aContainer,
+ nsIContent* aChild,
+ int32_t aIndexInContainer,
+ nsIContent* aPreviousSibling)
+{
+ if (mListNode && mListNode->Contains(aContainer)) {
+ RevalidateDataList();
+ }
+}
+
+void
+nsFormFillController::CharacterDataWillChange(nsIDocument* aDocument,
+ nsIContent* aContent,
+ CharacterDataChangeInfo* aInfo)
+{
+}
+
+void
+nsFormFillController::CharacterDataChanged(nsIDocument* aDocument,
+ nsIContent* aContent,
+ CharacterDataChangeInfo* aInfo)
+{
+}
+
+void
+nsFormFillController::AttributeWillChange(nsIDocument* aDocument,
+ mozilla::dom::Element* aElement,
+ int32_t aNameSpaceID,
+ nsIAtom* aAttribute, int32_t aModType,
+ const nsAttrValue* aNewValue)
+{
+}
+
+void
+nsFormFillController::NativeAnonymousChildListChange(nsIDocument* aDocument,
+ nsIContent* aContent,
+ bool aIsRemove)
+{
+}
+
+void
+nsFormFillController::ParentChainChanged(nsIContent* aContent)
+{
+}
+
+void
+nsFormFillController::NodeWillBeDestroyed(const nsINode* aNode)
+{
+ mPwmgrInputs.Remove(aNode);
+ if (aNode == mListNode) {
+ mListNode = nullptr;
+ RevalidateDataList();
+ } else if (aNode == mFocusedInputNode) {
+ mFocusedInputNode = nullptr;
+ mFocusedInput = nullptr;
+ }
+}
+
+void
+nsFormFillController::MaybeRemoveMutationObserver(nsINode* aNode)
+{
+ // Nodes being tracked in mPwmgrInputs will have their observers removed when
+ // they stop being tracked.
+ if (!mPwmgrInputs.Get(aNode)) {
+ aNode->RemoveMutationObserver(this);
+ }
+}
+
+////////////////////////////////////////////////////////////////////////
+//// nsIFormFillController
+
+NS_IMETHODIMP
+nsFormFillController::AttachToBrowser(nsIDocShell *aDocShell, nsIAutoCompletePopup *aPopup)
+{
+ NS_ENSURE_TRUE(aDocShell && aPopup, NS_ERROR_ILLEGAL_VALUE);
+
+ mDocShells.AppendElement(aDocShell);
+ mPopups.AppendElement(aPopup);
+
+ // Listen for focus events on the domWindow of the docShell
+ nsCOMPtr<nsPIDOMWindowOuter> window = GetWindowForDocShell(aDocShell);
+ AddWindowListeners(window);
+
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+nsFormFillController::DetachFromBrowser(nsIDocShell *aDocShell)
+{
+ int32_t index = GetIndexOfDocShell(aDocShell);
+ NS_ENSURE_TRUE(index >= 0, NS_ERROR_FAILURE);
+
+ // Stop listening for focus events on the domWindow of the docShell
+ nsCOMPtr<nsPIDOMWindowOuter> window =
+ GetWindowForDocShell(mDocShells.SafeElementAt(index));
+ RemoveWindowListeners(window);
+
+ mDocShells.RemoveElementAt(index);
+ mPopups.RemoveElementAt(index);
+
+ return NS_OK;
+}
+
+
+NS_IMETHODIMP
+nsFormFillController::MarkAsLoginManagerField(nsIDOMHTMLInputElement *aInput)
+{
+ /*
+ * The Login Manager can supply autocomplete results for username fields,
+ * when a user has multiple logins stored for a site. It uses this
+ * interface to indicate that the form manager shouldn't handle the
+ * autocomplete. The form manager also checks for this tag when saving
+ * form history (so it doesn't save usernames).
+ */
+ nsCOMPtr<nsINode> node = do_QueryInterface(aInput);
+ NS_ENSURE_STATE(node);
+
+ // If the field was already marked, we don't want to show the popup again.
+ if (mPwmgrInputs.Get(node)) {
+ return NS_OK;
+ }
+
+ mPwmgrInputs.Put(node, true);
+ node->AddMutationObserverUnlessExists(this);
+
+ nsFocusManager *fm = nsFocusManager::GetFocusManager();
+ if (fm) {
+ nsCOMPtr<nsIContent> focusedContent = fm->GetFocusedContent();
+ if (SameCOMIdentity(focusedContent, node)) {
+ nsCOMPtr<nsIDOMHTMLInputElement> input = do_QueryInterface(node);
+ if (!mFocusedInput) {
+ MaybeStartControllingInput(input);
+ }
+ }
+ }
+
+ if (!mLoginManager)
+ mLoginManager = do_GetService("@mozilla.org/login-manager;1");
+
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+nsFormFillController::GetFocusedInput(nsIDOMHTMLInputElement **aInput) {
+ *aInput = mFocusedInput;
+ NS_IF_ADDREF(*aInput);
+ return NS_OK;
+}
+
+////////////////////////////////////////////////////////////////////////
+//// nsIAutoCompleteInput
+
+NS_IMETHODIMP
+nsFormFillController::GetPopup(nsIAutoCompletePopup **aPopup)
+{
+ *aPopup = mFocusedPopup;
+ NS_IF_ADDREF(*aPopup);
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+nsFormFillController::GetController(nsIAutoCompleteController **aController)
+{
+ *aController = mController;
+ NS_IF_ADDREF(*aController);
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+nsFormFillController::GetPopupOpen(bool *aPopupOpen)
+{
+ if (mFocusedPopup)
+ mFocusedPopup->GetPopupOpen(aPopupOpen);
+ else
+ *aPopupOpen = false;
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+nsFormFillController::SetPopupOpen(bool aPopupOpen)
+{
+ if (mFocusedPopup) {
+ if (aPopupOpen) {
+ // make sure input field is visible before showing popup (bug 320938)
+ nsCOMPtr<nsIContent> content = do_QueryInterface(mFocusedInput);
+ NS_ENSURE_STATE(content);
+ nsCOMPtr<nsIDocShell> docShell = GetDocShellForInput(mFocusedInput);
+ NS_ENSURE_STATE(docShell);
+ nsCOMPtr<nsIPresShell> presShell = docShell->GetPresShell();
+ NS_ENSURE_STATE(presShell);
+ presShell->ScrollContentIntoView(content,
+ nsIPresShell::ScrollAxis(
+ nsIPresShell::SCROLL_MINIMUM,
+ nsIPresShell::SCROLL_IF_NOT_VISIBLE),
+ nsIPresShell::ScrollAxis(
+ nsIPresShell::SCROLL_MINIMUM,
+ nsIPresShell::SCROLL_IF_NOT_VISIBLE),
+ nsIPresShell::SCROLL_OVERFLOW_HIDDEN);
+ // mFocusedPopup can be destroyed after ScrollContentIntoView, see bug 420089
+ if (mFocusedPopup) {
+ nsCOMPtr<nsIDOMElement> element = do_QueryInterface(mFocusedInput);
+ mFocusedPopup->OpenAutocompletePopup(this, element);
+ }
+ } else
+ mFocusedPopup->ClosePopup();
+ }
+
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+nsFormFillController::GetDisableAutoComplete(bool *aDisableAutoComplete)
+{
+ *aDisableAutoComplete = mDisableAutoComplete;
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+nsFormFillController::SetDisableAutoComplete(bool aDisableAutoComplete)
+{
+ mDisableAutoComplete = aDisableAutoComplete;
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+nsFormFillController::GetCompleteDefaultIndex(bool *aCompleteDefaultIndex)
+{
+ *aCompleteDefaultIndex = mCompleteDefaultIndex;
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+nsFormFillController::SetCompleteDefaultIndex(bool aCompleteDefaultIndex)
+{
+ mCompleteDefaultIndex = aCompleteDefaultIndex;
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+nsFormFillController::GetCompleteSelectedIndex(bool *aCompleteSelectedIndex)
+{
+ *aCompleteSelectedIndex = mCompleteSelectedIndex;
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+nsFormFillController::SetCompleteSelectedIndex(bool aCompleteSelectedIndex)
+{
+ mCompleteSelectedIndex = aCompleteSelectedIndex;
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+nsFormFillController::GetForceComplete(bool *aForceComplete)
+{
+ *aForceComplete = mForceComplete;
+ return NS_OK;
+}
+
+NS_IMETHODIMP nsFormFillController::SetForceComplete(bool aForceComplete)
+{
+ mForceComplete = aForceComplete;
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+nsFormFillController::GetMinResultsForPopup(uint32_t *aMinResultsForPopup)
+{
+ *aMinResultsForPopup = mMinResultsForPopup;
+ return NS_OK;
+}
+
+NS_IMETHODIMP nsFormFillController::SetMinResultsForPopup(uint32_t aMinResultsForPopup)
+{
+ mMinResultsForPopup = aMinResultsForPopup;
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+nsFormFillController::GetMaxRows(uint32_t *aMaxRows)
+{
+ *aMaxRows = mMaxRows;
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+nsFormFillController::SetMaxRows(uint32_t aMaxRows)
+{
+ mMaxRows = aMaxRows;
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+nsFormFillController::GetShowImageColumn(bool *aShowImageColumn)
+{
+ *aShowImageColumn = false;
+ return NS_OK;
+}
+
+NS_IMETHODIMP nsFormFillController::SetShowImageColumn(bool aShowImageColumn)
+{
+ return NS_ERROR_NOT_IMPLEMENTED;
+}
+
+
+NS_IMETHODIMP
+nsFormFillController::GetShowCommentColumn(bool *aShowCommentColumn)
+{
+ *aShowCommentColumn = false;
+ return NS_OK;
+}
+
+NS_IMETHODIMP nsFormFillController::SetShowCommentColumn(bool aShowCommentColumn)
+{
+ return NS_ERROR_NOT_IMPLEMENTED;
+}
+
+NS_IMETHODIMP
+nsFormFillController::GetTimeout(uint32_t *aTimeout)
+{
+ *aTimeout = mTimeout;
+ return NS_OK;
+}
+
+NS_IMETHODIMP nsFormFillController::SetTimeout(uint32_t aTimeout)
+{
+ mTimeout = aTimeout;
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+nsFormFillController::SetSearchParam(const nsAString &aSearchParam)
+{
+ return NS_ERROR_NOT_IMPLEMENTED;
+}
+
+NS_IMETHODIMP
+nsFormFillController::GetSearchParam(nsAString &aSearchParam)
+{
+ if (!mFocusedInput) {
+ NS_WARNING("mFocusedInput is null for some reason! avoiding a crash. should find out why... - ben");
+ return NS_ERROR_FAILURE; // XXX why? fix me.
+ }
+
+ mFocusedInput->GetName(aSearchParam);
+ if (aSearchParam.IsEmpty()) {
+ nsCOMPtr<nsIDOMHTMLElement> element = do_QueryInterface(mFocusedInput);
+ element->GetId(aSearchParam);
+ }
+
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+nsFormFillController::GetSearchCount(uint32_t *aSearchCount)
+{
+ *aSearchCount = 1;
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+nsFormFillController::GetSearchAt(uint32_t index, nsACString & _retval)
+{
+ _retval.AssignLiteral("form-history");
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+nsFormFillController::GetTextValue(nsAString & aTextValue)
+{
+ if (mFocusedInput) {
+ nsCOMPtr<nsIDOMHTMLInputElement> input = mFocusedInput;
+ input->GetValue(aTextValue);
+ } else {
+ aTextValue.Truncate();
+ }
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+nsFormFillController::SetTextValue(const nsAString & aTextValue)
+{
+ nsCOMPtr<nsIDOMNSEditableElement> editable = do_QueryInterface(mFocusedInput);
+ if (editable) {
+ mSuppressOnInput = true;
+ editable->SetUserInput(aTextValue);
+ mSuppressOnInput = false;
+ }
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+nsFormFillController::SetTextValueWithReason(const nsAString & aTextValue,
+ uint16_t aReason)
+{
+ return SetTextValue(aTextValue);
+}
+
+NS_IMETHODIMP
+nsFormFillController::GetSelectionStart(int32_t *aSelectionStart)
+{
+ if (mFocusedInput) {
+ nsCOMPtr<nsIDOMHTMLInputElement> input = mFocusedInput;
+ input->GetSelectionStart(aSelectionStart);
+ }
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+nsFormFillController::GetSelectionEnd(int32_t *aSelectionEnd)
+{
+ if (mFocusedInput) {
+ nsCOMPtr<nsIDOMHTMLInputElement> input = mFocusedInput;
+ input->GetSelectionEnd(aSelectionEnd);
+ }
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+nsFormFillController::SelectTextRange(int32_t aStartIndex, int32_t aEndIndex)
+{
+ if (mFocusedInput) {
+ nsCOMPtr<nsIDOMHTMLInputElement> input = mFocusedInput;
+ input->SetSelectionRange(aStartIndex, aEndIndex, EmptyString());
+ }
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+nsFormFillController::OnSearchBegin()
+{
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+nsFormFillController::OnSearchComplete()
+{
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+nsFormFillController::OnTextEntered(nsIDOMEvent* aEvent,
+ bool* aPrevent)
+{
+ NS_ENSURE_ARG(aPrevent);
+ NS_ENSURE_TRUE(mFocusedInput, NS_OK);
+ // Fire off a DOMAutoComplete event
+ nsCOMPtr<nsIDOMDocument> domDoc;
+ nsCOMPtr<nsIDOMElement> element = do_QueryInterface(mFocusedInput);
+ element->GetOwnerDocument(getter_AddRefs(domDoc));
+ NS_ENSURE_STATE(domDoc);
+
+ nsCOMPtr<nsIDOMEvent> event;
+ domDoc->CreateEvent(NS_LITERAL_STRING("Events"), getter_AddRefs(event));
+ NS_ENSURE_STATE(event);
+
+ event->InitEvent(NS_LITERAL_STRING("DOMAutoComplete"), true, true);
+
+ // XXXjst: We mark this event as a trusted event, it's up to the
+ // callers of this to ensure that it's only called from trusted
+ // code.
+ event->SetTrusted(true);
+
+ nsCOMPtr<EventTarget> targ = do_QueryInterface(mFocusedInput);
+
+ bool defaultActionEnabled;
+ targ->DispatchEvent(event, &defaultActionEnabled);
+ *aPrevent = !defaultActionEnabled;
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+nsFormFillController::OnTextReverted(bool *_retval)
+{
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+nsFormFillController::GetConsumeRollupEvent(bool *aConsumeRollupEvent)
+{
+ *aConsumeRollupEvent = false;
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+nsFormFillController::GetInPrivateContext(bool *aInPrivateContext)
+{
+ if (!mFocusedInput) {
+ *aInPrivateContext = false;
+ return NS_OK;
+ }
+
+ nsCOMPtr<nsIDOMDocument> inputDoc;
+ nsCOMPtr<nsIDOMElement> element = do_QueryInterface(mFocusedInput);
+ element->GetOwnerDocument(getter_AddRefs(inputDoc));
+ nsCOMPtr<nsIDocument> doc = do_QueryInterface(inputDoc);
+ nsCOMPtr<nsILoadContext> loadContext = doc->GetLoadContext();
+ *aInPrivateContext = loadContext && loadContext->UsePrivateBrowsing();
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+nsFormFillController::GetNoRollupOnCaretMove(bool *aNoRollupOnCaretMove)
+{
+ *aNoRollupOnCaretMove = false;
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+nsFormFillController::GetUserContextId(uint32_t* aUserContextId)
+{
+ *aUserContextId = nsIScriptSecurityManager::DEFAULT_USER_CONTEXT_ID;
+ return NS_OK;
+}
+
+////////////////////////////////////////////////////////////////////////
+//// nsIAutoCompleteSearch
+
+NS_IMETHODIMP
+nsFormFillController::StartSearch(const nsAString &aSearchString, const nsAString &aSearchParam,
+ nsIAutoCompleteResult *aPreviousResult, nsIAutoCompleteObserver *aListener)
+{
+ nsresult rv;
+ nsCOMPtr<nsIFormControl> formControl = do_QueryInterface(mFocusedInputNode);
+
+ // If the login manager has indicated it's responsible for this field, let it
+ // handle the autocomplete. Otherwise, handle with form history.
+ // This method is sometimes called in unit tests and from XUL without a focused node.
+ if (mFocusedInputNode && (mPwmgrInputs.Get(mFocusedInputNode) ||
+ formControl->GetType() == NS_FORM_INPUT_PASSWORD)) {
+
+ // Handle the case where a password field is focused but
+ // MarkAsLoginManagerField wasn't called because password manager is disabled.
+ if (!mLoginManager) {
+ mLoginManager = do_GetService("@mozilla.org/login-manager;1");
+ }
+
+ if (NS_WARN_IF(!mLoginManager)) {
+ return NS_ERROR_FAILURE;
+ }
+
+ // XXX aPreviousResult shouldn't ever be a historyResult type, since we're not letting
+ // satchel manage the field?
+ mLastListener = aListener;
+ rv = mLoginManager->AutoCompleteSearchAsync(aSearchString,
+ aPreviousResult,
+ mFocusedInput,
+ this);
+ NS_ENSURE_SUCCESS(rv, rv);
+ } else {
+ mLastListener = aListener;
+
+ nsCOMPtr<nsIAutoCompleteResult> datalistResult;
+ if (mFocusedInput) {
+ rv = PerformInputListAutoComplete(aSearchString,
+ getter_AddRefs(datalistResult));
+ NS_ENSURE_SUCCESS(rv, rv);
+ }
+
+ nsCOMPtr <nsIFormAutoComplete> formAutoComplete =
+ do_GetService("@mozilla.org/satchel/form-autocomplete;1", &rv);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ formAutoComplete->AutoCompleteSearchAsync(aSearchParam,
+ aSearchString,
+ mFocusedInput,
+ aPreviousResult,
+ datalistResult,
+ this);
+ mLastFormAutoComplete = formAutoComplete;
+ }
+
+ return NS_OK;
+}
+
+nsresult
+nsFormFillController::PerformInputListAutoComplete(const nsAString& aSearch,
+ nsIAutoCompleteResult** aResult)
+{
+ // If an <input> is focused, check if it has a list="<datalist>" which can
+ // provide the list of suggestions.
+
+ MOZ_ASSERT(!mPwmgrInputs.Get(mFocusedInputNode));
+ nsresult rv;
+
+ nsCOMPtr <nsIInputListAutoComplete> inputListAutoComplete =
+ do_GetService("@mozilla.org/satchel/inputlist-autocomplete;1", &rv);
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = inputListAutoComplete->AutoCompleteSearch(aSearch,
+ mFocusedInput,
+ aResult);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ if (mFocusedInput) {
+ nsCOMPtr<nsIDOMHTMLElement> list;
+ mFocusedInput->GetList(getter_AddRefs(list));
+
+ // Add a mutation observer to check for changes to the items in the <datalist>
+ // and update the suggestions accordingly.
+ nsCOMPtr<nsINode> node = do_QueryInterface(list);
+ if (mListNode != node) {
+ if (mListNode) {
+ mListNode->RemoveMutationObserver(this);
+ mListNode = nullptr;
+ }
+ if (node) {
+ node->AddMutationObserverUnlessExists(this);
+ mListNode = node;
+ }
+ }
+ }
+
+ return NS_OK;
+}
+
+class UpdateSearchResultRunnable : public mozilla::Runnable
+{
+public:
+ UpdateSearchResultRunnable(nsIAutoCompleteObserver* aObserver,
+ nsIAutoCompleteSearch* aSearch,
+ nsIAutoCompleteResult* aResult)
+ : mObserver(aObserver)
+ , mSearch(aSearch)
+ , mResult(aResult)
+ {
+ MOZ_ASSERT(mResult, "Should have a valid result");
+ MOZ_ASSERT(mObserver, "You shouldn't call this runnable with a null observer!");
+ }
+
+ NS_IMETHOD Run() override {
+ mObserver->OnUpdateSearchResult(mSearch, mResult);
+ return NS_OK;
+ }
+
+private:
+ nsCOMPtr<nsIAutoCompleteObserver> mObserver;
+ nsCOMPtr<nsIAutoCompleteSearch> mSearch;
+ nsCOMPtr<nsIAutoCompleteResult> mResult;
+};
+
+void nsFormFillController::RevalidateDataList()
+{
+ if (!mLastListener) {
+ return;
+ }
+
+ if (XRE_IsContentProcess()) {
+ nsCOMPtr<nsIAutoCompleteController> controller(do_QueryInterface(mLastListener));
+ if (!controller) {
+ return;
+ }
+
+ controller->StartSearch(mLastSearchString);
+ return;
+ }
+
+ nsresult rv;
+ nsCOMPtr <nsIInputListAutoComplete> inputListAutoComplete =
+ do_GetService("@mozilla.org/satchel/inputlist-autocomplete;1", &rv);
+
+ nsCOMPtr<nsIAutoCompleteResult> result;
+
+ rv = inputListAutoComplete->AutoCompleteSearch(mLastSearchString,
+ mFocusedInput,
+ getter_AddRefs(result));
+
+ nsCOMPtr<nsIRunnable> event =
+ new UpdateSearchResultRunnable(mLastListener, this, result);
+ NS_DispatchToCurrentThread(event);
+}
+
+NS_IMETHODIMP
+nsFormFillController::StopSearch()
+{
+ // Make sure to stop and clear this, otherwise the controller will prevent
+ // mLastFormAutoComplete from being deleted.
+ if (mLastFormAutoComplete) {
+ mLastFormAutoComplete->StopAutoCompleteSearch();
+ mLastFormAutoComplete = nullptr;
+ } else if (mLoginManager) {
+ mLoginManager->StopSearch();
+ }
+ return NS_OK;
+}
+
+////////////////////////////////////////////////////////////////////////
+//// nsIFormAutoCompleteObserver
+
+NS_IMETHODIMP
+nsFormFillController::OnSearchCompletion(nsIAutoCompleteResult *aResult)
+{
+ nsAutoString searchString;
+ aResult->GetSearchString(searchString);
+
+ mLastSearchString = searchString;
+
+ if (mLastListener) {
+ mLastListener->OnSearchResult(this, aResult);
+ }
+
+ return NS_OK;
+}
+
+////////////////////////////////////////////////////////////////////////
+//// nsIDOMEventListener
+
+NS_IMETHODIMP
+nsFormFillController::HandleEvent(nsIDOMEvent* aEvent)
+{
+ nsAutoString type;
+ aEvent->GetType(type);
+
+ if (type.EqualsLiteral("focus")) {
+ return Focus(aEvent);
+ }
+ if (type.EqualsLiteral("mousedown")) {
+ return MouseDown(aEvent);
+ }
+ if (type.EqualsLiteral("keypress")) {
+ return KeyPress(aEvent);
+ }
+ if (type.EqualsLiteral("input")) {
+ bool unused = false;
+ return (!mSuppressOnInput && mController && mFocusedInput) ?
+ mController->HandleText(&unused) : NS_OK;
+ }
+ if (type.EqualsLiteral("blur")) {
+ if (mFocusedInput)
+ StopControllingInput();
+ return NS_OK;
+ }
+ if (type.EqualsLiteral("compositionstart")) {
+ NS_ASSERTION(mController, "should have a controller!");
+ if (mController && mFocusedInput)
+ mController->HandleStartComposition();
+ return NS_OK;
+ }
+ if (type.EqualsLiteral("compositionend")) {
+ NS_ASSERTION(mController, "should have a controller!");
+ if (mController && mFocusedInput)
+ mController->HandleEndComposition();
+ return NS_OK;
+ }
+ if (type.EqualsLiteral("contextmenu")) {
+ mContextMenuFiredBeforeFocus = true;
+ if (mFocusedPopup)
+ mFocusedPopup->ClosePopup();
+ return NS_OK;
+ }
+ if (type.EqualsLiteral("pagehide")) {
+
+ nsCOMPtr<nsIDocument> doc = do_QueryInterface(
+ aEvent->InternalDOMEvent()->GetTarget());
+ if (!doc)
+ return NS_OK;
+
+ if (mFocusedInput) {
+ if (doc == mFocusedInputNode->OwnerDoc())
+ StopControllingInput();
+ }
+
+ RemoveForDocument(doc);
+ }
+
+ return NS_OK;
+}
+
+void
+nsFormFillController::RemoveForDocument(nsIDocument* aDoc)
+{
+ for (auto iter = mPwmgrInputs.Iter(); !iter.Done(); iter.Next()) {
+ const nsINode* key = iter.Key();
+ if (key && (!aDoc || key->OwnerDoc() == aDoc)) {
+ // mFocusedInputNode's observer is tracked separately, so don't remove it
+ // here.
+ if (key != mFocusedInputNode) {
+ const_cast<nsINode*>(key)->RemoveMutationObserver(this);
+ }
+ iter.Remove();
+ }
+ }
+}
+
+void
+nsFormFillController::MaybeStartControllingInput(nsIDOMHTMLInputElement* aInput)
+{
+ nsCOMPtr<nsINode> inputNode = do_QueryInterface(aInput);
+ if (!inputNode)
+ return;
+
+ nsCOMPtr<nsIFormControl> formControl = do_QueryInterface(aInput);
+ if (!formControl || !formControl->IsSingleLineTextControl(false))
+ return;
+
+ bool isReadOnly = false;
+ aInput->GetReadOnly(&isReadOnly);
+ if (isReadOnly)
+ return;
+
+ bool autocomplete = nsContentUtils::IsAutocompleteEnabled(aInput);
+
+ nsCOMPtr<nsIDOMHTMLElement> datalist;
+ aInput->GetList(getter_AddRefs(datalist));
+ bool hasList = datalist != nullptr;
+
+ bool isPwmgrInput = false;
+ if (mPwmgrInputs.Get(inputNode) ||
+ formControl->GetType() == NS_FORM_INPUT_PASSWORD) {
+ isPwmgrInput = true;
+ }
+
+ if (isPwmgrInput || hasList || autocomplete) {
+ StartControllingInput(aInput);
+ }
+}
+
+nsresult
+nsFormFillController::Focus(nsIDOMEvent* aEvent)
+{
+ nsCOMPtr<nsIDOMHTMLInputElement> input = do_QueryInterface(
+ aEvent->InternalDOMEvent()->GetTarget());
+ MaybeStartControllingInput(input);
+
+ // Bail if we didn't start controlling the input.
+ if (!mFocusedInputNode) {
+ mContextMenuFiredBeforeFocus = false;
+ return NS_OK;
+ }
+
+#ifndef ANDROID
+ nsCOMPtr<nsIFormControl> formControl = do_QueryInterface(mFocusedInputNode);
+ MOZ_ASSERT(formControl);
+
+ // If this focus doesn't immediately follow a contextmenu event then show
+ // the autocomplete popup for all password fields.
+ if (!mContextMenuFiredBeforeFocus
+ && formControl->GetType() == NS_FORM_INPUT_PASSWORD) {
+ ShowPopup();
+ }
+#endif
+
+ mContextMenuFiredBeforeFocus = false;
+ return NS_OK;
+}
+
+nsresult
+nsFormFillController::KeyPress(nsIDOMEvent* aEvent)
+{
+ NS_ASSERTION(mController, "should have a controller!");
+ if (!mFocusedInput || !mController)
+ return NS_OK;
+
+ nsCOMPtr<nsIDOMKeyEvent> keyEvent = do_QueryInterface(aEvent);
+ if (!keyEvent)
+ return NS_ERROR_FAILURE;
+
+ bool cancel = false;
+ bool unused = false;
+
+ uint32_t k;
+ keyEvent->GetKeyCode(&k);
+ switch (k) {
+ case nsIDOMKeyEvent::DOM_VK_DELETE:
+#ifndef XP_MACOSX
+ mController->HandleDelete(&cancel);
+ break;
+ case nsIDOMKeyEvent::DOM_VK_BACK_SPACE:
+ mController->HandleText(&unused);
+ break;
+#else
+ case nsIDOMKeyEvent::DOM_VK_BACK_SPACE:
+ {
+ bool isShift = false;
+ keyEvent->GetShiftKey(&isShift);
+
+ if (isShift) {
+ mController->HandleDelete(&cancel);
+ } else {
+ mController->HandleText(&unused);
+ }
+
+ break;
+ }
+#endif
+ case nsIDOMKeyEvent::DOM_VK_PAGE_UP:
+ case nsIDOMKeyEvent::DOM_VK_PAGE_DOWN:
+ {
+ bool isCtrl, isAlt, isMeta;
+ keyEvent->GetCtrlKey(&isCtrl);
+ keyEvent->GetAltKey(&isAlt);
+ keyEvent->GetMetaKey(&isMeta);
+ if (isCtrl || isAlt || isMeta)
+ break;
+ }
+ MOZ_FALLTHROUGH;
+ case nsIDOMKeyEvent::DOM_VK_UP:
+ case nsIDOMKeyEvent::DOM_VK_DOWN:
+ case nsIDOMKeyEvent::DOM_VK_LEFT:
+ case nsIDOMKeyEvent::DOM_VK_RIGHT:
+ {
+ // Get the writing-mode of the relevant input element,
+ // so that we can remap arrow keys if necessary.
+ mozilla::WritingMode wm;
+ if (mFocusedInputNode && mFocusedInputNode->IsElement()) {
+ mozilla::dom::Element *elem = mFocusedInputNode->AsElement();
+ nsIFrame *frame = elem->GetPrimaryFrame();
+ if (frame) {
+ wm = frame->GetWritingMode();
+ }
+ }
+ if (wm.IsVertical()) {
+ switch (k) {
+ case nsIDOMKeyEvent::DOM_VK_LEFT:
+ k = wm.IsVerticalLR() ? nsIDOMKeyEvent::DOM_VK_UP
+ : nsIDOMKeyEvent::DOM_VK_DOWN;
+ break;
+ case nsIDOMKeyEvent::DOM_VK_RIGHT:
+ k = wm.IsVerticalLR() ? nsIDOMKeyEvent::DOM_VK_DOWN
+ : nsIDOMKeyEvent::DOM_VK_UP;
+ break;
+ case nsIDOMKeyEvent::DOM_VK_UP:
+ k = nsIDOMKeyEvent::DOM_VK_LEFT;
+ break;
+ case nsIDOMKeyEvent::DOM_VK_DOWN:
+ k = nsIDOMKeyEvent::DOM_VK_RIGHT;
+ break;
+ }
+ }
+ }
+ mController->HandleKeyNavigation(k, &cancel);
+ break;
+ case nsIDOMKeyEvent::DOM_VK_ESCAPE:
+ mController->HandleEscape(&cancel);
+ break;
+ case nsIDOMKeyEvent::DOM_VK_TAB:
+ mController->HandleTab();
+ cancel = false;
+ break;
+ case nsIDOMKeyEvent::DOM_VK_RETURN:
+ mController->HandleEnter(false, aEvent, &cancel);
+ break;
+ }
+
+ if (cancel) {
+ aEvent->PreventDefault();
+ // Don't let the page see the RETURN event when the popup is open
+ // (indicated by cancel=true) so sites don't manually submit forms
+ // (e.g. via submit.click()) without the autocompleted value being filled.
+ // Bug 286933 will fix this for other key events.
+ if (k == nsIDOMKeyEvent::DOM_VK_RETURN) {
+ aEvent->StopPropagation();
+ }
+ }
+
+ return NS_OK;
+}
+
+nsresult
+nsFormFillController::MouseDown(nsIDOMEvent* aEvent)
+{
+ nsCOMPtr<nsIDOMMouseEvent> mouseEvent(do_QueryInterface(aEvent));
+ if (!mouseEvent)
+ return NS_ERROR_FAILURE;
+
+ nsCOMPtr<nsIDOMHTMLInputElement> targetInput = do_QueryInterface(
+ aEvent->InternalDOMEvent()->GetTarget());
+ if (!targetInput)
+ return NS_OK;
+
+ int16_t button;
+ mouseEvent->GetButton(&button);
+ if (button != 0)
+ return NS_OK;
+
+ return ShowPopup();
+}
+
+NS_IMETHODIMP
+nsFormFillController::ShowPopup()
+{
+ bool isOpen = false;
+ GetPopupOpen(&isOpen);
+ if (isOpen) {
+ return SetPopupOpen(false);
+ }
+
+ nsCOMPtr<nsIAutoCompleteInput> input;
+ mController->GetInput(getter_AddRefs(input));
+ if (!input)
+ return NS_OK;
+
+ nsAutoString value;
+ input->GetTextValue(value);
+ if (value.Length() > 0) {
+ // Show the popup with a filtered result set
+ mController->SetSearchString(EmptyString());
+ bool unused = false;
+ mController->HandleText(&unused);
+ } else {
+ // Show the popup with the complete result set. Can't use HandleText()
+ // because it doesn't display the popup if the input is blank.
+ bool cancel = false;
+ mController->HandleKeyNavigation(nsIDOMKeyEvent::DOM_VK_DOWN, &cancel);
+ }
+
+ return NS_OK;
+}
+
+////////////////////////////////////////////////////////////////////////
+//// nsFormFillController
+
+void
+nsFormFillController::AddWindowListeners(nsPIDOMWindowOuter* aWindow)
+{
+ if (!aWindow)
+ return;
+
+ EventTarget* target = aWindow->GetChromeEventHandler();
+
+ if (!target)
+ return;
+
+ target->AddEventListener(NS_LITERAL_STRING("focus"), this,
+ true, false);
+ target->AddEventListener(NS_LITERAL_STRING("blur"), this,
+ true, false);
+ target->AddEventListener(NS_LITERAL_STRING("pagehide"), this,
+ true, false);
+ target->AddEventListener(NS_LITERAL_STRING("mousedown"), this,
+ true, false);
+ target->AddEventListener(NS_LITERAL_STRING("input"), this,
+ true, false);
+ target->AddEventListener(NS_LITERAL_STRING("keypress"), this, true, false);
+ target->AddEventListener(NS_LITERAL_STRING("compositionstart"), this,
+ true, false);
+ target->AddEventListener(NS_LITERAL_STRING("compositionend"), this,
+ true, false);
+ target->AddEventListener(NS_LITERAL_STRING("contextmenu"), this,
+ true, false);
+
+ // Note that any additional listeners added should ensure that they ignore
+ // untrusted events, which might be sent by content that's up to no good.
+}
+
+void
+nsFormFillController::RemoveWindowListeners(nsPIDOMWindowOuter* aWindow)
+{
+ if (!aWindow)
+ return;
+
+ StopControllingInput();
+
+ nsCOMPtr<nsIDocument> doc = aWindow->GetDoc();
+ RemoveForDocument(doc);
+
+ EventTarget* target = aWindow->GetChromeEventHandler();
+
+ if (!target)
+ return;
+
+ target->RemoveEventListener(NS_LITERAL_STRING("focus"), this, true);
+ target->RemoveEventListener(NS_LITERAL_STRING("blur"), this, true);
+ target->RemoveEventListener(NS_LITERAL_STRING("pagehide"), this, true);
+ target->RemoveEventListener(NS_LITERAL_STRING("mousedown"), this, true);
+ target->RemoveEventListener(NS_LITERAL_STRING("input"), this, true);
+ target->RemoveEventListener(NS_LITERAL_STRING("keypress"), this, true);
+ target->RemoveEventListener(NS_LITERAL_STRING("compositionstart"), this,
+ true);
+ target->RemoveEventListener(NS_LITERAL_STRING("compositionend"), this,
+ true);
+ target->RemoveEventListener(NS_LITERAL_STRING("contextmenu"), this, true);
+}
+
+void
+nsFormFillController::StartControllingInput(nsIDOMHTMLInputElement *aInput)
+{
+ // Make sure we're not still attached to an input
+ StopControllingInput();
+
+ if (!mController) {
+ return;
+ }
+
+ // Find the currently focused docShell
+ nsCOMPtr<nsIDocShell> docShell = GetDocShellForInput(aInput);
+ int32_t index = GetIndexOfDocShell(docShell);
+ if (index < 0)
+ return;
+
+ // Cache the popup for the focused docShell
+ mFocusedPopup = mPopups.SafeElementAt(index);
+
+ nsCOMPtr<nsINode> node = do_QueryInterface(aInput);
+ if (!node) {
+ return;
+ }
+
+ node->AddMutationObserverUnlessExists(this);
+ mFocusedInputNode = node;
+ mFocusedInput = aInput;
+
+ nsCOMPtr<nsIDOMHTMLElement> list;
+ mFocusedInput->GetList(getter_AddRefs(list));
+ nsCOMPtr<nsINode> listNode = do_QueryInterface(list);
+ if (listNode) {
+ listNode->AddMutationObserverUnlessExists(this);
+ mListNode = listNode;
+ }
+
+ mController->SetInput(this);
+}
+
+void
+nsFormFillController::StopControllingInput()
+{
+ if (mListNode) {
+ mListNode->RemoveMutationObserver(this);
+ mListNode = nullptr;
+ }
+
+ if (mController) {
+ // Reset the controller's input, but not if it has been switched
+ // to another input already, which might happen if the user switches
+ // focus by clicking another autocomplete textbox
+ nsCOMPtr<nsIAutoCompleteInput> input;
+ mController->GetInput(getter_AddRefs(input));
+ if (input == this)
+ mController->SetInput(nullptr);
+ }
+
+ if (mFocusedInputNode) {
+ MaybeRemoveMutationObserver(mFocusedInputNode);
+
+ nsresult rv;
+ nsCOMPtr <nsIFormAutoComplete> formAutoComplete =
+ do_GetService("@mozilla.org/satchel/form-autocomplete;1", &rv);
+ if (formAutoComplete) {
+ formAutoComplete->StopControllingInput(mFocusedInput);
+ }
+
+ mFocusedInputNode = nullptr;
+ mFocusedInput = nullptr;
+ }
+
+ if (mFocusedPopup) {
+ mFocusedPopup->ClosePopup();
+ }
+ mFocusedPopup = nullptr;
+}
+
+nsIDocShell *
+nsFormFillController::GetDocShellForInput(nsIDOMHTMLInputElement *aInput)
+{
+ nsCOMPtr<nsINode> node = do_QueryInterface(aInput);
+ NS_ENSURE_TRUE(node, nullptr);
+
+ nsCOMPtr<nsPIDOMWindowOuter> win = node->OwnerDoc()->GetWindow();
+ NS_ENSURE_TRUE(win, nullptr);
+
+ return win->GetDocShell();
+}
+
+nsPIDOMWindowOuter*
+nsFormFillController::GetWindowForDocShell(nsIDocShell *aDocShell)
+{
+ nsCOMPtr<nsIContentViewer> contentViewer;
+ aDocShell->GetContentViewer(getter_AddRefs(contentViewer));
+ NS_ENSURE_TRUE(contentViewer, nullptr);
+
+ nsCOMPtr<nsIDOMDocument> domDoc;
+ contentViewer->GetDOMDocument(getter_AddRefs(domDoc));
+ nsCOMPtr<nsIDocument> doc = do_QueryInterface(domDoc);
+ NS_ENSURE_TRUE(doc, nullptr);
+
+ return doc->GetWindow();
+}
+
+int32_t
+nsFormFillController::GetIndexOfDocShell(nsIDocShell *aDocShell)
+{
+ if (!aDocShell)
+ return -1;
+
+ // Loop through our cached docShells looking for the given docShell
+ uint32_t count = mDocShells.Length();
+ for (uint32_t i = 0; i < count; ++i) {
+ if (mDocShells[i] == aDocShell)
+ return i;
+ }
+
+ // Recursively check the parent docShell of this one
+ nsCOMPtr<nsIDocShellTreeItem> treeItem = do_QueryInterface(aDocShell);
+ nsCOMPtr<nsIDocShellTreeItem> parentItem;
+ treeItem->GetParent(getter_AddRefs(parentItem));
+ if (parentItem) {
+ nsCOMPtr<nsIDocShell> parentShell = do_QueryInterface(parentItem);
+ return GetIndexOfDocShell(parentShell);
+ }
+
+ return -1;
+}
+
+NS_GENERIC_FACTORY_CONSTRUCTOR(nsFormFillController)
+
+NS_DEFINE_NAMED_CID(NS_FORMFILLCONTROLLER_CID);
+
+static const mozilla::Module::CIDEntry kSatchelCIDs[] = {
+ { &kNS_FORMFILLCONTROLLER_CID, false, nullptr, nsFormFillControllerConstructor },
+ { nullptr }
+};
+
+static const mozilla::Module::ContractIDEntry kSatchelContracts[] = {
+ { "@mozilla.org/satchel/form-fill-controller;1", &kNS_FORMFILLCONTROLLER_CID },
+ { NS_FORMHISTORYAUTOCOMPLETE_CONTRACTID, &kNS_FORMFILLCONTROLLER_CID },
+ { nullptr }
+};
+
+static const mozilla::Module kSatchelModule = {
+ mozilla::Module::kVersion,
+ kSatchelCIDs,
+ kSatchelContracts
+};
+
+NSMODULE_DEFN(satchel) = &kSatchelModule;
diff --git a/toolkit/components/satchel/nsFormFillController.h b/toolkit/components/satchel/nsFormFillController.h
new file mode 100644
index 000000000..27fb1edbd
--- /dev/null
+++ b/toolkit/components/satchel/nsFormFillController.h
@@ -0,0 +1,125 @@
+/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* 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/. */
+
+#ifndef __nsFormFillController__
+#define __nsFormFillController__
+
+#include "nsIFormFillController.h"
+#include "nsIAutoCompleteInput.h"
+#include "nsIAutoCompleteSearch.h"
+#include "nsIAutoCompleteController.h"
+#include "nsIAutoCompletePopup.h"
+#include "nsIFormAutoComplete.h"
+#include "nsIDOMEventListener.h"
+#include "nsCOMPtr.h"
+#include "nsDataHashtable.h"
+#include "nsIDocShell.h"
+#include "nsIDOMHTMLInputElement.h"
+#include "nsILoginManager.h"
+#include "nsIMutationObserver.h"
+#include "nsTArray.h"
+#include "nsCycleCollectionParticipant.h"
+
+// X.h defines KeyPress
+#ifdef KeyPress
+#undef KeyPress
+#endif
+
+class nsFormHistory;
+class nsINode;
+class nsPIDOMWindowOuter;
+
+class nsFormFillController final : public nsIFormFillController,
+ public nsIAutoCompleteInput,
+ public nsIAutoCompleteSearch,
+ public nsIDOMEventListener,
+ public nsIFormAutoCompleteObserver,
+ public nsIMutationObserver
+{
+public:
+ NS_DECL_CYCLE_COLLECTING_ISUPPORTS
+ NS_DECL_NSIFORMFILLCONTROLLER
+ NS_DECL_NSIAUTOCOMPLETESEARCH
+ NS_DECL_NSIAUTOCOMPLETEINPUT
+ NS_DECL_NSIFORMAUTOCOMPLETEOBSERVER
+ NS_DECL_NSIDOMEVENTLISTENER
+ NS_DECL_NSIMUTATIONOBSERVER
+
+ NS_DECL_CYCLE_COLLECTION_CLASS_AMBIGUOUS(nsFormFillController, nsIFormFillController)
+
+ nsresult Focus(nsIDOMEvent* aEvent);
+ nsresult KeyPress(nsIDOMEvent* aKeyEvent);
+ nsresult MouseDown(nsIDOMEvent* aMouseEvent);
+
+ nsFormFillController();
+
+protected:
+ virtual ~nsFormFillController();
+
+ void AddWindowListeners(nsPIDOMWindowOuter* aWindow);
+ void RemoveWindowListeners(nsPIDOMWindowOuter* aWindow);
+
+ void AddKeyListener(nsINode* aInput);
+ void RemoveKeyListener();
+
+ void StartControllingInput(nsIDOMHTMLInputElement *aInput);
+ void StopControllingInput();
+ /**
+ * Checks that aElement is a type of element we want to fill, then calls
+ * StartControllingInput on it.
+ */
+ void MaybeStartControllingInput(nsIDOMHTMLInputElement* aElement);
+
+ nsresult PerformInputListAutoComplete(const nsAString& aSearch,
+ nsIAutoCompleteResult** aResult);
+
+ void RevalidateDataList();
+ bool RowMatch(nsFormHistory *aHistory, uint32_t aIndex, const nsAString &aInputName, const nsAString &aInputValue);
+
+ inline nsIDocShell *GetDocShellForInput(nsIDOMHTMLInputElement *aInput);
+ inline nsPIDOMWindowOuter *GetWindowForDocShell(nsIDocShell *aDocShell);
+ inline int32_t GetIndexOfDocShell(nsIDocShell *aDocShell);
+
+ void MaybeRemoveMutationObserver(nsINode* aNode);
+
+ void RemoveForDocument(nsIDocument* aDoc);
+ bool IsEventTrusted(nsIDOMEvent *aEvent);
+ // members //////////////////////////////////////////
+
+ nsCOMPtr<nsIAutoCompleteController> mController;
+ nsCOMPtr<nsILoginManager> mLoginManager;
+ nsIDOMHTMLInputElement* mFocusedInput;
+ nsINode* mFocusedInputNode;
+
+ // mListNode is a <datalist> element which, is set, has the form fill controller
+ // as a mutation observer for it.
+ nsINode* mListNode;
+ nsCOMPtr<nsIAutoCompletePopup> mFocusedPopup;
+
+ nsTArray<nsCOMPtr<nsIDocShell> > mDocShells;
+ nsTArray<nsCOMPtr<nsIAutoCompletePopup> > mPopups;
+
+ // The observer passed to StartSearch. It will be notified when the search is
+ // complete or the data from a datalist changes.
+ nsCOMPtr<nsIAutoCompleteObserver> mLastListener;
+
+ // This is cleared by StopSearch().
+ nsCOMPtr<nsIFormAutoComplete> mLastFormAutoComplete;
+ nsString mLastSearchString;
+
+ nsDataHashtable<nsPtrHashKey<const nsINode>, bool> mPwmgrInputs;
+
+ uint32_t mTimeout;
+ uint32_t mMinResultsForPopup;
+ uint32_t mMaxRows;
+ bool mContextMenuFiredBeforeFocus;
+ bool mDisableAutoComplete;
+ bool mCompleteDefaultIndex;
+ bool mCompleteSelectedIndex;
+ bool mForceComplete;
+ bool mSuppressOnInput;
+};
+
+#endif // __nsFormFillController__
diff --git a/toolkit/components/satchel/nsFormHistory.js b/toolkit/components/satchel/nsFormHistory.js
new file mode 100644
index 000000000..d68be2d58
--- /dev/null
+++ b/toolkit/components/satchel/nsFormHistory.js
@@ -0,0 +1,894 @@
+/* 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 Cc = Components.classes;
+const Ci = Components.interfaces;
+const Cr = Components.results;
+
+Components.utils.import("resource://gre/modules/XPCOMUtils.jsm");
+Components.utils.import("resource://gre/modules/Services.jsm");
+
+XPCOMUtils.defineLazyModuleGetter(this, "Deprecated",
+ "resource://gre/modules/Deprecated.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "AppConstants",
+ "resource://gre/modules/AppConstants.jsm");
+
+const DB_VERSION = 4;
+const DAY_IN_MS = 86400000; // 1 day in milliseconds
+
+function FormHistory() {
+ Deprecated.warning(
+ "nsIFormHistory2 is deprecated and will be removed in a future version",
+ "https://bugzilla.mozilla.org/show_bug.cgi?id=879118");
+ this.init();
+}
+
+FormHistory.prototype = {
+ classID : Components.ID("{0c1bb408-71a2-403f-854a-3a0659829ded}"),
+ QueryInterface : XPCOMUtils.generateQI([Ci.nsIFormHistory2,
+ Ci.nsIObserver,
+ Ci.nsIMessageListener,
+ Ci.nsISupportsWeakReference,
+ ]),
+
+ debug : true,
+ enabled : true,
+
+ // The current database schema.
+ dbSchema : {
+ tables : {
+ moz_formhistory: {
+ "id" : "INTEGER PRIMARY KEY",
+ "fieldname" : "TEXT NOT NULL",
+ "value" : "TEXT NOT NULL",
+ "timesUsed" : "INTEGER",
+ "firstUsed" : "INTEGER",
+ "lastUsed" : "INTEGER",
+ "guid" : "TEXT"
+ },
+ moz_deleted_formhistory: {
+ "id" : "INTEGER PRIMARY KEY",
+ "timeDeleted" : "INTEGER",
+ "guid" : "TEXT"
+ }
+ },
+ indices : {
+ moz_formhistory_index : {
+ table : "moz_formhistory",
+ columns : ["fieldname"]
+ },
+ moz_formhistory_lastused_index : {
+ table : "moz_formhistory",
+ columns : ["lastUsed"]
+ },
+ moz_formhistory_guid_index : {
+ table : "moz_formhistory",
+ columns : ["guid"]
+ },
+ }
+ },
+ dbStmts : null, // Database statements for memoization
+ dbFile : null,
+
+ _uuidService: null,
+ get uuidService() {
+ if (!this._uuidService)
+ this._uuidService = Cc["@mozilla.org/uuid-generator;1"].
+ getService(Ci.nsIUUIDGenerator);
+ return this._uuidService;
+ },
+
+ log : function log(message) {
+ if (!this.debug)
+ return;
+ dump("FormHistory: " + message + "\n");
+ Services.console.logStringMessage("FormHistory: " + message);
+ },
+
+
+ init : function init() {
+ this.updatePrefs();
+
+ this.dbStmts = {};
+
+ // Add observer
+ Services.obs.addObserver(this, "profile-before-change", true);
+ },
+
+ /* ---- nsIFormHistory2 interfaces ---- */
+
+
+ get hasEntries() {
+ return (this.countAllEntries() > 0);
+ },
+
+
+ addEntry : function addEntry(name, value) {
+ if (!this.enabled)
+ return;
+
+ this.log("addEntry for " + name + "=" + value);
+
+ let now = Date.now() * 1000; // microseconds
+
+ let [id, guid] = this.getExistingEntryID(name, value);
+ let stmt;
+
+ if (id != -1) {
+ // Update existing entry.
+ let query = "UPDATE moz_formhistory SET timesUsed = timesUsed + 1, lastUsed = :lastUsed WHERE id = :id";
+ let params = {
+ lastUsed : now,
+ id : id
+ };
+
+ try {
+ stmt = this.dbCreateStatement(query, params);
+ stmt.execute();
+ this.sendStringNotification("modifyEntry", name, value, guid);
+ } catch (e) {
+ this.log("addEntry (modify) failed: " + e);
+ throw e;
+ } finally {
+ if (stmt) {
+ stmt.reset();
+ }
+ }
+
+ } else {
+ // Add new entry.
+ guid = this.generateGUID();
+
+ let query = "INSERT INTO moz_formhistory (fieldname, value, timesUsed, firstUsed, lastUsed, guid) " +
+ "VALUES (:fieldname, :value, :timesUsed, :firstUsed, :lastUsed, :guid)";
+ let params = {
+ fieldname : name,
+ value : value,
+ timesUsed : 1,
+ firstUsed : now,
+ lastUsed : now,
+ guid : guid
+ };
+
+ try {
+ stmt = this.dbCreateStatement(query, params);
+ stmt.execute();
+ this.sendStringNotification("addEntry", name, value, guid);
+ } catch (e) {
+ this.log("addEntry (create) failed: " + e);
+ throw e;
+ } finally {
+ if (stmt) {
+ stmt.reset();
+ }
+ }
+ }
+ },
+
+
+ removeEntry : function removeEntry(name, value) {
+ this.log("removeEntry for " + name + "=" + value);
+
+ let [id, guid] = this.getExistingEntryID(name, value);
+ this.sendStringNotification("before-removeEntry", name, value, guid);
+
+ let stmt;
+ let query = "DELETE FROM moz_formhistory WHERE id = :id";
+ let params = { id : id };
+ let existingTransactionInProgress;
+
+ try {
+ // Don't start a transaction if one is already in progress since we can't nest them.
+ existingTransactionInProgress = this.dbConnection.transactionInProgress;
+ if (!existingTransactionInProgress)
+ this.dbConnection.beginTransaction();
+ this.moveToDeletedTable("VALUES (:guid, :timeDeleted)", {
+ guid: guid,
+ timeDeleted: Date.now()
+ });
+
+ // remove from the formhistory database
+ stmt = this.dbCreateStatement(query, params);
+ stmt.execute();
+ this.sendStringNotification("removeEntry", name, value, guid);
+ } catch (e) {
+ if (!existingTransactionInProgress)
+ this.dbConnection.rollbackTransaction();
+ this.log("removeEntry failed: " + e);
+ throw e;
+ } finally {
+ if (stmt) {
+ stmt.reset();
+ }
+ }
+ if (!existingTransactionInProgress)
+ this.dbConnection.commitTransaction();
+ },
+
+
+ removeEntriesForName : function removeEntriesForName(name) {
+ this.log("removeEntriesForName with name=" + name);
+
+ this.sendStringNotification("before-removeEntriesForName", name);
+
+ let stmt;
+ let query = "DELETE FROM moz_formhistory WHERE fieldname = :fieldname";
+ let params = { fieldname : name };
+ let existingTransactionInProgress;
+
+ try {
+ // Don't start a transaction if one is already in progress since we can't nest them.
+ existingTransactionInProgress = this.dbConnection.transactionInProgress;
+ if (!existingTransactionInProgress)
+ this.dbConnection.beginTransaction();
+ this.moveToDeletedTable(
+ "SELECT guid, :timeDeleted FROM moz_formhistory " +
+ "WHERE fieldname = :fieldname", {
+ fieldname: name,
+ timeDeleted: Date.now()
+ });
+
+ stmt = this.dbCreateStatement(query, params);
+ stmt.execute();
+ this.sendStringNotification("removeEntriesForName", name);
+ } catch (e) {
+ if (!existingTransactionInProgress)
+ this.dbConnection.rollbackTransaction();
+ this.log("removeEntriesForName failed: " + e);
+ throw e;
+ } finally {
+ if (stmt) {
+ stmt.reset();
+ }
+ }
+ if (!existingTransactionInProgress)
+ this.dbConnection.commitTransaction();
+ },
+
+
+ removeAllEntries : function removeAllEntries() {
+ this.log("removeAllEntries");
+
+ this.sendNotification("before-removeAllEntries", null);
+
+ let stmt;
+ let query = "DELETE FROM moz_formhistory";
+ let existingTransactionInProgress;
+
+ try {
+ // Don't start a transaction if one is already in progress since we can't nest them.
+ existingTransactionInProgress = this.dbConnection.transactionInProgress;
+ if (!existingTransactionInProgress)
+ this.dbConnection.beginTransaction();
+ // TODO: Add these items to the deleted items table once we've sorted
+ // out the issues from bug 756701
+ stmt = this.dbCreateStatement(query);
+ stmt.execute();
+ this.sendNotification("removeAllEntries", null);
+ } catch (e) {
+ if (!existingTransactionInProgress)
+ this.dbConnection.rollbackTransaction();
+ this.log("removeAllEntries failed: " + e);
+ throw e;
+ } finally {
+ if (stmt) {
+ stmt.reset();
+ }
+ }
+ if (!existingTransactionInProgress)
+ this.dbConnection.commitTransaction();
+ },
+
+
+ nameExists : function nameExists(name) {
+ this.log("nameExists for name=" + name);
+ let stmt;
+ let query = "SELECT COUNT(1) AS numEntries FROM moz_formhistory WHERE fieldname = :fieldname";
+ let params = { fieldname : name };
+ try {
+ stmt = this.dbCreateStatement(query, params);
+ stmt.executeStep();
+ return (stmt.row.numEntries > 0);
+ } catch (e) {
+ this.log("nameExists failed: " + e);
+ throw e;
+ } finally {
+ if (stmt) {
+ stmt.reset();
+ }
+ }
+ },
+
+ entryExists : function entryExists(name, value) {
+ this.log("entryExists for " + name + "=" + value);
+ let [id] = this.getExistingEntryID(name, value);
+ this.log("entryExists: id=" + id);
+ return (id != -1);
+ },
+
+ removeEntriesByTimeframe : function removeEntriesByTimeframe(beginTime, endTime) {
+ this.log("removeEntriesByTimeframe for " + beginTime + " to " + endTime);
+
+ this.sendIntNotification("before-removeEntriesByTimeframe", beginTime, endTime);
+
+ let stmt;
+ let query = "DELETE FROM moz_formhistory WHERE firstUsed >= :beginTime AND firstUsed <= :endTime";
+ let params = {
+ beginTime : beginTime,
+ endTime : endTime
+ };
+ let existingTransactionInProgress;
+
+ try {
+ // Don't start a transaction if one is already in progress since we can't nest them.
+ existingTransactionInProgress = this.dbConnection.transactionInProgress;
+ if (!existingTransactionInProgress)
+ this.dbConnection.beginTransaction();
+ this.moveToDeletedTable(
+ "SELECT guid, :timeDeleted FROM moz_formhistory " +
+ "WHERE firstUsed >= :beginTime AND firstUsed <= :endTime", {
+ beginTime: beginTime,
+ endTime: endTime
+ });
+
+ stmt = this.dbCreateStatement(query, params);
+ stmt.executeStep();
+ this.sendIntNotification("removeEntriesByTimeframe", beginTime, endTime);
+ } catch (e) {
+ if (!existingTransactionInProgress)
+ this.dbConnection.rollbackTransaction();
+ this.log("removeEntriesByTimeframe failed: " + e);
+ throw e;
+ } finally {
+ if (stmt) {
+ stmt.reset();
+ }
+ }
+ if (!existingTransactionInProgress)
+ this.dbConnection.commitTransaction();
+ },
+
+ moveToDeletedTable : function moveToDeletedTable(values, params) {
+ if (AppConstants.platform == "android") {
+ this.log("Moving entries to deleted table.");
+
+ let stmt;
+
+ try {
+ // Move the entries to the deleted items table.
+ let query = "INSERT INTO moz_deleted_formhistory (guid, timeDeleted) ";
+ if (values) query += values;
+ stmt = this.dbCreateStatement(query, params);
+ stmt.execute();
+ } catch (e) {
+ this.log("Moving deleted entries failed: " + e);
+ throw e;
+ } finally {
+ if (stmt) {
+ stmt.reset();
+ }
+ }
+ }
+ },
+
+ get dbConnection() {
+ // Make sure dbConnection can't be called from now to prevent infinite loops.
+ delete FormHistory.prototype.dbConnection;
+
+ try {
+ this.dbFile = Services.dirsvc.get("ProfD", Ci.nsIFile).clone();
+ this.dbFile.append("formhistory.sqlite");
+ this.log("Opening database at " + this.dbFile.path);
+
+ FormHistory.prototype.dbConnection = this.dbOpen();
+ this.dbInit();
+ } catch (e) {
+ this.log("Initialization failed: " + e);
+ // If dbInit fails...
+ if (e.result == Cr.NS_ERROR_FILE_CORRUPTED) {
+ this.dbCleanup();
+ FormHistory.prototype.dbConnection = this.dbOpen();
+ this.dbInit();
+ } else {
+ throw "Initialization failed";
+ }
+ }
+
+ return FormHistory.prototype.dbConnection;
+ },
+
+ get DBConnection() {
+ return this.dbConnection;
+ },
+
+
+ /* ---- nsIObserver interface ---- */
+
+
+ observe : function observe(subject, topic, data) {
+ switch (topic) {
+ case "nsPref:changed":
+ this.updatePrefs();
+ break;
+ case "profile-before-change":
+ this._dbClose(false);
+ break;
+ default:
+ this.log("Oops! Unexpected notification: " + topic);
+ break;
+ }
+ },
+
+
+ /* ---- helpers ---- */
+
+
+ generateGUID : function() {
+ // string like: "{f60d9eac-9421-4abc-8491-8e8322b063d4}"
+ let uuid = this.uuidService.generateUUID().toString();
+ let raw = ""; // A string with the low bytes set to random values
+ let bytes = 0;
+ for (let i = 1; bytes < 12 ; i+= 2) {
+ // Skip dashes
+ if (uuid[i] == "-")
+ i++;
+ let hexVal = parseInt(uuid[i] + uuid[i + 1], 16);
+ raw += String.fromCharCode(hexVal);
+ bytes++;
+ }
+ return btoa(raw);
+ },
+
+
+ sendStringNotification : function (changeType, str1, str2, str3) {
+ function wrapit(str) {
+ let wrapper = Cc["@mozilla.org/supports-string;1"].
+ createInstance(Ci.nsISupportsString);
+ wrapper.data = str;
+ return wrapper;
+ }
+
+ let strData;
+ if (arguments.length == 2) {
+ // Just 1 string, no need to put it in an array
+ strData = wrapit(str1);
+ } else {
+ // 3 strings, put them in an array.
+ strData = Cc["@mozilla.org/array;1"].
+ createInstance(Ci.nsIMutableArray);
+ strData.appendElement(wrapit(str1), false);
+ strData.appendElement(wrapit(str2), false);
+ strData.appendElement(wrapit(str3), false);
+ }
+ this.sendNotification(changeType, strData);
+ },
+
+
+ sendIntNotification : function (changeType, int1, int2) {
+ function wrapit(int) {
+ let wrapper = Cc["@mozilla.org/supports-PRInt64;1"].
+ createInstance(Ci.nsISupportsPRInt64);
+ wrapper.data = int;
+ return wrapper;
+ }
+
+ let intData;
+ if (arguments.length == 2) {
+ // Just 1 int, no need for an array
+ intData = wrapit(int1);
+ } else {
+ // 2 ints, put them in an array.
+ intData = Cc["@mozilla.org/array;1"].
+ createInstance(Ci.nsIMutableArray);
+ intData.appendElement(wrapit(int1), false);
+ intData.appendElement(wrapit(int2), false);
+ }
+ this.sendNotification(changeType, intData);
+ },
+
+
+ sendNotification : function (changeType, data) {
+ Services.obs.notifyObservers(data, "satchel-storage-changed", changeType);
+ },
+
+
+ getExistingEntryID : function (name, value) {
+ let id = -1, guid = null;
+ let stmt;
+ let query = "SELECT id, guid FROM moz_formhistory WHERE fieldname = :fieldname AND value = :value";
+ let params = {
+ fieldname : name,
+ value : value
+ };
+ try {
+ stmt = this.dbCreateStatement(query, params);
+ if (stmt.executeStep()) {
+ id = stmt.row.id;
+ guid = stmt.row.guid;
+ }
+ } catch (e) {
+ this.log("getExistingEntryID failed: " + e);
+ throw e;
+ } finally {
+ if (stmt) {
+ stmt.reset();
+ }
+ }
+
+ return [id, guid];
+ },
+
+
+ countAllEntries : function () {
+ let query = "SELECT COUNT(1) AS numEntries FROM moz_formhistory";
+
+ let stmt, numEntries;
+ try {
+ stmt = this.dbCreateStatement(query, null);
+ stmt.executeStep();
+ numEntries = stmt.row.numEntries;
+ } catch (e) {
+ this.log("countAllEntries failed: " + e);
+ throw e;
+ } finally {
+ if (stmt) {
+ stmt.reset();
+ }
+ }
+
+ this.log("countAllEntries: counted entries: " + numEntries);
+ return numEntries;
+ },
+
+
+ updatePrefs : function () {
+ this.debug = Services.prefs.getBoolPref("browser.formfill.debug");
+ this.enabled = Services.prefs.getBoolPref("browser.formfill.enable");
+ },
+
+ // Database Creation & Access
+
+ /*
+ * dbCreateStatement
+ *
+ * Creates a statement, wraps it, and then does parameter replacement
+ * Will use memoization so that statements can be reused.
+ */
+ dbCreateStatement : function (query, params) {
+ let stmt = this.dbStmts[query];
+ // Memoize the statements
+ if (!stmt) {
+ this.log("Creating new statement for query: " + query);
+ stmt = this.dbConnection.createStatement(query);
+ this.dbStmts[query] = stmt;
+ }
+ // Replace parameters, must be done 1 at a time
+ if (params)
+ for (let i in params)
+ stmt.params[i] = params[i];
+ return stmt;
+ },
+
+ /*
+ * dbOpen
+ *
+ * Open a connection with the database and returns it.
+ *
+ * @returns a db connection object.
+ */
+ dbOpen : function () {
+ this.log("Open Database");
+
+ let storage = Cc["@mozilla.org/storage/service;1"].
+ getService(Ci.mozIStorageService);
+ return storage.openDatabase(this.dbFile);
+ },
+
+ /*
+ * dbInit
+ *
+ * Attempts to initialize the database. This creates the file if it doesn't
+ * exist, performs any migrations, etc.
+ */
+ dbInit : function () {
+ this.log("Initializing Database");
+
+ let version = this.dbConnection.schemaVersion;
+
+ // Note: Firefox 3 didn't set a schema value, so it started from 0.
+ // So we can't depend on a simple version == 0 check
+ if (version == 0 && !this.dbConnection.tableExists("moz_formhistory"))
+ this.dbCreate();
+ else if (version != DB_VERSION)
+ this.dbMigrate(version);
+ },
+
+
+ dbCreate: function () {
+ this.log("Creating DB -- tables");
+ for (let name in this.dbSchema.tables) {
+ let table = this.dbSchema.tables[name];
+ this.dbCreateTable(name, table);
+ }
+
+ this.log("Creating DB -- indices");
+ for (let name in this.dbSchema.indices) {
+ let index = this.dbSchema.indices[name];
+ let statement = "CREATE INDEX IF NOT EXISTS " + name + " ON " + index.table +
+ "(" + index.columns.join(", ") + ")";
+ this.dbConnection.executeSimpleSQL(statement);
+ }
+
+ this.dbConnection.schemaVersion = DB_VERSION;
+ },
+
+ dbCreateTable: function(name, table) {
+ let tSQL = Object.keys(table).map(col => [col, table[col]].join(" ")).join(", ");
+ this.log("Creating table " + name + " with " + tSQL);
+ this.dbConnection.createTable(name, tSQL);
+ },
+
+ dbMigrate : function (oldVersion) {
+ this.log("Attempting to migrate from version " + oldVersion);
+
+ if (oldVersion > DB_VERSION) {
+ this.log("Downgrading to version " + DB_VERSION);
+ // User's DB is newer. Sanity check that our expected columns are
+ // present, and if so mark the lower version and merrily continue
+ // on. If the columns are borked, something is wrong so blow away
+ // the DB and start from scratch. [Future incompatible upgrades
+ // should swtich to a different table or file.]
+
+ if (!this.dbAreExpectedColumnsPresent())
+ throw Components.Exception("DB is missing expected columns",
+ Cr.NS_ERROR_FILE_CORRUPTED);
+
+ // Change the stored version to the current version. If the user
+ // runs the newer code again, it will see the lower version number
+ // and re-upgrade (to fixup any entries the old code added).
+ this.dbConnection.schemaVersion = DB_VERSION;
+ return;
+ }
+
+ // Upgrade to newer version...
+
+ this.dbConnection.beginTransaction();
+
+ try {
+ for (let v = oldVersion + 1; v <= DB_VERSION; v++) {
+ this.log("Upgrading to version " + v + "...");
+ let migrateFunction = "dbMigrateToVersion" + v;
+ this[migrateFunction]();
+ }
+ } catch (e) {
+ this.log("Migration failed: " + e);
+ this.dbConnection.rollbackTransaction();
+ throw e;
+ }
+
+ this.dbConnection.schemaVersion = DB_VERSION;
+ this.dbConnection.commitTransaction();
+ this.log("DB migration completed.");
+ },
+
+
+ /*
+ * dbMigrateToVersion1
+ *
+ * Updates the DB schema to v1 (bug 463154).
+ * Adds firstUsed, lastUsed, timesUsed columns.
+ */
+ dbMigrateToVersion1 : function () {
+ // Check to see if the new columns already exist (could be a v1 DB that
+ // was downgraded to v0). If they exist, we don't need to add them.
+ let query;
+ ["timesUsed", "firstUsed", "lastUsed"].forEach(function(column) {
+ if (!this.dbColumnExists(column)) {
+ query = "ALTER TABLE moz_formhistory ADD COLUMN " + column + " INTEGER";
+ this.dbConnection.executeSimpleSQL(query);
+ }
+ }, this);
+
+ // Set the default values for the new columns.
+ //
+ // Note that we set the timestamps to 24 hours in the past. We want a
+ // timestamp that's recent (so that "keep form history for 90 days"
+ // doesn't expire things surprisingly soon), but not so recent that
+ // "forget the last hour of stuff" deletes all freshly migrated data.
+ let stmt;
+ query = "UPDATE moz_formhistory " +
+ "SET timesUsed = 1, firstUsed = :time, lastUsed = :time " +
+ "WHERE timesUsed isnull OR firstUsed isnull or lastUsed isnull";
+ let params = { time: (Date.now() - DAY_IN_MS) * 1000 }
+ try {
+ stmt = this.dbCreateStatement(query, params);
+ stmt.execute();
+ } catch (e) {
+ this.log("Failed setting timestamps: " + e);
+ throw e;
+ } finally {
+ if (stmt) {
+ stmt.reset();
+ }
+ }
+ },
+
+
+ /*
+ * dbMigrateToVersion2
+ *
+ * Updates the DB schema to v2 (bug 243136).
+ * Adds lastUsed index, removes moz_dummy_table
+ */
+ dbMigrateToVersion2 : function () {
+ let query = "DROP TABLE IF EXISTS moz_dummy_table";
+ this.dbConnection.executeSimpleSQL(query);
+
+ query = "CREATE INDEX IF NOT EXISTS moz_formhistory_lastused_index ON moz_formhistory (lastUsed)";
+ this.dbConnection.executeSimpleSQL(query);
+ },
+
+
+ /*
+ * dbMigrateToVersion3
+ *
+ * Updates the DB schema to v3 (bug 506402).
+ * Adds guid column and index.
+ */
+ dbMigrateToVersion3 : function () {
+ // Check to see if GUID column already exists, add if needed
+ let query;
+ if (!this.dbColumnExists("guid")) {
+ query = "ALTER TABLE moz_formhistory ADD COLUMN guid TEXT";
+ this.dbConnection.executeSimpleSQL(query);
+
+ query = "CREATE INDEX IF NOT EXISTS moz_formhistory_guid_index ON moz_formhistory (guid)";
+ this.dbConnection.executeSimpleSQL(query);
+ }
+
+ // Get a list of IDs for existing logins
+ let ids = [];
+ query = "SELECT id FROM moz_formhistory WHERE guid isnull";
+ let stmt;
+ try {
+ stmt = this.dbCreateStatement(query);
+ while (stmt.executeStep())
+ ids.push(stmt.row.id);
+ } catch (e) {
+ this.log("Failed getting IDs: " + e);
+ throw e;
+ } finally {
+ if (stmt) {
+ stmt.reset();
+ }
+ }
+
+ // Generate a GUID for each login and update the DB.
+ query = "UPDATE moz_formhistory SET guid = :guid WHERE id = :id";
+ for (let id of ids) {
+ let params = {
+ id : id,
+ guid : this.generateGUID()
+ };
+
+ try {
+ stmt = this.dbCreateStatement(query, params);
+ stmt.execute();
+ } catch (e) {
+ this.log("Failed setting GUID: " + e);
+ throw e;
+ } finally {
+ if (stmt) {
+ stmt.reset();
+ }
+ }
+ }
+ },
+
+ dbMigrateToVersion4 : function () {
+ if (!this.dbConnection.tableExists("moz_deleted_formhistory")) {
+ this.dbCreateTable("moz_deleted_formhistory", this.dbSchema.tables.moz_deleted_formhistory);
+ }
+ },
+
+ /*
+ * dbAreExpectedColumnsPresent
+ *
+ * Sanity check to ensure that the columns this version of the code expects
+ * are present in the DB we're using.
+ */
+ dbAreExpectedColumnsPresent : function () {
+ for (let name in this.dbSchema.tables) {
+ let table = this.dbSchema.tables[name];
+ let query = "SELECT " +
+ Object.keys(table).join(", ") +
+ " FROM " + name;
+ try {
+ let stmt = this.dbConnection.createStatement(query);
+ // (no need to execute statement, if it compiled we're good)
+ stmt.finalize();
+ } catch (e) {
+ return false;
+ }
+ }
+
+ this.log("verified that expected columns are present in DB.");
+ return true;
+ },
+
+
+ /*
+ * dbColumnExists
+ *
+ * Checks to see if the named column already exists.
+ */
+ dbColumnExists : function (columnName) {
+ let query = "SELECT " + columnName + " FROM moz_formhistory";
+ try {
+ let stmt = this.dbConnection.createStatement(query);
+ // (no need to execute statement, if it compiled we're good)
+ stmt.finalize();
+ return true;
+ } catch (e) {
+ return false;
+ }
+ },
+
+ /**
+ * _dbClose
+ *
+ * Finalize all statements and close the connection.
+ *
+ * @param aBlocking - Should we spin the loop waiting for the db to be
+ * closed.
+ */
+ _dbClose : function FH__dbClose(aBlocking) {
+ for (let query in this.dbStmts) {
+ let stmt = this.dbStmts[query];
+ stmt.finalize();
+ }
+ this.dbStmts = {};
+
+ let connectionDescriptor = Object.getOwnPropertyDescriptor(FormHistory.prototype, "dbConnection");
+ // Return if the database hasn't been opened.
+ if (!connectionDescriptor || connectionDescriptor.value === undefined)
+ return;
+
+ let completed = false;
+ try {
+ this.dbConnection.asyncClose(function () { completed = true; });
+ } catch (e) {
+ completed = true;
+ Components.utils.reportError(e);
+ }
+
+ let thread = Services.tm.currentThread;
+ while (aBlocking && !completed) {
+ thread.processNextEvent(true);
+ }
+ },
+
+ /*
+ * dbCleanup
+ *
+ * Called when database creation fails. Finalizes database statements,
+ * closes the database connection, deletes the database file.
+ */
+ dbCleanup : function () {
+ this.log("Cleaning up DB file - close & remove & backup")
+
+ // Create backup file
+ let storage = Cc["@mozilla.org/storage/service;1"].
+ getService(Ci.mozIStorageService);
+ let backupFile = this.dbFile.leafName + ".corrupt";
+ storage.backupDatabaseFile(this.dbFile, backupFile);
+
+ this._dbClose(true);
+ this.dbFile.remove(false);
+ }
+};
+
+var component = [FormHistory];
+this.NSGetFactory = XPCOMUtils.generateNSGetFactory(component);
diff --git a/toolkit/components/satchel/nsIFormAutoComplete.idl b/toolkit/components/satchel/nsIFormAutoComplete.idl
new file mode 100644
index 000000000..6ce8563be
--- /dev/null
+++ b/toolkit/components/satchel/nsIFormAutoComplete.idl
@@ -0,0 +1,47 @@
+/* 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/. */
+
+
+#include "nsISupports.idl"
+
+interface nsIAutoCompleteResult;
+interface nsIFormAutoCompleteObserver;
+interface nsIDOMHTMLInputElement;
+
+[scriptable, uuid(bfd9b82b-0ab3-4b6b-9e54-aa961ff4b732)]
+interface nsIFormAutoComplete: nsISupports {
+ /**
+ * Generate results for a form input autocomplete menu asynchronously.
+ */
+ void autoCompleteSearchAsync(in AString aInputName,
+ in AString aSearchString,
+ in nsIDOMHTMLInputElement aField,
+ in nsIAutoCompleteResult aPreviousResult,
+ in nsIAutoCompleteResult aDatalistResult,
+ in nsIFormAutoCompleteObserver aListener);
+
+ /**
+ * If a search is in progress, stop it. Otherwise, do nothing. This is used
+ * to cancel an existing search, for example, in preparation for a new search.
+ */
+ void stopAutoCompleteSearch();
+
+ /**
+ * Since the controller is disconnecting, any related data must be cleared.
+ */
+ void stopControllingInput(in nsIDOMHTMLInputElement aField);
+};
+
+[scriptable, function, uuid(604419ab-55a0-4831-9eca-1b9e67cc4751)]
+interface nsIFormAutoCompleteObserver : nsISupports
+{
+ /*
+ * Called when a search is complete and the results are ready even if the
+ * result set is empty. If the search is cancelled or a new search is
+ * started, this is not called.
+ *
+ * @param result - The search result object
+ */
+ void onSearchCompletion(in nsIAutoCompleteResult result);
+};
diff --git a/toolkit/components/satchel/nsIFormFillController.idl b/toolkit/components/satchel/nsIFormFillController.idl
new file mode 100644
index 000000000..34104c91f
--- /dev/null
+++ b/toolkit/components/satchel/nsIFormFillController.idl
@@ -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/. */
+
+#include "nsISupports.idl"
+
+interface nsIDocShell;
+interface nsIAutoCompletePopup;
+interface nsIDOMHTMLInputElement;
+
+/*
+ * nsIFormFillController is an interface for controlling form fill behavior
+ * on HTML documents. Any number of docShells can be controller concurrently.
+ * While a docShell is attached, all HTML documents that are loaded within it
+ * will have a focus listener attached that will listen for when a text input
+ * is focused. When this happens, the input will be bound to the
+ * global nsIAutoCompleteController service.
+ */
+
+[scriptable, uuid(07f0a0dc-f6e9-4cdd-a55f-56d770523a4c)]
+interface nsIFormFillController : nsISupports
+{
+ /*
+ * The input element the form fill controller is currently bound to.
+ */
+ readonly attribute nsIDOMHTMLInputElement focusedInput;
+
+ /*
+ * Start controlling form fill behavior for the given browser
+ *
+ * @param docShell - The docShell to attach to
+ * @param popup - The popup to show when autocomplete results are available
+ */
+ void attachToBrowser(in nsIDocShell docShell, in nsIAutoCompletePopup popup);
+
+ /*
+ * Stop controlling form fill behavior for the given browser
+ *
+ * @param docShell - The docShell to detach from
+ */
+ void detachFromBrowser(in nsIDocShell docShell);
+
+ /*
+ * Mark the specified <input> element as being managed by password manager.
+ * Autocomplete requests will be handed off to the password manager, and will
+ * not be stored in form history.
+ *
+ * @param aInput - The HTML <input> element to tag
+ */
+ void markAsLoginManagerField(in nsIDOMHTMLInputElement aInput);
+
+ /*
+ * Open the autocomplete popup, if possible.
+ */
+ void showPopup();
+};
diff --git a/toolkit/components/satchel/nsIFormHistory.idl b/toolkit/components/satchel/nsIFormHistory.idl
new file mode 100644
index 000000000..ac78451e9
--- /dev/null
+++ b/toolkit/components/satchel/nsIFormHistory.idl
@@ -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/. */
+
+#include "nsISupports.idl"
+interface nsIFile;
+interface mozIStorageConnection;
+
+/**
+ * The nsIFormHistory object is a service which holds a set of name/value
+ * pairs. The names correspond to form field names, and the values correspond
+ * to values the user has submitted. So, several values may exist for a single
+ * name.
+ *
+ * Note: this interface provides no means to access stored values.
+ * Stored values are used by the FormFillController to generate
+ * autocomplete matches.
+ *
+ * @deprecated use FormHistory.jsm instead.
+ */
+
+[scriptable, uuid(5d7d84d1-9798-4016-bf61-a32acf09b29d)]
+interface nsIFormHistory2 : nsISupports
+{
+ /**
+ * Returns true if the form history has any entries.
+ */
+ readonly attribute boolean hasEntries;
+
+ /**
+ * Adds a name and value pair to the form history.
+ */
+ void addEntry(in AString name, in AString value);
+
+ /**
+ * Removes a name and value pair from the form history.
+ */
+ void removeEntry(in AString name, in AString value);
+
+ /**
+ * Removes all entries that are paired with a name.
+ */
+ void removeEntriesForName(in AString name);
+
+ /**
+ * Removes all entries in the entire form history.
+ */
+ void removeAllEntries();
+
+ /**
+ * Returns true if there is no entry that is paired with a name.
+ */
+ boolean nameExists(in AString name);
+
+ /**
+ * Gets whether a name and value pair exists in the form history.
+ */
+ boolean entryExists(in AString name, in AString value);
+
+ /**
+ * Removes entries that were created between the specified times.
+ *
+ * @param aBeginTime
+ * The beginning of the timeframe, in microseconds
+ * @param aEndTime
+ * The end of the timeframe, in microseconds
+ */
+ void removeEntriesByTimeframe(in long long aBeginTime, in long long aEndTime);
+
+ /**
+ * Returns the underlying DB connection the form history module is using.
+ */
+ readonly attribute mozIStorageConnection DBConnection;
+};
diff --git a/toolkit/components/satchel/nsIInputListAutoComplete.idl b/toolkit/components/satchel/nsIInputListAutoComplete.idl
new file mode 100644
index 000000000..6f0f492b0
--- /dev/null
+++ b/toolkit/components/satchel/nsIInputListAutoComplete.idl
@@ -0,0 +1,17 @@
+/* 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/. */
+
+#include "nsISupports.idl"
+
+interface nsIAutoCompleteResult;
+interface nsIDOMHTMLInputElement;
+
+[scriptable, uuid(0e33de3e-4faf-4a1a-b96e-24115b8bfd45)]
+interface nsIInputListAutoComplete: nsISupports {
+ /**
+ * Generate results for a form input autocomplete menu.
+ */
+ nsIAutoCompleteResult autoCompleteSearch(in AString aSearchString,
+ in nsIDOMHTMLInputElement aField);
+};
diff --git a/toolkit/components/satchel/nsInputListAutoComplete.js b/toolkit/components/satchel/nsInputListAutoComplete.js
new file mode 100644
index 000000000..f42427862
--- /dev/null
+++ b/toolkit/components/satchel/nsInputListAutoComplete.js
@@ -0,0 +1,64 @@
+/* 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 Ci = Components.interfaces;
+
+Components.utils.import("resource://gre/modules/XPCOMUtils.jsm");
+Components.utils.import("resource://gre/modules/Services.jsm");
+Components.utils.import("resource://gre/modules/nsFormAutoCompleteResult.jsm");
+
+function InputListAutoComplete() {}
+
+InputListAutoComplete.prototype = {
+ classID : Components.ID("{bf1e01d0-953e-11df-981c-0800200c9a66}"),
+ QueryInterface: XPCOMUtils.generateQI([Ci.nsIInputListAutoComplete]),
+
+ autoCompleteSearch : function (aUntrimmedSearchString, aField) {
+ let [values, labels] = this.getListSuggestions(aField);
+ let searchResult = values.length > 0 ? Ci.nsIAutoCompleteResult.RESULT_SUCCESS
+ : Ci.nsIAutoCompleteResult.RESULT_NOMATCH;
+ let defaultIndex = values.length > 0 ? 0 : -1;
+ return new FormAutoCompleteResult(aUntrimmedSearchString,
+ searchResult, defaultIndex, "",
+ values, labels, [], null);
+ },
+
+ getListSuggestions : function (aField) {
+ let values = [];
+ let labels = [];
+
+ if (aField) {
+ let filter = !aField.hasAttribute("mozNoFilter");
+ let lowerFieldValue = aField.value.toLowerCase();
+
+ if (aField.list) {
+ let options = aField.list.options;
+ let length = options.length;
+ for (let i = 0; i < length; i++) {
+ let item = options.item(i);
+ let label = "";
+ if (item.label) {
+ label = item.label;
+ } else if (item.text) {
+ label = item.text;
+ } else {
+ label = item.value;
+ }
+
+ if (filter && label.toLowerCase().indexOf(lowerFieldValue) == -1) {
+ continue;
+ }
+
+ labels.push(label);
+ values.push(item.value);
+ }
+ }
+ }
+
+ return [values, labels];
+ }
+};
+
+var component = [InputListAutoComplete];
+this.NSGetFactory = XPCOMUtils.generateNSGetFactory(component);
diff --git a/toolkit/components/satchel/satchel.manifest b/toolkit/components/satchel/satchel.manifest
new file mode 100644
index 000000000..5afc0a38c
--- /dev/null
+++ b/toolkit/components/satchel/satchel.manifest
@@ -0,0 +1,10 @@
+component {0c1bb408-71a2-403f-854a-3a0659829ded} nsFormHistory.js
+contract @mozilla.org/satchel/form-history;1 {0c1bb408-71a2-403f-854a-3a0659829ded}
+component {c11c21b2-71c9-4f87-a0f8-5e13f50495fd} nsFormAutoComplete.js
+contract @mozilla.org/satchel/form-autocomplete;1 {c11c21b2-71c9-4f87-a0f8-5e13f50495fd}
+component {bf1e01d0-953e-11df-981c-0800200c9a66} nsInputListAutoComplete.js
+contract @mozilla.org/satchel/inputlist-autocomplete;1 {bf1e01d0-953e-11df-981c-0800200c9a66}
+component {3a0012eb-007f-4bb8-aa81-a07385f77a25} FormHistoryStartup.js
+contract @mozilla.org/satchel/form-history-startup;1 {3a0012eb-007f-4bb8-aa81-a07385f77a25}
+category profile-after-change formHistoryStartup @mozilla.org/satchel/form-history-startup;1
+category idle-daily formHistoryStartup @mozilla.org/satchel/form-history-startup;1
diff --git a/toolkit/components/satchel/test/.eslintrc.js b/toolkit/components/satchel/test/.eslintrc.js
new file mode 100644
index 000000000..3c788d6d6
--- /dev/null
+++ b/toolkit/components/satchel/test/.eslintrc.js
@@ -0,0 +1,7 @@
+"use strict";
+
+module.exports = {
+ "extends": [
+ "../../../../testing/mochitest/mochitest.eslintrc.js"
+ ]
+};
diff --git a/toolkit/components/satchel/test/browser/.eslintrc.js b/toolkit/components/satchel/test/browser/.eslintrc.js
new file mode 100644
index 000000000..7c8021192
--- /dev/null
+++ b/toolkit/components/satchel/test/browser/.eslintrc.js
@@ -0,0 +1,7 @@
+"use strict";
+
+module.exports = {
+ "extends": [
+ "../../../../../testing/mochitest/browser.eslintrc.js"
+ ]
+};
diff --git a/toolkit/components/satchel/test/browser/browser.ini b/toolkit/components/satchel/test/browser/browser.ini
new file mode 100644
index 000000000..6a3fc452e
--- /dev/null
+++ b/toolkit/components/satchel/test/browser/browser.ini
@@ -0,0 +1,5 @@
+[DEFAULT]
+support-files =
+ !/toolkit/components/satchel/test/subtst_privbrowsing.html
+
+[browser_privbrowsing_perwindowpb.js]
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 000000000..982480648
--- /dev/null
+++ b/toolkit/components/satchel/test/browser/browser_privbrowsing_perwindowpb.js
@@ -0,0 +1,63 @@
+/* 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 = (Components.utils.import("resource://gre/modules/FormHistory.jsm", {})).FormHistory;
+
+/** Test for Bug 472396 **/
+add_task(function* test() {
+ // initialization
+ let windowsToClose = [];
+ let testURI =
+ "http://example.com/tests/toolkit/components/satchel/test/subtst_privbrowsing.html";
+
+ function* doTest(aShouldValueExist, aWindow) {
+ let browser = aWindow.gBrowser.selectedBrowser;
+ BrowserTestUtils.loadURI(browser, testURI);
+ yield BrowserTestUtils.browserLoaded(browser);
+
+ // Wait for the page to reload itself.
+ yield 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) {
+ do_throw("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();
+ }
+ });
+ yield doneCounting.promise;
+ }
+
+ function testOnWindow(aOptions, aCallback) {
+ return BrowserTestUtils.openNewBrowserWindow(aOptions)
+ .then(win => { windowsToClose.push(win); return win; });
+ }
+
+
+ yield testOnWindow({private: true}).then((aWin) => {
+ return Task.spawn(doTest(false, aWin));
+ });
+
+ // Test when not on private mode after visiting a site on private
+ // mode. The form history should not exist.
+ yield testOnWindow({}).then((aWin) => {
+ return Task.spawn(doTest(true, aWin));
+ });
+
+ yield 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 000000000..5a65baeb6
--- /dev/null
+++ b/toolkit/components/satchel/test/mochitest.ini
@@ -0,0 +1,19 @@
+[DEFAULT]
+skip-if = toolkit == 'android' || os == 'linux' # linux - bug 1022386
+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_form_autocomplete.html]
+[test_form_autocomplete_with_list.html]
+[test_form_submission.html]
+[test_form_submission_cap.html]
+[test_form_submission_cap2.html]
+[test_password_autocomplete.html]
+[test_popup_direction.html]
+[test_popup_enter_event.html]
diff --git a/toolkit/components/satchel/test/parent_utils.js b/toolkit/components/satchel/test/parent_utils.js
new file mode 100644
index 000000000..87738bdb5
--- /dev/null
+++ b/toolkit/components/satchel/test/parent_utils.js
@@ -0,0 +1,149 @@
+const { classes: Cc, interfaces: Ci, utils: Cu } = Components;
+
+Cu.import("resource://gre/modules/FormHistory.jsm");
+Cu.import("resource://gre/modules/Services.jsm");
+Cu.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() {
+ FormHistory.update({ op: "remove" });
+ },
+
+ updateFormHistory(changes) {
+ let handler = {
+ handleError: function (error) {
+ assert.ok(false, error);
+ sendAsyncMessage("formHistoryUpdated", { ok: false });
+ },
+ handleCompletion: function (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");
+ });
+ },
+
+ 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();
+ }
+};
+
+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("getPopupState", () => {
+ ParentUtils.getPopupState();
+});
+
+addMessageListener("addObserver", () => {
+ Services.obs.addObserver(ParentUtils, "satchel-storage-changed", false);
+});
+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 000000000..c047f40af
--- /dev/null
+++ b/toolkit/components/satchel/test/satchel_common.js
@@ -0,0 +1,274 @@
+/* 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 gPopupShownExpected = false;
+var gPopupShownListener;
+var gLastAutoCompleteResults;
+var gChromeScript;
+
+/*
+ * Returns the element with the specified |name| attribute.
+ */
+function $_(formNum, name) {
+ var form = document.getElementById("form" + formNum);
+ if (!form) {
+ ok(false, "$_ couldn't find requested form " + formNum);
+ return null;
+ }
+
+ var 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;
+}
+
+// Mochitest gives us a sendKey(), but it's targeted to a specific element.
+// This basically sends an untargeted key event, to whatever's focused.
+function doKey(aKey, modifier) {
+ var keyName = "DOM_VK_" + aKey.toUpperCase();
+ var key = SpecialPowers.Ci.nsIDOMKeyEvent[keyName];
+
+ // undefined --> null
+ if (!modifier)
+ modifier = null;
+
+ // Window utils for sending fake key events.
+ var wutils = SpecialPowers.getDOMWindowUtils(window);
+
+ if (wutils.sendKeyEvent("keydown", key, 0, modifier)) {
+ wutils.sendKeyEvent("keypress", key, 0, modifier);
+ }
+ wutils.sendKeyEvent("keyup", key, 0, modifier);
+}
+
+function registerPopupShownListener(listener) {
+ if (gPopupShownListener) {
+ ok(false, "got too many popupshownlisteners");
+ return;
+ }
+ gPopupShownListener = listener;
+}
+
+function getMenuEntries() {
+ if (!gLastAutoCompleteResults) {
+ throw new Error("no autocomplete results");
+ }
+
+ var results = gLastAutoCompleteResults;
+ gLastAutoCompleteResults = null;
+ return results;
+}
+
+function checkArrayValues(actualValues, expectedValues, msg) {
+ is(actualValues.length, expectedValues.length, "Checking array values: " + msg);
+ for (var 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: function(callback) {
+ if (this.verifyStack.length == 0)
+ callback();
+ else
+ this.callback = callback;
+ },
+
+ observe: function({ subject, topic, data }) {
+ if (data != "formhistory-add" && data != "formhistory-update")
+ return;
+ ok(this.verifyStack.length > 0, "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
+ //
+ var expected = this.verifyStack.shift();
+
+ countEntries(expected.name, expected.value,
+ function(num) {
+ ok(num > 0, expected.message);
+ if (checkObserver.verifyStack.length == 0) {
+ var callback = checkObserver.callback;
+ checkObserver.callback = null;
+ callback();
+ }
+ });
+ }
+};
+
+function checkForSave(name, value, message) {
+ checkObserver.verifyStack.push({ name : name, value: value, message: message });
+}
+
+function getFormSubmitButton(formNum) {
+ var 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.
+ var 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 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");
+ }
+ };
+}
+
+function* promiseNoUnexpectedPopupShown() {
+ gPopupShownExpected = false;
+ listenForUnexpectedPopupShown();
+ SimpleTest.requestFlakyTimeout("Giving a chance for an unexpected popupshown to occur");
+ yield new Promise(resolve => setTimeout(resolve, 1000));
+}
+
+/**
+ * Resolve at the next popupshown event for the autocomplete popup
+ * @return {Promise} with the results
+ */
+function promiseACShown() {
+ gPopupShownExpected = true;
+ return new Promise(resolve => {
+ gPopupShownListener = ({ results }) => {
+ gPopupShownExpected = false;
+ resolve(results);
+ };
+ });
+}
+
+function satchelCommonSetup() {
+ var 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");
+ gChromeScript.destroy();
+ });
+}
+
+
+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 000000000..f7441668a
--- /dev/null
+++ b/toolkit/components/satchel/test/subtst_form_submission_1.html
@@ -0,0 +1,38 @@
+<!DOCTYPE HTML>
+<html>
+
+<head>
+</head>
+
+<body>
+
+<form id="subform1" onsubmit="return checkSubmit(21)">
+ <input id="subtest1" type="text" name="subtest1">
+ <button type="submit">Submit</button>
+</form>
+
+<form id="subform2" onsubmit="return checkSubmit(100)">
+ <input id="subtest2" type="text" name="subtest2">
+ <button type="submit">Submit</button>
+</form>
+
+<script>
+ function checkSubmit(num) {
+ netscape.security.PrivilegeManager.enablePrivilege('UniversalXPConnect');
+ return parent.checkSubmit(num);
+ }
+
+ function clickButton(num) {
+ if (num == 21)
+ document.querySelectorAll("button")[0].click();
+ else if (num == 100)
+ document.querySelectorAll("button")[1].click();
+ }
+
+ // set the input's value (can't use a default value, as satchel will ignore it)
+ document.getElementById("subtest1").value = "subtestValue";
+ 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 000000000..b53e0b229
--- /dev/null
+++ b/toolkit/components/satchel/test/subtst_privbrowsing.html
@@ -0,0 +1,22 @@
+<html>
+<head>
+ <meta charset=UTF-8>
+ <title>Subtest for bug 472396</title>
+ <script>
+ function submitForm() {
+ if (location.search.indexOf("field") == -1) {
+ var form = document.getElementById("form");
+ var 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 000000000..66972d9b3
--- /dev/null
+++ b/toolkit/components/satchel/test/test_bug_511615.html
@@ -0,0 +1,194 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>Test for Form History Autocomplete Untrusted Events: Bug 511615</title>
+ <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script type="text/javascript" src="/tests/SimpleTest/SpawnTask.js"></script>
+ <script type="text/javascript" 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.
+ */
+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.
+ */
+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.");
+ });
+}
+
+function doKeyUnprivileged(key) {
+ let keyName = "DOM_VK_" + key.toUpperCase();
+ let keycode, charcode;
+
+ if (key.length == 1) {
+ keycode = 0;
+ charcode = key.charCodeAt(0);
+ alwaysval = charcode;
+ } else {
+ keycode = KeyEvent[keyName];
+ if (!keycode)
+ throw "invalid keyname in test";
+ charcode = 0;
+ alwaysval = keycode;
+ }
+
+ let dnEvent = document.createEvent('KeyboardEvent');
+ let prEvent = document.createEvent('KeyboardEvent');
+ let upEvent = document.createEvent('KeyboardEvent');
+
+ 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);
+
+ 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');
+
+ 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);
+
+ input.dispatchEvent(dnEvent);
+ input.dispatchEvent(upEvent);
+ input.dispatchEvent(ckEvent);
+}
+
+let input = $_(1, "field1");
+
+add_task(function* test_initialize() {
+ yield 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(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.
+ yield expectPopupDoesNotOpen(triggerFn);
+ }
+
+ // A privileged key press will actually open the popup.
+ let popupShown = waitForNextPopup();
+ doKey("down");
+ yield popupShown;
+
+ // The selected autocomplete item should not change from untrusted events.
+ for (let triggerFn of [
+ () => doKeyUnprivileged("down"),
+ () => doKeyUnprivileged("page_down"),
+ ]) {
+ triggerFn();
+ yield checkSelectedIndexAfterResponseTime(-1);
+ }
+
+ // A privileged key press will actually change the selected index.
+ let indexChanged = new Promise(resolve => notifySelectedIndex(0, resolve));
+ doKey("down");
+ yield 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();
+ yield 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 000000000..6ca5136cd
--- /dev/null
+++ b/toolkit/components/satchel/test/test_bug_787624.html
@@ -0,0 +1,88 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>Test for Layout of Form History Autocomplete: Bug 787624</title>
+ <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script type="text/javascript" src="/tests/SimpleTest/SpawnTask.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(function* test_popup_not_move_input() {
+ var input = $_(1, "field1");
+ var rect = input.getBoundingClientRect();
+
+ yield new Promise(resolve => updateFormHistory([
+ { op : "remove" },
+ { op : "add", fieldname : "field1", value : "value1" },
+ { op : "add", fieldname : "field1", value : "value2" },
+ ], resolve));
+
+ let popupShown = waitForNextPopup();
+ input.focus();
+ doKey("down");
+ yield popupShown;
+
+ var 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_with_caching.html b/toolkit/components/satchel/test/test_datalist_with_caching.html
new file mode 100644
index 000000000..8445cb159
--- /dev/null
+++ b/toolkit/components/satchel/test/test_datalist_with_caching.html
@@ -0,0 +1,139 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>Test for Form History Autocomplete</title>
+ <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script type="text/javascript" src="/tests/SimpleTest/EventUtils.js"></script>
+ <script type="text/javascript" src="/tests/SimpleTest/SpawnTask.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">
+
+var input = $_(1, "field1");
+
+function setupFormHistory(aCallback) {
+ updateFormHistory([
+ { op : "remove" },
+ { op : "add", fieldname : "field1", value : "Sec" },
+ ], () => {
+ spawn_task(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) {
+ var 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) {
+ var actualValues = getMenuEntries();
+ is(actualValues.length, expectedValues.length, testNum + " Checking length of expected menu");
+ for (var i = 0; i < expectedValues.length; i++)
+ is(actualValues[i], expectedValues[i], testNum + " Checking menu entry #"+i);
+}
+
+function* runTests() {
+ testNum++;
+ restoreForm();
+ doKey("down");
+ yield expectPopup();
+
+ checkMenuEntries(["Sec", "First", "Second", "Secomundo"]);
+ doKey("down");
+ doKey("return");
+ checkForm("Sec");
+
+ testNum++;
+ restoreForm();
+ sendString("Sec");
+ doKey("down");
+ yield expectPopup();
+
+ testNum++;
+ checkMenuEntries(["Sec", "Second", "Secomundo"]);
+ sendString("o");
+ yield waitForMenuChange(2);
+
+ testNum++;
+ checkMenuEntries(["Second", "Secomundo"]);
+ doKey("down");
+ doKey("return");
+ 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 000000000..4cf09117a
--- /dev/null
+++ b/toolkit/components/satchel/test/test_form_autocomplete.html
@@ -0,0 +1,1076 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>Test for Form History Autocomplete</title>
+ <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script type="text/javascript" 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 **/
+
+var input = $_(1, "field1");
+const shiftModifier = Event.SHIFT_MASK;
+
+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" },
+ { op : "add", fieldname : "field11", value : "2010-10-10" },
+ { op : "add", fieldname : "field12", value : "21:21" }, // not used, since type=time doesn't have autocomplete currently
+ { op : "add", fieldname : "field13", value : "32" }, // not used, since type=range doesn't have a drop down menu
+ { op : "add", fieldname : "field14", value : "#ffffff" }, // not used, since type=color doesn't have autocomplete currently
+ { 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) {
+ var 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 {
+ 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
+ */
+function runTest() {
+ testNum++;
+
+ ok(true, "Starting test #" + testNum);
+
+ switch (testNum) {
+ case 1:
+ // Make sure initial form is empty.
+ checkForm("");
+ // Trigger autocomplete popup
+ expectPopup();
+ restoreForm();
+ doKey("down");
+ break;
+
+ case 2:
+ checkMenuEntries(["value1", "value2", "value3", "value4"], testNum);
+ // Check first entry
+ doKey("down");
+ checkForm(""); // value shouldn't update
+ doKey("return"); // not "enter"!
+ checkForm("value1");
+
+ // Trigger autocomplete popup
+ expectPopup();
+ restoreForm();
+ doKey("down");
+ break;
+
+ case 3:
+ // Check second entry
+ doKey("down");
+ doKey("down");
+ doKey("return"); // not "enter"!
+ checkForm("value2");
+
+ // Trigger autocomplete popup
+ expectPopup();
+ restoreForm();
+ doKey("down");
+ break;
+
+ case 4:
+ // Check third entry
+ doKey("down");
+ doKey("down");
+ doKey("down");
+ doKey("return");
+ checkForm("value3");
+
+ // Trigger autocomplete popup
+ expectPopup();
+ restoreForm();
+ doKey("down");
+ break;
+
+ case 5:
+ // Check fourth entry
+ doKey("down");
+ doKey("down");
+ doKey("down");
+ doKey("down");
+ doKey("return");
+ checkForm("value4");
+
+ // Trigger autocomplete popup
+ expectPopup();
+ restoreForm();
+ doKey("down");
+ break;
+
+ case 6:
+ // Check first entry (wraparound)
+ doKey("down");
+ doKey("down");
+ doKey("down");
+ doKey("down");
+ doKey("down"); // deselects
+ doKey("down");
+ doKey("return");
+ checkForm("value1");
+
+ // Trigger autocomplete popup
+ expectPopup();
+ restoreForm();
+ doKey("down");
+ break;
+
+ case 7:
+ // Check the last entry via arrow-up
+ doKey("up");
+ doKey("return");
+ checkForm("value4");
+
+ // Trigger autocomplete popup
+ expectPopup();
+ restoreForm();
+ doKey("down");
+ break;
+
+ case 8:
+ // Check the last entry via arrow-up
+ doKey("down"); // select first entry
+ doKey("up"); // selects nothing!
+ doKey("up"); // select last entry
+ doKey("return");
+ checkForm("value4");
+
+ // Trigger autocomplete popup
+ expectPopup();
+ restoreForm();
+ doKey("down");
+ break;
+
+ case 9:
+ // Check the last entry via arrow-up (wraparound)
+ doKey("down");
+ doKey("up"); // deselects
+ doKey("up"); // last entry
+ doKey("up");
+ doKey("up");
+ doKey("up"); // first entry
+ doKey("up"); // deselects
+ doKey("up"); // last entry
+ doKey("return");
+ checkForm("value4");
+
+ // Trigger autocomplete popup
+ expectPopup();
+ restoreForm();
+ doKey("down");
+ break;
+
+ case 10:
+ // Set first entry w/o triggering autocomplete
+ doKey("down");
+ doKey("right");
+ checkForm("value1");
+
+ // Trigger autocomplete popup
+ expectPopup();
+ restoreForm();
+ doKey("down");
+ break;
+
+ case 11:
+ // Set first entry w/o triggering autocomplete
+ doKey("down");
+ doKey("left");
+ checkForm("value1");
+
+ // Trigger autocomplete popup
+ expectPopup();
+ restoreForm();
+ doKey("down");
+ break;
+
+ case 12:
+ // Check first entry (page up)
+ doKey("down");
+ doKey("down");
+ doKey("page_up");
+ doKey("return");
+ checkForm("value1");
+
+ // Trigger autocomplete popup
+ expectPopup();
+ restoreForm();
+ doKey("down");
+ break;
+
+ case 13:
+ // Check last entry (page down)
+ doKey("down");
+ doKey("page_down");
+ doKey("return");
+ checkForm("value4");
+
+ // Trigger autocomplete popup
+ testNum = 49;
+ expectPopup();
+ restoreForm();
+ doKey("down");
+ break;
+
+ /* Test removing entries from the dropdown */
+
+ case 50:
+ checkMenuEntries(["value1", "value2", "value3", "value4"], testNum);
+ // Delete the first entry (of 4)
+ setForm("value");
+ doKey("down");
+
+ // 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")
+ doKey("back_space", shiftModifier);
+ else
+ doKey("delete", shiftModifier);
+
+ // 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:
+ doKey("return");
+ checkForm("value2");
+
+ // Trigger autocomplete popup
+ expectPopup();
+ restoreForm();
+ doKey("down");
+ break;
+
+ case 53:
+ checkMenuEntries(["value2", "value3", "value4"], testNum);
+ // Check the new first entry (of 3)
+ doKey("down");
+ doKey("return");
+ checkForm("value2");
+
+ // Trigger autocomplete popup
+ expectPopup();
+ restoreForm();
+ doKey("down");
+ break;
+
+ case 54:
+ // Delete the second entry (of 3)
+ doKey("down");
+ doKey("down");
+ doKey("delete", shiftModifier);
+ waitForMenuChange(2);
+ break;
+
+ case 55:
+ checkForm("");
+ countEntries("field1", "value3",
+ function (num) {
+ ok(!num, testNum + " checking that f1/v3 was deleted");
+ runTest();
+ });
+ break;
+
+ case 56:
+ doKey("return");
+ checkForm("value4")
+
+ // Trigger autocomplete popup
+ expectPopup();
+ restoreForm();
+ doKey("down");
+ break;
+
+ case 57:
+ checkMenuEntries(["value2", "value4"], testNum);
+ // Check the new first entry (of 2)
+ doKey("down");
+ doKey("return");
+ checkForm("value2");
+
+ // Trigger autocomplete popup
+ expectPopup();
+ restoreForm();
+ doKey("down");
+ break;
+
+ case 58:
+ // Delete the last entry (of 2)
+ doKey("down");
+ doKey("down");
+ doKey("delete", shiftModifier);
+ checkForm("");
+ waitForMenuChange(1);
+ break;
+
+ case 59:
+ countEntries("field1", "value4",
+ function (num) {
+ ok(!num, testNum + " checking that f1/v4 was deleted");
+ runTest();
+ });
+ break;
+
+ case 60:
+ doKey("return");
+ checkForm("value2");
+
+ // Trigger autocomplete popup
+ expectPopup();
+ restoreForm();
+ doKey("down");
+ break;
+
+ case 61:
+ checkMenuEntries(["value2"], testNum);
+ // Check the new first entry (of 1)
+ doKey("down");
+ doKey("return");
+ checkForm("value2");
+
+ // Trigger autocomplete popup
+ expectPopup();
+ restoreForm();
+ doKey("down");
+ break;
+
+ case 62:
+ // Delete the only remaining entry
+ doKey("down");
+ doKey("delete", shiftModifier);
+ 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();
+ doKey("down");
+ break;
+
+ /* Test entries with autocomplete=off */
+
+ case 100:
+ // Select first entry
+ doKey("down");
+ doKey("return");
+ 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.
+ doKey("down");
+ waitForScroll();
+ break;
+
+ case 101:
+ // Ensure there's no autocomplete dropdown (autocomplete=off is present)
+ doKey("down");
+ doKey("return");
+ checkForm("");
+
+ // Look at form 4, try to trigger autocomplete popup
+ input = $_(4, "field2");
+ restoreForm();
+ doKey("down");
+ waitForMenuChange(0);
+ break;
+
+ case 102:
+ // Ensure there's no autocomplete dropdown (autocomplete=off is present)
+ doKey("down");
+ doKey("return");
+ 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);
+ doKey("back_space");
+ waitForMenuChange(3);
+ break;
+
+ case 203:
+ checkMenuEntries(["aa", "aaz", "aa\xe6"], testNum);
+ doKey("back_space");
+ 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();
+ doKey("left");
+ 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();
+ doKey("right");
+ 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);
+ doKey("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();
+ doKey("left");
+ 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();
+ doKey("down");
+ break;
+
+ case 300:
+ checkMenuEntries(["1", "12", "123", "1234"], testNum);
+ input.maxLength = 4;
+ expectPopup();
+ doKey("escape");
+ doKey("down");
+ break;
+
+ case 301:
+ checkMenuEntries(["1", "12", "123", "1234"], testNum);
+ input.maxLength = 3;
+ expectPopup();
+ doKey("escape");
+ doKey("down");
+ break;
+
+ case 302:
+ checkMenuEntries(["1", "12", "123"], testNum);
+ input.maxLength = 2;
+ expectPopup();
+ doKey("escape");
+ doKey("down");
+ break;
+
+ case 303:
+ checkMenuEntries(["1", "12"], testNum);
+ input.maxLength = 1;
+ expectPopup();
+ doKey("escape");
+ doKey("down");
+ break;
+
+ case 304:
+ checkMenuEntries(["1"], testNum);
+ input.maxLength = 0;
+ doKey("escape");
+ doKey("down");
+ waitForMenuChange(0);
+ break;
+
+ case 305:
+ checkMenuEntries([], testNum);
+ input.maxLength = 4;
+
+ // now again with a character typed
+ input.focus();
+ sendChar("1");
+ expectPopup();
+ doKey("escape");
+ doKey("down");
+ break;
+
+ case 306:
+ checkMenuEntries(["1", "12", "123", "1234"], testNum);
+ input.maxLength = 3;
+ expectPopup();
+ doKey("escape");
+ doKey("down");
+ break;
+
+ case 307:
+ checkMenuEntries(["1", "12", "123"], testNum);
+ input.maxLength = 2;
+ expectPopup();
+ doKey("escape");
+ doKey("down");
+ break;
+
+ case 308:
+ checkMenuEntries(["1", "12"], testNum);
+ input.maxLength = 1;
+ expectPopup();
+ doKey("escape");
+ doKey("down");
+ break;
+
+ case 309:
+ checkMenuEntries(["1"], testNum);
+ input.maxLength = 0;
+ doKey("escape");
+ doKey("down");
+ waitForMenuChange(0);
+ break;
+
+ case 310:
+ checkMenuEntries([], testNum);
+
+ input = $_(8, "field6");
+ testNum = 399;
+ expectPopup();
+ restoreForm();
+ doKey("down");
+ break;
+
+ case 400:
+ case 401:
+ case 402:
+ case 403:
+ checkMenuEntries(["value"], testNum);
+ doKey("down");
+ doKey("return");
+ 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();
+ doKey("down");
+ break;
+
+ case 404:
+ checkMenuEntries(["42"], testNum);
+ doKey("down");
+ doKey("return");
+ checkForm("42");
+
+ input = $_(14, "field11");
+ restoreForm();
+ expectPopup();
+ doKey("down");
+ break;
+
+ case 405:
+ checkMenuEntries(["2010-10-10"]);
+ doKey("down");
+ doKey("return");
+ checkForm("2010-10-10");
+
+ 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();
+ doKey("down");
+ waitForMenuChange(0);
+ break;
+
+ case 407:
+ checkMenuEntries([]); // type=range does not have a drop down menu
+ doKey("down");
+ doKey("return");
+ 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();
+ doKey("down");
+ break;
+
+ case 409:
+ checkMenuEntries(["2016-08"]);
+ doKey("down");
+ doKey("return");
+ checkForm("2016-08");
+
+ input = $_(19, "field16");
+ restoreForm();
+ expectPopup();
+ doKey("down");
+ break;
+
+ case 410:
+ checkMenuEntries(["2016-W32"]);
+ doKey("down");
+ doKey("return");
+ checkForm("2016-W32");
+
+ input = $_(20, "field17");
+ restoreForm();
+ expectPopup();
+ doKey("down");
+ break;
+
+ case 411:
+ checkMenuEntries(["2016-10-21T10:10"]);
+ doKey("down");
+ doKey("return");
+ checkForm("2016-10-21T10:10");
+
+ addEntry("field1", "value1");
+ break;
+
+ case 412:
+ input = $_(1, "field1");
+ // Go to test 500.
+ testNum = 499;
+
+ expectPopup();
+ restoreForm();
+ doKey("down");
+ break;
+
+ // Check that the input event is fired.
+ case 500:
+ input.addEventListener("input", function(event) {
+ input.removeEventListener("input", arguments.callee, false);
+ ok(true, testNum + " oninput should have been received");
+ ok(event.bubbles, testNum + " input event should bubble");
+ ok(event.cancelable, testNum + " input event should be cancelable");
+ }, false);
+
+ doKey("down");
+ checkForm("");
+ doKey("return");
+ checkForm("value1");
+ 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();
+ doKey("down");
+ waitForMenuChange(0);
+ break;
+
+ case 601:
+ checkMenuEntries([], testNum);
+ input.blur();
+ SimpleTest.finish();
+ return;
+
+ default:
+ ok(false, "Unexpected invocation of test #" + testNum);
+ SimpleTest.finish();
+ return;
+ }
+}
+
+function addEntry(name, value)
+{
+ updateFormHistory({ op : "add", fieldname : name, value: value }, runTest);
+}
+
+// Runs the next test when scroll event occurs
+function waitForScroll()
+{
+ addEventListener("scroll", function() {
+ if (!window.pageYOffset)
+ return;
+
+ removeEventListener("scroll", arguments.callee, false);
+ setTimeout(runTest, 100);
+ }, false);
+}
+
+function waitForMenuChange(expectedCount, expectedFirstValue)
+{
+ notifyMenuChanged(expectedCount, expectedFirstValue, runTest);
+}
+
+function checkMenuEntries(expectedValues, testNumber) {
+ var actualValues = getMenuEntries();
+ is(actualValues.length, expectedValues.length, testNumber + " Checking length of expected menu");
+ for (var i = 0; i < expectedValues.length; i++)
+ is(actualValues[i], expectedValues[i], testNumber + " Checking menu entry #"+i);
+}
+
+function startTest() {
+ setupFormHistory(function() {
+ runTest();
+ });
+}
+
+window.onload = startTest;
+
+SimpleTest.waitForExplicitFinish();
+SimpleTest.requestFlakyTimeout("untriaged");
+</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 000000000..04fb080c9
--- /dev/null
+++ b/toolkit/components/satchel/test/test_form_autocomplete_with_list.html
@@ -0,0 +1,506 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>Test for Form History Autocomplete</title>
+ <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script type="text/javascript" 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">
+
+/** Test for Form History autocomplete **/
+
+var input = $_(1, "field1");
+const shiftModifier = Components.interfaces.nsIDOMEvent.SHIFT_MASK;
+
+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) {
+ var formID = input.parentNode.id;
+ is(input.value, expectedValue, "Checking " + formID + " input");
+}
+
+var testNum = 0;
+var prevValue;
+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 {
+ 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
+*/
+function runTest() {
+ testNum++;
+
+ info("Starting test #" + testNum);
+
+ switch (testNum) {
+ case 1:
+ // Make sure initial form is empty.
+ checkForm("");
+ // Trigger autocomplete popup
+ expectPopup();
+ restoreForm();
+ doKey("down");
+ break;
+ case 2:
+ checkMenuEntries(["historyvalue", "PASS1", "PASS2", "final"], testNum);
+ // Check first entry
+ doKey("down");
+ checkForm(""); // value shouldn't update
+ doKey("return"); // not "enter"!
+ checkForm("historyvalue");
+
+ // Trigger autocomplete popup
+ expectPopup();
+ restoreForm();
+ doKey("down");
+ break;
+
+ case 3:
+ // Check second entry
+ doKey("down");
+ doKey("down");
+ doKey("return"); // not "enter"!
+ checkForm("Google");
+
+ // Trigger autocomplete popup
+ expectPopup();
+ restoreForm();
+ doKey("down");
+ break;
+
+ case 4:
+ // Check third entry
+ doKey("down");
+ doKey("down");
+ doKey("down");
+ doKey("return");
+ checkForm("Reddit");
+
+ // Trigger autocomplete popup
+ expectPopup();
+ restoreForm();
+ doKey("down");
+ break;
+
+ case 5:
+ // Check fourth entry
+ doKey("down");
+ doKey("down");
+ doKey("down");
+ doKey("down");
+ doKey("return");
+ checkForm("final");
+ expectPopup();
+ restoreForm();
+ doKey("down");
+ break;
+
+ case 6:
+ // Delete the first entry (of 3)
+ doKey("down");
+ doKey("delete", shiftModifier);
+ 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:
+ doKey("return");
+ checkForm("Google")
+
+ // Trigger autocomplete popup
+ expectPopup();
+ restoreForm();
+ doKey("down");
+ break;
+
+ case 9:
+ // Test deletion
+ checkMenuEntries(["PASS1", "PASS2", "final"], testNum);
+ // Check the new first entry (of 3)
+ doKey("down");
+ doKey("return");
+ checkForm("Google");
+
+ expectPopup();
+ restoreForm();
+ doKey("down");
+ 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:
+ doKey("down");
+ doKey("return");
+ checkForm("Google");
+
+ // Trigger autocomplete popup
+ // Look at form 3, try to trigger autocomplete popup
+ input.value = "";
+ input = $_(3, "field2");
+ testNum = 99;
+ expectPopup();
+ restoreForm();
+ doKey("down");
+ break;
+
+ case 100:
+ checkMenuEntries(["PASS1", "PASS2", "final"], testNum);
+ // Check first entry
+ doKey("down");
+ checkForm(""); // value shouldn't update
+ doKey("return"); // not "enter"!
+ checkForm("Google");
+
+ // Trigger autocomplete popup
+ expectPopup();
+ restoreForm();
+ doKey("down");
+ break;
+
+ case 101:
+ // Check second entry
+ doKey("down");
+ doKey("down");
+ doKey("return"); // not "enter"!
+ checkForm("Reddit");
+
+ // Trigger autocomplete popup
+ expectPopup();
+ restoreForm();
+ doKey("down");
+ break;
+
+ case 102:
+ // Check third entry
+ doKey("down");
+ doKey("down");
+ doKey("down");
+ doKey("return");
+ checkForm("final");
+
+ // Trigger autocomplete popup
+ expectPopup();
+ restoreForm();
+ doKey("down");
+ break;
+
+ case 103:
+ checkMenuEntries(["PASS1", "PASS2", "final"], testNum);
+ // Check first entry
+ doKey("down");
+ checkForm(""); // value shouldn't update
+ doKey("return"); // not "enter"!
+ checkForm("Google");
+
+ // Trigger autocomplete popup
+ expectPopup();
+ restoreForm();
+ doKey("down");
+ break;
+
+ case 104:
+ // Check second entry
+ doKey("down");
+ doKey("down");
+ doKey("return"); // not "enter"!
+ checkForm("Reddit");
+
+ // Trigger autocomplete popup
+ expectPopup();
+ restoreForm();
+ doKey("down");
+ break;
+
+ case 105:
+ // Check third entry
+ doKey("down");
+ doKey("down");
+ doKey("down");
+ doKey("return");
+ checkForm("final");
+
+ testNum = 199;
+ expectPopup();
+ restoreForm();
+ doKey("down");
+ 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.
+ doKey("down");
+ var datalist = document.getElementById('suggest');
+ var toRemove = datalist.children[1]
+ datalist.removeChild(toRemove);
+
+ SimpleTest.executeSoon(function() {
+ doKey("down");
+ doKey("down");
+ doKey("return");
+ checkForm("final");
+
+ // Restore the element.
+ datalist.insertBefore(toRemove, datalist.children[1]);
+ expectPopup();
+ restoreForm();
+ doKey("down");
+ });
+ 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.
+ doKey("down");
+ datalist = document.getElementById('suggest');
+ var added = new Option("Foo");
+ datalist.insertBefore(added, datalist.children[1]);
+ waitForMenuChange(4);
+ break;
+
+ case 202:
+ doKey("down");
+ doKey("down");
+ doKey("return");
+ 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:
+ doKey("down");
+ doKey("return");
+ 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:
+ doKey("down");
+ doKey("return");
+ 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();
+ synthesizeKey("f", {});
+ expectPopup();
+ break;
+
+ case 301:
+ doKey("down");
+ doKey("return");
+ checkForm("final");
+ expectPopup();
+ restoreForm();
+ doKey("down");
+ break;
+
+ case 302:
+ // Filter with a letter in the middle of the word.
+ synthesizeKey("i", {});
+ synthesizeKey("n", {});
+ waitForMenuChange(1);
+ break;
+
+ case 303:
+ // Continuation of test 302.
+ doKey("down");
+ doKey("return");
+ checkForm("final");
+ expectPopup();
+ restoreForm();
+ doKey("down");
+ break;
+
+ case 304:
+ // Filter is disabled with mozNoFilter.
+ input.setAttribute('mozNoFilter', 'true');
+ synthesizeKey("f", {});
+ waitForMenuChange(3); // no change
+ break;
+
+ case 305:
+ // Continuation of test 304.
+ doKey("down");
+ doKey("return");
+ checkForm("Google");
+ input.removeAttribute('mozNoFilter');
+ testNum = 399;
+ expectPopup();
+ restoreForm();
+ doKey("down");
+ break;
+
+ case 400:
+ // Check that the input event is fired.
+ input.addEventListener("input", function(event) {
+ input.removeEventListener("input", arguments.callee, false);
+ ok(true, "oninput should have been received");
+ ok(event.bubbles, "input event should bubble");
+ ok(event.cancelable, "input event should be cancelable");
+ checkForm("Google");
+ input.blur();
+ SimpleTest.finish();
+ }, false);
+
+ doKey("down");
+ checkForm("");
+ doKey("return");
+ break;
+
+ default:
+ ok(false, "Unexpected invocation of test #" + testNum);
+ SimpleTest.finish();
+ return;
+ }
+}
+
+function waitForMenuChange(expectedCount) {
+ notifyMenuChanged(expectedCount, null, runTest);
+}
+
+function checkMenuEntries(expectedValues, testNumber) {
+ var actualValues = getMenuEntries();
+ is(actualValues.length, expectedValues.length, testNumber + " Checking length of expected menu");
+ for (var i = 0; i < expectedValues.length; i++)
+ is(actualValues[i], expectedValues[i], testNumber + " Checking menu entry #"+i);
+}
+
+function startTest() {
+ 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 000000000..ecccabcaf
--- /dev/null
+++ b/toolkit/components/satchel/test/test_form_submission.html
@@ -0,0 +1,537 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>Satchel Test for Form Submisstion</title>
+ <script type="text/javascript" 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 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 changed 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 (9 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>
+
+ <!-- form data submitted through HTTPS, when browser.formfill.saveHttpsForms is false -->
+ <form id="form20" action="https://www.example.com/" onsubmit="return checkSubmit(20)">
+ <input type="text" name="test1">
+ <button type="submit">Submit</button>
+ </form>
+
+ <!-- Form 21 is submitted into an iframe, not declared here. -->
+
+ <!-- Don't save values if the form is invalid. -->
+ <form id="form22" onsubmit="return checkSubmit(22);">
+ <input type='email' name='test1' oninvalid="return checkSubmit(22);">
+ <button type='submit'>Submit</button>
+ </form>
+
+ <!-- Don't save values if the form is invalid. -->
+ <form id="form23" onsubmit="return checkSubmit(23);">
+ <input type='email' value='foo' oninvalid="return checkSubmit(23);">
+ <input type='text' name='test1'>
+ <button type='submit'>Submit</button>
+ </form>
+
+ <!-- Don't save values if the input name is 'searchbar-history' -->
+ <form id="form24" onsubmit="return checkSubmit(24);">
+ <input type='text' name='searchbar-history'>
+ <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>
+
+ <!-- regular form data, when browser.formfill.saveHttpsForms is false -->
+ <form id="form110" onsubmit="return checkSubmit(110)">
+ <input type="text" name="test10">
+ <button type="submit">Submit</button>
+ </form>
+
+</div>
+<pre id="test">
+<script class="testbody" type="text/javascript">
+
+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, "12345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890").value = "dontSaveThis";
+ $_(11, "test1").value = "12345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890";
+ $_(12, "test1").value = " ";
+ $_(13, "test1").value = "dontSaveThis";
+ $_(14, "test1").type = "password";
+ $_(14, "test1").value = "dontSaveThis";
+
+ var 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 = "001064088";
+ $_(18, "test1").value = "0000-0000-0080-4609";
+ $_(19, "test1").value = "0000 0000 0222 331";
+ $_(20, "test1").value = "dontSaveThis";
+ $_(22, "test1").value = "dontSaveThis";
+ $_(23, "test1").value = "dontSaveThis";
+ $_(24, "searchbar-history").value = "dontSaveThis";
+
+ $_(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 = "00000000109181";
+
+ 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";
+ $_(110, "test10").value = "savedValue";
+
+ // submit the first form.
+ var button = getFormSubmitButton(1);
+ button.click();
+}
+
+
+// Called by each form's onsubmit handler.
+function checkSubmit(formNum) {
+ netscape.security.PrivilegeManager.enablePrivilege('UniversalXPConnect');
+
+ 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", "00000000109181", "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;
+ case 110:
+ checkForSave("test10", "savedValue", "checking saved value");
+ break;
+ default:
+ ok(false, "Unexpected form submission");
+ break;
+ }
+
+ return submitForm(formNum);
+}
+
+function submitForm(formNum)
+{
+ // Forms 13 and 14 would trigger a save-password notification. Temporarily
+ // disable pwmgr, then reenable it.
+ if (formNum == 12)
+ SpecialPowers.setBoolPref("signon.rememberSignons", false);
+ if (formNum == 14)
+ SpecialPowers.clearUserPref("signon.rememberSignons");
+
+ // Forms 20 and 21 requires browser.formfill.saveHttpsForms to be false
+ if (formNum == 19)
+ SpecialPowers.setBoolPref("browser.formfill.saveHttpsForms", false);
+ // Reset preference now that 20 and 21 are over
+ if (formNum == 21)
+ SpecialPowers.clearUserPref("browser.formfill.saveHttpsForms");
+
+ // End the test now on SeaMonkey.
+ if (formNum == 21 && navigator.userAgent.match(/ SeaMonkey\//)) {
+ checkObserver.uninit();
+ is(numSubmittedForms, 21, "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
+ }
+
+ // Form 109 requires browser.formfill.save_https_forms to be true;
+ // Form 110 requires it to be false.
+ if (formNum == 108)
+ SpecialPowers.setBoolPref("browser.formfill.saveHttpsForms", true);
+ if (formNum == 109)
+ SpecialPowers.setBoolPref("browser.formfill.saveHttpsForms", false);
+ if (formNum == 110)
+ SpecialPowers.clearUserPref("browser.formfill.saveHttpsForms");
+
+ // End the test at the last form.
+ if (formNum == 110) {
+ is(numSubmittedForms, 35, "Ensuring all forms were submitted.");
+ checkObserver.uninit();
+ 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() {
+ var 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 == 21 || nextFormNum == 100) {
+ ok(true, "submitting iframe test " + nextFormNum);
+ document.getElementById("iframe").contentWindow.clickButton(nextFormNum);
+ }
+ else {
+ var 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 000000000..96112f1c1
--- /dev/null
+++ b/toolkit/components/satchel/test/test_form_submission_cap.html
@@ -0,0 +1,85 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>Satchel Test for Form Submisstion Field Cap</title>
+ <script type="text/javascript" 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() {
+ var form = document.getElementById("form1");
+ for (i = 1; i <= numInputFields; i++) {
+ var 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 (i = 1; i <= numInputFields; i++) {
+ $_(1, "test" + i).value = i;
+ }
+
+ // submit the first form.
+ var button = getFormSubmitButton(1);
+ button.click();
+}
+
+
+// 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 (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 000000000..f51fb5f47
--- /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 type="text/javascript" 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.
+ var 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);
+ }
+ });
+}
+
+// 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_password_autocomplete.html b/toolkit/components/satchel/test/test_password_autocomplete.html
new file mode 100644
index 000000000..82781ae35
--- /dev/null
+++ b/toolkit/components/satchel/test/test_password_autocomplete.html
@@ -0,0 +1,107 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>Test for form history on type=password</title>
+ <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script type="text/javascript" src="/tests/SimpleTest/SpawnTask.js"></script>
+ <script type="text/javascript" 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>
+</div>
+
+<pre id="test">
+<script class="testbody" type="text/javascript">
+/* import-globals-from satchel_common.js */
+
+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.
+ */
+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(function* test_initialize() {
+ yield SpecialPowers.pushPrefEnv({"set": [["signon.rememberSignons", false]]});
+
+ // Now that rememberSignons is false, create the password field
+ $_(1, "field1").type = "password";
+
+ yield 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(function* test_insecure_focusWarning() {
+ // The form is insecure so should show the warning even if password manager is disabled.
+ let input = $_(1, "field1");
+ let shownPromise = waitForNextPopup();
+ input.focus();
+ yield 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 000000000..02e044bbd
--- /dev/null
+++ b/toolkit/components/satchel/test/test_popup_direction.html
@@ -0,0 +1,61 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>Test for Popup Direction</title>
+ <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script type="text/javascript" src="/tests/SimpleTest/SpawnTask.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(function* test_popup_direction() {
+ var input = $_(1, "field1");
+
+ yield 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();
+ doKey("down");
+ yield popupShown;
+
+ let popupState = yield 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 000000000..1a7aa8c19
--- /dev/null
+++ b/toolkit/components/satchel/test/test_popup_enter_event.html
@@ -0,0 +1,86 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Test for events while the form history popup is open</title>
+ <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script type="text/javascript" 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) {
+ doKey("down");
+ doKey("return"); // select the first entry in the popup
+ doKey("return"); // try to submit the form with the filled value
+}
+
+function runTest() {
+ input.addEventListener("keypress", handleEnter, true);
+ form.addEventListener("submit", evt => {
+ is(input.value, expectedValue, "Check input value in the submit handler");
+ evt.preventDefault();
+ 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();
+ doKey("down");
+}
+
+function startTest() {
+ setupFormHistory(function() {
+ runTest();
+ });
+}
+
+window.onload = startTest;
+
+SimpleTest.waitForExplicitFinish();
+</script>
+</pre>
+</body>
+</html>
diff --git a/toolkit/components/satchel/test/unit/.eslintrc.js b/toolkit/components/satchel/test/unit/.eslintrc.js
new file mode 100644
index 000000000..d35787cd2
--- /dev/null
+++ b/toolkit/components/satchel/test/unit/.eslintrc.js
@@ -0,0 +1,7 @@
+"use strict";
+
+module.exports = {
+ "extends": [
+ "../../../../../testing/xpcshell/xpcshell.eslintrc.js"
+ ]
+};
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 000000000..07b43c209
--- /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 000000000..5eeab074f
--- /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 000000000..5f7498bfc
--- /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 000000000..00daf03c2
--- /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 000000000..724cff73f
--- /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 000000000..e0e8fe246
--- /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 000000000..8eab177e9
--- /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 000000000..14279f05f
--- /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 000000000..21d9c1f1c
--- /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 000000000..282d07ba5
--- /dev/null
+++ b/toolkit/components/satchel/test/unit/head_satchel.js
@@ -0,0 +1,102 @@
+/* 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/. */
+
+Components.utils.import("resource://gre/modules/XPCOMUtils.jsm");
+Components.utils.import("resource://gre/modules/Services.jsm");
+Components.utils.import("resource://gre/modules/FormHistory.jsm");
+
+var Ci = Components.interfaces;
+var Cc = Components.classes;
+var Cu = Components.utils;
+
+const CURRENT_SCHEMA = 4;
+const PR_HOURS = 60 * 60 * 1000000;
+
+do_get_profile();
+
+var dirSvc = Cc["@mozilla.org/file/directory_service;1"].
+ getService(Ci.nsIProperties);
+
+// 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) {
+ var ss = Cc["@mozilla.org/storage/service;1"].
+ getService(Ci.mozIStorageService);
+ var dbConnection = ss.openDatabase(dbfile);
+ var version = dbConnection.schemaVersion;
+ dbConnection.close();
+
+ return version;
+}
+
+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: function (error) {
+ do_throw("Error occurred searching form history: " + error);
+ },
+ handleCompletion: function (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) {
+ var obj = {};
+ if (name !== null)
+ obj.fieldname = name;
+ if (value !== null)
+ obj.value = value;
+
+ let count = 0;
+ FormHistory.count(obj, { handleResult: result => count = result,
+ handleError: function (error) {
+ do_throw("Error occurred searching form history: " + error);
+ },
+ handleCompletion: function (reason) { if (!reason) then(count); }
+ });
+}
+
+// Perform a single form history update and call then() when done.
+function updateEntry(op, name, value, then) {
+ var obj = { op: 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: value, timesUsed: 1,
+ firstUsed: now, lastUsed: now }, then);
+}
+
+// Wrapper around FormHistory.update which handles errors. Calls then() when done.
+function updateFormHistory(changes, then) {
+ FormHistory.update(changes, { handleError: function (error) {
+ do_throw("Error occurred updating form history: " + error);
+ },
+ handleCompletion: function (reason) { if (!reason) then(); },
+ });
+}
+
+/**
+ * Logs info to the console in the standard way (includes the filename).
+ *
+ * @param aMessage
+ * The message to log to the console.
+ */
+function do_log_info(aMessage) {
+ print("TEST-INFO | " + _TEST_FILE + " | " + aMessage);
+}
diff --git a/toolkit/components/satchel/test/unit/perf_autocomplete.js b/toolkit/components/satchel/test/unit/perf_autocomplete.js
new file mode 100644
index 000000000..6e8bb5125
--- /dev/null
+++ b/toolkit/components/satchel/test/unit/perf_autocomplete.js
@@ -0,0 +1,140 @@
+/* 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 fh;
+var fac;
+var prefs;
+
+function countAllEntries() {
+ let stmt = fh.DBConnection.createStatement("SELECT COUNT(*) as numEntries FROM moz_formhistory");
+ do_check_true(stmt.executeStep());
+ let numEntries = stmt.row.numEntries;
+ stmt.finalize();
+ return numEntries;
+}
+
+function do_AC_search(searchTerm, previousResult) {
+ var duration = 0;
+ var searchCount = 5;
+ var tempPrevious = null;
+ var startTime;
+ for (var i = 0; i < searchCount; i++) {
+ if (previousResult !== null)
+ tempPrevious = fac.autoCompleteSearch("searchbar-history", previousResult, null, null);
+ startTime = Date.now();
+ results = fac.autoCompleteSearch("searchbar-history", searchTerm, null, tempPrevious);
+ duration += Date.now() - startTime;
+ }
+ dump("[autoCompleteSearch][test " + testnum + "] for '" + searchTerm + "' ");
+ if (previousResult !== null)
+ dump("with '" + previousResult + "' previous result ");
+ else
+ dump("w/o previous result ");
+ dump("took " + duration + " ms with " + results.matchCount + " matches. ");
+ dump("Average of " + Math.round(duration / searchCount) + " ms per search\n");
+ return results;
+}
+
+function run_test() {
+ try {
+
+ // ===== test init =====
+ var testfile = do_get_file("formhistory_1000.sqlite");
+ var profileDir = dirSvc.get("ProfD", Ci.nsIFile);
+ var results;
+
+ // Cleanup from any previous tests or failures.
+ var destFile = profileDir.clone();
+ destFile.append("formhistory.sqlite");
+ if (destFile.exists())
+ destFile.remove(false);
+
+ testfile.copyTo(profileDir, "formhistory.sqlite");
+
+ fh = Cc["@mozilla.org/satchel/form-history;1"].
+ getService(Ci.nsIFormHistory2);
+ fac = Cc["@mozilla.org/satchel/form-autocomplete;1"].
+ getService(Ci.nsIFormAutoComplete);
+ prefs = Cc["@mozilla.org/preferences-service;1"].
+ getService(Ci.nsIPrefBranch);
+
+ timeGroupingSize = prefs.getIntPref("browser.formfill.timeGroupingSize") * 1000 * 1000;
+ maxTimeGroupings = prefs.getIntPref("browser.formfill.maxTimeGroupings");
+ bucketSize = prefs.getIntPref("browser.formfill.bucketSize");
+
+ // ===== 1 =====
+ // Check initial state is as expected
+ testnum++;
+ do_check_true(fh.hasEntries);
+ do_check_eq(1000, countAllEntries());
+ fac.autoCompleteSearch("searchbar-history", "zzzzzzzzzz", null, null); // warm-up search
+ do_check_true(fh.nameExists("searchbar-history"));
+
+ // ===== 2 =====
+ // Search for '' with no previous result
+ testnum++;
+ results = do_AC_search("", null);
+ do_check_true(results.matchCount > 0);
+
+ // ===== 3 =====
+ // Search for 'r' with no previous result
+ testnum++;
+ results = do_AC_search("r", null);
+ do_check_true(results.matchCount > 0);
+
+ // ===== 4 =====
+ // Search for 'r' with '' previous result
+ testnum++;
+ results = do_AC_search("r", "");
+ do_check_true(results.matchCount > 0);
+
+ // ===== 5 =====
+ // Search for 're' with no previous result
+ testnum++;
+ results = do_AC_search("re", null);
+ do_check_true(results.matchCount > 0);
+
+ // ===== 6 =====
+ // Search for 're' with 'r' previous result
+ testnum++;
+ results = do_AC_search("re", "r");
+ do_check_true(results.matchCount > 0);
+
+ // ===== 7 =====
+ // Search for 'rea' without previous result
+ testnum++;
+ results = do_AC_search("rea", null);
+ let countREA = results.matchCount;
+
+ // ===== 8 =====
+ // Search for 'rea' with 're' previous result
+ testnum++;
+ results = do_AC_search("rea", "re");
+ do_check_eq(countREA, results.matchCount);
+
+ // ===== 9 =====
+ // Search for 'real' with 'rea' previous result
+ testnum++;
+ results = do_AC_search("real", "rea");
+ let countREAL = results.matchCount;
+ do_check_true(results.matchCount <= countREA);
+
+ // ===== 10 =====
+ // Search for 'real' with 're' previous result
+ testnum++;
+ results = do_AC_search("real", "re");
+ do_check_eq(countREAL, results.matchCount);
+
+ // ===== 11 =====
+ // Search for 'real' with no previous result
+ testnum++;
+ results = do_AC_search("real", null);
+ do_check_eq(countREAL, results.matchCount);
+
+
+ } catch (e) {
+ throw "FAILED in test #" + testnum + " -- " + e;
+ }
+}
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 000000000..501cbdfe5
--- /dev/null
+++ b/toolkit/components/satchel/test/unit/test_async_expire.js
@@ -0,0 +1,168 @@
+/* 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 dbFile, oldSize;
+var currentTestIndex = 0;
+
+function triggerExpiration() {
+ // 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", null);
+}
+
+var checkExists = function(num) { do_check_true(num > 0); next_test(); }
+var checkNotExists = function(num) { do_check_true(!num); next_test(); }
+
+var TestObserver = {
+ QueryInterface : XPCOMUtils.generateQI([Ci.nsIObserver, Ci.nsISupportsWeakReference]),
+
+ observe : function (subject, topic, data) {
+ do_check_eq(topic, "satchel-storage-changed");
+
+ if (data == "formhistory-expireoldentries") {
+ next_test();
+ }
+ }
+};
+
+function test_finished() {
+ // Make sure we always reset prefs.
+ if (Services.prefs.prefHasUserValue("browser.formfill.expire_days"))
+ Services.prefs.clearUserPref("browser.formfill.expire_days");
+
+ do_test_finished();
+}
+
+var iter = tests();
+
+function run_test()
+{
+ do_test_pending();
+ iter.next();
+}
+
+function next_test()
+{
+ iter.next();
+}
+
+function* tests()
+{
+ Services.obs.addObserver(TestObserver, "satchel-storage-changed", true);
+
+ // ===== test init =====
+ var testfile = do_get_file("asyncformhistory_expire.sqlite");
+ var profileDir = do_get_profile();
+
+ // Cleanup from any previous tests or failures.
+ dbFile = profileDir.clone();
+ dbFile.append("formhistory.sqlite");
+ if (dbFile.exists())
+ dbFile.remove(false);
+
+ testfile.copyTo(profileDir, "formhistory.sqlite");
+ do_check_true(dbFile.exists());
+
+ // We're going to clear this at the end, so it better have the default value now.
+ do_check_false(Services.prefs.prefHasUserValue("browser.formfill.expire_days"));
+
+ // Sanity check initial state
+ yield countEntries(null, null, function(num) { do_check_eq(508, num); next_test(); });
+ yield countEntries("name-A", "value-A", checkExists); // lastUsed == distant past
+ yield countEntries("name-B", "value-B", checkExists); // lastUsed == distant future
+
+ do_check_eq(CURRENT_SCHEMA, FormHistory.schemaVersion);
+
+ // Add a new entry
+ yield countEntries("name-C", "value-C", checkNotExists);
+ yield addEntry("name-C", "value-C", next_test);
+ yield countEntries("name-C", "value-C", checkExists);
+
+ // Update some existing entries to have ages relative to when the test runs.
+ var now = 1000 * Date.now();
+ let updateLastUsed = function updateLastUsedFn(results, age)
+ {
+ let lastUsed = now - age * 24 * PR_HOURS;
+
+ let changes = [ ];
+ for (let r = 0; r < results.length; r++) {
+ changes.push({ op: "update", lastUsed: lastUsed, guid: results[r].guid });
+ }
+
+ return changes;
+ }
+
+ let results = yield searchEntries(["guid"], { lastUsed: 181 }, iter);
+ yield updateFormHistory(updateLastUsed(results, 181), next_test);
+
+ results = yield searchEntries(["guid"], { lastUsed: 179 }, iter);
+ yield updateFormHistory(updateLastUsed(results, 179), next_test);
+
+ results = yield searchEntries(["guid"], { lastUsed: 31 }, iter);
+ yield updateFormHistory(updateLastUsed(results, 31), next_test);
+
+ results = yield searchEntries(["guid"], { lastUsed: 29 }, iter);
+ yield updateFormHistory(updateLastUsed(results, 29), next_test);
+
+ results = yield searchEntries(["guid"], { lastUsed: 9999 }, iter);
+ yield updateFormHistory(updateLastUsed(results, 11), next_test);
+
+ results = yield searchEntries(["guid"], { lastUsed: 9 }, iter);
+ yield updateFormHistory(updateLastUsed(results, 9), next_test);
+
+ yield countEntries("name-A", "value-A", checkExists);
+ yield countEntries("181DaysOld", "foo", checkExists);
+ yield countEntries("179DaysOld", "foo", checkExists);
+ yield countEntries(null, null, function(num) { do_check_eq(509, num); next_test(); });
+
+ // 2 entries are expected to expire.
+ triggerExpiration();
+ yield;
+
+ yield countEntries("name-A", "value-A", checkNotExists);
+ yield countEntries("181DaysOld", "foo", checkNotExists);
+ yield countEntries("179DaysOld", "foo", checkExists);
+ yield countEntries(null, null, function(num) { do_check_eq(507, num); next_test(); });
+
+ // And again. No change expected.
+ triggerExpiration();
+ yield;
+
+ yield countEntries(null, null, function(num) { do_check_eq(507, num); next_test(); });
+
+ // Set formfill pref to 30 days.
+ Services.prefs.setIntPref("browser.formfill.expire_days", 30);
+ yield countEntries("179DaysOld", "foo", checkExists);
+ yield countEntries("bar", "31days", checkExists);
+ yield countEntries("bar", "29days", checkExists);
+ yield countEntries(null, null, function(num) { do_check_eq(507, num); next_test(); });
+
+ triggerExpiration();
+ yield;
+
+ yield countEntries("179DaysOld", "foo", checkNotExists);
+ yield countEntries("bar", "31days", checkNotExists);
+ yield countEntries("bar", "29days", checkExists);
+ yield countEntries(null, null, function(num) { do_check_eq(505, num); next_test(); });
+
+ // 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);
+
+ yield countEntries("bar", "29days", checkExists);
+ yield countEntries("9DaysOld", "foo", checkExists);
+ yield countEntries(null, null, function(num) { do_check_eq(505, num); next_test(); });
+
+ triggerExpiration();
+ yield;
+
+ yield countEntries("bar", "29days", checkNotExists);
+ yield countEntries("9DaysOld", "foo", checkExists);
+ yield countEntries("name-B", "value-B", checkExists);
+ yield countEntries("name-C", "value-C", checkExists);
+ yield countEntries(null, null, function(num) { do_check_eq(3, num); next_test(); });
+
+ test_finished();
+}
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 000000000..211753809
--- /dev/null
+++ b/toolkit/components/satchel/test/unit/test_autocomplete.js
@@ -0,0 +1,266 @@
+/* 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 testnum = 0;
+var fac;
+var prefs;
+
+var numRecords, timeGroupingSize, now;
+
+const DEFAULT_EXPIRE_DAYS = 180;
+
+function padLeft(number, length) {
+ var str = number + '';
+ while (str.length < length)
+ str = '0' + str;
+ return str;
+}
+
+function getFormExpiryDays() {
+ if (prefs.prefHasUserValue("browser.formfill.expire_days")) {
+ return prefs.getIntPref("browser.formfill.expire_days");
+ }
+ return DEFAULT_EXPIRE_DAYS;
+}
+
+function run_test() {
+ // ===== test init =====
+ var testfile = do_get_file("formhistory_autocomplete.sqlite");
+ var profileDir = dirSvc.get("ProfD", Ci.nsIFile);
+
+ // Cleanup from any previous tests or failures.
+ var 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);
+ prefs = Cc["@mozilla.org/preferences-service;1"].
+ getService(Ci.nsIPrefBranch);
+
+ timeGroupingSize = prefs.getIntPref("browser.formfill.timeGroupingSize") * 1000 * 1000;
+
+ run_next_test();
+}
+
+add_test(function test0() {
+ var maxTimeGroupings = prefs.getIntPref("browser.formfill.maxTimeGroupings");
+ var bucketSize = 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) {
+ do_check_true(count > 0);
+ run_next_test();
+ });
+ });
+});
+
+add_test(function test2() {
+ do_log_info("Check search contains all entries");
+
+ fac.autoCompleteSearchAsync("field1", "", null, null, null, {
+ onSearchCompletion : function(aResults) {
+ do_check_eq(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 : function(aResults) {
+ for (let i = 0; i < numRecords; i+=2) {
+ do_check_eq(parseInt(aResults.getValueAt(i + 1).substr(5), 10), --lastFound);
+ do_check_eq(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 : function(aResults) {
+ for (let i = 0; i < numRecords; i+=2) {
+ do_check_eq(parseInt(aResults.getValueAt(i + 1).substr(5), 10), --lastFound);
+ do_check_eq(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 : function(aResults) {
+ for (let i = 0; i < timesUsedSamples; i++) {
+ do_check_eq(parseInt(aResults.getValueAt(i).substr(5)), --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 : function(aResults) {
+ for (let i = 0; i < timesUsedSamples; i++) {
+ do_check_eq(parseInt(aResults.getValueAt(i).substr(5)), --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 : function(aResults) {
+ do_check_eq(aResults.getValueAt(0), "senior citizen");
+ do_check_eq(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 : function(aResults) {
+ do_check_eq(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: 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 : function(aResults) {
+ do_check_eq(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 : function(aResults) {
+ do_check_eq(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 000000000..a6fdc4c02
--- /dev/null
+++ b/toolkit/components/satchel/test/unit/test_db_corrupt.js
@@ -0,0 +1,89 @@
+/* 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 = 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.
+ do_check_false(bakFile.exists());
+ // Doing any request to the DB should create it.
+ countEntries(null, null, run_next_test);
+});
+
+add_test(function test_corruptFormHistoryDB_lazyCorruptInit2() {
+ do_check_true(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 : function(aNumEntries) {
+ do_check_true(aNumEntries == 0);
+ FormHistory.count({ fieldname : "name-A", value : "value-A" }, {
+ handleResult : function(aNumEntries2) {
+ do_check_true(aNumEntries2 == 0);
+ run_next_test();
+ },
+ handleError : function(aError2) {
+ do_throw("DB initialized after reading a corrupt DB file found an entry.");
+ }
+ });
+ },
+ handleError : function (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) {
+ do_check_true(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) {
+ do_check_true(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 000000000..84b17e8a0
--- /dev/null
+++ b/toolkit/components/satchel/test/unit/test_db_update_v4.js
@@ -0,0 +1,60 @@
+/* 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 iter;
+
+function run_test()
+{
+ do_test_pending();
+ iter = next_test();
+ iter.next();
+}
+
+function* next_test()
+{
+ try {
+
+ // ===== test init =====
+ var testfile = do_get_file("formhistory_v3.sqlite");
+ var profileDir = dirSvc.get("ProfD", Ci.nsIFile);
+
+ // Cleanup from any previous tests or failures.
+ var destFile = profileDir.clone();
+ destFile.append("formhistory.sqlite");
+ if (destFile.exists())
+ destFile.remove(false);
+
+ testfile.copyTo(profileDir, "formhistory.sqlite");
+ do_check_eq(3, getDBVersion(testfile));
+
+ // ===== 1 =====
+ testnum++;
+
+ destFile = profileDir.clone();
+ destFile.append("formhistory.sqlite");
+ let dbConnection = Services.storage.openUnsharedDatabase(destFile);
+
+ // check for upgraded schema.
+ do_check_eq(CURRENT_SCHEMA, FormHistory.schemaVersion);
+
+ // Check that the index was added
+ do_check_true(dbConnection.tableExists("moz_deleted_formhistory"));
+ dbConnection.close();
+
+ // check for upgraded schema.
+ do_check_eq(CURRENT_SCHEMA, FormHistory.schemaVersion);
+ // check that an entry still exists
+ yield countEntries("name-A", "value-A",
+ function (num) {
+ do_check_true(num > 0);
+ do_test_finished();
+ }
+ );
+
+ } catch (e) {
+ throw "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 000000000..356d34a48
--- /dev/null
+++ b/toolkit/components/satchel/test/unit/test_db_update_v4b.js
@@ -0,0 +1,58 @@
+/* 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 iter;
+
+function run_test()
+{
+ do_test_pending();
+ iter = next_test();
+ iter.next();
+}
+
+function* next_test()
+{
+ try {
+
+ // ===== test init =====
+ var testfile = do_get_file("formhistory_v3v4.sqlite");
+ var profileDir = dirSvc.get("ProfD", Ci.nsIFile);
+
+ // Cleanup from any previous tests or failures.
+ var destFile = profileDir.clone();
+ destFile.append("formhistory.sqlite");
+ if (destFile.exists())
+ destFile.remove(false);
+
+ testfile.copyTo(profileDir, "formhistory.sqlite");
+ do_check_eq(3, getDBVersion(testfile));
+
+ // ===== 1 =====
+ testnum++;
+
+ destFile = profileDir.clone();
+ destFile.append("formhistory.sqlite");
+ dbConnection = Services.storage.openUnsharedDatabase(destFile);
+
+ // check for upgraded schema.
+ do_check_eq(CURRENT_SCHEMA, FormHistory.schemaVersion);
+
+ // Check that the index was added
+ do_check_true(dbConnection.tableExists("moz_deleted_formhistory"));
+ dbConnection.close();
+
+ // check that an entry still exists
+ yield countEntries("name-A", "value-A",
+ function (num) {
+ do_check_true(num > 0);
+ do_test_finished();
+ }
+ );
+
+ } catch (e) {
+ throw "FAILED in test #" + testnum + " -- " + e;
+ }
+}
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 000000000..0a44d06aa
--- /dev/null
+++ b/toolkit/components/satchel/test/unit/test_db_update_v999a.js
@@ -0,0 +1,75 @@
+/* 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.
+ */
+
+var iter = tests();
+
+function run_test()
+{
+ do_test_pending();
+ iter.next();
+}
+
+function next_test()
+{
+ iter.next();
+}
+
+function* tests()
+{
+ try {
+ var testnum = 0;
+
+ // ===== test init =====
+ var testfile = do_get_file("formhistory_v999a.sqlite");
+ var profileDir = dirSvc.get("ProfD", Ci.nsIFile);
+
+ // Cleanup from any previous tests or failures.
+ var destFile = profileDir.clone();
+ destFile.append("formhistory.sqlite");
+ if (destFile.exists())
+ destFile.remove(false);
+
+ testfile.copyTo(profileDir, "formhistory.sqlite");
+ do_check_eq(999, getDBVersion(testfile));
+
+ let checkZero = function(num) { do_check_eq(num, 0); next_test(); }
+ let checkOne = function(num) { do_check_eq(num, 1); next_test(); }
+
+ // ===== 1 =====
+ testnum++;
+ // Check for expected contents.
+ yield countEntries(null, null, function(num) { do_check_true(num > 0); next_test(); });
+ yield countEntries("name-A", "value-A", checkOne);
+ yield countEntries("name-B", "value-B", checkOne);
+ yield countEntries("name-C", "value-C1", checkOne);
+ yield countEntries("name-C", "value-C2", checkOne);
+ yield countEntries("name-E", "value-E", checkOne);
+
+ // check for downgraded schema.
+ do_check_eq(CURRENT_SCHEMA, FormHistory.schemaVersion);
+
+ // ===== 2 =====
+ testnum++;
+ // Exercise adding and removing a name/value pair
+ yield countEntries("name-D", "value-D", checkZero);
+ yield updateEntry("add", "name-D", "value-D", next_test);
+ yield countEntries("name-D", "value-D", checkOne);
+ yield updateEntry("remove", "name-D", "value-D", next_test);
+ yield countEntries("name-D", "value-D", checkZero);
+
+ } catch (e) {
+ throw "FAILED in test #" + testnum + " -- " + e;
+ }
+
+ do_test_finished();
+}
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 000000000..fb0ecd1b7
--- /dev/null
+++ b/toolkit/components/satchel/test/unit/test_db_update_v999b.js
@@ -0,0 +1,92 @@
+/* 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.
+ */
+
+var iter = tests();
+
+function run_test()
+{
+ do_test_pending();
+ iter.next();
+}
+
+function next_test()
+{
+ iter.next();
+}
+
+function* tests()
+{
+ try {
+ var testnum = 0;
+
+ // ===== test init =====
+ var testfile = do_get_file("formhistory_v999b.sqlite");
+ var profileDir = dirSvc.get("ProfD", Ci.nsIFile);
+
+ // Cleanup from any previous tests or failures.
+ var destFile = profileDir.clone();
+ destFile.append("formhistory.sqlite");
+ if (destFile.exists())
+ destFile.remove(false);
+
+ var bakFile = profileDir.clone();
+ bakFile.append("formhistory.sqlite.corrupt");
+ if (bakFile.exists())
+ bakFile.remove(false);
+
+ testfile.copyTo(profileDir, "formhistory.sqlite");
+ do_check_eq(999, getDBVersion(testfile));
+
+ let checkZero = function(num) { do_check_eq(num, 0); next_test(); }
+ let checkOne = function(num) { do_check_eq(num, 1); next_test(); }
+
+ // ===== 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.
+ do_check_false(bakFile.exists());
+ // Doing any request to the DB should create it.
+ yield countEntries("", "", next_test);
+
+ do_check_true(bakFile.exists());
+ bakFile.remove(false);
+
+ // ===== 2 =====
+ testnum++;
+ // File should be empty
+ yield countEntries(null, null, function(num) { do_check_false(num); next_test(); });
+ yield countEntries("name-A", "value-A", checkZero);
+ // check for current schema.
+ do_check_eq(CURRENT_SCHEMA, FormHistory.schemaVersion);
+
+ // ===== 3 =====
+ testnum++;
+ // Try adding an entry
+ yield updateEntry("add", "name-A", "value-A", next_test);
+ yield countEntries(null, null, checkOne);
+ yield countEntries("name-A", "value-A", checkOne);
+
+ // ===== 4 =====
+ testnum++;
+ // Try removing an entry
+ yield updateEntry("remove", "name-A", "value-A", next_test);
+ yield countEntries(null, null, checkZero);
+ yield countEntries("name-A", "value-A", checkZero);
+
+ } catch (e) {
+ throw "FAILED in test #" + testnum + " -- " + e;
+ }
+
+ do_test_finished();
+}
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 000000000..753504183
--- /dev/null
+++ b/toolkit/components/satchel/test/unit/test_history_api.js
@@ -0,0 +1,457 @@
+/* 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
+
+Cu.import("resource://gre/modules/Promise.jsm");
+
+function countDeletedEntries(expected)
+{
+ let deferred = Promise.defer();
+ let stmt = dbConnection.createAsyncStatement("SELECT COUNT(*) AS numEntries FROM moz_deleted_formhistory");
+ stmt.executeAsync({
+ handleResult: function(resultSet) {
+ do_check_eq(expected, resultSet.getNextRow().getResultByName("numEntries"));
+ deferred.resolve();
+ },
+ handleError : function () {
+ do_throw("Error occurred counting deleted entries: " + error);
+ deferred.reject();
+ },
+ handleCompletion : function () {
+ stmt.finalize();
+ }
+ });
+ return deferred.promise;
+}
+
+function checkTimeDeleted(guid, checkFunction)
+{
+ let deferred = Promise.defer();
+ let stmt = dbConnection.createAsyncStatement("SELECT timeDeleted FROM moz_deleted_formhistory WHERE guid = :guid");
+ stmt.params.guid = guid;
+ stmt.executeAsync({
+ handleResult: function(resultSet) {
+ checkFunction(resultSet.getNextRow().getResultByName("timeDeleted"));
+ deferred.resolve();
+ },
+ handleError : function () {
+ do_throw("Error occurred getting deleted entries: " + error);
+ deferred.reject();
+ },
+ handleCompletion : function () {
+ stmt.finalize();
+ }
+ });
+ return deferred.promise;
+}
+
+function promiseUpdateEntry(op, name, value)
+{
+ var change = { op: op };
+ if (name !== null)
+ change.fieldname = name;
+ if (value !== null)
+ change.value = value;
+ return promiseUpdate(change);
+}
+
+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();
+ }
+ }
+ });
+ });
+}
+
+function promiseSearchEntries(terms, params)
+{
+ let deferred = Promise.defer();
+ let results = [];
+ FormHistory.search(terms, params,
+ { handleResult: result => results.push(result),
+ handleError: function (error) {
+ do_throw("Error occurred searching form history: " + error);
+ deferred.reject(error);
+ },
+ handleCompletion: function (reason) { if (!reason) deferred.resolve(results); }
+ });
+ return deferred.promise;
+}
+
+function promiseCountEntries(name, value, checkFn)
+{
+ let deferred = Promise.defer();
+ countEntries(name, value, function (result) { checkFn(result); deferred.resolve(); } );
+ return deferred.promise;
+}
+
+add_task(function* ()
+{
+ let oldSupportsDeletedTable = FormHistory._supportsDeletedTable;
+ FormHistory._supportsDeletedTable = true;
+
+ try {
+
+ // ===== test init =====
+ var testfile = do_get_file("formhistory_apitest.sqlite");
+ var profileDir = dirSvc.get("ProfD", Ci.nsIFile);
+
+ // Cleanup from any previous tests or failures.
+ var destFile = profileDir.clone();
+ destFile.append("formhistory.sqlite");
+ if (destFile.exists())
+ destFile.remove(false);
+
+ testfile.copyTo(profileDir, "formhistory.sqlite");
+
+ function checkExists(num) { do_check_true(num > 0); }
+ function checkNotExists(num) { do_check_true(num == 0); }
+
+ // ===== 1 =====
+ // Check initial state is as expected
+ testnum++;
+ yield promiseCountEntries("name-A", null, checkExists);
+ yield promiseCountEntries("name-B", null, checkExists);
+ yield promiseCountEntries("name-C", null, checkExists);
+ yield promiseCountEntries("name-D", null, checkExists);
+ yield promiseCountEntries("name-A", "value-A", checkExists);
+ yield promiseCountEntries("name-B", "value-B1", checkExists);
+ yield promiseCountEntries("name-B", "value-B2", checkExists);
+ yield promiseCountEntries("name-C", "value-C", checkExists);
+ yield 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: function(resultSet) { },
+ handleError : function () {
+ do_throw("Error occurred counting deleted all entries: " + error);
+ },
+ handleCompletion : function () {
+ stmt.finalize();
+ deferred.resolve();
+ }
+ });
+ yield deferred.promise;
+
+ // ===== 2 =====
+ // Test looking for nonexistent / bogus data.
+ testnum++;
+ yield promiseCountEntries("blah", null, checkNotExists);
+ yield promiseCountEntries("", null, checkNotExists);
+ yield promiseCountEntries("name-A", "blah", checkNotExists);
+ yield promiseCountEntries("name-A", "", checkNotExists);
+ yield promiseCountEntries("name-A", null, checkExists);
+ yield promiseCountEntries("blah", "value-A", checkNotExists);
+ yield promiseCountEntries("", "value-A", checkNotExists);
+ yield 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();
+ yield FormHistory.count({ fieldname: null, value: null },
+ { handleResult: result => checkNotExists(result),
+ handleError: function (error) {
+ do_throw("Error occurred searching form history: " + error);
+ },
+ handleCompletion: function(reason) { if (!reason) deferred.resolve() }
+ });
+ yield deferred.promise;
+
+ // ===== 3 =====
+ // Test removeEntriesForName with a single matching value
+ testnum++;
+ yield promiseUpdateEntry("remove", "name-A", null);
+
+ yield promiseCountEntries("name-A", "value-A", checkNotExists);
+ yield promiseCountEntries("name-B", "value-B1", checkExists);
+ yield promiseCountEntries("name-B", "value-B2", checkExists);
+ yield promiseCountEntries("name-C", "value-C", checkExists);
+ yield promiseCountEntries("name-D", "value-D", checkExists);
+ yield countDeletedEntries(1);
+
+ // ===== 4 =====
+ // Test removeEntriesForName with multiple matching values
+ testnum++;
+ yield promiseUpdateEntry("remove", "name-B", null);
+
+ yield promiseCountEntries("name-A", "value-A", checkNotExists);
+ yield promiseCountEntries("name-B", "value-B1", checkNotExists);
+ yield promiseCountEntries("name-B", "value-B2", checkNotExists);
+ yield promiseCountEntries("name-C", "value-C", checkExists);
+ yield promiseCountEntries("name-D", "value-D", checkExists);
+ yield countDeletedEntries(3);
+
+ // ===== 5 =====
+ // Test removing by time range (single entry, not surrounding entries)
+ testnum++;
+ yield promiseCountEntries("time-A", null, checkExists); // firstUsed=1000, lastUsed=1000
+ yield promiseCountEntries("time-B", null, checkExists); // firstUsed=1000, lastUsed=1099
+ yield promiseCountEntries("time-C", null, checkExists); // firstUsed=1099, lastUsed=1099
+ yield promiseCountEntries("time-D", null, checkExists); // firstUsed=2001, lastUsed=2001
+ yield promiseUpdate({ op : "remove", firstUsedStart: 1050, firstUsedEnd: 2000 });
+
+ yield promiseCountEntries("time-A", null, checkExists);
+ yield promiseCountEntries("time-B", null, checkExists);
+ yield promiseCountEntries("time-C", null, checkNotExists);
+ yield promiseCountEntries("time-D", null, checkExists);
+ yield countDeletedEntries(4);
+
+ // ===== 6 =====
+ // Test removing by time range (multiple entries)
+ testnum++;
+ yield promiseUpdate({ op : "remove", firstUsedStart: 1000, firstUsedEnd: 2000 });
+
+ yield promiseCountEntries("time-A", null, checkNotExists);
+ yield promiseCountEntries("time-B", null, checkNotExists);
+ yield promiseCountEntries("time-C", null, checkNotExists);
+ yield promiseCountEntries("time-D", null, checkExists);
+ yield countDeletedEntries(6);
+
+ // ===== 7 =====
+ // test removeAllEntries
+ testnum++;
+ yield promiseUpdateEntry("remove", null, null);
+
+ yield promiseCountEntries("name-C", null, checkNotExists);
+ yield promiseCountEntries("name-D", null, checkNotExists);
+ yield promiseCountEntries("name-C", "value-C", checkNotExists);
+ yield promiseCountEntries("name-D", "value-D", checkNotExists);
+
+ yield promiseCountEntries(null, null, checkNotExists);
+ yield countDeletedEntries(6);
+
+ // ===== 8 =====
+ // Add a single entry back
+ testnum++;
+ yield promiseUpdateEntry("add", "newname-A", "newvalue-A");
+ yield promiseCountEntries("newname-A", "newvalue-A", checkExists);
+
+ // ===== 9 =====
+ // Remove the single entry
+ testnum++;
+ yield promiseUpdateEntry("remove", "newname-A", "newvalue-A");
+ yield promiseCountEntries("newname-A", "newvalue-A", checkNotExists);
+
+ // ===== 10 =====
+ // Add a single entry
+ testnum++;
+ yield promiseUpdateEntry("add", "field1", "value1");
+ yield promiseCountEntries("field1", "value1", checkExists);
+
+ let processFirstResult = function processResults(results)
+ {
+ // Only handle the first result
+ if (results.length > 0) {
+ let result = results[0];
+ return [result.timesUsed, result.firstUsed, result.lastUsed, result.guid];
+ }
+ return undefined;
+ }
+
+ results = yield promiseSearchEntries(["timesUsed", "firstUsed", "lastUsed"],
+ { fieldname: "field1", value: "value1" });
+ let [timesUsed, firstUsed, lastUsed] = processFirstResult(results);
+ do_check_eq(1, timesUsed);
+ do_check_true(firstUsed > 0);
+ do_check_true(lastUsed > 0);
+ yield promiseCountEntries(null, null, num => do_check_eq(num, 1));
+
+ // ===== 11 =====
+ // Add another single entry
+ testnum++;
+ yield promiseUpdateEntry("add", "field1", "value1b");
+ yield promiseCountEntries("field1", "value1", checkExists);
+ yield promiseCountEntries("field1", "value1b", checkExists);
+ yield promiseCountEntries(null, null, num => do_check_eq(num, 2));
+
+ // ===== 12 =====
+ // Update a single entry
+ testnum++;
+
+ results = yield promiseSearchEntries(["guid"], { fieldname: "field1", value: "value1" });
+ let guid = processFirstResult(results)[3];
+
+ yield promiseUpdate({ op : "update", guid: guid, value: "modifiedValue" });
+ yield promiseCountEntries("field1", "modifiedValue", checkExists);
+ yield promiseCountEntries("field1", "value1", checkNotExists);
+ yield promiseCountEntries("field1", "value1b", checkExists);
+ yield promiseCountEntries(null, null, num => do_check_eq(num, 2));
+
+ // ===== 13 =====
+ // Add a single entry with times
+ testnum++;
+ yield promiseUpdate({ op : "add", fieldname: "field2", value: "value2",
+ timesUsed: 20, firstUsed: 100, lastUsed: 500 });
+
+ results = yield promiseSearchEntries(["timesUsed", "firstUsed", "lastUsed"],
+ { fieldname: "field2", value: "value2" });
+ [timesUsed, firstUsed, lastUsed] = processFirstResult(results);
+
+ do_check_eq(20, timesUsed);
+ do_check_eq(100, firstUsed);
+ do_check_eq(500, lastUsed);
+ yield promiseCountEntries(null, null, num => do_check_eq(num, 3));
+
+ // ===== 14 =====
+ // Bump an entry, which updates its lastUsed field
+ testnum++;
+ yield promiseUpdate({ op : "bump", fieldname: "field2", value: "value2",
+ timesUsed: 20, firstUsed: 100, lastUsed: 500 });
+ results = yield promiseSearchEntries(["timesUsed", "firstUsed", "lastUsed"],
+ { fieldname: "field2", value: "value2" });
+ [timesUsed, firstUsed, lastUsed] = processFirstResult(results);
+ do_check_eq(21, timesUsed);
+ do_check_eq(100, firstUsed);
+ do_check_true(lastUsed > 500);
+ yield promiseCountEntries(null, null, num => do_check_eq(num, 3));
+
+ // ===== 15 =====
+ // Bump an entry that does not exist
+ testnum++;
+ yield promiseUpdate({ op : "bump", fieldname: "field3", value: "value3",
+ timesUsed: 10, firstUsed: 50, lastUsed: 400 });
+ results = yield promiseSearchEntries(["timesUsed", "firstUsed", "lastUsed"],
+ { fieldname: "field3", value: "value3" });
+ [timesUsed, firstUsed, lastUsed] = processFirstResult(results);
+ do_check_eq(10, timesUsed);
+ do_check_eq(50, firstUsed);
+ do_check_eq(400, lastUsed);
+ yield promiseCountEntries(null, null, num => do_check_eq(num, 4));
+
+ // ===== 16 =====
+ // Bump an entry with a guid
+ testnum++;
+ results = yield promiseSearchEntries(["guid"], { fieldname: "field3", value: "value3" });
+ guid = processFirstResult(results)[3];
+ yield promiseUpdate({ op : "bump", guid: guid, timesUsed: 20, firstUsed: 55, lastUsed: 400 });
+ results = yield promiseSearchEntries(["timesUsed", "firstUsed", "lastUsed"],
+ { fieldname: "field3", value: "value3" });
+ [timesUsed, firstUsed, lastUsed] = processFirstResult(results);
+ do_check_eq(11, timesUsed);
+ do_check_eq(50, firstUsed);
+ do_check_true(lastUsed > 400);
+ yield promiseCountEntries(null, null, num => do_check_eq(num, 4));
+
+ // ===== 17 =====
+ // Remove an entry
+ testnum++;
+ yield countDeletedEntries(7);
+
+ results = yield promiseSearchEntries(["guid"], { fieldname: "field1", value: "value1b" });
+ guid = processFirstResult(results)[3];
+
+ yield promiseUpdate({ op : "remove", guid: guid});
+ yield promiseCountEntries("field1", "modifiedValue", checkExists);
+ yield promiseCountEntries("field1", "value1b", checkNotExists);
+ yield promiseCountEntries(null, null, num => do_check_eq(num, 3));
+
+ yield countDeletedEntries(8);
+ yield checkTimeDeleted(guid, timeDeleted => do_check_true(timeDeleted > 10000));
+
+ // ===== 18 =====
+ // Add yet another single entry
+ testnum++;
+ yield promiseUpdate({ op : "add", fieldname: "field4", value: "value4",
+ timesUsed: 5, firstUsed: 230, lastUsed: 600 });
+ yield promiseCountEntries(null, null, num => do_check_eq(num, 4));
+
+ // ===== 19 =====
+ // Remove an entry by time
+ testnum++;
+ yield promiseUpdate({ op : "remove", firstUsedStart: 60, firstUsedEnd: 250 });
+ yield promiseCountEntries("field1", "modifiedValue", checkExists);
+ yield promiseCountEntries("field2", "value2", checkNotExists);
+ yield promiseCountEntries("field3", "value3", checkExists);
+ yield promiseCountEntries("field4", "value4", checkNotExists);
+ yield promiseCountEntries(null, null, num => do_check_eq(num, 2));
+ yield countDeletedEntries(10);
+
+ // ===== 20 =====
+ // Bump multiple existing entries at once
+ testnum++;
+
+ yield 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 }]);
+ yield promiseCountEntries(null, null, num => do_check_eq(num, 4));
+
+ yield promiseUpdate([
+ { op : "bump", fieldname: "field5", value: "value5" },
+ { op : "bump", fieldname: "field6", value: "value6" }]);
+ results = yield promiseSearchEntries(["fieldname", "timesUsed", "firstUsed", "lastUsed"], { });
+
+ do_check_eq(6, results[2].timesUsed);
+ do_check_eq(13, results[3].timesUsed);
+ do_check_eq(230, results[2].firstUsed);
+ do_check_eq(430, results[3].firstUsed);
+ do_check_true(results[2].lastUsed > 600);
+ do_check_true(results[3].lastUsed > 700);
+
+ yield promiseCountEntries(null, null, num => do_check_eq(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.
+ 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");
+ 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");
+ 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");
+ 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.
+ yield promiseUpdate([{ op: "remove", fieldname: "field5", value: null },
+ { op: "remove", fieldname: null, value: null }]);
+ Services.prefs.clearUserPref("browser.formfill.enable");
+
+ } catch (e) {
+ throw "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_notify.js b/toolkit/components/satchel/test/unit/test_notify.js
new file mode 100644
index 000000000..556ecd4b0
--- /dev/null
+++ b/toolkit/components/satchel/test/unit/test_notify.js
@@ -0,0 +1,158 @@
+/*
+ * Test suite for satchel notifications
+ *
+ * Tests notifications dispatched when modifying form history.
+ *
+ */
+
+var expectedNotification;
+var expectedData;
+
+var TestObserver = {
+ QueryInterface : XPCOMUtils.generateQI([Ci.nsIObserver, Ci.nsISupportsWeakReference]),
+
+ observe : function (subject, topic, data) {
+ do_check_eq(topic, "satchel-storage-changed");
+ do_check_eq(data, expectedNotification);
+
+ switch (data) {
+ case "formhistory-add":
+ case "formhistory-update":
+ do_check_true(subject instanceof Ci.nsISupportsString);
+ do_check_true(isGUID.test(subject.toString()));
+ break;
+ case "formhistory-remove":
+ do_check_eq(null, subject);
+ break;
+ default:
+ do_throw("Unhandled notification: " + data + " / " + topic);
+ }
+
+ expectedNotification = null;
+ expectedData = null;
+ }
+};
+
+var testIterator = null;
+
+function run_test() {
+ do_test_pending();
+ testIterator = run_test_steps();
+ testIterator.next();
+}
+
+function next_test()
+{
+ testIterator.next();
+}
+
+function* run_test_steps() {
+
+try {
+
+var testnum = 0;
+var testdesc = "Setup of test form history entries";
+
+var entry1 = ["entry1", "value1"];
+
+/* ========== 1 ========== */
+testnum = 1;
+testdesc = "Initial connection to storage module"
+
+yield updateEntry("remove", null, null, next_test);
+yield countEntries(null, null, function (num) { do_check_false(num, "Checking initial DB is empty"); next_test(); });
+
+// Add the observer
+var os = Cc["@mozilla.org/observer-service;1"].
+ getService(Ci.nsIObserverService);
+os.addObserver(TestObserver, "satchel-storage-changed", false);
+
+/* ========== 2 ========== */
+testnum++;
+testdesc = "addEntry";
+
+expectedNotification = "formhistory-add";
+expectedData = entry1;
+
+yield updateEntry("add", entry1[0], entry1[1], next_test);
+do_check_eq(expectedNotification, null); // check that observer got a notification
+
+yield countEntries(entry1[0], entry1[1], function (num) { do_check_true(num > 0); next_test(); });
+
+/* ========== 3 ========== */
+testnum++;
+testdesc = "modifyEntry";
+
+expectedNotification = "formhistory-update";
+expectedData = entry1;
+// will update previous entry
+yield updateEntry("update", entry1[0], entry1[1], next_test);
+yield countEntries(entry1[0], entry1[1], function (num) { do_check_true(num > 0); next_test(); });
+
+do_check_eq(expectedNotification, null);
+
+/* ========== 4 ========== */
+testnum++;
+testdesc = "removeEntry";
+
+expectedNotification = "formhistory-remove";
+expectedData = entry1;
+yield updateEntry("remove", entry1[0], entry1[1], next_test);
+
+do_check_eq(expectedNotification, null);
+yield countEntries(entry1[0], entry1[1], function(num) { do_check_false(num, "doesn't exist after remove"); next_test(); });
+
+/* ========== 5 ========== */
+testnum++;
+testdesc = "removeAllEntries";
+
+expectedNotification = "formhistory-remove";
+expectedData = null; // no data expected
+yield updateEntry("remove", null, null, next_test);
+
+do_check_eq(expectedNotification, null);
+
+/* ========== 6 ========== */
+testnum++;
+testdesc = "removeAllEntries (again)";
+
+expectedNotification = "formhistory-remove";
+expectedData = null;
+yield updateEntry("remove", null, null, next_test);
+
+do_check_eq(expectedNotification, null);
+
+/* ========== 7 ========== */
+testnum++;
+testdesc = "removeEntriesForName";
+
+expectedNotification = "formhistory-remove";
+expectedData = "field2";
+yield updateEntry("remove", null, "field2", next_test);
+
+do_check_eq(expectedNotification, null);
+
+/* ========== 8 ========== */
+testnum++;
+testdesc = "removeEntriesByTimeframe";
+
+expectedNotification = "formhistory-remove";
+expectedData = [10, 99999999999];
+
+yield FormHistory.update({ op: "remove", firstUsedStart: expectedData[0], firstUsedEnd: expectedData[1] },
+ { handleCompletion: function(reason) { if (!reason) next_test() },
+ handleErrors: function (error) {
+ do_throw("Error occurred updating form history: " + error);
+ }
+ });
+
+do_check_eq(expectedNotification, null);
+
+os.removeObserver(TestObserver, "satchel-storage-changed", false);
+
+do_test_finished();
+
+} catch (e) {
+ throw "FAILED in test #" + testnum + " -- " + testdesc + ": " + e;
+}
+}
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 000000000..06e5a385b
--- /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: function(search, result) {
+ do_check_eq(result.searchString, "aaa");
+ do_test_finished();
+ }
+};
+
+var aaListener = {
+ onSearchResult: function(search, result) {
+ do_check_eq(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(Components.interfaces.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 000000000..4a41b47d6
--- /dev/null
+++ b/toolkit/components/satchel/test/unit/xpcshell.ini
@@ -0,0 +1,26 @@
+[DEFAULT]
+head = head_satchel.js
+tail =
+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
+ perf_autocomplete.js
+
+[test_async_expire.js]
+[test_autocomplete.js]
+[test_db_corrupt.js]
+[test_db_update_v4.js]
+[test_db_update_v4b.js]
+[test_db_update_v999a.js]
+[test_db_update_v999b.js]
+[test_history_api.js]
+[test_notify.js]
+[test_previous_result.js]
diff --git a/toolkit/components/satchel/towel b/toolkit/components/satchel/towel
new file mode 100644
index 000000000..c26c7a8b2
--- /dev/null
+++ b/toolkit/components/satchel/towel
@@ -0,0 +1,5 @@
+"Any man who can hitch the length and breadth of the galaxy, rough it,
+slum it, struggle against terrible odds, win through, and still knows
+where his towel is is clearly a man to be reckoned with."
+
+ - Douglas Adams