summaryrefslogtreecommitdiffstats
path: root/toolkit/components/satchel
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-19 00:47:55 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-19 00:47:55 +0000
commit26a029d407be480d791972afb5975cf62c9360a6 (patch)
treef435a8308119effd964b339f76abb83a57c29483 /toolkit/components/satchel
parentInitial commit. (diff)
downloadfirefox-26a029d407be480d791972afb5975cf62c9360a6.tar.xz
firefox-26a029d407be480d791972afb5975cf62c9360a6.zip
Adding upstream version 124.0.1.upstream/124.0.1
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'toolkit/components/satchel')
-rw-r--r--toolkit/components/satchel/FillHelpers.sys.mjs41
-rw-r--r--toolkit/components/satchel/FormAutoComplete.sys.mjs693
-rw-r--r--toolkit/components/satchel/FormHistory.sys.mjs1307
-rw-r--r--toolkit/components/satchel/FormHistoryChild.sys.mjs139
-rw-r--r--toolkit/components/satchel/FormHistoryParent.sys.mjs107
-rw-r--r--toolkit/components/satchel/FormHistoryStartup.sys.mjs99
-rw-r--r--toolkit/components/satchel/FormScenarios.sys.mjs87
-rw-r--r--toolkit/components/satchel/SignUpFormRuleset.sys.mjs589
-rw-r--r--toolkit/components/satchel/components.conf33
-rw-r--r--toolkit/components/satchel/integrations/FirefoxRelay.sys.mjs647
-rw-r--r--toolkit/components/satchel/integrations/FirefoxRelayTelemetry.mjs73
-rw-r--r--toolkit/components/satchel/integrations/FirefoxRelayUtils.sys.mjs23
-rw-r--r--toolkit/components/satchel/integrations/WebAuthnFeature.sys.mjs129
-rw-r--r--toolkit/components/satchel/moz.build57
-rw-r--r--toolkit/components/satchel/nsFormFillController.cpp1300
-rw-r--r--toolkit/components/satchel/nsFormFillController.h146
-rw-r--r--toolkit/components/satchel/nsIFormAutoComplete.idl44
-rw-r--r--toolkit/components/satchel/nsIFormFillController.idl69
-rw-r--r--toolkit/components/satchel/test/FormHistoryTestUtils.sys.mjs106
-rw-r--r--toolkit/components/satchel/test/browser/browser.toml10
-rw-r--r--toolkit/components/satchel/test/browser/browser_close_tab.js46
-rw-r--r--toolkit/components/satchel/test/browser/browser_popup_mouseover.js81
-rw-r--r--toolkit/components/satchel/test/browser/browser_privbrowsing_perwindowpb.js49
-rw-r--r--toolkit/components/satchel/test/mochitest.toml49
-rw-r--r--toolkit/components/satchel/test/parent_utils.js194
-rw-r--r--toolkit/components/satchel/test/satchel_common.js316
-rw-r--r--toolkit/components/satchel/test/subtst_form_submission_1.html15
-rw-r--r--toolkit/components/satchel/test/subtst_privbrowsing.html23
-rw-r--r--toolkit/components/satchel/test/test_bug_511615.html179
-rw-r--r--toolkit/components/satchel/test/test_bug_787624.html73
-rw-r--r--toolkit/components/satchel/test/test_capture_limit.html61
-rw-r--r--toolkit/components/satchel/test/test_datalist_attribute_change.html53
-rw-r--r--toolkit/components/satchel/test/test_datalist_dynamic.html82
-rw-r--r--toolkit/components/satchel/test/test_datalist_readonly_change.html41
-rw-r--r--toolkit/components/satchel/test/test_datalist_shadow_dom.html55
-rw-r--r--toolkit/components/satchel/test/test_datalist_with_caching.html64
-rw-r--r--toolkit/components/satchel/test/test_form_autocomplete.html698
-rw-r--r--toolkit/components/satchel/test/test_form_autocomplete_validation_at_input_event.html83
-rw-r--r--toolkit/components/satchel/test/test_form_autocomplete_with_list.html402
-rw-r--r--toolkit/components/satchel/test/test_form_submission.html598
-rw-r--r--toolkit/components/satchel/test/test_history_datalist_duplicates.html55
-rw-r--r--toolkit/components/satchel/test/test_input_valid_state_with_autocomplete.html125
-rw-r--r--toolkit/components/satchel/test/test_password_autocomplete.html89
-rw-r--r--toolkit/components/satchel/test/test_popup_direction.html44
-rw-r--r--toolkit/components/satchel/test/test_popup_enter_event.html70
-rw-r--r--toolkit/components/satchel/test/test_submit_on_keydown_enter.html108
-rw-r--r--toolkit/components/satchel/test/unit/asyncformhistory_expire.sqlitebin0 -> 98304 bytes
-rw-r--r--toolkit/components/satchel/test/unit/formhistory_1000.sqlitebin0 -> 164864 bytes
-rw-r--r--toolkit/components/satchel/test/unit/formhistory_CORRUPT.sqlite1
-rw-r--r--toolkit/components/satchel/test/unit/formhistory_apitest.sqlitebin0 -> 5120 bytes
-rw-r--r--toolkit/components/satchel/test/unit/formhistory_autocomplete.sqlitebin0 -> 72704 bytes
-rw-r--r--toolkit/components/satchel/test/unit/formhistory_v3.sqlitebin0 -> 5120 bytes
-rw-r--r--toolkit/components/satchel/test/unit/formhistory_v3v4.sqlitebin0 -> 6144 bytes
-rw-r--r--toolkit/components/satchel/test/unit/formhistory_v999a.sqlitebin0 -> 11264 bytes
-rw-r--r--toolkit/components/satchel/test/unit/formhistory_v999b.sqlitebin0 -> 8192 bytes
-rw-r--r--toolkit/components/satchel/test/unit/head_satchel.js188
-rw-r--r--toolkit/components/satchel/test/unit/test_async_expire.js134
-rw-r--r--toolkit/components/satchel/test/unit/test_autocomplete.js388
-rw-r--r--toolkit/components/satchel/test/unit/test_db_access_denied.js52
-rw-r--r--toolkit/components/satchel/test/unit/test_db_corrupt.js80
-rw-r--r--toolkit/components/satchel/test/unit/test_db_update_v4.js59
-rw-r--r--toolkit/components/satchel/test/unit/test_db_update_v4b.js49
-rw-r--r--toolkit/components/satchel/test/unit/test_db_update_v5.js29
-rw-r--r--toolkit/components/satchel/test/unit/test_db_update_v999a.js56
-rw-r--r--toolkit/components/satchel/test/unit/test_db_update_v999b.js74
-rw-r--r--toolkit/components/satchel/test/unit/test_history_api.js485
-rw-r--r--toolkit/components/satchel/test/unit/test_history_sources.js88
-rw-r--r--toolkit/components/satchel/test/unit/test_notify.js171
-rw-r--r--toolkit/components/satchel/test/unit/test_previous_result.js25
-rw-r--r--toolkit/components/satchel/test/unit/xpcshell.toml43
-rw-r--r--toolkit/components/satchel/towel5
71 files changed, 11279 insertions, 0 deletions
diff --git a/toolkit/components/satchel/FillHelpers.sys.mjs b/toolkit/components/satchel/FillHelpers.sys.mjs
new file mode 100644
index 0000000000..88a248adba
--- /dev/null
+++ b/toolkit/components/satchel/FillHelpers.sys.mjs
@@ -0,0 +1,41 @@
+/* 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 item shows image, title & subtitle.
+// Once selected it will send fillMessageName with fillMessageData
+// to the parent actor and response will be used to fill into the field.
+export class GenericAutocompleteItem {
+ comment = "";
+ style = "generic";
+ value = "";
+
+ constructor(image, title, subtitle, fillMessageName, fillMessageData) {
+ this.image = image;
+ this.comment = JSON.stringify({
+ title,
+ subtitle,
+ fillMessageName,
+ fillMessageData,
+ });
+ }
+}
+
+/**
+ * Show confirmation tooltip
+ *
+ * @param {object} browser - An object representing the browser.
+ * @param {string} messageId - Message ID from browser/confirmationHints.ftl
+ * @param {string} [anchorId="identity-icon-box"] - ID of the element to anchor the hint to.
+ The "password-notification-icon" and "notification-popup-box" are hidden
+ at the point of showing the hint (for *most* cases), so approximate the
+ location with the next closest, visible icon as the anchor.
+ */
+export function showConfirmation(
+ browser,
+ messageId,
+ anchorId = "identity-icon-box"
+) {
+ const anchor = browser.ownerDocument.getElementById(anchorId);
+ anchor.ownerGlobal.ConfirmationHint.show(anchor, messageId, {});
+}
diff --git a/toolkit/components/satchel/FormAutoComplete.sys.mjs b/toolkit/components/satchel/FormAutoComplete.sys.mjs
new file mode 100644
index 0000000000..1cae8b07c1
--- /dev/null
+++ b/toolkit/components/satchel/FormAutoComplete.sys.mjs
@@ -0,0 +1,693 @@
+/* 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/. */
+
+import { GenericAutocompleteItem } from "resource://gre/modules/FillHelpers.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ FormScenarios: "resource://gre/modules/FormScenarios.sys.mjs",
+});
+
+const formFillController = Cc[
+ "@mozilla.org/satchel/form-fill-controller;1"
+].getService(Ci.nsIFormFillController);
+
+function isAutocompleteDisabled(aField) {
+ if (!aField) {
+ return false;
+ }
+
+ if (aField.autocomplete !== "") {
+ return aField.autocomplete === "off";
+ }
+
+ return 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} clientInfo
+ * Info required to build the FormHistoryClient
+ * @param {Node} clientInfo.formField
+ * A DOM node that we're requesting form history for.
+ * @param {string} clientInfo.inputName
+ * 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.
+ */
+export class FormHistoryClient {
+ constructor({ formField, inputName }) {
+ if (formField) {
+ if (inputName == this.SEARCHBAR_ID) {
+ throw new Error(
+ "FormHistoryClient constructed with both a formField and an inputName. " +
+ "This is not supported, and only empty results will be returned."
+ );
+ }
+ const window = formField.ownerGlobal;
+ this.windowGlobal = window.windowGlobalChild;
+ }
+
+ this.inputName = inputName;
+ this.id = FormHistoryClient.nextRequestID++;
+ }
+
+ static nextRequestID = 1;
+ SEARCHBAR_ID = "searchbar-history";
+ cancelled = false;
+ inputName = "";
+
+ getActor() {
+ return this.windowGlobal?.getActor("FormHistory");
+ }
+
+ /**
+ * Query FormHistory for some results.
+ *
+ * @param {string} searchString
+ * The string to search FormHistory for. See
+ * FormHistory.getAutoCompleteResults.
+ * @param {object} params
+ * An Object with search properties. See
+ * FormHistory.getAutoCompleteResults.
+ * @param {string} scenarioName
+ * Optional autocompletion scenario name.
+ * @param {Function} callback
+ * A callback function that will take a single
+ * argument (the found entries).
+ */
+ requestAutoCompleteResults(searchString, params, scenarioName, callback) {
+ this.cancelled = false;
+
+ // Use the actor if possible, otherwise for the searchbar,
+ // use the more roundabout per-process message manager which has
+ // no sendQuery method.
+ const actor = this.getActor();
+ if (actor) {
+ actor
+ .sendQuery("FormHistory:AutoCompleteSearchAsync", {
+ searchString,
+ params,
+ scenarioName,
+ })
+ .then(
+ results => this.handleAutoCompleteResults(results, callback),
+ () => this.cancel()
+ );
+ } else {
+ this.callback = callback;
+ Services.cpmm.addMessageListener(
+ "FormHistory:AutoCompleteSearchResults",
+ this
+ );
+ Services.cpmm.sendAsyncMessage("FormHistory:AutoCompleteSearchAsync", {
+ id: this.id,
+ searchString,
+ params,
+ scenarioName,
+ });
+ }
+ }
+
+ handleAutoCompleteResults(results, callback) {
+ if (this.cancelled) {
+ return;
+ }
+
+ if (!callback) {
+ console.error("FormHistoryClient received response with no callback");
+ return;
+ }
+
+ callback(results);
+ this.cancel();
+ }
+
+ /**
+ * Cancel an in-flight results request. This ensures that the
+ * callback that requestAutoCompleteResults was passed is never
+ * called from this FormHistoryClient.
+ */
+ cancel() {
+ if (this.callback) {
+ Services.cpmm.removeMessageListener(
+ "FormHistory:AutoCompleteSearchResults",
+ this
+ );
+ this.callback = null;
+ }
+ this.cancelled = true;
+ }
+
+ /**
+ * Remove an item from FormHistory.
+ *
+ * @param {string} value
+ *
+ * The value to remove for this particular
+ * field.
+ *
+ * @param {string} guid
+ *
+ * The guid for the item being removed.
+ */
+ remove(value, guid) {
+ const actor = this.getActor() || Services.cpmm;
+ actor.sendAsyncMessage("FormHistory:RemoveEntry", {
+ inputName: this.inputName,
+ value,
+ guid,
+ });
+ }
+
+ receiveMessage(msg) {
+ const { id, results } = msg.data;
+ if (id == this.id) {
+ this.handleAutoCompleteResults(results, this.callback);
+ }
+ }
+}
+
+/**
+ * This autocomplete result combines 3 arrays of entries, fixedEntries and
+ * externalEntries.
+ * Entries are Form History entries, they can be removed.
+ * Fixed entries are "appended" to entries, they are used for datalist items,
+ * search suggestions and extra items from integrations.
+ * External entries are meant for integrations, like Firefox Relay.
+ * Internally entries and fixed entries are kept separated so we can
+ * reuse and filter them.
+ *
+ * @implements {nsIAutoCompleteResult}
+ */
+export class FormAutoCompleteResult {
+ constructor(client, entries, fieldName, searchString) {
+ this.client = client;
+ this.entries = entries;
+ this.fieldName = fieldName;
+ this.searchString = searchString;
+ }
+
+ QueryInterface = ChromeUtils.generateQI([
+ "nsIAutoCompleteResult",
+ "nsISupportsWeakReference",
+ ]);
+
+ // private
+ client = null;
+ entries = null;
+ fieldName = null;
+ #fixedEntries = [];
+ externalEntries = [];
+
+ set fixedEntries(value) {
+ this.#fixedEntries = value;
+ this.removeDuplicateHistoryEntries();
+ }
+
+ canSearchIncrementally(searchString) {
+ const prevSearchString = this.searchString.trim();
+ return (
+ prevSearchString.length > 1 &&
+ searchString.includes(prevSearchString.toLowerCase())
+ );
+ }
+
+ incrementalSearch(searchString) {
+ this.searchString = searchString;
+ searchString = searchString.trim().toLowerCase();
+ this.#fixedEntries = this.#fixedEntries.filter(item =>
+ item.label.toLowerCase().includes(searchString)
+ );
+
+ const 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 filteredEntries = [];
+ for (const entry of this.entries) {
+ // 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.includes(tok))) {
+ continue;
+ }
+ this.#calculateScore(entry, searchString, searchTokens);
+ filteredEntries.push(entry);
+ }
+ filteredEntries.sort((a, b) => b.totalScore - a.totalScore);
+ this.entries = filteredEntries;
+ this.removeDuplicateHistoryEntries();
+ }
+
+ /*
+ * #calculateScore
+ *
+ * entry -- an nsIAutoCompleteResult entry
+ * aSearchString -- current value of the input (lowercase)
+ * searchTokens -- array of tokens of the search string
+ *
+ * Returns: an int
+ */
+ #calculateScore(entry, aSearchString, searchTokens) {
+ let boundaryCalc = 0;
+ // for each word, calculate word boundary weights
+ for (const token of searchTokens) {
+ if (entry.textLowerCase.startsWith(token)) {
+ boundaryCalc++;
+ }
+ if (entry.textLowerCase.includes(" " + token)) {
+ boundaryCalc++;
+ }
+ }
+ boundaryCalc = boundaryCalc * this._boundaryWeight;
+ // now add more weight if we have a traditional prefix match and
+ // multiply boundary bonuses by boundary weight
+ if (entry.textLowerCase.startsWith(aSearchString)) {
+ boundaryCalc += this._prefixWeight;
+ }
+ entry.totalScore = Math.round(entry.frecency * Math.max(1, boundaryCalc));
+ }
+
+ /**
+ * Remove items from history list that are already present in fixed list.
+ * We do this rather than the opposite ( i.e. remove items from fixed list)
+ * to reflect the order that is specified in the fixed list.
+ */
+ removeDuplicateHistoryEntries() {
+ this.entries = this.entries.filter(entry =>
+ this.#fixedEntries.every(
+ fixed => entry.text != (fixed.label || fixed.value)
+ )
+ );
+ }
+
+ getAt(index) {
+ for (const group of [
+ this.entries,
+ this.#fixedEntries,
+ this.externalEntries,
+ ]) {
+ if (index < group.length) {
+ return group[index];
+ }
+ index -= group.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() {
+ return this.matchCount ? 0 : -1;
+ }
+
+ get searchResult() {
+ return this.matchCount
+ ? Ci.nsIAutoCompleteResult.RESULT_SUCCESS
+ : Ci.nsIAutoCompleteResult.RESULT_NOMATCH;
+ }
+
+ get matchCount() {
+ return (
+ this.entries.length +
+ this.#fixedEntries.length +
+ this.externalEntries.length
+ );
+ }
+
+ getValueAt(index) {
+ const item = this.getAt(index);
+ return item.text || item.value;
+ }
+
+ getLabelAt(index) {
+ const item = this.getAt(index);
+ return item.text || item.label || item.value;
+ }
+
+ getCommentAt(index) {
+ return this.getAt(index).comment ?? "";
+ }
+
+ getStyleAt(index) {
+ const itemStyle = this.getAt(index).style;
+ if (itemStyle) {
+ return itemStyle;
+ }
+
+ if (index >= 0) {
+ if (index < this.entries.length) {
+ return "fromhistory";
+ }
+
+ if (index > 0 && index == this.entries.length) {
+ return "datalist-first";
+ }
+ }
+ return "";
+ }
+
+ getImageAt(_index) {
+ return "";
+ }
+
+ getFinalCompleteValueAt(index) {
+ return this.getValueAt(index);
+ }
+
+ isRemovableAt(index) {
+ return this.#isFormHistoryEntry(index) || this.getAt(index).removable;
+ }
+
+ removeValueAt(index) {
+ if (this.#isFormHistoryEntry(index)) {
+ const [removedEntry] = this.entries.splice(index, 1);
+ this.client.remove(removedEntry.text, removedEntry.guid);
+ }
+ }
+
+ #isFormHistoryEntry(index) {
+ return index >= 0 && index < this.entries.length;
+ }
+}
+
+export class FormAutoComplete {
+ constructor() {
+ // 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");
+ Services.obs.addObserver(this, "autocomplete-will-enter-text");
+ }
+
+ classID = Components.ID("{c11c21b2-71c9-4f87-a0f8-5e13f50495fd}");
+ QueryInterface = ChromeUtils.generateQI([
+ "nsIFormAutoComplete",
+ "nsISupportsWeakReference",
+ ]);
+
+ // 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;
+
+ fillRequestId = 0;
+
+ observer = {
+ _self: null,
+
+ QueryInterface: ChromeUtils.generateQI([
+ "nsIObserver",
+ "nsISupportsWeakReference",
+ ]),
+
+ observe(_subject, topic, data) {
+ const self = this._self;
+
+ if (topic == "nsPref:changed") {
+ const prefName = data;
+ self.log(`got change to ${prefName} preference`);
+
+ switch (prefName) {
+ case "debug":
+ self._debug = self._prefBranch.getBoolPref(prefName);
+ break;
+ case "enable":
+ self._enabled = self._prefBranch.getBoolPref(prefName);
+ break;
+ }
+ }
+ },
+ };
+
+ // 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(message) {
+ if (!this._debug) {
+ return;
+ }
+ dump("FormAutoComplete: " + message + "\n");
+ Services.console.logStringMessage("FormAutoComplete: " + message);
+ }
+
+ /*
+ * autoCompleteSearchAsync
+ *
+ * aInputName -- |name| or |id| attribute value from the form input being
+ * autocompleted
+ * aUntrimmedSearchString -- current value of the input
+ * aField -- HTMLInputElement being autocompleted (may be null if from chrome)
+ * aPreviousResult -- previous search result, if any.
+ * aAddDataList -- add results from list=datalist for aField.
+ * aListener -- nsIFormAutoCompleteObserver that listens for the nsIAutoCompleteResult
+ * that may be returned asynchronously.
+ */
+ autoCompleteSearchAsync(
+ aInputName,
+ aUntrimmedSearchString,
+ aField,
+ aPreviousResult,
+ aAddDataList,
+ aListener
+ ) {
+ // Guard against void DOM strings filtering into this code.
+ if (typeof aInputName === "object") {
+ aInputName = "";
+ }
+ if (typeof aUntrimmedSearchString === "object") {
+ aUntrimmedSearchString = "";
+ }
+
+ const client = new FormHistoryClient({
+ formField: aField,
+ inputName: aInputName,
+ });
+
+ function reportSearchResult(result) {
+ aListener?.onSearchCompletion(result);
+ }
+
+ // If we have datalist results, they become our "empty" result.
+ const result = new FormAutoCompleteResult(
+ client,
+ [],
+ aInputName,
+ aUntrimmedSearchString
+ );
+
+ if (aAddDataList) {
+ result.fixedEntries = this.getDataListSuggestions(aField);
+ }
+
+ if (!this._enabled) {
+ reportSearchResult(result);
+ 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`);
+ reportSearchResult(result);
+ return;
+ }
+
+ if (isAutocompleteDisabled(aField)) {
+ this.log("autoCompleteSearch not allowed due to autcomplete=off");
+ reportSearchResult(result);
+ return;
+ }
+
+ const searchString = aUntrimmedSearchString.trim().toLowerCase();
+ const prevResult = aPreviousResult?.wrappedJSObject;
+ if (prevResult?.canSearchIncrementally(searchString)) {
+ this.log("Using previous autocomplete result");
+ prevResult.incrementalSearch(aUntrimmedSearchString);
+ reportSearchResult(prevResult);
+ } else {
+ this.log("Creating new autocomplete search result.");
+ this.getAutoCompleteValues(
+ client,
+ aInputName,
+ searchString,
+ lazy.FormScenarios.detect({ input: aField }).signUpForm
+ ? "SignUpFormScenario"
+ : "",
+ ({ formHistoryEntries, externalEntries }) => {
+ formHistoryEntries ??= [];
+ externalEntries ??= [];
+
+ if (aField?.maxLength > -1) {
+ result.entries = formHistoryEntries.filter(
+ el => el.text.length <= aField.maxLength
+ );
+ } else {
+ result.entries = formHistoryEntries;
+ }
+
+ result.externalEntries.push(
+ ...externalEntries.map(
+ entry =>
+ new GenericAutocompleteItem(
+ entry.image,
+ entry.title,
+ entry.subtitle,
+ entry.fillMessageName,
+ entry.fillMessageData
+ )
+ )
+ );
+
+ result.removeDuplicateHistoryEntries();
+ reportSearchResult(result);
+ }
+ );
+ }
+ }
+
+ getDataListSuggestions(aField) {
+ const items = [];
+
+ if (!aField?.list) {
+ return items;
+ }
+
+ const upperFieldValue = aField.value.toUpperCase();
+
+ for (const option of aField.list.options) {
+ const label = option.label || option.text || option.value || "";
+
+ if (!label.toUpperCase().includes(upperFieldValue)) {
+ continue;
+ }
+
+ items.push({
+ label,
+ value: option.value,
+ });
+ }
+
+ return items;
+ }
+
+ stopAutoCompleteSearch() {
+ 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
+ * scenarioName - Optional autocompletion scenario name.
+ * 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(
+ client,
+ fieldname,
+ searchString,
+ scenarioName,
+ callback
+ ) {
+ this.stopAutoCompleteSearch();
+ client.requestAutoCompleteResults(
+ searchString,
+ { fieldname },
+ scenarioName,
+ entries => {
+ this.#pendingClient = null;
+ callback(entries);
+ }
+ );
+ this.#pendingClient = client;
+ }
+
+ async observe(subject, topic, data) {
+ switch (topic) {
+ case "autocomplete-will-enter-text": {
+ await this.sendFillRequestToFormHistoryParent(subject, data);
+ break;
+ }
+ }
+ }
+
+ async sendFillRequestToFormHistoryParent(input, comment) {
+ if (!comment) {
+ return;
+ }
+
+ if (!input || input != formFillController.controller?.input) {
+ return;
+ }
+
+ const { fillMessageName, fillMessageData } = JSON.parse(comment ?? "{}");
+ if (!fillMessageName) {
+ return;
+ }
+
+ this.fillRequestId++;
+ const fillRequestId = this.fillRequestId;
+ const actor =
+ input.focusedInput.ownerGlobal.windowGlobalChild.getActor("FormHistory");
+ const value = await actor.sendQuery(fillMessageName, fillMessageData ?? {});
+
+ // skip fill if another fill operation started during await
+ if (fillRequestId != this.fillRequestId) {
+ return;
+ }
+
+ if (typeof value !== "string") {
+ return;
+ }
+
+ // If FormHistoryParent returned a string to fill, we must do it here because
+ // nsAutoCompleteController.cpp already finished it's work before we finished await.
+ input.textValue = value;
+ input.selectTextRange(value.length, value.length);
+ }
+}
diff --git a/toolkit/components/satchel/FormHistory.sys.mjs b/toolkit/components/satchel/FormHistory.sys.mjs
new file mode 100644
index 0000000000..07c33ceaed
--- /dev/null
+++ b/toolkit/components/satchel/FormHistory.sys.mjs
@@ -0,0 +1,1307 @@
+/* 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.
+ *
+ * async search(terms, queryData)
+ * 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 }
+ * Resolves to an array containing the found results. Each element in
+ * the array is an object containing a property for each search term
+ * specified by 'terms'.
+ * Rejects in case of errors.
+ * async count(queryData)
+ * 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.
+ * Resolves to the number of found entries.
+ * Rejects in case of errors.
+ * async update(changes)
+ * 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.
+ * Resolves once the operation is complete.
+ * Rejects in case of errors.
+ * async getAutoCompeteResults(searchString, params, callback)
+ * Retrieve an array of form history values suitable for display in an autocomplete list.
+ * 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
+ * source
+ * callback - callback that is invoked for each result, the second argument
+ * is a function that can be used to cancel the operation.
+ * Each result is an object with four properties:
+ * text, textLowerCase, frecency, totalScore
+ * Resolves with an array of results, once the operation is complete.
+ * Rejects in case of errors.
+ *
+ * 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.
+ */
+
+export let FormHistory;
+
+import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ Sqlite: "resource://gre/modules/Sqlite.sys.mjs",
+ setTimeout: "resource://gre/modules/Timer.sys.mjs",
+});
+
+const DB_SCHEMA_VERSION = 5;
+const DAY_IN_MS = 86400000; // 1 day in milliseconds
+const MAX_SEARCH_TOKENS = 10;
+const DB_FILENAME = "formhistory.sqlite";
+
+var supportsDeletedTable = AppConstants.platform == "android";
+
+const wait = ms => new Promise(res => lazy.setTimeout(res, ms));
+
+var Prefs = {
+ _initialized: false,
+
+ get(name) {
+ this.ensureInitialized();
+ return this[`_${name}`];
+ },
+
+ ensureInitialized() {
+ if (this._initialized) {
+ return;
+ }
+
+ this._initialized = true;
+
+ this._prefBranch = Services.prefs.getBranch("browser.formfill.");
+ this._prefBranch.addObserver("", this, true);
+
+ this._agedWeight = this._prefBranch.getIntPref("agedWeight");
+ this._boundaryWeight = this._prefBranch.getIntPref("boundaryWeight");
+ this._bucketSize = this._prefBranch.getIntPref("bucketSize");
+ this._debug = this._prefBranch.getBoolPref("debug");
+ this._enabled = this._prefBranch.getBoolPref("enable");
+ this._expireDays = this._prefBranch.getIntPref("expire_days");
+ this._maxTimeGroupings = this._prefBranch.getIntPref("maxTimeGroupings");
+ this._prefixWeight = this._prefBranch.getIntPref("prefixWeight");
+ this._timeGroupingSize =
+ this._prefBranch.getIntPref("timeGroupingSize") * 1000 * 1000;
+ },
+
+ observe(_subject, topic, data) {
+ if (topic == "nsPref:changed") {
+ let prefName = data;
+ log(`got change to ${prefName} preference`);
+
+ switch (prefName) {
+ case "agedWeight":
+ this._agedWeight = this._prefBranch.getIntPref(prefName);
+ break;
+ case "boundaryWeight":
+ this._boundaryWeight = this._prefBranch.getIntPref(prefName);
+ break;
+ case "bucketSize":
+ this._bucketSize = this._prefBranch.getIntPref(prefName);
+ break;
+ case "debug":
+ this._debug = this._prefBranch.getBoolPref(prefName);
+ break;
+ case "enable":
+ this._enabled = this._prefBranch.getBoolPref(prefName);
+ break;
+ case "expire_days":
+ this._expireDays = this._prefBranch.getIntPref("expire_days");
+ break;
+ case "maxTimeGroupings":
+ this._maxTimeGroupings = this._prefBranch.getIntPref(prefName);
+ break;
+ case "prefixWeight":
+ this._prefixWeight = this._prefBranch.getIntPref(prefName);
+ break;
+ case "timeGroupingSize":
+ this._timeGroupingSize =
+ this._prefBranch.getIntPref(prefName) * 1000 * 1000;
+ break;
+ default:
+ log(`Oops! Pref ${prefName} not handled, change ignored.`);
+ break;
+ }
+ }
+ },
+
+ QueryInterface: ChromeUtils.generateQI([
+ "nsIObserver",
+ "nsISupportsWeakReference",
+ ]),
+};
+
+function log(aMessage) {
+ if (Prefs.get("debug")) {
+ Services.console.logStringMessage("FormHistory: " + aMessage);
+ }
+}
+
+function sendNotification(aType, aData) {
+ if (typeof aData == "string") {
+ const strWrapper = Cc["@mozilla.org/supports-string;1"].createInstance(
+ Ci.nsISupportsString
+ );
+ strWrapper.data = aData;
+ aData = strWrapper;
+ } else if (typeof aData == "number") {
+ const 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",
+ },
+ moz_sources: {
+ id: "INTEGER PRIMARY KEY",
+ source: "TEXT NOT NULL",
+ },
+ moz_history_to_sources: {
+ history_id: "INTEGER",
+ source_id: "INTEGER",
+ SQL: `
+ PRIMARY KEY (history_id, source_id),
+ FOREIGN KEY (history_id) REFERENCES moz_formhistory(id) ON DELETE CASCADE,
+ FOREIGN KEY (source_id) REFERENCES moz_sources(id) ON DELETE CASCADE
+ `,
+ },
+ },
+ 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",
+ "firstUsed",
+ "guid",
+ "lastUsed",
+ "source",
+ "timesUsed",
+ "value",
+];
+
+const searchFilters = [
+ "firstUsedStart",
+ "firstUsedEnd",
+ "lastUsedStart",
+ "lastUsedEnd",
+ "source",
+];
+
+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 (const field in aData) {
+ if (field != "op" && !thisValidFields.includes(field)) {
+ throw Components.Exception(
+ `${aDataType} query contains an unrecognized field: ${field}`,
+ Cr.NS_ERROR_ILLEGAL_VALUE
+ );
+ }
+ }
+ return aData;
+}
+
+function validateSearchData(aData, aDataType) {
+ for (const field in aData) {
+ if (
+ field != "op" &&
+ !validFields.includes(field) &&
+ !searchFilters.includes(field)
+ ) {
+ throw Components.Exception(
+ `${aDataType} query contains an unrecognized field: ${field}`,
+ Cr.NS_ERROR_ILLEGAL_VALUE
+ );
+ }
+ }
+}
+
+function makeQueryPredicates(aQueryData, delimiter = " AND ") {
+ const params = {};
+ const queryTerms = Object.keys(aQueryData)
+ .filter(field => aQueryData[field] !== undefined)
+ .map(field => {
+ params[field] = aQueryData[field];
+ switch (field) {
+ case "firstUsedStart":
+ return "firstUsed >= :" + field;
+ case "firstUsedEnd":
+ return "firstUsed <= :" + field;
+ case "lastUsedStart":
+ return "lastUsed >= :" + field;
+ case "lastUsedEnd":
+ return "lastUsed <= :" + field;
+ case "source":
+ return `EXISTS(
+ SELECT 1 FROM moz_history_to_sources
+ JOIN moz_sources s ON s.id = source_id
+ WHERE source = :${field}
+ AND history_id = moz_formhistory.id
+ )`;
+ }
+ return field + " = :" + field;
+ })
+ .join(delimiter);
+ return { queryTerms, params };
+}
+
+function generateGUID() {
+ // string like: "{f60d9eac-9421-4abc-8491-8e8322b063d4}"
+ const uuid = Services.uuid.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++;
+ }
+ const hexVal = parseInt(uuid[i] + uuid[i + 1], 16);
+ raw += String.fromCharCode(hexVal);
+ bytes++;
+ }
+ return btoa(raw);
+}
+
+var Migrators = {
+ // Bug 506402 - Adds deleted form history table.
+ async dbAsyncMigrateToVersion4(conn) {
+ const tableName = "moz_deleted_formhistory";
+ const tableExists = await conn.tableExists(tableName);
+ if (!tableExists) {
+ await createTable(conn, tableName);
+ }
+ },
+
+ // Bug 1654862 - Adds sources and moz_history_to_sources tables.
+ async dbAsyncMigrateToVersion5(conn) {
+ if (!(await conn.tableExists("moz_sources"))) {
+ for (const tableName of ["moz_history_to_sources", "moz_sources"]) {
+ await createTable(conn, tableName);
+ }
+ }
+ },
+};
+
+/**
+ * @typedef {object} InsertQueryData
+ * @property {object} updatedChange
+ * A change requested by FormHistory.
+ * @property {string} query
+ * The insert query string.
+ */
+
+/**
+ * Prepares a query and some default parameters when inserting an entry
+ * to the database.
+ *
+ * @param {object} change
+ * The change requested by FormHistory.
+ * @param {number} now
+ * The current timestamp in microseconds.
+ * @returns {InsertQueryData}
+ * The query information needed to pass along to the database.
+ */
+function prepareInsertQuery(change, now) {
+ const params = {};
+ for (const key of new Set([
+ ...Object.keys(change),
+ // These must always be NOT NULL.
+ "firstUsed",
+ "lastUsed",
+ "timesUsed",
+ ])) {
+ switch (key) {
+ case "fieldname":
+ case "guid":
+ case "value":
+ params[key] = change[key];
+ break;
+ case "firstUsed":
+ case "lastUsed":
+ params[key] = change[key] || now;
+ break;
+ case "timesUsed":
+ params[key] = change[key] || 1;
+ break;
+ default:
+ // Skip unnecessary properties.
+ }
+ }
+
+ return {
+ query: `
+ INSERT INTO moz_formhistory
+ (fieldname, value, timesUsed, firstUsed, lastUsed, guid)
+ VALUES (:fieldname, :value, :timesUsed, :firstUsed, :lastUsed, :guid)`,
+ params,
+ };
+}
+
+// There is a fieldname / value uniqueness constraint that's at this time
+// only enforced at this level. This Map maps fieldnames => values that
+// are in the process of being inserted into the database so that we know
+// not to try to insert the same ones on top. Attempts to do so will be
+// ignored.
+var InProgressInserts = {
+ _inProgress: new Map(),
+
+ add(fieldname, value) {
+ const fieldnameSet = this._inProgress.get(fieldname);
+ if (!fieldnameSet) {
+ this._inProgress.set(fieldname, new Set([value]));
+ return true;
+ }
+
+ if (!fieldnameSet.has(value)) {
+ fieldnameSet.add(value);
+ return true;
+ }
+
+ return false;
+ },
+
+ clear(fieldnamesAndValues) {
+ for (const [fieldname, value] of fieldnamesAndValues) {
+ const fieldnameSet = this._inProgress.get(fieldname);
+ if (fieldnameSet?.delete(value) && fieldnameSet.size == 0) {
+ this._inProgress.delete(fieldname);
+ }
+ }
+ },
+};
+
+function getAddSourceToGuidQueries(source, guid) {
+ return [
+ {
+ query: `INSERT OR IGNORE INTO moz_sources (source) VALUES (:source)`,
+ params: { source },
+ },
+ {
+ query: `
+ INSERT OR IGNORE INTO moz_history_to_sources (history_id, source_id)
+ VALUES(
+ (SELECT id FROM moz_formhistory WHERE guid = :guid),
+ (SELECT id FROM moz_sources WHERE source = :source)
+ )
+ `,
+ params: { guid, source },
+ },
+ ];
+}
+
+/**
+ * Constructs and executes database statements from a pre-processed list of
+ * inputted changes.
+ *
+ * @param {Array.<object>} aChanges changes to form history
+ */
+// XXX This should be split up and the complexity reduced.
+// eslint-disable-next-line complexity
+async function updateFormHistoryWrite(aChanges) {
+ log("updateFormHistoryWrite " + aChanges.length);
+
+ // pass 'now' down so that every entry in the batch has the same timestamp
+ const now = Date.now() * 1000;
+ let queries = [];
+ const notifications = [];
+ const adds = [];
+ const conn = await FormHistory.db;
+
+ for (const change of aChanges) {
+ const operation = change.op;
+ delete change.op;
+ switch (operation) {
+ case "remove": {
+ log("Remove from form history " + change);
+ const { queryTerms, params } = makeQueryPredicates(change);
+
+ // If source is defined, we only remove the source relation, if the
+ // consumer intends to remove the value from everywhere, then they
+ // should not pass source. This gives full control to the caller.
+ if (change.source) {
+ await conn.executeCached(
+ `DELETE FROM moz_history_to_sources
+ WHERE source_id = (
+ SELECT id FROM moz_sources WHERE source = :source
+ )
+ AND history_id = (
+ SELECT id FROM moz_formhistory WHERE ${queryTerms}
+ )
+ `,
+ params
+ );
+ break;
+ }
+
+ // Fetch the GUIDs we are going to delete.
+ try {
+ let query = "SELECT guid FROM moz_formhistory";
+ if (queryTerms) {
+ query += " WHERE " + queryTerms;
+ }
+
+ await conn.executeCached(query, params, row => {
+ notifications.push([
+ "formhistory-remove",
+ row.getResultByName("guid"),
+ ]);
+ });
+ } catch (e) {
+ log("Error getting guids from moz_formhistory: " + e);
+ }
+
+ if (supportsDeletedTable) {
+ log("Moving to deleted table " + change);
+ let query = "INSERT INTO moz_deleted_formhistory (guid, timeDeleted)";
+
+ // TODO: Add these items to the deleted items table once we've sorted
+ // out the issues from bug 756701
+ if (change.guid || queryTerms) {
+ query += change.guid
+ ? " VALUES (:guid, :timeDeleted)"
+ : " SELECT guid, :timeDeleted FROM moz_formhistory WHERE " +
+ queryTerms;
+ queries.push({
+ query,
+ params: Object.assign({ timeDeleted: now }, params),
+ });
+ }
+ }
+
+ let query = "DELETE FROM moz_formhistory";
+ 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.
+ }
+
+ queries.push({ query, params });
+ // Expire orphan sources.
+ queries.push({
+ query: `
+ DELETE FROM moz_sources WHERE id NOT IN (
+ SELECT DISTINCT source_id FROM moz_history_to_sources
+ )`,
+ });
+ break;
+ }
+ case "update": {
+ log("Update form history " + change);
+ const 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;
+ }
+
+ let query = "UPDATE moz_formhistory SET ";
+ let { queryTerms, params } = makeQueryPredicates(change, ", ");
+ if (!queryTerms) {
+ throw Components.Exception(
+ "Update query must define fields to modify.",
+ Cr.NS_ERROR_ILLEGAL_VALUE
+ );
+ }
+ query += queryTerms + " WHERE guid = :existing_guid";
+ queries.push({
+ query,
+ params: Object.assign({ existing_guid: guid }, params),
+ });
+
+ notifications.push(["formhistory-update", guid]);
+
+ // Source is ignored for "update" operations, since it's not really
+ // common to change the source of a value, and anyway currently this is
+ // mostly used to update guids.
+ break;
+ }
+ case "bump": {
+ log("Bump form history " + change);
+ if (change.guid) {
+ const query =
+ "UPDATE moz_formhistory " +
+ "SET timesUsed = timesUsed + 1, lastUsed = :lastUsed WHERE guid = :guid";
+ const queryParams = {
+ lastUsed: now,
+ guid: change.guid,
+ };
+
+ queries.push({ query, params: queryParams });
+ notifications.push(["formhistory-update", change.guid]);
+ } else {
+ if (!InProgressInserts.add(change.fieldname, change.value)) {
+ // This updateFormHistoryWrite call, or a previous one, is already
+ // going to add this fieldname / value pair, so we can ignore this.
+ continue;
+ }
+ adds.push([change.fieldname, change.value]);
+ change.guid = generateGUID();
+ const { query, params } = prepareInsertQuery(change, now);
+ queries.push({ query, params });
+ notifications.push(["formhistory-add", params.guid]);
+ }
+
+ if (change.source) {
+ queries = queries.concat(
+ getAddSourceToGuidQueries(change.source, change.guid)
+ );
+ }
+ break;
+ }
+ case "add": {
+ if (!InProgressInserts.add(change.fieldname, change.value)) {
+ // This updateFormHistoryWrite call, or a previous one, is already
+ // going to add this fieldname / value pair, so we can ignore this.
+ continue;
+ }
+ adds.push([change.fieldname, change.value]);
+
+ log("Add to form history " + change);
+ if (!change.guid) {
+ change.guid = generateGUID();
+ }
+
+ const { query, params } = prepareInsertQuery(change, now);
+ queries.push({ query, params });
+
+ notifications.push(["formhistory-add", params.guid]);
+
+ if (change.source) {
+ queries = queries.concat(
+ getAddSourceToGuidQueries(change.source, 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
+ );
+ }
+ }
+ }
+
+ try {
+ await conn.executeTransaction(async () => {
+ for (const { query, params } of queries) {
+ await conn.executeCached(query, params);
+ }
+ });
+ for (const [notification, param] of notifications) {
+ // We're either sending a GUID or nothing at all.
+ sendNotification(notification, param);
+ }
+ } finally {
+ InProgressInserts.clear(adds);
+ }
+}
+
+/**
+ * Functions that expire entries in form history and shrinks database
+ * afterwards as necessary initiated by expireOldEntries.
+ */
+
+/**
+ * Removes entries from database.
+ *
+ * @param {number} aExpireTime expiration timestamp
+ * @param {number} aBeginningCount numer of entries at first
+ * @returns {Promise} resolved once the work is complete
+ */
+async function expireOldEntriesDeletion(aExpireTime, aBeginningCount) {
+ log(`expireOldEntriesDeletion(${aExpireTime},${aBeginningCount})`);
+
+ await FormHistory.update([
+ {
+ op: "remove",
+ lastUsedEnd: aExpireTime,
+ },
+ ]);
+ await expireOldEntriesVacuum(aExpireTime, aBeginningCount);
+}
+
+/**
+ * Counts number of entries removed and shrinks database as necessary.
+ *
+ * @param {number} aExpireTime expiration timestamp
+ * @param {number} aBeginningCount number of entries at first
+ */
+async function expireOldEntriesVacuum(aExpireTime, aBeginningCount) {
+ const count = await FormHistory.count({});
+ if (aBeginningCount - count > 500) {
+ log("expireOldEntriesVacuum");
+ const conn = await FormHistory.db;
+ await conn.executeCached("VACUUM");
+ }
+ sendNotification("formhistory-expireoldentries", aExpireTime);
+}
+
+async function createTable(conn, tableName) {
+ const table = dbSchema.tables[tableName];
+ const columns = Object.keys(table)
+ .filter(col => col != "SQL")
+ .map(col => [col, table[col]].join(" "))
+ .join(", ");
+ const no_rowid = Object.keys(table).includes("id") ? "" : "WITHOUT ROWID";
+ log(`Creating table ${tableName} with ${columns}`);
+ await conn.execute(
+ `CREATE TABLE ${tableName} (
+ ${columns}
+ ${table.SQL ? "," + table.SQL : ""}
+ ) ${no_rowid}`
+ );
+}
+
+/**
+ * Database creation and access. Used by FormHistory and some of the
+ * utility functions, but is not exposed to the outside world.
+ *
+ * @class
+ */
+var DB = {
+ // Once we establish a database connection, we have to hold a reference
+ // to it so that it won't get GC'd.
+ _instance: null,
+ // MAX_ATTEMPTS is how many times we'll try to establish a connection
+ // or migrate a database before giving up.
+ MAX_ATTEMPTS: 4,
+
+ /** String representing where the FormHistory database is on the filesystem */
+ get path() {
+ return PathUtils.join(PathUtils.profileDir, DB_FILENAME);
+ },
+
+ /**
+ * Sets up and returns a connection to the FormHistory database. The
+ * connection also registers itself with AsyncShutdown so that the
+ * connection is closed on when the profile-before-change observer
+ * notification is fired.
+ *
+ * @returns {Promise<OpenedConnection>}
+ * A {@link toolkit/modules/Sqlite.sys.mjs} connection to the database.
+ * @throws
+ * If connecting to the database, or migrating the database
+ * failed after MAX_ATTEMPTS attempts, this will reject
+ * with the Sqlite.sys.mjs error.
+ */
+ get conn() {
+ delete this.conn;
+ const conn = (async () => {
+ try {
+ this._instance = await this._establishConn();
+ } catch (e) {
+ log("Failed to establish database connection: " + e);
+ throw e;
+ }
+
+ return this._instance;
+ })();
+
+ return (this.conn = conn);
+ },
+
+ // Private functions
+
+ /**
+ * Tries to connect to the Sqlite database at this.path, and then
+ * migrates the database as necessary. If any of the steps to do this
+ * fail, this function should re-enter itself with an incremented
+ * attemptNum so that another attempt can be made after backing up
+ * and deleting the old database.
+ *
+ * @async
+ * @param {number} attemptNum
+ * The optional number of the attempt that is being made to connect
+ * to the database. Defaults to 0.
+ * @returns {Promise<OpenedConnection>}
+ * A {@link toolkit/modules/Sqlite.sys.mjs} connection to the database.
+ * @throws
+ * If connecting to the database, or migrating the database
+ * failed after MAX_ATTEMPTS attempts, this will reject
+ * with the Sqlite.sys.mjs error.
+ */
+ async _establishConn(attemptNum = 0) {
+ log(`Establishing database connection - attempt # ${attemptNum}`);
+ let conn;
+ try {
+ conn = await lazy.Sqlite.openConnection({ path: this.path });
+ lazy.Sqlite.shutdown.addBlocker("Closing FormHistory database.", () =>
+ conn.close()
+ );
+ } catch (e) {
+ // retrying.
+ // If error is a db corruption error, backup the database and create a new one.
+ // Else, use an exponential backoff algorithm to restart up to MAX_ATTEMPTS times.
+ if (attemptNum < this.MAX_ATTEMPTS) {
+ log(`Establishing connection failed due with error ${e.result}`);
+
+ if (e.result === Cr.NS_ERROR_FILE_CORRUPTED) {
+ log("Corrupt database, resetting database");
+ await this._failover(conn);
+ } else {
+ if (conn) {
+ await conn.close();
+ }
+ // retrying with an exponential backoff
+ await wait(2 ** attemptNum * 10);
+ }
+
+ return this._establishConn(++attemptNum);
+ }
+
+ if (conn) {
+ await conn.close();
+ }
+ log("Establishing connection failed too many times. Giving up.");
+ throw e;
+ }
+
+ try {
+ // Enable foreign keys support.
+ await conn.execute("PRAGMA foreign_keys = ON");
+
+ const dbVersion = parseInt(await conn.getSchemaVersion(), 10);
+
+ // Case 1: Database is up to date and we're ready to go.
+ if (dbVersion == DB_SCHEMA_VERSION) {
+ return conn;
+ }
+
+ // Case 2: Downgrade
+ if (dbVersion > 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 (!(await this._expectedColumnsPresent(conn))) {
+ 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).
+ await conn.setSchemaVersion(DB_SCHEMA_VERSION);
+ return conn;
+ }
+
+ // Case 3: Very old database that cannot be migrated.
+ //
+ // When FormHistory is released, we will no longer support the various
+ // schema versions prior to this release that nsIFormHistory2 once did.
+ // We'll throw an NS_ERROR_FILE_CORRUPTED, which should cause us to wipe
+ // out this DB and create a new one (unless this is our MAX_ATTEMPTS
+ // attempt).
+ if (dbVersion > 0 && dbVersion < 3) {
+ throw Components.Exception(
+ "DB version is unsupported.",
+ Cr.NS_ERROR_FILE_CORRUPTED
+ );
+ }
+
+ if (dbVersion == 0) {
+ // Case 4: New database
+ await conn.executeTransaction(async () => {
+ log("Creating DB -- tables");
+ for (const name in dbSchema.tables) {
+ await createTable(conn, name);
+ }
+
+ log("Creating DB -- indices");
+ for (const name in dbSchema.indices) {
+ const index = dbSchema.indices[name];
+ const statement = `CREATE INDEX IF NOT EXISTS ${name} ON ${
+ index.table
+ }(${index.columns.join(", ")})`;
+ await conn.execute(statement);
+ }
+ });
+ } else {
+ // Case 5: Old database requiring a migration
+ await conn.executeTransaction(async () => {
+ for (let v = dbVersion + 1; v <= DB_SCHEMA_VERSION; v++) {
+ log(`Upgrading to version ${v}...`);
+ await Migrators["dbAsyncMigrateToVersion" + v](conn);
+ }
+ });
+ }
+
+ await conn.setSchemaVersion(DB_SCHEMA_VERSION);
+
+ return conn;
+ } catch (e) {
+ if (e.result != Cr.NS_ERROR_FILE_CORRUPTED) {
+ throw e;
+ }
+
+ if (attemptNum < this.MAX_ATTEMPTS) {
+ log("Setting up database failed.");
+ await this._failover(conn);
+ return this._establishConn(++attemptNum);
+ }
+
+ if (conn) {
+ await conn.close();
+ }
+
+ log("Setting up database failed too many times. Giving up.");
+
+ throw e;
+ }
+ },
+
+ /**
+ * Closes a connection to the database, then backs up the database before
+ * deleting it.
+ *
+ * @async
+ * @param {SqliteConnection | null} conn
+ * The connection to the database that we failed to establish or
+ * migrate.
+ * @throws If any file operations fail.
+ */
+ async _failover(conn) {
+ log("Cleaning up DB file - close & remove & backup.");
+ if (conn) {
+ await conn.close();
+ }
+ const backupFile = this.path + ".corrupt";
+ const uniquePath = await IOUtils.createUniqueFile(
+ PathUtils.parent(backupFile),
+ PathUtils.filename(backupFile),
+ 0o600
+ );
+ await IOUtils.copy(this.path, uniquePath);
+ await IOUtils.remove(this.path);
+ log("Completed DB cleanup.");
+ },
+
+ /**
+ * Tests that a database connection contains the tables that we expect.
+ *
+ * @async
+ * @param {SqliteConnection | null} conn
+ * The connection to the database that we're testing.
+ * @returns {Promise<boolean>} true if all expected columns are present.
+ */
+ async _expectedColumnsPresent(conn) {
+ for (const name in dbSchema.tables) {
+ const table = dbSchema.tables[name];
+ const columns = Object.keys(table).filter(col => col != "SQL");
+ const query = `SELECT ${columns.join(", ")} FROM ${name}`;
+ try {
+ await conn.execute(query, null, (_row, cancel) => {
+ // One row is enough to let us know this worked.
+ cancel();
+ });
+ } catch (e) {
+ return false;
+ }
+ }
+
+ log("Verified that expected columns are present in DB.");
+ return true;
+ },
+};
+
+FormHistory = {
+ get db() {
+ return DB.conn;
+ },
+
+ get enabled() {
+ return Prefs.get("enabled");
+ },
+
+ async search(aSelectTerms, aSearchData, aRowFunc) {
+ // if no terms selected, select everything
+ if (!aSelectTerms) {
+ // Source is not a valid column in moz_formhistory.
+ aSelectTerms = validFields.filter(f => f != "source");
+ }
+
+ validateSearchData(aSearchData, "Search");
+
+ let query = `SELECT ${aSelectTerms.join(", ")} FROM moz_formhistory`;
+ const { queryTerms, params } = makeQueryPredicates(aSearchData);
+ if (queryTerms) {
+ query += " WHERE " + queryTerms;
+ }
+
+ const allResults = [];
+
+ const conn = await this.db;
+ await conn.executeCached(query, params, row => {
+ const result = {};
+ for (const field of aSelectTerms) {
+ result[field] = row.getResultByName(field);
+ }
+ aRowFunc?.(result);
+ allResults.push(result);
+ });
+
+ return allResults;
+ },
+
+ async count(aSearchData) {
+ validateSearchData(aSearchData, "Count");
+
+ let query = "SELECT COUNT(*) AS numEntries FROM moz_formhistory";
+ const { queryTerms, params } = makeQueryPredicates(aSearchData);
+ if (queryTerms) {
+ query += " WHERE " + queryTerms;
+ }
+
+ const conn = await this.db;
+ const rows = await conn.executeCached(query, params);
+ return rows[0].getResultByName("numEntries");
+ },
+
+ async update(aChanges) {
+ function validIdentifier(change) {
+ // The identifier is only valid if one of either the guid
+ // or the (fieldname/value) are set (so an X-OR)
+ return Boolean(change.guid) != Boolean(change.fieldname && change.value);
+ }
+
+ if (!("length" in aChanges)) {
+ aChanges = [aChanges];
+ }
+
+ const isRemoveOperation = aChanges.every(change => change?.op == "remove");
+ if (!this.enabled && !isRemoveOperation) {
+ throw new Error(
+ "Form history is disabled, only remove operations are allowed"
+ );
+ }
+
+ for (const 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.fieldname && change.value) {
+ validateOpData(change, "Add");
+ } else {
+ throw Components.Exception(
+ "update op='add' must have a fieldname and a value.",
+ Cr.NS_ERROR_ILLEGAL_VALUE
+ );
+ }
+ break;
+ default:
+ throw Components.Exception(
+ "update does not recognize op='" + change.op + "'",
+ Cr.NS_ERROR_ILLEGAL_VALUE
+ );
+ }
+
+ const results = await FormHistory.search(["guid"], {
+ fieldname: change.fieldname,
+ value: change.value,
+ });
+ if (results.length > 1) {
+ const error =
+ "Database contains multiple entries with the same fieldname/value pair.";
+ log(error);
+ throw new Error(error);
+ }
+ change.guid = results[0]?.guid;
+ }
+
+ await updateFormHistoryWrite(aChanges);
+ },
+
+ /**
+ * Gets results for the autocomplete widget.
+ *
+ * @param {string} searchString The string to search for.
+ * @param {object} params zero or more filter properties:
+ * - fieldname
+ * - source
+ * @param {Function} [isCancelled] optional function that can return true
+ * to cancel result retrieval
+ * @returns {Promise<Array>}
+ * An array of results. If the search was canceled it will be an empty array.
+ */
+ async getAutoCompleteResults(searchString, params, isCancelled) {
+ // only do substring matching when the search string contains more than one character
+ let searchTokens;
+ let where = "";
+ let boundaryCalc = "";
+
+ params = {
+ agedWeight: Prefs.get("agedWeight"),
+ bucketSize: Prefs.get("bucketSize"),
+ expiryDate:
+ 1000 * (Date.now() - Prefs.get("expireDays") * 24 * 60 * 60 * 1000),
+ maxTimeGroupings: Prefs.get("maxTimeGroupings"),
+ timeGroupingSize: Prefs.get("timeGroupingSize"),
+ prefixWeight: Prefs.get("prefixWeight"),
+ boundaryWeight: Prefs.get("boundaryWeight"),
+ ...params,
+ };
+
+ if (searchString.length >= 1) {
+ params.valuePrefix = searchString.replaceAll("/", "//") + "%";
+ }
+
+ 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++) {
+ let escapedToken = searchTokens[i].replaceAll("/", "//");
+ params["tokenBegin" + i] = escapedToken + "%";
+ params["tokenBoundary" + i] = "% " + escapedToken + "%";
+ params["tokenContains" + i] = "%" + escapedToken + "%";
+
+ 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
+
+ if (params.source) {
+ where += `AND EXISTS(
+ SELECT 1 FROM moz_history_to_sources
+ JOIN moz_sources s ON s.id = source_id
+ WHERE source = :source
+ AND history_id = moz_formhistory.id
+ )`;
+ }
+
+ /* 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.
+ */
+
+ const query =
+ "/* do not warn (bug 496471): can't use an index */ " +
+ "SELECT value, guid, " +
+ "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 results = [];
+ const conn = await this.db;
+ await conn.executeCached(query, params, (row, cancel) => {
+ if (isCancelled?.()) {
+ cancel();
+ results = [];
+ return;
+ }
+
+ const value = row.getResultByName("value");
+ const guid = row.getResultByName("guid");
+ const frecency = row.getResultByName("frecency");
+ const entry = {
+ text: value,
+ guid,
+ textLowerCase: value.toLowerCase(),
+ frecency,
+ totalScore: Math.round(
+ frecency * row.getResultByName("boundaryBonuses")
+ ),
+ };
+ results.push(entry);
+ });
+ return results;
+ },
+
+ // 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
+ async expireOldEntries() {
+ log("expireOldEntries");
+
+ // Determine how many days of history we're supposed to keep.
+ // Calculate expireTime in microseconds
+ const expireTime =
+ (Date.now() - Prefs.get("expireDays") * DAY_IN_MS) * 1000;
+
+ sendNotification("formhistory-beforeexpireoldentries", expireTime);
+
+ const count = await FormHistory.count({});
+ await expireOldEntriesDeletion(expireTime, count);
+ },
+};
+
+// Prevent add-ons from redefining this API
+Object.freeze(FormHistory);
diff --git a/toolkit/components/satchel/FormHistoryChild.sys.mjs b/toolkit/components/satchel/FormHistoryChild.sys.mjs
new file mode 100644
index 0000000000..242d8f2e29
--- /dev/null
+++ b/toolkit/components/satchel/FormHistoryChild.sys.mjs
@@ -0,0 +1,139 @@
+/* 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/. */
+
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ CreditCard: "resource://gre/modules/CreditCard.sys.mjs",
+ PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs",
+});
+
+XPCOMUtils.defineLazyPreferenceGetter(lazy, "gDebug", "browser.formfill.debug");
+XPCOMUtils.defineLazyPreferenceGetter(
+ lazy,
+ "gEnabled",
+ "browser.formfill.enable"
+);
+XPCOMUtils.defineLazyServiceGetter(
+ lazy,
+ "gFormFillService",
+ "@mozilla.org/satchel/form-fill-controller;1",
+ "nsIFormFillController"
+);
+
+function log(message) {
+ if (!lazy.gDebug) {
+ return;
+ }
+ dump("satchelFormListener: " + message + "\n");
+ Services.console.logStringMessage("satchelFormListener: " + message);
+}
+
+export class FormHistoryChild extends JSWindowActorChild {
+ handleEvent(event) {
+ switch (event.type) {
+ case "DOMFormBeforeSubmit":
+ this.#onDOMFormBeforeSubmit(event.target);
+ break;
+ default:
+ throw new Error("Unexpected event");
+ }
+ }
+
+ #onDOMFormBeforeSubmit(form) {
+ if (
+ !lazy.gEnabled ||
+ lazy.PrivateBrowsingUtils.isContentWindowPrivate(form.ownerGlobal)
+ ) {
+ return;
+ }
+
+ log("Form submit observer notified.");
+
+ if (form.getAttribute("autocomplete")?.toLowerCase() == "off") {
+ return;
+ }
+
+ const entries = [];
+ for (const input of form.elements) {
+ if (!HTMLInputElement.isInstance(input)) {
+ continue;
+ }
+
+ // Only use inputs that hold text values (not including type="password")
+ if (!input.mozIsTextField(true)) {
+ continue;
+ }
+
+ // Don't save fields that were previously type=password such as on sites
+ // that allow the user to toggle password visibility.
+ if (input.hasBeenTypePassword) {
+ continue;
+ }
+
+ // Bug 1780571, Bug 394612: If Login Manager marked this input, don't save it.
+ // The login manager will deal with remembering it.
+ if (lazy.gFormFillService.isLoginManagerField(input)) {
+ continue;
+ }
+
+ // Don't save values when @autocomplete is "off" or has a sensitive field name.
+ const autocompleteInfo = input.getAutocompleteInfo();
+ if (autocompleteInfo?.canAutomaticallyPersist === false) {
+ continue;
+ }
+
+ const value = input.lastInteractiveValue?.trim();
+
+ // Only save user entered values even if they match the default value.
+ // Any script input is ignored.
+ // See Bug 1642570 for details.
+ if (!value) {
+ continue;
+ }
+
+ // Save only when user input was last.
+ if (value != input.value.trim()) {
+ continue;
+ }
+
+ // Don't save credit card numbers.
+ if (lazy.CreditCard.isValidNumber(value)) {
+ log("skipping saving a credit card number");
+ continue;
+ }
+
+ const name = input.name || input.id;
+ if (!name) {
+ continue;
+ }
+
+ if (name == "searchbar-history") {
+ log('addEntry for input name "' + name + '" is denied');
+ continue;
+ }
+
+ // Limit stored data to 200 characters.
+ if (name.length > 200 || value.length > 200) {
+ log("skipping input that has a name/value too large");
+ continue;
+ }
+
+ entries.push({ name, value });
+
+ // Limit number of fields stored per form.
+ if (entries.length >= 100) {
+ log("not saving any more entries for this form.");
+ break;
+ }
+ }
+
+ if (entries.length) {
+ log("sending entries to parent process for form " + form.id);
+ this.sendAsyncMessage("FormHistory:FormSubmitEntries", entries);
+ }
+ }
+}
diff --git a/toolkit/components/satchel/FormHistoryParent.sys.mjs b/toolkit/components/satchel/FormHistoryParent.sys.mjs
new file mode 100644
index 0000000000..8fe943566e
--- /dev/null
+++ b/toolkit/components/satchel/FormHistoryParent.sys.mjs
@@ -0,0 +1,107 @@
+/* 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/. */
+
+import { FirefoxRelayTelemetry } from "resource://gre/modules/FirefoxRelayTelemetry.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ FirefoxRelay: "resource://gre/modules/FirefoxRelay.sys.mjs",
+ FormHistory: "resource://gre/modules/FormHistory.sys.mjs",
+ LoginHelper: "resource://gre/modules/LoginHelper.sys.mjs",
+});
+
+export class FormHistoryParent extends JSWindowActorParent {
+ receiveMessage({ name, data }) {
+ switch (name) {
+ case "FormHistory:FormSubmitEntries":
+ this.#onFormSubmitEntries(data);
+ break;
+
+ case "FormHistory:AutoCompleteSearchAsync":
+ return this.#onAutoCompleteSearch(data);
+
+ case "FormHistory:RemoveEntry":
+ this.#onRemoveEntry(data);
+ break;
+
+ case "PasswordManager:offerRelayIntegration": {
+ FirefoxRelayTelemetry.recordRelayOfferedEvent(
+ "clicked",
+ data.telemetry.flowId,
+ data.telemetry.scenarioName
+ );
+ return this.#offerRelayIntegration();
+ }
+
+ case "PasswordManager:generateRelayUsername": {
+ FirefoxRelayTelemetry.recordRelayUsernameFilledEvent(
+ "clicked",
+ data.telemetry.flowId
+ );
+ return this.#generateRelayUsername();
+ }
+ }
+
+ return undefined;
+ }
+
+ #onFormSubmitEntries(entries) {
+ const changes = entries.map(entry => ({
+ op: "bump",
+ fieldname: entry.name,
+ value: entry.value,
+ }));
+
+ lazy.FormHistory.update(changes);
+ }
+
+ get formOrigin() {
+ return lazy.LoginHelper.getLoginOrigin(
+ this.manager.documentPrincipal?.originNoSuffix
+ );
+ }
+
+ async #onAutoCompleteSearch({ searchString, params, scenarioName }) {
+ const formHistoryPromise = lazy.FormHistory.getAutoCompleteResults(
+ searchString,
+ params
+ );
+
+ const relayPromise = lazy.FirefoxRelay.autocompleteItemsAsync({
+ formOrigin: this.formOrigin,
+ scenarioName,
+ hasInput: !!searchString.length,
+ });
+ const [formHistoryEntries, externalEntries] = await Promise.all([
+ formHistoryPromise,
+ relayPromise,
+ ]);
+
+ return { formHistoryEntries, externalEntries };
+ }
+
+ #onRemoveEntry({ inputName, value, guid }) {
+ lazy.FormHistory.update({
+ op: "remove",
+ fieldname: inputName,
+ value,
+ guid,
+ });
+ }
+
+ getRootBrowser() {
+ return this.browsingContext.topFrameElement;
+ }
+
+ async #offerRelayIntegration() {
+ const browser = this.getRootBrowser();
+ return lazy.FirefoxRelay.offerRelayIntegration(browser, this.formOrigin);
+ }
+
+ async #generateRelayUsername() {
+ const browser = this.getRootBrowser();
+ return lazy.FirefoxRelay.generateUsername(browser, this.formOrigin);
+ }
+}
diff --git a/toolkit/components/satchel/FormHistoryStartup.sys.mjs b/toolkit/components/satchel/FormHistoryStartup.sys.mjs
new file mode 100644
index 0000000000..104756c583
--- /dev/null
+++ b/toolkit/components/satchel/FormHistoryStartup.sys.mjs
@@ -0,0 +1,99 @@
+/* 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 lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ FormHistory: "resource://gre/modules/FormHistory.sys.mjs",
+});
+
+export class FormHistoryStartup {
+ classID = Components.ID("{3A0012EB-007F-4BB8-AA81-A07385F77A25}");
+
+ QueryInterface = ChromeUtils.generateQI([
+ "nsIObserver",
+ "nsISupportsWeakReference",
+ ]);
+
+ observe(_subject, topic, _data) {
+ switch (topic) {
+ case "idle-daily":
+ case "formhistory-expire-now":
+ lazy.FormHistory.expireOldEntries().catch(console.error);
+ break;
+ case "profile-after-change":
+ this.init();
+ break;
+ }
+ }
+
+ init() {
+ if (this.inited) {
+ return;
+ }
+ this.inited = true;
+
+ // triggers needed service cleanup and db shutdown
+ Services.obs.addObserver(this, "idle-daily", true);
+ Services.obs.addObserver(this, "formhistory-expire-now", true);
+
+ Services.ppmm.addMessageListener(
+ "FormHistory:AutoCompleteSearchAsync",
+ this
+ );
+ Services.ppmm.addMessageListener("FormHistory:RemoveEntry", this);
+ }
+
+ receiveMessage(message) {
+ switch (message.name) {
+ case "FormHistory:AutoCompleteSearchAsync":
+ this.#onFormHistoryAutoCompleteSearchAsync({
+ ...message.data,
+ target: message.target,
+ });
+ break;
+
+ case "FormHistory:RemoveEntry":
+ this.#onFormHistoryRemoveEntry(message.data);
+ break;
+ }
+ }
+
+ async #onFormHistoryAutoCompleteSearchAsync({
+ id,
+ searchString,
+ params,
+ target,
+ }) {
+ // This case is only used for the search field. There is a
+ // similar algorithm in FormHistoryParent.jsm that uses
+ // sendQuery for other form fields.
+
+ const instance = (this._queryInstance = {});
+ const formHistoryEntries = await lazy.FormHistory.getAutoCompleteResults(
+ searchString,
+ params,
+ () => this._queryInstance != instance
+ );
+
+ if (this._queryInstance == instance) {
+ target.sendAsyncMessage("FormHistory:AutoCompleteSearchResults", {
+ id,
+ results: {
+ formHistoryEntries,
+ externalEntries: [],
+ },
+ });
+ }
+ }
+
+ #onFormHistoryRemoveEntry({ inputName, value, guid }) {
+ lazy.FormHistory.update({
+ op: "remove",
+ fieldname: inputName,
+ value,
+ guid,
+ });
+ }
+}
diff --git a/toolkit/components/satchel/FormScenarios.sys.mjs b/toolkit/components/satchel/FormScenarios.sys.mjs
new file mode 100644
index 0000000000..505b5443ec
--- /dev/null
+++ b/toolkit/components/satchel/FormScenarios.sys.mjs
@@ -0,0 +1,87 @@
+/* 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/. */
+
+import { FormLikeFactory } from "resource://gre/modules/FormLikeFactory.sys.mjs";
+import { SignUpFormRuleset } from "resource://gre/modules/SignUpFormRuleset.sys.mjs";
+import { FirefoxRelayUtils } from "resource://gre/modules/FirefoxRelayUtils.sys.mjs";
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+export class FormScenarios {
+ /**
+ * Caches the scores when running the SignUpFormRuleset against a form
+ */
+ static #cachedSignUpFormScore = new WeakMap();
+
+ /**
+ * Detect usage scenarios of the form.
+ *
+ * @param {object} options named options
+ * @param {HTMLInputElement} [options.input] where current focus is
+ * @param {FormLike} [options.form]
+ *
+ * @returns {Array<string>} detected scenario names
+ */
+ static detect({ input, form }) {
+ const scenarios = {};
+
+ if (!FormScenarios.signupDetectionEnabled) {
+ return scenarios;
+ }
+
+ // Running simple heuristics first, because running the SignUpFormRuleset is expensive
+ if (
+ input &&
+ // At the moment Relay integration is the only interested party in "sign up form",
+ // so we optimize a bit by checking if it's enabled or not.
+ FirefoxRelayUtils.isRelayInterestedField(input)
+ ) {
+ form ??= FormLikeFactory.findRootForField(input);
+
+ scenarios.signUpForm = FormScenarios.#isProbablyASignUpForm(form);
+ }
+
+ return scenarios;
+ }
+
+ /**
+ * Determine if the form is a sign-up form.
+ * This is done by running the rules of the Fathom SignUpFormRuleset against the form and calucating a score between 0 and 1.
+ * It's considered a sign-up form, if the score is higher than the confidence threshold (default=0.75)
+ *
+ * @param {HTMLFormElement} formElement
+ * @returns {boolean} returns true if the calculcated score is higher than the confidenceThreshold
+ */
+ static #isProbablyASignUpForm(formElement) {
+ let score = FormScenarios.#cachedSignUpFormScore.get(formElement);
+ if (!score) {
+ TelemetryStopwatch.start("PWMGR_SIGNUP_FORM_DETECTION_MS");
+ try {
+ const { rules, type } = SignUpFormRuleset;
+ const results = rules.against(formElement);
+ score = results.get(formElement).scoreFor(type);
+ TelemetryStopwatch.finish("PWMGR_SIGNUP_FORM_DETECTION_MS");
+ } finally {
+ if (TelemetryStopwatch.running("PWMGR_SIGNUP_FORM_DETECTION_MS")) {
+ TelemetryStopwatch.cancel("PWMGR_SIGNUP_FORM_DETECTION_MS");
+ }
+ }
+ FormScenarios.#cachedSignUpFormScore.set(formElement, score);
+ }
+
+ const threshold = FormScenarios.signupDetectionConfidenceThreshold;
+ return score > threshold;
+ }
+}
+XPCOMUtils.defineLazyPreferenceGetter(
+ FormScenarios,
+ "signupDetectionConfidenceThreshold",
+ "signon.signupDetection.confidenceThreshold",
+ "0.75"
+);
+XPCOMUtils.defineLazyPreferenceGetter(
+ FormScenarios,
+ "signupDetectionEnabled",
+ "signon.signupDetection.enabled",
+ true
+);
diff --git a/toolkit/components/satchel/SignUpFormRuleset.sys.mjs b/toolkit/components/satchel/SignUpFormRuleset.sys.mjs
new file mode 100644
index 0000000000..7a42880fdb
--- /dev/null
+++ b/toolkit/components/satchel/SignUpFormRuleset.sys.mjs
@@ -0,0 +1,589 @@
+/* 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/. */
+
+/**
+ * Fathom ML model for identifying sign up <forms>
+ *
+ * This is developed out-of-tree at https://github.com/mozilla-services/fathom-login-forms,
+ * where there is also over a GB of training, validation, and
+ * testing data. To make changes, do your edits there (whether adding new
+ * training pages, adding new rules, or both), retrain and evaluate as
+ * documented at https://mozilla.github.io/fathom/training.html, paste the
+ * coefficients emitted by the trainer into the ruleset, and finally copy the
+ * ruleset's "CODE TO COPY INTO PRODUCTION" section to this file's "CODE FROM
+ * TRAINING REPOSITORY" section.
+ */
+
+import {
+ dom,
+ out,
+ rule,
+ ruleset,
+ score,
+ type,
+ element,
+ utils,
+} from "resource://gre/modules/third_party/fathom/fathom.mjs";
+
+let { isVisible, attributesMatch, setDefault } = utils;
+
+const DEVELOPMENT = false;
+
+/**
+ * --- START OF CODE FROM TRAINING REPOSITORY ---
+ */
+const coefficients = {
+ form: new Map([
+ ["formAttributesMatchRegisterRegex", 0.4614015519618988],
+ ["formAttributesMatchLoginRegex", -2.608457326889038],
+ ["formAttributesMatchSubscriptionRegex", -3.253319501876831],
+ ["formAttributesMatchLoginAndRegisterRegex", 3.6423728466033936],
+ ["formHasAcNewPassword", 2.214113473892212],
+ ["formHasAcCurrentPassword", -0.43707895278930664],
+ ["formHasEmailField", 1.760241150856018],
+ ["formHasUsernameField", 1.1527059078216553],
+ ["formHasPasswordField", 1.6670876741409302],
+ ["formHasFirstOrLastNameField", 0.9517516493797302],
+ ["formHasRegisterButton", 1.574048638343811],
+ ["formHasLoginButton", -1.1688978672027588],
+ ["formHasSubscribeButton", -0.26299405097961426],
+ ["formHasContinueButton", 2.3797709941864014],
+ ["formHasTermsAndConditionsHyperlink", 1.764896035194397],
+ ["formHasPasswordForgottenHyperlink", -0.32138824462890625],
+ ["formHasAlreadySignedUpHyperlink", 3.160510301589966],
+ ["closestElementIsEmailLabelLike", 1.0336143970489502],
+ ["formHasRememberMeCheckbox", -1.2176686525344849],
+ ["formHasSubcriptionCheckbox", 0.6100747585296631],
+ ["docTitleMatchesRegisterRegex", 0.680654764175415],
+ ["docTitleMatchesEditProfileRegex", -4.104133605957031],
+ ["closestHeaderMatchesRegisterRegex", 1.3462989330291748],
+ ["closestHeaderMatchesLoginRegex", -0.1804502159357071],
+ ["closestHeaderMatchesSubscriptionRegex", -1.3057124614715576],
+ ]),
+};
+
+const biases = [["form", -4.402400970458984]];
+
+const loginRegex =
+ /login|log-in|log_in|log in|signon|sign-on|sign_on|sign on|signin|sign-in|sign_in|sign in|einloggen|anmelden|logon|log-on|log_on|log on|Войти|ورود|登录|Přihlásit se|Přihlaste|Авторизоваться|Авторизация|entrar|ログイン|로그인|inloggen|Συνδέσου|accedi|ログオン|Giriş Yap|登入|connecter|connectez-vous|Connexion|Вход|inicia/i;
+const registerRegex =
+ /regist|sign up|signup|sign-up|sign_up|join|new|登録|neu|erstellen|設定|신규|Créer|Nouveau|baru|nouă|nieuw|create[a-zA-Z\s]+account|create[a-zA-Z\s]+profile|activate[a-zA-Z\s]+account|Zugang anlegen|Angaben prüfen|Konto erstellen|ثبت نام|登録|注册|cadastr|Зарегистрироваться|Регистрация|Bellige alynmak|تسجيل|ΕΓΓΡΑΦΗΣ|Εγγραφή|Créer mon compte|Créer un compte|Mendaftar|가입하기|inschrijving|Zarejestruj się|Deschideți un cont|Создать аккаунт|ร่วม|Üye Ol|ساخت حساب کاربری|Schrijf je|S'inscrire/i;
+const emailRegex = /mail/i;
+const usernameRegex = /user|member/i;
+const nameRegex = /first|last|middle/i;
+const subscriptionRegex =
+ /subscri|trial|offer|information|angebote|probe|ニュースレター|abonn|promotion|news/i;
+const termsAndConditionsRegex =
+ /terms|condition|rules|policy|privacy|nutzungsbedingungen|AGB|richtlinien|datenschutz|términos|condiciones/i;
+const pwForgottenRegex =
+ /forgot|reset|set password|vergessen|vergeten|oublié|dimenticata|Esqueceu|esqueci|Забыли|忘记|找回|Zapomenuté|lost|忘れた|忘れられた|忘れの方|재설정|찾기|help|فراموشی| را فراموش کرده اید|Восстановить|Unuttu|perdus|重新設定|recover|remind|request|restore|trouble|olvidada/i;
+const continueRegex =
+ /continue|go on|weiter|fortfahren|ga verder|next|continuar/i;
+const rememberMeRegex =
+ /remember|stay|speichern|merken|bleiben|auto_login|auto-login|auto login|ricordami|manter|mantenha|savelogin|keep me logged in|keep me signed in|save email address|save id|stay signed in|次回からログオンIDの入力を省略する|メールアドレスを保存する|を保存|아이디저장|아이디 저장|로그인 상태 유지|lembrar|mantenha-me conectado|Запомни меня|запомнить меня|Запомните меня|Не спрашивать в следующий раз|下次自动登录|记住我|recordar|angemeldet bleiben/i;
+const alreadySignedUpRegex = /already|bereits|schon|ya tienes cuenta/i;
+const editProfile = /edit/i;
+
+function createRuleset(coeffs, biases) {
+ let descendantsCache;
+ let surroundingNodesCache;
+
+ /**
+ * Check document characteristics
+ */
+ function docTitleMatchesRegisterRegex(fnode) {
+ const docTitle = fnode.element.ownerDocument.title;
+ return checkValueAgainstRegex(docTitle, registerRegex);
+ }
+ function docTitleMatchesEditProfileRegex(fnode) {
+ const docTitle = fnode.element.ownerDocument.title;
+ return checkValueAgainstRegex(docTitle, editProfile);
+ }
+
+ /**
+ * Check header
+ */
+ function closestHeaderMatchesLoginRegex(fnode) {
+ return closestHeaderMatchesPredicate(fnode.element, header =>
+ checkValueAgainstRegex(header.innerText, loginRegex)
+ );
+ }
+ function closestHeaderMatchesRegisterRegex(fnode) {
+ return closestHeaderMatchesPredicate(fnode.element, header =>
+ checkValueAgainstRegex(header.innerText, registerRegex)
+ );
+ }
+ function closestHeaderMatchesSubscriptionRegex(fnode) {
+ return closestHeaderMatchesPredicate(fnode.element, header =>
+ checkValueAgainstRegex(header.innerText, subscriptionRegex)
+ );
+ }
+
+ /**
+ * Check checkboxes
+ */
+ function formHasRememberMeCheckbox(fnode) {
+ return elementHasRegexMatchingCheckbox(fnode.element, rememberMeRegex);
+ }
+ function formHasSubcriptionCheckbox(fnode) {
+ return elementHasRegexMatchingCheckbox(fnode.element, subscriptionRegex);
+ }
+
+ /**
+ * Check input fields
+ */
+ function formHasFirstOrLastNameField(fnode) {
+ const acValues = ["name", "given-name", "family-name"];
+ return elementHasPredicateMatchingInput(
+ fnode.element,
+ elem =>
+ atLeastOne(acValues.filter(ac => elem.autocomplete == ac)) ||
+ inputFieldMatchesPredicate(elem, attr =>
+ checkValueAgainstRegex(attr, nameRegex)
+ )
+ );
+ }
+ function formHasEmailField(fnode) {
+ return elementHasPredicateMatchingInput(
+ fnode.element,
+ elem =>
+ elem.autocomplete == "email" ||
+ elem.type == "email" ||
+ inputFieldMatchesPredicate(elem, attr =>
+ checkValueAgainstRegex(attr, emailRegex)
+ )
+ );
+ }
+ function formHasUsernameField(fnode) {
+ return elementHasPredicateMatchingInput(
+ fnode.element,
+ elem =>
+ elem.autocomplete == "username" ||
+ inputFieldMatchesPredicate(elem, attr =>
+ checkValueAgainstRegex(attr, usernameRegex)
+ )
+ );
+ }
+ function formHasPasswordField(fnode) {
+ const acValues = ["current-password", "new-password"];
+ return elementHasPredicateMatchingInput(
+ fnode.element,
+ elem =>
+ atLeastOne(acValues.filter(ac => elem.autocomplete == ac)) ||
+ elem.type == "password"
+ );
+ }
+
+ /**
+ * Check autocomplete values
+ */
+ function formHasAcCurrentPassword(fnode) {
+ return inputFieldMatchesSelector(
+ fnode.element,
+ "autocomplete=current-password"
+ );
+ }
+ function formHasAcNewPassword(fnode) {
+ return inputFieldMatchesSelector(
+ fnode.element,
+ "autocomplete=new-password"
+ );
+ }
+
+ /**
+ * Check hyperlinks within form
+ */
+ function formHasTermsAndConditionsHyperlink(fnode) {
+ return elementHasPredicateMatchingHyperlink(
+ fnode.element,
+ termsAndConditionsRegex
+ );
+ }
+ function formHasPasswordForgottenHyperlink(fnode) {
+ return elementHasPredicateMatchingHyperlink(
+ fnode.element,
+ pwForgottenRegex
+ );
+ }
+ function formHasAlreadySignedUpHyperlink(fnode) {
+ return elementHasPredicateMatchingHyperlink(
+ fnode.element,
+ alreadySignedUpRegex
+ );
+ }
+
+ /**
+ * Check labels
+ */
+ function closestElementIsEmailLabelLike(fnode) {
+ return elementHasPredicateMatchingInput(fnode.element, elem =>
+ previousSiblingLabelMatchesRegex(elem, emailRegex)
+ );
+ }
+
+ /**
+ * Check buttons
+ */
+ function formHasRegisterButton(fnode) {
+ return elementHasPredicateMatchingButton(
+ fnode.element,
+ button =>
+ checkValueAgainstRegex(button.innerText, registerRegex) ||
+ buttonMatchesPredicate(button, attr =>
+ checkValueAgainstRegex(attr, registerRegex)
+ )
+ );
+ }
+ function formHasLoginButton(fnode) {
+ return elementHasPredicateMatchingButton(
+ fnode.element,
+ button =>
+ checkValueAgainstRegex(button.innerText, loginRegex) ||
+ buttonMatchesPredicate(button, attr =>
+ checkValueAgainstRegex(attr, loginRegex)
+ )
+ );
+ }
+ function formHasContinueButton(fnode) {
+ return elementHasPredicateMatchingButton(
+ fnode.element,
+ button =>
+ checkValueAgainstRegex(button.innerText, continueRegex) ||
+ buttonMatchesPredicate(button, attr =>
+ checkValueAgainstRegex(attr, continueRegex)
+ )
+ );
+ }
+ function formHasSubscribeButton(fnode) {
+ return elementHasPredicateMatchingButton(
+ fnode.element,
+ button =>
+ checkValueAgainstRegex(button.innerText, subscriptionRegex) ||
+ buttonMatchesPredicate(button, attr =>
+ checkValueAgainstRegex(attr, subscriptionRegex)
+ )
+ );
+ }
+
+ /**
+ * Check form attributes
+ */
+ function formAttributesMatchRegisterRegex(fnode) {
+ return formMatchesPredicate(fnode.element, attr =>
+ checkValueAgainstRegex(attr, registerRegex)
+ );
+ }
+ function formAttributesMatchLoginRegex(fnode) {
+ return formMatchesPredicate(fnode.element, attr =>
+ checkValueAgainstRegex(attr, loginRegex)
+ );
+ }
+ function formAttributesMatchSubscriptionRegex(fnode) {
+ return formMatchesPredicate(fnode.element, attr =>
+ checkValueAgainstRegex(attr, subscriptionRegex)
+ );
+ }
+ function formAttributesMatchLoginAndRegisterRegex(fnode) {
+ return formMatchesPredicate(fnode.element, attr =>
+ checkValueAgainstAllRegex(attr, [registerRegex, loginRegex])
+ );
+ }
+
+ /**
+ * HELPER FUNCTIONS
+ */
+ function elementMatchesPredicate(element, predicate, additional = []) {
+ return attributesMatch(
+ element,
+ predicate,
+ ["id", "name", "className"].concat(additional)
+ );
+ }
+ function formMatchesPredicate(element, predicate) {
+ return elementMatchesPredicate(element, predicate, ["action"]);
+ }
+ function inputFieldMatchesPredicate(element, predicate) {
+ return elementMatchesPredicate(element, predicate, ["placeholder"]);
+ }
+ function inputFieldMatchesSelector(element, selector) {
+ return atLeastOne(getElementDescendants(element, `input[${selector}]`));
+ }
+ function buttonMatchesPredicate(element, predicate) {
+ return elementMatchesPredicate(element, predicate, [
+ "value",
+ "id",
+ "title",
+ ]);
+ }
+ function elementHasPredicateMatchingDescendant(element, selector, predicate) {
+ const matchingElements = getElementDescendants(element, selector);
+ return matchingElements.some(predicate);
+ }
+ function elementHasPredicateMatchingHeader(element, predicate) {
+ return (
+ elementHasPredicateMatchingDescendant(
+ element,
+ "h1,h2,h3,h4,h5,h6",
+ predicate
+ ) ||
+ elementHasPredicateMatchingDescendant(
+ element,
+ "div[class*=heading],div[class*=header],div[class*=title],header",
+ predicate
+ )
+ );
+ }
+ function elementHasPredicateMatchingButton(element, predicate) {
+ return elementHasPredicateMatchingDescendant(
+ element,
+ "button,input[type=submit],input[type=button]",
+ predicate
+ );
+ }
+ function elementHasPredicateMatchingInput(element, predicate) {
+ return elementHasPredicateMatchingDescendant(element, "input", predicate);
+ }
+ function elementHasPredicateMatchingHyperlink(element, regexExp) {
+ return elementHasPredicateMatchingDescendant(
+ element,
+ "a",
+ link =>
+ previousSiblingLabelMatchesRegex(link, regexExp) ||
+ checkValueAgainstRegex(link.innerText, regexExp) ||
+ elementMatchesPredicate(
+ link,
+ attr => checkValueAgainstRegex(attr, regexExp),
+ ["href"]
+ ) ||
+ nextSiblingLabelMatchesRegex(link, regexExp)
+ );
+ }
+ function elementHasRegexMatchingCheckbox(element, regexExp) {
+ return elementHasPredicateMatchingDescendant(
+ element,
+ "input[type=checkbox], div[class*=checkbox]",
+ box =>
+ elementMatchesPredicate(box, attr =>
+ checkValueAgainstRegex(attr, regexExp)
+ ) || nextSiblingLabelMatchesRegex(box, regexExp)
+ );
+ }
+
+ function nextSiblingLabelMatchesRegex(element, regexExp) {
+ let nextElem = element.nextElementSibling;
+ if (nextElem && nextElem.tagName == "LABEL") {
+ return checkValueAgainstRegex(nextElem.innerText, regexExp);
+ }
+ let closestElem = closestElementFollowing(element, "label");
+ return closestElem
+ ? checkValueAgainstRegex(closestElem.innerText, regexExp)
+ : false;
+ }
+
+ function previousSiblingLabelMatchesRegex(element, regexExp) {
+ let previousElem = element.previousElementSibling;
+ if (previousElem && previousElem.tagName == "LABEL") {
+ return checkValueAgainstRegex(previousElem.innerText, regexExp);
+ }
+ let closestElem = closestElementPreceding(element, "label");
+ return closestElem
+ ? checkValueAgainstRegex(closestElem.innerText, regexExp)
+ : false;
+ }
+ function getElementDescendants(element, selector) {
+ const selectorToDescendants = setDefault(
+ descendantsCache,
+ element,
+ () => new Map()
+ );
+
+ return setDefault(selectorToDescendants, selector, () =>
+ Array.from(element.querySelectorAll(selector))
+ );
+ }
+
+ function clearCache() {
+ descendantsCache = new WeakMap();
+ surroundingNodesCache = new WeakMap();
+ }
+ function closestHeaderMatchesPredicate(element, predicate) {
+ return (
+ elementHasPredicateMatchingHeader(element, predicate) ||
+ closestHeaderAboveMatchesPredicate(element, predicate)
+ );
+ }
+ function closestHeaderAboveMatchesPredicate(element, predicate) {
+ let closestHeader = closestElementPreceding(element, "h1,h2,h3,h4,h5,h6");
+
+ if (closestHeader !== null) {
+ if (predicate(closestHeader)) {
+ return true;
+ }
+ }
+ closestHeader = closestElementPreceding(
+ element,
+ "div[class*=heading],div[class*=header],div[class*=title],header"
+ );
+ return closestHeader ? predicate(closestHeader) : false;
+ }
+ function closestElementPreceding(element, selector) {
+ return getSurroundingNodes(element, selector).precedingNode;
+ }
+ function closestElementFollowing(element, selector) {
+ return getSurroundingNodes(element, selector).followingNode;
+ }
+ function getSurroundingNodes(element, selector) {
+ const selectorToSurroundingNodes = setDefault(
+ surroundingNodesCache,
+ element,
+ () => new Map()
+ );
+
+ return setDefault(selectorToSurroundingNodes, selector, () => {
+ let elements = getElementDescendants(element.ownerDocument, selector);
+ let followingIndex = closestFollowingNodeIndex(elements, element);
+ let precedingIndex = followingIndex - 1;
+ let preceding = precedingIndex < 0 ? null : elements[precedingIndex];
+ let following =
+ followingIndex == elements.length ? null : elements[followingIndex];
+ return { precedingNode: preceding, followingNode: following };
+ });
+ }
+ function closestFollowingNodeIndex(elements, element) {
+ let low = 0;
+ let high = elements.length;
+ while (low < high) {
+ let i = (low + high) >>> 1;
+ if (
+ element.compareDocumentPosition(elements[i]) &
+ Node.DOCUMENT_POSITION_PRECEDING
+ ) {
+ low = i + 1;
+ } else {
+ high = i;
+ }
+ }
+ return low;
+ }
+
+ function checkValueAgainstAllRegex(value, regexExp = []) {
+ return regexExp.every(reg => checkValueAgainstRegex(value, reg));
+ }
+
+ function checkValueAgainstRegex(value, regexExp) {
+ return value ? regexExp.test(value) : false;
+ }
+ function atLeastOne(iter) {
+ return iter.length >= 1;
+ }
+
+ /**
+ * CREATION OF RULESET
+ */
+ const rules = ruleset(
+ [
+ rule(
+ DEVELOPMENT ? dom("form").when(isVisible) : element("form"),
+ type("form").note(clearCache)
+ ),
+ // Check form attributes
+ rule(type("form"), score(formAttributesMatchRegisterRegex), {
+ name: "formAttributesMatchRegisterRegex",
+ }),
+ rule(type("form"), score(formAttributesMatchLoginRegex), {
+ name: "formAttributesMatchLoginRegex",
+ }),
+ rule(type("form"), score(formAttributesMatchSubscriptionRegex), {
+ name: "formAttributesMatchSubscriptionRegex",
+ }),
+ rule(type("form"), score(formAttributesMatchLoginAndRegisterRegex), {
+ name: "formAttributesMatchLoginAndRegisterRegex",
+ }),
+ // Check autocomplete attributes
+ rule(type("form"), score(formHasAcCurrentPassword), {
+ name: "formHasAcCurrentPassword",
+ }),
+ rule(type("form"), score(formHasAcNewPassword), {
+ name: "formHasAcNewPassword",
+ }),
+ // Check input fields
+ rule(type("form"), score(formHasEmailField), {
+ name: "formHasEmailField",
+ }),
+ rule(type("form"), score(formHasUsernameField), {
+ name: "formHasUsernameField",
+ }),
+ rule(type("form"), score(formHasPasswordField), {
+ name: "formHasPasswordField",
+ }),
+ rule(type("form"), score(formHasFirstOrLastNameField), {
+ name: "formHasFirstOrLastNameField",
+ }),
+ // Check buttons
+ rule(type("form"), score(formHasRegisterButton), {
+ name: "formHasRegisterButton",
+ }),
+ rule(type("form"), score(formHasLoginButton), {
+ name: "formHasLoginButton",
+ }),
+ rule(type("form"), score(formHasContinueButton), {
+ name: "formHasContinueButton",
+ }),
+ rule(type("form"), score(formHasSubscribeButton), {
+ name: "formHasSubscribeButton",
+ }),
+ // Check hyperlinks
+ rule(type("form"), score(formHasTermsAndConditionsHyperlink), {
+ name: "formHasTermsAndConditionsHyperlink",
+ }),
+ rule(type("form"), score(formHasPasswordForgottenHyperlink), {
+ name: "formHasPasswordForgottenHyperlink",
+ }),
+ rule(type("form"), score(formHasAlreadySignedUpHyperlink), {
+ name: "formHasAlreadySignedUpHyperlink",
+ }),
+ // Check labels
+ rule(type("form"), score(closestElementIsEmailLabelLike), {
+ name: "closestElementIsEmailLabelLike",
+ }),
+ // Check checkboxes
+ rule(type("form"), score(formHasRememberMeCheckbox), {
+ name: "formHasRememberMeCheckbox",
+ }),
+ rule(type("form"), score(formHasSubcriptionCheckbox), {
+ name: "formHasSubcriptionCheckbox",
+ }),
+ // Check header
+ rule(type("form"), score(closestHeaderMatchesRegisterRegex), {
+ name: "closestHeaderMatchesRegisterRegex",
+ }),
+ rule(type("form"), score(closestHeaderMatchesLoginRegex), {
+ name: "closestHeaderMatchesLoginRegex",
+ }),
+ rule(type("form"), score(closestHeaderMatchesSubscriptionRegex), {
+ name: "closestHeaderMatchesSubscriptionRegex",
+ }),
+ // Check doc title
+ rule(type("form"), score(docTitleMatchesRegisterRegex), {
+ name: "docTitleMatchesRegisterRegex",
+ }),
+ rule(type("form"), score(docTitleMatchesEditProfileRegex), {
+ name: "docTitleMatchesEditProfileRegex",
+ }),
+ rule(type("form"), out("form")),
+ ],
+ coeffs,
+ biases
+ );
+ return rules;
+}
+
+/**
+ * --- END OF CODE FROM TRAINING REPOSITORY ---
+ */
+
+export const SignUpFormRuleset = {
+ type: "form",
+ rules: createRuleset([...coefficients.form], biases),
+};
diff --git a/toolkit/components/satchel/components.conf b/toolkit/components/satchel/components.conf
new file mode 100644
index 0000000000..d843b869d6
--- /dev/null
+++ b/toolkit/components/satchel/components.conf
@@ -0,0 +1,33 @@
+# -*- 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/.
+
+Classes = [
+ {
+ 'cid': '{895db6c7-dbdf-40ea-9f64-b175033243dc}',
+ 'contract_ids': [
+ '@mozilla.org/satchel/form-fill-controller;1',
+ '@mozilla.org/autocomplete/search;1?name=form-history',
+ ],
+ 'type': 'nsFormFillController',
+ 'constructor': 'nsFormFillController::GetSingleton',
+ 'headers': ['/toolkit/components/satchel/nsFormFillController.h'],
+ 'categories': {'app-startup': 'FormFillController'},
+ },
+
+ {
+ 'cid': '{c11c21b2-71c9-4f87-a0f8-5e13f50495fd}',
+ 'contract_ids': ['@mozilla.org/satchel/form-autocomplete;1'],
+ 'esModule': 'resource://gre/modules/FormAutoComplete.sys.mjs',
+ 'constructor': 'FormAutoComplete',
+ },
+ {
+ 'cid': '{3a0012eb-007f-4bb8-aa81-a07385f77a25}',
+ 'contract_ids': ['@mozilla.org/satchel/form-history-startup;1'],
+ 'esModule': 'resource://gre/modules/FormHistoryStartup.sys.mjs',
+ 'constructor': 'FormHistoryStartup',
+ 'categories': {'profile-after-change': 'formHistoryStartup'},
+ },
+]
diff --git a/toolkit/components/satchel/integrations/FirefoxRelay.sys.mjs b/toolkit/components/satchel/integrations/FirefoxRelay.sys.mjs
new file mode 100644
index 0000000000..8f88373763
--- /dev/null
+++ b/toolkit/components/satchel/integrations/FirefoxRelay.sys.mjs
@@ -0,0 +1,647 @@
+/* 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/. */
+
+import { FirefoxRelayTelemetry } from "resource://gre/modules/FirefoxRelayTelemetry.mjs";
+import {
+ LoginHelper,
+ OptInFeature,
+ ParentAutocompleteOption,
+} from "resource://gre/modules/LoginHelper.sys.mjs";
+import { TelemetryUtils } from "resource://gre/modules/TelemetryUtils.sys.mjs";
+import { showConfirmation } from "resource://gre/modules/FillHelpers.sys.mjs";
+
+const lazy = {};
+
+// Static configuration
+const gConfig = (function () {
+ const baseUrl = Services.prefs.getStringPref(
+ "signon.firefoxRelay.base_url",
+ undefined
+ );
+ return {
+ scope: ["profile", "https://identity.mozilla.com/apps/relay"],
+ addressesUrl: baseUrl + `relayaddresses/`,
+ acceptTermsUrl: baseUrl + `terms-accepted-user/`,
+ profilesUrl: baseUrl + `profiles/`,
+ learnMoreURL: Services.urlFormatter.formatURLPref(
+ "signon.firefoxRelay.learn_more_url"
+ ),
+ manageURL: Services.urlFormatter.formatURLPref(
+ "signon.firefoxRelay.manage_url"
+ ),
+ relayFeaturePref: "signon.firefoxRelay.feature",
+ termsOfServiceUrl: Services.urlFormatter.formatURLPref(
+ "signon.firefoxRelay.terms_of_service_url"
+ ),
+ privacyPolicyUrl: Services.urlFormatter.formatURLPref(
+ "signon.firefoxRelay.privacy_policy_url"
+ ),
+ };
+})();
+
+ChromeUtils.defineLazyGetter(lazy, "log", () =>
+ LoginHelper.createLogger("FirefoxRelay")
+);
+ChromeUtils.defineLazyGetter(lazy, "fxAccounts", () =>
+ ChromeUtils.importESModule(
+ "resource://gre/modules/FxAccounts.sys.mjs"
+ ).getFxAccountsSingleton()
+);
+ChromeUtils.defineLazyGetter(lazy, "strings", function () {
+ return new Localization([
+ "branding/brand.ftl",
+ "browser/firefoxRelay.ftl",
+ "toolkit/branding/accounts.ftl",
+ "toolkit/branding/brandings.ftl",
+ ]);
+});
+
+if (Services.appinfo.processType !== Services.appinfo.PROCESS_TYPE_DEFAULT) {
+ throw new Error("FirefoxRelay.sys.mjs should only run in the parent process");
+}
+
+// Using 418 to avoid conflict with other standard http error code
+const AUTH_TOKEN_ERROR_CODE = 418;
+
+let gFlowId;
+
+async function getRelayTokenAsync() {
+ try {
+ return await lazy.fxAccounts.getOAuthToken({ scope: gConfig.scope });
+ } catch (e) {
+ console.error(`There was an error getting the user's token: ${e.message}`);
+ return undefined;
+ }
+}
+
+async function hasFirefoxAccountAsync() {
+ if (!lazy.fxAccounts.constructor.config.isProductionConfig()) {
+ return false;
+ }
+ return lazy.fxAccounts.hasLocalSession();
+}
+
+async function fetchWithReauth(
+ browser,
+ createRequest,
+ canGetFreshOAuthToken = true
+) {
+ const relayToken = await getRelayTokenAsync();
+ if (!relayToken) {
+ if (browser) {
+ await showErrorAsync(browser, "firefox-relay-must-login-to-account");
+ }
+ return undefined;
+ }
+
+ const headers = new Headers({
+ Authorization: `Bearer ${relayToken}`,
+ Accept: "application/json",
+ "Accept-Language": Services.locale.requestedLocales,
+ "Content-Type": "application/json",
+ });
+
+ const request = createRequest(headers);
+ const response = await fetch(request);
+
+ if (canGetFreshOAuthToken && response.status == 401) {
+ await lazy.fxAccounts.removeCachedOAuthToken({ token: relayToken });
+ return fetchWithReauth(browser, createRequest, false);
+ }
+ return response;
+}
+
+async function getReusableMasksAsync(browser, _origin) {
+ const response = await fetchWithReauth(
+ browser,
+ headers =>
+ new Request(gConfig.addressesUrl, {
+ method: "GET",
+ headers,
+ })
+ );
+
+ if (!response) {
+ // fetchWithReauth only returns undefined if login / obtaining a token failed.
+ // Otherwise, it will return a response object.
+ return [undefined, AUTH_TOKEN_ERROR_CODE];
+ }
+
+ if (response.ok) {
+ return [await response.json(), response.status];
+ }
+
+ lazy.log.error(
+ `failed to find reusable Relay masks: ${response.status}:${response.statusText}`
+ );
+ await showErrorAsync(browser, "firefox-relay-get-reusable-masks-failed", {
+ status: response.status,
+ });
+
+ return [undefined, response.status];
+}
+
+/**
+ * Show localized notification.
+ *
+ * @param {*} browser
+ * @param {*} messageId from browser/firefoxRelay.ftl
+ * @param {object} messageArgs
+ */
+async function showErrorAsync(browser, messageId, messageArgs) {
+ const { PopupNotifications } = browser.ownerGlobal.wrappedJSObject;
+ const [message] = await lazy.strings.formatValues([
+ { id: messageId, args: messageArgs },
+ ]);
+ PopupNotifications.show(
+ browser,
+ "relay-integration-error",
+ message,
+ "password-notification-icon",
+ null,
+ null,
+ {
+ autofocus: true,
+ removeOnDismissal: true,
+ popupIconURL: "chrome://browser/content/logos/relay.svg",
+ learnMoreURL: gConfig.learnMoreURL,
+ }
+ );
+}
+
+function customizeNotificationHeader(notification) {
+ if (!notification) {
+ return;
+ }
+ const document = notification.owner.panel.ownerDocument;
+ const description = document.querySelector(
+ `description[popupid=${notification.id}]`
+ );
+ const headerTemplate = document.getElementById("firefox-relay-header");
+ description.replaceChildren(headerTemplate.firstChild.cloneNode(true));
+}
+
+async function formatMessages(...ids) {
+ for (let i in ids) {
+ if (typeof ids[i] == "string") {
+ ids[i] = { id: ids[i] };
+ }
+ }
+
+ const messages = await lazy.strings.formatMessages(ids);
+ return messages.map(message => {
+ if (message.attributes) {
+ return message.attributes.reduce(
+ (result, { name, value }) => ({ ...result, [name]: value }),
+ {}
+ );
+ }
+ return message.value;
+ });
+}
+
+async function showReusableMasksAsync(browser, origin, error) {
+ const [reusableMasks, status] = await getReusableMasksAsync(browser, origin);
+ if (!reusableMasks) {
+ FirefoxRelayTelemetry.recordRelayReusePanelEvent("shown", gFlowId, status);
+ return null;
+ }
+
+ let fillUsername;
+ const fillUsernamePromise = new Promise(resolve => (fillUsername = resolve));
+ const [getUnlimitedMasksStrings] = await formatMessages(
+ "firefox-relay-get-unlimited-masks"
+ );
+ const getUnlimitedMasks = {
+ label: getUnlimitedMasksStrings.label,
+ accessKey: getUnlimitedMasksStrings.accesskey,
+ dismiss: true,
+ async callback() {
+ FirefoxRelayTelemetry.recordRelayReusePanelEvent(
+ "get_unlimited_masks",
+ gFlowId
+ );
+ browser.ownerGlobal.openWebLinkIn(gConfig.manageURL, "tab");
+ },
+ };
+
+ let notification;
+
+ function getReusableMasksList() {
+ return notification?.owner.panel.getElementsByClassName(
+ "reusable-relay-masks"
+ )[0];
+ }
+
+ function notificationShown() {
+ if (!notification) {
+ return;
+ }
+
+ customizeNotificationHeader(notification);
+
+ notification.owner.panel.getElementsByClassName(
+ "error-message"
+ )[0].textContent = error.detail || "";
+
+ // rebuild "reuse mask" buttons list
+ const list = getReusableMasksList();
+ list.innerHTML = "";
+
+ const document = list.ownerDocument;
+ const fragment = document.createDocumentFragment();
+ reusableMasks
+ .filter(mask => mask.enabled)
+ .forEach(mask => {
+ const button = document.createElement("button");
+
+ const maskFullAddress = document.createElement("span");
+ maskFullAddress.textContent = mask.full_address;
+ button.appendChild(maskFullAddress);
+
+ const maskDescription = document.createElement("span");
+ maskDescription.textContent =
+ mask.description || mask.generated_for || mask.used_on;
+ button.appendChild(maskDescription);
+
+ button.addEventListener(
+ "click",
+ () => {
+ notification.remove();
+ lazy.log.info("Reusing Relay mask");
+ fillUsername(mask.full_address);
+ showConfirmation(
+ browser,
+ "confirmation-hint-firefox-relay-mask-reused"
+ );
+ FirefoxRelayTelemetry.recordRelayReusePanelEvent(
+ "reuse_mask",
+ gFlowId
+ );
+ },
+ { once: true }
+ );
+ fragment.appendChild(button);
+ });
+ list.appendChild(fragment);
+ }
+
+ function notificationRemoved() {
+ const list = getReusableMasksList();
+ list.innerHTML = "";
+ }
+
+ function onNotificationEvent(event) {
+ switch (event) {
+ case "removed":
+ notificationRemoved();
+ break;
+ case "shown":
+ notificationShown();
+ FirefoxRelayTelemetry.recordRelayReusePanelEvent("shown", gFlowId);
+ break;
+ }
+ }
+
+ const { PopupNotifications } = browser.ownerGlobal.wrappedJSObject;
+ notification = PopupNotifications.show(
+ browser,
+ "relay-integration-reuse-masks",
+ "", // content is provided after popup shown
+ "password-notification-icon",
+ getUnlimitedMasks,
+ [],
+ {
+ autofocus: true,
+ removeOnDismissal: true,
+ eventCallback: onNotificationEvent,
+ }
+ );
+
+ return fillUsernamePromise;
+}
+
+async function generateUsernameAsync(browser, origin) {
+ const body = JSON.stringify({
+ enabled: true,
+ description: origin.substr(0, 64),
+ generated_for: origin.substr(0, 255),
+ used_on: origin,
+ });
+
+ const response = await fetchWithReauth(
+ browser,
+ headers =>
+ new Request(gConfig.addressesUrl, {
+ method: "POST",
+ headers,
+ body,
+ })
+ );
+
+ if (!response) {
+ FirefoxRelayTelemetry.recordRelayUsernameFilledEvent(
+ "shown",
+ gFlowId,
+ AUTH_TOKEN_ERROR_CODE
+ );
+ return undefined;
+ }
+
+ if (response.ok) {
+ lazy.log.info(`generated Relay mask`);
+ const result = await response.json();
+ showConfirmation(browser, "confirmation-hint-firefox-relay-mask-created");
+ return result.full_address;
+ }
+
+ if (response.status == 403) {
+ const error = await response.json();
+ if (error?.error_code == "free_tier_limit") {
+ FirefoxRelayTelemetry.recordRelayUsernameFilledEvent(
+ "shown",
+ gFlowId,
+ error?.error_code
+ );
+ return showReusableMasksAsync(browser, origin, error);
+ }
+ }
+
+ lazy.log.error(
+ `failed to generate Relay mask: ${response.status}:${response.statusText}`
+ );
+
+ await showErrorAsync(browser, "firefox-relay-mask-generation-failed", {
+ status: response.status,
+ });
+
+ FirefoxRelayTelemetry.recordRelayReusePanelEvent(
+ "shown",
+ gFlowId,
+ response.status
+ );
+
+ return undefined;
+}
+
+function isSignup(scenarioName) {
+ return scenarioName == "SignUpFormScenario";
+}
+
+class RelayOffered {
+ async *autocompleteItemsAsync(_origin, scenarioName, hasInput) {
+ if (
+ !hasInput &&
+ isSignup(scenarioName) &&
+ (await hasFirefoxAccountAsync()) &&
+ !Services.prefs.prefIsLocked("signon.firefoxRelay.feature")
+ ) {
+ const [title, subtitle] = await formatMessages(
+ "firefox-relay-opt-in-title-1",
+ "firefox-relay-opt-in-subtitle-1"
+ );
+ yield new ParentAutocompleteOption(
+ "chrome://browser/content/logos/relay.svg",
+ title,
+ subtitle,
+ "PasswordManager:offerRelayIntegration",
+ {
+ telemetry: {
+ flowId: gFlowId,
+ scenarioName,
+ },
+ }
+ );
+ FirefoxRelayTelemetry.recordRelayOfferedEvent(
+ "shown",
+ gFlowId,
+ scenarioName
+ );
+ }
+ }
+
+ async notifyServerTermsAcceptedAsync(browser) {
+ const response = await fetchWithReauth(
+ browser,
+ headers =>
+ new Request(gConfig.acceptTermsUrl, {
+ method: "POST",
+ headers,
+ })
+ );
+
+ if (!response?.ok) {
+ lazy.log.error(
+ `failed to notify server that terms are accepted : ${response?.status}:${response?.statusText}`
+ );
+
+ let error;
+ try {
+ error = await response?.json();
+ } catch {}
+ await showErrorAsync(browser, "firefox-relay-mask-generation-failed", {
+ status: error?.detail || response.status,
+ });
+ return false;
+ }
+
+ return true;
+ }
+
+ async offerRelayIntegration(feature, browser, origin) {
+ const fxaUser = await lazy.fxAccounts.getSignedInUser();
+
+ if (!fxaUser) {
+ return null;
+ }
+ const { PopupNotifications } = browser.ownerGlobal.wrappedJSObject;
+ let fillUsername;
+ const fillUsernamePromise = new Promise(
+ resolve => (fillUsername = resolve)
+ );
+ const [enableStrings, disableStrings, postponeStrings] =
+ await formatMessages(
+ "firefox-relay-opt-in-confirmation-enable-button",
+ "firefox-relay-opt-in-confirmation-disable",
+ "firefox-relay-opt-in-confirmation-postpone"
+ );
+ const enableIntegration = {
+ label: enableStrings.label,
+ accessKey: enableStrings.accesskey,
+ dismiss: true,
+ callback: async () => {
+ lazy.log.info("user opted in to Firefox Relay integration");
+ // Capture the flowId here since async operations might take some time to resolve
+ // and by then gFlowId might have another value
+ const flowId = gFlowId;
+ if (await this.notifyServerTermsAcceptedAsync(browser)) {
+ feature.markAsEnabled();
+ FirefoxRelayTelemetry.recordRelayOptInPanelEvent("enabled", flowId);
+ fillUsername(await generateUsernameAsync(browser, origin));
+ }
+ },
+ };
+ const postpone = {
+ label: postponeStrings.label,
+ accessKey: postponeStrings.accesskey,
+ dismiss: true,
+ callback() {
+ lazy.log.info(
+ "user decided not to decide about Firefox Relay integration"
+ );
+ feature.markAsOffered();
+ FirefoxRelayTelemetry.recordRelayOptInPanelEvent("postponed", gFlowId);
+ },
+ };
+ const disableIntegration = {
+ label: disableStrings.label,
+ accessKey: disableStrings.accesskey,
+ dismiss: true,
+ callback() {
+ lazy.log.info("user opted out from Firefox Relay integration");
+ feature.markAsDisabled();
+ FirefoxRelayTelemetry.recordRelayOptInPanelEvent("disabled", gFlowId);
+ },
+ };
+ let notification;
+ feature.markAsOffered();
+ notification = PopupNotifications.show(
+ browser,
+ "relay-integration-offer",
+ "", // content is provided after popup shown
+ "password-notification-icon",
+ enableIntegration,
+ [postpone, disableIntegration],
+ {
+ autofocus: true,
+ removeOnDismissal: true,
+ learnMoreURL: gConfig.learnMoreURL,
+ eventCallback: event => {
+ switch (event) {
+ case "shown":
+ customizeNotificationHeader(notification);
+ const document = notification.owner.panel.ownerDocument;
+ const tosLink = document.getElementById(
+ "firefox-relay-offer-tos-url"
+ );
+ tosLink.href = gConfig.termsOfServiceUrl;
+ const privacyLink = document.getElementById(
+ "firefox-relay-offer-privacy-url"
+ );
+ privacyLink.href = gConfig.privacyPolicyUrl;
+ const content = document.querySelector(
+ `popupnotification[id=${notification.id}-notification] popupnotificationcontent`
+ );
+ const line3 = content.querySelector(
+ "[id=firefox-relay-offer-what-relay-provides]"
+ );
+ document.l10n.setAttributes(
+ line3,
+ "firefox-relay-offer-what-relay-provides",
+ {
+ useremail: fxaUser.email,
+ }
+ );
+ FirefoxRelayTelemetry.recordRelayOptInPanelEvent(
+ "shown",
+ gFlowId
+ );
+ break;
+ }
+ },
+ }
+ );
+ getRelayTokenAsync();
+ return fillUsernamePromise;
+ }
+}
+
+class RelayEnabled {
+ async *autocompleteItemsAsync(origin, scenarioName, hasInput) {
+ if (
+ !hasInput &&
+ isSignup(scenarioName) &&
+ (await hasFirefoxAccountAsync())
+ ) {
+ const [title] = await formatMessages("firefox-relay-use-mask-title");
+ yield new ParentAutocompleteOption(
+ "chrome://browser/content/logos/relay.svg",
+ title,
+ "", // when the user has opted-in, there is no subtitle content
+ "PasswordManager:generateRelayUsername",
+ {
+ telemetry: {
+ flowId: gFlowId,
+ },
+ }
+ );
+ FirefoxRelayTelemetry.recordRelayUsernameFilledEvent("shown", gFlowId);
+ }
+ }
+
+ async generateUsername(browser, origin) {
+ return generateUsernameAsync(browser, origin);
+ }
+}
+
+class RelayDisabled {}
+
+class RelayFeature extends OptInFeature {
+ constructor() {
+ super(RelayOffered, RelayEnabled, RelayDisabled, gConfig.relayFeaturePref);
+ Services.telemetry.setEventRecordingEnabled("relay_integration", true);
+ // Update the config when the signon.firefoxRelay.base_url pref is changed.
+ // This is added mainly for tests.
+ Services.prefs.addObserver(
+ "signon.firefoxRelay.base_url",
+ this.updateConfig
+ );
+ }
+
+ get learnMoreUrl() {
+ return gConfig.learnMoreURL;
+ }
+
+ updateConfig() {
+ const newBaseUrl = Services.prefs.getStringPref(
+ "signon.firefoxRelay.base_url"
+ );
+ gConfig.addressesUrl = newBaseUrl + `relayaddresses/`;
+ gConfig.profilesUrl = newBaseUrl + `profiles/`;
+ gConfig.acceptTermsUrl = newBaseUrl + `terms-accepted-user/`;
+ }
+
+ async autocompleteItemsAsync({ origin, scenarioName, hasInput }) {
+ const result = [];
+
+ // Generate a flowID to unique identify a series of user action. FlowId
+ // allows us to link users' interaction on different UI component (Ex. autocomplete, notification)
+ // We can use flowID to build the Funnel Diagram
+ // This value need to always be regenerated in the entry point of an user
+ // action so we overwrite the previous one.
+ gFlowId = TelemetryUtils.generateUUID();
+
+ if (this.implementation.autocompleteItemsAsync) {
+ for await (const item of this.implementation.autocompleteItemsAsync(
+ origin,
+ scenarioName,
+ hasInput
+ )) {
+ result.push(item);
+ }
+ }
+
+ return result;
+ }
+
+ async generateUsername(browser, origin) {
+ return this.implementation.generateUsername?.(browser, origin);
+ }
+
+ async offerRelayIntegration(browser, origin) {
+ return this.implementation.offerRelayIntegration?.(this, browser, origin);
+ }
+}
+
+export const FirefoxRelay = new RelayFeature();
diff --git a/toolkit/components/satchel/integrations/FirefoxRelayTelemetry.mjs b/toolkit/components/satchel/integrations/FirefoxRelayTelemetry.mjs
new file mode 100644
index 0000000000..c03f48ba0f
--- /dev/null
+++ b/toolkit/components/satchel/integrations/FirefoxRelayTelemetry.mjs
@@ -0,0 +1,73 @@
+/* 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/. */
+
+export const FirefoxRelayTelemetry = {
+ recordRelayIntegrationTelemetryEvent(
+ eventObject,
+ eventMethod,
+ eventFlowId,
+ eventExtras
+ ) {
+ Services.telemetry.recordEvent(
+ "relay_integration",
+ eventMethod,
+ eventObject,
+ eventFlowId ?? "",
+ eventExtras ?? {}
+ );
+ },
+
+ recordRelayPrefEvent(eventMethod, eventFlowId, eventExtras) {
+ this.recordRelayIntegrationTelemetryEvent(
+ "pref_change",
+ eventMethod,
+ eventFlowId,
+ eventExtras
+ );
+ },
+
+ recordRelayOfferedEvent(eventMethod, eventFlowId, scenarioName) {
+ return this.recordRelayIntegrationTelemetryEvent(
+ "offer_relay",
+ eventMethod,
+ eventFlowId,
+ {
+ scenario: scenarioName,
+ }
+ );
+ },
+
+ recordRelayUsernameFilledEvent(eventMethod, eventFlowId, errorCode = 0) {
+ return this.recordRelayIntegrationTelemetryEvent(
+ "fill_username",
+ eventMethod,
+ eventFlowId,
+ {
+ error_code: errorCode + "",
+ }
+ );
+ },
+
+ recordRelayReusePanelEvent(eventMethod, eventFlowId, errorCode = 0) {
+ return this.recordRelayIntegrationTelemetryEvent(
+ "reuse_panel",
+ eventMethod,
+ eventFlowId,
+ {
+ error_code: errorCode + "",
+ }
+ );
+ },
+
+ recordRelayOptInPanelEvent(eventMethod, eventFlowId, eventExtras) {
+ return this.recordRelayIntegrationTelemetryEvent(
+ "opt_in_panel",
+ eventMethod,
+ eventFlowId,
+ eventExtras
+ );
+ },
+};
+
+export default FirefoxRelayTelemetry;
diff --git a/toolkit/components/satchel/integrations/FirefoxRelayUtils.sys.mjs b/toolkit/components/satchel/integrations/FirefoxRelayUtils.sys.mjs
new file mode 100644
index 0000000000..0dfb2a969d
--- /dev/null
+++ b/toolkit/components/satchel/integrations/FirefoxRelayUtils.sys.mjs
@@ -0,0 +1,23 @@
+/* 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/. */
+
+import { LoginHelper } from "resource://gre/modules/LoginHelper.sys.mjs";
+
+export const FirefoxRelayUtils = {
+ isRelayInterestedField(input) {
+ return (
+ FirefoxRelayUtils.relayIsAvailableOrEnabled &&
+ (LoginHelper.isInferredEmailField(input) ||
+ LoginHelper.isInferredUsernameField(input))
+ );
+ },
+
+ relayIsAvailableOrEnabled() {
+ const value = Services.prefs.getStringPref(
+ "signon.firefoxRelay.feature",
+ undefined
+ );
+ return ["available", "offered", "enabled"].includes(value);
+ },
+};
diff --git a/toolkit/components/satchel/integrations/WebAuthnFeature.sys.mjs b/toolkit/components/satchel/integrations/WebAuthnFeature.sys.mjs
new file mode 100644
index 0000000000..b5805d5a93
--- /dev/null
+++ b/toolkit/components/satchel/integrations/WebAuthnFeature.sys.mjs
@@ -0,0 +1,129 @@
+/* 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/. */
+
+import {
+ LoginHelper,
+ ParentAutocompleteOption,
+} from "resource://gre/modules/LoginHelper.sys.mjs";
+
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+const lazy = {};
+
+XPCOMUtils.defineLazyServiceGetter(
+ lazy,
+ "webauthnService",
+ "@mozilla.org/webauthn/service;1",
+ "nsIWebAuthnService"
+);
+
+ChromeUtils.defineLazyGetter(
+ lazy,
+ "strings",
+ () => new Localization(["browser/webauthnDialog.ftl"])
+);
+ChromeUtils.defineLazyGetter(lazy, "log", () =>
+ LoginHelper.createLogger("WebAuthnFeature")
+);
+
+if (Services.appinfo.processType !== Services.appinfo.PROCESS_TYPE_DEFAULT) {
+ throw new Error(
+ "PasskeySupport.sys.mjs should only run in the parent process"
+ );
+}
+
+class WebAuthnSupport {
+ async *#getAutocompleteItemsAsync(browsingContextId, formOrigin) {
+ let transactionId = lazy.webauthnService.hasPendingConditionalGet(
+ browsingContextId,
+ formOrigin
+ );
+ if (transactionId == 0) {
+ // No pending transaction
+ return;
+ }
+ let credentials = lazy.webauthnService.getAutoFillEntries(transactionId);
+
+ let labels = credentials.map(x => ({
+ id: "webauthn-specific-passkey-label",
+ args: { domain: x.rpId },
+ }));
+ if (!credentials.length) {
+ labels.push({ id: "webauthn-a-passkey-label" });
+ } else {
+ labels.push({ id: "webauthn-another-passkey-label" });
+ }
+ const formattedLabels = await lazy.strings.formatValues(labels);
+ for (let i = 0; i < credentials.length; i++) {
+ yield new ParentAutocompleteOption(
+ "chrome://browser/content/logos/passkey.svg",
+ credentials[i].userName,
+ formattedLabels[i],
+ "PasswordManager:promptForAuthenticator",
+ {
+ selection: {
+ transactionId,
+ credentialId: credentials[i].credentialId,
+ },
+ }
+ );
+ }
+ // `getAutoFillEntries` may not return all of the credentials on the device
+ // (in particular it will not include credentials with a protection policy
+ // that forbids silent discovery), so we include a catch-all entry in the
+ // list. If the user selects this entry, the WebAuthn transaction will
+ // proceed using the modal UI.
+ yield new ParentAutocompleteOption(
+ "chrome://browser/content/logos/passkey.svg",
+ formattedLabels[formattedLabels.length - 1],
+ "",
+ "PasswordManager:promptForAuthenticator",
+ {
+ selection: {
+ transactionId,
+ },
+ }
+ );
+ }
+
+ /**
+ *
+ * @param {int} browsingContextId the browsing context ID associated with this request
+ * @param {string} formOrigin
+ * @param {string} scenarioName can be "SignUpFormScenario" or undefined
+ * @param {string} isWebAuthn indicates whether "webauthn" was included in the input's autocomplete value
+ * @returns {ParentAutocompleteOption} the optional WebAuthn autocomplete item
+ */
+ async autocompleteItemsAsync(
+ browsingContextId,
+ formOrigin,
+ scenarioName,
+ isWebAuthn
+ ) {
+ const result = [];
+ if (scenarioName !== "SignUpFormScenario" || isWebAuthn) {
+ for await (const item of this.#getAutocompleteItemsAsync(
+ browsingContextId,
+ formOrigin
+ )) {
+ result.push(item);
+ }
+ }
+ return result;
+ }
+
+ async promptForAuthenticator(browser, selection) {
+ lazy.log.info("Prompting to authenticate with relying party.");
+ if (selection.credentialId) {
+ lazy.webauthnService.selectAutoFillEntry(
+ selection.transactionId,
+ selection.credentialId
+ );
+ } else {
+ lazy.webauthnService.resumeConditionalGet(selection.transactionId);
+ }
+ }
+}
+
+export const WebAuthnFeature = new WebAuthnSupport();
diff --git a/toolkit/components/satchel/moz.build b/toolkit/components/satchel/moz.build
new file mode 100644
index 0000000000..90dbd9ad2d
--- /dev/null
+++ b/toolkit/components/satchel/moz.build
@@ -0,0 +1,57 @@
+# -*- 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/.
+
+with Files("**"):
+ BUG_COMPONENT = ("Toolkit", "Form Manager")
+
+MOCHITEST_MANIFESTS += ["test/mochitest.toml"]
+XPCSHELL_TESTS_MANIFESTS += ["test/unit/xpcshell.toml"]
+BROWSER_CHROME_MANIFESTS += ["test/browser/browser.toml"]
+
+XPIDL_SOURCES += [
+ "nsIFormAutoComplete.idl",
+ "nsIFormFillController.idl",
+]
+
+XPIDL_MODULE = "satchel"
+
+SOURCES += [
+ "nsFormFillController.cpp",
+]
+
+LOCAL_INCLUDES += [
+ "../build",
+]
+
+EXTRA_JS_MODULES += [
+ "FillHelpers.sys.mjs",
+ "FormAutoComplete.sys.mjs",
+ "FormHistory.sys.mjs",
+ "FormHistoryStartup.sys.mjs",
+ "FormScenarios.sys.mjs",
+ "integrations/FirefoxRelay.sys.mjs",
+ "integrations/FirefoxRelayTelemetry.mjs",
+ "integrations/FirefoxRelayUtils.sys.mjs",
+ "integrations/WebAuthnFeature.sys.mjs",
+ "SignUpFormRuleset.sys.mjs",
+]
+
+XPCOM_MANIFESTS += [
+ "components.conf",
+]
+
+TESTING_JS_MODULES += [
+ "test/FormHistoryTestUtils.sys.mjs",
+]
+
+include("/ipc/chromium/chromium-config.mozbuild")
+
+FINAL_TARGET_FILES.actors += [
+ "FormHistoryChild.sys.mjs",
+ "FormHistoryParent.sys.mjs",
+]
+
+FINAL_LIBRARY = "xul"
diff --git a/toolkit/components/satchel/nsFormFillController.cpp b/toolkit/components/satchel/nsFormFillController.cpp
new file mode 100644
index 0000000000..7872ab36c8
--- /dev/null
+++ b/toolkit/components/satchel/nsFormFillController.cpp
@@ -0,0 +1,1300 @@
+/* -*- 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/ClearOnShutdown.h"
+#include "mozilla/ErrorResult.h"
+#include "mozilla/EventListenerManager.h"
+#include "mozilla/dom/Document.h"
+#include "mozilla/dom/Element.h"
+#include "mozilla/dom/Event.h" // for Event
+#include "mozilla/dom/HTMLDataListElement.h"
+#include "mozilla/dom/HTMLInputElement.h"
+#include "mozilla/dom/KeyboardEvent.h"
+#include "mozilla/dom/KeyboardEventBinding.h"
+#include "mozilla/dom/MouseEvent.h"
+#include "mozilla/dom/PageTransitionEvent.h"
+#include "mozilla/Logging.h"
+#include "mozilla/PresShell.h"
+#include "mozilla/Services.h"
+#include "mozilla/StaticPrefs_ui.h"
+#include "nsCRT.h"
+#include "nsIFormAutoComplete.h"
+#include "nsString.h"
+#include "nsPIDOMWindow.h"
+#include "nsIAutoCompleteResult.h"
+#include "nsIContent.h"
+#include "nsInterfaceHashtable.h"
+#include "nsContentUtils.h"
+#include "nsGenericHTMLElement.h"
+#include "nsILoadContext.h"
+#include "nsIFrame.h"
+#include "nsIScriptSecurityManager.h"
+#include "nsFocusManager.h"
+#include "nsQueryActor.h"
+#include "nsQueryObject.h"
+#include "nsServiceManagerUtils.h"
+#include "xpcpublic.h"
+
+using namespace mozilla;
+using namespace mozilla::dom;
+using mozilla::ErrorResult;
+using mozilla::LogLevel;
+
+static mozilla::LazyLogModule sLogger("satchel");
+
+static nsIFormAutoComplete* GetFormAutoComplete() {
+ static nsCOMPtr<nsIFormAutoComplete> sInstance;
+ static bool sInitialized = false;
+ if (!sInitialized) {
+ nsresult rv;
+ sInstance = do_GetService("@mozilla.org/satchel/form-autocomplete;1", &rv);
+
+ if (NS_SUCCEEDED(rv)) {
+ ClearOnShutdown(&sInstance);
+ sInitialized = true;
+ }
+ }
+ return sInstance;
+}
+
+NS_IMPL_CYCLE_COLLECTION(nsFormFillController, mController, mLoginManagerAC,
+ mFocusedPopup, 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(nsIFormAutoCompleteObserver)
+ NS_INTERFACE_MAP_ENTRY(nsIDOMEventListener)
+ NS_INTERFACE_MAP_ENTRY(nsIObserver)
+ 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),
+ mListNode(nullptr),
+ // The amount of time a context menu event supresses showing a
+ // popup from a focus event in ms. This matches the threshold in
+ // toolkit/components/passwordmgr/LoginManagerChild.jsm.
+ mFocusAfterRightClickThreshold(400),
+ mTimeout(50),
+ mMinResultsForPopup(1),
+ mMaxRows(0),
+ mDisableAutoComplete(false),
+ mCompleteDefaultIndex(false),
+ mCompleteSelectedIndex(false),
+ mForceComplete(false),
+ mSuppressOnInput(false),
+ mPasswordPopupAutomaticallyOpened(false) {
+ mController = do_GetService("@mozilla.org/autocomplete/controller;1");
+ MOZ_ASSERT(mController);
+
+ nsCOMPtr<nsIObserverService> obs = mozilla::services::GetObserverService();
+ MOZ_ASSERT(obs);
+
+ obs->AddObserver(this, "chrome-event-target-created", false);
+ obs->AddObserver(this, "autofill-fill-starting", false);
+ obs->AddObserver(this, "autofill-fill-complete", false);
+}
+
+nsFormFillController::~nsFormFillController() {
+ if (mListNode) {
+ mListNode->RemoveMutationObserver(this);
+ mListNode = nullptr;
+ }
+ if (mFocusedInput) {
+ MaybeRemoveMutationObserver(mFocusedInput);
+ mFocusedInput = nullptr;
+ }
+ RemoveForDocument(nullptr);
+}
+
+/* static */
+already_AddRefed<nsFormFillController> nsFormFillController::GetSingleton() {
+ static RefPtr<nsFormFillController> sSingleton;
+ if (!sSingleton) {
+ sSingleton = new nsFormFillController();
+ ClearOnShutdown(&sSingleton);
+ }
+ return do_AddRef(sSingleton);
+}
+
+////////////////////////////////////////////////////////////////////////
+//// nsIMutationObserver
+//
+
+MOZ_CAN_RUN_SCRIPT_BOUNDARY
+void nsFormFillController::AttributeChanged(mozilla::dom::Element* aElement,
+ int32_t aNameSpaceID,
+ nsAtom* aAttribute,
+ int32_t aModType,
+ const nsAttrValue* aOldValue) {
+ if ((aAttribute == nsGkAtoms::type || aAttribute == nsGkAtoms::readonly ||
+ aAttribute == nsGkAtoms::autocomplete) &&
+ aNameSpaceID == kNameSpaceID_None) {
+ RefPtr<HTMLInputElement> 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<RefPtr<HTMLInputElement>>(
+ "nsFormFillController::MaybeStartControllingInput", this,
+ &nsFormFillController::MaybeStartControllingInput, focusedInput);
+ aElement->OwnerDoc()->Dispatch(event.forget());
+ }
+
+ if (mListNode && mListNode->Contains(aElement)) {
+ RevalidateDataList();
+ }
+}
+
+MOZ_CAN_RUN_SCRIPT_BOUNDARY
+void nsFormFillController::ContentAppended(nsIContent* aChild) {
+ if (mListNode && mListNode->Contains(aChild->GetParent())) {
+ RevalidateDataList();
+ }
+}
+
+MOZ_CAN_RUN_SCRIPT_BOUNDARY
+void nsFormFillController::ContentInserted(nsIContent* aChild) {
+ if (mListNode && mListNode->Contains(aChild->GetParent())) {
+ RevalidateDataList();
+ }
+}
+
+MOZ_CAN_RUN_SCRIPT_BOUNDARY
+void nsFormFillController::ContentRemoved(nsIContent* aChild,
+ nsIContent* aPreviousSibling) {
+ if (mListNode && mListNode->Contains(aChild->GetParent())) {
+ RevalidateDataList();
+ }
+}
+
+void nsFormFillController::CharacterDataWillChange(
+ nsIContent* aContent, const CharacterDataChangeInfo&) {}
+
+void nsFormFillController::CharacterDataChanged(
+ nsIContent* aContent, const CharacterDataChangeInfo&) {}
+
+void nsFormFillController::AttributeWillChange(mozilla::dom::Element* aElement,
+ int32_t aNameSpaceID,
+ nsAtom* aAttribute,
+ int32_t aModType) {}
+
+void nsFormFillController::ParentChainChanged(nsIContent* aContent) {}
+
+void nsFormFillController::ARIAAttributeDefaultWillChange(
+ mozilla::dom::Element* aElement, nsAtom* aAttribute, int32_t aModType) {}
+
+void nsFormFillController::ARIAAttributeDefaultChanged(
+ mozilla::dom::Element* aElement, nsAtom* aAttribute, int32_t aModType) {}
+
+MOZ_CAN_RUN_SCRIPT_BOUNDARY
+void nsFormFillController::NodeWillBeDestroyed(nsINode* aNode) {
+ MOZ_LOG(sLogger, LogLevel::Verbose, ("NodeWillBeDestroyed: %p", aNode));
+ mPwmgrInputs.Remove(aNode);
+ mAutofillInputs.Remove(aNode);
+ MaybeRemoveMutationObserver(aNode);
+ if (aNode == mListNode) {
+ mListNode = nullptr;
+ RevalidateDataList();
+ } else if (aNode == mFocusedInput) {
+ 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) && !mAutofillInputs.Get(aNode)) {
+ aNode->RemoveMutationObserver(this);
+ }
+}
+
+////////////////////////////////////////////////////////////////////////
+//// nsIFormFillController
+
+NS_IMETHODIMP
+nsFormFillController::AttachPopupElementToDocument(Document* aDocument,
+ dom::Element* aPopupEl) {
+ if (!xpc::IsInAutomation()) {
+ return NS_ERROR_NOT_AVAILABLE;
+ }
+
+ MOZ_LOG(sLogger, LogLevel::Debug,
+ ("AttachPopupElementToDocument for document %p with popup %p",
+ aDocument, aPopupEl));
+ NS_ENSURE_TRUE(aDocument && aPopupEl, NS_ERROR_ILLEGAL_VALUE);
+
+ nsCOMPtr<nsIAutoCompletePopup> popup = aPopupEl->AsAutoCompletePopup();
+ NS_ENSURE_STATE(popup);
+
+ mPopups.InsertOrUpdate(aDocument, popup);
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+nsFormFillController::DetachFromDocument(Document* aDocument) {
+ if (!xpc::IsInAutomation()) {
+ return NS_ERROR_NOT_AVAILABLE;
+ }
+ mPopups.Remove(aDocument);
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+nsFormFillController::MarkAsLoginManagerField(HTMLInputElement* 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).
+ */
+ NS_ENSURE_STATE(aInput);
+
+ // If the field was already marked, we don't want to show the popup again.
+ if (mPwmgrInputs.Get(aInput)) {
+ return NS_OK;
+ }
+
+ mPwmgrInputs.InsertOrUpdate(aInput, true);
+ aInput->AddMutationObserverUnlessExists(this);
+
+ nsFocusManager* fm = nsFocusManager::GetFocusManager();
+ if (fm) {
+ nsCOMPtr<nsIContent> focusedContent = fm->GetFocusedElement();
+ if (focusedContent == aInput) {
+ if (!mFocusedInput) {
+ MaybeStartControllingInput(aInput);
+ } else {
+ // If we change who is responsible for searching the autocomplete
+ // result, notify the controller that the previous result is not valid
+ // anymore.
+ nsCOMPtr<nsIAutoCompleteController> controller = mController;
+ controller->ResetInternalState();
+ }
+ }
+ }
+
+ if (!mLoginManagerAC) {
+ mLoginManagerAC =
+ do_GetService("@mozilla.org/login-manager/autocompletesearch;1");
+ }
+
+ return NS_OK;
+}
+
+MOZ_CAN_RUN_SCRIPT NS_IMETHODIMP nsFormFillController::IsLoginManagerField(
+ HTMLInputElement* aInput, bool* isLoginManagerField) {
+ *isLoginManagerField = mPwmgrInputs.Get(aInput);
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+nsFormFillController::MarkAsAutofillField(HTMLInputElement* aInput) {
+ /*
+ * Support other components implementing form autofill and handle autocomplete
+ * for the field.
+ */
+ NS_ENSURE_STATE(aInput);
+
+ MOZ_LOG(sLogger, LogLevel::Verbose,
+ ("MarkAsAutofillField: aInput = %p", aInput));
+
+ if (mAutofillInputs.Get(aInput)) {
+ return NS_OK;
+ }
+
+ mAutofillInputs.InsertOrUpdate(aInput, true);
+ aInput->AddMutationObserverUnlessExists(this);
+
+ aInput->EnablePreview();
+
+ nsFocusManager* fm = nsFocusManager::GetFocusManager();
+ if (fm) {
+ nsCOMPtr<nsIContent> focusedContent = fm->GetFocusedElement();
+ if (focusedContent == aInput) {
+ if (!mFocusedInput) {
+ MaybeStartControllingInput(aInput);
+ } else {
+ // See `MarkAsLoginManagerField` for why this is needed.
+ nsCOMPtr<nsIAutoCompleteController> controller = mController;
+ controller->ResetInternalState();
+ }
+ }
+ }
+
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+nsFormFillController::GetFocusedInput(HTMLInputElement** 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::GetPopupElement(Element** aPopup) {
+ return NS_ERROR_NOT_IMPLEMENTED;
+}
+
+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 = mFocusedInput;
+ NS_ENSURE_STATE(content);
+ nsCOMPtr<nsIDocShell> docShell = GetDocShellForInput(mFocusedInput);
+ NS_ENSURE_STATE(docShell);
+ RefPtr<PresShell> presShell = docShell->GetPresShell();
+ NS_ENSURE_STATE(presShell);
+ presShell->ScrollContentIntoView(
+ content,
+ ScrollAxis(WhereToScroll::Nearest, WhenToScroll::IfNotVisible),
+ ScrollAxis(WhereToScroll::Nearest, WhenToScroll::IfNotVisible),
+ ScrollFlags::ScrollOverflowHidden);
+ // mFocusedPopup can be destroyed after ScrollContentIntoView, see bug
+ // 420089
+ if (mFocusedPopup) {
+ mFocusedPopup->OpenAutocompletePopup(this, mFocusedInput);
+ }
+ } else {
+ mFocusedPopup->ClosePopup();
+ mPasswordPopupAutomaticallyOpened = false;
+ }
+ }
+
+ 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::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()) {
+ mFocusedInput->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) {
+ if (mAutofillInputs.Get(mFocusedInput)) {
+ MOZ_LOG(sLogger, LogLevel::Debug, ("GetSearchAt: autofill-profiles field"));
+ nsCOMPtr<nsIAutoCompleteSearch> profileSearch = do_GetService(
+ "@mozilla.org/autocomplete/search;1?name=autofill-profiles");
+ if (profileSearch) {
+ _retval.AssignLiteral("autofill-profiles");
+ return NS_OK;
+ }
+ }
+
+ MOZ_LOG(sLogger, LogLevel::Debug, ("GetSearchAt: form-history field"));
+ _retval.AssignLiteral("form-history");
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+nsFormFillController::GetTextValue(nsAString& aTextValue) {
+ if (mFocusedInput) {
+ mFocusedInput->GetValue(aTextValue, CallerType::System);
+ } else {
+ aTextValue.Truncate();
+ }
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+nsFormFillController::SetTextValue(const nsAString& aTextValue) {
+ if (mFocusedInput) {
+ mSuppressOnInput = true;
+ mFocusedInput->SetUserInput(aTextValue,
+ *nsContentUtils::GetSystemPrincipal());
+ mSuppressOnInput = false;
+ }
+
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+nsFormFillController::GetSelectionStart(int32_t* aSelectionStart) {
+ if (!mFocusedInput) {
+ return NS_ERROR_UNEXPECTED;
+ }
+ ErrorResult rv;
+ *aSelectionStart = mFocusedInput->GetSelectionStartIgnoringType(rv);
+ return rv.StealNSResult();
+}
+
+NS_IMETHODIMP
+nsFormFillController::GetSelectionEnd(int32_t* aSelectionEnd) {
+ if (!mFocusedInput) {
+ return NS_ERROR_UNEXPECTED;
+ }
+ ErrorResult rv;
+ *aSelectionEnd = mFocusedInput->GetSelectionEndIgnoringType(rv);
+ return rv.StealNSResult();
+}
+
+MOZ_CAN_RUN_SCRIPT_BOUNDARY NS_IMETHODIMP
+nsFormFillController::SelectTextRange(int32_t aStartIndex, int32_t aEndIndex) {
+ if (!mFocusedInput) {
+ return NS_ERROR_UNEXPECTED;
+ }
+ RefPtr<HTMLInputElement> focusedInput(mFocusedInput);
+ ErrorResult rv;
+ focusedInput->SetSelectionRange(aStartIndex, aEndIndex, Optional<nsAString>(),
+ rv);
+ return rv.StealNSResult();
+}
+
+NS_IMETHODIMP
+nsFormFillController::OnSearchBegin() { return NS_OK; }
+
+NS_IMETHODIMP
+nsFormFillController::OnSearchComplete() { return NS_OK; }
+
+NS_IMETHODIMP
+nsFormFillController::OnTextEntered(Event* aEvent) {
+ NS_ENSURE_TRUE(mFocusedInput, NS_OK);
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+nsFormFillController::OnTextReverted(bool* _retval) {
+ mPasswordPopupAutomaticallyOpened = false;
+ 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;
+ }
+
+ RefPtr<Document> doc = mFocusedInput->OwnerDoc();
+ 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::GetNoRollupOnEmptySearch(bool* aNoRollupOnEmptySearch) {
+ if (mFocusedInput && (mPwmgrInputs.Get(mFocusedInput) ||
+ mFocusedInput->HasBeenTypePassword())) {
+ // Don't close the login popup when the field is cleared (bug 1534896).
+ *aNoRollupOnEmptySearch = true;
+ } else {
+ *aNoRollupOnEmptySearch = false;
+ }
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+nsFormFillController::GetUserContextId(uint32_t* aUserContextId) {
+ *aUserContextId = nsIScriptSecurityManager::DEFAULT_USER_CONTEXT_ID;
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+nsFormFillController::GetInvalidatePreviousResult(
+ bool* aInvalidatePreviousResult) {
+ *aInvalidatePreviousResult = mInvalidatePreviousResult;
+ return NS_OK;
+}
+
+////////////////////////////////////////////////////////////////////////
+//// nsIAutoCompleteSearch
+
+NS_IMETHODIMP
+nsFormFillController::StartSearch(const nsAString& aSearchString,
+ const nsAString& aSearchParam,
+ nsIAutoCompleteResult* aPreviousResult,
+ nsIAutoCompleteObserver* aListener) {
+ MOZ_LOG(sLogger, LogLevel::Debug, ("StartSearch for %p", mFocusedInput));
+
+ nsresult rv;
+
+ // 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 (mFocusedInput && (mPwmgrInputs.Get(mFocusedInput) ||
+ mFocusedInput->HasBeenTypePassword())) {
+ MOZ_LOG(sLogger, LogLevel::Debug, ("StartSearch: login field"));
+
+ // Handle the case where a password field is focused but
+ // MarkAsLoginManagerField wasn't called because password manager is
+ // disabled.
+ if (!mLoginManagerAC) {
+ mLoginManagerAC =
+ do_GetService("@mozilla.org/login-manager/autocompletesearch;1");
+ }
+
+ if (NS_WARN_IF(!mLoginManagerAC)) {
+ 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 = mLoginManagerAC->StartSearch(aSearchString, aPreviousResult,
+ mFocusedInput, this);
+ NS_ENSURE_SUCCESS(rv, rv);
+ } else {
+ MOZ_LOG(sLogger, LogLevel::Debug, ("StartSearch: non-login field"));
+ mLastListener = aListener;
+
+ bool addDataList = IsTextControl(mFocusedInput);
+ if (addDataList) {
+ MaybeObserveDataListMutations();
+ }
+
+ auto formAutoComplete = GetFormAutoComplete();
+ NS_ENSURE_TRUE(formAutoComplete, NS_ERROR_FAILURE);
+
+ formAutoComplete->AutoCompleteSearchAsync(aSearchParam, aSearchString,
+ mFocusedInput, aPreviousResult,
+ addDataList, this);
+ mLastFormAutoComplete = formAutoComplete;
+ }
+
+ return NS_OK;
+}
+
+void nsFormFillController::MaybeObserveDataListMutations() {
+ // If an <input> is focused, check if it has a list="<datalist>" which can
+ // provide the list of suggestions.
+
+ MOZ_ASSERT(!mPwmgrInputs.Get(mFocusedInput));
+
+ if (mFocusedInput) {
+ Element* list = mFocusedInput->GetList();
+
+ // Add a mutation observer to check for changes to the items in the
+ // <datalist> and update the suggestions accordingly.
+ if (mListNode != list) {
+ if (mListNode) {
+ mListNode->RemoveMutationObserver(this);
+ mListNode = nullptr;
+ }
+ if (list) {
+ list->AddMutationObserverUnlessExists(this);
+ mListNode = list;
+ }
+ }
+ }
+}
+
+void nsFormFillController::RevalidateDataList() {
+ if (!mLastListener) {
+ return;
+ }
+
+ nsCOMPtr<nsIAutoCompleteController> controller(
+ do_QueryInterface(mLastListener));
+ if (!controller) {
+ return;
+ }
+
+ // We cannot use previous result since any items in search target are updated.
+ mInvalidatePreviousResult = true;
+ controller->StartSearch(mLastSearchString);
+}
+
+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;
+ }
+
+ if (mLoginManagerAC) {
+ mLoginManagerAC->StopSearch();
+ }
+ return NS_OK;
+}
+
+////////////////////////////////////////////////////////////////////////
+//// nsIFormAutoCompleteObserver
+
+NS_IMETHODIMP
+nsFormFillController::OnSearchCompletion(nsIAutoCompleteResult* aResult) {
+ nsAutoString searchString;
+ aResult->GetSearchString(searchString);
+
+ mLastSearchString = searchString;
+
+ if (mLastListener) {
+ nsCOMPtr<nsIAutoCompleteObserver> lastListener = mLastListener;
+ lastListener->OnSearchResult(this, aResult);
+ }
+
+ return NS_OK;
+}
+
+////////////////////////////////////////////////////////////////////////
+//// nsIObserver
+
+NS_IMETHODIMP
+nsFormFillController::Observe(nsISupports* aSubject, const char* aTopic,
+ const char16_t* aData) {
+ if (!nsCRT::strcmp(aTopic, "chrome-event-target-created")) {
+ if (RefPtr<EventTarget> eventTarget = do_QueryObject(aSubject)) {
+ AttachListeners(eventTarget);
+ }
+ } else if (!nsCRT::strcmp(aTopic, "autofill-fill-starting")) {
+ mAutoCompleteActive = true;
+ } else if (!nsCRT::strcmp(aTopic, "autofill-fill-complete")) {
+ mAutoCompleteActive = false;
+ }
+ return NS_OK;
+}
+
+////////////////////////////////////////////////////////////////////////
+//// nsIDOMEventListener
+
+NS_IMETHODIMP
+nsFormFillController::HandleEvent(Event* aEvent) {
+ EventTarget* target = aEvent->GetOriginalTarget();
+ NS_ENSURE_STATE(target);
+
+ mInvalidatePreviousResult = false;
+
+ nsIGlobalObject* global = target->GetOwnerGlobal();
+ NS_ENSURE_STATE(global);
+ nsPIDOMWindowInner* inner = global->GetAsInnerWindow();
+ NS_ENSURE_STATE(inner);
+
+ if (!inner->GetBrowsingContext()->IsContent()) {
+ return NS_OK;
+ }
+
+ if (aEvent->ShouldIgnoreChromeEventTargetListener()) {
+ return NS_OK;
+ }
+
+ WidgetEvent* internalEvent = aEvent->WidgetEventPtr();
+ NS_ENSURE_STATE(internalEvent);
+
+ switch (internalEvent->mMessage) {
+ case eFocus:
+ return Focus(aEvent);
+ case eMouseDown:
+ return MouseDown(aEvent);
+ case eKeyDown:
+ return KeyDown(aEvent);
+ case eEditorInput: {
+ if (!(mAutoCompleteActive || mSuppressOnInput)) {
+ nsCOMPtr<nsINode> input =
+ do_QueryInterface(aEvent->GetComposedTarget());
+ if (IsTextControl(input) && IsFocusedInputControlled()) {
+ nsCOMPtr<nsIAutoCompleteController> controller = mController;
+ bool unused = false;
+ return controller->HandleText(&unused);
+ }
+ }
+ return NS_OK;
+ }
+ case eBlur:
+ if (mFocusedInput && !StaticPrefs::ui_popup_disable_autohide()) {
+ StopControllingInput();
+ }
+ return NS_OK;
+ case eCompositionStart:
+ NS_ASSERTION(mController, "should have a controller!");
+ if (IsFocusedInputControlled()) {
+ nsCOMPtr<nsIAutoCompleteController> controller = mController;
+ controller->HandleStartComposition();
+ }
+ return NS_OK;
+ case eCompositionEnd:
+ NS_ASSERTION(mController, "should have a controller!");
+ if (IsFocusedInputControlled()) {
+ nsCOMPtr<nsIAutoCompleteController> controller = mController;
+ controller->HandleEndComposition();
+ }
+ return NS_OK;
+ case eContextMenu:
+ if (mFocusedPopup) {
+ mFocusedPopup->ClosePopup();
+ }
+ return NS_OK;
+ case ePageHide: {
+ nsCOMPtr<Document> doc = do_QueryInterface(aEvent->GetTarget());
+ if (!doc) {
+ return NS_OK;
+ }
+
+ if (mFocusedInput && doc == mFocusedInput->OwnerDoc()) {
+ StopControllingInput();
+ }
+
+ // Only remove the observer notifications and marked autofill and password
+ // manager fields if the page isn't going to be persisted (i.e. it's being
+ // unloaded) so that appropriate autocomplete handling works with bfcache.
+ bool persisted = aEvent->AsPageTransitionEvent()->Persisted();
+ if (!persisted) {
+ RemoveForDocument(doc);
+ }
+ } break;
+ default:
+ // Handling the default case to shut up stupid -Wswitch warnings.
+ // One day compilers will be smarter...
+ break;
+ }
+
+ return NS_OK;
+}
+
+void nsFormFillController::AttachListeners(EventTarget* aEventTarget) {
+ EventListenerManager* elm = aEventTarget->GetOrCreateListenerManager();
+ NS_ENSURE_TRUE_VOID(elm);
+
+ elm->AddEventListenerByType(this, u"focus"_ns, TrustedEventsAtCapture());
+ elm->AddEventListenerByType(this, u"blur"_ns, TrustedEventsAtCapture());
+ elm->AddEventListenerByType(this, u"pagehide"_ns, TrustedEventsAtCapture());
+ elm->AddEventListenerByType(this, u"mousedown"_ns, TrustedEventsAtCapture());
+ elm->AddEventListenerByType(this, u"input"_ns, TrustedEventsAtCapture());
+ elm->AddEventListenerByType(this, u"keydown"_ns, TrustedEventsAtCapture());
+ elm->AddEventListenerByType(this, u"keypress"_ns,
+ TrustedEventsAtSystemGroupCapture());
+ elm->AddEventListenerByType(this, u"compositionstart"_ns,
+ TrustedEventsAtCapture());
+ elm->AddEventListenerByType(this, u"compositionend"_ns,
+ TrustedEventsAtCapture());
+ elm->AddEventListenerByType(this, u"contextmenu"_ns,
+ TrustedEventsAtCapture());
+}
+
+void nsFormFillController::RemoveForDocument(Document* aDoc) {
+ MOZ_LOG(sLogger, LogLevel::Verbose, ("RemoveForDocument: %p", aDoc));
+ for (auto iter = mPwmgrInputs.Iter(); !iter.Done(); iter.Next()) {
+ const nsINode* key = iter.Key();
+ if (key && (!aDoc || key->OwnerDoc() == aDoc)) {
+ // mFocusedInput's observer is tracked separately, so don't remove it
+ // here.
+ if (key != mFocusedInput) {
+ const_cast<nsINode*>(key)->RemoveMutationObserver(this);
+ }
+ iter.Remove();
+ }
+ }
+
+ for (auto iter = mAutofillInputs.Iter(); !iter.Done(); iter.Next()) {
+ const nsINode* key = iter.Key();
+ if (key && (!aDoc || key->OwnerDoc() == aDoc)) {
+ // mFocusedInput's observer is tracked separately, so don't remove it
+ // here.
+ if (key != mFocusedInput) {
+ const_cast<nsINode*>(key)->RemoveMutationObserver(this);
+ }
+ iter.Remove();
+ }
+ }
+}
+
+bool nsFormFillController::IsTextControl(nsINode* aNode) {
+ nsCOMPtr<nsIFormControl> formControl = do_QueryInterface(aNode);
+ return formControl && formControl->IsSingleLineTextControl(false);
+}
+
+void nsFormFillController::MaybeStartControllingInput(
+ HTMLInputElement* aInput) {
+ MOZ_LOG(sLogger, LogLevel::Verbose,
+ ("MaybeStartControllingInput for %p", aInput));
+ if (!aInput) {
+ return;
+ }
+
+ bool hasList = !!aInput->GetList();
+
+ if (!IsTextControl(aInput)) {
+ // Even if this is not a text control yet, it can become one in the future
+ if (hasList) {
+ StartControllingInput(aInput);
+ }
+ return;
+ }
+
+ bool autocomplete = nsContentUtils::IsAutocompleteEnabled(aInput);
+
+ bool isPwmgrInput = false;
+ if (mPwmgrInputs.Get(aInput) || aInput->HasBeenTypePassword()) {
+ isPwmgrInput = true;
+ }
+
+ bool isAutofillInput = false;
+ if (mAutofillInputs.Get(aInput)) {
+ isAutofillInput = true;
+ }
+
+ if (isAutofillInput || isPwmgrInput || hasList || autocomplete) {
+ StartControllingInput(aInput);
+ }
+}
+
+nsresult nsFormFillController::HandleFocus(HTMLInputElement* aInput) {
+ MaybeStartControllingInput(aInput);
+
+ // Bail if we didn't start controlling the input.
+ if (!mFocusedInput) {
+ return NS_OK;
+ }
+
+ // If this focus doesn't follow a right click within our specified
+ // threshold then show the autocomplete popup for all password fields.
+ // This is done to avoid showing both the context menu and the popup
+ // at the same time.
+ // We use a timestamp instead of a bool to avoid complexity when dealing with
+ // multiple input forms and the fact that a mousedown into an already focused
+ // field does not trigger another focus.
+
+ if (!mFocusedInput->HasBeenTypePassword()) {
+ return NS_OK;
+ }
+
+ // If we have not seen a right click yet, just show the popup.
+ if (mLastRightClickTimeStamp.IsNull()) {
+ mPasswordPopupAutomaticallyOpened = true;
+ ShowPopup();
+ return NS_OK;
+ }
+
+ uint64_t timeDiff =
+ (TimeStamp::Now() - mLastRightClickTimeStamp).ToMilliseconds();
+ if (timeDiff > mFocusAfterRightClickThreshold) {
+ mPasswordPopupAutomaticallyOpened = true;
+ ShowPopup();
+ }
+
+ return NS_OK;
+}
+
+nsresult nsFormFillController::Focus(Event* aEvent) {
+ nsCOMPtr<nsIContent> input = do_QueryInterface(aEvent->GetComposedTarget());
+ return HandleFocus(MOZ_KnownLive(HTMLInputElement::FromNodeOrNull(input)));
+}
+
+nsresult nsFormFillController::KeyDown(Event* aEvent) {
+ NS_ASSERTION(mController, "should have a controller!");
+
+ mPasswordPopupAutomaticallyOpened = false;
+
+ if (!IsFocusedInputControlled()) {
+ return NS_OK;
+ }
+
+ RefPtr<KeyboardEvent> keyEvent = aEvent->AsKeyboardEvent();
+ if (!keyEvent) {
+ return NS_ERROR_FAILURE;
+ }
+
+ bool cancel = false;
+ bool unused = false;
+
+ uint32_t k = keyEvent->KeyCode();
+ switch (k) {
+ case KeyboardEvent_Binding::DOM_VK_RETURN: {
+ nsCOMPtr<nsIAutoCompleteController> controller = mController;
+ controller->HandleEnter(false, aEvent, &cancel);
+ break;
+ }
+ case KeyboardEvent_Binding::DOM_VK_DELETE:
+#ifndef XP_MACOSX
+ {
+ nsCOMPtr<nsIAutoCompleteController> controller = mController;
+ controller->HandleDelete(&cancel);
+ break;
+ }
+ case KeyboardEvent_Binding::DOM_VK_BACK_SPACE: {
+ nsCOMPtr<nsIAutoCompleteController> controller = mController;
+ controller->HandleText(&unused);
+ break;
+ }
+#else
+ case KeyboardEvent_Binding::DOM_VK_BACK_SPACE: {
+ if (keyEvent->ShiftKey()) {
+ nsCOMPtr<nsIAutoCompleteController> controller = mController;
+ controller->HandleDelete(&cancel);
+ } else {
+ nsCOMPtr<nsIAutoCompleteController> controller = mController;
+ controller->HandleText(&unused);
+ }
+ break;
+ }
+#endif
+ case KeyboardEvent_Binding::DOM_VK_PAGE_UP:
+ case KeyboardEvent_Binding::DOM_VK_PAGE_DOWN: {
+ if (keyEvent->CtrlKey() || keyEvent->AltKey() || keyEvent->MetaKey()) {
+ break;
+ }
+ }
+ [[fallthrough]];
+ case KeyboardEvent_Binding::DOM_VK_UP:
+ case KeyboardEvent_Binding::DOM_VK_DOWN:
+ case KeyboardEvent_Binding::DOM_VK_LEFT:
+ case KeyboardEvent_Binding::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 (mFocusedInput) {
+ nsIFrame* frame = mFocusedInput->GetPrimaryFrame();
+ if (frame) {
+ wm = frame->GetWritingMode();
+ }
+ }
+ if (wm.IsVertical()) {
+ switch (k) {
+ case KeyboardEvent_Binding::DOM_VK_LEFT:
+ k = wm.IsVerticalLR() ? KeyboardEvent_Binding::DOM_VK_UP
+ : KeyboardEvent_Binding::DOM_VK_DOWN;
+ break;
+ case KeyboardEvent_Binding::DOM_VK_RIGHT:
+ k = wm.IsVerticalLR() ? KeyboardEvent_Binding::DOM_VK_DOWN
+ : KeyboardEvent_Binding::DOM_VK_UP;
+ break;
+ case KeyboardEvent_Binding::DOM_VK_UP:
+ k = KeyboardEvent_Binding::DOM_VK_LEFT;
+ break;
+ case KeyboardEvent_Binding::DOM_VK_DOWN:
+ k = KeyboardEvent_Binding::DOM_VK_RIGHT;
+ break;
+ }
+ }
+ nsCOMPtr<nsIAutoCompleteController> controller = mController;
+ controller->HandleKeyNavigation(k, &cancel);
+ break;
+ }
+ case KeyboardEvent_Binding::DOM_VK_ESCAPE: {
+ nsCOMPtr<nsIAutoCompleteController> controller = mController;
+ controller->HandleEscape(&cancel);
+ break;
+ }
+ case KeyboardEvent_Binding::DOM_VK_TAB: {
+ nsCOMPtr<nsIAutoCompleteController> controller = mController;
+ controller->HandleTab();
+ cancel = false;
+ 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 == KeyboardEvent_Binding::DOM_VK_RETURN) {
+ aEvent->StopPropagation();
+ }
+ }
+
+ return NS_OK;
+}
+
+nsresult nsFormFillController::MouseDown(Event* aEvent) {
+ MouseEvent* mouseEvent = aEvent->AsMouseEvent();
+ if (!mouseEvent) {
+ return NS_ERROR_FAILURE;
+ }
+
+ nsCOMPtr<nsINode> targetNode = do_QueryInterface(aEvent->GetComposedTarget());
+ if (!HTMLInputElement::FromNodeOrNull(targetNode)) {
+ return NS_OK;
+ }
+
+ int16_t button = mouseEvent->Button();
+
+ // In case of a right click we set a timestamp that
+ // will be checked in Focus() to avoid showing
+ // both contextmenu and popup at the same time.
+ if (button == 2) {
+ mLastRightClickTimeStamp = TimeStamp::Now();
+ return NS_OK;
+ }
+
+ if (button != 0) {
+ return NS_OK;
+ }
+
+ return ShowPopup();
+}
+
+NS_IMETHODIMP
+nsFormFillController::ShowPopup() {
+ bool isOpen = false;
+ GetPopupOpen(&isOpen);
+ if (isOpen) {
+ return SetPopupOpen(false);
+ }
+
+ nsCOMPtr<nsIAutoCompleteController> controller = mController;
+
+ nsCOMPtr<nsIAutoCompleteInput> input;
+ controller->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
+ controller->SetSearchString(u""_ns);
+ bool unused = false;
+ controller->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;
+ controller->HandleKeyNavigation(KeyboardEvent_Binding::DOM_VK_DOWN,
+ &cancel);
+ }
+
+ return NS_OK;
+}
+
+NS_IMETHODIMP nsFormFillController::GetPasswordPopupAutomaticallyOpened(
+ bool* _retval) {
+ *_retval = mPasswordPopupAutomaticallyOpened;
+ return NS_OK;
+}
+
+void nsFormFillController::StartControllingInput(HTMLInputElement* aInput) {
+ MOZ_LOG(sLogger, LogLevel::Verbose, ("StartControllingInput for %p", aInput));
+ // Make sure we're not still attached to an input
+ StopControllingInput();
+
+ if (!mController || !aInput) {
+ return;
+ }
+
+ nsCOMPtr<nsIAutoCompletePopup> popup = mPopups.Get(aInput->OwnerDoc());
+ if (!popup) {
+ popup = do_QueryActor("AutoComplete", aInput->OwnerDoc());
+ if (!popup) {
+ return;
+ }
+ }
+
+ mFocusedPopup = popup;
+
+ aInput->AddMutationObserverUnlessExists(this);
+ mFocusedInput = aInput;
+
+ if (Element* list = mFocusedInput->GetList()) {
+ list->AddMutationObserverUnlessExists(this);
+ mListNode = list;
+ }
+
+ if (!mFocusedInput->ReadOnly()) {
+ nsCOMPtr<nsIAutoCompleteController> controller = mController;
+ controller->SetInput(this);
+ }
+}
+
+bool nsFormFillController::IsFocusedInputControlled() const {
+ return mFocusedInput && mController && !mFocusedInput->ReadOnly();
+}
+
+void nsFormFillController::StopControllingInput() {
+ mPasswordPopupAutomaticallyOpened = false;
+
+ if (mListNode) {
+ mListNode->RemoveMutationObserver(this);
+ mListNode = nullptr;
+ }
+
+ if (nsCOMPtr<nsIAutoCompleteController> controller = 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;
+ controller->GetInput(getter_AddRefs(input));
+ if (input == this) {
+ MOZ_LOG(sLogger, LogLevel::Verbose,
+ ("StopControllingInput: Nulled controller input for %p", this));
+ controller->SetInput(nullptr);
+ }
+ }
+
+ MOZ_LOG(sLogger, LogLevel::Verbose,
+ ("StopControllingInput: Stopped controlling %p", mFocusedInput));
+ if (mFocusedInput) {
+ MaybeRemoveMutationObserver(mFocusedInput);
+ mFocusedInput = nullptr;
+ }
+
+ if (mFocusedPopup) {
+ mFocusedPopup->ClosePopup();
+ }
+ mFocusedPopup = nullptr;
+}
+
+nsIDocShell* nsFormFillController::GetDocShellForInput(
+ HTMLInputElement* aInput) {
+ NS_ENSURE_TRUE(aInput, nullptr);
+
+ nsCOMPtr<nsPIDOMWindowOuter> win = aInput->OwnerDoc()->GetWindow();
+ NS_ENSURE_TRUE(win, nullptr);
+
+ return win->GetDocShell();
+}
diff --git a/toolkit/components/satchel/nsFormFillController.h b/toolkit/components/satchel/nsFormFillController.h
new file mode 100644
index 0000000000..eef6addb7a
--- /dev/null
+++ b/toolkit/components/satchel/nsFormFillController.h
@@ -0,0 +1,146 @@
+/* -*- 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 "mozilla/TimeStamp.h"
+#include "nsIFormFillController.h"
+#include "nsIAutoCompleteInput.h"
+#include "nsIAutoCompleteSearch.h"
+#include "nsIAutoCompleteController.h"
+#include "nsIAutoCompletePopup.h"
+#include "nsIDOMEventListener.h"
+#include "nsIFormAutoComplete.h"
+#include "nsCOMPtr.h"
+#include "nsStubMutationObserver.h"
+#include "nsTHashMap.h"
+#include "nsInterfaceHashtable.h"
+#include "nsIDocShell.h"
+#include "nsILoginAutoCompleteSearch.h"
+#include "nsIMutationObserver.h"
+#include "nsIObserver.h"
+#include "nsCycleCollectionParticipant.h"
+
+class nsFormHistory;
+class nsINode;
+
+namespace mozilla {
+namespace dom {
+class EventTarget;
+class HTMLInputElement;
+} // namespace dom
+} // namespace mozilla
+
+class nsFormFillController final : public nsIFormFillController,
+ public nsIAutoCompleteInput,
+ public nsIAutoCompleteSearch,
+ public nsIFormAutoCompleteObserver,
+ public nsIDOMEventListener,
+ public nsIObserver,
+ public nsMultiMutationObserver {
+ public:
+ NS_DECL_CYCLE_COLLECTING_ISUPPORTS
+ NS_DECL_NSIFORMFILLCONTROLLER
+ NS_DECL_NSIAUTOCOMPLETESEARCH
+ NS_DECL_NSIAUTOCOMPLETEINPUT
+ NS_DECL_NSIFORMAUTOCOMPLETEOBSERVER
+ NS_DECL_NSIDOMEVENTLISTENER
+ NS_DECL_NSIOBSERVER
+ NS_DECL_NSIMUTATIONOBSERVER
+
+ NS_DECL_CYCLE_COLLECTION_CLASS_AMBIGUOUS(nsFormFillController,
+ nsIFormFillController)
+
+ MOZ_CAN_RUN_SCRIPT nsresult Focus(mozilla::dom::Event* aEvent);
+ MOZ_CAN_RUN_SCRIPT nsresult KeyDown(mozilla::dom::Event* aKeyEvent);
+ MOZ_CAN_RUN_SCRIPT nsresult MouseDown(mozilla::dom::Event* aMouseEvent);
+
+ nsFormFillController();
+
+ static already_AddRefed<nsFormFillController> GetSingleton();
+
+ protected:
+ MOZ_CAN_RUN_SCRIPT virtual ~nsFormFillController();
+
+ MOZ_CAN_RUN_SCRIPT
+ void StartControllingInput(mozilla::dom::HTMLInputElement* aInput);
+ MOZ_CAN_RUN_SCRIPT void StopControllingInput();
+
+ bool IsFocusedInputControlled() const;
+
+ MOZ_CAN_RUN_SCRIPT
+ nsresult HandleFocus(mozilla::dom::HTMLInputElement* aInput);
+
+ void AttachListeners(mozilla::dom::EventTarget* aEventTarget);
+
+ /**
+ * Checks that aElement is a type of element we want to fill, then calls
+ * StartControllingInput on it.
+ */
+ MOZ_CAN_RUN_SCRIPT
+ void MaybeStartControllingInput(mozilla::dom::HTMLInputElement* aElement);
+
+ void MaybeObserveDataListMutations();
+
+ MOZ_CAN_RUN_SCRIPT void RevalidateDataList();
+ bool RowMatch(nsFormHistory* aHistory, uint32_t aIndex,
+ const nsAString& aInputName, const nsAString& aInputValue);
+
+ inline nsIDocShell* GetDocShellForInput(
+ mozilla::dom::HTMLInputElement* aInput);
+
+ void MaybeRemoveMutationObserver(nsINode* aNode);
+
+ void RemoveForDocument(mozilla::dom::Document* aDoc);
+
+ bool IsTextControl(nsINode* aNode);
+
+ MOZ_CAN_RUN_SCRIPT NS_IMETHODIMP isLoginManagerField(
+ mozilla::dom::HTMLInputElement* aInput, bool* isLoginManagerField);
+
+ // members //////////////////////////////////////////
+
+ nsCOMPtr<nsIAutoCompleteController> mController;
+ nsCOMPtr<nsILoginAutoCompleteSearch> mLoginManagerAC;
+ mozilla::dom::HTMLInputElement* mFocusedInput;
+
+ // mListNode is a <datalist> element which, is set, has the form fill
+ // controller as a mutation observer for it.
+ nsINode* mListNode;
+ nsCOMPtr<nsIAutoCompletePopup> mFocusedPopup;
+
+ // Only used by tests.
+ nsInterfaceHashtable<nsRefPtrHashKey<mozilla::dom::Document>,
+ 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;
+
+ nsTHashMap<nsPtrHashKey<const nsINode>, bool> mPwmgrInputs;
+ nsTHashMap<nsPtrHashKey<const nsINode>, bool> mAutofillInputs;
+
+ uint16_t mFocusAfterRightClickThreshold;
+ uint32_t mTimeout;
+ uint32_t mMinResultsForPopup;
+ uint32_t mMaxRows;
+ mozilla::TimeStamp mLastRightClickTimeStamp;
+ bool mDisableAutoComplete;
+ bool mCompleteDefaultIndex;
+ bool mCompleteSelectedIndex;
+ bool mForceComplete;
+ bool mSuppressOnInput;
+ bool mPasswordPopupAutomaticallyOpened;
+ bool mAutoCompleteActive = false;
+ bool mInvalidatePreviousResult = false;
+};
+
+#endif // __nsFormFillController__
diff --git a/toolkit/components/satchel/nsIFormAutoComplete.idl b/toolkit/components/satchel/nsIFormAutoComplete.idl
new file mode 100644
index 0000000000..cc40872dd3
--- /dev/null
+++ b/toolkit/components/satchel/nsIFormAutoComplete.idl
@@ -0,0 +1,44 @@
+/* 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 nsIPropertyBag2;
+
+webidl HTMLInputElement;
+
+[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 HTMLInputElement aField,
+ in nsIAutoCompleteResult aPreviousResult,
+ in bool aAddDatalist,
+ 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();
+};
+
+[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
+ */
+ [can_run_script] void onSearchCompletion(in nsIAutoCompleteResult result);
+};
diff --git a/toolkit/components/satchel/nsIFormFillController.idl b/toolkit/components/satchel/nsIFormFillController.idl
new file mode 100644
index 0000000000..25bd2d6738
--- /dev/null
+++ b/toolkit/components/satchel/nsIFormFillController.idl
@@ -0,0 +1,69 @@
+/* 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 nsIAutoCompletePopup;
+
+webidl Document;
+webidl Element;
+webidl Event;
+webidl HTMLInputElement;
+
+/*
+ * 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 HTMLInputElement focusedInput;
+
+ /*
+ * Whether the autocomplete popup on a password field was automatically opened
+ * by the form fill controller (upon focus).
+ */
+ readonly attribute boolean passwordPopupAutomaticallyOpened;
+
+ // Only used by tests.
+ void attachPopupElementToDocument(in Document document, in Element popup);
+ void detachFromDocument(in Document document);
+
+ /*
+ * Returns true if aInput is managed by the login manager.
+ *
+ * @param aInput - The HTML <input> element to tag
+ */
+ [can_run_script] boolean isLoginManagerField(in HTMLInputElement aInput);
+
+ /*
+ * 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
+ */
+ [can_run_script] void markAsLoginManagerField(in HTMLInputElement aInput);
+
+ /*
+ * Mark the specified <input> element as being managed by a form autofill component.
+ * Autocomplete requests will be handed off to the autofill component.
+ *
+ * @param aInput - The HTML <input> element to mark
+ */
+ [can_run_script] void markAsAutofillField(in HTMLInputElement aInput);
+
+ /*
+ * Open the autocomplete popup, if possible.
+ */
+ [can_run_script] void showPopup();
+};
diff --git a/toolkit/components/satchel/test/FormHistoryTestUtils.sys.mjs b/toolkit/components/satchel/test/FormHistoryTestUtils.sys.mjs
new file mode 100644
index 0000000000..f17b952b90
--- /dev/null
+++ b/toolkit/components/satchel/test/FormHistoryTestUtils.sys.mjs
@@ -0,0 +1,106 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ FormHistory: "resource://gre/modules/FormHistory.sys.mjs",
+});
+
+/**
+ * Provides a js-friendly promise-based API around FormHistory, and utils.
+ *
+ * Note: This is not a 100% complete implementation, it is intended for quick
+ * additions and check, thus further changes may be necessary for different
+ * use-cases.
+ */
+export var FormHistoryTestUtils = {
+ /**
+ * Adds values to form history.
+ *
+ * @param {string} fieldname The field name.
+ * @param {Array} additions Array of entries describing the values to add.
+ * Each entry can either be a string, or an object with the shape
+ * { value, source}.
+ * @returns {Promise} Resolved once the operation is complete.
+ */
+ async add(fieldname, additions = []) {
+ // Additions are made one by one, so multiple identical entries are properly
+ // applied.
+ additions = additions.map(v => (typeof v == "string" ? { value: v } : v));
+ for (let { value, source } of additions) {
+ await lazy.FormHistory.update(
+ Object.assign({ fieldname }, { op: "bump", value, source })
+ );
+ }
+ },
+
+ /**
+ * Counts values from form history.
+ *
+ * @param {string} fieldname The field name.
+ * @param {Array} filters Objects describing the search properties.
+ * @returns {number} The number of entries found.
+ */
+ async count(fieldname, filters = {}) {
+ return lazy.FormHistory.count(Object.assign({ fieldname }, filters));
+ },
+
+ /**
+ * Removes values from form history.
+ * If you want to remove all history, use clear() instead.
+ *
+ * @param {string} fieldname The field name.
+ * @param {Array} removals Array of entries describing the values to add.
+ * Each entry can either be a string, or an object with the shape
+ * { value, source}. If source is specified, only the source relation will
+ * be removed, while the global form history value persists.
+ * @returns {Promise} Resolved once the operation is complete.
+ */
+ remove(fieldname, removals) {
+ let changes = removals.map(v => {
+ let criteria = typeof v == "string" ? { value: v } : v;
+ return Object.assign({ fieldname, op: "remove" }, criteria);
+ });
+ return lazy.FormHistory.update(changes);
+ },
+
+ /**
+ * Removes all values from form history.
+ * If you want to remove individual values, use remove() instead.
+ *
+ * @param {string} fieldname The field name whose history should be cleared.
+ * Can be omitted to clear all form history.
+ * @returns {Promise} Resolved once the operation is complete.
+ */
+ clear(fieldname) {
+ let baseChange = fieldname ? { fieldname } : {};
+ return lazy.FormHistory.update(Object.assign(baseChange, { op: "remove" }));
+ },
+
+ /**
+ * Searches form history.
+ *
+ * @param {string} fieldname The field name.
+ * @param {Array} filters Objects describing the search properties.
+ * @returns {Promise<Array>} Resolves an array of found form history entries.
+ */
+ search(fieldname, filters = {}) {
+ return lazy.FormHistory.search(null, Object.assign({ fieldname }, filters));
+ },
+
+ /**
+ * Gets autocomplete results from form history.
+ *
+ * @param {string} searchString The search string.
+ * @param {string} fieldname The field name.
+ * @param {Array} filters Objects describing the search properties.
+ * @returns {Promise<Array>} Resolves an array of found form history entries.
+ */
+ autocomplete(searchString, fieldname, filters = {}) {
+ return lazy.FormHistory.getAutoCompleteResults(
+ searchString,
+ Object.assign({ fieldname }, filters)
+ );
+ },
+};
diff --git a/toolkit/components/satchel/test/browser/browser.toml b/toolkit/components/satchel/test/browser/browser.toml
new file mode 100644
index 0000000000..7207c27f34
--- /dev/null
+++ b/toolkit/components/satchel/test/browser/browser.toml
@@ -0,0 +1,10 @@
+[DEFAULT]
+support-files = ["!/toolkit/components/satchel/test/subtst_privbrowsing.html"]
+
+["browser_close_tab.js"]
+
+["browser_popup_mouseover.js"]
+skip-if = ["verify"]
+
+["browser_privbrowsing_perwindowpb.js"]
+skip-if = ["verify"]
diff --git a/toolkit/components/satchel/test/browser/browser_close_tab.js b/toolkit/components/satchel/test/browser/browser_close_tab.js
new file mode 100644
index 0000000000..37962d37d8
--- /dev/null
+++ b/toolkit/components/satchel/test/browser/browser_close_tab.js
@@ -0,0 +1,46 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const { FormHistory } = ChromeUtils.importESModule(
+ "resource://gre/modules/FormHistory.sys.mjs"
+);
+
+add_task(async function test() {
+ const url = `data:text/html,<input type="text" name="field1">`;
+
+ // Open a dummy tab.
+ await BrowserTestUtils.withNewTab(
+ { gBrowser, url },
+ async function (browser) {}
+ );
+
+ await BrowserTestUtils.withNewTab(
+ { gBrowser, url },
+ async function (browser) {
+ const { autoCompletePopup } = browser;
+ const mockHistory = [{ op: "add", fieldname: "field1", value: "value1" }];
+
+ await FormHistory.update([{ op: "remove" }, ...mockHistory]);
+ await SpecialPowers.spawn(browser, [], async function () {
+ const input = content.document.querySelector("input");
+
+ input.focus();
+ });
+
+ // show popup
+ await BrowserTestUtils.synthesizeKey("VK_DOWN", {}, browser);
+ await TestUtils.waitForCondition(() => {
+ return autoCompletePopup.popupOpen;
+ });
+
+ gBrowser.removeCurrentTab();
+
+ await TestUtils.waitForCondition(() => {
+ return !autoCompletePopup.popupOpen;
+ });
+
+ Assert.ok(!autoCompletePopup.popupOpen, "Ensure the popup is closed.");
+ }
+ );
+});
diff --git a/toolkit/components/satchel/test/browser/browser_popup_mouseover.js b/toolkit/components/satchel/test/browser/browser_popup_mouseover.js
new file mode 100644
index 0000000000..2b293ab983
--- /dev/null
+++ b/toolkit/components/satchel/test/browser/browser_popup_mouseover.js
@@ -0,0 +1,81 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const { FormHistory } = ChromeUtils.importESModule(
+ "resource://gre/modules/FormHistory.sys.mjs"
+);
+
+add_task(async function test() {
+ const url = `data:text/html,<input type="text" name="field1">`;
+ await BrowserTestUtils.withNewTab(
+ { gBrowser, url },
+ async function (browser) {
+ const {
+ autoCompletePopup,
+ autoCompletePopup: { richlistbox: itemsBox },
+ } = browser;
+ const mockHistory = [
+ { op: "add", fieldname: "field1", value: "value1" },
+ { op: "add", fieldname: "field1", value: "value2" },
+ { op: "add", fieldname: "field1", value: "value3" },
+ { op: "add", fieldname: "field1", value: "value4" },
+ ];
+
+ await FormHistory.update([{ op: "remove" }, ...mockHistory]);
+ await SpecialPowers.spawn(browser, [], async function () {
+ const input = content.document.querySelector("input");
+
+ input.focus();
+ });
+
+ // show popup
+ await BrowserTestUtils.synthesizeKey("VK_DOWN", {}, browser);
+ await BrowserTestUtils.waitForCondition(() => {
+ return autoCompletePopup.popupOpen;
+ });
+ const listItemElems = itemsBox.querySelectorAll(
+ ".autocomplete-richlistitem"
+ );
+ Assert.equal(
+ listItemElems.length,
+ mockHistory.length,
+ "ensure result length"
+ );
+ Assert.equal(
+ autoCompletePopup.mousedOverIndex,
+ -1,
+ "mousedOverIndex should be -1"
+ );
+
+ // navigate to the first item
+ await BrowserTestUtils.synthesizeKey("VK_DOWN", {}, browser);
+ Assert.equal(
+ autoCompletePopup.selectedIndex,
+ 0,
+ "selectedIndex should be 0"
+ );
+
+ // mouseover the second item
+ EventUtils.synthesizeMouseAtCenter(listItemElems[1], {
+ type: "mouseover",
+ });
+ await BrowserTestUtils.waitForCondition(() => {
+ return (autoCompletePopup.mousedOverIndex = 1);
+ });
+ Assert.ok(true, "mousedOverIndex changed");
+ Assert.equal(
+ autoCompletePopup.selectedIndex,
+ 0,
+ "selectedIndex should not be changed by mouseover"
+ );
+
+ // close popup
+ await SpecialPowers.spawn(browser, [], async function () {
+ const input = content.document.querySelector("input");
+
+ input.blur();
+ });
+ }
+ );
+});
diff --git a/toolkit/components/satchel/test/browser/browser_privbrowsing_perwindowpb.js b/toolkit/components/satchel/test/browser/browser_privbrowsing_perwindowpb.js
new file mode 100644
index 0000000000..3abc6ebe54
--- /dev/null
+++ b/toolkit/components/satchel/test/browser/browser_privbrowsing_perwindowpb.js
@@ -0,0 +1,49 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { FormHistory } = ChromeUtils.importESModule(
+ "resource://gre/modules/FormHistory.sys.mjs"
+);
+
+/** Test for Bug 472396 */
+add_task(async function test() {
+ // initialization
+ let windowsToClose = [];
+ let testURI =
+ "http://example.com/tests/toolkit/components/satchel/test/subtst_privbrowsing.html";
+
+ async function doTest(aShouldValueExist, aWindow) {
+ let browser = aWindow.gBrowser.selectedBrowser;
+ BrowserTestUtils.startLoadingURIString(browser, testURI);
+ await BrowserTestUtils.browserLoaded(browser);
+
+ // Wait for the page to reload itself.
+ await BrowserTestUtils.browserLoaded(browser);
+
+ let count = await FormHistory.count({ fieldname: "field", value: "value" });
+
+ if (aShouldValueExist) {
+ Assert.equal(count, 1, "In non-PB mode, we add a single entry");
+ } else {
+ Assert.equal(count, 0, "In PB mode, we don't add any entries");
+ }
+ }
+
+ function testOnWindow(aOptions, aCallback) {
+ return BrowserTestUtils.openNewBrowserWindow(aOptions).then(win => {
+ windowsToClose.push(win);
+ return win;
+ });
+ }
+
+ await testOnWindow({ private: true }).then(aWin => doTest(false, aWin));
+
+ // Test when not on private mode after visiting a site on private
+ // mode. The form history should not exist.
+ await testOnWindow({}).then(aWin => doTest(true, aWin));
+
+ await Promise.all(
+ windowsToClose.map(win => BrowserTestUtils.closeWindow(win))
+ );
+});
diff --git a/toolkit/components/satchel/test/mochitest.toml b/toolkit/components/satchel/test/mochitest.toml
new file mode 100644
index 0000000000..beff4bc7d7
--- /dev/null
+++ b/toolkit/components/satchel/test/mochitest.toml
@@ -0,0 +1,49 @@
+[DEFAULT]
+skip-if = ["os == 'android'"]
+support-files = [
+ "satchel_common.js",
+ "subtst_form_submission_1.html",
+ "subtst_privbrowsing.html",
+ "parent_utils.js",
+]
+
+["test_bug_511615.html"]
+
+["test_bug_787624.html"]
+
+["test_capture_limit.html"]
+
+["test_datalist_attribute_change.html"]
+
+["test_datalist_dynamic.html"]
+
+["test_datalist_readonly_change.html"]
+
+["test_datalist_shadow_dom.html"]
+
+["test_datalist_with_caching.html"]
+
+["test_form_autocomplete.html"]
+
+["test_form_autocomplete_validation_at_input_event.html"]
+
+["test_form_autocomplete_with_list.html"]
+skip-if = [
+ "http3",
+ "http2",
+]
+
+["test_form_submission.html"]
+
+["test_history_datalist_duplicates.html"]
+
+["test_input_valid_state_with_autocomplete.html"]
+
+["test_password_autocomplete.html"]
+scheme = "https"
+
+["test_popup_direction.html"]
+
+["test_popup_enter_event.html"]
+
+["test_submit_on_keydown_enter.html"]
diff --git a/toolkit/components/satchel/test/parent_utils.js b/toolkit/components/satchel/test/parent_utils.js
new file mode 100644
index 0000000000..a46db9a63c
--- /dev/null
+++ b/toolkit/components/satchel/test/parent_utils.js
@@ -0,0 +1,194 @@
+/* eslint-env mozilla/chrome-script */
+
+const { FormHistory } = ChromeUtils.importESModule(
+ "resource://gre/modules/FormHistory.sys.mjs"
+);
+const { ContentTaskUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/ContentTaskUtils.sys.mjs"
+);
+const { TestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/TestUtils.sys.mjs"
+);
+
+var gAutocompletePopup =
+ Services.ww.activeWindow.document.getElementById("PopupAutoComplete");
+assert.ok(gAutocompletePopup, "Got autocomplete popup");
+
+var ParentUtils = {
+ getMenuEntries() {
+ let entries = [];
+ let numRows = gAutocompletePopup.view.matchCount;
+ for (let i = 0; i < numRows; i++) {
+ entries.push(gAutocompletePopup.view.getValueAt(i));
+ }
+ return entries;
+ },
+
+ cleanUpFormHistory() {
+ return FormHistory.update({ op: "remove" });
+ },
+
+ updateFormHistory(changes) {
+ FormHistory.update(changes).then(
+ () => {
+ sendAsyncMessage("formHistoryUpdated", { ok: true });
+ },
+ error => {
+ sendAsyncMessage("formHistoryUpdated", { ok: false });
+ assert.ok(false, error);
+ }
+ );
+ },
+
+ popupshownListener() {
+ let results = this.getMenuEntries();
+ sendAsyncMessage("onpopupshown", { results });
+ },
+
+ countEntries(name, value) {
+ let obj = {};
+ if (name) {
+ obj.fieldname = name;
+ }
+ if (value) {
+ obj.value = value;
+ }
+
+ FormHistory.count(obj).then(
+ count => {
+ sendAsyncMessage("entriesCounted", { ok: true, count });
+ },
+ error => {
+ assert.ok(false, error);
+ sendAsyncMessage("entriesCounted", { ok: false });
+ }
+ );
+ },
+
+ async checkRowCount(expectedCount, expectedFirstValue = null) {
+ await ContentTaskUtils.waitForCondition(() => {
+ // This may be called before gAutocompletePopup has initialised
+ // which causes it to throw
+ try {
+ return (
+ gAutocompletePopup.view.matchCount === expectedCount &&
+ (!expectedFirstValue ||
+ expectedCount <= 1 ||
+ gAutocompletePopup.view.getValueAt(0) === expectedFirstValue)
+ );
+ } catch (e) {
+ return false;
+ }
+ }, `Waiting for row count change to ${expectedCount}, first value: ${expectedFirstValue}.`);
+ return this.getMenuEntries();
+ },
+
+ async checkSelectedIndex(expectedIndex) {
+ await ContentTaskUtils.waitForCondition(
+ () =>
+ gAutocompletePopup.popupOpen &&
+ gAutocompletePopup.selectedIndex === expectedIndex,
+ "Checking selected index"
+ );
+ },
+
+ // Tests using this function need to flip pref for exceptional use of
+ // `new Function` / `eval()`.
+ // See test_autofill_and_ordinal_forms.html for example.
+ testMenuEntry(index, statement) {
+ ContentTaskUtils.waitForCondition(() => {
+ let el = gAutocompletePopup.richlistbox.getItemAtIndex(index);
+ let testFunc = new Services.ww.activeWindow.Function(
+ "el",
+ `return ${statement}`
+ );
+ return gAutocompletePopup.popupOpen && el && testFunc(el);
+ }, "Testing menu entry").then(() => {
+ sendAsyncMessage("menuEntryTested");
+ });
+ },
+
+ getPopupState() {
+ function reply() {
+ sendAsyncMessage("gotPopupState", {
+ open: gAutocompletePopup.popupOpen,
+ selectedIndex: gAutocompletePopup.selectedIndex,
+ direction: gAutocompletePopup.style.direction,
+ });
+ }
+ // If the popup state is stable, we can reply immediately. However, if
+ // it's showing or hiding, we should wait its finish and then, send the
+ // reply.
+ if (
+ gAutocompletePopup.state == "open" ||
+ gAutocompletePopup.state == "closed"
+ ) {
+ reply();
+ return;
+ }
+ const stablerState =
+ gAutocompletePopup.state == "showing" ? "open" : "closed";
+ TestUtils.waitForCondition(
+ () => gAutocompletePopup.state == stablerState,
+ `Waiting for autocomplete popup getting "${stablerState}" state`
+ ).then(reply);
+ },
+
+ observe(_subject, topic, data) {
+ // This function can be called after SimpleTest.finish().
+ // Do not write assertions here, they will lead to intermittent failures.
+ sendAsyncMessage("satchel-storage-changed", { subject: null, topic, data });
+ },
+
+ async cleanup() {
+ gAutocompletePopup.removeEventListener(
+ "popupshown",
+ this._popupshownListener
+ );
+ await this.cleanUpFormHistory();
+ },
+};
+
+ParentUtils._popupshownListener =
+ ParentUtils.popupshownListener.bind(ParentUtils);
+gAutocompletePopup.addEventListener(
+ "popupshown",
+ ParentUtils._popupshownListener
+);
+ParentUtils.cleanUpFormHistory();
+
+addMessageListener("updateFormHistory", msg => {
+ ParentUtils.updateFormHistory(msg.changes);
+});
+
+addMessageListener("countEntries", ({ name, value }) => {
+ ParentUtils.countEntries(name, value);
+});
+
+addMessageListener(
+ "waitForMenuChange",
+ ({ expectedCount, expectedFirstValue }) =>
+ ParentUtils.checkRowCount(expectedCount, expectedFirstValue)
+);
+
+addMessageListener("waitForSelectedIndex", ({ expectedIndex }) =>
+ ParentUtils.checkSelectedIndex(expectedIndex)
+);
+addMessageListener("waitForMenuEntryTest", ({ index, statement }) => {
+ ParentUtils.testMenuEntry(index, statement);
+});
+
+addMessageListener("getPopupState", () => {
+ ParentUtils.getPopupState();
+});
+
+addMessageListener("addObserver", () => {
+ Services.obs.addObserver(ParentUtils, "satchel-storage-changed");
+});
+addMessageListener("removeObserver", () => {
+ Services.obs.removeObserver(ParentUtils, "satchel-storage-changed");
+});
+
+addMessageListener("cleanup", async () => {
+ await ParentUtils.cleanup();
+});
diff --git a/toolkit/components/satchel/test/satchel_common.js b/toolkit/components/satchel/test/satchel_common.js
new file mode 100644
index 0000000000..4e3436aba7
--- /dev/null
+++ b/toolkit/components/satchel/test/satchel_common.js
@@ -0,0 +1,316 @@
+/* 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;
+
+const TelemetryFilterPropsAC = Object.freeze({
+ category: "form_autocomplete",
+ method: "show",
+ object: "logins",
+});
+
+/*
+ * Returns the element with the specified |name| attribute.
+ */
+function getFormElementByName(formNum, name) {
+ const formElement = document.querySelector(
+ `#form${formNum} [name="${name}"]`
+ );
+
+ if (!formElement) {
+ ok(false, `getFormElementByName: Couldn't find specified CSS selector.`);
+ return null;
+ }
+
+ return formElement;
+}
+
+function registerPopupShownListener(listener) {
+ if (gPopupShownListener) {
+ ok(false, "got too many popupshownlisteners");
+ return;
+ }
+ gPopupShownListener = listener;
+}
+
+function getMenuEntries() {
+ if (!gLastAutoCompleteResults) {
+ throw new Error("no autocomplete results");
+ }
+
+ let results = gLastAutoCompleteResults;
+ gLastAutoCompleteResults = null;
+ return results;
+}
+
+class StorageEventsObserver {
+ promisesToResolve = [];
+
+ constructor() {
+ gChromeScript.sendAsyncMessage("addObserver");
+ gChromeScript.addMessageListener(
+ "satchel-storage-changed",
+ this.observe.bind(this)
+ );
+ }
+
+ async cleanup() {
+ await gChromeScript.sendQuery("removeObserver");
+ }
+
+ observe({ subject, topic, data }) {
+ this.promisesToResolve.shift()?.({ subject, topic, data });
+ }
+
+ promiseNextStorageEvent() {
+ return new Promise(resolve => this.promisesToResolve.push(resolve));
+ }
+}
+
+function getFormSubmitButton(formNum) {
+ let form = $("form" + formNum); // by id, not name
+ ok(form != null, "getting form " + formNum);
+
+ // we can't just call form.submit(), because that doesn't seem to
+ // invoke the form onsubmit handler.
+ let button = form.firstChild;
+ while (button && button.type != "submit") {
+ button = button.nextSibling;
+ }
+ ok(button != null, "getting form submit button");
+
+ return button;
+}
+
+// Count the number of entries with the given name and value, and call then(number)
+// when done. If name or value is null, then the value of that field does not matter.
+function countEntries(name, value, then = null) {
+ return new Promise(resolve => {
+ gChromeScript.sendAsyncMessage("countEntries", { name, value });
+ gChromeScript.addMessageListener("entriesCounted", function counted(data) {
+ gChromeScript.removeMessageListener("entriesCounted", counted);
+ if (!data.ok) {
+ ok(false, "Error occurred counting form history");
+ SimpleTest.finish();
+ return;
+ }
+
+ if (then) {
+ then(data.count);
+ }
+ resolve(data.count);
+ });
+ });
+}
+
+// Wrapper around FormHistory.update which handles errors. Calls then() when done.
+function updateFormHistory(changes, then = null) {
+ return new Promise(resolve => {
+ gChromeScript.sendAsyncMessage("updateFormHistory", { changes });
+ gChromeScript.addMessageListener(
+ "formHistoryUpdated",
+ function updated({ ok }) {
+ gChromeScript.removeMessageListener("formHistoryUpdated", updated);
+ if (!ok) {
+ ok(false, "Error occurred updating form history");
+ SimpleTest.finish();
+ return;
+ }
+
+ if (then) {
+ then();
+ }
+ resolve();
+ }
+ );
+ });
+}
+
+async function notifyMenuChanged(expectedCount, expectedFirstValue) {
+ gLastAutoCompleteResults = await gChromeScript.sendQuery(
+ "waitForMenuChange",
+ { expectedCount, expectedFirstValue }
+ );
+ return gLastAutoCompleteResults;
+}
+
+function notifySelectedIndex(expectedIndex) {
+ return gChromeScript.sendQuery("waitForSelectedIndex", { expectedIndex });
+}
+
+function testMenuEntry(index, statement) {
+ return new Promise(resolve => {
+ gChromeScript.sendAsyncMessage("waitForMenuEntryTest", {
+ index,
+ statement,
+ });
+ gChromeScript.addMessageListener("menuEntryTested", function changed() {
+ gChromeScript.removeMessageListener("menuEntryTested", changed);
+ resolve();
+ });
+ });
+}
+
+function getPopupState(then = null) {
+ return new Promise(resolve => {
+ gChromeScript.sendAsyncMessage("getPopupState");
+ gChromeScript.addMessageListener("gotPopupState", function listener(state) {
+ gChromeScript.removeMessageListener("gotPopupState", listener);
+ if (then) {
+ then(state);
+ }
+ resolve(state);
+ });
+ });
+}
+
+function listenForUnexpectedPopupShown() {
+ gPopupShownListener = function onPopupShown() {
+ if (!gPopupShownExpected) {
+ ok(false, "Unexpected autocomplete popupshown event");
+ }
+ };
+}
+
+async function popupBy(triggerFn) {
+ gPopupShownExpected = true;
+ const promise = new Promise(resolve => {
+ gPopupShownListener = ({ results }) => {
+ gPopupShownExpected = false;
+ resolve(results);
+ };
+ });
+ if (triggerFn) {
+ triggerFn();
+ }
+ return promise;
+}
+
+async function noPopupBy(triggerFn) {
+ gPopupShownExpected = false;
+ listenForUnexpectedPopupShown();
+ SimpleTest.requestFlakyTimeout(
+ "Giving a chance for an unexpected popupshown to occur"
+ );
+ if (triggerFn) {
+ await triggerFn();
+ }
+ await new Promise(resolve => setTimeout(resolve, 500));
+}
+
+async function popupByArrowDown() {
+ return popupBy(() => {
+ synthesizeKey("KEY_Escape"); // in case popup is already open
+ synthesizeKey("KEY_ArrowDown");
+ });
+}
+
+async function noPopupByArrowDown() {
+ await noPopupBy(() => {
+ synthesizeKey("KEY_Escape"); // in case popup is already open
+ synthesizeKey("KEY_ArrowDown");
+ });
+}
+
+function checkACTelemetryEvent(actualEvent, input, augmentedExtra) {
+ ok(
+ parseInt(actualEvent[4], 10) > 0,
+ "elapsed time is a positive integer after converting from a string"
+ );
+ let expectedExtra = {
+ acFieldName: SpecialPowers.wrap(input).getAutocompleteInfo().fieldName,
+ typeWasPassword: SpecialPowers.wrap(input).hasBeenTypePassword ? "1" : "0",
+ fieldType: input.type,
+ stringLength: input.value.length + "",
+ ...augmentedExtra,
+ };
+ isDeeply(actualEvent[5], expectedExtra, "Check event extra object");
+}
+
+let gStorageEventsObserver;
+
+function promiseNextStorageEvent() {
+ return gStorageEventsObserver.promiseNextStorageEvent();
+}
+
+function satchelCommonSetup() {
+ let chromeURL = SimpleTest.getTestFileURL("parent_utils.js");
+ gChromeScript = SpecialPowers.loadChromeScript(chromeURL);
+ gChromeScript.addMessageListener("onpopupshown", ({ results }) => {
+ gLastAutoCompleteResults = results;
+ if (gPopupShownListener) {
+ gPopupShownListener({ results });
+ }
+ });
+
+ gStorageEventsObserver = new StorageEventsObserver();
+
+ SimpleTest.registerCleanupFunction(async () => {
+ await gStorageEventsObserver.cleanup();
+ await gChromeScript.sendQuery("cleanup");
+ gChromeScript.destroy();
+ });
+}
+
+function add_named_task(name, fn) {
+ return add_task(
+ {
+ [name]() {
+ return fn();
+ },
+ }[name]
+ );
+}
+
+function preventSubmitOnForms() {
+ for (const form of document.querySelectorAll("form")) {
+ form.onsubmit = e => e.preventDefault();
+ }
+}
+
+/**
+ * Press requested keys and assert input's value
+ *
+ * @param {HTMLInputElement} input
+ * @param {string | Array} keys
+ * @param {string} expectedValue
+ */
+function assertValueAfterKeys(input, keys, expectedValue) {
+ if (!Array.isArray(keys)) {
+ keys = [keys];
+ }
+ for (const key of keys) {
+ synthesizeKey(key);
+ }
+
+ is(input.value, expectedValue, "input value");
+}
+
+function assertAutocompleteItems(...expectedValues) {
+ const actualValues = getMenuEntries();
+ isDeeply(actualValues, expectedValues, "expected autocomplete list");
+}
+
+function deleteSelectedAutocompleteItem() {
+ synthesizeKey("KEY_Delete", { shiftKey: true });
+}
+
+async function openPopupOn(
+ inputOrSelector,
+ { inputValue = "", expectPopup = true } = {}
+) {
+ const input =
+ typeof inputOrSelector == "string"
+ ? document.querySelector(inputOrSelector)
+ : inputOrSelector;
+ input.value = inputValue;
+ input.focus();
+ const items = await (expectPopup ? popupByArrowDown() : noPopupByArrowDown());
+ return { input, items };
+}
+
+satchelCommonSetup();
diff --git a/toolkit/components/satchel/test/subtst_form_submission_1.html b/toolkit/components/satchel/test/subtst_form_submission_1.html
new file mode 100644
index 0000000000..c1b902d6bc
--- /dev/null
+++ b/toolkit/components/satchel/test/subtst_form_submission_1.html
@@ -0,0 +1,15 @@
+<!DOCTYPE HTML>
+<html>
+
+<head>
+</head>
+
+<body>
+
+<form>
+ <input id="subtest2" type="text" name="subtest2">
+ <button type="submit">Submit</button>
+</form>
+
+</body>
+</html>
diff --git a/toolkit/components/satchel/test/subtst_privbrowsing.html b/toolkit/components/satchel/test/subtst_privbrowsing.html
new file mode 100644
index 0000000000..a61da1a714
--- /dev/null
+++ b/toolkit/components/satchel/test/subtst_privbrowsing.html
@@ -0,0 +1,23 @@
+<html>
+<head>
+ <meta charset=UTF-8>
+ <title>Subtest for bug 472396</title>
+ <script>
+ /* exported submitForm */
+ function submitForm() {
+ if (!location.search.includes("field")) {
+ let form = document.getElementById("form");
+ let field = SpecialPowers.wrap(document.getElementById("field"));
+ field.setUserInput("value");
+ form.submit();
+ }
+ }
+ </script>
+</head>
+<body onload="submitForm();">
+ <h2>Subtest for bug 472396</h2>
+ <form id="form">
+ <input name="field" id="field">
+ </form>
+</body>
+</html>
diff --git a/toolkit/components/satchel/test/test_bug_511615.html b/toolkit/components/satchel/test/test_bug_511615.html
new file mode 100644
index 0000000000..2d67c5468b
--- /dev/null
+++ b/toolkit/components/satchel/test/test_bug_511615.html
@@ -0,0 +1,179 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>Test for Form History Autocomplete Untrusted Events: Bug 511615</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/EventUtils.js"></script>
+ <script type="text/javascript" src="satchel_common.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
+</head>
+<body>
+Test for Form History Autocomplete Untrusted Events: Bug 511615
+<p id="display"></p>
+
+<!-- we presumably can't hide the content for this test. -->
+<div id="content">
+ <!-- normal, basic form -->
+ <form id="form1" onsubmit="return false;">
+ <input type="text" name="field1">
+ <button type="submit">Submit</button>
+ </form>
+</div>
+
+<script>
+/**
+ * Indicates the time to wait before checking that the state of the autocomplete
+ * popup, including whether it is open, has not changed in response to events.
+ *
+ * Manual testing on a fast machine revealed that 80ms was still unreliable,
+ * while 100ms detected a simulated failure reliably. Unfortunately, this means
+ * that to take into account slower machines we should use a larger value.
+ *
+ * Note that if a machine takes more than this time to show the popup, this
+ * would not cause a failure, conversely the machine would not be able to detect
+ * whether the test should have failed. In other words, this use of timeouts is
+ * never expected to cause intermittent failures with test automation.
+ */
+const POPUP_RESPONSE_WAIT_TIME_MS = 200;
+
+/**
+ * Checks that the selected index in the popup still matches the given value.
+ *
+ * @param {number} expectedIndex
+ * @returns {Promise}
+ */
+async function checkSelectedIndexAfterResponseTime(expectedIndex) {
+ const popupState = await new Promise(resolve => {
+ setTimeout(() => getPopupState(resolve), POPUP_RESPONSE_WAIT_TIME_MS);
+ });
+ is(popupState.open, true, "Popup should still be open.");
+ is(popupState.selectedIndex, expectedIndex, "Selected index should match.");
+}
+
+const input = document.querySelector("#form1 > input");
+
+function doKeyUnprivileged(key) {
+ const keyName = "DOM_VK_" + key.toUpperCase();
+ let keycode, charcode, alwaysval;
+
+ if (key.length == 1) {
+ [keycode, charcode, alwaysval] = [0, key.charCodeAt(0), charcode];
+ } else {
+ [keycode, charcode, alwaysval] = [KeyEvent[keyName], 0, keycode];
+ if (!keycode) {
+ throw new Error("invalid keyname in test");
+ }
+ }
+
+ const dnEvent = new KeyboardEvent("keydown", {
+ bubbles: true,
+ cancelable: true,
+ view: null,
+ keyCode: alwaysval,
+ charCode: 0,
+ });
+ const prEvent = new KeyboardEvent("keypress", {
+ bubbles: true,
+ cancelable: true,
+ view: null,
+ keyCode: keycode,
+ charCode: charcode,
+ });
+ const upEvent = new KeyboardEvent("keyup", {
+ bubbles: true,
+ cancelable: true,
+ view: null,
+ keyCode: alwaysval,
+ charCode: 0,
+ });
+
+ input.dispatchEvent(dnEvent);
+ input.dispatchEvent(prEvent);
+ input.dispatchEvent(upEvent);
+}
+
+function doClickWithMouseEventUnprivileged() {
+ let dnEvent = document.createEvent("MouseEvent");
+ let upEvent = document.createEvent("MouseEvent");
+ let ckEvent = document.createEvent("MouseEvent");
+
+ /* eslint-disable no-multi-spaces, max-len */
+ dnEvent.initMouseEvent("mousedown", true, true, window, 1, 0, 0, 0, 0, false, false, false, false, 0, null);
+ upEvent.initMouseEvent("mouseup", true, true, window, 1, 0, 0, 0, 0, false, false, false, false, 0, null);
+ ckEvent.initMouseEvent("mouseclick", true, true, window, 1, 0, 0, 0, 0, false, false, false, false, 0, null);
+ /* eslint-enable no-multi-spaces, max-len */
+
+ input.dispatchEvent(dnEvent);
+ input.dispatchEvent(upEvent);
+ input.dispatchEvent(ckEvent);
+}
+
+add_setup(async () => {
+ await new Promise(resolve => updateFormHistory([
+ { op: "remove" },
+ { op: "add", fieldname: "field1", value: "value1" },
+ { op: "add", fieldname: "field1", value: "value2" },
+ { op: "add", fieldname: "field1", value: "value3" },
+ { op: "add", fieldname: "field1", value: "value4" },
+ { op: "add", fieldname: "field1", value: "value5" },
+ { op: "add", fieldname: "field1", value: "value6" },
+ { op: "add", fieldname: "field1", value: "value7" },
+ { op: "add", fieldname: "field1", value: "value8" },
+ { op: "add", fieldname: "field1", value: "value9" },
+ ], resolve));
+});
+
+add_task(async function test_untrusted_events_ignored() {
+ // The autocomplete popup should not open from untrusted events.
+ for (let triggerFn of [
+ () => input.focus(),
+ () => input.click(),
+ () => doClickWithMouseEventUnprivileged(),
+ () => doKeyUnprivileged("down"),
+ () => doKeyUnprivileged("page_down"),
+ () => doKeyUnprivileged("return"),
+ () => doKeyUnprivileged("v"),
+ () => doKeyUnprivileged(" "),
+ () => doKeyUnprivileged("back_space"),
+ ]) {
+ // We must wait for the entire timeout for each individual test, because the
+ // next event in the list might prevent the popup from opening.
+ await noPopupBy(triggerFn);
+ }
+
+ // A privileged key press will actually open the popup.
+ await openPopupOn(input);
+
+ // The selected autocomplete item should not change from untrusted events.
+ for (let triggerFn of [
+ () => doKeyUnprivileged("down"),
+ () => doKeyUnprivileged("page_down"),
+ ]) {
+ triggerFn();
+ await checkSelectedIndexAfterResponseTime(-1);
+ }
+
+ // A privileged key press will actually change the selected index.
+ const indexChanged = notifySelectedIndex(0);
+ synthesizeKey("KEY_ArrowDown");
+ await indexChanged;
+
+ // The selected autocomplete item should not change and it should not be
+ // possible to use it from untrusted events.
+ for (let triggerFn of [
+ () => doKeyUnprivileged("down"),
+ () => doKeyUnprivileged("page_down"),
+ () => doKeyUnprivileged("right"),
+ () => doKeyUnprivileged(" "),
+ () => doKeyUnprivileged("back_space"),
+ () => doKeyUnprivileged("back_space"),
+ () => doKeyUnprivileged("return"),
+ ]) {
+ triggerFn();
+ await checkSelectedIndexAfterResponseTime(0);
+ is(input.value, "", "The selected item should not have been used.");
+ }
+});
+</script>
+</body>
+</html>
diff --git a/toolkit/components/satchel/test/test_bug_787624.html b/toolkit/components/satchel/test/test_bug_787624.html
new file mode 100644
index 0000000000..13af53a477
--- /dev/null
+++ b/toolkit/components/satchel/test/test_bug_787624.html
@@ -0,0 +1,73 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>Test for Layout of Form History Autocomplete: Bug 787624</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/EventUtils.js"></script>
+ <script type="text/javascript" src="satchel_common.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
+ <style>
+ .container {
+ border: 1px solid #333;
+ width: 80px;
+ height: 26px;
+ position: absolute;
+ z-index: 2;
+ }
+
+ .subcontainer {
+ width: 100%;
+ overflow: hidden;
+ }
+
+ .subcontainer input {
+ width: 120px;
+ margin: 2px 6px;
+ padding-right: 4px;
+ border: none;
+ height: 22px;
+ z-index: 1;
+ outline: 1px dashed #555
+ }
+ </style>
+</head>
+<body>
+Form History Layout test: form field autocomplete: Bug 787624
+<p id="display"></p>
+
+<!-- we presumably can't hide the content for this test. -->
+<div id="content">
+ <!-- in this form, the input field is partially hidden and can scroll -->
+ <div class="container">
+ <div class="subcontainer">
+ <form id="form1" onsubmit="return false;">
+ <input type="text" name="field1">
+ <button type="submit">Submit</button>
+ </form>
+ </div>
+ </div>
+</div>
+
+<script>
+add_setup(async () => {
+ await updateFormHistory([
+ { op: "remove" },
+ { op: "add", fieldname: "field1", value: "value1" },
+ { op: "add", fieldname: "field1", value: "value2" },
+ ]);
+});
+
+add_task(async function test_popup_not_move_input() {
+ const input = document.querySelector("#form1 > input");
+ const rect = input.getBoundingClientRect();
+ await openPopupOn(input);
+ const newRect = input.getBoundingClientRect();
+ isDeeply(
+ [newRect.left, newRect.top],
+ [rect.left, rect.top],
+ "popup does not disturb the input position");
+});
+
+</script>
+</body>
+</html>
diff --git a/toolkit/components/satchel/test/test_capture_limit.html b/toolkit/components/satchel/test/test_capture_limit.html
new file mode 100644
index 0000000000..8591544016
--- /dev/null
+++ b/toolkit/components/satchel/test/test_capture_limit.html
@@ -0,0 +1,61 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>Form History capture no more than 100 changes</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script type="text/javascript" src="satchel_common.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
+</head>
+<body>
+<p id="display"></p>
+<div id="content" style="display: none">
+ <form id="form1">
+ <button type="submit">Submit</button>
+ </form>
+</div>
+<script>
+
+add_setup(async () => {
+ const count = await countEntries(null, null);
+ ok(!count, "initial storage is empty");
+});
+
+add_task(async function captureLimit() {
+ // Capture no more than 100 fields per submit. See FormHistoryChild.jsm.
+ const inputsCount = 100 + 2;
+ const form = document.getElementById("form1");
+ for (let i = 1; i <= inputsCount; i++) {
+ const newField = document.createElement("input");
+ newField.setAttribute("type", "text");
+ newField.setAttribute("name", "test" + i);
+ form.appendChild(newField);
+ if( i != 50) {
+ SpecialPowers.wrap(newField).setUserInput(i);
+ }
+ }
+
+ form.addEventListener("submit", e => e.preventDefault(), { once: true });
+ const storageUpdated = promiseNextStorageEvent();
+ getFormSubmitButton(1).click();
+ await storageUpdated;
+
+ for (let i = 1; i <= inputsCount; i++) { // check all but last
+ const historyEntries = await countEntries("test" + i, i);
+
+ switch(i) {
+ case 50:
+ is(historyEntries, 0, `no history saved for input ${i} because user didn't modify it`);
+ break;
+ case 102:
+ is(historyEntries, 0, `no history saved for input ${i} because form already captured 100 fields`);
+ break;
+ default:
+ is(historyEntries, 1, `history saved for input ${i}`);
+ break;
+ }
+ }
+});
+
+</script>
+</body>
+</html>
diff --git a/toolkit/components/satchel/test/test_datalist_attribute_change.html b/toolkit/components/satchel/test/test_datalist_attribute_change.html
new file mode 100644
index 0000000000..f9c774f8d0
--- /dev/null
+++ b/toolkit/components/satchel/test/test_datalist_attribute_change.html
@@ -0,0 +1,53 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>Test for Form History / Attribute change with datalist entries: Bug 1767250</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/EventUtils.js"></script>
+ <script type="text/javascript" src="satchel_common.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
+</head>
+<body>
+<div id="content">
+
+ <form>
+ <input list="suggest" type="button" name="input" id="input" />
+ <datalist id="suggest">
+ <option value="Mozilla">
+ <option value="Firefox">
+ <option value="Thunderbird">
+ </datalist>
+ </form>
+
+</div>
+
+<script>
+
+add_task(async function test_dropdown_shown_when_type_attribute_changed() {
+ const input = document.getElementById("input");
+ input.addEventListener("click", () => input.setAttribute("type", "text"));
+
+ is(input.type, "button", "Input type is initially button.");
+
+ synthesizeMouseAtCenter(input, { button: input, type: "mousedown" }, window);
+ synthesizeMouseAtCenter(input, { button: input, type: "mouseup" }, window);
+
+ await SimpleTest.promiseWaitForCondition(() => input.type === "text", "Input type changed to text.");
+
+ is(document.activeElement, input, "Text input is focused.");
+ // In the course of fixing Bug 1767250, we discovered that the focus ring was not shown although the element was focused.
+ // We decided to refer fixing this to a later bug, This is tracked in Bug 1788698.
+ // ok(input.matches(":focus-visible"), "Outer focus ring is shown.");
+
+ await openPopupOn(input);
+
+ isDeeply(
+ getMenuEntries(),
+ ["Mozilla", "Firefox", "Thunderbird"],
+ "Datalist shown after changing input type from button to text.");
+ input.removeEventListener("click", () => input.setAttribute("type", "text"));
+});
+
+</script>
+</body>
+</html>
diff --git a/toolkit/components/satchel/test/test_datalist_dynamic.html b/toolkit/components/satchel/test/test_datalist_dynamic.html
new file mode 100644
index 0000000000..89452236ec
--- /dev/null
+++ b/toolkit/components/satchel/test/test_datalist_dynamic.html
@@ -0,0 +1,82 @@
+<!DOCTYPE HTML>
+<html>
+<title>Dynamic change datalist</title>
+<script src="/tests/SimpleTest/SimpleTest.js"></script>
+<script src="/tests/SimpleTest/EventUtils.js"></script>
+<script src="satchel_common.js"></script>
+<link rel="stylesheet" href="/tests/SimpleTest/test.css" />
+<input list="suggest" type="text" name="field1">
+<datalist id="suggest">
+ <option value="a1">
+ <option value="a2">
+ <option value="ab1">
+ <option value="ab2">
+</datalist>
+<script>
+const { TestUtils } = SpecialPowers.ChromeUtils.importESModule(
+ "resource://testing-common/TestUtils.sys.mjs"
+);
+
+const DATALIST_DATA = {
+ "a": ["a1", "a2", "ab1", "ab2"],
+ "ab": ["ab1", "ab2", "abc1", "abc2"],
+ "abc": ["abc1", "abc2", "abcd1", "abcd2"],
+ "abcd": ["abcd1", "abcd2", "abcde1", "abcde2", "abcde3"]
+};
+
+add_task(async function() {
+ const input = document.querySelector("input");
+ const datalist = document.querySelector("datalist");
+
+ async function inputHandler() {
+ const options = DATALIST_DATA[input.value] || [];
+
+ await TestUtils.waitForTick();
+
+ while (datalist.firstChild) {
+ datalist.firstChild.remove();
+ }
+
+ for (const option of options) {
+ const element = document.createElement("option");
+ element.setAttribute("value", option);
+ datalist.appendChild(element);
+ }
+ }
+
+ await SimpleTest.promiseFocus();
+
+ input.addEventListener("input", inputHandler);
+
+ input.focus();
+ synthesizeKey("a");
+ synthesizeKey("b");
+ synthesizeKey("c");
+ is(input.value, "abc", "<input>'s value has to be abc for initial data");
+ await notifyMenuChanged(4);
+ let values = getMenuEntries();
+ is(values.length, 4, "expected count of datalist popup");
+ for (let i = 0; i < values.length; i++) {
+ is(values[i], DATALIST_DATA[input.value][i], "expected data #" + i);
+ }
+
+ let promise = notifyMenuChanged(5);
+ synthesizeKey("d");
+ is(input.value, "abcd", "<input>'s value has to be abcd for next test");
+ synthesizeKey("KEY_ArrowDown");
+ await promise;
+
+ values = getMenuEntries();
+ is(values.length, 5, "expected count of datalist popup");
+ for (let i = 0; i < values.length; i++) {
+ is(values[i], DATALIST_DATA[input.value][i], "expected data #" + i);
+ }
+
+ synthesizeKey("KEY_ArrowDown");
+ synthesizeKey("KEY_Enter");
+ is(input.value, "abcd1", "<input>'s value has to set abcd1");
+
+ input.removeEventListener("input", inputHandler);
+});
+</script>
+</html>
diff --git a/toolkit/components/satchel/test/test_datalist_readonly_change.html b/toolkit/components/satchel/test/test_datalist_readonly_change.html
new file mode 100644
index 0000000000..986ef0cb7e
--- /dev/null
+++ b/toolkit/components/satchel/test/test_datalist_readonly_change.html
@@ -0,0 +1,41 @@
+<!DOCTYPE HTML>
+<html>
+
+<head>
+ <title>Dynamic change to readonly doesn't prevent datalist to keep working</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/EventUtils.js"></script>
+ <script src="satchel_common.js"></script>
+ <link rel="stylesheet" href="/tests/SimpleTest/test.css" />
+</head>
+
+<body>
+ <input readonly list="suggest" type="text" name="field1">
+ <datalist id="suggest">
+ <option value="First"></option>
+ <option value="Second"></option>
+ <option value="Secomundo"></option>
+ </datalist>
+<script>
+
+add_task(async function readonly() {
+ const { input } = await openPopupOn("input", { expectPopup: false });
+ ok(input.readOnly, "Input should be readonly");
+
+ input.removeAttribute("readonly");
+ await new Promise(resolve => SimpleTest.executeSoon(resolve));
+ // AttributeChanged takes control of the input again off a runnable...
+ ok(!input.readOnly, "Input should not be readonly");
+ is(document.activeElement, input, "Should still be focused");
+ await openPopupOn(input);
+ assertAutocompleteItems("First", "Second", "Secomundo");
+ assertValueAfterKeys(
+ input,
+ ["KEY_ArrowDown", "KEY_Enter"],
+ "First");
+});
+
+</script>
+</body>
+
+</html>
diff --git a/toolkit/components/satchel/test/test_datalist_shadow_dom.html b/toolkit/components/satchel/test/test_datalist_shadow_dom.html
new file mode 100644
index 0000000000..e2baeccb34
--- /dev/null
+++ b/toolkit/components/satchel/test/test_datalist_shadow_dom.html
@@ -0,0 +1,55 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>Test for datalist in Shadow DOM</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/EventUtils.js"></script>
+ <script type="text/javascript" src="satchel_common.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
+</head>
+<body>
+<p id="display"></p>
+<div id="content">
+ <div id="host"></div>
+</div>
+
+<script>
+
+const host = document.getElementById("host");
+host.attachShadow({ mode: "open" }).innerHTML = `
+ <form id="form1" onsubmit="return false;">
+ <input list="suggest" type="text" name="field1">
+ <button type="submit">Submit</button>
+ </form>
+ <datalist id="suggest">
+ <option value="First"></option>
+ <option value="Second"></option>
+ <option value="Secomundo"></option>
+ </datalist>
+`;
+
+add_task(async function fill_in_shadow() {
+ const { input } = await openPopupOn(host.shadowRoot.querySelector("input"));
+ assertAutocompleteItems("First", "Second", "Secomundo");
+ assertValueAfterKeys(
+ input,
+ ["KEY_ArrowDown", "KEY_Enter"],
+ "First");
+});
+
+add_task(async function filter_and_fill_in_shadow() {
+ const { input } = await openPopupOn(host.shadowRoot.querySelector("input"));
+ sendString("Sec");
+ await notifyMenuChanged(2);
+ assertAutocompleteItems("Second", "Secomundo");
+ sendString("o");
+ await notifyMenuChanged(2);
+ assertValueAfterKeys(
+ input,
+ ["KEY_ArrowDown", "KEY_Enter"],
+ "Second");
+});
+
+</script>
+</body>
+</html>
diff --git a/toolkit/components/satchel/test/test_datalist_with_caching.html b/toolkit/components/satchel/test/test_datalist_with_caching.html
new file mode 100644
index 0000000000..9366d69ff6
--- /dev/null
+++ b/toolkit/components/satchel/test/test_datalist_with_caching.html
@@ -0,0 +1,64 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>Test for Form History Autocomplete</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/EventUtils.js"></script>
+ <script type="text/javascript" src="satchel_common.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
+</head>
+<body>
+Form History test: form field autocomplete
+<p id="display"></p>
+
+<!-- we presumably can't hide the content for this test. -->
+<div id="content">
+
+ <!-- normal, basic form -->
+ <form id="form1" onsubmit="return false;">
+ <input list="suggest" type="text" name="field1">
+ <button type="submit">Submit</button>
+ </form>
+
+ <datalist id="suggest">
+ <option value="First"></option>
+ <option value="Second"></option>
+ <option value="Secomundo"></option>
+ </datalist>
+</div>
+
+<script>
+add_setup(async () => {
+ await updateFormHistory([
+ { op: "remove" },
+ { op: "add", fieldname: "field1", value: "Sec" },
+ ]);
+});
+
+add_task(async function datalist_with_caching() {
+ const { input } = await openPopupOn("#form1 > input");
+ assertAutocompleteItems("Sec", "First", "Second", "Secomundo");
+ assertValueAfterKeys(
+ input,
+ ["KEY_ArrowDown", "KEY_Enter"],
+ "Sec");
+});
+
+add_task(async function filtering_datalist_with_caching() {
+ const { input } = await openPopupOn("#form1 > input");
+ sendString("Sec");
+ await notifyMenuChanged(3);
+ assertAutocompleteItems("Sec", "Second", "Secomundo");
+
+ sendString("o");
+ await notifyMenuChanged(2);
+ assertAutocompleteItems("Second", "Secomundo");
+ assertValueAfterKeys(
+ input,
+ ["KEY_ArrowDown", "KEY_Enter"],
+ "Second");
+});
+
+</script>
+</body>
+</html>
diff --git a/toolkit/components/satchel/test/test_form_autocomplete.html b/toolkit/components/satchel/test/test_form_autocomplete.html
new file mode 100644
index 0000000000..ee0b2d5e14
--- /dev/null
+++ b/toolkit/components/satchel/test/test_form_autocomplete.html
@@ -0,0 +1,698 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>Test for Form History Autocomplete</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/EventUtils.js"></script>
+ <script type="text/javascript" src="satchel_common.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
+ <style>
+.spacer {
+ height: 50vh;
+ width: 100%;
+}
+ </style>
+</head>
+<body>
+Form History test: form field autocomplete
+<p id="display"></p>
+
+<!-- We presumably can't hide the content for this test. The large top padding is to allow
+ listening for scrolls to occur. -->
+<div id="content" style="padding-top: 20000px;">
+
+ <!-- normal, basic form -->
+ <form id="form1">
+ <input type="text" name="field1">
+ <button type="submit">Submit</button>
+ </form>
+
+ <!-- normal, basic form (new fieldname) -->
+ <form id="form2">
+ <input type="text" name="field2">
+ <button type="submit">Submit</button>
+ </form>
+
+ <div class="spacer">
+ Space to force a scroll on form3 focus
+ </div>
+
+ <!-- form with autocomplete=off on input -->
+ <form id="form3">
+ <input type="text" name="field2" autocomplete="off">
+ <button type="submit">Submit</button>
+ </form>
+
+ <!-- form with autocomplete=off on form -->
+ <form id="form4" autocomplete="off">
+ <input type="text" name="field2">
+ <button type="submit">Submit</button>
+ </form>
+
+ <!-- normal form for testing filtering -->
+ <form id="form5">
+ <input type="text" name="field3">
+ <button type="submit">Submit</button>
+ </form>
+
+ <!-- normal form for testing word boundary filtering -->
+ <form id="form6">
+ <input type="text" name="field4">
+ <button type="submit">Submit</button>
+ </form>
+
+ <!-- form with maxlength attribute on input -->
+ <form id="form7">
+ <input type="text" name="field5" maxlength="10">
+ <button type="submit">Submit</button>
+ </form>
+
+ <!-- form with input type='email' -->
+ <form id="form8">
+ <input type="email" name="field6">
+ <button type="submit">Submit</button>
+ </form>
+
+ <!-- form with input type='tel' -->
+ <form id="form9">
+ <input type="tel" name="field7">
+ <button type="submit">Submit</button>
+ </form>
+
+ <!-- form with input type='url' -->
+ <form id="form10">
+ <input type="url" name="field8">
+ <button type="submit">Submit</button>
+ </form>
+
+ <!-- form with input type='search' -->
+ <form id="form11">
+ <input type="search" name="field9">
+ <button type="submit">Submit</button>
+ </form>
+
+ <!-- form with input type='number' -->
+ <form id="form12">
+ <input type="text" name="field10"> <!-- TODO: change back to type=number -->
+ <button type="submit">Submit</button>
+ </form>
+
+ <!-- normal, basic form (with fieldname='searchbar-history') -->
+ <form id="form13">
+ <input type="text" name="searchbar-history">
+ <button type="submit">Submit</button>
+ </form>
+
+ <!-- form with input type='date' -->
+ <form id="form14">
+ <input type="date" name="field11">
+ <button type="submit">Submit</button>
+ </form>
+
+ <!-- form with input type='time' -->
+ <form id="form15">
+ <input type="time" name="field12">
+ <button type="submit">Submit</button>
+ </form>
+
+ <!-- form with input type='range' -->
+ <form id="form16">
+ <input type="range" name="field13" max="64">
+ <button type="submit">Submit</button>
+ </form>
+
+ <!-- form with input type='color' -->
+ <form id="form17">
+ <input type="color" name="field14">
+ <button type="submit">Submit</button>
+ </form>
+
+ <!-- form with input type='month' -->
+ <form id="form18">
+ <input type="month" name="field15">
+ <button type="submit">Submit</button>
+ </form>
+
+ <!-- form with input type='week' -->
+ <form id="form19">
+ <input type="week" name="field16">
+ <button type="submit">Submit</button>
+ </form>
+
+ <!-- form with input type='datetime-local' -->
+ <form id="form20">
+ <input type="datetime-local" name="field17">
+ <button type="submit">Submit</button>
+ </form>
+
+</div>
+
+<script>
+async function addEntry(fieldname, value) {
+ await updateFormHistory({ op: "add", fieldname, value });
+}
+
+preventSubmitOnForms();
+SpecialPowers.pushPrefEnv({"set": [["security.allow_eval_with_system_principal", true]]});
+
+add_setup(async () => {
+ await updateFormHistory([
+ { op: "remove" },
+ { op: "add", fieldname: "field1", value: "value1" },
+ { op: "add", fieldname: "field1", value: "value2" },
+ { op: "add", fieldname: "field1", value: "value3" },
+ { op: "add", fieldname: "field1", value: "value4" },
+ { op: "add", fieldname: "field2", value: "value1" },
+ { op: "add", fieldname: "field3", value: "a" },
+ { op: "add", fieldname: "field3", value: "aa" },
+ { op: "add", fieldname: "field3", value: "aaz" },
+ { op: "add", fieldname: "field3", value: "aa\xe6" }, // 0xae == latin ae pair (0xc6 == AE)
+ { op: "add", fieldname: "field3", value: "az" },
+ { op: "add", fieldname: "field3", value: "z" },
+ { op: "add", fieldname: "field4", value: "a\xe6" },
+ { op: "add", fieldname: "field4", value: "aa a\xe6" },
+ { op: "add", fieldname: "field4", value: "aba\xe6" },
+ { op: "add", fieldname: "field4", value: "bc d\xe6" },
+ { op: "add", fieldname: "field5", value: "1" },
+ { op: "add", fieldname: "field5", value: "12" },
+ { op: "add", fieldname: "field5", value: "123" },
+ { op: "add", fieldname: "field5", value: "1234" },
+ { op: "add", fieldname: "field6", value: "value" },
+ { op: "add", fieldname: "field7", value: "value" },
+ { op: "add", fieldname: "field8", value: "value" },
+ { op: "add", fieldname: "field9", value: "value" },
+ { op: "add", fieldname: "field10", value: "42" },
+ // not used, since type=date doesn't have autocomplete currently
+ { op: "add", fieldname: "field11", value: "2010-10-10" },
+ // not used, since type=time doesn't have autocomplete currently
+ { op: "add", fieldname: "field12", value: "21:21" },
+ // not used, since type=range doesn't have a dropdown menu
+ { op: "add", fieldname: "field13", value: "32" },
+ // not used, since type=color doesn't have autocomplete currently
+ { op: "add", fieldname: "field14", value: "#ffffff" },
+ { op: "add", fieldname: "field15", value: "2016-08" },
+ { op: "add", fieldname: "field16", value: "2016-W32" },
+ { op: "add", fieldname: "field17", value: "2016-10-21T10:10" },
+ { op: "add", fieldname: "searchbar-history", value: "blacklist test" },
+ ]);
+});
+
+add_task(async function use_1st_item() {
+ const { input } = await openPopupOn("#form1 > input");
+
+ assertAutocompleteItems("value1", "value2", "value3", "value4");
+ assertValueAfterKeys(input, "KEY_ArrowDown", "");
+ assertValueAfterKeys(input, "KEY_Enter", "value1");
+});
+
+add_task(async function use_2nd_item() {
+ const { input } = await openPopupOn("#form1 > input");
+ assertValueAfterKeys(
+ input,
+ ["KEY_ArrowDown", "KEY_ArrowDown", "KEY_Enter"],
+ "value2");
+});
+
+add_task(async function use_3rd_item() {
+ const { input } = await openPopupOn("#form1 > input");
+ assertValueAfterKeys(
+ input,
+ ["KEY_ArrowDown", "KEY_ArrowDown", "KEY_ArrowDown", "KEY_Enter"],
+ "value3");
+});
+
+add_task(async function use_4th_item() {
+ const { input } = await openPopupOn("#form1 > input");
+ assertValueAfterKeys(
+ input,
+ ["KEY_ArrowDown", "KEY_ArrowDown", "KEY_ArrowDown", "KEY_ArrowDown", "KEY_Enter"],
+ "value4");
+});
+
+add_task(async function use_1st_item_wrap_around() {
+ const { input } = await openPopupOn("#form1 > input");
+ assertValueAfterKeys(
+ input,
+ ["KEY_ArrowDown", "KEY_ArrowDown", "KEY_ArrowDown", "KEY_ArrowDown",
+ "KEY_ArrowDown", "KEY_ArrowDown", "KEY_Enter"],
+ "value1");
+});
+
+add_task(async function use_last_item_via_arrow_up() {
+ const { input } = await openPopupOn("#form1 > input");
+ assertValueAfterKeys(
+ input,
+ ["KEY_ArrowUp", "KEY_Enter"],
+ "value4");
+});
+
+add_task(async function use_last_item_via_arrow_up_from_selected_1st() {
+ const { input } = await openPopupOn("#form1 > input");
+ assertValueAfterKeys(
+ input,
+ ["KEY_ArrowDown", "KEY_ArrowUp", "KEY_ArrowUp", "KEY_Enter"],
+ "value4");
+});
+
+add_task(async function test9() {
+ const { input } = await openPopupOn("#form1 > input");
+ assertValueAfterKeys(
+ input,
+ ["KEY_ArrowDown", "KEY_ArrowUp", "KEY_ArrowUp", "KEY_ArrowUp", "KEY_ArrowUp",
+ "KEY_ArrowUp", "KEY_ArrowUp", "KEY_ArrowUp", "KEY_Enter"],
+ "value4");
+});
+
+add_task(async function select_1st_item_without_autocomplete() {
+ const { input } = await openPopupOn("#form1 > input");
+ assertValueAfterKeys(
+ input,
+ ["KEY_ArrowDown", "KEY_ArrowRight"],
+ "value1");
+});
+
+add_task(async function set_first_item_without_autocomplete() {
+ const { input } = await openPopupOn("#form1 > input");
+ assertValueAfterKeys(
+ input,
+ ["KEY_ArrowDown", "KEY_ArrowLeft"],
+ "value1");
+});
+
+add_task(async function use_1st_item_with_page_up() {
+ const { input } = await openPopupOn("#form1 > input");
+ assertValueAfterKeys(
+ input,
+ ["KEY_ArrowDown", "KEY_ArrowDown", "KEY_PageUp", "KEY_Enter"],
+ "value1");
+});
+
+add_task(async function use_last_item_with_page_down() {
+ const { input } = await openPopupOn("#form1 > input");
+ assertValueAfterKeys(
+ input,
+ ["KEY_ArrowDown", "KEY_PageDown", "KEY_Enter"],
+ "value4");
+});
+
+add_task(async function delete_1st_item() {
+ assertAutocompleteItems("value1", "value2", "value3", "value4");
+
+ const { input } = await openPopupOn("#form1 > input", { inputValue: "value" });
+ synthesizeKey("KEY_ArrowDown");
+
+ // Tests that on OS X shift-backspace didn't delete the last character
+ // in the input (bug 480262).
+ // On OS X, shift-backspace and shift-delete work, just delete does not.
+ // On Win/Linux, shift-backspace does not work, delete and shift-delete do.
+ synthesizeKey(SpecialPowers.OS == "Darwin" ? "KEY_Backspace" : "KEY_Delete", { shiftKey: true });
+ assertValueAfterKeys(input, [], "value");
+ await notifyMenuChanged(3);
+ is(await countEntries("field1", "value1"), 0, "field1:value1 item deleted");
+ assertValueAfterKeys(input, ["KEY_Enter"], "value2");
+ assertAutocompleteItems("value2", "value3", "value4");
+});
+
+add_task(async function use_1st_item_of_3() {
+ const { input } = await openPopupOn("#form1 > input");
+ assertValueAfterKeys(
+ input,
+ ["KEY_ArrowDown", "KEY_Enter"],
+ "value2");
+});
+
+add_task(async function delete_2nd_item() {
+ const { input } = await openPopupOn("#form1 > input");
+ synthesizeKey("KEY_ArrowDown");
+ synthesizeKey("KEY_ArrowDown");
+ deleteSelectedAutocompleteItem();
+ await notifyMenuChanged(2);
+ assertValueAfterKeys(input, [], "");
+ is(await countEntries("field1", "value3"), 0, "field1:value3 item deleted");
+ assertValueAfterKeys(input, ["KEY_Enter"], "value4");
+ assertAutocompleteItems("value2", "value4");
+});
+
+add_task(async function use_1st_item_of_2() {
+ const { input } = await openPopupOn("#form1 > input");
+ assertValueAfterKeys(
+ input,
+ ["KEY_ArrowDown", "KEY_Enter"],
+ "value2");
+});
+
+add_task(async function delete_last_item_of_2() {
+ const { input } = await openPopupOn("#form1 > input");
+ synthesizeKey("KEY_ArrowDown");
+ synthesizeKey("KEY_ArrowDown");
+ deleteSelectedAutocompleteItem();
+ await notifyMenuChanged(1);
+ assertValueAfterKeys(input, [], "");
+ is(await countEntries("field1", "value4"), 0, "field1/value4 item deleted");
+ assertAutocompleteItems("value2");
+ assertValueAfterKeys(input, "KEY_Enter", "value2");
+});
+
+add_task(async function use_1st_item_of_1() {
+ const { input } = await openPopupOn("#form1 > input");
+ assertValueAfterKeys(
+ input,
+ ["KEY_ArrowDown", "KEY_Enter"],
+ "value2");
+});
+
+add_task(async function delete_only_item() {
+ const { input } = await openPopupOn("#form1 > input");
+ synthesizeKey("KEY_ArrowDown");
+ deleteSelectedAutocompleteItem();
+ await notifyMenuChanged(0);
+ is(await countEntries("field1", "value2"), 0, "field1/value2 item deleted");
+ assertValueAfterKeys(input, [], "");
+});
+
+add_task(async function form2_fills() {
+ const { input } = await openPopupOn("#form2 > input");
+ assertValueAfterKeys(
+ input,
+ ["KEY_ArrowDown", "KEY_Enter"],
+ "value1");
+});
+
+add_task(async function form3_autocomplete_off() {
+ // Look at form 3, try to trigger autocomplete popup
+ // Sometimes, this will fail if scrollTo(0, 0) is called, so that doesn't
+ // happen here. Fortunately, a different input is used from the last test,
+ // so a scroll should still occur.
+ const scrollend = new Promise(resolve => {
+ addEventListener("scrollend", resolve);
+ });
+ const { input } = await openPopupOn("#form3 > input", { expectPopup: false });
+ await scrollend;
+ // Ensure there's no autocomplete dropdown (autocomplete=off is present)
+ assertValueAfterKeys(
+ input,
+ ["KEY_ArrowDown", "KEY_Enter"],
+ "");
+});
+
+add_task(async function form4_autocomplete_off() {
+ const { input } = await openPopupOn("#form4 > input", { expectPopup: false });
+ await notifyMenuChanged(0);
+
+ // Ensure there's no autocomplete dropdown (autocomplete=off is present)
+ assertValueAfterKeys(
+ input,
+ ["KEY_ArrowDown", "KEY_Enter"],
+ "");
+});
+
+add_task(async function filtering_form5() {
+ const { input } = await openPopupOn("#form5 > input");
+ sendChar("a");
+ await notifyMenuChanged(5);
+ assertAutocompleteItems("a", "aa", "aaz", "aa\xe6", "az");
+ sendChar("a");
+ await notifyMenuChanged(3);
+ assertAutocompleteItems("aa", "aaz", "aa\xe6");
+ sendChar("\xc6");
+ await notifyMenuChanged(1);
+ assertAutocompleteItems("aa\xe6");
+ synthesizeKey("KEY_Backspace");
+ await notifyMenuChanged(3);
+ assertAutocompleteItems("aa", "aaz", "aa\xe6");
+ synthesizeKey("KEY_Backspace");
+ await notifyMenuChanged(5);
+ assertAutocompleteItems("a", "aa", "aaz", "aa\xe6", "az");
+ input.focus();
+ sendChar("z");
+ await notifyMenuChanged(2);
+ assertAutocompleteItems("az", "aaz");
+ synthesizeKey("KEY_ArrowLeft");
+ // Check case-insensitivity.
+ sendChar("A");
+ await notifyMenuChanged(1);
+ assertAutocompleteItems("aaz");
+ await addEntry("field3", "aazq");
+ // check that results were cached
+ input.focus();
+ synthesizeKey("KEY_ArrowRight");
+ sendChar("q");
+ await notifyMenuChanged(0);
+ // check that results were cached
+ assertAutocompleteItems();
+ await addEntry("field3", "aazqq");
+ input.focus();
+ window.scrollTo(0, 0);
+ sendChar("q");
+ await notifyMenuChanged(0);
+ // check that empty results were cached - bug 496466
+ assertAutocompleteItems();
+ synthesizeKey("KEY_Escape");
+});
+
+add_task(async function filtering_form6_part1() {
+ await openPopupOn("#form6 > input");
+ sendChar("a");
+ await notifyMenuChanged(3);
+
+ // Test substring matches and word boundary bonuses
+ // alphabetical results for first character
+ assertAutocompleteItems("aa a\xe6", "aba\xe6", "a\xe6");
+ sendChar("\xe6");
+ await notifyMenuChanged(3, "a\xe6");
+
+ // prefix match comes first, then word boundary match
+ // followed by substring match
+ assertAutocompleteItems("a\xe6", "aa a\xe6", "aba\xe6");
+});
+
+add_task(async function filtering_form6_part2() {
+ await openPopupOn("#form6 > input");
+ sendChar("b");
+ await notifyMenuChanged(1, "bc d\xe6");
+ assertAutocompleteItems("bc d\xe6");
+ sendChar(" ");
+ await notifyMenuChanged(1);
+
+ // check that trailing space has no effect after single char.
+ assertAutocompleteItems("bc d\xe6");
+ sendChar("\xc6");
+ await notifyMenuChanged(2);
+
+ // check multi-word substring matches
+ assertAutocompleteItems("bc d\xe6", "aba\xe6");
+ synthesizeKey("KEY_ArrowLeft");
+ sendChar("d");
+ await notifyMenuChanged(1);
+
+ // check inserting in multi-word searches
+ assertAutocompleteItems("bc d\xe6");
+ sendChar("z");
+ await notifyMenuChanged(0);
+ assertAutocompleteItems();
+});
+
+add_task(async function input_maxLength() {
+ let { input } = await openPopupOn("#form7 > input");
+ await notifyMenuChanged(4);
+ assertAutocompleteItems("1", "12", "123", "1234");
+
+ input.maxLength = 4;
+ input = (await openPopupOn("#form7 > input")).input;
+ await notifyMenuChanged(4);
+ assertAutocompleteItems("1", "12", "123", "1234");
+
+ input.maxLength = 3;
+ input = (await openPopupOn("#form7 > input")).input;
+ await notifyMenuChanged(3);
+ assertAutocompleteItems("1", "12", "123");
+
+ input.maxLength = 2;
+ input = (await openPopupOn("#form7 > input")).input;
+ await notifyMenuChanged(2);
+ assertAutocompleteItems("1", "12");
+
+ input.maxLength = 1;
+ input = (await openPopupOn("#form7 > input")).input;
+ await notifyMenuChanged(1);
+ assertAutocompleteItems("1");
+
+ input.maxLength = 0;
+ synthesizeKey("KEY_Escape");
+ synthesizeKey("KEY_ArrowDown");
+ await notifyMenuChanged(0);
+ assertAutocompleteItems();
+
+ input.maxLength = 4;
+});
+
+add_task(async function input_maxLength_with_character_typed() {
+ let { input } = await openPopupOn("#form7 > input");
+ sendChar("1");
+ await notifyMenuChanged(4);
+ assertAutocompleteItems("1", "12", "123", "1234");
+
+ input.maxLength = 3;
+ input = (await openPopupOn("#form7 > input")).input;
+ assertAutocompleteItems("1", "12", "123");
+
+ input.maxLength = 2;
+ input = (await openPopupOn("#form7 > input")).input;
+ assertAutocompleteItems("1", "12");
+
+ input.maxLength = 1;
+ input = (await openPopupOn("#form7 > input")).input;
+ assertAutocompleteItems("1");
+
+ input.maxLength = 0;
+ synthesizeKey("KEY_Escape");
+ synthesizeKey("KEY_ArrowDown");
+ await notifyMenuChanged(0);
+ assertAutocompleteItems();
+});
+
+for (const formId of ["form8", "form9", "form10", "form11"]) {
+ add_named_task(formId, async () => {
+ const { input } = await openPopupOn(`#${formId} > input`);
+ assertAutocompleteItems("value");
+ assertValueAfterKeys(input, ["KEY_ArrowDown", "KEY_Enter"], "value");
+ });
+}
+
+add_task(async function form12() {
+ const { input } = await openPopupOn("#form12 > input");
+ assertAutocompleteItems("42");
+ assertValueAfterKeys(
+ input,
+ ["KEY_ArrowDown", "KEY_Enter"],
+ "42");
+});
+
+add_task(async function form14() {
+ const { input } = await openPopupOn("#form14 > input", { expectPopup: false });
+ await notifyMenuChanged(0);
+ // type=date with it's own control frame does not have a dropdown menu
+ assertAutocompleteItems();
+ assertValueAfterKeys(input, [], "");
+});
+
+add_task(async function form15() {
+ const { input } = await openPopupOn("#form15 > input", { expectPopup: false });
+ await notifyMenuChanged(0);
+ // type=time with it's own control frame does not have a dropdown menu
+ assertAutocompleteItems();
+ assertValueAfterKeys(input, [], "");
+});
+
+add_task(async function form16() {
+ const { input } = await openPopupOn("#form16 > input", { expectPopup: false });
+ await notifyMenuChanged(0);
+ // type=range does not have a dropdown menu
+ assertAutocompleteItems();
+ // default (midway between minimum (0) and maximum (64)) - step
+ assertValueAfterKeys(input, [], "31");
+});
+
+add_task(async function form17() {
+ const { input } = await openPopupOn("#form17 > input", { expectPopup: false });
+ await notifyMenuChanged(0);
+ // type=color does not have a dropdown menu
+ assertAutocompleteItems();
+ // default color value
+ assertValueAfterKeys(input, [], "#000000");
+});
+
+add_task(async function form18() {
+ const { input } = await openPopupOn("#form18 > input");
+ assertAutocompleteItems("2016-08");
+ assertValueAfterKeys(
+ input,
+ ["KEY_ArrowDown", "KEY_Enter"],
+ "2016-08");
+});
+
+add_task(async function form19() {
+ const { input } = await openPopupOn("#form19 > input");
+ assertAutocompleteItems("2016-W32");
+ assertValueAfterKeys(
+ input,
+ ["KEY_ArrowDown", "KEY_Enter"],
+ "2016-W32");
+});
+
+add_task(async function form20() {
+ const { input } = await openPopupOn("#form20 > input", { expectPopup: false });
+ await notifyMenuChanged(0);
+ // type=datetime-local with it's own control frame does not have a dropdown menu
+ assertAutocompleteItems();
+ assertValueAfterKeys(input, [], "");
+});
+
+add_task(async function input_event_fired() {
+ await addEntry("field1", "value1");
+ const { input } = await openPopupOn("#form1 > input");
+
+ let beforeInputFired = false;
+ input.addEventListener("beforeinput", (e) => {
+ beforeInputFired = true;
+ ok(e instanceof InputEvent, "beforeinput event has InputEvent interface");
+ ok(e.bubbles, "beforeinput event should bubble");
+ is(e.cancelable, SpecialPowers.getBoolPref("dom.input_event.allow_to_cancel_set_user_input"),
+ "beforeinput event for insertReplacementText should be cancelable unless it's suppressed by the pref");
+ is(e.inputType, "insertReplacementText",
+ "inputType of beforeinput event should be insertReplacementText");
+ is(e.data, "value1", "data of beforeinput event should be value1");
+ is(e.dataTransfer, null, "dataTransfer of beforeinput event should be null");
+ is(e.getTargetRanges().length, 0,
+ "getTargetRanges() of beforeinput event should empty array");
+ is(input.value, "", "input value should've not been modified yet at beforeinput event");
+ }, { once: true });
+
+ let inputFired = false;
+ input.addEventListener("input", (e) => {
+ inputFired = true;
+ ok(e instanceof InputEvent, "input event has InputEvent interface");
+ ok(e.bubbles, "input event should bubble");
+ ok(!e.cancelable, "input event shouldn't be cancelable");
+ is(e.inputType, "insertReplacementText",
+ "inputType of input event should be insertReplacementText");
+ is(e.data, "value1","data of input event should be value1");
+ is(e.dataTransfer, null, "dataTransfer of input event should be null");
+ is(e.getTargetRanges().length, 0,
+ "getTargetRanges() of input event should empty array");
+ is(input.value, "value1", "input value should've already been modified at input event");
+ }, { once: true });
+
+ assertValueAfterKeys(input, "KEY_ArrowDown", "");
+ assertValueAfterKeys(input, "KEY_Enter", "value1");
+ ok(beforeInputFired, "beforeinput event should have been fired");
+ ok(inputFired, "input event should have been fired");
+});
+
+add_task(async function cancelling_beforeinput_cancels_autocompletion() {
+ const { input } = await openPopupOn("#form1 > input");
+ await SpecialPowers.pushPrefEnv({
+ set: [["dom.input_event.allow_to_cancel_set_user_input", true]],
+ });
+ input.addEventListener("beforeinput", (e) => e.preventDefault(), { once: true });
+
+ let inputFired = false;
+ input.addEventListener("input", () => inputFired = true, { once: true });
+
+ assertValueAfterKeys(input, "KEY_ArrowDown", "");
+ assertValueAfterKeys(input, "KEY_Enter", "");
+ ok(!inputFired, "no input event when beforeinput is canceled");
+
+ await SpecialPowers.pushPrefEnv({
+ clear: [["dom.input_event.allow_to_cancel_set_user_input"]],
+ });
+});
+
+add_task(async function no_autocomplete_for_searchbar_history() {
+ await openPopupOn("#form13 > input", { expectPopup: false });
+ await notifyMenuChanged(0);
+ assertAutocompleteItems();
+});
+</script>
+</body>
+</html>
diff --git a/toolkit/components/satchel/test/test_form_autocomplete_validation_at_input_event.html b/toolkit/components/satchel/test/test_form_autocomplete_validation_at_input_event.html
new file mode 100644
index 0000000000..7df6e97f27
--- /dev/null
+++ b/toolkit/components/satchel/test/test_form_autocomplete_validation_at_input_event.html
@@ -0,0 +1,83 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>Test for validation has been done before "input" event</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/EventUtils.js"></script>
+ <script type="text/javascript" src="satchel_common.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
+ <style>
+ input:invalid {
+ background-color: red;
+ }
+ </style>
+</head>
+<body>
+<p id="display"></p>
+
+<div id="content">
+ <form>
+ <input type="text" name="field1" pattern="[0-9]{4}">
+ </form>
+</div>
+
+<script>
+
+add_setup(async () => {
+ await updateFormHistory([
+ { op: "remove" },
+ { op: "add", fieldname: "field1", value: "1234" },
+ { op: "add", fieldname: "field1", value: "12345" },
+ ]);
+});
+
+add_task(async function input_events() {
+ const { input } = await openPopupOn("input[name=field1]");
+ synthesizeKey("KEY_Escape");
+
+ let inputFired = false;
+ info("Typing first character to open popup...");
+ input.addEventListener("input", e => {
+ inputFired = true;
+ is(e.inputType, "insertText", "Typing '1' produces 'input' event with inputType='insertText'");
+ is(input.validity.valid, false, "Typing '1' marks it as 'invalid'");
+ is(input.matches(":invalid"), true, "Typing '1' causes matching with ':invalid' pseudo-class");
+ }, { once: true, capture: true });
+ synthesizeKey("1");
+ ok(inputFired, "Typing '1' causes one 'input' event");
+});
+
+add_task(async function input_events_for_valid_selection() {
+ const { input } = await openPopupOn("input[name=field1]");
+
+ let inputFired = false;
+ input.addEventListener("input", e => {
+ inputFired = true;
+ is(e.inputType, "insertReplacementText", "Selecting valid value causes an 'input' event with inputType='insertReplacementText'");
+ is(input.validity.valid, true, "Selecting valid value marks it as 'valid'");
+ is(input.matches(":invalid"), false, "Selecting valid value causes not matching with ':invalid' pseudo-class");
+ }, { once: true, capture: true });
+ synthesizeKey("KEY_ArrowDown");
+ synthesizeKey("KEY_Enter");
+ ok(inputFired, "Selecting valid item causes an 'input' event");
+});
+
+add_task(async function input_events_for_invalid_selection() {
+ const { input } = await openPopupOn("input[name=field1]");
+
+ let inputFired = false;
+ input.addEventListener("input", e => {
+ inputFired = true;
+ is(e.inputType, "insertReplacementText", "Selecting invalid value causes an 'input' event with inputType='insertReplacementText'");
+ is(input.validity.valid, false, "Selecting invalid value marks it as 'invalid'");
+ is(input.matches(":invalid"), true, "Selecting invalid value causes matching with ':invalid' pseudo-class");
+ }, { once: true, capture: true });
+ synthesizeKey("KEY_ArrowDown");
+ synthesizeKey("KEY_ArrowDown");
+ synthesizeKey("KEY_Enter");
+ ok(inputFired, "Selecting invalid item should cause an 'input' event");
+});
+
+</script>
+</body>
+</html>
diff --git a/toolkit/components/satchel/test/test_form_autocomplete_with_list.html b/toolkit/components/satchel/test/test_form_autocomplete_with_list.html
new file mode 100644
index 0000000000..b903af5246
--- /dev/null
+++ b/toolkit/components/satchel/test/test_form_autocomplete_with_list.html
@@ -0,0 +1,402 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>Test for Form History Autocomplete</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/EventUtils.js"></script>
+ <script type="text/javascript" src="satchel_common.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
+</head>
+<body>
+Form History test: form field autocomplete
+<p id="display"></p>
+
+<!-- we presumably can't hide the content for this test. -->
+<div id="content">
+ <datalist id="suggest">
+ <option value="Google" label="PASS1">FAIL</option>
+ <option value="Reddit">PASS2</option>
+ <option value="final"></option>
+ </datalist>
+
+ <!-- normal, basic form -->
+ <form id="form1">
+ <input list="suggest" type="text" name="field1">
+ <button type="submit">Submit</button>
+ </form>
+
+ <!-- form with autocomplete=off on input -->
+ <form id="form3">
+ <input list="suggest" type="text" name="field2" autocomplete="off">
+ <button type="submit">Submit</button>
+ </form>
+
+ <!-- form with autocomplete=off on form -->
+ <form id="form4" autocomplete="off">
+ <input list="suggest" type="text" name="field2">
+ <button type="submit">Submit</button>
+ </form>
+
+ <!-- yet another normal, basic form, with 2 history entries -->
+ <form id="form5">
+ <input list="suggest" type="text" name="field3">
+ <button type="submit">Submit</button>
+ </form>
+
+ <form id="show_datalist_for_text_inputs_only">
+ <input list="suggest" type="button" popup="false" />
+ <input list="suggest" type="checkbox" popup="false" />
+ <input list="suggest" type="color" popup="false" />
+ <input list="suggest" type="date" popup="false" />
+ <input list="suggest" type="datetime-local" popup="false" />
+ <input list="suggest" type="email" popup="true" />
+ <input list="suggest" type="file" popup="false" />
+ <input list="suggest" type="image" popup="false" />
+ <input list="suggest" type="month" popup="true" />
+ <input list="suggest" type="number" popup="false" />
+ <input list="suggest" type="password" popup="true" />
+ <input list="suggest" type="radio" popup="false" />
+ <input list="suggest" type="range" popup="false" />
+ <input list="suggest" type="reset" popup="false" />
+ <input list="suggest" type="search" popup="true" />
+ <input list="suggest" type="submit" popup="false" />
+ <input list="suggest" type="tel" popup="true" />
+ <input list="suggest" type="text" popup="true" />
+ <input list="suggest" type="time" popup="false" />
+ <input list="suggest" type="url" popup="true" />
+ <input list="suggest" type="week" popup="true" />
+ </form>
+</div>
+
+<script>
+
+preventSubmitOnForms();
+
+add_setup(async () => {
+ await updateFormHistory([
+ { op: "remove" },
+ { op: "add", fieldname: "field1", value: "historyvalue" },
+ { op: "add", fieldname: "field2", value: "othervalue" },
+ { op: "add", fieldname: "field3", value: "history1" },
+ { op: "add", fieldname: "field3", value: "history2" },
+ ]);
+});
+
+add_task(async function no_changes_when_opening_popup(){
+ const { input } = await openPopupOn("#form1 > input");
+ assertValueAfterKeys(input, [], "");
+});
+
+add_task(async function use_1st_entry() {
+ const { input } = await openPopupOn("#form1 > input");
+ assertAutocompleteItems("historyvalue", "PASS1", "PASS2", "final");
+ assertValueAfterKeys(
+ input,
+ ["KEY_ArrowDown", "KEY_Enter"],
+ "historyvalue");
+});
+
+add_task(async function use_2nd_entry(){
+ const { input } = await openPopupOn("#form1 > input");
+ assertValueAfterKeys(
+ input,
+ ["KEY_ArrowDown", "KEY_ArrowDown", "KEY_Enter"],
+ "Google");
+});
+
+add_task(async function use_3rd_entry(){
+ const { input } = await openPopupOn("#form1 > input");
+ assertValueAfterKeys(
+ input,
+ ["KEY_ArrowDown", "KEY_ArrowDown", "KEY_ArrowDown", "KEY_Enter"],
+ "Reddit");
+});
+
+add_task(async function use_4th_entry(){
+ const { input } = await openPopupOn("#form1 > input");
+ assertValueAfterKeys(
+ input,
+ ["KEY_ArrowDown", "KEY_ArrowDown", "KEY_ArrowDown", "KEY_ArrowDown", "KEY_Enter"],
+ "final");
+});
+
+add_task(async function delete_1st_entry(){
+ const { input } = await openPopupOn("#form1 > input");
+ assertValueAfterKeys(input, "KEY_ArrowDown", "");
+ deleteSelectedAutocompleteItem();
+ await notifyMenuChanged(3);
+ is(await countEntries("field1", "historyvalue"), 0, "item is absent");
+});
+
+add_task(async function can_use_next_item_after_deletion(){
+ const { input } = await openPopupOn("#form1 > input");
+ assertAutocompleteItems("PASS1", "PASS2", "final");
+ assertValueAfterKeys(input, ["KEY_ArrowDown", "KEY_Enter"], "Google");
+});
+
+add_task(async function autocomplete_on_datalist_with_cached_results(){
+ const { input } = await openPopupOn("#form1 > input");
+ sendString("PAS");
+ await notifyMenuChanged(2);
+ sendString("S1");
+ await notifyMenuChanged(1);
+ assertValueAfterKeys(
+ input,
+ ["KEY_ArrowDown", "KEY_Enter"],
+ "Google");
+});
+
+add_task(async function fills_with_autocomplete_off_on_form(){
+ const { input } = await openPopupOn("#form4 > input");
+ assertValueAfterKeys(
+ input,
+ ["KEY_ArrowDown", "KEY_Enter"],
+ "Google");
+ assertAutocompleteItems("PASS1", "PASS2", "final");
+});
+
+add_task(async function use_1st_entry_with_autocomplete_off_on_form(){
+ const { input } = await openPopupOn("#form4 > input");
+ assertValueAfterKeys(
+ input,
+ ["KEY_ArrowDown", "KEY_Enter"],
+ "Google");
+});
+
+add_task(async function use_2nd_entry_with_autocomplete_off_on_form(){
+ const { input } = await openPopupOn("#form4 > input");
+ assertValueAfterKeys(
+ input,
+ ["KEY_ArrowDown", "KEY_ArrowDown", "KEY_Enter"],
+ "Reddit");
+});
+
+add_task(async function use_3rd_entry_with_autocomplete_off_on_form(){
+ const { input } = await openPopupOn("#form4 > input");
+ assertValueAfterKeys(
+ input,
+ ["KEY_ArrowDown", "KEY_ArrowDown", "KEY_ArrowDown", "KEY_Enter"],
+ "final");
+});
+
+add_task(async function fills_with_autocomplete_off_on_input(){
+ const { input } = await openPopupOn("#form3 > input");
+ assertValueAfterKeys(
+ input,
+ ["KEY_ArrowDown", "KEY_Enter"],
+ "Google");
+ assertAutocompleteItems("PASS1", "PASS2", "final");
+});
+
+add_task(async function use_1st_entry_with_autocomplete_off_on_input(){
+ const { input } = await openPopupOn("#form3 > input");
+ assertValueAfterKeys(
+ input,
+ ["KEY_ArrowDown", "KEY_Enter"],
+ "Google");
+});
+
+add_task(async function use_2nd_entry_with_autocomplete_off_on_input(){
+ const { input } = await openPopupOn("#form3 > input");
+ assertValueAfterKeys(
+ input,
+ ["KEY_ArrowDown", "KEY_ArrowDown", "KEY_Enter"],
+ "Reddit");
+});
+
+add_task(async function use_3rd_entry_with_autocomplete_off_on_input(){
+ const { input } = await openPopupOn("#form3 > input");
+ assertValueAfterKeys(
+ input,
+ ["KEY_ArrowDown", "KEY_ArrowDown", "KEY_ArrowDown", "KEY_Enter"],
+ "final");
+});
+
+add_task(async function remove_item_from_datalist(){
+ // When there is an update of the list, the selection is lost so we need to
+ // go down like if we were at the beginning of the list again.
+ //
+ // Removing the second element while on the first then going down and
+ // push enter. Value should be one from the third suggesion.
+ const { input } = await openPopupOn("#form3 > input");
+ synthesizeKey("KEY_ArrowDown");
+ const datalist = document.getElementById("suggest");
+ const toRemove = datalist.children[1];
+ datalist.removeChild(toRemove);
+
+ assertValueAfterKeys(
+ input,
+ ["KEY_ArrowDown", "KEY_ArrowDown", "KEY_Enter"],
+ "final");
+
+ // Restore the element.
+ datalist.insertBefore(toRemove, datalist.children[1]);
+});
+
+add_task(async function add_item_to_datalist(){
+ const { input } = await openPopupOn("#form3 > input");
+ const datalist = document.getElementById("suggest");
+
+ // Adding an attribute after the first one while on the first then going
+ // down and push enter. Value should be the on from the new suggestion.
+ synthesizeKey("KEY_ArrowDown");
+ datalist.insertBefore(new Option("New value"), datalist.children[1]);
+ await notifyMenuChanged(4);
+ assertValueAfterKeys(
+ input,
+ ["KEY_ArrowDown", "KEY_ArrowDown", "KEY_Enter"],
+ "New value");
+});
+
+add_task(async function change_datalist_option_value(){
+ const datalist = document.getElementById("suggest");
+
+ // Remove the element.
+ datalist.removeChild(datalist.children[1]);
+ await notifyMenuChanged(0);
+
+ // Change the first element value attribute.
+ const prevValue = datalist.children[0].value;
+ datalist.children[0].value = "foo";
+
+ const { input } = await openPopupOn("#form3 > input");
+ assertValueAfterKeys(
+ input,
+ ["KEY_ArrowDown", "KEY_Enter"],
+ "foo");
+
+ datalist.children[0].value = prevValue;
+ await notifyMenuChanged(0);
+});
+
+add_task(async function change_datalist_option_text_content(){
+ const datalist = document.getElementById("suggest");
+ const prevValue = datalist.children[0].getAttribute("value");
+ datalist.children[0].removeAttribute("value");
+ datalist.children[0].textContent = "foobar";
+ const { input } = await openPopupOn("#form3 > input");
+
+ assertValueAfterKeys(
+ input,
+ ["KEY_ArrowDown", "KEY_Enter"],
+ "foobar");
+
+ datalist.children[0].setAttribute("value", prevValue);
+ await notifyMenuChanged(0);
+});
+
+add_task(async function filters_with_1st_letter(){
+ const { input } = await openPopupOn("#form3 > input");
+ sendString("f");
+ assertValueAfterKeys(
+ input,
+ ["KEY_ArrowDown", "KEY_ArrowDown", "KEY_ArrowDown", "KEY_Enter"],
+ "final");
+});
+
+add_task(async function filters_with_letter_in_the_middle(){
+ const { input } = await openPopupOn("#form3 > input");
+ sendString("in");
+ assertValueAfterKeys(
+ input,
+ ["KEY_ArrowDown", "KEY_ArrowDown", "KEY_ArrowDown", "KEY_Enter"],
+ "final");
+});
+
+add_task(async function input_events(){
+ const { input } = await openPopupOn("#form3 > input");
+
+ let beforeInputFired = false;
+ input.addEventListener("beforeinput", (event) => {
+ beforeInputFired = true;
+ ok(event instanceof InputEvent, "beforeinput event has InputEvent interface");
+ ok(event.bubbles, "beforeinput event should bubble");
+ is(
+ event.cancelable,
+ SpecialPowers.getBoolPref("dom.input_event.allow_to_cancel_set_user_input"),
+ "beforeinput event for insertReplacementText should be cancelable if not suppressed");
+ is(event.inputType, "insertReplacementText", "inputType of beforeinput event should be insertReplacementText");
+ is(event.data, "Google", "data of beforeinput event should be Google");
+ is(event.dataTransfer, null, "dataTransfer of beforeinput event should be null");
+ is(event.getTargetRanges().length, 0, "getTargetRanges() of beforeinput event should be empty array");
+ }, { once: true });
+
+ let inputFired = false;
+ input.addEventListener("input", function(event) {
+ inputFired = true;
+ ok(event instanceof InputEvent,
+ "input event should be dispatched with InputEvent interface");
+ ok(event.bubbles, "input event should bubble");
+ ok(!event.cancelable, "input event should be cancelable");
+ is(event.inputType, "insertReplacementText", "inputType of input event should be insertReplacementText");
+ is(event.data, "Google", "data of input event should be Google");
+ is(event.dataTransfer, null, "dataTransfer of input event should be null");
+ is(event.getTargetRanges().length, 0, "getTargetRanges() of input event should be empty array");
+ }, { once: true });
+
+ assertValueAfterKeys(input, ["KEY_ArrowDown", "KEY_Enter"], "Google");
+ ok(beforeInputFired, "beforeinput event should've been fired");
+ ok(inputFired, "input event should've been fired");
+});
+
+add_task(async function cancelled_beforeinput_event(){
+ const { input } = await openPopupOn("#form3 > input");
+ await SpecialPowers.pushPrefEnv({
+ set: [["dom.input_event.allow_to_cancel_set_user_input", true]],
+ });
+ input.addEventListener("beforeinput", e => e.preventDefault(), { once: true });
+ let inputFired = false;
+ input.addEventListener("input", () => inputFired = true, { once: true });
+
+ assertValueAfterKeys(input, "KEY_ArrowDown", "");
+ assertValueAfterKeys(input, "KEY_Enter", "");
+ ok(!inputFired, "no input event when beforeinput is canceled");
+
+ input.blur();
+ await SpecialPowers.pushPrefEnv({
+ clear: [["dom.input_event.allow_to_cancel_set_user_input"]],
+ });
+});
+
+add_task(async function attempt_to_delete_datalist_entries(){
+ const { input } = await openPopupOn("#form5 > input");
+ assertAutocompleteItems("history1", "history2", "PASS1", "PASS2", "final");
+
+ assertValueAfterKeys(
+ input,
+ ["KEY_ArrowDown", "KEY_ArrowDown", "KEY_ArrowDown"],
+ "");
+
+ deleteSelectedAutocompleteItem();
+ synthesizeKey("KEY_ArrowUp");
+ deleteSelectedAutocompleteItem();
+ await notifyMenuChanged(4);
+ assertAutocompleteItems("history1", "PASS1", "PASS2", "final");
+ // Delete the second entry, that is the 1st entry of datalist.
+ // This has no effect.
+ deleteSelectedAutocompleteItem();
+
+ // Delete the first entry, that is the 1nd entry of history.
+ synthesizeKey("KEY_ArrowUp");
+ deleteSelectedAutocompleteItem();
+ await notifyMenuChanged(3);
+
+ assertAutocompleteItems("PASS1", "PASS2", "final");
+ assertValueAfterKeys(input, "KEY_Enter", "Google");
+});
+
+for (const input of document
+ .querySelectorAll("form#show_datalist_for_text_inputs_only input")) {
+ const expectPopup = input.getAttribute("popup") == "true";
+ add_named_task(
+ `datalist_is_${expectPopup ? "" : "not_"}shown_for_${input.type}_input`,
+ async () => {
+ input.focus();
+ is(document.activeElement, input, "Input is focused.");
+ await (expectPopup ? popupByArrowDown() : noPopupByArrowDown());
+ }
+ );
+}
+</script>
+</body>
+</html>
diff --git a/toolkit/components/satchel/test/test_form_submission.html b/toolkit/components/satchel/test/test_form_submission.html
new file mode 100644
index 0000000000..d1c0542609
--- /dev/null
+++ b/toolkit/components/satchel/test/test_form_submission.html
@@ -0,0 +1,598 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>Satchel Test for Form Submisstion</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script type="text/javascript" src="satchel_common.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
+</head>
+<body>
+<p id="display"></p>
+<iframe id="iframe" src="https://example.com/tests/toolkit/components/satchel/test/subtst_form_submission_1.html"></iframe>
+<div id="content" style="display: none">
+
+ <!-- ===== Things that should not be saved. ===== -->
+
+ <form purpose="nothing stored for input autocomplete=off (case-insensitive token)"
+ id="form1">
+ <input type="text" name="test1" autocomplete=" oFf ">
+ <button type="submit">Submit</button>
+ </form>
+
+ <form purpose="nothing stored for form autocomplete=off"
+ id="form2" autocomplete="oFf">
+ <input type="text" name="test1">
+ <button type="submit">Submit</button>
+ </form>
+
+ <form purpose="nothing stored for type=hidden"
+ id="form3">
+ <input type="hidden" name="test1">
+ <button type="submit">Submit</button>
+ </form>
+
+ <form purpose="nothing stored for type=checkbox"
+ id="form4">
+ <input type="checkbox" name="test1">
+ <button type="submit">Submit</button>
+ </form>
+
+ <form purpose="nothing stored for empty values."
+ id="form5">
+ <input type="text" name="test1" value="originalValue">
+ <button type="submit">Submit</button>
+ </form>
+
+ <form purpose="nothing stored for unchanged values when set by a script."
+ id="form6">
+ <input type="text" name="test1" value="dontSaveThis">
+ <button type="submit">Submit</button>
+ </form>
+
+ <form purpose="nothing stored for unchanged values. (.value not touched)"
+ id="form7">
+ <input type="text" name="test1" value="dontSaveThis">
+ <button type="submit">Submit</button>
+ </form>
+
+ <form purpose="nothing stored for no field name or ID"
+ id="form8">
+ <input type="text">
+ <button type="submit">Submit</button>
+ </form>
+
+ <form purpose="nothing stored for nothing to save"
+ id="form9">
+ <button type="submit">Submit</button>
+ </form>
+
+ <form purpose="nothing stored for input with name too long (300 chars.)"
+ id="form10">
+ <input type="text" name="12345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890">
+ <button type="submit">Submit</button>
+ </form>
+
+ <form purpose="nothing stored for input with value too long (300 chars.)"
+ id="form11">
+ <input type="text" name="test1">
+ <button type="submit">Submit</button>
+ </form>
+
+ <form purpose="nothing stored for input with value of one space (which should be trimmed)"
+ id="form12">
+ <input type="text" name="test1">
+ <button type="submit">Submit</button>
+ </form>
+
+ <form purpose="nothing stored for password field"
+ id="form13">
+ <input type="password" name="test1">
+ <button type="submit">Submit</button>
+ </form>
+
+ <form purpose="nothing stored for password field (type toggled to password and back after pageload)"
+ id="form14">
+ <input type="text" name="test1">
+ <button type="submit">Submit</button>
+ </form>
+
+ <form purpose="nothing stored for input with sensitive data (16 digit credit card number)"
+ id="form15">
+ <script type="text/javascript">
+ let form = document.getElementById("form15");
+ for (let i = 0; i < 10; i++) {
+ const input = form.appendChild(document.createElement("input"));
+ input.type = "text";
+ input.name = "test" + (i + 1);
+ }
+ </script>
+ <button type="submit">Submit</button>
+ </form>
+
+ <form purpose="nothing stored for input with sensitive data (15 digit credit card number)"
+ id="form16">
+ <script type="text/javascript">
+ form = document.getElementById("form16");
+ for (let i = 0; i < 10; i++) {
+ const input = form.appendChild(document.createElement("input"));
+ input.type = "text";
+ input.name = "test" + (i + 1);
+ }
+ </script>
+ <button type="submit">Submit</button>
+ </form>
+
+ <form purpose="nothing stored for input with sensitive data (19 digit credit card number)"
+ id="form17">
+ <input type="text" name="test1">
+ <button type="submit">Submit</button>
+ </form>
+
+ <form purpose="nothing stored for input with sensitive data (16 digit hyphenated credit card number)"
+ id="form18">
+ <input type="text" name="test1">
+ <button type="submit">Submit</button>
+ </form>
+
+ <form purpose="nothing stored for input with sensitive data (15 digit whitespace-separated credit card number)"
+ id="form19">
+ <input type="text" name="test1">
+ <button type="submit">Submit</button>
+ </form>
+
+ <form purpose="nothing stored for the invalid form"
+ id="form20">
+ <input type='email' name='test1'>
+ <button type='submit'>Submit</button>
+ </form>
+
+ <form purpose="nothing stored for the invalid form"
+ id="form21">
+ <input type='email' value='foo'>
+ <input type='text' name='test1'>
+ <button type='submit'>Submit</button>
+ </form>
+
+ <form purpose="nothing stored for the input with name 'searchbar-history'"
+ id="form22">
+ <input type='text' name='searchbar-history'>
+ <button type='submit'>Submit</button>
+ </form>
+
+ <form purpose="nothing stored for input autocomplete=cc-csc (case-insensitive token)"
+ id="form23">
+ <input type="text" name="test1" autocomplete=" cc-CSC ">
+ <button type="submit">Submit</button>
+ </form>
+
+ <form purpose="nothing stored for input autocomplete=new-password (case-insensitive token)"
+ id="form24">
+ <input type="text" name="test1" autocomplete=" NEW-password ">
+ <button type="submit">Submit</button>
+ </form>
+
+ <form purpose="nothing stored after user input followed by reset button click"
+ id="form25">
+ <input type="text" name="test1" defaultValue="do not save me" value="do not save me either">
+ <button type="submit">Submit</button>
+ <button type="reset">Reset</button>
+ </form>
+
+ <form purpose="nothing stored after user input changed by a script"
+ id="form26">
+ <input type="text" name="test1" defaultValue="do not save me" value="do not save me either">
+ <button type="submit">Submit</button>
+ </form>
+
+ <form purpose="nothing stored for input autocomplete=one-time-code"
+ id="form27">
+ <input type="text" name="test1" autocomplete="one-time-code">
+ <button type="submit">Submit</button>
+ </form>
+
+ <!-- ===== Things that should be saved ===== -->
+
+ <!-- Form 100 is submitted into an iframe, not declared here. -->
+
+ <form purpose="saved input with no default value"
+ id="form101">
+ <input type="text" name="test1">
+ <button type="submit">Submit</button>
+ </form>
+
+ <form purpose="saved input with a default value"
+ id="form102">
+ <input type="text" name="test2" value="originalValue">
+ <button type="submit">Submit</button>
+ </form>
+
+ <form purpose="saved input with id and not name"
+ id="form103">
+ <input type="text" name="test3">
+ <button type="submit">Submit</button>
+ </form>
+
+ <form purpose="saved input with leading and trailing space"
+ id="form104">
+ <input type="text" name="test4">
+ <button type="submit">Submit</button>
+ </form>
+
+ <form purpose="saved input with leading and trailing whitespace"
+ id="form105">
+ <input type="text" name="test5">
+ <button type="submit">Submit</button>
+ </form>
+
+ <form purpose="saved input that looks like sensitive data but doesn't satisfy the requirements (incorrect length)"
+ id="form106">
+ <input type="text" name="test6">
+ <button type="submit">Submit</button>
+ </form>
+
+ <form purpose="input that looks like sensitive data but doesn't satisfy the requirements (Luhn check fails for 16 chars)"
+ id="form107">
+ <script type="text/javascript">
+ form = document.getElementById("form107");
+ for (let i = 0; i < 10; i++) {
+ let input = form.appendChild(document.createElement("input"));
+ input.type = "text";
+ input.name = "test7_" + (i + 1);
+ }
+ </script>
+ <button type="submit">Submit</button>
+ </form>
+
+ <form purpose="input that looks like sensitive data but doesn't satisfy the requirements (Luhn check fails for 15 chars)"
+ id="form108">
+ <script type="text/javascript">
+ form = document.getElementById("form108");
+ for (let i = 0; i != 10; i++) {
+ let input = form.appendChild(document.createElement("input"));
+ input.type = "text";
+ input.name = "test8_" + (i + 1);
+ }
+ </script>
+ <button type="submit">Submit</button>
+ </form>
+
+ <form purpose="form data submitted through HTTPS, when browser.formfill.saveHttpsForms is true"
+ id="form109" action="https://www.example.com/">
+ <input type="text" name="test9">
+ <button type="submit">Submit</button>
+ </form>
+
+</div>
+<script>
+/* eslint-disable complexity */
+
+const ccNumbers = {
+ valid15: [
+ "930771457288760", "474915027480942",
+ "924894781317325", "714816113937185",
+ "790466087343106", "474320195408363",
+ "219211148122351", "633038472250799",
+ "354236732906484", "095347810189325",
+ ],
+ valid16: [
+ "3091269135815020", "5471839082338112",
+ "0580828863575793", "5015290610002932",
+ "9465714503078607", "4302068493801686",
+ "2721398408985465", "6160334316984331",
+ "8643619970075142", "0218246069710785",
+ ],
+ invalid15: [
+ "526931005800649", "724952425140686",
+ "379761391174135", "030551436468583",
+ "947377014076746", "254848023655752",
+ "226871580283345", "708025346034339",
+ "917585839076788", "918632588027666",
+ ],
+ invalid16: [
+ "9946177098017064", "4081194386488872",
+ "3095975979578034", "3662215692222536",
+ "6723210018630429", "4411962856225025",
+ "8276996369036686", "4449796938248871",
+ "3350852696538147", "5011802870046957",
+ ],
+};
+
+function setUserInput(formNumber, inputName, value) {
+ const input = SpecialPowers.wrap(getFormElementByName(formNumber, inputName));
+ input.setUserInput(value);
+}
+
+function setScriptInput(formNumber, inputName, value) {
+ getFormElementByName(formNumber, inputName).value = value;
+}
+
+function checkSubmitDoesNotSave(formNumber, inputName, value) {
+ return new Promise((resolve, reject) => {
+ const form = document.getElementById("form" + formNumber);
+ form.addEventListener("submit", async e => {
+ const historyEntriesCount = await countEntries(null, null);
+ ok(!historyEntriesCount, form.getAttribute("purpose"));
+ resolve();
+ }, { once: true });
+
+ getFormSubmitButton(formNumber).click();
+ });
+}
+
+function checkInvalidFirstInputDoesNotSave(formNumber, value) {
+ return new Promise((resolve, reject) => {
+ const form = document.getElementById("form" + formNumber);
+ const input = form.querySelector("input");
+ input.addEventListener("invalid", async e => {
+ const historyEntriesCount = await countEntries(null, null);
+ ok(!historyEntriesCount, form.getAttribute("purpose"));
+ resolve();
+ }, { once: true});
+
+ getFormSubmitButton(formNumber).click();
+ });
+}
+
+async function checkSubmitSaves(formNumber, inputName, interactiveValue, savedValue, storageEventData = "formhistory-add") {
+ setUserInput(formNumber, inputName, interactiveValue);
+ const form = document.getElementById("form" + formNumber);
+ const storageEventPromise = promiseNextStorageEvent();
+
+ getFormSubmitButton(formNumber).click();
+
+ const storageEvent = await storageEventPromise;
+ isDeeply(storageEvent, {
+ subject: null,
+ topic: "satchel-storage-changed",
+ data: storageEventData
+ }, "expected storage event");
+
+ const historyEntriesCount = await countEntries(inputName, savedValue);
+ is(historyEntriesCount, 1, form.getAttribute("purpose"));
+}
+
+preventSubmitOnForms();
+
+add_setup(async () => {
+ await updateFormHistory([
+ { op: "remove" },
+ ]);
+ const historyEntriesCount = await countEntries(null, null);
+ ok(!historyEntriesCount, "checking for initially empty storage");
+});
+
+add_task(async function form1_does_not_save() {
+ setUserInput(1, "test1", "dontSaveThis");
+ await checkSubmitDoesNotSave(1);
+});
+
+add_task(async function form2_does_not_save() {
+ setUserInput(2, "test1", "dontSaveThis");
+ await checkSubmitDoesNotSave(2);
+});
+
+add_task(async function form3_does_not_save() {
+ setUserInput(3, "test1", "dontSaveThis");
+ await checkSubmitDoesNotSave(3);
+});
+
+add_task(async function form4_does_not_save() {
+ setUserInput(4, "test1", "dontSaveThis");
+ await checkSubmitDoesNotSave(4);
+});
+
+add_task(async function form5_does_not_save() {
+ setUserInput(5, "test1", "");
+ await checkSubmitDoesNotSave(5);
+});
+
+add_task(async function form6_does_not_save() {
+ setScriptInput(6, "test1", "dontSaveThis");
+ await checkSubmitDoesNotSave(6);
+});
+
+add_task(async function form7_does_not_save() {
+ // Form 7 deliberately left untouched.
+ await checkSubmitDoesNotSave(7);
+});
+
+add_task(async function form8_does_not_save() {
+ // Form 8 has an input with no name or input attribute.
+ const input = SpecialPowers.wrap(document.getElementById("form8").elements[0]);
+ is(input.type, "text", "checking we got unidentified input");
+ input.setUserInput("dontSaveThis");
+ await checkSubmitDoesNotSave(8);
+});
+
+add_task(async function form9_does_not_save() {
+ // Form 9 has nothing to modify.
+ await checkSubmitDoesNotSave(9);
+});
+
+add_task(async function form10_does_not_save() {
+ setUserInput(10,
+ "12345678901234567890123456789012345678901234567890123456789012345678901234567890123456" +
+ "789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456" +
+ "789012345678901234567890123456789012345678901234567890123456789012345678901234567890",
+ "dontSaveThis");
+ await checkSubmitDoesNotSave(10);
+});
+
+add_task(async function form11_does_not_save() {
+ setUserInput(11, "test1",
+ "123456789012345678901234567890123456789012345678901234567890123456789" +
+ "012345678901234567890123456789012345678901234567890123456789012345678" +
+ "901234567890123456789012345678901234567890123456789012345678901234567" +
+ "89012345678901234567890123456789012345678901234567890");
+ await checkSubmitDoesNotSave(11);
+});
+
+add_task(async function form12_does_not_save() {
+ setUserInput(12, "test1", " ");
+ await checkSubmitDoesNotSave(12);
+});
+
+add_task(async function form13_does_not_save() {
+ setUserInput(13, "test1", "dontSaveThis");
+ await checkSubmitDoesNotSave(13);
+});
+
+add_task(async function form14_does_not_save() {
+ const input = SpecialPowers.wrap(document.getElementById("form14").elements[0]);
+ input.type = "password";
+ input.setUserInput("dontSaveThis");
+ // Set it back to type=text to simulate a password visibility toggle.
+ input.type = "text";
+ await checkSubmitDoesNotSave(14);
+});
+
+add_task(async function form15_does_not_save() {
+ const testData = ccNumbers.valid16;
+ for (let i = 0; i < testData.length; i++) {
+ setUserInput(15, "test" + (i + 1), testData[i]);
+ }
+ await checkSubmitDoesNotSave(15);
+});
+
+add_task(async function form16_does_not_save() {
+ const testData = ccNumbers.valid15;
+ for (let i = 0; i < testData.length; i++) {
+ setUserInput(16, "test" + (i + 1), testData[i]);
+ }
+ await checkSubmitDoesNotSave(16);
+});
+
+add_task(async function form17_does_not_save() {
+ setUserInput(17, "test1", "6799990100000000019");
+ await checkSubmitDoesNotSave(17);
+});
+
+add_task(async function form18_does_not_save() {
+ setUserInput(18, "test1", "0000-0000-0080-4609");
+ await checkSubmitDoesNotSave(18);
+});
+
+add_task(async function form19_does_not_save() {
+ setUserInput(19, "test1", "0000 0000 0222 331");
+ await checkSubmitDoesNotSave(19);
+});
+
+add_task(async function form20_does_not_save() {
+ setUserInput(20, "test1", "dontSaveThis");
+ await checkInvalidFirstInputDoesNotSave(20, "invalid");
+});
+
+add_task(async function form21_does_not_save() {
+ setUserInput(21, "test1", "dontSaveThis");
+ await checkInvalidFirstInputDoesNotSave(21, "invalid");
+});
+
+add_task(async function form22_does_not_save() {
+ setUserInput(22, "searchbar-history", "dontSaveThis");
+ await checkSubmitDoesNotSave(22);
+});
+
+add_task(async function form23_does_not_save() {
+ setUserInput(23, "test1", "987");
+ await checkSubmitDoesNotSave(23);
+});
+
+add_task(async function form24_does_not_save() {
+ setUserInput(24, "test1", "s3cr3t");
+ await checkSubmitDoesNotSave(24);
+});
+
+add_task(async function form25_does_not_save() {
+ setUserInput(25, "test1", "s3cr3t");
+ document.querySelector("form[id=form25] button[type=reset]").click();
+ await checkSubmitDoesNotSave(25);
+});
+
+add_task(async function form26_does_not_save() {
+ setUserInput(26, "test1", "s3cr3t");
+ document.querySelector("form[id=form26] input[name=test1]").value = "script changed me";
+ await checkSubmitDoesNotSave(26);
+});
+
+add_task(async function form27_does_not_save() {
+ setUserInput(27, "test1", "123456");
+ await checkSubmitDoesNotSave(27);
+});
+
+add_task(async function form100_saves() {
+ const iframe = SpecialPowers.wrap(document.getElementById("iframe"));
+ const browsingContext = SpecialPowers.unwrap(iframe.browsingContext);
+ const storageEventPromise = promiseNextStorageEvent();
+ await SpecialPowers.spawn(browsingContext, [], () => {
+ /* eslint-disable no-undef */
+ const input = SpecialPowers.wrap(content.document.getElementById("subtest2"));
+ input.setUserInput("subtestValue");
+ // This will prevent endless loop of tests
+ for (const form of content.document.forms) {
+ /* eslint-disable-next-line mozilla/balanced-listeners */
+ form.addEventListener("submit", e => e.preventDefault());
+ }
+ content.document.querySelector("button").click();
+ /* eslint-enable no-undef */
+ });
+
+ const storageEvent = await storageEventPromise;
+ isDeeply(storageEvent, {
+ subject: null,
+ topic: "satchel-storage-changed",
+ data: "formhistory-add"
+ }, "expected storage event");
+
+ const historyEntriesCount = await countEntries("subtest2", "subtestValue");
+ is(historyEntriesCount, 1, "saved from iframe");
+});
+
+add_task(async function form101_saves() {
+ await checkSubmitSaves(101, "test1", "savedValue", "savedValue");
+});
+
+add_task(async function form102_saves() {
+ await checkSubmitSaves(102, "test2", "savedValue", "savedValue");
+});
+
+add_task(async function form103_saves() {
+ await checkSubmitSaves(103, "test3", "savedValue", "savedValue");
+});
+
+add_task(async function form104_saves() {
+ await checkSubmitSaves(104, "test4", " trimTrailingAndLeadingSpace ", "trimTrailingAndLeadingSpace");
+});
+
+add_task(async function form105_saves() {
+ await checkSubmitSaves(105, "test5", "\t trimTrailingAndLeadingWhitespace\t ", "trimTrailingAndLeadingWhitespace");
+});
+
+add_task(async function form106_saves() {
+ // passes luhn but too long
+ await checkSubmitSaves(106, "test6", "55555555555544445553", "55555555555544445553");
+});
+
+add_task(async function form107_saves() {
+ for (let i = 0; i != ccNumbers.invalid16.length; i++) {
+ const name = "test7_" + (i + 1);
+ const value = ccNumbers.invalid16[i];
+ await checkSubmitSaves(107, name, value, value, i != 0 ? "formhistory-update" : "formhistory-add");
+ }
+});
+
+add_task(async function form108_saves() {
+ for (let i = 0; i != ccNumbers.invalid15.length; i++) {
+ const name = "test8_" + (i + 1);
+ const value = ccNumbers.invalid15[i];
+ await checkSubmitSaves(108, name, value, value, i != 0 ? "formhistory-update" : "formhistory-add");
+ }
+});
+
+add_task(async function form109_saves() {
+ setUserInput(109, "test9", "savedValue");
+ await checkSubmitSaves(109, "test9", "savedValue", "savedValue");
+});
+
+</script>
+</body>
+</html>
diff --git a/toolkit/components/satchel/test/test_history_datalist_duplicates.html b/toolkit/components/satchel/test/test_history_datalist_duplicates.html
new file mode 100644
index 0000000000..395405396a
--- /dev/null
+++ b/toolkit/components/satchel/test/test_history_datalist_duplicates.html
@@ -0,0 +1,55 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>Test for Form History / DataList Duplicate Autocomplete Entries: Bug 1263588</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/EventUtils.js"></script>
+ <script type="text/javascript" src="satchel_common.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
+</head>
+<body>
+<div id="content">
+
+ <!-- normal, basic form -->
+ <form id="form1" onsubmit="return false;">
+ <input list="suggest" type="text" name="field1" id="field1">
+ <button type="submit">Submit</button>
+ <datalist id="suggest">
+ <option value="Mozilla">
+ <option value="Firefox">
+ <option value="Relay is awesome">
+ </datalist>
+ </form>
+
+</div>
+
+<script>
+
+add_setup(async () => {
+ await updateFormHistory([
+ { op: "add", fieldname: "field1", value: "Mozilla" },
+ { op: "add", fieldname: "field1", value: "Firefox" },
+ { op: "add", fieldname: "field1", value: "Relay" },
+ ]);
+});
+
+add_task(async function test_all_entries_are_unique_and_ordered() {
+ await triggerAutofillAndCheckEntriesValue("",["Relay", "Mozilla", "Firefox", "Relay is awesome"]);
+});
+
+add_task(async function test_duplicate_entries_are_shown_once() {
+ await triggerAutofillAndCheckEntriesValue("f", ["Firefox"]);
+});
+
+add_task(async function test_non_duplicate_entries_are_shown_once() {
+ await triggerAutofillAndCheckEntriesValue("rel",["Relay", "Relay is awesome"]);
+});
+
+async function triggerAutofillAndCheckEntriesValue(inputValue, expectedValues) {
+ await openPopupOn("#form1 > input", { inputValue });
+ isDeeply(getMenuEntries(), expectedValues, "Matching deduplicated autocomplete list entries with expected values.");
+}
+
+</script>
+</body>
+</html>
diff --git a/toolkit/components/satchel/test/test_input_valid_state_with_autocomplete.html b/toolkit/components/satchel/test/test_input_valid_state_with_autocomplete.html
new file mode 100644
index 0000000000..196ca59765
--- /dev/null
+++ b/toolkit/components/satchel/test/test_input_valid_state_with_autocomplete.html
@@ -0,0 +1,125 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Test for valid state with autocomplete</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/EventUtils.js"></script>
+ <script type="text/javascript" src="satchel_common.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
+ <style>
+ input:invalid {
+ border: red 1px solid;
+ }
+ </style>
+</head>
+<body>
+<p id="display"></p>
+
+<div id="content">
+ <form id="form1">
+ <input type="email" name="field1">
+ <button type="submit">Submit</button>
+ </form>
+</div>
+
+<script>
+
+add_setup(async () => {
+ await updateFormHistory([
+ { op: "remove" },
+ { op: "add", fieldname: "field1", value: "email@example.com" },
+ { op: "add", fieldname: "field1", value: "email@example.com." },
+ ]);
+});
+
+add_task(async function datalist_with_caching() {
+ const kSetUserInputCancelable = SpecialPowers.getBoolPref("dom.input_event.allow_to_cancel_set_user_input");
+ const { input } = await openPopupOn("input[name=field1]");
+ synthesizeKey("KEY_Escape");
+
+ let beforeInputFired = false;
+ input.addEventListener("beforeinput", e => {
+ beforeInputFired = true;
+ ok(e.cancelable, "'beforeinput' event for 'insertText' is cancelable");
+ is(e.inputType, "insertText", "inputType of 'beforeinput' event is 'insertText'");
+ ok(input.validity.valid, "Valid immediately before inserting a character");
+ ok(!input.matches(":invalid"),
+ "Shouldn't match ':invalid' immediately before inserting a character");
+ }, { once: true });
+
+ let inputFired = false;
+ input.addEventListener("input", e => {
+ inputFired = true;
+ is(e.inputType, "insertText", "inputType of 'input' event should be 'insertText'");
+ ok(!input.validity.valid, "Invalid immediately after inserting a character");
+ ok(input.matches(":invalid"),
+ "Should match ':invalid' immediately after inserting a character");
+ }, { once: true });
+ synthesizeKey("e");
+ ok(beforeInputFired, "'beforeinput' event fired at typing 'e'");
+ ok(inputFired, "'input' event fired at typing 'e'");
+
+ await notifyMenuChanged(2);
+ inputFired = false;
+ beforeInputFired = false;
+ input.addEventListener("beforeinput", e => {
+ beforeInputFired = true;
+ is(e.cancelable, kSetUserInputCancelable,
+ "'beforeinput' event for 'insertReplacementText' is cancelable unless it's suppressed by the pref");
+ is(e.inputType, "insertReplacementText",
+ "inputType of 'beforeinput' event is 'insertReplacementText'");
+ ok(!input.validity.valid,
+ "Invalid immediately before selecting valid item in autocomplete list");
+ ok(input.matches(":invalid"),
+ "Matches ':invalid' immediately before selecting valid item in autocomplete list");
+ }, { once: true });
+ input.addEventListener("input", e => {
+ inputFired = true;
+ is(e.inputType, "insertReplacementText",
+ "inputType of 'input' event is 'insertReplacementText'");
+ ok(input.validity.valid,
+ "Valid immediately after selecting valid item in autocomplete list");
+ ok(!input.matches(":invalid"),
+ "Shouldn't match ':invalid' immediately after selecting valid item in autocomplete list");
+ }, { once: true });
+ synthesizeKey("KEY_ArrowDown");
+ synthesizeKey("KEY_Enter"); // Select valid item
+ ok(beforeInputFired, "'beforeinput' event should have been fired at selecting valid item");
+ ok(inputFired, "'input' event should have been fired at selecting valid item");
+
+ inputFired = false;
+ beforeInputFired = false;
+ synthesizeKey("KEY_Backspace");
+ await notifyMenuChanged(2);
+ synthesizeKey("KEY_ArrowDown");
+ synthesizeKey("KEY_ArrowDown");
+ input.addEventListener("beforeinput", e => {
+ ok(!beforeInputFired, '"input" event should be fired only once at typing');
+ beforeInputFired = true;
+ is(e.cancelable, kSetUserInputCancelable,
+ `"beforeinput" event for "insertReplacementText" is cancelable unless it's suppressed by the pref`);
+ is(e.inputType, "insertReplacementText",
+ "inputType of 'beforeinput' event is 'insertReplacementText'");
+ ok(input.validity.valid,
+ "Valid immediately before selecting invalid item in autocomplete list");
+ ok(!input.matches(":invalid"),
+ "Shouldn't match ':invalid' immediately after selecting invalid item in autocomplete list");
+ }, { once: true });
+ input.addEventListener("input", (event) => {
+ ok(!inputFired, '"input" event is fired only once at typing');
+ inputFired = true;
+ is(event.inputType, "insertReplacementText",
+ "inputType of 'input' event is 'insertReplacementText'");
+ ok(!input.validity.valid,
+ "Invalid immediately after selecting invalid item in autocomplete list");
+ ok(input.matches(":invalid"),
+ "Matches ':invalid' immediately after selecting invalid item in autocomplete list");
+ }, { once: true });
+ synthesizeKey("KEY_Enter"); // Select invalid item
+ ok(beforeInputFired, "'beforeinput' event should have been fired at selecting invalid item");
+ ok(inputFired, "'input' event should have been fired at selecting invalid item");
+});
+
+</script>
+</body>
diff --git a/toolkit/components/satchel/test/test_password_autocomplete.html b/toolkit/components/satchel/test/test_password_autocomplete.html
new file mode 100644
index 0000000000..d24f556d9e
--- /dev/null
+++ b/toolkit/components/satchel/test/test_password_autocomplete.html
@@ -0,0 +1,89 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>Test for form history on type=password</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/EventUtils.js"></script>
+ <script type="text/javascript" src="satchel_common.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
+</head>
+<body>
+ Test for form history on type=password
+ (based on test_bug_511615.html)
+<p id="display"></p>
+
+<!-- we presumably can't hide the content for this test. -->
+<div id="content">
+ <datalist id="datalist1">
+ <option>value10</option>
+ <option>value11</option>
+ <option>value12</option>
+ </datalist>
+ <form id="form1" onsubmit="return false;">
+ <!-- Don't set the type to password until rememberSignons is false since we
+ want to test when rememberSignons is false. -->
+ <input type="to-be-password" name="field1" list="datalist1">
+ <button type="submit">Submit</button>
+ </form>
+ <!-- Same as form1 but with an insecure HTTP action -->
+ <form id="form2" onsubmit="return false;" action="http://mochi.test/">
+ <input type="to-be-password" name="field1" list="datalist1">
+ <button type="submit">Submit</button>
+ </form>
+</div>
+
+<script>
+
+add_setup(async () => {
+ await SpecialPowers.pushPrefEnv({set: [["signon.rememberSignons", false]]});
+
+ is(window.location.protocol, "https:", "This test must run on HTTPS");
+
+ // Now that rememberSignons is false, create the password fields.
+ document.querySelector("#form1 > input").type = "password";
+ document.querySelector("#form2 > input").type = "password";
+
+ await updateFormHistory([
+ { op: "remove" },
+ { op: "add", fieldname: "field1", value: "value1" },
+ { op: "add", fieldname: "field1", value: "value2" },
+ { op: "add", fieldname: "field1", value: "value3" },
+ { op: "add", fieldname: "field1", value: "value4" },
+ { op: "add", fieldname: "field1", value: "value5" },
+ { op: "add", fieldname: "field1", value: "value6" },
+ { op: "add", fieldname: "field1", value: "value7" },
+ { op: "add", fieldname: "field1", value: "value8" },
+ { op: "add", fieldname: "field1", value: "value9" },
+ ]);
+});
+
+add_task(async function test_secure_noFormHistoryOrWarning() {
+ const input = document.querySelector("#form1 input");
+
+ // The autocomplete popup should not open under any circumstances on
+ // type=password with password manager disabled.
+ for (let triggerFn of [
+ () => input.focus(),
+ () => input.click(),
+ () => synthesizeKey("KEY_ArrowDown"),
+ () => synthesizeKey("KEY_PageDown"),
+ () => synthesizeKey("KEY_Enter"),
+ () => sendString("v "),
+ () => synthesizeKey("KEY_Backspace"),
+ ]) {
+ info("Testing: " + triggerFn.toString());
+ // We must wait for the entire timeout for each individual test, because the
+ // next event in the list might prevent the popup from opening.
+ await noPopupBy(triggerFn);
+ }
+});
+
+add_task(async function test_insecure_focusWarning() {
+ // Form 2 has an insecure action so should show the warning even if password manager is disabled.
+ await openPopupOn("#form2 > input");
+ ok(getMenuEntries()[0].includes("Logins entered here could be compromised"),
+ "Check warning is first");
+});
+</script>
+</body>
+</html>
diff --git a/toolkit/components/satchel/test/test_popup_direction.html b/toolkit/components/satchel/test/test_popup_direction.html
new file mode 100644
index 0000000000..6b2fa010d6
--- /dev/null
+++ b/toolkit/components/satchel/test/test_popup_direction.html
@@ -0,0 +1,44 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>Test for Popup Direction</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/EventUtils.js"></script>
+ <script type="text/javascript" src="satchel_common.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
+</head>
+<body>
+Test for Popup Direction
+<p id="display"></p>
+
+<!-- we presumably can't hide the content for this test. -->
+<div id="content">
+ <!-- normal, basic form -->
+ <form id="form1" onsubmit="return false;">
+ <input type="text" name="field1">
+ <button type="submit">Submit</button>
+ </form>
+</div>
+
+<script>
+
+add_setup(async () => {
+ await updateFormHistory([
+ { op: "remove" },
+ { op: "add", fieldname: "field1", value: "value1" },
+ { op: "add", fieldname: "field1", value: "value2" },
+ ]);
+});
+
+add_task(async function test_popup_direction() {
+ for (const direction of ["ltr", "rtl"]) {
+ document.getElementById("content").style.direction = direction;
+ await openPopupOn("#form1 > input");
+ const popupState = await getPopupState();
+ is(popupState.direction, direction, "Direction should match.");
+ }
+});
+
+</script>
+</body>
+</html>
diff --git a/toolkit/components/satchel/test/test_popup_enter_event.html b/toolkit/components/satchel/test/test_popup_enter_event.html
new file mode 100644
index 0000000000..6150a8a57a
--- /dev/null
+++ b/toolkit/components/satchel/test/test_popup_enter_event.html
@@ -0,0 +1,70 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Test for events while the form history popup is open</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/EventUtils.js"></script>
+ <script type="text/javascript" src="satchel_common.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
+</head>
+<body>
+Form History test: Test for events while the form history popup is open
+<p id="display"></p>
+
+<div id="content">
+ <form id="form1">
+ <input type="text" name="field1">
+ <button type="submit">Submit</button>
+ </form>
+</div>
+
+<script>
+
+add_setup(async () => {
+ await updateFormHistory([
+ { op: "remove" },
+ { op: "add", fieldname: "field1", value: "value1" },
+ ]);
+});
+
+add_task(async function popupEnterEvent() {
+ const form = document.querySelector("#form1");
+ const input = form.querySelector("input");
+ const expectedValue = "value1";
+
+ function handleEnter(e) {
+ if (e.keyCode != KeyEvent.DOM_VK_RETURN) {
+ return;
+ }
+
+ info("RETURN received for phase: " + e.eventPhase);
+ if (input.value == expectedValue) {
+ ok(true, "RETURN should be received when the popup is closed");
+ is(input.value, expectedValue, "Check input value when enter is pressed the 2nd time");
+ info("form should submit with the default handler");
+ } else {
+ ok(false, "RETURN keypress shouldn't have been received when a popup item is selected");
+ }
+ }
+
+ const submitTested = new Promise(resolve => {
+ SpecialPowers.addSystemEventListener(input, "keypress", handleEnter, true);
+ form.addEventListener("submit", e => {
+ e.preventDefault();
+ is(input.value, expectedValue, "Check input value in the submit handler");
+ SpecialPowers.removeSystemEventListener(input, "keypress", handleEnter, true);
+ resolve();
+ }, { once: true });
+ });
+
+ await openPopupOn(input, { inputValue: "value" });
+ synthesizeKey("KEY_ArrowDown");
+ synthesizeKey("KEY_Enter"); // select the first entry in the popup
+ synthesizeKey("KEY_Enter"); // try to submit the form with the filled value
+ await submitTested;
+});
+
+</script>
+</body>
+</html>
diff --git a/toolkit/components/satchel/test/test_submit_on_keydown_enter.html b/toolkit/components/satchel/test/test_submit_on_keydown_enter.html
new file mode 100644
index 0000000000..f5879a2b5e
--- /dev/null
+++ b/toolkit/components/satchel/test/test_submit_on_keydown_enter.html
@@ -0,0 +1,108 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Test for events while the form history popup is open</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/EventUtils.js"></script>
+ <script type="text/javascript" src="satchel_common.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
+</head>
+<body>
+Form History test: Test for keydown handler submitting the form
+<p id="display"></p>
+
+<div id="content">
+ <form id="form1">
+ <input type="text" name="field1">
+ <button type="submit">Submit</button>
+ </form>
+</div>
+
+<script>
+
+add_setup(async () => {
+ await updateFormHistory([
+ { op: "remove" },
+ { op: "add", fieldname: "field1", value: "value1" },
+ ]);
+});
+
+add_task(async function submitOnKeydownEnter() {
+ const form = document.querySelector("#form1");
+ const input = form.querySelector("input");
+ const expectedValue = "value1";
+ let beforeInputFired = false;
+ const submitTested = new Promise(resolve => {
+
+ function handleBeforeInput(aEvent) {
+ info("BeforeInput");
+ beforeInputFired = true;
+ is(input.value, "value", "The value should've not been modified yet");
+ ok(aEvent instanceof InputEvent,
+ '"beforeinput" event should be dispatched with InputEvent interface');
+ is(aEvent.cancelable, SpecialPowers.getBoolPref("dom.input_event.allow_to_cancel_set_user_input"),
+ `"beforeinput" event should be cancelable unless it's supporessed by the pref`);
+ is(aEvent.bubbles, true,
+ '"beforeinput" event should always bubble');
+ is(aEvent.inputType, "insertReplacementText",
+ 'inputType of "beforeinput" event should be "insertReplacementText"');
+ is(aEvent.data, expectedValue,
+ `data of "beforeinput" event should be "${expectedValue}"`);
+ is(aEvent.dataTransfer, null,
+ 'dataTransfer of "beforeinput" event should be null');
+ is(aEvent.getTargetRanges().length, 0,
+ 'getTargetRanges() of "beforeinput" event should return empty array');
+ }
+
+ function handleInput(e) {
+ info("Input");
+ ok(beforeInputFired, '"beforeinput" event should have been fired');
+ is(input.value, expectedValue, "Check input value");
+ ok(e instanceof InputEvent,
+ '"input" event should be dispatched with InputEvent interface');
+ is(e.cancelable, false, '"input" event should be never cancelable');
+ is(e.bubbles, true, '"input" event should always bubble');
+ is(e.inputType, "insertReplacementText", 'inputType of "input" event');
+ is(e.data, expectedValue, "data of input event");
+ is(e.dataTransfer, null, 'dataTransfer of "input" event');
+ is(e.getTargetRanges().length, 0,
+ 'getTargetRanges() of "input" event should return empty array');
+ removeEventListeners();
+ resolve();
+ }
+
+ function removeEventListeners() {
+ input.removeEventListener("beforeinput", handleBeforeInput, true);
+ input.removeEventListener("input", handleInput, true);
+ }
+
+ input.addEventListener("beforeinput", handleBeforeInput, true);
+ input.addEventListener("input", handleInput, true);
+ input.addEventListener("keydown", function handleEnterDown(e) {
+ if (e.keyCode != KeyEvent.DOM_VK_RETURN) {
+ return;
+ }
+ info("Enter KeyDown");
+ input.removeEventListener("keydown", handleEnterDown, true);
+ form.submit();
+ }, true);
+ form.addEventListener("submit", () => {
+ info("Submit");
+ ok(false, "The form should not be submitted");
+ removeEventListeners();
+ resolve();
+ }, { once: true });
+ });
+
+ await openPopupOn(input, { inputValue: "value" });
+
+ synthesizeKey("KEY_ArrowDown");
+ synthesizeKey("KEY_Enter"); // select the first entry in the popup
+
+ await submitTested;
+});
+
+</script>
+</body>
+</html>
diff --git a/toolkit/components/satchel/test/unit/asyncformhistory_expire.sqlite b/toolkit/components/satchel/test/unit/asyncformhistory_expire.sqlite
new file mode 100644
index 0000000000..07b43c2096
--- /dev/null
+++ b/toolkit/components/satchel/test/unit/asyncformhistory_expire.sqlite
Binary files differ
diff --git a/toolkit/components/satchel/test/unit/formhistory_1000.sqlite b/toolkit/components/satchel/test/unit/formhistory_1000.sqlite
new file mode 100644
index 0000000000..5eeab074fd
--- /dev/null
+++ b/toolkit/components/satchel/test/unit/formhistory_1000.sqlite
Binary files differ
diff --git a/toolkit/components/satchel/test/unit/formhistory_CORRUPT.sqlite b/toolkit/components/satchel/test/unit/formhistory_CORRUPT.sqlite
new file mode 100644
index 0000000000..5f7498bfc2
--- /dev/null
+++ b/toolkit/components/satchel/test/unit/formhistory_CORRUPT.sqlite
@@ -0,0 +1 @@
+BACON
diff --git a/toolkit/components/satchel/test/unit/formhistory_apitest.sqlite b/toolkit/components/satchel/test/unit/formhistory_apitest.sqlite
new file mode 100644
index 0000000000..00daf03c27
--- /dev/null
+++ b/toolkit/components/satchel/test/unit/formhistory_apitest.sqlite
Binary files differ
diff --git a/toolkit/components/satchel/test/unit/formhistory_autocomplete.sqlite b/toolkit/components/satchel/test/unit/formhistory_autocomplete.sqlite
new file mode 100644
index 0000000000..724cff73f6
--- /dev/null
+++ b/toolkit/components/satchel/test/unit/formhistory_autocomplete.sqlite
Binary files differ
diff --git a/toolkit/components/satchel/test/unit/formhistory_v3.sqlite b/toolkit/components/satchel/test/unit/formhistory_v3.sqlite
new file mode 100644
index 0000000000..e0e8fe2468
--- /dev/null
+++ b/toolkit/components/satchel/test/unit/formhistory_v3.sqlite
Binary files differ
diff --git a/toolkit/components/satchel/test/unit/formhistory_v3v4.sqlite b/toolkit/components/satchel/test/unit/formhistory_v3v4.sqlite
new file mode 100644
index 0000000000..8eab177e97
--- /dev/null
+++ b/toolkit/components/satchel/test/unit/formhistory_v3v4.sqlite
Binary files differ
diff --git a/toolkit/components/satchel/test/unit/formhistory_v999a.sqlite b/toolkit/components/satchel/test/unit/formhistory_v999a.sqlite
new file mode 100644
index 0000000000..216bce4a34
--- /dev/null
+++ b/toolkit/components/satchel/test/unit/formhistory_v999a.sqlite
Binary files differ
diff --git a/toolkit/components/satchel/test/unit/formhistory_v999b.sqlite b/toolkit/components/satchel/test/unit/formhistory_v999b.sqlite
new file mode 100644
index 0000000000..fe400d04a1
--- /dev/null
+++ b/toolkit/components/satchel/test/unit/formhistory_v999b.sqlite
Binary files differ
diff --git a/toolkit/components/satchel/test/unit/head_satchel.js b/toolkit/components/satchel/test/unit/head_satchel.js
new file mode 100644
index 0000000000..3ff06b89fe
--- /dev/null
+++ b/toolkit/components/satchel/test/unit/head_satchel.js
@@ -0,0 +1,188 @@
+/* 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 CURRENT_SCHEMA = 5;
+const PR_HOURS = 60 * 60 * 1000000;
+
+var { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+ChromeUtils.defineESModuleGetters(this, {
+ FormHistory: "resource://gre/modules/FormHistory.sys.mjs",
+ FormHistoryTestUtils:
+ "resource://testing-common/FormHistoryTestUtils.sys.mjs",
+ Sqlite: "resource://gre/modules/Sqlite.sys.mjs",
+});
+
+do_get_profile();
+
+// Send the profile-after-change notification to the form history component to ensure
+// that it has been initialized.
+var formHistoryStartup = Cc[
+ "@mozilla.org/satchel/form-history-startup;1"
+].getService(Ci.nsIObserver);
+formHistoryStartup.observe(null, "profile-after-change", null);
+
+async function getDBVersion(dbfile) {
+ let dbConnection = await Sqlite.openConnection({ path: dbfile.path });
+ let version = await dbConnection.getSchemaVersion();
+ await dbConnection.close();
+
+ return version;
+}
+
+async function getDBSchemaVersion(path) {
+ let db = await Sqlite.openConnection({ path });
+ try {
+ return await db.getSchemaVersion();
+ } finally {
+ await db.close();
+ }
+}
+
+function getFormHistoryDBVersion() {
+ let profileDir = do_get_profile();
+ // Cleanup from any previous tests or failures.
+ let dbFile = profileDir.clone();
+ dbFile.append("formhistory.sqlite");
+ return getDBVersion(dbFile);
+}
+
+const isGUID = /[A-Za-z0-9\+\/]{16}/;
+
+// Find form history entries.
+function searchEntries(terms, params, iter) {
+ FormHistory.search(terms, params).then(
+ results => iter.next(results),
+ error => do_throw("Error occurred searching form history: " + error)
+ );
+}
+
+// Count the number of entries with the given name and value, and call then(number)
+// when done. If name or value is null, then the value of that field does not matter.
+function countEntries(name, value, then) {
+ let obj = {};
+ if (name !== null) {
+ obj.fieldname = name;
+ }
+ if (value !== null) {
+ obj.value = value;
+ }
+
+ FormHistory.count(obj).then(
+ count => {
+ then(count);
+ },
+ error => {
+ do_throw("Error occurred searching form history: " + error);
+ }
+ );
+}
+
+// Perform a single form history update and call then() when done.
+function updateEntry(op, name, value, then) {
+ let obj = { op };
+ if (name !== null) {
+ obj.fieldname = name;
+ }
+ if (value !== null) {
+ obj.value = value;
+ }
+ updateFormHistory(obj, then);
+}
+
+// Add a single form history entry with the current time and call then() when done.
+function addEntry(name, value, then) {
+ let now = Date.now() * 1000;
+ updateFormHistory(
+ {
+ op: "add",
+ fieldname: name,
+ value,
+ timesUsed: 1,
+ firstUsed: now,
+ lastUsed: now,
+ },
+ then
+ );
+}
+
+function promiseCountEntries(name, value, checkFn = () => {}) {
+ return new Promise(resolve => {
+ countEntries(name, value, function (result) {
+ checkFn(result);
+ resolve(result);
+ });
+ });
+}
+
+function promiseUpdateEntry(op, name, value) {
+ return new Promise(res => {
+ updateEntry(op, name, value, res);
+ });
+}
+
+function promiseAddEntry(name, value) {
+ return new Promise(res => {
+ addEntry(name, value, res);
+ });
+}
+
+// Wrapper around FormHistory.update which handles errors. Calls then() when done.
+function updateFormHistory(changes, then) {
+ FormHistory.update(changes).then(then, error => {
+ do_throw("Error occurred updating form history: " + error);
+ });
+}
+
+function promiseUpdate(change) {
+ return FormHistory.update(change);
+}
+
+/**
+ * Logs info to the console in the standard way (includes the filename).
+ *
+ * @param {string} aMessage
+ * The message to log to the console.
+ */
+function do_log_info(aMessage) {
+ print("TEST-INFO | " + _TEST_FILE + " | " + aMessage);
+}
+
+/**
+ * Copies a test file into the profile folder.
+ *
+ * @param {string} aFilename
+ * The name of the file to copy.
+ * @param {string} aDestFilename
+ * The name of the file to copy.
+ * @param {object} [options]
+ * @param {object} [options.overwriteExisting]
+ * Whether to overwrite an existing file.
+ * @returns {string} path to the copied file.
+ */
+async function copyToProfile(
+ aFilename,
+ aDestFilename,
+ { overwriteExisting = false } = {}
+) {
+ let curDir = Services.dirsvc.get("CurWorkD", Ci.nsIFile).path;
+ let srcPath = PathUtils.join(curDir, aFilename);
+ Assert.ok(await IOUtils.exists(srcPath), "Database file found");
+
+ // Ensure that our file doesn't exist already.
+ let destPath = PathUtils.join(PathUtils.profileDir, aDestFilename);
+ let exists = await IOUtils.exists(destPath);
+ if (exists) {
+ if (overwriteExisting) {
+ await IOUtils.remove(destPath);
+ } else {
+ throw new Error("The file should not exist");
+ }
+ }
+ await IOUtils.copy(srcPath, destPath);
+ info(`Copied ${aFilename} to ${destPath}`);
+ return destPath;
+}
diff --git a/toolkit/components/satchel/test/unit/test_async_expire.js b/toolkit/components/satchel/test/unit/test_async_expire.js
new file mode 100644
index 0000000000..b6f25b81dd
--- /dev/null
+++ b/toolkit/components/satchel/test/unit/test_async_expire.js
@@ -0,0 +1,134 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+ChromeUtils.defineESModuleGetters(this, {
+ TestUtils: "resource://testing-common/TestUtils.sys.mjs",
+});
+
+function promiseExpiration() {
+ let promise = TestUtils.topicObserved(
+ "satchel-storage-changed",
+ (subject, data) => {
+ return data == "formhistory-expireoldentries";
+ }
+ );
+
+ // We can't easily fake a "daily idle" event, so for testing purposes form
+ // history listens for another notification to trigger an immediate
+ // expiration.
+ Services.obs.notifyObservers(null, "formhistory-expire-now");
+
+ return promise;
+}
+
+add_task(async function () {
+ // ===== test init =====
+ let testfile = do_get_file("asyncformhistory_expire.sqlite");
+ let profileDir = do_get_profile();
+
+ // Cleanup from any previous tests or failures.
+ let dbFile = profileDir.clone();
+ dbFile.append("formhistory.sqlite");
+ if (dbFile.exists()) {
+ dbFile.remove(false);
+ }
+
+ testfile.copyTo(profileDir, "formhistory.sqlite");
+ Assert.ok(dbFile.exists());
+
+ // We're going to clear this at the end, so it better have the default value now.
+ Assert.ok(!Services.prefs.prefHasUserValue("browser.formfill.expire_days"));
+
+ // Sanity check initial state
+ Assert.equal(508, await promiseCountEntries(null, null));
+ Assert.ok((await promiseCountEntries("name-A", "value-A")) > 0); // lastUsed == distant past
+ Assert.ok((await promiseCountEntries("name-B", "value-B")) > 0); // lastUsed == distant future
+
+ Assert.equal(CURRENT_SCHEMA, await getDBVersion(dbFile));
+
+ // Add a new entry
+ Assert.equal(0, await promiseCountEntries("name-C", "value-C"));
+ await promiseAddEntry("name-C", "value-C");
+ Assert.equal(1, await promiseCountEntries("name-C", "value-C"));
+
+ // Update some existing entries to have ages relative to when the test runs.
+ let now = 1000 * Date.now();
+ let updateLastUsed = (results, age) => {
+ let lastUsed = now - age * 24 * PR_HOURS;
+
+ let changes = [];
+ for (let result of results) {
+ changes.push({ op: "update", lastUsed, guid: result.guid });
+ }
+
+ return changes;
+ };
+
+ let results = await FormHistory.search(["guid"], { lastUsed: 181 });
+ await promiseUpdate(updateLastUsed(results, 181));
+
+ results = await FormHistory.search(["guid"], { lastUsed: 179 });
+ await promiseUpdate(updateLastUsed(results, 179));
+
+ results = await FormHistory.search(["guid"], { lastUsed: 31 });
+ await promiseUpdate(updateLastUsed(results, 31));
+
+ results = await FormHistory.search(["guid"], { lastUsed: 29 });
+ await promiseUpdate(updateLastUsed(results, 29));
+
+ results = await FormHistory.search(["guid"], { lastUsed: 9999 });
+ await promiseUpdate(updateLastUsed(results, 11));
+
+ results = await FormHistory.search(["guid"], { lastUsed: 9 });
+ await promiseUpdate(updateLastUsed(results, 9));
+
+ Assert.ok((await promiseCountEntries("name-A", "value-A")) > 0);
+ Assert.ok((await promiseCountEntries("181DaysOld", "foo")) > 0);
+ Assert.ok((await promiseCountEntries("179DaysOld", "foo")) > 0);
+ Assert.equal(509, await promiseCountEntries(null, null));
+
+ // 2 entries are expected to expire.
+ await promiseExpiration();
+
+ Assert.equal(0, await promiseCountEntries("name-A", "value-A"));
+ Assert.equal(0, await promiseCountEntries("181DaysOld", "foo"));
+ Assert.ok((await promiseCountEntries("179DaysOld", "foo")) > 0);
+ Assert.equal(507, await promiseCountEntries(null, null));
+
+ // And again. No change expected.
+ await promiseExpiration();
+
+ Assert.equal(507, await promiseCountEntries(null, null));
+
+ // Set formfill pref to 30 days.
+ Services.prefs.setIntPref("browser.formfill.expire_days", 30);
+
+ Assert.ok((await promiseCountEntries("179DaysOld", "foo")) > 0);
+ Assert.ok((await promiseCountEntries("bar", "31days")) > 0);
+ Assert.ok((await promiseCountEntries("bar", "29days")) > 0);
+ Assert.equal(507, await promiseCountEntries(null, null));
+
+ await promiseExpiration();
+
+ Assert.equal(0, await promiseCountEntries("179DaysOld", "foo"));
+ Assert.equal(0, await promiseCountEntries("bar", "31days"));
+ Assert.ok((await promiseCountEntries("bar", "29days")) > 0);
+ Assert.equal(505, await promiseCountEntries(null, null));
+
+ // Set override pref to 10 days and expire. This expires a large batch of
+ // entries, and should trigger a VACCUM to reduce file size.
+ Services.prefs.setIntPref("browser.formfill.expire_days", 10);
+
+ Assert.ok((await promiseCountEntries("bar", "29days")) > 0);
+ Assert.ok((await promiseCountEntries("9DaysOld", "foo")) > 0);
+ Assert.equal(505, await promiseCountEntries(null, null));
+
+ await promiseExpiration();
+
+ Assert.equal(0, await promiseCountEntries("bar", "29days"));
+ Assert.ok((await promiseCountEntries("9DaysOld", "foo")) > 0);
+ Assert.ok((await promiseCountEntries("name-B", "value-B")) > 0);
+ Assert.ok((await promiseCountEntries("name-C", "value-C")) > 0);
+ Assert.equal(3, await promiseCountEntries(null, null));
+});
diff --git a/toolkit/components/satchel/test/unit/test_autocomplete.js b/toolkit/components/satchel/test/unit/test_autocomplete.js
new file mode 100644
index 0000000000..13f66eb0f2
--- /dev/null
+++ b/toolkit/components/satchel/test/unit/test_autocomplete.js
@@ -0,0 +1,388 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+var fac;
+
+var numRecords, timeGroupingSize, now;
+
+const DEFAULT_EXPIRE_DAYS = 180;
+
+function padLeft(number, length) {
+ let str = number + "";
+ while (str.length < length) {
+ str = "0" + str;
+ }
+ return str;
+}
+
+function getFormExpiryDays() {
+ if (Services.prefs.prefHasUserValue("browser.formfill.expire_days")) {
+ return Services.prefs.getIntPref("browser.formfill.expire_days");
+ }
+ return DEFAULT_EXPIRE_DAYS;
+}
+
+function run_test() {
+ // ===== test init =====
+ let testfile = do_get_file("formhistory_autocomplete.sqlite");
+ let profileDir = Services.dirsvc.get("ProfD", Ci.nsIFile);
+
+ // Cleanup from any previous tests or failures.
+ let destFile = profileDir.clone();
+ destFile.append("formhistory.sqlite");
+ if (destFile.exists()) {
+ destFile.remove(false);
+ }
+
+ testfile.copyTo(profileDir, "formhistory.sqlite");
+
+ fac = Cc["@mozilla.org/satchel/form-autocomplete;1"].getService(
+ Ci.nsIFormAutoComplete
+ );
+
+ timeGroupingSize =
+ Services.prefs.getIntPref("browser.formfill.timeGroupingSize") *
+ 1000 *
+ 1000;
+
+ run_next_test();
+}
+
+add_test(function test0() {
+ let maxTimeGroupings = Services.prefs.getIntPref(
+ "browser.formfill.maxTimeGroupings"
+ );
+ let bucketSize = Services.prefs.getIntPref("browser.formfill.bucketSize");
+
+ // ===== Tests with constant timesUsed and varying lastUsed date =====
+ // insert 2 records per bucket to check alphabetical sort within
+ now = 1000 * Date.now();
+ numRecords = Math.ceil(maxTimeGroupings / bucketSize) * 2;
+
+ let changes = [];
+ for (let i = 0; i < numRecords; i += 2) {
+ let useDate = now - (i / 2) * bucketSize * timeGroupingSize;
+
+ changes.push({
+ op: "add",
+ fieldname: "field1",
+ value: "value" + padLeft(numRecords - 1 - i, 2),
+ timesUsed: 1,
+ firstUsed: useDate,
+ lastUsed: useDate,
+ });
+ changes.push({
+ op: "add",
+ fieldname: "field1",
+ value: "value" + padLeft(numRecords - 2 - i, 2),
+ timesUsed: 1,
+ firstUsed: useDate,
+ lastUsed: useDate,
+ });
+ }
+
+ updateFormHistory(changes, run_next_test);
+});
+
+add_test(function test1() {
+ do_log_info("Check initial state is as expected");
+
+ countEntries(null, null, function () {
+ countEntries("field1", null, function (count) {
+ Assert.ok(count > 0);
+ run_next_test();
+ });
+ });
+});
+
+add_test(function test2() {
+ do_log_info("Check search contains all entries");
+
+ fac.autoCompleteSearchAsync("field1", "", null, null, false, {
+ onSearchCompletion(aResults) {
+ Assert.equal(numRecords, aResults.matchCount);
+ run_next_test();
+ },
+ });
+});
+
+add_test(function test3() {
+ do_log_info("Check search result ordering with empty search term");
+
+ let lastFound = numRecords;
+ fac.autoCompleteSearchAsync("field1", "", null, null, false, {
+ onSearchCompletion(aResults) {
+ for (let i = 0; i < numRecords; i += 2) {
+ Assert.equal(
+ parseInt(aResults.getValueAt(i + 1).substr(5), 10),
+ --lastFound
+ );
+ Assert.equal(
+ parseInt(aResults.getValueAt(i).substr(5), 10),
+ --lastFound
+ );
+ }
+ run_next_test();
+ },
+ });
+});
+
+add_test(function test4() {
+ do_log_info('Check search result ordering with "v"');
+
+ let lastFound = numRecords;
+ fac.autoCompleteSearchAsync("field1", "v", null, null, false, {
+ onSearchCompletion(aResults) {
+ for (let i = 0; i < numRecords; i += 2) {
+ Assert.equal(
+ parseInt(aResults.getValueAt(i + 1).substr(5), 10),
+ --lastFound
+ );
+ Assert.equal(
+ parseInt(aResults.getValueAt(i).substr(5), 10),
+ --lastFound
+ );
+ }
+ run_next_test();
+ },
+ });
+});
+
+const timesUsedSamples = 20;
+
+add_test(function test5() {
+ do_log_info("Begin tests with constant use dates and varying timesUsed");
+
+ let changes = [];
+ for (let i = 0; i < timesUsedSamples; i++) {
+ let timesUsed = timesUsedSamples - i;
+ let change = {
+ op: "add",
+ fieldname: "field2",
+ value: "value" + (timesUsedSamples - 1 - i),
+ timesUsed: timesUsed * timeGroupingSize,
+ firstUsed: now,
+ lastUsed: now,
+ };
+ changes.push(change);
+ }
+ updateFormHistory(changes, run_next_test);
+});
+
+add_test(function test6() {
+ do_log_info("Check search result ordering with empty search term");
+
+ let lastFound = timesUsedSamples;
+ fac.autoCompleteSearchAsync("field2", "", null, null, false, {
+ onSearchCompletion(aResults) {
+ for (let i = 0; i < timesUsedSamples; i++) {
+ Assert.equal(
+ parseInt(aResults.getValueAt(i).substr(5), 10),
+ --lastFound
+ );
+ }
+ run_next_test();
+ },
+ });
+});
+
+add_test(function test7() {
+ do_log_info('Check search result ordering with "v"');
+
+ let lastFound = timesUsedSamples;
+ fac.autoCompleteSearchAsync("field2", "v", null, null, false, {
+ onSearchCompletion(aResults) {
+ for (let i = 0; i < timesUsedSamples; i++) {
+ Assert.equal(
+ parseInt(aResults.getValueAt(i).substr(5), 10),
+ --lastFound
+ );
+ }
+ run_next_test();
+ },
+ });
+});
+
+add_test(function test8() {
+ do_log_info(
+ 'Check that "senior citizen" entries get a bonus (browser.formfill.agedBonus)'
+ );
+
+ let agedDate =
+ 1000 * (Date.now() - getFormExpiryDays() * 24 * 60 * 60 * 1000);
+
+ let changes = [];
+ changes.push({
+ op: "add",
+ fieldname: "field3",
+ value: "old but not senior",
+ timesUsed: 100,
+ firstUsed: agedDate + 60 * 1000 * 1000,
+ lastUsed: now,
+ });
+ changes.push({
+ op: "add",
+ fieldname: "field3",
+ value: "senior citizen",
+ timesUsed: 100,
+ firstUsed: agedDate - 60 * 1000 * 1000,
+ lastUsed: now,
+ });
+ updateFormHistory(changes, run_next_test);
+});
+
+add_test(function test9() {
+ fac.autoCompleteSearchAsync("field3", "", null, null, false, {
+ onSearchCompletion(aResults) {
+ Assert.equal(aResults.getValueAt(0), "senior citizen");
+ Assert.equal(aResults.getValueAt(1), "old but not senior");
+ run_next_test();
+ },
+ });
+});
+
+add_test(function test10() {
+ do_log_info("Check entries that are really old or in the future");
+
+ let changes = [];
+ changes.push({
+ op: "add",
+ fieldname: "field4",
+ value: "date of 0",
+ timesUsed: 1,
+ firstUsed: 0,
+ lastUsed: 0,
+ });
+ changes.push({
+ op: "add",
+ fieldname: "field4",
+ value: "in the future 1",
+ timesUsed: 1,
+ firstUsed: 0,
+ lastUsed: now * 2,
+ });
+ changes.push({
+ op: "add",
+ fieldname: "field4",
+ value: "in the future 2",
+ timesUsed: 1,
+ firstUsed: now * 2,
+ lastUsed: now * 2,
+ });
+ updateFormHistory(changes, run_next_test);
+});
+
+add_test(function test11() {
+ fac.autoCompleteSearchAsync("field4", "", null, null, false, {
+ onSearchCompletion(aResults) {
+ Assert.equal(aResults.matchCount, 3);
+ run_next_test();
+ },
+ });
+});
+
+var syncValues = ["sync1", "sync1a", "sync2", "sync3"];
+
+add_test(function test12() {
+ do_log_info("Check old synchronous api");
+
+ let changes = [];
+ for (let value of syncValues) {
+ changes.push({ op: "add", fieldname: "field5", value });
+ }
+ updateFormHistory(changes, run_next_test);
+});
+
+add_test(function test_token_limit_DB() {
+ function test_token_limit_previousResult(previousResult) {
+ do_log_info(
+ "Check that the number of tokens used in a search is not capped to " +
+ "MAX_SEARCH_TOKENS when using a previousResult"
+ );
+ // This provide more accuracy since performance is less of an issue.
+ // Search for a string where the first 10 tokens match the previous value but the 11th does not
+ // when re-using a previous result.
+ fac.autoCompleteSearchAsync(
+ "field_token_cap",
+ "a b c d e f g h i j .",
+ null,
+ previousResult,
+ false,
+ {
+ onSearchCompletion(aResults) {
+ Assert.equal(
+ aResults.matchCount,
+ 0,
+ "All search tokens should be used with previous results"
+ );
+ run_next_test();
+ },
+ }
+ );
+ }
+
+ do_log_info(
+ "Check that the number of tokens used in a search is capped to MAX_SEARCH_TOKENS " +
+ "for performance when querying the DB"
+ );
+ let changes = [];
+ changes.push({
+ op: "add",
+ fieldname: "field_token_cap",
+ // value with 15 unique tokens
+ value: "a b c d e f g h i j k l m n o",
+ timesUsed: 1,
+ firstUsed: 0,
+ lastUsed: 0,
+ });
+ updateFormHistory(changes, () => {
+ // Search for a string where the first 10 tokens match the value above but the 11th does not
+ // (which would prevent the result from being returned if the 11th term was used).
+ fac.autoCompleteSearchAsync(
+ "field_token_cap",
+ "a b c d e f g h i j .",
+ null,
+ null,
+ false,
+ {
+ onSearchCompletion(aResults) {
+ Assert.equal(
+ aResults.matchCount,
+ 1,
+ "Only the first MAX_SEARCH_TOKENS tokens " +
+ "should be used for DB queries"
+ );
+ test_token_limit_previousResult(aResults);
+ },
+ }
+ );
+ });
+});
+
+add_test(async function can_search_escape_marker() {
+ await promiseUpdate({
+ op: "add",
+ fieldname: "field1",
+ value: "/* Further reading */ test",
+ timesUsed: 1,
+ firstUsed: now,
+ lastUsed: now,
+ });
+
+ fac.autoCompleteSearchAsync(
+ "field1",
+ "/* Further reading */ t",
+ null,
+ null,
+ false,
+ {
+ onSearchCompletion(aResults) {
+ Assert.equal(1, aResults.matchCount);
+ run_next_test();
+ },
+ }
+ );
+});
diff --git a/toolkit/components/satchel/test/unit/test_db_access_denied.js b/toolkit/components/satchel/test/unit/test_db_access_denied.js
new file mode 100644
index 0000000000..99e038ea43
--- /dev/null
+++ b/toolkit/components/satchel/test/unit/test_db_access_denied.js
@@ -0,0 +1,52 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var bakFile;
+var dbFile;
+
+function run_test() {
+ let testfile = do_get_file("formhistory_apitest.sqlite");
+ let profileDir = Services.dirsvc.get("ProfD", Ci.nsIFile);
+
+ // Cleanup from any previous tests or failures.
+ let destFile = profileDir.clone();
+ destFile.append("formhistory.sqlite");
+ if (destFile.exists()) {
+ destFile.remove(false);
+ }
+
+ bakFile = profileDir.clone();
+ bakFile.append("formhistory.sqlite.corrupt");
+ if (bakFile.exists()) {
+ bakFile.remove(false);
+ }
+
+ dbFile = profileDir.clone();
+ dbFile.append("formhistory.sqlite");
+
+ testfile.copyTo(profileDir, "formhistory.sqlite");
+
+ run_next_test();
+}
+
+add_test(async function initialize_database_in_readonly_results_in_db_reset() {
+ // original permissions are 440, now set to not readable...
+ dbFile.permissions = 0;
+
+ // ...and reset them later for the next connection setup retry, which happens
+ // after 3 retries (10 + 20 + 40) ms
+ do_timeout(70, () => (dbFile.permissions = 440));
+
+ // this establishes a connection, the first one will fail but after a few
+ // retries we will have sufficiant permissions
+ const numEntriesAfter = await FormHistory.count({});
+
+ // original fixture data present
+ Assert.equal(9, numEntriesAfter);
+
+ // No backup has been created
+ Assert.ok(!bakFile.exists(), "backup file does not exist");
+
+ run_next_test();
+});
diff --git a/toolkit/components/satchel/test/unit/test_db_corrupt.js b/toolkit/components/satchel/test/unit/test_db_corrupt.js
new file mode 100644
index 0000000000..b53b5cd6d0
--- /dev/null
+++ b/toolkit/components/satchel/test/unit/test_db_corrupt.js
@@ -0,0 +1,80 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var bakFile;
+
+function run_test() {
+ // ===== test init =====
+ let testfile = do_get_file("formhistory_CORRUPT.sqlite");
+ let profileDir = Services.dirsvc.get("ProfD", Ci.nsIFile);
+
+ // Cleanup from any previous tests or failures.
+ let destFile = profileDir.clone();
+ destFile.append("formhistory.sqlite");
+ if (destFile.exists()) {
+ destFile.remove(false);
+ }
+
+ bakFile = profileDir.clone();
+ bakFile.append("formhistory.sqlite.corrupt");
+ if (bakFile.exists()) {
+ bakFile.remove(false);
+ }
+
+ testfile.copyTo(profileDir, "formhistory.sqlite");
+ run_next_test();
+}
+
+add_test(function test_corruptFormHistoryDB_lazyCorruptInit1() {
+ do_log_info("ensure FormHistory backs up a corrupt DB on initialization.");
+
+ // DB init is done lazily so the DB shouldn't be created yet.
+ Assert.ok(!bakFile.exists());
+ // Doing any request to the DB should create it.
+ countEntries(null, null, run_next_test);
+});
+
+add_test(function test_corruptFormHistoryDB_lazyCorruptInit2() {
+ Assert.ok(bakFile.exists());
+ bakFile.remove(false);
+ run_next_test();
+});
+
+add_test(function test_corruptFormHistoryDB_emptyInit() {
+ do_log_info(
+ "test that FormHistory initializes an empty DB in place of corrupt DB."
+ );
+
+ (async function () {
+ let count = await FormHistory.count({});
+ Assert.equal(count, 0);
+ count = await FormHistory.count({ fieldname: "name-A", value: "value-A" });
+ Assert.equal(count, 0);
+ run_next_test();
+ })().catch(error => {
+ do_throw("DB initialized after reading a corrupt DB file is not empty.");
+ });
+});
+
+add_test(function test_corruptFormHistoryDB_addEntry() {
+ do_log_info("test adding an entry to the empty DB.");
+
+ updateEntry("add", "name-A", "value-A", function () {
+ countEntries("name-A", "value-A", function (count) {
+ Assert.ok(count == 1);
+ run_next_test();
+ });
+ });
+});
+
+add_test(function test_corruptFormHistoryDB_removeEntry() {
+ do_log_info("test removing an entry to the empty DB.");
+
+ updateEntry("remove", "name-A", "value-A", function () {
+ countEntries("name-A", "value-A", function (count) {
+ Assert.ok(count == 0);
+ run_next_test();
+ });
+ });
+});
diff --git a/toolkit/components/satchel/test/unit/test_db_update_v4.js b/toolkit/components/satchel/test/unit/test_db_update_v4.js
new file mode 100644
index 0000000000..8c39dd7788
--- /dev/null
+++ b/toolkit/components/satchel/test/unit/test_db_update_v4.js
@@ -0,0 +1,59 @@
+/* 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 { Sqlite } = ChromeUtils.importESModule(
+ "resource://gre/modules/Sqlite.sys.mjs"
+);
+
+add_task(async function () {
+ let testnum = 0;
+
+ try {
+ // ===== test init =====
+ let testfile = do_get_file("formhistory_v3.sqlite");
+ let profileDir = Services.dirsvc.get("ProfD", Ci.nsIFile);
+
+ // Cleanup from any previous tests or failures.
+ let destFile = profileDir.clone();
+ destFile.append("formhistory.sqlite");
+ if (destFile.exists()) {
+ destFile.remove(false);
+ }
+
+ testfile.copyTo(profileDir, "formhistory.sqlite");
+ Assert.equal(3, await getDBVersion(testfile));
+
+ Assert.ok(destFile.exists());
+
+ // ===== 1 =====
+ testnum++;
+
+ destFile = profileDir.clone();
+ destFile.append("formhistory.sqlite");
+ let dbConnection = await Sqlite.openConnection({
+ path: destFile.path,
+ sharedMemoryCache: false,
+ });
+
+ // Do something that will cause FormHistory to access and upgrade the
+ // database
+ await FormHistory.count({});
+
+ // check for upgraded schema.
+ Assert.equal(CURRENT_SCHEMA, await getDBVersion(destFile));
+
+ // Check that the index was added
+ Assert.ok(dbConnection.tableExists("moz_deleted_formhistory"));
+ dbConnection.close();
+
+ // check for upgraded schema.
+ Assert.equal(CURRENT_SCHEMA, await getDBVersion(destFile));
+
+ // check that an entry still exists
+ let num = await promiseCountEntries("name-A", "value-A");
+ Assert.ok(num > 0);
+ } catch (e) {
+ throw new Error(`FAILED in test #${testnum} -- ${e}`);
+ }
+});
diff --git a/toolkit/components/satchel/test/unit/test_db_update_v4b.js b/toolkit/components/satchel/test/unit/test_db_update_v4b.js
new file mode 100644
index 0000000000..d3319d2956
--- /dev/null
+++ b/toolkit/components/satchel/test/unit/test_db_update_v4b.js
@@ -0,0 +1,49 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+add_task(async function () {
+ let testnum = 0;
+
+ try {
+ // ===== test init =====
+ let testfile = do_get_file("formhistory_v3v4.sqlite");
+ let profileDir = Services.dirsvc.get("ProfD", Ci.nsIFile);
+
+ // Cleanup from any previous tests or failures.
+ let destFile = profileDir.clone();
+ destFile.append("formhistory.sqlite");
+ if (destFile.exists()) {
+ destFile.remove(false);
+ }
+
+ testfile.copyTo(profileDir, "formhistory.sqlite");
+ Assert.equal(3, await getDBVersion(testfile));
+
+ // ===== 1 =====
+ testnum++;
+
+ destFile = profileDir.clone();
+ destFile.append("formhistory.sqlite");
+ let dbConnection = await Sqlite.openConnection({
+ path: destFile.path,
+ sharedMemoryCache: false,
+ });
+
+ // Do something that will cause FormHistory to access and upgrade the
+ // database
+ await FormHistory.count({});
+
+ // check for upgraded schema.
+ Assert.equal(CURRENT_SCHEMA, await getDBVersion(destFile));
+
+ // Check that the index was added
+ Assert.ok(dbConnection.tableExists("moz_deleted_formhistory"));
+ dbConnection.close();
+
+ // check that an entry still exists
+ Assert.ok((await promiseCountEntries("name-A", "value-A")) > 0);
+ } catch (e) {
+ throw new Error(`FAILED in test #${testnum} -- ${e}`);
+ }
+});
diff --git a/toolkit/components/satchel/test/unit/test_db_update_v5.js b/toolkit/components/satchel/test/unit/test_db_update_v5.js
new file mode 100644
index 0000000000..f546d4a0f5
--- /dev/null
+++ b/toolkit/components/satchel/test/unit/test_db_update_v5.js
@@ -0,0 +1,29 @@
+/* Any copyright is dedicated to the Public Domain.
+ * https://creativecommons.org/publicdomain/zero/1.0/ */
+
+add_task(async function () {
+ let destPath = await copyToProfile(
+ "formhistory_v3.sqlite",
+ "formhistory.sqlite"
+ );
+ Assert.equal(3, await getDBSchemaVersion(destPath));
+
+ // Do something that will cause FormHistory to access and upgrade the
+ // database
+ await FormHistory.count({});
+
+ // check for upgraded schema.
+ Assert.equal(CURRENT_SCHEMA, await getDBSchemaVersion(destPath));
+
+ // Check that the source tables were added.
+ let db = await Sqlite.openConnection({ path: destPath });
+ try {
+ Assert.ok(db.tableExists("moz_sources"));
+ Assert.ok(db.tableExists("moz_sources_to_history"));
+ } finally {
+ await db.close();
+ }
+ // check that an entry still exists
+ let num = await promiseCountEntries("name-A", "value-A");
+ Assert.ok(num > 0);
+});
diff --git a/toolkit/components/satchel/test/unit/test_db_update_v999a.js b/toolkit/components/satchel/test/unit/test_db_update_v999a.js
new file mode 100644
index 0000000000..f48c43c5cc
--- /dev/null
+++ b/toolkit/components/satchel/test/unit/test_db_update_v999a.js
@@ -0,0 +1,56 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/*
+ * This test uses a formhistory.sqlite with schema version set to 999 (a
+ * future version). This exercies the code that allows using a future schema
+ * version as long as the expected columns are present.
+ *
+ * Part A tests this when the columns do match, so the DB is used.
+ * Part B tests this when the columns do *not* match, so the DB is reset.
+ */
+
+add_task(async function () {
+ let testnum = 0;
+
+ try {
+ // ===== test init =====
+ let testfile = do_get_file("formhistory_v999a.sqlite");
+ let profileDir = Services.dirsvc.get("ProfD", Ci.nsIFile);
+
+ // Cleanup from any previous tests or failures.
+ let destFile = profileDir.clone();
+ destFile.append("formhistory.sqlite");
+ if (destFile.exists()) {
+ destFile.remove(false);
+ }
+
+ testfile.copyTo(profileDir, "formhistory.sqlite");
+ Assert.equal(999, await getDBVersion(testfile));
+
+ // ===== 1 =====
+ testnum++;
+ // Check for expected contents.
+ Assert.ok((await promiseCountEntries(null, null)) > 0);
+ Assert.equal(1, await promiseCountEntries("name-A", "value-A"));
+ Assert.equal(1, await promiseCountEntries("name-B", "value-B"));
+ Assert.equal(1, await promiseCountEntries("name-C", "value-C1"));
+ Assert.equal(1, await promiseCountEntries("name-C", "value-C2"));
+ Assert.equal(1, await promiseCountEntries("name-E", "value-E"));
+
+ // check for downgraded schema.
+ Assert.equal(CURRENT_SCHEMA, await getDBVersion(destFile));
+
+ // ===== 2 =====
+ testnum++;
+ // Exercise adding and removing a name/value pair
+ Assert.equal(0, await promiseCountEntries("name-D", "value-D"));
+ await promiseUpdateEntry("add", "name-D", "value-D");
+ Assert.equal(1, await promiseCountEntries("name-D", "value-D"));
+ await promiseUpdateEntry("remove", "name-D", "value-D");
+ Assert.equal(0, await promiseCountEntries("name-D", "value-D"));
+ } catch (e) {
+ throw new Error(`FAILED in test #${testnum} -- ${e}`);
+ }
+});
diff --git a/toolkit/components/satchel/test/unit/test_db_update_v999b.js b/toolkit/components/satchel/test/unit/test_db_update_v999b.js
new file mode 100644
index 0000000000..3424411343
--- /dev/null
+++ b/toolkit/components/satchel/test/unit/test_db_update_v999b.js
@@ -0,0 +1,74 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/*
+ * This test uses a formhistory.sqlite with schema version set to 999 (a
+ * future version). This exercies the code that allows using a future schema
+ * version as long as the expected columns are present.
+ *
+ * Part A tests this when the columns do match, so the DB is used.
+ * Part B tests this when the columns do *not* match, so the DB is reset.
+ */
+
+add_task(async function () {
+ let testnum = 0;
+
+ try {
+ // ===== test init =====
+ let testfile = do_get_file("formhistory_v999b.sqlite");
+ let profileDir = Services.dirsvc.get("ProfD", Ci.nsIFile);
+
+ // Cleanup from any previous tests or failures.
+ let destFile = profileDir.clone();
+ destFile.append("formhistory.sqlite");
+ if (destFile.exists()) {
+ destFile.remove(false);
+ }
+
+ let bakFile = profileDir.clone();
+ bakFile.append("formhistory.sqlite.corrupt");
+ if (bakFile.exists()) {
+ bakFile.remove(false);
+ }
+
+ testfile.copyTo(profileDir, "formhistory.sqlite");
+ Assert.equal(999, await getDBVersion(destFile));
+
+ // ===== 1 =====
+ testnum++;
+
+ // Open the DB, ensure that a backup of the corrupt DB is made.
+ // DB init is done lazily so the DB shouldn't be created yet.
+ Assert.ok(!bakFile.exists());
+ // Doing any request to the DB should create it.
+ await promiseCountEntries("", "");
+
+ Assert.ok(bakFile.exists());
+ bakFile.remove(false);
+
+ // ===== 2 =====
+ testnum++;
+ // File should be empty
+ Assert.ok(!(await promiseCountEntries(null, null)));
+ Assert.equal(0, await promiseCountEntries("name-A", "value-A"));
+ // check for current schema.
+ Assert.equal(CURRENT_SCHEMA, await getDBVersion(destFile));
+
+ // ===== 3 =====
+ testnum++;
+ // Try adding an entry
+ await promiseUpdateEntry("add", "name-A", "value-A");
+ Assert.equal(1, await promiseCountEntries(null, null));
+ Assert.equal(1, await promiseCountEntries("name-A", "value-A"));
+
+ // ===== 4 =====
+ testnum++;
+ // Try removing an entry
+ await promiseUpdateEntry("remove", "name-A", "value-A");
+ Assert.equal(0, await promiseCountEntries(null, null));
+ Assert.equal(0, await promiseCountEntries("name-A", "value-A"));
+ } catch (e) {
+ throw new Error(`FAILED in test #${testnum} -- ${e}`);
+ }
+});
diff --git a/toolkit/components/satchel/test/unit/test_history_api.js b/toolkit/components/satchel/test/unit/test_history_api.js
new file mode 100644
index 0000000000..91da24696c
--- /dev/null
+++ b/toolkit/components/satchel/test/unit/test_history_api.js
@@ -0,0 +1,485 @@
+/* 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
+
+async function countDeletedEntries(expected) {
+ let stmt = "SELECT COUNT(*) AS numEntries FROM moz_deleted_formhistory";
+ try {
+ let requiredRow = await dbConnection.executeCached(stmt);
+ Assert.equal(expected, requiredRow[0].getResultByName("numEntries"));
+ } catch (error) {
+ do_throw("Error occurred counting deleted entries: " + error);
+ }
+}
+
+async function checkTimeDeleted(guid, checkFunction) {
+ let stmt =
+ "SELECT timeDeleted FROM moz_deleted_formhistory WHERE guid = :guid";
+ let params = { guid };
+
+ try {
+ let requiredRow = await dbConnection.executeCached(stmt, params);
+ checkFunction(requiredRow[0].getResultByName("timeDeleted"));
+ } catch (error) {
+ do_throw("Error occurred getting deleted entries: " + error);
+ }
+}
+
+function promiseUpdateEntry(op, name, value) {
+ let change = { op };
+ if (name !== null) {
+ change.fieldname = name;
+ }
+ if (value !== null) {
+ change.value = value;
+ }
+ return promiseUpdate(change);
+}
+
+add_task(async function () {
+ let oldSupportsDeletedTable = FormHistory._supportsDeletedTable;
+ FormHistory._supportsDeletedTable = true;
+
+ try {
+ // ===== test init =====
+ let testfile = do_get_file("formhistory_apitest.sqlite");
+ let profileDir = Services.dirsvc.get("ProfD", Ci.nsIFile);
+
+ // Cleanup from any previous tests or failures.
+ let destFile = profileDir.clone();
+ destFile.append("formhistory.sqlite");
+ if (destFile.exists()) {
+ destFile.remove(false);
+ }
+
+ testfile.copyTo(profileDir, "formhistory.sqlite");
+
+ function checkExists(num) {
+ Assert.ok(num > 0);
+ }
+ function checkNotExists(num) {
+ Assert.ok(num == 0);
+ }
+
+ // ===== 1 =====
+ // Check initial state is as expected
+ testnum++;
+ await promiseCountEntries("name-A", null, checkExists);
+ await promiseCountEntries("name-B", null, checkExists);
+ await promiseCountEntries("name-C", null, checkExists);
+ await promiseCountEntries("name-D", null, checkExists);
+ await promiseCountEntries("name-A", "value-A", checkExists);
+ await promiseCountEntries("name-B", "value-B1", checkExists);
+ await promiseCountEntries("name-B", "value-B2", checkExists);
+ await promiseCountEntries("name-C", "value-C", checkExists);
+ await promiseCountEntries("name-D", "value-D", checkExists);
+ // time-A/B/C/D checked below.
+
+ // Delete anything from the deleted table
+ let dbFile = Services.dirsvc.get("ProfD", Ci.nsIFile).clone();
+ dbFile.append("formhistory.sqlite");
+
+ dbConnection = await Sqlite.openConnection({
+ path: dbFile.path,
+ sharedMemoryCache: false,
+ });
+
+ let stmt = "DELETE FROM moz_deleted_formhistory";
+ try {
+ await dbConnection.executeCached(stmt);
+ } catch (error) {
+ do_throw("Error occurred counting deleted all entries: " + error);
+ }
+
+ // ===== 2 =====
+ // Test looking for nonexistent / bogus data.
+ testnum++;
+ await promiseCountEntries("blah", null, checkNotExists);
+ await promiseCountEntries("", null, checkNotExists);
+ await promiseCountEntries("name-A", "blah", checkNotExists);
+ await promiseCountEntries("name-A", "", checkNotExists);
+ await promiseCountEntries("name-A", null, checkExists);
+ await promiseCountEntries("blah", "value-A", checkNotExists);
+ await promiseCountEntries("", "value-A", checkNotExists);
+ await promiseCountEntries(null, "value-A", checkExists);
+
+ // Cannot use promiseCountEntries when name and value are null
+ // because it treats null values as not set
+ // and here a search should be done explicity for null.
+ let count = await FormHistory.count({ fieldname: null, value: null });
+ checkNotExists(count);
+
+ // ===== 3 =====
+ // Test removeEntriesForName with a single matching value
+ testnum++;
+ await promiseUpdateEntry("remove", "name-A", null);
+
+ await promiseCountEntries("name-A", "value-A", checkNotExists);
+ await promiseCountEntries("name-B", "value-B1", checkExists);
+ await promiseCountEntries("name-B", "value-B2", checkExists);
+ await promiseCountEntries("name-C", "value-C", checkExists);
+ await promiseCountEntries("name-D", "value-D", checkExists);
+ await countDeletedEntries(1);
+
+ // ===== 4 =====
+ // Test removeEntriesForName with multiple matching values
+ testnum++;
+ await promiseUpdateEntry("remove", "name-B", null);
+
+ await promiseCountEntries("name-A", "value-A", checkNotExists);
+ await promiseCountEntries("name-B", "value-B1", checkNotExists);
+ await promiseCountEntries("name-B", "value-B2", checkNotExists);
+ await promiseCountEntries("name-C", "value-C", checkExists);
+ await promiseCountEntries("name-D", "value-D", checkExists);
+ await countDeletedEntries(3);
+
+ // ===== 5 =====
+ // Test removing by time range (single entry, not surrounding entries)
+ testnum++;
+ await promiseCountEntries("time-A", null, checkExists); // firstUsed=1000, lastUsed=1000
+ await promiseCountEntries("time-B", null, checkExists); // firstUsed=1000, lastUsed=1099
+ await promiseCountEntries("time-C", null, checkExists); // firstUsed=1099, lastUsed=1099
+ await promiseCountEntries("time-D", null, checkExists); // firstUsed=2001, lastUsed=2001
+ await promiseUpdate({
+ op: "remove",
+ firstUsedStart: 1050,
+ firstUsedEnd: 2000,
+ });
+
+ await promiseCountEntries("time-A", null, checkExists);
+ await promiseCountEntries("time-B", null, checkExists);
+ await promiseCountEntries("time-C", null, checkNotExists);
+ await promiseCountEntries("time-D", null, checkExists);
+ await countDeletedEntries(4);
+
+ // ===== 6 =====
+ // Test removing by time range (multiple entries)
+ testnum++;
+ await promiseUpdate({
+ op: "remove",
+ firstUsedStart: 1000,
+ firstUsedEnd: 2000,
+ });
+
+ await promiseCountEntries("time-A", null, checkNotExists);
+ await promiseCountEntries("time-B", null, checkNotExists);
+ await promiseCountEntries("time-C", null, checkNotExists);
+ await promiseCountEntries("time-D", null, checkExists);
+ await countDeletedEntries(6);
+
+ // ===== 7 =====
+ // test removeAllEntries
+ testnum++;
+ await promiseUpdateEntry("remove", null, null);
+
+ await promiseCountEntries("name-C", null, checkNotExists);
+ await promiseCountEntries("name-D", null, checkNotExists);
+ await promiseCountEntries("name-C", "value-C", checkNotExists);
+ await promiseCountEntries("name-D", "value-D", checkNotExists);
+
+ await promiseCountEntries(null, null, checkNotExists);
+ await countDeletedEntries(6);
+
+ // ===== 8 =====
+ // Add a single entry back
+ testnum++;
+ await promiseUpdateEntry("add", "newname-A", "newvalue-A");
+ await promiseCountEntries("newname-A", "newvalue-A", checkExists);
+
+ // ===== 9 =====
+ // Remove the single entry
+ testnum++;
+ await promiseUpdateEntry("remove", "newname-A", "newvalue-A");
+ await promiseCountEntries("newname-A", "newvalue-A", checkNotExists);
+
+ // ===== 10 =====
+ // Add a single entry
+ testnum++;
+ await promiseUpdateEntry("add", "field1", "value1");
+ await promiseCountEntries("field1", "value1", checkExists);
+
+ let processFirstResult = function processResults(results) {
+ // Only handle the first result
+ if (results.length) {
+ let result = results[0];
+ return [
+ result.timesUsed,
+ result.firstUsed,
+ result.lastUsed,
+ result.guid,
+ ];
+ }
+ return undefined;
+ };
+
+ let results = await FormHistory.search(
+ ["timesUsed", "firstUsed", "lastUsed"],
+ { fieldname: "field1", value: "value1" }
+ );
+ let [timesUsed, firstUsed, lastUsed] = processFirstResult(results);
+ Assert.equal(1, timesUsed);
+ Assert.ok(firstUsed > 0);
+ Assert.ok(lastUsed > 0);
+ await promiseCountEntries(null, null, num => Assert.equal(num, 1));
+
+ // ===== 11 =====
+ // Add another single entry
+ testnum++;
+ await promiseUpdateEntry("add", "field1", "value1b");
+ await promiseCountEntries("field1", "value1", checkExists);
+ await promiseCountEntries("field1", "value1b", checkExists);
+ await promiseCountEntries(null, null, num => Assert.equal(num, 2));
+
+ // ===== 12 =====
+ // Update a single entry
+ testnum++;
+
+ results = await FormHistory.search(["guid"], {
+ fieldname: "field1",
+ value: "value1",
+ });
+ let guid = processFirstResult(results)[3];
+
+ await promiseUpdate({ op: "update", guid, value: "modifiedValue" });
+ await promiseCountEntries("field1", "modifiedValue", checkExists);
+ await promiseCountEntries("field1", "value1", checkNotExists);
+ await promiseCountEntries("field1", "value1b", checkExists);
+ await promiseCountEntries(null, null, num => Assert.equal(num, 2));
+
+ // ===== 13 =====
+ // Add a single entry with times
+ testnum++;
+ await promiseUpdate({
+ op: "add",
+ fieldname: "field2",
+ value: "value2",
+ timesUsed: 20,
+ firstUsed: 100,
+ lastUsed: 500,
+ });
+
+ results = await FormHistory.search(["timesUsed", "firstUsed", "lastUsed"], {
+ fieldname: "field2",
+ value: "value2",
+ });
+ [timesUsed, firstUsed, lastUsed] = processFirstResult(results);
+
+ Assert.equal(20, timesUsed);
+ Assert.equal(100, firstUsed);
+ Assert.equal(500, lastUsed);
+ await promiseCountEntries(null, null, num => Assert.equal(num, 3));
+
+ // ===== 14 =====
+ // Bump an entry, which updates its lastUsed field
+ testnum++;
+ await promiseUpdate({
+ op: "bump",
+ fieldname: "field2",
+ value: "value2",
+ timesUsed: 20,
+ firstUsed: 100,
+ lastUsed: 500,
+ });
+ results = await FormHistory.search(["timesUsed", "firstUsed", "lastUsed"], {
+ fieldname: "field2",
+ value: "value2",
+ });
+ [timesUsed, firstUsed, lastUsed] = processFirstResult(results);
+ Assert.equal(21, timesUsed);
+ Assert.equal(100, firstUsed);
+ Assert.ok(lastUsed > 500);
+ await promiseCountEntries(null, null, num => Assert.equal(num, 3));
+
+ // ===== 15 =====
+ // Bump an entry that does not exist
+ testnum++;
+ await promiseUpdate({
+ op: "bump",
+ fieldname: "field3",
+ value: "value3",
+ timesUsed: 10,
+ firstUsed: 50,
+ lastUsed: 400,
+ });
+ results = await FormHistory.search(["timesUsed", "firstUsed", "lastUsed"], {
+ fieldname: "field3",
+ value: "value3",
+ });
+ [timesUsed, firstUsed, lastUsed] = processFirstResult(results);
+ Assert.equal(10, timesUsed);
+ Assert.equal(50, firstUsed);
+ Assert.equal(400, lastUsed);
+ await promiseCountEntries(null, null, num => Assert.equal(num, 4));
+
+ // ===== 16 =====
+ // Bump an entry with a guid
+ testnum++;
+ results = await FormHistory.search(["guid"], {
+ fieldname: "field3",
+ value: "value3",
+ });
+ guid = processFirstResult(results)[3];
+ await promiseUpdate({
+ op: "bump",
+ guid,
+ timesUsed: 20,
+ firstUsed: 55,
+ lastUsed: 400,
+ });
+ results = await FormHistory.search(["timesUsed", "firstUsed", "lastUsed"], {
+ fieldname: "field3",
+ value: "value3",
+ });
+ [timesUsed, firstUsed, lastUsed] = processFirstResult(results);
+ Assert.equal(11, timesUsed);
+ Assert.equal(50, firstUsed);
+ Assert.ok(lastUsed > 400);
+ await promiseCountEntries(null, null, num => Assert.equal(num, 4));
+
+ // ===== 17 =====
+ // Remove an entry
+ testnum++;
+ await countDeletedEntries(7);
+
+ results = await FormHistory.search(["guid"], {
+ fieldname: "field1",
+ value: "value1b",
+ });
+ guid = processFirstResult(results)[3];
+
+ await promiseUpdate({ op: "remove", guid });
+ await promiseCountEntries("field1", "modifiedValue", checkExists);
+ await promiseCountEntries("field1", "value1b", checkNotExists);
+ await promiseCountEntries(null, null, num => Assert.equal(num, 3));
+
+ await countDeletedEntries(8);
+ await checkTimeDeleted(guid, timeDeleted => Assert.ok(timeDeleted > 10000));
+
+ // ===== 18 =====
+ // Add yet another single entry
+ testnum++;
+ await promiseUpdate({
+ op: "add",
+ fieldname: "field4",
+ value: "value4",
+ timesUsed: 5,
+ firstUsed: 230,
+ lastUsed: 600,
+ });
+ await promiseCountEntries(null, null, num => Assert.equal(num, 4));
+
+ // ===== 19 =====
+ // Remove an entry by time
+ testnum++;
+ await promiseUpdate({
+ op: "remove",
+ firstUsedStart: 60,
+ firstUsedEnd: 250,
+ });
+ await promiseCountEntries("field1", "modifiedValue", checkExists);
+ await promiseCountEntries("field2", "value2", checkNotExists);
+ await promiseCountEntries("field3", "value3", checkExists);
+ await promiseCountEntries("field4", "value4", checkNotExists);
+ await promiseCountEntries(null, null, num => Assert.equal(num, 2));
+ await countDeletedEntries(10);
+
+ // ===== 20 =====
+ // Bump multiple existing entries at once
+ testnum++;
+
+ await promiseUpdate([
+ {
+ op: "add",
+ fieldname: "field5",
+ value: "value5",
+ timesUsed: 5,
+ firstUsed: 230,
+ lastUsed: 600,
+ },
+ {
+ op: "add",
+ fieldname: "field6",
+ value: "value6",
+ timesUsed: 12,
+ firstUsed: 430,
+ lastUsed: 700,
+ },
+ ]);
+ await promiseCountEntries(null, null, num => Assert.equal(num, 4));
+
+ await promiseUpdate([
+ { op: "bump", fieldname: "field5", value: "value5" },
+ { op: "bump", fieldname: "field6", value: "value6" },
+ ]);
+ results = await FormHistory.search(
+ ["fieldname", "timesUsed", "firstUsed", "lastUsed"],
+ {}
+ );
+
+ Assert.equal(6, results[2].timesUsed);
+ Assert.equal(13, results[3].timesUsed);
+ Assert.equal(230, results[2].firstUsed);
+ Assert.equal(430, results[3].firstUsed);
+ Assert.ok(results[2].lastUsed > 600);
+ Assert.ok(results[3].lastUsed > 700);
+
+ await promiseCountEntries(null, null, num => Assert.equal(num, 4));
+
+ // ===== 21 =====
+ // Check update fails if form history is disabled and the operation is not a
+ // pure removal.
+ testnum++;
+ Services.prefs.setBoolPref("browser.formfill.enable", false);
+
+ // Cannot use arrow functions, see bug 1237961.
+ await Assert.rejects(
+ promiseUpdate({ op: "bump", fieldname: "field5", value: "value5" }),
+ /Form history is disabled, only remove operations are allowed/,
+ "bumping when form history is disabled should fail"
+ );
+ await Assert.rejects(
+ promiseUpdate({ op: "add", fieldname: "field5", value: "value5" }),
+ /Form history is disabled, only remove operations are allowed/,
+ "Adding when form history is disabled should fail"
+ );
+ await Assert.rejects(
+ promiseUpdate([
+ { op: "update", fieldname: "field5", value: "value5" },
+ { op: "remove", fieldname: "field5", value: "value5" },
+ ]),
+ /Form history is disabled, only remove operations are allowed/,
+ "mixed operations when form history is disabled should fail"
+ );
+ await Assert.rejects(
+ promiseUpdate([
+ null,
+ undefined,
+ "",
+ 1,
+ {},
+ { op: "remove", fieldname: "field5", value: "value5" },
+ ]),
+ /Form history is disabled, only remove operations are allowed/,
+ "Invalid entries when form history is disabled should fail"
+ );
+
+ // Remove should work though.
+ await promiseUpdate([
+ { op: "remove", fieldname: "field5", value: null },
+ { op: "remove", fieldname: null, value: null },
+ ]);
+ Services.prefs.clearUserPref("browser.formfill.enable");
+ } catch (e) {
+ throw new Error(`FAILED in test #${testnum} -- ${e}`);
+ } finally {
+ FormHistory._supportsDeletedTable = oldSupportsDeletedTable;
+ await dbConnection.close(do_test_finished);
+ }
+});
+
+function run_test() {
+ return run_next_test();
+}
diff --git a/toolkit/components/satchel/test/unit/test_history_sources.js b/toolkit/components/satchel/test/unit/test_history_sources.js
new file mode 100644
index 0000000000..a598a457be
--- /dev/null
+++ b/toolkit/components/satchel/test/unit/test_history_sources.js
@@ -0,0 +1,88 @@
+/* Any copyright is dedicated to the Public Domain.
+ * https://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Tests source usage in the form history API.
+
+add_task(async function () {
+ // Shorthands to improve test readability.
+ const count = FormHistoryTestUtils.count.bind(FormHistoryTestUtils);
+ async function search(fieldname, filters) {
+ let results1 = (await FormHistoryTestUtils.search(fieldname, filters)).map(
+ f => f.value
+ );
+ // Check autocomplete returns the same value.
+ let results2 = (
+ await FormHistoryTestUtils.autocomplete("va", fieldname, filters)
+ ).map(f => f.text);
+ Assert.deepEqual(results1, results2);
+ return results1;
+ }
+
+ info("Sanity checks");
+ Assert.equal(await count("field-A"), 0);
+ Assert.equal(await count("field-B"), 0);
+ await FormHistoryTestUtils.add("field-A", [{ value: "value-A" }]);
+ Assert.equal(await FormHistoryTestUtils.count("field-A"), 1);
+ Assert.deepEqual(await search("field-A"), ["value-A"]);
+ await FormHistoryTestUtils.remove("field-A", [{ value: "value-A" }]);
+ Assert.equal(await count("field-A"), 0);
+
+ info("Test source for field-A");
+ await FormHistoryTestUtils.add("field-A", [
+ { value: "value-A", source: "test" },
+ ]);
+ Assert.equal(await count("field-A"), 1);
+ Assert.deepEqual(await search("field-A"), ["value-A"]);
+ Assert.equal(await count("field-A", { source: "test" }), 1);
+ Assert.deepEqual(await search("field-A", { source: "test" }), ["value-A"]);
+ Assert.equal(await count("field-A", { source: "test2" }), 0);
+ Assert.deepEqual(await search("field-A", { source: "test2" }), []);
+
+ info("Test source for field-B");
+ await FormHistoryTestUtils.add("field-B", [
+ { value: "value-B", source: "test" },
+ ]);
+ Assert.equal(await count("field-B", { source: "test" }), 1);
+ Assert.equal(await count("field-B", { source: "test2" }), 0);
+
+ info("Remove source");
+ await FormHistoryTestUtils.add("field-B", [
+ { value: "value-B", source: "test2" },
+ ]);
+ Assert.equal(await count("field-B", { source: "test2" }), 1);
+ Assert.deepEqual(await search("field-B", { source: "test2" }), ["value-B"]);
+ await FormHistoryTestUtils.remove("field-B", [{ source: "test2" }]);
+ Assert.equal(await count("field-B", { source: "test2" }), 0);
+ Assert.deepEqual(await search("field-B", { source: "test2" }), []);
+ Assert.equal(await count("field-A"), 1);
+ Assert.deepEqual(await search("field-A"), ["value-A"]);
+ Assert.deepEqual(await search("field-A", { source: "test" }), ["value-A"]);
+ info("The other source should be untouched");
+ Assert.equal(await count("field-B", { source: "test" }), 1);
+ Assert.deepEqual(await search("field-B", { source: "test" }), ["value-B"]);
+ Assert.equal(await count("field-B"), 1);
+ Assert.deepEqual(await search("field-B"), ["value-B"]);
+
+ info("Clear field-A");
+ await FormHistoryTestUtils.clear("field-A");
+ Assert.equal(await count("field-A", { source: "test" }), 0);
+ Assert.equal(await count("field-A", { source: "test2" }), 0);
+ Assert.equal(await count("field-A"), 0);
+ Assert.equal(await count("field-B", { source: "test" }), 1);
+ Assert.equal(await count("field-B", { source: "test2" }), 0);
+ Assert.equal(await count("field-B"), 1);
+
+ info("Clear All");
+ await FormHistoryTestUtils.clear();
+ Assert.equal(await count("field-B", { source: "test" }), 0);
+ Assert.equal(await count("field-B"), 0);
+ Assert.deepEqual(await search("field-A"), []);
+ Assert.deepEqual(await search("field-A", { source: "test" }), []);
+ Assert.deepEqual(await search("field-B"), []);
+ Assert.deepEqual(await search("field-B", { source: "test" }), []);
+
+ info("Check there's no orphan sources");
+ let db = await FormHistory.db;
+ let rows = await db.execute(`SELECT count(*) FROM moz_sources`);
+ Assert.equal(rows[0].getResultByIndex(0), 0, "There should be no orphans");
+});
diff --git a/toolkit/components/satchel/test/unit/test_notify.js b/toolkit/components/satchel/test/unit/test_notify.js
new file mode 100644
index 0000000000..94be56f3f5
--- /dev/null
+++ b/toolkit/components/satchel/test/unit/test_notify.js
@@ -0,0 +1,171 @@
+/*
+ * Test suite for satchel notifications
+ *
+ * Tests notifications dispatched when modifying form history.
+ *
+ */
+
+ChromeUtils.defineESModuleGetters(this, {
+ Preferences: "resource://gre/modules/Preferences.sys.mjs",
+ setTimeout: "resource://gre/modules/Timer.sys.mjs",
+});
+
+const TestObserver = {
+ observed: [],
+ QueryInterface: ChromeUtils.generateQI([
+ "nsIObserver",
+ "nsISupportsWeakReference",
+ ]),
+ observe(subject, topic, data) {
+ if (subject instanceof Ci.nsISupportsString) {
+ subject = subject.toString();
+ }
+ this.observed.push({ subject, topic, data });
+ },
+ reset() {
+ this.observed = [];
+ },
+};
+
+const entry1 = ["entry1", "value1"];
+const entry2 = ["entry2", "value2"];
+const entry3 = ["entry3", "value3"];
+
+add_setup(async () => {
+ await promiseUpdateEntry("remove", null, null);
+ const count = await promiseCountEntries(null, null);
+ Assert.ok(!count, "Checking initial DB is empty");
+
+ // Add the observer
+ Services.obs.addObserver(TestObserver, "satchel-storage-changed");
+});
+
+add_task(async function addAndUpdateEntry() {
+ // Add
+ await promiseUpdateEntry("add", entry1[0], entry1[1]);
+ Assert.equal(TestObserver.observed.length, 1);
+ let { subject, data } = TestObserver.observed[0];
+ Assert.equal(data, "formhistory-add");
+ Assert.ok(isGUID.test(subject));
+
+ let count = await promiseCountEntries(entry1[0], entry1[1]);
+ Assert.equal(count, 1);
+
+ // Update
+ TestObserver.reset();
+
+ await promiseUpdateEntry("update", entry1[0], entry1[1]);
+ Assert.equal(TestObserver.observed.length, 1);
+ ({ subject, data } = TestObserver.observed[0]);
+ Assert.equal(data, "formhistory-update");
+ Assert.ok(isGUID.test(subject));
+
+ count = await promiseCountEntries(entry1[0], entry1[1]);
+ Assert.equal(count, 1);
+
+ // Clean-up
+ await promiseUpdateEntry("remove", null, null);
+});
+
+add_task(async function removeEntry() {
+ TestObserver.reset();
+ await promiseUpdateEntry("add", entry1[0], entry1[1]);
+ const guid = TestObserver.observed[0].subject;
+ TestObserver.reset();
+
+ await FormHistory.update({
+ op: "remove",
+ fieldname: entry1[0],
+ value: entry1[1],
+ guid,
+ });
+ Assert.equal(TestObserver.observed.length, 1);
+ const { subject, data } = TestObserver.observed[0];
+ Assert.equal(data, "formhistory-remove");
+ Assert.ok(isGUID.test(subject));
+
+ const count = await promiseCountEntries(entry1[0], entry1[1]);
+ Assert.equal(count, 0, "doesn't exist after remove");
+});
+
+add_task(async function removeAllEntries() {
+ await promiseAddEntry(entry1[0], entry1[1]);
+ await promiseAddEntry(entry2[0], entry2[1]);
+ await promiseAddEntry(entry3[0], entry3[1]);
+ TestObserver.reset();
+
+ await promiseUpdateEntry("remove", null, null);
+ Assert.equal(TestObserver.observed.length, 3);
+ for (const notification of TestObserver.observed) {
+ const { subject, data } = notification;
+ Assert.equal(data, "formhistory-remove");
+ Assert.ok(isGUID.test(subject));
+ }
+
+ const count = await promiseCountEntries(null, null);
+ Assert.equal(count, 0);
+});
+
+add_task(async function removeEntriesForName() {
+ await promiseAddEntry(entry1[0], entry1[1]);
+ await promiseAddEntry(entry2[0], entry2[1]);
+ await promiseAddEntry(entry3[0], entry3[1]);
+ TestObserver.reset();
+
+ await promiseUpdateEntry("remove", entry2[0], null);
+ Assert.equal(TestObserver.observed.length, 1);
+ const { subject, data } = TestObserver.observed[0];
+ Assert.equal(data, "formhistory-remove");
+ Assert.ok(isGUID.test(subject));
+
+ let count = await promiseCountEntries(entry2[0], entry2[1]);
+ Assert.equal(count, 0);
+
+ count = await promiseCountEntries(null, null);
+ Assert.equal(count, 2, "the other entries are still there");
+
+ // Clean-up
+ await promiseUpdateEntry("remove", null, null);
+});
+
+add_task(async function removeEntriesByTimeframe() {
+ let timerPrecision = Preferences.get("privacy.reduceTimerPrecision");
+ Preferences.set("privacy.reduceTimerPrecision", false);
+
+ registerCleanupFunction(function () {
+ Preferences.set("privacy.reduceTimerPrecision", timerPrecision);
+ });
+
+ await promiseAddEntry(entry1[0], entry1[1]);
+ await promiseAddEntry(entry2[0], entry2[1]);
+
+ const cutoffDate = Date.now();
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(res => setTimeout(res, 10));
+
+ await promiseAddEntry(entry3[0], entry3[1]);
+ TestObserver.reset();
+
+ await FormHistory.update({
+ op: "remove",
+ firstUsedStart: 10,
+ firstUsedEnd: cutoffDate * 1000,
+ });
+ Assert.equal(TestObserver.observed.length, 2);
+ for (const notification of TestObserver.observed) {
+ const { subject, data } = notification;
+ Assert.equal(data, "formhistory-remove");
+ Assert.ok(isGUID.test(subject));
+ }
+
+ const count = await promiseCountEntries(null, null);
+ Assert.equal(count, 1, "entry2 should still be there");
+
+ // Clean-up
+ await promiseUpdateEntry("remove", null, null);
+});
+
+add_task(async function teardown() {
+ await promiseUpdateEntry("remove", null, null);
+ Services.obs.removeObserver(TestObserver, "satchel-storage-changed");
+});
diff --git a/toolkit/components/satchel/test/unit/test_previous_result.js b/toolkit/components/satchel/test/unit/test_previous_result.js
new file mode 100644
index 0000000000..a782832db7
--- /dev/null
+++ b/toolkit/components/satchel/test/unit/test_previous_result.js
@@ -0,0 +1,25 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var aaaListener = {
+ onSearchResult(search, result) {
+ Assert.equal(result.searchString, "aaa");
+ do_test_finished();
+ },
+};
+
+var aaListener = {
+ onSearchResult(search, result) {
+ Assert.equal(result.searchString, "aa");
+ search.startSearch("aaa", "", result, aaaListener);
+ },
+};
+
+function run_test() {
+ do_test_pending();
+ let search = Cc[
+ "@mozilla.org/autocomplete/search;1?name=form-history"
+ ].getService(Ci.nsIAutoCompleteSearch);
+ search.startSearch("aa", "", null, aaListener);
+}
diff --git a/toolkit/components/satchel/test/unit/xpcshell.toml b/toolkit/components/satchel/test/unit/xpcshell.toml
new file mode 100644
index 0000000000..68a379e74d
--- /dev/null
+++ b/toolkit/components/satchel/test/unit/xpcshell.toml
@@ -0,0 +1,43 @@
+[DEFAULT]
+head = "head_satchel.js"
+tags = "condprof"
+skip-if = ["os == 'android'"]
+support-files = [
+ "asyncformhistory_expire.sqlite",
+ "formhistory_1000.sqlite",
+ "formhistory_CORRUPT.sqlite",
+ "formhistory_apitest.sqlite",
+ "formhistory_autocomplete.sqlite",
+ "formhistory_v3.sqlite",
+ "formhistory_v3v4.sqlite",
+ "formhistory_v999a.sqlite",
+ "formhistory_v999b.sqlite",
+]
+
+["test_async_expire.js"]
+
+["test_autocomplete.js"]
+
+["test_db_access_denied.js"]
+skip-if = ["os != 'linux'"] # simulates insufficiant file permissions
+
+["test_db_corrupt.js"]
+
+["test_db_update_v4.js"]
+
+["test_db_update_v4b.js"]
+
+["test_db_update_v5.js"]
+skip-if = ["condprof"] # Bug 1769154 - not supported
+
+["test_db_update_v999a.js"]
+
+["test_db_update_v999b.js"]
+
+["test_history_api.js"]
+
+["test_history_sources.js"]
+
+["test_notify.js"]
+
+["test_previous_result.js"]
diff --git a/toolkit/components/satchel/towel b/toolkit/components/satchel/towel
new file mode 100644
index 0000000000..c26c7a8b28
--- /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