diff options
Diffstat (limited to 'toolkit/components/satchel')
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 Binary files differnew file mode 100644 index 000000000..07b43c209 --- /dev/null +++ b/toolkit/components/satchel/test/unit/asyncformhistory_expire.sqlite diff --git a/toolkit/components/satchel/test/unit/formhistory_1000.sqlite b/toolkit/components/satchel/test/unit/formhistory_1000.sqlite Binary files differnew file mode 100644 index 000000000..5eeab074f --- /dev/null +++ b/toolkit/components/satchel/test/unit/formhistory_1000.sqlite diff --git a/toolkit/components/satchel/test/unit/formhistory_CORRUPT.sqlite b/toolkit/components/satchel/test/unit/formhistory_CORRUPT.sqlite new file mode 100644 index 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 Binary files differnew file mode 100644 index 000000000..00daf03c2 --- /dev/null +++ b/toolkit/components/satchel/test/unit/formhistory_apitest.sqlite diff --git a/toolkit/components/satchel/test/unit/formhistory_autocomplete.sqlite b/toolkit/components/satchel/test/unit/formhistory_autocomplete.sqlite Binary files differnew file mode 100644 index 000000000..724cff73f --- /dev/null +++ b/toolkit/components/satchel/test/unit/formhistory_autocomplete.sqlite diff --git a/toolkit/components/satchel/test/unit/formhistory_v3.sqlite b/toolkit/components/satchel/test/unit/formhistory_v3.sqlite Binary files differnew file mode 100644 index 000000000..e0e8fe246 --- /dev/null +++ b/toolkit/components/satchel/test/unit/formhistory_v3.sqlite diff --git a/toolkit/components/satchel/test/unit/formhistory_v3v4.sqlite b/toolkit/components/satchel/test/unit/formhistory_v3v4.sqlite Binary files differnew file mode 100644 index 000000000..8eab177e9 --- /dev/null +++ b/toolkit/components/satchel/test/unit/formhistory_v3v4.sqlite diff --git a/toolkit/components/satchel/test/unit/formhistory_v999a.sqlite b/toolkit/components/satchel/test/unit/formhistory_v999a.sqlite Binary files differnew file mode 100644 index 000000000..14279f05f --- /dev/null +++ b/toolkit/components/satchel/test/unit/formhistory_v999a.sqlite diff --git a/toolkit/components/satchel/test/unit/formhistory_v999b.sqlite b/toolkit/components/satchel/test/unit/formhistory_v999b.sqlite Binary files differnew file mode 100644 index 000000000..21d9c1f1c --- /dev/null +++ b/toolkit/components/satchel/test/unit/formhistory_v999b.sqlite diff --git a/toolkit/components/satchel/test/unit/head_satchel.js b/toolkit/components/satchel/test/unit/head_satchel.js new file mode 100644 index 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 |