diff options
Diffstat (limited to 'browser/extensions')
389 files changed, 73094 insertions, 0 deletions
diff --git a/browser/extensions/doh-rollout/manifest.json b/browser/extensions/doh-rollout/manifest.json new file mode 100644 index 0000000000..d41945d991 --- /dev/null +++ b/browser/extensions/doh-rollout/manifest.json @@ -0,0 +1,15 @@ +{ + "manifest_version": 2, + "name": "DoH Roll-Out", + "description": "This used to be a Mozilla add-on that supported the roll-out of DoH, but now only exists as a stub to enable migrations.", + "version": "2.0.0", + + "hidden": true, + + "applications": { + "gecko": { + "id": "doh-rollout@mozilla.org", + "strict_min_version": "72.0a1" + } + } +} diff --git a/browser/extensions/doh-rollout/moz.build b/browser/extensions/doh-rollout/moz.build new file mode 100644 index 0000000000..bce8283117 --- /dev/null +++ b/browser/extensions/doh-rollout/moz.build @@ -0,0 +1,14 @@ +# -*- 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/. + +DEFINES["MOZ_APP_VERSION"] = CONFIG["MOZ_APP_VERSION"] +DEFINES["MOZ_APP_MAXVERSION"] = CONFIG["MOZ_APP_MAXVERSION"] + + +FINAL_TARGET_FILES.features["doh-rollout@mozilla.org"] += ["manifest.json"] + +with Files("**"): + BUG_COMPONENT = ("Firefox", "Security") diff --git a/browser/extensions/formautofill/.eslintrc.js b/browser/extensions/formautofill/.eslintrc.js new file mode 100644 index 0000000000..0187f91da3 --- /dev/null +++ b/browser/extensions/formautofill/.eslintrc.js @@ -0,0 +1,100 @@ +/* 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"; + +module.exports = { + rules: { + // Rules from the mozilla plugin + "mozilla/balanced-listeners": "error", + "mozilla/no-aArgs": "error", + "mozilla/var-only-at-top-level": "error", + + "valid-jsdoc": [ + "error", + { + prefer: { + return: "returns", + }, + preferType: { + Boolean: "boolean", + Number: "number", + String: "string", + bool: "boolean", + }, + requireParamDescription: false, + requireReturn: false, + requireReturnDescription: false, + }, + ], + + // No expressions where a statement is expected + "no-unused-expressions": "error", + + // No declaring variables that are never used + "no-unused-vars": [ + "error", + { + args: "none", + vars: "all", + }, + ], + + // No using variables before defined + "no-use-before-define": "error", + + // Disallow using variables outside the blocks they are defined (especially + // since only let and const are used, see "no-var"). + "block-scoped-var": "error", + + // Warn about cyclomatic complexity in functions. + complexity: ["error", { max: 26 }], + + // Maximum depth callbacks can be nested. + "max-nested-callbacks": ["error", 4], + + // Disallow using the console API. + "no-console": "error", + + // Disallow fallthrough of case statements, except if there is a comment. + "no-fallthrough": "error", + + // Disallow use of multiline strings (use template strings instead). + "no-multi-str": "error", + + // Disallow usage of __proto__ property. + "no-proto": "error", + + // Disallow use of assignment in return statement. It is preferable for a + // single line of code to have only one easily predictable effect. + "no-return-assign": "error", + + // Require use of the second argument for parseInt(). + radix: "error", + + // Require "use strict" to be defined globally in the script. + strict: ["error", "global"], + + // Disallow Yoda conditions (where literal value comes first). + yoda: "error", + + // Disallow function or variable declarations in nested blocks + "no-inner-declarations": "error", + }, + + overrides: [ + { + files: "test/unit/head.js", + rules: { + "no-unused-vars": [ + "error", + { + args: "none", + vars: "local", + }, + ], + }, + }, + ], +}; diff --git a/browser/extensions/formautofill/FormAutofill.jsm b/browser/extensions/formautofill/FormAutofill.jsm new file mode 100644 index 0000000000..513f82d22e --- /dev/null +++ b/browser/extensions/formautofill/FormAutofill.jsm @@ -0,0 +1,156 @@ +/* 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 EXPORTED_SYMBOLS = ["FormAutofill"]; + +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); +const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); + +XPCOMUtils.defineLazyModuleGetters(this, { + Region: "resource://gre/modules/Region.jsm", +}); + +const ADDRESSES_FIRST_TIME_USE_PREF = "extensions.formautofill.firstTimeUse"; +const AUTOFILL_CREDITCARDS_AVAILABLE_PREF = + "extensions.formautofill.creditCards.available"; +const CREDITCARDS_USED_STATUS_PREF = "extensions.formautofill.creditCards.used"; +const ENABLED_AUTOFILL_ADDRESSES_PREF = + "extensions.formautofill.addresses.enabled"; +const ENABLED_AUTOFILL_ADDRESSES_CAPTURE_PREF = + "extensions.formautofill.addresses.capture.enabled"; +const ENABLED_AUTOFILL_CREDITCARDS_PREF = + "extensions.formautofill.creditCards.enabled"; +const ENABLED_AUTOFILL_CREDITCARDS_REAUTH_PREF = + "extensions.formautofill.reauth.enabled"; +const AUTOFILL_CREDITCARDS_HIDE_UI_PREF = + "extensions.formautofill.creditCards.hideui"; +const SUPPORTED_COUNTRIES_PREF = "extensions.formautofill.supportedCountries"; + +XPCOMUtils.defineLazyPreferenceGetter( + this, + "logLevel", + "extensions.formautofill.loglevel", + "Warn" +); + +// A logging helper for debug logging to avoid creating Console objects +// or triggering expensive JS -> C++ calls when debug logging is not +// enabled. +// +// Console objects, even natively-implemented ones, can consume a lot of +// memory, and since this code may run in every content process, that +// memory can add up quickly. And, even when debug-level messages are +// being ignored, console.debug() calls can be expensive. +// +// This helper avoids both of those problems by never touching the +// console object unless debug logging is enabled. +function debug() { + if (logLevel.toLowerCase() == "debug") { + this.log.debug(...arguments); + } +} + +var FormAutofill = { + ENABLED_AUTOFILL_ADDRESSES_PREF, + ENABLED_AUTOFILL_ADDRESSES_CAPTURE_PREF, + ENABLED_AUTOFILL_CREDITCARDS_PREF, + ENABLED_AUTOFILL_CREDITCARDS_REAUTH_PREF, + ADDRESSES_FIRST_TIME_USE_PREF, + CREDITCARDS_USED_STATUS_PREF, + + get DEFAULT_REGION() { + return Region.home || "US"; + }, + get isAutofillEnabled() { + return ( + FormAutofill.isAutofillAddressesEnabled || + this.isAutofillCreditCardsEnabled + ); + }, + get isAutofillCreditCardsEnabled() { + return ( + FormAutofill.isAutofillCreditCardsAvailable && + FormAutofill._isAutofillCreditCardsEnabled + ); + }, + + defineLazyLogGetter(scope, logPrefix) { + scope.debug = debug; + + XPCOMUtils.defineLazyGetter(scope, "log", () => { + let ConsoleAPI = ChromeUtils.import( + "resource://gre/modules/Console.jsm", + {} + ).ConsoleAPI; + return new ConsoleAPI({ + maxLogLevelPref: "extensions.formautofill.loglevel", + prefix: logPrefix, + }); + }); + }, +}; + +XPCOMUtils.defineLazyPreferenceGetter( + FormAutofill, + "isAutofillAddressesEnabled", + ENABLED_AUTOFILL_ADDRESSES_PREF +); +XPCOMUtils.defineLazyPreferenceGetter( + FormAutofill, + "isAutofillAddressesCaptureEnabled", + ENABLED_AUTOFILL_ADDRESSES_CAPTURE_PREF +); +XPCOMUtils.defineLazyPreferenceGetter( + FormAutofill, + "isAutofillCreditCardsAvailable", + AUTOFILL_CREDITCARDS_AVAILABLE_PREF +); +XPCOMUtils.defineLazyPreferenceGetter( + FormAutofill, + "_isAutofillCreditCardsEnabled", + ENABLED_AUTOFILL_CREDITCARDS_PREF +); +XPCOMUtils.defineLazyPreferenceGetter( + FormAutofill, + "isAutofillCreditCardsHideUI", + AUTOFILL_CREDITCARDS_HIDE_UI_PREF +); +XPCOMUtils.defineLazyPreferenceGetter( + FormAutofill, + "isAutofillAddressesFirstTimeUse", + ADDRESSES_FIRST_TIME_USE_PREF +); +XPCOMUtils.defineLazyPreferenceGetter( + FormAutofill, + "AutofillCreditCardsUsedStatus", + CREDITCARDS_USED_STATUS_PREF +); +XPCOMUtils.defineLazyPreferenceGetter( + FormAutofill, + "supportedCountries", + SUPPORTED_COUNTRIES_PREF, + null, + null, + val => val.split(",") +); + +// XXX: This should be invalidated on intl:app-locales-changed. +XPCOMUtils.defineLazyGetter(FormAutofill, "countries", () => { + let availableRegionCodes = Services.intl.getAvailableLocaleDisplayNames( + "region" + ); + let displayNames = Services.intl.getRegionDisplayNames( + undefined, + availableRegionCodes + ); + let result = new Map(); + for (let i = 0; i < availableRegionCodes.length; i++) { + result.set(availableRegionCodes[i].toUpperCase(), displayNames[i]); + } + return result; +}); diff --git a/browser/extensions/formautofill/FormAutofillChild.jsm b/browser/extensions/formautofill/FormAutofillChild.jsm new file mode 100644 index 0000000000..58aa1c4a91 --- /dev/null +++ b/browser/extensions/formautofill/FormAutofillChild.jsm @@ -0,0 +1,198 @@ +/* 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 EXPORTED_SYMBOLS = ["FormAutofillChild"]; + +var { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); +ChromeUtils.defineModuleGetter( + this, + "setTimeout", + "resource://gre/modules/Timer.jsm" +); +ChromeUtils.defineModuleGetter( + this, + "FormAutofill", + "resource://formautofill/FormAutofill.jsm" +); +ChromeUtils.defineModuleGetter( + this, + "FormAutofillContent", + "resource://formautofill/FormAutofillContent.jsm" +); +ChromeUtils.defineModuleGetter( + this, + "FormAutofillUtils", + "resource://formautofill/FormAutofillUtils.jsm" +); +ChromeUtils.defineModuleGetter( + this, + "AutoCompleteChild", + "resource://gre/actors/AutoCompleteChild.jsm" +); + +/** + * Handles content's interactions for the frame. + */ +class FormAutofillChild extends JSWindowActorChild { + constructor() { + super(); + + this._nextHandleElement = null; + this._alreadyDOMContentLoaded = false; + this._hasDOMContentLoadedHandler = false; + this._hasPendingTask = false; + this.testListener = null; + + AutoCompleteChild.addPopupStateListener(this); + } + + didDestroy() { + AutoCompleteChild.removePopupStateListener(this); + } + + popupStateChanged(messageName, data, target) { + let docShell; + try { + docShell = this.docShell; + } catch (ex) { + AutoCompleteChild.removePopupStateListener(this); + return; + } + + if (!FormAutofill.isAutofillEnabled) { + return; + } + + const { chromeEventHandler } = docShell; + + switch (messageName) { + case "FormAutoComplete:PopupClosed": { + FormAutofillContent.onPopupClosed(data.selectedRowStyle); + Services.tm.dispatchToMainThread(() => { + chromeEventHandler.removeEventListener( + "keydown", + FormAutofillContent._onKeyDown, + true + ); + }); + + break; + } + case "FormAutoComplete:PopupOpened": { + FormAutofillContent.onPopupOpened(); + chromeEventHandler.addEventListener( + "keydown", + FormAutofillContent._onKeyDown, + true + ); + break; + } + } + } + + _doIdentifyAutofillFields() { + if (this._hasPendingTask) { + return; + } + this._hasPendingTask = true; + + setTimeout(() => { + FormAutofillContent.identifyAutofillFields(this._nextHandleElement); + this._hasPendingTask = false; + this._nextHandleElement = null; + // This is for testing purpose only which sends a notification to indicate that the + // form has been identified, and ready to open popup. + this.sendAsyncMessage("FormAutofill:FieldsIdentified"); + FormAutofillContent.updateActiveInput(); + }); + } + + handleEvent(evt) { + if (!evt.isTrusted) { + return; + } + + switch (evt.type) { + case "focusin": { + if (FormAutofill.isAutofillEnabled) { + this.onFocusIn(evt); + } + break; + } + case "DOMFormBeforeSubmit": { + if (FormAutofill.isAutofillEnabled) { + this.onDOMFormBeforeSubmit(evt); + } + break; + } + + default: { + throw new Error("Unexpected event type"); + } + } + } + + onFocusIn(evt) { + FormAutofillContent.updateActiveInput(); + + let element = evt.target; + if (!FormAutofillUtils.isFieldEligibleForAutofill(element)) { + return; + } + this._nextHandleElement = element; + + if (!this._alreadyDOMContentLoaded) { + let doc = element.ownerDocument; + if (doc.readyState === "loading") { + if (!this._hasDOMContentLoadedHandler) { + this._hasDOMContentLoadedHandler = true; + doc.addEventListener( + "DOMContentLoaded", + () => this._doIdentifyAutofillFields(), + { once: true } + ); + } + return; + } + this._alreadyDOMContentLoaded = true; + } + + this._doIdentifyAutofillFields(); + } + + /** + * Handle the DOMFormBeforeSubmit event. + * @param {Event} evt + */ + onDOMFormBeforeSubmit(evt) { + let formElement = evt.target; + + if (!FormAutofill.isAutofillEnabled) { + return; + } + + FormAutofillContent.formSubmitted(formElement); + } + + receiveMessage(message) { + if (!FormAutofill.isAutofillEnabled) { + return; + } + + const doc = this.document; + + switch (message.name) { + case "FormAutofill:PreviewProfile": { + FormAutofillContent.previewProfile(doc); + break; + } + case "FormAutofill:ClearForm": { + FormAutofillContent.clearForm(); + break; + } + } + } +} diff --git a/browser/extensions/formautofill/FormAutofillContent.jsm b/browser/extensions/formautofill/FormAutofillContent.jsm new file mode 100644 index 0000000000..ff7ebd06de --- /dev/null +++ b/browser/extensions/formautofill/FormAutofillContent.jsm @@ -0,0 +1,915 @@ +/* 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/. */ + +/** + * Form Autofill content process module. + */ + +/* eslint-disable no-use-before-define */ + +"use strict"; + +var EXPORTED_SYMBOLS = ["FormAutofillContent"]; + +const Cm = Components.manager; + +const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); + +ChromeUtils.defineModuleGetter( + this, + "AddressResult", + "resource://formautofill/ProfileAutoCompleteResult.jsm" +); +ChromeUtils.defineModuleGetter( + this, + "ComponentUtils", + "resource://gre/modules/ComponentUtils.jsm" +); +ChromeUtils.defineModuleGetter( + this, + "CreditCardResult", + "resource://formautofill/ProfileAutoCompleteResult.jsm" +); +ChromeUtils.defineModuleGetter( + this, + "FormAutofill", + "resource://formautofill/FormAutofill.jsm" +); +ChromeUtils.defineModuleGetter( + this, + "FormAutofillHandler", + "resource://formautofill/FormAutofillHandler.jsm" +); +ChromeUtils.defineModuleGetter( + this, + "FormAutofillUtils", + "resource://formautofill/FormAutofillUtils.jsm" +); +ChromeUtils.defineModuleGetter( + this, + "FormLikeFactory", + "resource://gre/modules/FormLikeFactory.jsm" +); +ChromeUtils.defineModuleGetter( + this, + "InsecurePasswordUtils", + "resource://gre/modules/InsecurePasswordUtils.jsm" +); +ChromeUtils.defineModuleGetter( + this, + "PrivateBrowsingUtils", + "resource://gre/modules/PrivateBrowsingUtils.jsm" +); + +const formFillController = Cc[ + "@mozilla.org/satchel/form-fill-controller;1" +].getService(Ci.nsIFormFillController); +const autocompleteController = Cc[ + "@mozilla.org/autocomplete/controller;1" +].getService(Ci.nsIAutoCompleteController); + +XPCOMUtils.defineLazyGetter( + this, + "ADDRESSES_COLLECTION_NAME", + () => FormAutofillUtils.ADDRESSES_COLLECTION_NAME +); +XPCOMUtils.defineLazyGetter( + this, + "CREDITCARDS_COLLECTION_NAME", + () => FormAutofillUtils.CREDITCARDS_COLLECTION_NAME +); +XPCOMUtils.defineLazyGetter( + this, + "FIELD_STATES", + () => FormAutofillUtils.FIELD_STATES +); + +function getActorFromWindow(contentWindow, name = "FormAutofill") { + // In unit tests, contentWindow isn't a real window. + if (!contentWindow) { + return null; + } + + return contentWindow.windowGlobalChild + ? contentWindow.windowGlobalChild.getActor(name) + : null; +} + +// Register/unregister a constructor as a factory. +function AutocompleteFactory() {} +AutocompleteFactory.prototype = { + register(targetConstructor) { + let proto = targetConstructor.prototype; + this._classID = proto.classID; + + let factory = ComponentUtils._getFactory(targetConstructor); + this._factory = factory; + + let registrar = Cm.QueryInterface(Ci.nsIComponentRegistrar); + registrar.registerFactory( + proto.classID, + proto.classDescription, + proto.contractID, + factory + ); + + if (proto.classID2) { + this._classID2 = proto.classID2; + registrar.registerFactory( + proto.classID2, + proto.classDescription, + proto.contractID2, + factory + ); + } + }, + + unregister() { + let registrar = Cm.QueryInterface(Ci.nsIComponentRegistrar); + registrar.unregisterFactory(this._classID, this._factory); + if (this._classID2) { + registrar.unregisterFactory(this._classID2, this._factory); + } + this._factory = null; + }, +}; + +/** + * @constructor + * + * @implements {nsIAutoCompleteSearch} + */ +function AutofillProfileAutoCompleteSearch() { + FormAutofill.defineLazyLogGetter(this, "AutofillProfileAutoCompleteSearch"); +} +AutofillProfileAutoCompleteSearch.prototype = { + classID: Components.ID("4f9f1e4c-7f2c-439e-9c9e-566b68bc187d"), + contractID: "@mozilla.org/autocomplete/search;1?name=autofill-profiles", + classDescription: "AutofillProfileAutoCompleteSearch", + QueryInterface: ChromeUtils.generateQI(["nsIAutoCompleteSearch"]), + + // Begin nsIAutoCompleteSearch implementation + + /** + * Searches for a given string and notifies a listener (either synchronously + * or asynchronously) of the result + * + * @param {string} searchString the string to search for + * @param {string} searchParam + * @param {Object} previousResult a previous result to use for faster searchinig + * @param {Object} listener the listener to notify when the search is complete + */ + startSearch(searchString, searchParam, previousResult, listener) { + let { + activeInput, + activeSection, + activeFieldDetail, + savedFieldNames, + } = FormAutofillContent; + this.forceStop = false; + + this.debug("startSearch: for", searchString, "with input", activeInput); + + let isAddressField = FormAutofillUtils.isAddressField( + activeFieldDetail.fieldName + ); + const isCreditCardField = FormAutofillUtils.isCreditCardField( + activeFieldDetail.fieldName + ); + let isInputAutofilled = activeFieldDetail.state == FIELD_STATES.AUTO_FILLED; + let allFieldNames = activeSection.allFieldNames; + let filledRecordGUID = activeSection.filledRecordGUID; + + let creditCardsEnabledAndVisible = + FormAutofill.isAutofillCreditCardsEnabled && + !FormAutofill.isAutofillCreditCardsHideUI; + let searchPermitted = isAddressField + ? FormAutofill.isAutofillAddressesEnabled + : creditCardsEnabledAndVisible; + let AutocompleteResult = isAddressField ? AddressResult : CreditCardResult; + let isFormAutofillSearch = true; + let pendingSearchResult = null; + + ProfileAutocomplete.lastProfileAutoCompleteFocusedInput = activeInput; + // Fallback to form-history if ... + // - specified autofill feature is pref off. + // - no profile can fill the currently-focused input. + // - the current form has already been populated and the field is not + // an empty credit card field. + // - (address only) less than 3 inputs are covered by all saved fields in the storage. + if ( + !searchPermitted || + !savedFieldNames.has(activeFieldDetail.fieldName) || + (!isInputAutofilled && + filledRecordGUID && + !(isCreditCardField && activeInput.value === "")) || + (isAddressField && + allFieldNames.filter(field => savedFieldNames.has(field)).length < + FormAutofillUtils.AUTOFILL_FIELDS_THRESHOLD) + ) { + isFormAutofillSearch = false; + if (activeInput.autocomplete == "off") { + // Create a dummy result as an empty search result. + pendingSearchResult = new AutocompleteResult("", "", [], [], {}); + } else { + pendingSearchResult = new Promise(resolve => { + let formHistory = Cc[ + "@mozilla.org/autocomplete/search;1?name=form-history" + ].createInstance(Ci.nsIAutoCompleteSearch); + formHistory.startSearch(searchString, searchParam, previousResult, { + onSearchResult: (_, result) => resolve(result), + }); + }); + } + } else if (isInputAutofilled) { + pendingSearchResult = new AutocompleteResult(searchString, "", [], [], { + isInputAutofilled, + }); + } else { + let infoWithoutElement = { ...activeFieldDetail }; + delete infoWithoutElement.elementWeakRef; + + let data = { + collectionName: isAddressField + ? ADDRESSES_COLLECTION_NAME + : CREDITCARDS_COLLECTION_NAME, + info: infoWithoutElement, + searchString, + }; + + pendingSearchResult = this._getRecords(activeInput, data).then( + records => { + if (this.forceStop) { + return null; + } + // Sort addresses by timeLastUsed for showing the lastest used address at top. + records.sort((a, b) => b.timeLastUsed - a.timeLastUsed); + + let adaptedRecords = activeSection.getAdaptedProfiles(records); + let handler = FormAutofillContent.activeHandler; + let isSecure = InsecurePasswordUtils.isFormSecure(handler.form); + + return new AutocompleteResult( + searchString, + activeFieldDetail.fieldName, + allFieldNames, + adaptedRecords, + { isSecure, isInputAutofilled } + ); + } + ); + } + + Promise.resolve(pendingSearchResult).then(result => { + listener.onSearchResult(this, result); + // Don't save cache results or reset state when returning non-autofill results such as the + // form history fallback above. + if (isFormAutofillSearch) { + ProfileAutocomplete.lastProfileAutoCompleteResult = result; + // Reset AutoCompleteController's state at the end of startSearch to ensure that + // none of form autofill result will be cached in other places and make the + // result out of sync. + autocompleteController.resetInternalState(); + } else { + // Clear the cache so that we don't try to autofill from it after falling + // back to form history. + ProfileAutocomplete.lastProfileAutoCompleteResult = null; + } + }); + }, + + /** + * Stops an asynchronous search that is in progress + */ + stopSearch() { + ProfileAutocomplete.lastProfileAutoCompleteResult = null; + this.forceStop = true; + }, + + /** + * Get the records from parent process for AutoComplete result. + * + * @private + * @param {Object} input + * Input element for autocomplete. + * @param {Object} data + * Parameters for querying the corresponding result. + * @param {string} data.collectionName + * The name used to specify which collection to retrieve records. + * @param {string} data.searchString + * The typed string for filtering out the matched records. + * @param {string} data.info + * The input autocomplete property's information. + * @returns {Promise} + * Promise that resolves when addresses returned from parent process. + */ + _getRecords(input, data) { + this.debug("_getRecords with data:", data); + if (!input) { + return []; + } + + let actor = getActorFromWindow(input.ownerGlobal); + return actor.sendQuery("FormAutofill:GetRecords", data); + }, +}; + +let ProfileAutocomplete = { + QueryInterface: ChromeUtils.generateQI(["nsIObserver"]), + + lastProfileAutoCompleteResult: null, + lastProfileAutoCompleteFocusedInput: null, + _registered: false, + _factory: null, + + ensureRegistered() { + if (this._registered) { + return; + } + + FormAutofill.defineLazyLogGetter(this, "ProfileAutocomplete"); + this.debug("ensureRegistered"); + this._factory = new AutocompleteFactory(); + this._factory.register(AutofillProfileAutoCompleteSearch); + this._registered = true; + + Services.obs.addObserver(this, "autocomplete-will-enter-text"); + + this.debug( + "ensureRegistered. Finished with _registered:", + this._registered + ); + }, + + ensureUnregistered() { + if (!this._registered) { + return; + } + + this.debug("ensureUnregistered"); + this._factory.unregister(); + this._factory = null; + this._registered = false; + this._lastAutoCompleteResult = null; + + Services.obs.removeObserver(this, "autocomplete-will-enter-text"); + }, + + async observe(subject, topic, data) { + switch (topic) { + case "autocomplete-will-enter-text": { + if (!FormAutofillContent.activeInput) { + // The observer notification is for autocomplete in a different process. + break; + } + FormAutofillContent.autofillPending = true; + Services.obs.notifyObservers(null, "autofill-fill-starting"); + await this._fillFromAutocompleteRow(FormAutofillContent.activeInput); + Services.obs.notifyObservers(null, "autofill-fill-complete"); + FormAutofillContent.autofillPending = false; + break; + } + } + }, + + _getSelectedIndex(contentWindow) { + let actor = getActorFromWindow(contentWindow, "AutoComplete"); + if (!actor) { + throw new Error("Invalid autocomplete selectedIndex"); + } + + return actor.selectedIndex; + }, + + async _fillFromAutocompleteRow(focusedInput) { + this.debug("_fillFromAutocompleteRow:", focusedInput); + let formDetails = FormAutofillContent.activeFormDetails; + if (!formDetails) { + // The observer notification is for a different frame. + return; + } + + let selectedIndex = this._getSelectedIndex(focusedInput.ownerGlobal); + if ( + selectedIndex == -1 || + !this.lastProfileAutoCompleteResult || + this.lastProfileAutoCompleteResult.getStyleAt(selectedIndex) != + "autofill-profile" + ) { + return; + } + + let profile = JSON.parse( + this.lastProfileAutoCompleteResult.getCommentAt(selectedIndex) + ); + + await FormAutofillContent.activeHandler.autofillFormFields(profile); + }, + + _clearProfilePreview() { + if ( + !this.lastProfileAutoCompleteFocusedInput || + !FormAutofillContent.activeSection + ) { + return; + } + + FormAutofillContent.activeSection.clearPreviewedFormFields(); + }, + + _previewSelectedProfile(selectedIndex) { + if ( + !FormAutofillContent.activeInput || + !FormAutofillContent.activeFormDetails + ) { + // The observer notification is for a different process/frame. + return; + } + + if ( + !this.lastProfileAutoCompleteResult || + this.lastProfileAutoCompleteResult.getStyleAt(selectedIndex) != + "autofill-profile" + ) { + return; + } + + let profile = JSON.parse( + this.lastProfileAutoCompleteResult.getCommentAt(selectedIndex) + ); + FormAutofillContent.activeSection.previewFormFields(profile); + }, +}; + +/** + * Handles content's interactions for the process. + * + * NOTE: Declares it by "var" to make it accessible in unit tests. + */ +var FormAutofillContent = { + /** + * @type {WeakMap} mapping FormLike root HTML elements to FormAutofillHandler objects. + */ + _formsDetails: new WeakMap(), + + /** + * @type {Set} Set of the fields with usable values in any saved profile. + */ + get savedFieldNames() { + return Services.cpmm.sharedData.get("FormAutofill:savedFieldNames"); + }, + + /** + * @type {Object} The object where to store the active items, e.g. element, + * handler, section, and field detail. + */ + _activeItems: {}, + + /** + * @type {boolean} Flag indicating whether a focus action requiring + * the popup to be active is pending. + */ + _popupPending: false, + + /** + * @type {boolean} Flag indicating whether the form is waiting to be + * filled by Autofill. + */ + _autofillPending: false, + + init() { + FormAutofill.defineLazyLogGetter(this, "FormAutofillContent"); + this.debug("init"); + + // eslint-disable-next-line mozilla/balanced-listeners + Services.cpmm.sharedData.addEventListener("change", this); + + let autofillEnabled = Services.cpmm.sharedData.get("FormAutofill:enabled"); + // If storage hasn't be initialized yet autofillEnabled is undefined but we need to ensure + // autocomplete is registered before the focusin so register it in this case as long as the + // pref is true. + let shouldEnableAutofill = + autofillEnabled === undefined && + (FormAutofill.isAutofillAddressesEnabled || + FormAutofill.isAutofillCreditCardsEnabled); + if (autofillEnabled || shouldEnableAutofill) { + ProfileAutocomplete.ensureRegistered(); + } + }, + + /** + * Send the profile to parent for doorhanger and storage saving/updating. + * + * @param {Object} profile Submitted form's address/creditcard guid and record. + * @param {Object} domWin Current content window. + * @param {int} timeStartedFillingMS Time of form filling started. + */ + _onFormSubmit(profile, domWin, timeStartedFillingMS) { + let actor = getActorFromWindow(domWin); + actor.sendAsyncMessage("FormAutofill:OnFormSubmit", { + profile, + timeStartedFillingMS, + }); + }, + + /** + * Handle a form submission and early return when: + * 1. In private browsing mode. + * 2. Could not map any autofill handler by form element. + * 3. Number of filled fields is less than autofill threshold + * + * @param {HTMLElement} formElement Root element which receives submit event. + * @param {Window} domWin Content window; passed for unit tests and when + * invoked by the FormAutofillSection + * @param {Object} handler FormAutofillHander, if known by caller + */ + formSubmitted( + formElement, + domWin = formElement.ownerGlobal, + handler = undefined + ) { + this.debug("Handling form submission"); + + if (!FormAutofill.isAutofillEnabled) { + this.debug("Form Autofill is disabled"); + return; + } + + // The `domWin` truthiness test is used by unit tests to bypass this check. + if (domWin && PrivateBrowsingUtils.isContentWindowPrivate(domWin)) { + this.debug("Ignoring submission in a private window"); + return; + } + + handler = handler ?? this._formsDetails.get(formElement); + if (!handler) { + this.debug("Form element could not map to an existing handler"); + return; + } + + let records = handler.createRecords(); + if (!Object.values(records).some(typeRecords => typeRecords.length)) { + return; + } + + records.creditCard.forEach(record => { + let extra = { + // Fields which have been filled manually. + fields_not_auto: "0", + // Fields which have been autofilled. + fields_auto: "0", + // Fields which have been autofilled and then modified. + fields_modified: "0", + }; + + if (record.guid !== null) { + // If the `guid` is not null, it means we're editing an existing record. + // In that case, all fields in the record are autofilled, and fields in + // `untouchedFields` are unmodified. + let totalCount = handler.form.elements.length; + let autofilledCount = Object.keys(record.record).length; + let unmodifiedCount = record.untouchedFields.length; + + extra.fields_not_auto = (totalCount - autofilledCount).toString(); + extra.fields_auto = autofilledCount.toString(); + extra.fields_modified = (autofilledCount - unmodifiedCount).toString(); + } else { + // If the `guid` is null, we're filling a new form. + // In that case, all not-null fields are manually filled. + extra.fields_not_auto = Array.from(handler.form.elements) + .filter(element => !!element.value.trim().length) + .length.toString(); + } + + Services.telemetry.recordEvent( + "creditcard", + "submitted", + "cc_form", + record.flowId, + extra + ); + }); + if (records.creditCard.length) { + Services.telemetry.scalarAdd( + "formautofill.creditCards.submitted_sections_count", + records.creditCard.length + ); + } + + this._onFormSubmit(records, domWin, handler.timeStartedFillingMS); + }, + + handleEvent(evt) { + switch (evt.type) { + case "change": { + if (!evt.changedKeys.includes("FormAutofill:enabled")) { + return; + } + if (Services.cpmm.sharedData.get("FormAutofill:enabled")) { + ProfileAutocomplete.ensureRegistered(); + if (this._popupPending) { + this._popupPending = false; + this.debug("handleEvent: Opening deferred popup"); + formFillController.showPopup(); + } + } else { + ProfileAutocomplete.ensureUnregistered(); + } + break; + } + } + }, + + /** + * Get the form's handler from cache which is created after page identified. + * + * @param {HTMLInputElement} element Focused input which triggered profile searching + * @returns {Array<Object>|null} + * Return target form's handler from content cache + * (or return null if the information is not found in the cache). + * + */ + _getFormHandler(element) { + if (!element) { + return null; + } + let rootElement = FormLikeFactory.findRootForField(element); + return this._formsDetails.get(rootElement); + }, + + /** + * Get the active form's information from cache which is created after page + * identified. + * + * @returns {Array<Object>|null} + * Return target form's information from content cache + * (or return null if the information is not found in the cache). + * + */ + get activeFormDetails() { + let formHandler = this.activeHandler; + return formHandler ? formHandler.fieldDetails : null; + }, + + /** + * All active items should be updated according the active element of + * `formFillController.focusedInput`. All of them including element, + * handler, section, and field detail, can be retrieved by their own getters. + * + * @param {HTMLElement|null} element The active item should be updated based + * on this or `formFillController.focusedInput` will be taken. + */ + updateActiveInput(element) { + element = element || formFillController.focusedInput; + if (!element) { + this.debug("updateActiveElement: no element selected"); + this._activeItems = {}; + return; + } + this._activeItems = { + elementWeakRef: Cu.getWeakReference(element), + fieldDetail: null, + }; + + this.debug("updateActiveElement: checking for popup-on-focus"); + // We know this element just received focus. If it's a credit card field, + // open its popup. + if (this._autofillPending) { + this.debug("updateActiveElement: skipping check; autofill is imminent"); + } else if (element.value?.length !== 0) { + this.debug( + "updateActiveElement: Not opening popup because field is " + + `not empty: element.value = "${element.value}"` + ); + } else { + this.debug( + "updateActiveElement: checking if empty field is cc-*: ", + this.activeFieldDetail?.fieldName + ); + if (this.activeFieldDetail?.fieldName?.startsWith("cc-")) { + if (Services.cpmm.sharedData.get("FormAutofill:enabled")) { + this.debug("updateActiveElement: opening pop up"); + formFillController.showPopup(); + } else { + this.debug( + "updateActiveElement: Deferring pop-up until Autofill is ready" + ); + this._popupPending = true; + } + } + } + }, + + get activeInput() { + let elementWeakRef = this._activeItems.elementWeakRef; + return elementWeakRef ? elementWeakRef.get() : null; + }, + + get activeHandler() { + const activeInput = this.activeInput; + if (!activeInput) { + return null; + } + + // XXX: We are recomputing the activeHandler every time to avoid keeping a + // reference on the active element. This might be called quite frequently + // so if _getFormHandler/findRootForField become more costly, we should + // look into caching this result (eg by adding a weakmap). + let handler = this._getFormHandler(activeInput); + if (handler) { + handler.focusedInput = activeInput; + } + return handler; + }, + + get activeSection() { + let formHandler = this.activeHandler; + return formHandler ? formHandler.activeSection : null; + }, + + /** + * Get the active input's information from cache which is created after page + * identified. + * + * @returns {Object|null} + * Return the active input's information that cloned from content cache + * (or return null if the information is not found in the cache). + */ + get activeFieldDetail() { + if (!this._activeItems.fieldDetail) { + let formDetails = this.activeFormDetails; + if (!formDetails) { + return null; + } + for (let detail of formDetails) { + let detailElement = detail.elementWeakRef.get(); + if (detailElement && this.activeInput == detailElement) { + this._activeItems.fieldDetail = detail; + break; + } + } + } + return this._activeItems.fieldDetail; + }, + + set autofillPending(flag) { + this.debug("Setting autofillPending to", flag); + this._autofillPending = flag; + }, + + identifyAutofillFields(element) { + this.debug( + "identifyAutofillFields:", + String(element.ownerDocument.location) + ); + + if (!this.savedFieldNames) { + this.debug("identifyAutofillFields: savedFieldNames are not known yet"); + let actor = getActorFromWindow(element.ownerGlobal); + if (actor) { + actor.sendAsyncMessage("FormAutofill:InitStorage"); + } + } + + let formHandler = this._getFormHandler(element); + if (!formHandler) { + let formLike = FormLikeFactory.createFromField(element); + formHandler = new FormAutofillHandler( + formLike, + this.formSubmitted.bind(this) + ); + } else if (!formHandler.updateFormIfNeeded(element)) { + this.debug("No control is removed or inserted since last collection."); + return; + } + + let validDetails = formHandler.collectFormFields(); + + this._formsDetails.set(formHandler.form.rootElement, formHandler); + this.debug("Adding form handler to _formsDetails:", formHandler); + + validDetails.forEach(detail => + this._markAsAutofillField(detail.elementWeakRef.get()) + ); + }, + + clearForm() { + let focusedInput = + this.activeInput || ProfileAutocomplete._lastAutoCompleteFocusedInput; + if (!focusedInput) { + return; + } + + this.activeSection.clearPopulatedForm(); + }, + + previewProfile(doc) { + let docWin = doc.ownerGlobal; + let selectedIndex = ProfileAutocomplete._getSelectedIndex(docWin); + let lastAutoCompleteResult = + ProfileAutocomplete.lastProfileAutoCompleteResult; + let focusedInput = this.activeInput; + let actor = getActorFromWindow(docWin); + + if ( + selectedIndex === -1 || + !focusedInput || + !lastAutoCompleteResult || + lastAutoCompleteResult.getStyleAt(selectedIndex) != "autofill-profile" + ) { + actor.sendAsyncMessage("FormAutofill:UpdateWarningMessage", {}); + + ProfileAutocomplete._clearProfilePreview(); + } else { + let focusedInputDetails = this.activeFieldDetail; + let profile = JSON.parse( + lastAutoCompleteResult.getCommentAt(selectedIndex) + ); + let allFieldNames = FormAutofillContent.activeSection.allFieldNames; + let profileFields = allFieldNames.filter( + fieldName => !!profile[fieldName] + ); + + let focusedCategory = FormAutofillUtils.getCategoryFromFieldName( + focusedInputDetails.fieldName + ); + let categories = FormAutofillUtils.getCategoriesFromFieldNames( + profileFields + ); + actor.sendAsyncMessage("FormAutofill:UpdateWarningMessage", { + focusedCategory, + categories, + }); + + ProfileAutocomplete._previewSelectedProfile(selectedIndex); + } + }, + + onPopupClosed(selectedRowStyle) { + this.debug("Popup has closed."); + ProfileAutocomplete._clearProfilePreview(); + + let lastAutoCompleteResult = + ProfileAutocomplete.lastProfileAutoCompleteResult; + let focusedInput = FormAutofillContent.activeInput; + if ( + lastAutoCompleteResult && + FormAutofillContent._keyDownEnterForInput && + focusedInput === FormAutofillContent._keyDownEnterForInput && + focusedInput === ProfileAutocomplete.lastProfileAutoCompleteFocusedInput + ) { + if (selectedRowStyle == "autofill-footer") { + let actor = getActorFromWindow(focusedInput.ownerGlobal); + actor.sendAsyncMessage("FormAutofill:OpenPreferences"); + } else if (selectedRowStyle == "autofill-clear-button") { + FormAutofillContent.clearForm(); + } + } + }, + + onPopupOpened() { + this.debug( + "Popup has opened, automatic =", + formFillController.passwordPopupAutomaticallyOpened + ); + + Services.telemetry.recordEvent( + "creditcard", + "popup_shown", + "cc_form", + this.activeSection.flowId + ); + }, + + _markAsAutofillField(field) { + // Since Form Autofill popup is only for input element, any non-Input + // element should be excluded here. + if (!field || ChromeUtils.getClassName(field) !== "HTMLInputElement") { + return; + } + + formFillController.markAsAutofillField(field); + }, + + _onKeyDown(e) { + delete FormAutofillContent._keyDownEnterForInput; + let lastAutoCompleteResult = + ProfileAutocomplete.lastProfileAutoCompleteResult; + let focusedInput = FormAutofillContent.activeInput; + if ( + e.keyCode != e.DOM_VK_RETURN || + !lastAutoCompleteResult || + !focusedInput || + focusedInput != ProfileAutocomplete.lastProfileAutoCompleteFocusedInput + ) { + return; + } + FormAutofillContent._keyDownEnterForInput = focusedInput; + }, +}; + +FormAutofillContent.init(); diff --git a/browser/extensions/formautofill/FormAutofillDoorhanger.jsm b/browser/extensions/formautofill/FormAutofillDoorhanger.jsm new file mode 100644 index 0000000000..cf27bf56dc --- /dev/null +++ b/browser/extensions/formautofill/FormAutofillDoorhanger.jsm @@ -0,0 +1,446 @@ +/* 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/. */ + +/* + * Implements doorhanger singleton that wraps up the PopupNotifications and handles + * the doorhager UI for formautofill related features. + */ + +"use strict"; + +var EXPORTED_SYMBOLS = ["FormAutofillDoorhanger"]; + +const { AppConstants } = ChromeUtils.import( + "resource://gre/modules/AppConstants.jsm" +); +const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); +const { FormAutofill } = ChromeUtils.import( + "resource://formautofill/FormAutofill.jsm" +); +const { FormAutofillUtils } = ChromeUtils.import( + "resource://formautofill/FormAutofillUtils.jsm" +); + +this.log = null; +FormAutofill.defineLazyLogGetter(this, EXPORTED_SYMBOLS[0]); + +const GetStringFromName = FormAutofillUtils.stringBundle.GetStringFromName; +const formatStringFromName = + FormAutofillUtils.stringBundle.formatStringFromName; +const brandShortName = FormAutofillUtils.brandBundle.GetStringFromName( + "brandShortName" +); +let changeAutofillOptsKey = "changeAutofillOptions"; +let autofillOptsKey = "autofillOptionsLink"; +if (AppConstants.platform == "macosx") { + changeAutofillOptsKey += "OSX"; + autofillOptsKey += "OSX"; +} + +const CONTENT = { + firstTimeUse: { + notificationId: "autofill-address", + message: formatStringFromName("saveAddressesMessage", [brandShortName]), + anchor: { + id: "autofill-address-notification-icon", + URL: "chrome://formautofill/content/formfill-anchor.svg", + tooltiptext: GetStringFromName("openAutofillMessagePanel"), + }, + mainAction: { + label: GetStringFromName(changeAutofillOptsKey), + accessKey: GetStringFromName("changeAutofillOptionsAccessKey"), + callbackState: "open-pref", + disableHighlight: true, + }, + options: { + persistWhileVisible: true, + popupIconURL: "chrome://formautofill/content/icon-address-save.svg", + checkbox: { + get checked() { + return Services.prefs.getBoolPref("services.sync.engine.addresses"); + }, + get label() { + // If sync account is not set, return null label to hide checkbox + return Services.prefs.prefHasUserValue("services.sync.username") + ? GetStringFromName("addressesSyncCheckbox") + : null; + }, + callback(event) { + let checked = event.target.checked; + Services.prefs.setBoolPref("services.sync.engine.addresses", checked); + log.debug("Set addresses sync to", checked); + }, + }, + hideClose: true, + }, + }, + updateAddress: { + notificationId: "autofill-address", + message: GetStringFromName("updateAddressMessage"), + descriptionLabel: GetStringFromName("updateAddressDescriptionLabel"), + descriptionIcon: false, + linkMessage: GetStringFromName(autofillOptsKey), + spotlightURL: "about:preferences#privacy-address-autofill", + anchor: { + id: "autofill-address-notification-icon", + URL: "chrome://formautofill/content/formfill-anchor.svg", + tooltiptext: GetStringFromName("openAutofillMessagePanel"), + }, + mainAction: { + label: GetStringFromName("updateAddressLabel"), + accessKey: GetStringFromName("updateAddressAccessKey"), + callbackState: "update", + }, + secondaryActions: [ + { + label: GetStringFromName("createAddressLabel"), + accessKey: GetStringFromName("createAddressAccessKey"), + callbackState: "create", + }, + ], + options: { + persistWhileVisible: true, + popupIconURL: "chrome://formautofill/content/icon-address-update.svg", + hideClose: true, + }, + }, + addCreditCard: { + notificationId: "autofill-credit-card", + message: formatStringFromName("saveCreditCardMessage", [brandShortName]), + descriptionLabel: GetStringFromName("saveCreditCardDescriptionLabel"), + descriptionIcon: true, + linkMessage: GetStringFromName(autofillOptsKey), + spotlightURL: "about:preferences#privacy-credit-card-autofill", + anchor: { + id: "autofill-credit-card-notification-icon", + URL: "chrome://formautofill/content/formfill-anchor.svg", + tooltiptext: GetStringFromName("openAutofillMessagePanel"), + }, + mainAction: { + label: GetStringFromName("saveCreditCardLabel"), + accessKey: GetStringFromName("saveCreditCardAccessKey"), + callbackState: "save", + }, + secondaryActions: [ + { + label: GetStringFromName("cancelCreditCardLabel"), + accessKey: GetStringFromName("cancelCreditCardAccessKey"), + callbackState: "cancel", + }, + { + label: GetStringFromName("neverSaveCreditCardLabel"), + accessKey: GetStringFromName("neverSaveCreditCardAccessKey"), + callbackState: "disable", + }, + ], + options: { + persistWhileVisible: true, + popupIconURL: "chrome://formautofill/content/icon-credit-card.svg", + hideClose: true, + checkbox: { + get checked() { + return Services.prefs.getBoolPref("services.sync.engine.creditcards"); + }, + get label() { + // Only set the label when the fallowing conditions existed: + // - sync account is set + // - credit card sync is disabled + // - credit card sync is available + // otherwise return null label to hide checkbox. + return Services.prefs.prefHasUserValue("services.sync.username") && + !Services.prefs.getBoolPref("services.sync.engine.creditcards") && + Services.prefs.getBoolPref( + "services.sync.engine.creditcards.available" + ) + ? GetStringFromName("creditCardsSyncCheckbox") + : null; + }, + callback(event) { + let { + secondaryButton, + menubutton, + } = event.target.parentNode.parentNode.parentNode; + let checked = event.target.checked; + Services.prefs.setBoolPref( + "services.sync.engine.creditcards", + checked + ); + secondaryButton.disabled = checked; + menubutton.disabled = checked; + log.debug("Set creditCard sync to", checked); + }, + }, + }, + }, + updateCreditCard: { + notificationId: "autofill-credit-card", + message: GetStringFromName("updateCreditCardMessage"), + descriptionLabel: GetStringFromName("updateCreditCardDescriptionLabel"), + descriptionIcon: true, + linkMessage: GetStringFromName(autofillOptsKey), + spotlightURL: "about:preferences#privacy-credit-card-autofill", + anchor: { + id: "autofill-credit-card-notification-icon", + URL: "chrome://formautofill/content/formfill-anchor.svg", + tooltiptext: GetStringFromName("openAutofillMessagePanel"), + }, + mainAction: { + label: GetStringFromName("updateCreditCardLabel"), + accessKey: GetStringFromName("updateCreditCardAccessKey"), + callbackState: "update", + }, + secondaryActions: [ + { + label: GetStringFromName("createCreditCardLabel"), + accessKey: GetStringFromName("createCreditCardAccessKey"), + callbackState: "create", + }, + ], + options: { + persistWhileVisible: true, + popupIconURL: "chrome://formautofill/content/icon-credit-card.svg", + hideClose: true, + }, + }, +}; + +let FormAutofillDoorhanger = { + /** + * Generate the main action and secondary actions from content parameters and + * promise resolve. + * + * @private + * @param {Object} mainActionParams + * Parameters for main action. + * @param {Array<Object>} secondaryActionParams + * Array of the parameters for secondary actions. + * @param {Function} resolve Should be called in action callback. + * @returns {Array<Object>} + Return the mainAction and secondary actions in an array for showing doorhanger + */ + _createActions(mainActionParams, secondaryActionParams, resolve) { + if (!mainActionParams) { + return [null, null]; + } + + let { + label, + accessKey, + disableHighlight, + callbackState, + } = mainActionParams; + let callback = resolve.bind(null, callbackState); + let mainAction = { label, accessKey, callback, disableHighlight }; + + if (!secondaryActionParams) { + return [mainAction, null]; + } + + let secondaryActions = []; + for (let params of secondaryActionParams) { + let cb = resolve.bind(null, params.callbackState); + secondaryActions.push({ + label: params.label, + accessKey: params.accessKey, + callback: cb, + }); + } + + return [mainAction, secondaryActions]; + }, + _getNotificationElm(browser, id) { + let notificationId = id + "-notification"; + let chromeDoc = browser.ownerDocument; + return chromeDoc.getElementById(notificationId); + }, + /** + * Append the link label element to the popupnotificationcontent. + * @param {XULElement} content + * popupnotificationcontent + * @param {string} message + * The localized string for link title. + * @param {string} link + * Makes it possible to open and highlight a section in preferences + */ + _appendPrivacyPanelLink(content, message, link) { + let chromeDoc = content.ownerDocument; + let privacyLinkElement = chromeDoc.createXULElement("label", { + is: "text-link", + }); + privacyLinkElement.setAttribute("useoriginprincipal", true); + privacyLinkElement.setAttribute( + "href", + link || "about:preferences#privacy-form-autofill" + ); + privacyLinkElement.setAttribute("value", message); + content.appendChild(privacyLinkElement); + }, + + /** + * Append the description section to the popupnotificationcontent. + * @param {XULElement} content + * popupnotificationcontent + * @param {string} descriptionLabel + * The label showing above description. + * @param {string} descriptionIcon + * The src of description icon. + */ + _appendDescription(content, descriptionLabel, descriptionIcon) { + let chromeDoc = content.ownerDocument; + let docFragment = chromeDoc.createDocumentFragment(); + + let descriptionLabelElement = chromeDoc.createXULElement("label"); + descriptionLabelElement.setAttribute("value", descriptionLabel); + docFragment.appendChild(descriptionLabelElement); + + let descriptionWrapper = chromeDoc.createXULElement("hbox"); + descriptionWrapper.className = "desc-message-box"; + + if (descriptionIcon) { + let descriptionIconElement = chromeDoc.createXULElement("image"); + descriptionWrapper.appendChild(descriptionIconElement); + } + + let descriptionElement = chromeDoc.createXULElement("description"); + descriptionWrapper.appendChild(descriptionElement); + docFragment.appendChild(descriptionWrapper); + + content.appendChild(docFragment); + }, + + _updateDescription(content, description) { + content.querySelector("description").textContent = description; + }, + + /** + * Create an image element for notification anchor if it doesn't already exist. + * @param {XULElement} browser + * Target browser element for showing doorhanger. + * @param {Object} anchor + * Anchor options for setting the anchor element. + * @param {string} anchor.id + * ID of the anchor element. + * @param {string} anchor.URL + * Path of the icon asset. + * @param {string} anchor.tooltiptext + * Tooltip string for the anchor. + */ + _setAnchor(browser, anchor) { + let chromeDoc = browser.ownerDocument; + let { id, URL, tooltiptext } = anchor; + let anchorEt = chromeDoc.getElementById(id); + if (!anchorEt) { + let notificationPopupBox = chromeDoc.getElementById( + "notification-popup-box" + ); + // Icon shown on URL bar + let anchorElement = chromeDoc.createXULElement("image"); + anchorElement.id = id; + anchorElement.setAttribute("src", URL); + anchorElement.classList.add("notification-anchor-icon"); + anchorElement.setAttribute("role", "button"); + anchorElement.setAttribute("tooltiptext", tooltiptext); + notificationPopupBox.appendChild(anchorElement); + } + }, + _addCheckboxListener(browser, { notificationId, options }) { + if (!options.checkbox) { + return; + } + let { checkbox } = this._getNotificationElm(browser, notificationId); + + if (checkbox && !checkbox.hidden) { + checkbox.addEventListener("command", options.checkbox.callback); + } + }, + _removeCheckboxListener(browser, { notificationId, options }) { + if (!options.checkbox) { + return; + } + let { checkbox } = this._getNotificationElm(browser, notificationId); + + if (checkbox && !checkbox.hidden) { + checkbox.removeEventListener("command", options.checkbox.callback); + } + }, + /** + * Show different types of doorhanger by leveraging PopupNotifications. + * @param {XULElement} browser + * Target browser element for showing doorhanger. + * @param {string} type + * The type of the doorhanger. There will have first time use/update/credit card. + * @param {string} description + * The message that provides more information on doorhanger. + * @returns {Promise} + Resolved with action type when action callback is triggered. + */ + async show(browser, type, description) { + log.debug("show doorhanger with type:", type); + return new Promise(resolve => { + let { + notificationId, + message, + descriptionLabel, + descriptionIcon, + linkMessage, + spotlightURL, + anchor, + mainAction, + secondaryActions, + options, + } = CONTENT[type]; + + const { ownerGlobal: chromeWin, ownerDocument: chromeDoc } = browser; + options.eventCallback = topic => { + log.debug("eventCallback:", topic); + + if (topic == "removed" || topic == "dismissed") { + this._removeCheckboxListener(browser, { notificationId, options }); + return; + } + + // The doorhanger is customizable only when notification box is shown + if (topic != "shown") { + return; + } + this._addCheckboxListener(browser, { notificationId, options }); + + // There's no preferences link or other customization in first time use doorhanger. + if (type == "firstTimeUse") { + return; + } + + const notificationElementId = notificationId + "-notification"; + const notification = chromeDoc.getElementById(notificationElementId); + const notificationContent = + notification.querySelector("popupnotificationcontent") || + chromeDoc.createXULElement("popupnotificationcontent"); + if (!notification.contains(notificationContent)) { + notificationContent.setAttribute("orient", "vertical"); + this._appendDescription( + notificationContent, + descriptionLabel, + descriptionIcon + ); + this._appendPrivacyPanelLink( + notificationContent, + linkMessage, + spotlightURL + ); + notification.appendNotificationContent(notificationContent); + } + this._updateDescription(notificationContent, description); + }; + this._setAnchor(browser, anchor); + chromeWin.PopupNotifications.show( + browser, + notificationId, + message, + anchor.id, + ...this._createActions(mainAction, secondaryActions, resolve), + options + ); + }); + }, +}; diff --git a/browser/extensions/formautofill/FormAutofillHandler.jsm b/browser/extensions/formautofill/FormAutofillHandler.jsm new file mode 100644 index 0000000000..0bf6dfa496 --- /dev/null +++ b/browser/extensions/formautofill/FormAutofillHandler.jsm @@ -0,0 +1,1478 @@ +/* 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/. */ + +/* + * Defines a handler object to represent forms that autofill can handle. + */ + +"use strict"; + +var EXPORTED_SYMBOLS = ["FormAutofillHandler"]; + +const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); + +const { AppConstants } = ChromeUtils.import( + "resource://gre/modules/AppConstants.jsm" +); +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); +const { FormAutofill } = ChromeUtils.import( + "resource://formautofill/FormAutofill.jsm" +); + +ChromeUtils.defineModuleGetter( + this, + "FormAutofillUtils", + "resource://formautofill/FormAutofillUtils.jsm" +); +ChromeUtils.defineModuleGetter( + this, + "FormAutofillHeuristics", + "resource://formautofill/FormAutofillHeuristics.jsm" +); +ChromeUtils.defineModuleGetter( + this, + "FormLikeFactory", + "resource://gre/modules/FormLikeFactory.jsm" +); + +const formFillController = Cc[ + "@mozilla.org/satchel/form-fill-controller;1" +].getService(Ci.nsIFormFillController); + +XPCOMUtils.defineLazyGetter(this, "reauthPasswordPromptMessage", () => { + const brandShortName = FormAutofillUtils.brandBundle.GetStringFromName( + "brandShortName" + ); + // The string name for Mac is changed because the value needed updating. + const platform = AppConstants.platform.replace("macosx", "macos"); + return FormAutofillUtils.stringBundle.formatStringFromName( + `useCreditCardPasswordPrompt.${platform}`, + [brandShortName] + ); +}); + +XPCOMUtils.defineLazyModuleGetters(this, { + CreditCard: "resource://gre/modules/CreditCard.jsm", +}); + +XPCOMUtils.defineLazyServiceGetters(this, { + gUUIDGenerator: ["@mozilla.org/uuid-generator;1", "nsIUUIDGenerator"], +}); + +this.log = null; +FormAutofill.defineLazyLogGetter(this, EXPORTED_SYMBOLS[0]); + +const { FIELD_STATES } = FormAutofillUtils; + +class FormAutofillSection { + constructor(fieldDetails, winUtils) { + this.fieldDetails = fieldDetails; + this.filledRecordGUID = null; + this.winUtils = winUtils; + + /** + * Enum for form autofill MANUALLY_MANAGED_STATES values + */ + this._FIELD_STATE_ENUM = { + // not themed + [FIELD_STATES.NORMAL]: null, + // highlighted + [FIELD_STATES.AUTO_FILLED]: "autofill", + // highlighted && grey color text + [FIELD_STATES.PREVIEW]: "-moz-autofill-preview", + }; + + if (!this.isValidSection()) { + this.fieldDetails = []; + log.debug( + `Ignoring ${this.constructor.name} related fields since it is an invalid section` + ); + } + + this._cacheValue = { + allFieldNames: null, + matchingSelectOption: null, + }; + } + + /* + * Examine the section is a valid section or not based on its fieldDetails or + * other information. This method must be overrided. + * + * @returns {boolean} True for a valid section, otherwise false + * + */ + isValidSection() { + throw new TypeError("isValidSection method must be overrided"); + } + + /* + * Examine the section is an enabled section type or not based on its + * preferences. This method must be overrided. + * + * @returns {boolean} True for an enabled section type, otherwise false + * + */ + isEnabled() { + throw new TypeError("isEnabled method must be overrided"); + } + + /* + * Examine the section is createable for storing the profile. This method + * must be overrided. + * + * @param {Object} record The record for examining createable + * @returns {boolean} True for the record is createable, otherwise false + * + */ + isRecordCreatable(record) { + throw new TypeError("isRecordCreatable method must be overrided"); + } + + /** + * Override this method if the profile is needed to apply some transformers. + * + * @param {Object} profile + * A profile should be converted based on the specific requirement. + */ + applyTransformers(profile) {} + + /** + * Override this method if the profile is needed to be customized for + * previewing values. + * + * @param {Object} profile + * A profile for pre-processing before previewing values. + */ + preparePreviewProfile(profile) {} + + /** + * Override this method if the profile is needed to be customized for filling + * values. + * + * @param {Object} profile + * A profile for pre-processing before filling values. + * @returns {boolean} Whether the profile should be filled. + */ + async prepareFillingProfile(profile) { + return true; + } + + /* + * Override this methid if any data for `createRecord` is needed to be + * normailized before submitting the record. + * + * @param {Object} profile + * A record for normalization. + */ + normalizeCreatingRecord(data) {} + + /* + * Override this method if there is any field value needs to compute for a + * specific case. Return the original value in the default case. + * @param {String} value + * The original field value. + * @param {Object} fieldDetail + * A fieldDetail of the related element. + * @param {HTMLElement} element + * A element for checking converting value. + * + * @returns {String} + * A string of the converted value. + */ + computeFillingValue(value, fieldName, element) { + return value; + } + + set focusedInput(element) { + this._focusedDetail = this.getFieldDetailByElement(element); + } + + getFieldDetailByElement(element) { + return this.fieldDetails.find( + detail => detail.elementWeakRef.get() == element + ); + } + + get allFieldNames() { + if (!this._cacheValue.allFieldNames) { + this._cacheValue.allFieldNames = this.fieldDetails.map( + record => record.fieldName + ); + } + return this._cacheValue.allFieldNames; + } + + getFieldDetailByName(fieldName) { + return this.fieldDetails.find(detail => detail.fieldName == fieldName); + } + + matchSelectOptions(profile) { + if (!this._cacheValue.matchingSelectOption) { + this._cacheValue.matchingSelectOption = new WeakMap(); + } + + for (let fieldName in profile) { + let fieldDetail = this.getFieldDetailByName(fieldName); + if (!fieldDetail) { + continue; + } + + let element = fieldDetail.elementWeakRef.get(); + if (ChromeUtils.getClassName(element) !== "HTMLSelectElement") { + continue; + } + + let cache = this._cacheValue.matchingSelectOption.get(element) || {}; + let value = profile[fieldName]; + if (cache[value] && cache[value].get()) { + continue; + } + + let option = FormAutofillUtils.findSelectOption( + element, + profile, + fieldName + ); + if (option) { + cache[value] = Cu.getWeakReference(option); + this._cacheValue.matchingSelectOption.set(element, cache); + } else { + if (cache[value]) { + delete cache[value]; + this._cacheValue.matchingSelectOption.set(element, cache); + } + // Delete the field so the phishing hint won't treat it as a "also fill" + // field. + delete profile[fieldName]; + } + } + } + + adaptFieldMaxLength(profile) { + for (let key in profile) { + let detail = this.getFieldDetailByName(key); + if (!detail) { + continue; + } + + let element = detail.elementWeakRef.get(); + if (!element) { + continue; + } + + let maxLength = element.maxLength; + if ( + maxLength === undefined || + maxLength < 0 || + profile[key].toString().length <= maxLength + ) { + continue; + } + + if (maxLength) { + switch (typeof profile[key]) { + case "string": + // If this is an expiration field and our previous + // adaptations haven't resulted in a string that is + // short enough to satisfy the field length, and the + // field is constrained to a length of 5, then we + // assume it is intended to hold an expiration of the + // form "MM/YY". + if (key == "cc-exp" && maxLength == 5) { + const month2Digits = ( + "0" + profile["cc-exp-month"].toString() + ).slice(-2); + const year2Digits = profile["cc-exp-year"].toString().slice(-2); + profile[key] = `${month2Digits}/${year2Digits}`; + } else { + profile[key] = profile[key].substr(0, maxLength); + } + break; + case "number": + // There's no way to truncate a number smaller than a + // single digit. + if (maxLength < 1) { + maxLength = 1; + } + // The only numbers we store are expiration month/year, + // and if they truncate, we want the final digits, not + // the initial ones. + profile[key] = profile[key] % Math.pow(10, maxLength); + break; + default: + log.warn( + "adaptFieldMaxLength: Don't know how to truncate", + typeof profile[key], + profile[key] + ); + } + } else { + delete profile[key]; + } + } + } + + getAdaptedProfiles(originalProfiles) { + for (let profile of originalProfiles) { + this.applyTransformers(profile); + } + return originalProfiles; + } + + /** + * Processes form fields that can be autofilled, and populates them with the + * profile provided by backend. + * + * @param {Object} profile + * A profile to be filled in. + * @returns {boolean} + * True if successful, false if failed + */ + async autofillFields(profile) { + let focusedDetail = this._focusedDetail; + if (!focusedDetail) { + throw new Error("No fieldDetail for the focused input."); + } + + if (!(await this.prepareFillingProfile(profile))) { + log.debug("profile cannot be filled", profile); + return false; + } + log.debug("profile in autofillFields:", profile); + + let focusedInput = focusedDetail.elementWeakRef.get(); + + this.filledRecordGUID = profile.guid; + for (let fieldDetail of this.fieldDetails) { + // Avoid filling field value in the following cases: + // 1. a non-empty input field for an unfocused input + // 2. the invalid value set + // 3. value already chosen in select element + + let element = fieldDetail.elementWeakRef.get(); + if (!element) { + continue; + } + + element.previewValue = ""; + let value = profile[fieldDetail.fieldName]; + + if (ChromeUtils.getClassName(element) === "HTMLInputElement" && value) { + // For the focused input element, it will be filled with a valid value + // anyway. + // For the others, the fields should be only filled when their values + // are empty or are the result of an earlier auto-fill. + if ( + element == focusedInput || + (element != focusedInput && !element.value) || + fieldDetail.state == FIELD_STATES.AUTO_FILLED + ) { + element.focus({ preventScroll: true }); + element.setUserInput(value); + this._changeFieldState(fieldDetail, FIELD_STATES.AUTO_FILLED); + } + } else if (ChromeUtils.getClassName(element) === "HTMLSelectElement") { + let cache = this._cacheValue.matchingSelectOption.get(element) || {}; + let option = cache[value] && cache[value].get(); + if (!option) { + continue; + } + // Do not change value or dispatch events if the option is already selected. + // Use case for multiple select is not considered here. + if (!option.selected) { + option.selected = true; + element.focus({ preventScroll: true }); + element.dispatchEvent( + new element.ownerGlobal.Event("input", { bubbles: true }) + ); + element.dispatchEvent( + new element.ownerGlobal.Event("change", { bubbles: true }) + ); + } + // Autofill highlight appears regardless if value is changed or not + this._changeFieldState(fieldDetail, FIELD_STATES.AUTO_FILLED); + } + } + focusedInput.focus({ preventScroll: true }); + return true; + } + + /** + * Populates result to the preview layers with given profile. + * + * @param {Object} profile + * A profile to be previewed with + */ + previewFormFields(profile) { + log.debug("preview profile: ", profile); + + this.preparePreviewProfile(profile); + + for (let fieldDetail of this.fieldDetails) { + let element = fieldDetail.elementWeakRef.get(); + let value = profile[fieldDetail.fieldName] || ""; + + // Skip the field that is null + if (!element) { + continue; + } + + if (ChromeUtils.getClassName(element) === "HTMLSelectElement") { + // Unlike text input, select element is always previewed even if + // the option is already selected. + if (value) { + let cache = this._cacheValue.matchingSelectOption.get(element) || {}; + let option = cache[value] && cache[value].get(); + if (option) { + value = option.text || ""; + } else { + value = ""; + } + } + } else if (element.value) { + // Skip the field if it already has text entered. + continue; + } + element.previewValue = value; + this._changeFieldState( + fieldDetail, + value ? FIELD_STATES.PREVIEW : FIELD_STATES.NORMAL + ); + } + } + + /** + * Clear preview text and background highlight of all fields. + */ + clearPreviewedFormFields() { + log.debug("clear previewed fields in:", this.form); + + for (let fieldDetail of this.fieldDetails) { + let element = fieldDetail.elementWeakRef.get(); + if (!element) { + log.warn(fieldDetail.fieldName, "is unreachable"); + continue; + } + + element.previewValue = ""; + + // We keep the state if this field has + // already been auto-filled. + if (fieldDetail.state == FIELD_STATES.AUTO_FILLED) { + continue; + } + + this._changeFieldState(fieldDetail, FIELD_STATES.NORMAL); + } + } + + /** + * Clear value and highlight style of all filled fields. + */ + clearPopulatedForm() { + for (let fieldDetail of this.fieldDetails) { + let element = fieldDetail.elementWeakRef.get(); + if (!element) { + log.warn(fieldDetail.fieldName, "is unreachable"); + continue; + } + + // Only reset value for input element. + if ( + fieldDetail.state == FIELD_STATES.AUTO_FILLED && + ChromeUtils.getClassName(element) === "HTMLInputElement" + ) { + element.setUserInput(""); + } + } + } + + /** + * Change the state of a field to correspond with different presentations. + * + * @param {Object} fieldDetail + * A fieldDetail of which its element is about to update the state. + * @param {string} nextState + * Used to determine the next state + */ + _changeFieldState(fieldDetail, nextState) { + let element = fieldDetail.elementWeakRef.get(); + + if (!element) { + log.warn(fieldDetail.fieldName, "is unreachable while changing state"); + return; + } + if (!(nextState in this._FIELD_STATE_ENUM)) { + log.warn( + fieldDetail.fieldName, + "is trying to change to an invalid state" + ); + return; + } + if (fieldDetail.state == nextState) { + return; + } + + for (let [state, mmStateValue] of Object.entries(this._FIELD_STATE_ENUM)) { + // The NORMAL state is simply the absence of other manually + // managed states so we never need to add or remove it. + if (!mmStateValue) { + continue; + } + + if (state == nextState) { + this.winUtils.addManuallyManagedState(element, mmStateValue); + } else { + this.winUtils.removeManuallyManagedState(element, mmStateValue); + } + } + + if (nextState == FIELD_STATES.AUTO_FILLED) { + element.addEventListener("input", this, { mozSystemGroup: true }); + } + + fieldDetail.state = nextState; + } + + resetFieldStates() { + for (let fieldDetail of this.fieldDetails) { + const element = fieldDetail.elementWeakRef.get(); + element.removeEventListener("input", this, { mozSystemGroup: true }); + this._changeFieldState(fieldDetail, FIELD_STATES.NORMAL); + } + this.filledRecordGUID = null; + } + + isFilled() { + return !!this.filledRecordGUID; + } + + /** + * Return the record that is converted from `fieldDetails` and only valid + * form record is included. + * + * @returns {Object|null} + * A record object consists of three properties: + * - guid: The id of the previously-filled profile or null if omitted. + * - record: A valid record converted from details with trimmed result. + * - untouchedFields: Fields that aren't touched after autofilling. + * Return `null` for any uncreatable or invalid record. + */ + createRecord() { + let details = this.fieldDetails; + if (!this.isEnabled() || !details || !details.length) { + return null; + } + + let data = { + guid: this.filledRecordGUID, + record: {}, + untouchedFields: [], + }; + if (this.flowId) { + data.flowId = this.flowId; + } + + details.forEach(detail => { + let element = detail.elementWeakRef.get(); + // Remove the unnecessary spaces + let value = element && element.value.trim(); + value = this.computeFillingValue(value, detail, element); + + if (!value || value.length > FormAutofillUtils.MAX_FIELD_VALUE_LENGTH) { + // Keep the property and preserve more information for updating + data.record[detail.fieldName] = ""; + return; + } + + data.record[detail.fieldName] = value; + + if (detail.state == FIELD_STATES.AUTO_FILLED) { + data.untouchedFields.push(detail.fieldName); + } + }); + + this.normalizeCreatingRecord(data); + + if (!this.isRecordCreatable(data.record)) { + return null; + } + + return data; + } + + handleEvent(event) { + switch (event.type) { + case "input": { + if (!event.isTrusted) { + return; + } + const target = event.target; + const targetFieldDetail = this.getFieldDetailByElement(target); + const isCreditCardField = FormAutofillUtils.isCreditCardField( + targetFieldDetail.fieldName + ); + + // If the user manually blanks a credit card field, then + // we want the popup to be activated. + if ( + ChromeUtils.getClassName(target) !== "HTMLSelectElement" && + isCreditCardField && + target.value === "" + ) { + formFillController.showPopup(); + } + + if (targetFieldDetail.state == FIELD_STATES.NORMAL) { + return; + } + + this._changeFieldState(targetFieldDetail, FIELD_STATES.NORMAL); + + if (isCreditCardField) { + Services.telemetry.recordEvent( + "creditcard", + "filled_modified", + "cc_form", + this.flowId, + { + field_name: targetFieldDetail.fieldName, + } + ); + } + + let isAutofilled = false; + let dimFieldDetails = []; + for (const fieldDetail of this.fieldDetails) { + const element = fieldDetail.elementWeakRef.get(); + + if (ChromeUtils.getClassName(element) === "HTMLSelectElement") { + // Dim fields are those we don't attempt to revert their value + // when clear the target set, such as <select>. + dimFieldDetails.push(fieldDetail); + } else { + isAutofilled |= fieldDetail.state == FIELD_STATES.AUTO_FILLED; + } + } + if (!isAutofilled) { + // Restore the dim fields to initial state as well once we knew + // that user had intention to clear the filled form manually. + for (const fieldDetail of dimFieldDetails) { + this._changeFieldState(fieldDetail, FIELD_STATES.NORMAL); + } + this.filledRecordGUID = null; + } + break; + } + } + } +} + +class FormAutofillAddressSection extends FormAutofillSection { + constructor(fieldDetails, winUtils) { + super(fieldDetails, winUtils); + + this._cacheValue.oneLineStreetAddress = null; + } + + isValidSection() { + return ( + this.fieldDetails.length >= FormAutofillUtils.AUTOFILL_FIELDS_THRESHOLD + ); + } + + isEnabled() { + return FormAutofill.isAutofillAddressesEnabled; + } + + isRecordCreatable(record) { + if ( + record.country && + !FormAutofill.supportedCountries.includes(record.country) + ) { + // We don't want to save data in the wrong fields due to not having proper + // heuristic regexes in countries we don't yet support. + log.warn("isRecordCreatable: Country not supported:", record.country); + return false; + } + + let hasName = 0; + let length = 0; + for (let key of Object.keys(record)) { + if (!record[key]) { + continue; + } + if (FormAutofillUtils.getCategoryFromFieldName(key) == "name") { + hasName = 1; + continue; + } + length++; + } + return length + hasName >= FormAutofillUtils.AUTOFILL_FIELDS_THRESHOLD; + } + + _getOneLineStreetAddress(address) { + if (!this._cacheValue.oneLineStreetAddress) { + this._cacheValue.oneLineStreetAddress = {}; + } + if (!this._cacheValue.oneLineStreetAddress[address]) { + this._cacheValue.oneLineStreetAddress[ + address + ] = FormAutofillUtils.toOneLineAddress(address); + } + return this._cacheValue.oneLineStreetAddress[address]; + } + + addressTransformer(profile) { + if (profile["street-address"]) { + // "-moz-street-address-one-line" is used by the labels in + // ProfileAutoCompleteResult. + profile["-moz-street-address-one-line"] = this._getOneLineStreetAddress( + profile["street-address"] + ); + let streetAddressDetail = this.getFieldDetailByName("street-address"); + if ( + streetAddressDetail && + ChromeUtils.getClassName(streetAddressDetail.elementWeakRef.get()) === + "HTMLInputElement" + ) { + profile["street-address"] = profile["-moz-street-address-one-line"]; + } + + let waitForConcat = []; + for (let f of ["address-line3", "address-line2", "address-line1"]) { + waitForConcat.unshift(profile[f]); + if (this.getFieldDetailByName(f)) { + if (waitForConcat.length > 1) { + profile[f] = FormAutofillUtils.toOneLineAddress(waitForConcat); + } + waitForConcat = []; + } + } + } + } + + /** + * Replace tel with tel-national if tel violates the input element's + * restriction. + * @param {Object} profile + * A profile to be converted. + */ + telTransformer(profile) { + if (!profile.tel || !profile["tel-national"]) { + return; + } + + let detail = this.getFieldDetailByName("tel"); + if (!detail) { + return; + } + + let element = detail.elementWeakRef.get(); + let _pattern; + let testPattern = str => { + if (!_pattern) { + // The pattern has to match the entire value. + _pattern = new RegExp("^(?:" + element.pattern + ")$", "u"); + } + return _pattern.test(str); + }; + if (element.pattern) { + if (testPattern(profile.tel)) { + return; + } + } else if (element.maxLength) { + if ( + detail._reason == "autocomplete" && + profile.tel.length <= element.maxLength + ) { + return; + } + } + + if (detail._reason != "autocomplete") { + // Since we only target people living in US and using en-US websites in + // MVP, it makes more sense to fill `tel-national` instead of `tel` + // if the field is identified by heuristics and no other clues to + // determine which one is better. + // TODO: [Bug 1407545] This should be improved once more countries are + // supported. + profile.tel = profile["tel-national"]; + } else if (element.pattern) { + if (testPattern(profile["tel-national"])) { + profile.tel = profile["tel-national"]; + } + } else if (element.maxLength) { + if (profile["tel-national"].length <= element.maxLength) { + profile.tel = profile["tel-national"]; + } + } + } + + /* + * Apply all address related transformers. + * + * @param {Object} profile + * A profile for adjusting address related value. + * @override + */ + applyTransformers(profile) { + this.addressTransformer(profile); + this.telTransformer(profile); + this.matchSelectOptions(profile); + this.adaptFieldMaxLength(profile); + } + + computeFillingValue(value, fieldDetail, element) { + // Try to abbreviate the value of select element. + if ( + fieldDetail.fieldName == "address-level1" && + ChromeUtils.getClassName(element) === "HTMLSelectElement" + ) { + // Don't save the record when the option value is empty *OR* there + // are multiple options being selected. The empty option is usually + // assumed to be default along with a meaningless text to users. + if (!value || element.selectedOptions.length != 1) { + // Keep the property and preserve more information for address updating + value = ""; + } else { + let text = element.selectedOptions[0].text.trim(); + value = + FormAutofillUtils.getAbbreviatedSubregionName([value, text]) || text; + } + } + return value; + } + + normalizeCreatingRecord(address) { + if (!address) { + return; + } + + // Normalize Country + if (address.record.country) { + let detail = this.getFieldDetailByName("country"); + // Try identifying country field aggressively if it doesn't come from + // @autocomplete. + if (detail._reason != "autocomplete") { + let countryCode = FormAutofillUtils.identifyCountryCode( + address.record.country + ); + if (countryCode) { + address.record.country = countryCode; + } + } + } + + // Normalize Tel + FormAutofillUtils.compressTel(address.record); + if (address.record.tel) { + let allTelComponentsAreUntouched = Object.keys(address.record) + .filter( + field => FormAutofillUtils.getCategoryFromFieldName(field) == "tel" + ) + .every(field => address.untouchedFields.includes(field)); + if (allTelComponentsAreUntouched) { + // No need to verify it if none of related fields are modified after autofilling. + if (!address.untouchedFields.includes("tel")) { + address.untouchedFields.push("tel"); + } + } else { + let strippedNumber = address.record.tel.replace(/[\s\(\)-]/g, ""); + + // Remove "tel" if it contains invalid characters or the length of its + // number part isn't between 5 and 15. + // (The maximum length of a valid number in E.164 format is 15 digits + // according to https://en.wikipedia.org/wiki/E.164 ) + if (!/^(\+?)[\da-zA-Z]{5,15}$/.test(strippedNumber)) { + address.record.tel = ""; + } + } + } + } +} + +class FormAutofillCreditCardSection extends FormAutofillSection { + /** + * Credit Card Section Constructor + * + * @param {Object} fieldDetails + * The fieldDetail objects for the fields in this section + * @param {Object} winUtils + * A WindowUtils reference for the Window the section appears in + * @param {Object} handler + * The FormAutofillHandler responsible for this section + */ + constructor(fieldDetails, winUtils, handler) { + super(fieldDetails, winUtils); + + this.handler = handler; + + // Identifier used to correlate events relating to the same form + this.flowId = gUUIDGenerator.generateUUID().toString(); + log.debug("Creating new credit card section with flowId =", this.flowId); + + if (!this.isValidSection()) { + return; + } + + // Record which fields could be identified + let identified = new Set(); + fieldDetails.forEach(detail => identified.add(detail.fieldName)); + Services.telemetry.recordEvent( + "creditcard", + "detected", + "cc_form", + this.flowId, + { + cc_name_found: identified.has("cc-name") ? "true" : "false", + cc_number_found: identified.has("cc-number") ? "true" : "false", + cc_exp_found: + identified.has("cc-exp") || + (identified.has("cc-exp-month") && identified.has("cc-exp-year")) + ? "true" + : "false", + } + ); + Services.telemetry.scalarAdd( + "formautofill.creditCards.detected_sections_count", + 1 + ); + + // Check whether the section is in an <iframe>; and, if so, + // watch for the <iframe> to pagehide. + if (handler.window.location != handler.window.parent?.location) { + log.debug( + "Credit card form is in an iframe -- watching for pagehide", + fieldDetails + ); + handler.window.addEventListener( + "pagehide", + this._handlePageHide.bind(this) + ); + } + } + + _handlePageHide(event) { + this.handler.window.removeEventListener( + "pagehide", + this._handlePageHide.bind(this) + ); + log.debug("Credit card subframe is pagehideing", this.handler.form); + this.handler.onFormSubmitted(); + } + + isValidSection() { + let ccNumberReason = ""; + let hasCCNumber = false; + let hasExpiryDate = false; + let hasCCName = false; + + for (let detail of this.fieldDetails) { + switch (detail.fieldName) { + case "cc-number": + hasCCNumber = true; + ccNumberReason = detail._reason; + break; + case "cc-name": + case "cc-given-name": + case "cc-additional-name": + case "cc-family-name": + hasCCName = true; + break; + case "cc-exp": + case "cc-exp-month": + case "cc-exp-year": + hasExpiryDate = true; + break; + } + } + + return ( + hasCCNumber && + (ccNumberReason == "autocomplete" || hasExpiryDate || hasCCName) + ); + } + + isEnabled() { + return FormAutofill.isAutofillCreditCardsEnabled; + } + + isRecordCreatable(record) { + return ( + record["cc-number"] && FormAutofillUtils.isCCNumber(record["cc-number"]) + ); + } + + creditCardExpDateTransformer(profile) { + if (!profile["cc-exp"]) { + return; + } + + let detail = this.getFieldDetailByName("cc-exp"); + if (!detail) { + return; + } + + let element = detail.elementWeakRef.get(); + if (element.tagName != "INPUT" || !element.placeholder) { + return; + } + + let result, + ccExpMonth = profile["cc-exp-month"], + ccExpYear = profile["cc-exp-year"], + placeholder = element.placeholder; + + result = /(?:[^m]|\b)(m{1,2})\s*([-/\\]*)\s*(y{2,4})(?!y)/i.exec( + placeholder + ); + if (result) { + profile["cc-exp"] = + String(ccExpMonth).padStart(result[1].length, "0") + + result[2] + + String(ccExpYear).substr(-1 * result[3].length); + return; + } + + result = /(?:[^y]|\b)(y{2,4})\s*([-/\\]*)\s*(m{1,2})(?!m)/i.exec( + placeholder + ); + if (result) { + profile["cc-exp"] = + String(ccExpYear).substr(-1 * result[1].length) + + result[2] + + String(ccExpMonth).padStart(result[3].length, "0"); + } + } + + async _decrypt(cipherText, reauth) { + // Get the window for the form field. + let window; + for (let fieldDetail of this.fieldDetails) { + let element = fieldDetail.elementWeakRef.get(); + if (element) { + window = element.ownerGlobal; + break; + } + } + if (!window) { + return null; + } + + let actor = window.windowGlobalChild.getActor("FormAutofill"); + return actor.sendQuery("FormAutofill:GetDecryptedString", { + cipherText, + reauth, + }); + } + + /* + * Apply all credit card related transformers. + * + * @param {Object} profile + * A profile for adjusting credit card related value. + * @override + */ + applyTransformers(profile) { + this.matchSelectOptions(profile); + this.creditCardExpDateTransformer(profile); + this.adaptFieldMaxLength(profile); + } + + computeFillingValue(value, fieldDetail, element) { + if ( + fieldDetail.fieldName != "cc-type" || + ChromeUtils.getClassName(element) !== "HTMLSelectElement" + ) { + return value; + } + + if (CreditCard.isValidNetwork(value)) { + return value; + } + + // Don't save the record when the option value is empty *OR* there + // are multiple options being selected. The empty option is usually + // assumed to be default along with a meaningless text to users. + if (value && element.selectedOptions.length == 1) { + let selectedOption = element.selectedOptions[0]; + let networkType = + CreditCard.getNetworkFromName(selectedOption.text) ?? + CreditCard.getNetworkFromName(selectedOption.value); + if (networkType) { + return networkType; + } + } + // If we couldn't match the value to any network, we'll + // strip this field when submitting. + return value; + } + + /** + * Customize for previewing prorifle. + * + * @param {Object} profile + * A profile for pre-processing before previewing values. + * @override + */ + preparePreviewProfile(profile) { + // Always show the decrypted credit card number when Master Password is + // disabled. + if (profile["cc-number-decrypted"]) { + profile["cc-number"] = profile["cc-number-decrypted"]; + } + } + + /** + * Customize for filling prorifle. + * + * @param {Object} profile + * A profile for pre-processing before filling values. + * @returns {boolean} Whether the profile should be filled. + * @override + */ + async prepareFillingProfile(profile) { + // Prompt the OS login dialog to get the decrypted credit + // card number. + if (profile["cc-number-encrypted"]) { + let decrypted = await this._decrypt( + profile["cc-number-encrypted"], + reauthPasswordPromptMessage + ); + + if (!decrypted) { + // Early return if the decrypted is empty or undefined + return false; + } + + profile["cc-number"] = decrypted; + } + return true; + } + + async autofillFields(profile) { + if (!(await super.autofillFields(profile))) { + return false; + } + + // Calculate values for telemetry + let extra = { + cc_name: "unavailable", + cc_number: "unavailable", + cc_exp: "unavailable", + }; + + for (let fieldDetail of this.fieldDetails) { + let element = fieldDetail.elementWeakRef.get(); + let state = profile[fieldDetail.fieldName] ? "filled" : "not_filled"; + + if ( + fieldDetail.state == FIELD_STATES.NORMAL && + (ChromeUtils.getClassName(element) == "HTMLSelectElement" || + (ChromeUtils.getClassName(element) == "HTMLInputElement" && + element.value.length)) + ) { + state = "user_filled"; + } + switch (fieldDetail.fieldName) { + case "cc-name": + extra.cc_name = state; + break; + case "cc-number": + extra.cc_number = state; + break; + case "cc-exp": + case "cc-exp-month": + case "cc-exp-year": + extra.cc_exp = state; + break; + } + } + Services.telemetry.recordEvent( + "creditcard", + "filled", + "cc_form", + this.flowId, + extra + ); + return true; + } +} + +/** + * Handles profile autofill for a DOM Form element. + */ +class FormAutofillHandler { + /** + * Initialize the form from `FormLike` object to handle the section or form + * operations. + * @param {FormLike} form Form that need to be auto filled + * @param {function} onFormSubmitted Function that can be invoked + * to simulate form submission. Function is passed + * three arguments: (1) a FormLike for the form being + * submitted, (2) the corresponding Window, and (3) the + * responsible FormAutofillHandler. + */ + constructor(form, onFormSubmitted = () => {}) { + this._updateForm(form); + + /** + * The window to which this form belongs + */ + this.window = this.form.rootElement.ownerGlobal; + + /** + * A WindowUtils reference of which Window the form belongs + */ + this.winUtils = this.window.windowUtils; + + /** + * Time in milliseconds since epoch when a user started filling in the form. + */ + this.timeStartedFillingMS = null; + + /** + * This function is used if the form handler (or one of its sections) + * determines that it needs to act as if the form had been submitted. + */ + this.onFormSubmitted = () => { + onFormSubmitted(this.form, this.window, this); + }; + } + + set focusedInput(element) { + let section = this._sectionCache.get(element); + if (!section) { + section = this.sections.find(s => s.getFieldDetailByElement(element)); + this._sectionCache.set(element, section); + } + + this._focusedSection = section; + + if (section) { + section.focusedInput = element; + } + } + + get activeSection() { + return this._focusedSection; + } + + /** + * Check the form is necessary to be updated. This function should be able to + * detect any changes including all control elements in the form. + * @param {HTMLElement} element The element supposed to be in the form. + * @returns {boolean} FormAutofillHandler.form is updated or not. + */ + updateFormIfNeeded(element) { + // When the following condition happens, FormAutofillHandler.form should be + // updated: + // * The count of form controls is changed. + // * When the element can not be found in the current form. + // + // However, we should improve the function to detect the element changes. + // e.g. a tel field is changed from type="hidden" to type="tel". + + let _formLike; + let getFormLike = () => { + if (!_formLike) { + _formLike = FormLikeFactory.createFromField(element); + } + return _formLike; + }; + + let currentForm = element.form; + if (!currentForm) { + currentForm = getFormLike(); + } + + if (currentForm.elements.length != this.form.elements.length) { + log.debug("The count of form elements is changed."); + this._updateForm(getFormLike()); + return true; + } + + if (!this.form.elements.includes(element)) { + log.debug("The element can not be found in the current form."); + this._updateForm(getFormLike()); + return true; + } + + return false; + } + + /** + * Update the form with a new FormLike, and the related fields should be + * updated or clear to ensure the data consistency. + * @param {FormLike} form a new FormLike to replace the original one. + */ + _updateForm(form) { + /** + * DOM Form element to which this object is attached. + */ + this.form = form; + + /** + * Array of collected data about relevant form fields. Each item is an object + * storing the identifying details of the field and a reference to the + * originally associated element from the form. + * + * The "section", "addressType", "contactType", and "fieldName" values are + * used to identify the exact field when the serializable data is received + * from the backend. There cannot be multiple fields which have + * the same exact combination of these values. + * + * A direct reference to the associated element cannot be sent to the user + * interface because processing may be done in the parent process. + */ + this.fieldDetails = null; + + this.sections = []; + this._sectionCache = new WeakMap(); + } + + /** + * Set fieldDetails from the form about fields that can be autofilled. + * + * @param {boolean} allowDuplicates + * true to remain any duplicated field details otherwise to remove the + * duplicated ones. + * @returns {Array} The valid address and credit card details. + */ + collectFormFields(allowDuplicates = false) { + let sections = FormAutofillHeuristics.getFormInfo( + this.form, + allowDuplicates + ); + let allValidDetails = []; + for (let { fieldDetails, type } of sections) { + let section; + if (type == FormAutofillUtils.SECTION_TYPES.ADDRESS) { + section = new FormAutofillAddressSection(fieldDetails, this.winUtils); + } else if (type == FormAutofillUtils.SECTION_TYPES.CREDIT_CARD) { + section = new FormAutofillCreditCardSection( + fieldDetails, + this.winUtils, + this + ); + } else { + throw new Error("Unknown field type."); + } + this.sections.push(section); + allValidDetails.push(...section.fieldDetails); + } + + for (let detail of allValidDetails) { + let input = detail.elementWeakRef.get(); + if (!input) { + continue; + } + input.addEventListener("input", this, { mozSystemGroup: true }); + } + + this.fieldDetails = allValidDetails; + return allValidDetails; + } + + _hasFilledSection() { + return this.sections.some(section => section.isFilled()); + } + + /** + * Processes form fields that can be autofilled, and populates them with the + * profile provided by backend. + * + * @param {Object} profile + * A profile to be filled in. + */ + async autofillFormFields(profile) { + let noFilledSectionsPreviously = !this._hasFilledSection(); + await this.activeSection.autofillFields(profile); + + const onChangeHandler = e => { + if (!e.isTrusted) { + return; + } + if (e.type == "reset") { + for (let section of this.sections) { + section.resetFieldStates(); + } + } + // Unregister listeners once no field is in AUTO_FILLED state. + if (!this._hasFilledSection()) { + this.form.rootElement.removeEventListener("input", onChangeHandler, { + mozSystemGroup: true, + }); + this.form.rootElement.removeEventListener("reset", onChangeHandler, { + mozSystemGroup: true, + }); + } + }; + + if (noFilledSectionsPreviously) { + // Handle the highlight style resetting caused by user's correction afterward. + log.debug("register change handler for filled form:", this.form); + this.form.rootElement.addEventListener("input", onChangeHandler, { + mozSystemGroup: true, + }); + this.form.rootElement.addEventListener("reset", onChangeHandler, { + mozSystemGroup: true, + }); + } + } + + handleEvent(event) { + switch (event.type) { + case "input": + if (!event.isTrusted) { + return; + } + + for (let detail of this.fieldDetails) { + let input = detail.elementWeakRef.get(); + if (!input) { + continue; + } + input.removeEventListener("input", this, { mozSystemGroup: true }); + } + this.timeStartedFillingMS = Date.now(); + break; + } + } + + /** + * Collect the filled sections within submitted form and convert all the valid + * field data into multiple records. + * + * @returns {Object} records + * {Array.<Object>} records.address + * {Array.<Object>} records.creditCard + */ + createRecords() { + const records = { + address: [], + creditCard: [], + }; + + for (const section of this.sections) { + const secRecord = section.createRecord(); + if (!secRecord) { + continue; + } + if (section instanceof FormAutofillAddressSection) { + records.address.push(secRecord); + } else if (section instanceof FormAutofillCreditCardSection) { + records.creditCard.push(secRecord); + } else { + throw new Error("Unknown section type"); + } + } + log.debug("Create records:", records); + return records; + } +} diff --git a/browser/extensions/formautofill/FormAutofillHeuristics.jsm b/browser/extensions/formautofill/FormAutofillHeuristics.jsm new file mode 100644 index 0000000000..158785ace4 --- /dev/null +++ b/browser/extensions/formautofill/FormAutofillHeuristics.jsm @@ -0,0 +1,1280 @@ +/* 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/. */ + +/* + * Form Autofill field heuristics. + */ + +"use strict"; + +var EXPORTED_SYMBOLS = ["FormAutofillHeuristics", "LabelUtils"]; + +const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); +const { FormAutofill } = ChromeUtils.import( + "resource://formautofill/FormAutofill.jsm" +); +ChromeUtils.defineModuleGetter( + this, + "FormAutofillUtils", + "resource://formautofill/FormAutofillUtils.jsm" +); + +XPCOMUtils.defineLazyModuleGetters(this, { + CreditCard: "resource://gre/modules/CreditCard.jsm", +}); + +this.log = null; +FormAutofill.defineLazyLogGetter(this, EXPORTED_SYMBOLS[0]); + +const PREF_HEURISTICS_ENABLED = "extensions.formautofill.heuristics.enabled"; +const PREF_SECTION_ENABLED = "extensions.formautofill.section.enabled"; +const DEFAULT_SECTION_NAME = "-moz-section-default"; + +/** + * To help us classify sections, we want to know what fields can appear + * multiple times in a row. + * Such fields, like `address-line{X}`, should not break sections. + */ +const MULTI_FIELD_NAMES = [ + "address-level3", + "address-level2", + "address-level1", + "tel", + "postal-code", + "email", + "street-address", +]; + +/** + * A scanner for traversing all elements in a form and retrieving the field + * detail with FormAutofillHeuristics.getInfo function. It also provides a + * cursor (parsingIndex) to indicate which element is waiting for parsing. + */ +class FieldScanner { + /** + * Create a FieldScanner based on form elements with the existing + * fieldDetails. + * + * @param {Array.DOMElement} elements + * The elements from a form for each parser. + */ + constructor(elements, { allowDuplicates = false, sectionEnabled = true }) { + this._elementsWeakRef = Cu.getWeakReference(elements); + this.fieldDetails = []; + this._parsingIndex = 0; + this._sections = []; + this._allowDuplicates = allowDuplicates; + this._sectionEnabled = sectionEnabled; + } + + get _elements() { + return this._elementsWeakRef.get(); + } + + /** + * This cursor means the index of the element which is waiting for parsing. + * + * @returns {number} + * The index of the element which is waiting for parsing. + */ + get parsingIndex() { + return this._parsingIndex; + } + + /** + * Move the parsingIndex to the next elements. Any elements behind this index + * means the parsing tasks are finished. + * + * @param {number} index + * The latest index of elements waiting for parsing. + */ + set parsingIndex(index) { + if (index > this._elements.length) { + throw new Error("The parsing index is out of range."); + } + this._parsingIndex = index; + } + + /** + * Retrieve the field detail by the index. If the field detail is not ready, + * the elements will be traversed until matching the index. + * + * @param {number} index + * The index of the element that you want to retrieve. + * @returns {Object} + * The field detail at the specific index. + */ + getFieldDetailByIndex(index) { + if (index >= this._elements.length) { + throw new Error( + `The index ${index} is out of range.(${this._elements.length})` + ); + } + + if (index < this.fieldDetails.length) { + return this.fieldDetails[index]; + } + + for (let i = this.fieldDetails.length; i < index + 1; i++) { + this.pushDetail(); + } + + return this.fieldDetails[index]; + } + + get parsingFinished() { + return this.parsingIndex >= this._elements.length; + } + + _pushToSection(name, fieldDetail) { + for (let section of this._sections) { + if (section.name == name) { + section.fieldDetails.push(fieldDetail); + return; + } + } + this._sections.push({ + name, + fieldDetails: [fieldDetail], + }); + } + + _classifySections() { + let fieldDetails = this._sections[0].fieldDetails; + this._sections = []; + let seenTypes = new Set(); + let previousType; + let sectionCount = 0; + + for (let fieldDetail of fieldDetails) { + if (!fieldDetail.fieldName) { + continue; + } + if ( + seenTypes.has(fieldDetail.fieldName) && + (previousType != fieldDetail.fieldName || + !MULTI_FIELD_NAMES.includes(fieldDetail.fieldName)) + ) { + seenTypes.clear(); + sectionCount++; + } + previousType = fieldDetail.fieldName; + seenTypes.add(fieldDetail.fieldName); + this._pushToSection( + DEFAULT_SECTION_NAME + "-" + sectionCount, + fieldDetail + ); + } + } + + /** + * The result is an array contains the sections with its belonging field + * details. If `this._sections` contains one section only with the default + * section name (DEFAULT_SECTION_NAME), `this._classifySections` should be + * able to identify all sections in the heuristic way. + * + * @returns {Array<Object>} + * The array with the sections, and the belonging fieldDetails are in + * each section. + */ + getSectionFieldDetails() { + // When the section feature is disabled, `getSectionFieldDetails` should + // provide a single address and credit card section result. + if (!this._sectionEnabled) { + return this._getFinalDetails(this.fieldDetails); + } + if (!this._sections.length) { + return []; + } + if ( + this._sections.length == 1 && + this._sections[0].name == DEFAULT_SECTION_NAME + ) { + this._classifySections(); + } + + return this._sections.reduce((sections, current) => { + sections.push(...this._getFinalDetails(current.fieldDetails)); + return sections; + }, []); + } + + /** + * This function will prepare an autocomplete info object with getInfo + * function and push the detail to fieldDetails property. + * Any field will be pushed into `this._sections` based on the section name + * in `autocomplete` attribute. + * + * Any element without the related detail will be used for adding the detail + * to the end of field details. + */ + pushDetail() { + let elementIndex = this.fieldDetails.length; + if (elementIndex >= this._elements.length) { + throw new Error("Try to push the non-existing element info."); + } + let element = this._elements[elementIndex]; + let info = FormAutofillHeuristics.getInfo(element); + let fieldInfo = { + section: info ? info.section : "", + addressType: info ? info.addressType : "", + contactType: info ? info.contactType : "", + fieldName: info ? info.fieldName : "", + elementWeakRef: Cu.getWeakReference(element), + }; + + if (info && info._reason) { + fieldInfo._reason = info._reason; + } + + this.fieldDetails.push(fieldInfo); + this._pushToSection(this._getSectionName(fieldInfo), fieldInfo); + } + + _getSectionName(info) { + let names = []; + if (info.section) { + names.push(info.section); + } + if (info.addressType) { + names.push(info.addressType); + } + return names.length ? names.join(" ") : DEFAULT_SECTION_NAME; + } + + /** + * When a field detail should be changed its fieldName after parsing, use + * this function to update the fieldName which is at a specific index. + * + * @param {number} index + * The index indicates a field detail to be updated. + * @param {string} fieldName + * The new fieldName + */ + updateFieldName(index, fieldName) { + if (index >= this.fieldDetails.length) { + throw new Error("Try to update the non-existing field detail."); + } + this.fieldDetails[index].fieldName = fieldName; + } + + _isSameField(field1, field2) { + return ( + field1.section == field2.section && + field1.addressType == field2.addressType && + field1.fieldName == field2.fieldName + ); + } + + /** + * Provide the final field details without invalid field name, and the + * duplicated fields will be removed as well. For the debugging purpose, + * the final `fieldDetails` will include the duplicated fields if + * `_allowDuplicates` is true. + * + * Each item should contain one type of fields only, and the two valid types + * are Address and CreditCard. + * + * @param {Array<Object>} fieldDetails + * The field details for trimming. + * @returns {Array<Object>} + * The array with the field details without invalid field name and + * duplicated fields. + */ + _getFinalDetails(fieldDetails) { + let addressFieldDetails = []; + let creditCardFieldDetails = []; + for (let fieldDetail of fieldDetails) { + let fieldName = fieldDetail.fieldName; + if (FormAutofillUtils.isAddressField(fieldName)) { + addressFieldDetails.push(fieldDetail); + } else if (FormAutofillUtils.isCreditCardField(fieldName)) { + creditCardFieldDetails.push(fieldDetail); + } else { + log.debug( + "Not collecting a field with a unknown fieldName", + fieldDetail + ); + } + } + + return [ + { + type: FormAutofillUtils.SECTION_TYPES.ADDRESS, + fieldDetails: addressFieldDetails, + }, + { + type: FormAutofillUtils.SECTION_TYPES.CREDIT_CARD, + fieldDetails: creditCardFieldDetails, + }, + ] + .map(section => { + if (this._allowDuplicates) { + return section; + } + // Deduplicate each set of fieldDetails + let details = section.fieldDetails; + section.fieldDetails = details.filter((detail, index) => { + let previousFields = details.slice(0, index); + return !previousFields.find(f => this._isSameField(detail, f)); + }); + return section; + }) + .filter(section => !!section.fieldDetails.length); + } + + elementExisting(index) { + return index < this._elements.length; + } +} + +var LabelUtils = { + // The tag name list is from Chromium except for "STYLE": + // eslint-disable-next-line max-len + // https://cs.chromium.org/chromium/src/components/autofill/content/renderer/form_autofill_util.cc?l=216&rcl=d33a171b7c308a64dc3372fac3da2179c63b419e + EXCLUDED_TAGS: ["SCRIPT", "NOSCRIPT", "OPTION", "STYLE"], + + // A map object, whose keys are the id's of form fields and each value is an + // array consisting of label elements correponding to the id. + // @type {Map<string, array>} + _mappedLabels: null, + + // An array consisting of label elements whose correponding form field doesn't + // have an id attribute. + // @type {Array<HTMLLabelElement>} + _unmappedLabels: null, + + // A weak map consisting of label element and extracted strings pairs. + // @type {WeakMap<HTMLLabelElement, array>} + _labelStrings: null, + + /** + * Extract all strings of an element's children to an array. + * "element.textContent" is a string which is merged of all children nodes, + * and this function provides an array of the strings contains in an element. + * + * @param {Object} element + * A DOM element to be extracted. + * @returns {Array} + * All strings in an element. + */ + extractLabelStrings(element) { + if (this._labelStrings.has(element)) { + return this._labelStrings.get(element); + } + let strings = []; + let _extractLabelStrings = el => { + if (this.EXCLUDED_TAGS.includes(el.tagName)) { + return; + } + + if (el.nodeType == el.TEXT_NODE || !el.childNodes.length) { + let trimmedText = el.textContent.trim(); + if (trimmedText) { + strings.push(trimmedText); + } + return; + } + + for (let node of el.childNodes) { + let nodeType = node.nodeType; + if (nodeType != node.ELEMENT_NODE && nodeType != node.TEXT_NODE) { + continue; + } + _extractLabelStrings(node); + } + }; + _extractLabelStrings(element); + this._labelStrings.set(element, strings); + return strings; + }, + + generateLabelMap(doc) { + let mappedLabels = new Map(); + let unmappedLabels = []; + + for (let label of doc.querySelectorAll("label")) { + let id = label.htmlFor; + if (!id) { + let control = label.control; + if (!control) { + continue; + } + id = control.id; + } + if (id) { + let labels = mappedLabels.get(id); + if (labels) { + labels.push(label); + } else { + mappedLabels.set(id, [label]); + } + } else { + unmappedLabels.push(label); + } + } + + this._mappedLabels = mappedLabels; + this._unmappedLabels = unmappedLabels; + this._labelStrings = new WeakMap(); + }, + + clearLabelMap() { + this._mappedLabels = null; + this._unmappedLabels = null; + this._labelStrings = null; + }, + + findLabelElements(element) { + if (!this._mappedLabels) { + this.generateLabelMap(element.ownerDocument); + } + + let id = element.id; + if (!id) { + return this._unmappedLabels.filter(label => label.control == element); + } + return this._mappedLabels.get(id) || []; + }, +}; + +/** + * Returns the autocomplete information of fields according to heuristics. + */ +this.FormAutofillHeuristics = { + RULES: null, + + /** + * Try to find a contiguous sub-array within an array. + * + * @param {Array} array + * @param {Array} subArray + * + * @returns {boolean} + * Return whether subArray was found within the array or not. + */ + _matchContiguousSubArray(array, subArray) { + return array.some((elm, i) => + subArray.every((sElem, j) => sElem == array[i + j]) + ); + }, + + /** + * Try to find the field that is look like a month select. + * + * @param {DOMElement} element + * @returns {boolean} + * Return true if we observe the trait of month select in + * the current element. + */ + _isExpirationMonthLikely(element) { + if (ChromeUtils.getClassName(element) !== "HTMLSelectElement") { + return false; + } + + const options = [...element.options]; + const desiredValues = Array(12) + .fill(1) + .map((v, i) => v + i); + + // The number of month options shouldn't be less than 12 or larger than 13 + // including the default option. + if (options.length < 12 || options.length > 13) { + return false; + } + + return ( + this._matchContiguousSubArray( + options.map(e => +e.value), + desiredValues + ) || + this._matchContiguousSubArray( + options.map(e => +e.label), + desiredValues + ) + ); + }, + + /** + * Try to find the field that is look like a year select. + * + * @param {DOMElement} element + * @returns {boolean} + * Return true if we observe the trait of year select in + * the current element. + */ + _isExpirationYearLikely(element) { + if (ChromeUtils.getClassName(element) !== "HTMLSelectElement") { + return false; + } + + const options = [...element.options]; + // A normal expiration year select should contain at least the last three years + // in the list. + const curYear = new Date().getFullYear(); + const desiredValues = Array(3) + .fill(0) + .map((v, i) => v + curYear + i); + + return ( + this._matchContiguousSubArray( + options.map(e => +e.value), + desiredValues + ) || + this._matchContiguousSubArray( + options.map(e => +e.label), + desiredValues + ) + ); + }, + + /** + * Try to match the telephone related fields to the grammar + * list to see if there is any valid telephone set and correct their + * field names. + * + * @param {FieldScanner} fieldScanner + * The current parsing status for all elements + * @returns {boolean} + * Return true if there is any field can be recognized in the parser, + * otherwise false. + */ + _parsePhoneFields(fieldScanner) { + let matchingResult; + + const GRAMMARS = this.PHONE_FIELD_GRAMMARS; + for (let i = 0; i < GRAMMARS.length; i++) { + let detailStart = fieldScanner.parsingIndex; + let ruleStart = i; + for ( + ; + i < GRAMMARS.length && + GRAMMARS[i][0] && + fieldScanner.elementExisting(detailStart); + i++, detailStart++ + ) { + let detail = fieldScanner.getFieldDetailByIndex(detailStart); + if ( + !detail || + GRAMMARS[i][0] != detail.fieldName || + (detail._reason && detail._reason == "autocomplete") + ) { + break; + } + let element = detail.elementWeakRef.get(); + if (!element) { + break; + } + if ( + GRAMMARS[i][2] && + (!element.maxLength || GRAMMARS[i][2] < element.maxLength) + ) { + break; + } + } + if (i >= GRAMMARS.length) { + break; + } + + if (!GRAMMARS[i][0]) { + matchingResult = { + ruleFrom: ruleStart, + ruleTo: i, + }; + break; + } + + // Fast rewinding to the next rule. + for (; i < GRAMMARS.length; i++) { + if (!GRAMMARS[i][0]) { + break; + } + } + } + + let parsedField = false; + if (matchingResult) { + let { ruleFrom, ruleTo } = matchingResult; + let detailStart = fieldScanner.parsingIndex; + for (let i = ruleFrom; i < ruleTo; i++) { + fieldScanner.updateFieldName(detailStart, GRAMMARS[i][1]); + fieldScanner.parsingIndex++; + detailStart++; + parsedField = true; + } + } + + if (fieldScanner.parsingFinished) { + return parsedField; + } + + let nextField = fieldScanner.getFieldDetailByIndex( + fieldScanner.parsingIndex + ); + if ( + nextField && + nextField._reason != "autocomplete" && + fieldScanner.parsingIndex > 0 + ) { + const regExpTelExtension = new RegExp( + "\\bext|ext\\b|extension|ramal", // pt-BR, pt-PT + "iu" + ); + const previousField = fieldScanner.getFieldDetailByIndex( + fieldScanner.parsingIndex - 1 + ); + const previousFieldType = FormAutofillUtils.getCategoryFromFieldName( + previousField.fieldName + ); + if ( + previousField && + previousFieldType == "tel" && + this._matchRegexp(nextField.elementWeakRef.get(), regExpTelExtension) + ) { + fieldScanner.updateFieldName( + fieldScanner.parsingIndex, + "tel-extension" + ); + fieldScanner.parsingIndex++; + parsedField = true; + } + } + + return parsedField; + }, + + /** + * Try to find the correct address-line[1-3] sequence and correct their field + * names. + * + * @param {FieldScanner} fieldScanner + * The current parsing status for all elements + * @returns {boolean} + * Return true if there is any field can be recognized in the parser, + * otherwise false. + */ + _parseAddressFields(fieldScanner) { + let parsedFields = false; + const addressLines = ["address-line1", "address-line2", "address-line3"]; + + // TODO: These address-line* regexps are for the lines with numbers, and + // they are the subset of the regexps in `heuristicsRegexp.js`. We have to + // find a better way to make them consistent. + const addressLineRegexps = { + "address-line1": new RegExp( + "address[_-]?line(1|one)|address1|addr1" + + "|addrline1|address_1" + // Extra rules by Firefox + "|indirizzo1" + // it-IT + "|住所1" + // ja-JP + "|地址1" + // zh-CN + "|주소.?1", // ko-KR + "iu" + ), + "address-line2": new RegExp( + "address[_-]?line(2|two)|address2|addr2" + + "|addrline2|address_2" + // Extra rules by Firefox + "|indirizzo2" + // it-IT + "|住所2" + // ja-JP + "|地址2" + // zh-CN + "|주소.?2", // ko-KR + "iu" + ), + "address-line3": new RegExp( + "address[_-]?line(3|three)|address3|addr3" + + "|addrline3|address_3" + // Extra rules by Firefox + "|indirizzo3" + // it-IT + "|住所3" + // ja-JP + "|地址3" + // zh-CN + "|주소.?3", // ko-KR + "iu" + ), + }; + while (!fieldScanner.parsingFinished) { + let detail = fieldScanner.getFieldDetailByIndex( + fieldScanner.parsingIndex + ); + if ( + !detail || + !addressLines.includes(detail.fieldName) || + detail._reason == "autocomplete" + ) { + // When the field is not related to any address-line[1-3] fields or + // determined by autocomplete attr, it means the parsing process can be + // terminated. + break; + } + const elem = detail.elementWeakRef.get(); + for (let regexp of Object.keys(addressLineRegexps)) { + if (this._matchRegexp(elem, addressLineRegexps[regexp])) { + fieldScanner.updateFieldName(fieldScanner.parsingIndex, regexp); + parsedFields = true; + } + } + fieldScanner.parsingIndex++; + } + + return parsedFields; + }, + + /** + * Try to look for expiration date fields and revise the field names if needed. + * + * @param {FieldScanner} fieldScanner + * The current parsing status for all elements + * @returns {boolean} + * Return true if there is any field can be recognized in the parser, + * otherwise false. + */ + _parseCreditCardFields(fieldScanner) { + if (fieldScanner.parsingFinished) { + return false; + } + + const savedIndex = fieldScanner.parsingIndex; + const detail = fieldScanner.getFieldDetailByIndex( + fieldScanner.parsingIndex + ); + + // Respect to autocomplete attr + if (!detail || (detail._reason && detail._reason == "autocomplete")) { + return false; + } + + const monthAndYearFieldNames = ["cc-exp-month", "cc-exp-year"]; + // Skip the uninteresting fields + if ( + !["cc-exp", "cc-type", ...monthAndYearFieldNames].includes( + detail.fieldName + ) + ) { + return false; + } + + const element = detail.elementWeakRef.get(); + + // If we didn't auto-discover type field, check every select for options that + // match credit card network names in value or label. + if (ChromeUtils.getClassName(element) == "HTMLSelectElement") { + for (let option of element.querySelectorAll("option")) { + if ( + CreditCard.getNetworkFromName(option.value) || + CreditCard.getNetworkFromName(option.text) + ) { + fieldScanner.updateFieldName(fieldScanner.parsingIndex, "cc-type"); + fieldScanner.parsingIndex++; + return true; + } + } + } + + // If the input type is a month picker, then assume it's cc-exp. + if (element.type == "month") { + fieldScanner.updateFieldName(fieldScanner.parsingIndex, "cc-exp"); + fieldScanner.parsingIndex++; + + return true; + } + + // Don't process the fields if expiration month and expiration year are already + // matched by regex in correct order. + if ( + fieldScanner.getFieldDetailByIndex(fieldScanner.parsingIndex++) + .fieldName == "cc-exp-month" && + !fieldScanner.parsingFinished && + fieldScanner.getFieldDetailByIndex(fieldScanner.parsingIndex++) + .fieldName == "cc-exp-year" + ) { + return true; + } + fieldScanner.parsingIndex = savedIndex; + + // Determine the field name by checking if the fields are month select and year select + // likely. + if (this._isExpirationMonthLikely(element)) { + fieldScanner.updateFieldName(fieldScanner.parsingIndex, "cc-exp-month"); + fieldScanner.parsingIndex++; + if (!fieldScanner.parsingFinished) { + const nextDetail = fieldScanner.getFieldDetailByIndex( + fieldScanner.parsingIndex + ); + const nextElement = nextDetail.elementWeakRef.get(); + if (this._isExpirationYearLikely(nextElement)) { + fieldScanner.updateFieldName( + fieldScanner.parsingIndex, + "cc-exp-year" + ); + fieldScanner.parsingIndex++; + return true; + } + } + } + fieldScanner.parsingIndex = savedIndex; + + // Verify that the following consecutive two fields can match cc-exp-month and cc-exp-year + // respectively. + if (this._findMatchedFieldName(element, ["cc-exp-month"])) { + fieldScanner.updateFieldName(fieldScanner.parsingIndex, "cc-exp-month"); + fieldScanner.parsingIndex++; + if (!fieldScanner.parsingFinished) { + const nextDetail = fieldScanner.getFieldDetailByIndex( + fieldScanner.parsingIndex + ); + const nextElement = nextDetail.elementWeakRef.get(); + if (this._findMatchedFieldName(nextElement, ["cc-exp-year"])) { + fieldScanner.updateFieldName( + fieldScanner.parsingIndex, + "cc-exp-year" + ); + fieldScanner.parsingIndex++; + return true; + } + } + } + fieldScanner.parsingIndex = savedIndex; + + // Look for MM and/or YY(YY). + if (this._matchRegexp(element, /^mm$/gi)) { + fieldScanner.updateFieldName(fieldScanner.parsingIndex, "cc-exp-month"); + fieldScanner.parsingIndex++; + if (!fieldScanner.parsingFinished) { + const nextDetail = fieldScanner.getFieldDetailByIndex( + fieldScanner.parsingIndex + ); + const nextElement = nextDetail.elementWeakRef.get(); + if (this._matchRegexp(nextElement, /^(yy|yyyy)$/)) { + fieldScanner.updateFieldName( + fieldScanner.parsingIndex, + "cc-exp-year" + ); + fieldScanner.parsingIndex++; + + return true; + } + } + } + fieldScanner.parsingIndex = savedIndex; + + // Look for a cc-exp with 2-digit or 4-digit year. + if ( + this._matchRegexp( + element, + /(?:exp.*date[^y\\n\\r]*|mm\\s*[-/]?\\s*)yy(?:[^y]|$)/gi + ) || + this._matchRegexp( + element, + /(?:exp.*date[^y\\n\\r]*|mm\\s*[-/]?\\s*)yyyy(?:[^y]|$)/gi + ) + ) { + fieldScanner.updateFieldName(fieldScanner.parsingIndex, "cc-exp"); + fieldScanner.parsingIndex++; + return true; + } + fieldScanner.parsingIndex = savedIndex; + + // Match general cc-exp regexp at last. + if (this._findMatchedFieldName(element, ["cc-exp"])) { + fieldScanner.updateFieldName(fieldScanner.parsingIndex, "cc-exp"); + fieldScanner.parsingIndex++; + return true; + } + fieldScanner.parsingIndex = savedIndex; + + // Set current field name to null as it failed to match any patterns. + fieldScanner.updateFieldName(fieldScanner.parsingIndex, null); + fieldScanner.parsingIndex++; + return true; + }, + + /** + * This function should provide all field details of a form which are placed + * in the belonging section. The details contain the autocomplete info + * (e.g. fieldName, section, etc). + * + * `allowDuplicates` is used for the xpcshell-test purpose currently because + * the heuristics should be verified that some duplicated elements still can + * be predicted correctly. + * + * @param {HTMLFormElement} form + * the elements in this form to be predicted the field info. + * @param {boolean} allowDuplicates + * true to remain any duplicated field details otherwise to remove the + * duplicated ones. + * @returns {Array<Array<Object>>} + * all sections within its field details in the form. + */ + getFormInfo(form, allowDuplicates = false) { + const eligibleFields = Array.from(form.elements).filter(elem => + FormAutofillUtils.isFieldEligibleForAutofill(elem) + ); + + if (eligibleFields.length <= 0) { + return []; + } + + let fieldScanner = new FieldScanner(eligibleFields, { + allowDuplicates, + sectionEnabled: this._sectionEnabled, + }); + while (!fieldScanner.parsingFinished) { + let parsedPhoneFields = this._parsePhoneFields(fieldScanner); + let parsedAddressFields = this._parseAddressFields(fieldScanner); + let parsedExpirationDateFields = this._parseCreditCardFields( + fieldScanner + ); + + // If there is no any field parsed, the parsing cursor can be moved + // forward to the next one. + if ( + !parsedPhoneFields && + !parsedAddressFields && + !parsedExpirationDateFields + ) { + fieldScanner.parsingIndex++; + } + } + + LabelUtils.clearLabelMap(); + + return fieldScanner.getSectionFieldDetails(); + }, + + _regExpTableHashValue(...signBits) { + return signBits.reduce((p, c, i) => p | (!!c << i), 0); + }, + + _setRegExpListCache(regexps, b0, b1, b2) { + if (!this._regexpList) { + this._regexpList = []; + } + this._regexpList[this._regExpTableHashValue(b0, b1, b2)] = regexps; + }, + + _getRegExpListCache(b0, b1, b2) { + if (!this._regexpList) { + return null; + } + return this._regexpList[this._regExpTableHashValue(b0, b1, b2)] || null; + }, + + _getRegExpList(isAutoCompleteOff, elementTagName) { + let isSelectElem = elementTagName == "SELECT"; + let regExpListCache = this._getRegExpListCache( + isAutoCompleteOff, + FormAutofill.isAutofillCreditCardsAvailable, + isSelectElem + ); + if (regExpListCache) { + return regExpListCache; + } + const FIELDNAMES_IGNORING_AUTOCOMPLETE_OFF = [ + "cc-name", + "cc-number", + "cc-exp-month", + "cc-exp-year", + "cc-exp", + "cc-type", + ]; + let regexps = isAutoCompleteOff + ? FIELDNAMES_IGNORING_AUTOCOMPLETE_OFF + : Object.keys(this.RULES); + + if (!FormAutofill.isAutofillCreditCardsAvailable) { + regexps = regexps.filter( + name => !FormAutofillUtils.isCreditCardField(name) + ); + } + + if (isSelectElem) { + const FIELDNAMES_FOR_SELECT_ELEMENT = [ + "address-level1", + "address-level2", + "country", + "cc-exp-month", + "cc-exp-year", + "cc-exp", + "cc-type", + ]; + regexps = regexps.filter(name => + FIELDNAMES_FOR_SELECT_ELEMENT.includes(name) + ); + } + + this._setRegExpListCache( + regexps, + isAutoCompleteOff, + FormAutofill.isAutofillCreditCardsAvailable, + isSelectElem + ); + + return regexps; + }, + + getInfo(element) { + let info = element.getAutocompleteInfo(); + // An input[autocomplete="on"] will not be early return here since it stll + // needs to find the field name. + if ( + info && + info.fieldName && + info.fieldName != "on" && + info.fieldName != "off" + ) { + info._reason = "autocomplete"; + return info; + } + + if (!this._prefEnabled) { + return null; + } + + let isAutoCompleteOff = + element.autocomplete == "off" || + (element.form && element.form.autocomplete == "off"); + + // "email" type of input is accurate for heuristics to determine its Email + // field or not. However, "tel" type is used for ZIP code for some web site + // (e.g. HomeDepot, BestBuy), so "tel" type should be not used for "tel" + // prediction. + if (element.type == "email" && !isAutoCompleteOff) { + return { + fieldName: "email", + section: "", + addressType: "", + contactType: "", + }; + } + + let regexps = this._getRegExpList(isAutoCompleteOff, element.tagName); + if (!regexps.length) { + return null; + } + + let matchedFieldName = this._findMatchedFieldName(element, regexps); + if (matchedFieldName) { + return { + fieldName: matchedFieldName, + section: "", + addressType: "", + contactType: "", + }; + } + + return null; + }, + + /** + * @typedef ElementStrings + * @type {object} + * @yield {string} id - element id. + * @yield {string} name - element name. + * @yield {Array<string>} labels - extracted labels. + */ + + /** + * Extract all the signature strings of an element. + * + * @param {HTMLElement} element + * @returns {ElementStrings} + */ + _getElementStrings(element) { + return { + *[Symbol.iterator]() { + yield element.id; + yield element.name; + + const labels = LabelUtils.findLabelElements(element); + for (let label of labels) { + yield* LabelUtils.extractLabelStrings(label); + } + }, + }; + }, + + /** + * Find the first matched field name of the element wih given regex list. + * + * @param {HTMLElement} element + * @param {Array<string>} regexps + * The regex key names that correspond to pattern in the rule list. + * @returns {?string} The first matched field name + */ + _findMatchedFieldName(element, regexps) { + const getElementStrings = this._getElementStrings(element); + for (let regexp of regexps) { + for (let string of getElementStrings) { + if (this.RULES[regexp].test(string)) { + return regexp; + } + } + } + + return null; + }, + + /** + * Determine whether the regexp can match any of element strings. + * + * @param {HTMLElement} element + * @param {RegExp} regexp + * + * @returns {boolean} + */ + _matchRegexp(element, regexp) { + const elemStrings = this._getElementStrings(element); + for (const str of elemStrings) { + if (regexp.test(str)) { + return true; + } + } + return false; + }, + + /** + * Phone field grammars - first matched grammar will be parsed. Grammars are + * separated by { REGEX_SEPARATOR, FIELD_NONE, 0 }. Suffix and extension are + * parsed separately unless they are necessary parts of the match. + * The following notation is used to describe the patterns: + * <cc> - country code field. + * <ac> - area code field. + * <phone> - phone or prefix. + * <suffix> - suffix. + * <ext> - extension. + * :N means field is limited to N characters, otherwise it is unlimited. + * (pattern <field>)? means pattern is optional and matched separately. + * + * This grammar list from Chromium will be enabled partially once we need to + * support more cases of Telephone fields. + */ + PHONE_FIELD_GRAMMARS: [ + // Country code: <cc> Area Code: <ac> Phone: <phone> (- <suffix> + + // (Ext: <ext>)?)? + // {REGEX_COUNTRY, FIELD_COUNTRY_CODE, 0}, + // {REGEX_AREA, FIELD_AREA_CODE, 0}, + // {REGEX_PHONE, FIELD_PHONE, 0}, + // {REGEX_SEPARATOR, FIELD_NONE, 0}, + + // \( <ac> \) <phone>:3 <suffix>:4 (Ext: <ext>)? + // {REGEX_AREA_NOTEXT, FIELD_AREA_CODE, 3}, + // {REGEX_PREFIX_SEPARATOR, FIELD_PHONE, 3}, + // {REGEX_PHONE, FIELD_SUFFIX, 4}, + // {REGEX_SEPARATOR, FIELD_NONE, 0}, + + // Phone: <cc> <ac>:3 - <phone>:3 - <suffix>:4 (Ext: <ext>)? + // {REGEX_PHONE, FIELD_COUNTRY_CODE, 0}, + // {REGEX_PHONE, FIELD_AREA_CODE, 3}, + // {REGEX_PREFIX_SEPARATOR, FIELD_PHONE, 3}, + // {REGEX_SUFFIX_SEPARATOR, FIELD_SUFFIX, 4}, + // {REGEX_SEPARATOR, FIELD_NONE, 0}, + + // Phone: <cc>:3 <ac>:3 <phone>:3 <suffix>:4 (Ext: <ext>)? + ["tel", "tel-country-code", 3], + ["tel", "tel-area-code", 3], + ["tel", "tel-local-prefix", 3], + ["tel", "tel-local-suffix", 4], + [null, null, 0], + + // Area Code: <ac> Phone: <phone> (- <suffix> (Ext: <ext>)?)? + // {REGEX_AREA, FIELD_AREA_CODE, 0}, + // {REGEX_PHONE, FIELD_PHONE, 0}, + // {REGEX_SEPARATOR, FIELD_NONE, 0}, + + // Phone: <ac> <phone>:3 <suffix>:4 (Ext: <ext>)? + // {REGEX_PHONE, FIELD_AREA_CODE, 0}, + // {REGEX_PHONE, FIELD_PHONE, 3}, + // {REGEX_PHONE, FIELD_SUFFIX, 4}, + // {REGEX_SEPARATOR, FIELD_NONE, 0}, + + // Phone: <cc> \( <ac> \) <phone> (- <suffix> (Ext: <ext>)?)? + // {REGEX_PHONE, FIELD_COUNTRY_CODE, 0}, + // {REGEX_AREA_NOTEXT, FIELD_AREA_CODE, 0}, + // {REGEX_PREFIX_SEPARATOR, FIELD_PHONE, 0}, + // {REGEX_SEPARATOR, FIELD_NONE, 0}, + + // Phone: \( <ac> \) <phone> (- <suffix> (Ext: <ext>)?)? + // {REGEX_PHONE, FIELD_COUNTRY_CODE, 0}, + // {REGEX_AREA_NOTEXT, FIELD_AREA_CODE, 0}, + // {REGEX_PREFIX_SEPARATOR, FIELD_PHONE, 0}, + // {REGEX_SEPARATOR, FIELD_NONE, 0}, + + // Phone: <cc> - <ac> - <phone> - <suffix> (Ext: <ext>)? + // {REGEX_PHONE, FIELD_COUNTRY_CODE, 0}, + // {REGEX_PREFIX_SEPARATOR, FIELD_AREA_CODE, 0}, + // {REGEX_PREFIX_SEPARATOR, FIELD_PHONE, 0}, + // {REGEX_SUFFIX_SEPARATOR, FIELD_SUFFIX, 0}, + // {REGEX_SEPARATOR, FIELD_NONE, 0}, + + // Area code: <ac>:3 Prefix: <prefix>:3 Suffix: <suffix>:4 (Ext: <ext>)? + // {REGEX_AREA, FIELD_AREA_CODE, 3}, + // {REGEX_PREFIX, FIELD_PHONE, 3}, + // {REGEX_SUFFIX, FIELD_SUFFIX, 4}, + // {REGEX_SEPARATOR, FIELD_NONE, 0}, + + // Phone: <ac> Prefix: <phone> Suffix: <suffix> (Ext: <ext>)? + // {REGEX_PHONE, FIELD_AREA_CODE, 0}, + // {REGEX_PREFIX, FIELD_PHONE, 0}, + // {REGEX_SUFFIX, FIELD_SUFFIX, 0}, + // {REGEX_SEPARATOR, FIELD_NONE, 0}, + + // Phone: <ac> - <phone>:3 - <suffix>:4 (Ext: <ext>)? + ["tel", "tel-area-code", 0], + ["tel", "tel-local-prefix", 3], + ["tel", "tel-local-suffix", 4], + [null, null, 0], + + // Phone: <cc> - <ac> - <phone> (Ext: <ext>)? + // {REGEX_PHONE, FIELD_COUNTRY_CODE, 0}, + // {REGEX_PREFIX_SEPARATOR, FIELD_AREA_CODE, 0}, + // {REGEX_SUFFIX_SEPARATOR, FIELD_PHONE, 0}, + // {REGEX_SEPARATOR, FIELD_NONE, 0}, + + // Phone: <ac> - <phone> (Ext: <ext>)? + // {REGEX_AREA, FIELD_AREA_CODE, 0}, + // {REGEX_PHONE, FIELD_PHONE, 0}, + // {REGEX_SEPARATOR, FIELD_NONE, 0}, + + // Phone: <cc>:3 - <phone>:10 (Ext: <ext>)? + // {REGEX_PHONE, FIELD_COUNTRY_CODE, 3}, + // {REGEX_PHONE, FIELD_PHONE, 10}, + // {REGEX_SEPARATOR, FIELD_NONE, 0}, + + // Ext: <ext> + // {REGEX_EXTENSION, FIELD_EXTENSION, 0}, + // {REGEX_SEPARATOR, FIELD_NONE, 0}, + + // Phone: <phone> (Ext: <ext>)? + // {REGEX_PHONE, FIELD_PHONE, 0}, + // {REGEX_SEPARATOR, FIELD_NONE, 0}, + ], +}; + +XPCOMUtils.defineLazyGetter(FormAutofillHeuristics, "RULES", () => { + let sandbox = {}; + const HEURISTICS_REGEXP = "chrome://formautofill/content/heuristicsRegexp.js"; + Services.scriptloader.loadSubScript(HEURISTICS_REGEXP, sandbox); + return sandbox.HeuristicsRegExp.RULES; +}); + +XPCOMUtils.defineLazyGetter(FormAutofillHeuristics, "_prefEnabled", () => { + return Services.prefs.getBoolPref(PREF_HEURISTICS_ENABLED); +}); + +Services.prefs.addObserver(PREF_HEURISTICS_ENABLED, () => { + FormAutofillHeuristics._prefEnabled = Services.prefs.getBoolPref( + PREF_HEURISTICS_ENABLED + ); +}); + +XPCOMUtils.defineLazyGetter(FormAutofillHeuristics, "_sectionEnabled", () => { + return Services.prefs.getBoolPref(PREF_SECTION_ENABLED); +}); + +Services.prefs.addObserver(PREF_SECTION_ENABLED, () => { + FormAutofillHeuristics._sectionEnabled = Services.prefs.getBoolPref( + PREF_SECTION_ENABLED + ); +}); diff --git a/browser/extensions/formautofill/FormAutofillNameUtils.jsm b/browser/extensions/formautofill/FormAutofillNameUtils.jsm new file mode 100644 index 0000000000..0cd9198d57 --- /dev/null +++ b/browser/extensions/formautofill/FormAutofillNameUtils.jsm @@ -0,0 +1,410 @@ +/* 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 EXPORTED_SYMBOLS = ["FormAutofillNameUtils"]; + +// FormAutofillNameUtils is initially translated from +// https://cs.chromium.org/chromium/src/components/autofill/core/browser/autofill_data_util.cc?rcl=b861deff77abecff11ae6a9f6946e9cc844b9817 +var FormAutofillNameUtils = { + NAME_PREFIXES: [ + "1lt", + "1st", + "2lt", + "2nd", + "3rd", + "admiral", + "capt", + "captain", + "col", + "cpt", + "dr", + "gen", + "general", + "lcdr", + "lt", + "ltc", + "ltg", + "ltjg", + "maj", + "major", + "mg", + "mr", + "mrs", + "ms", + "pastor", + "prof", + "rep", + "reverend", + "rev", + "sen", + "st", + ], + + NAME_SUFFIXES: [ + "b.a", + "ba", + "d.d.s", + "dds", + "i", + "ii", + "iii", + "iv", + "ix", + "jr", + "m.a", + "m.d", + "ma", + "md", + "ms", + "ph.d", + "phd", + "sr", + "v", + "vi", + "vii", + "viii", + "x", + ], + + FAMILY_NAME_PREFIXES: [ + "d'", + "de", + "del", + "der", + "di", + "la", + "le", + "mc", + "san", + "st", + "ter", + "van", + "von", + ], + + // The common and non-ambiguous CJK surnames (last names) that have more than + // one character. + COMMON_CJK_MULTI_CHAR_SURNAMES: [ + // Korean, taken from the list of surnames: + // https://ko.wikipedia.org/wiki/%ED%95%9C%EA%B5%AD%EC%9D%98_%EC%84%B1%EC%94%A8_%EB%AA%A9%EB%A1%9D + "남궁", + "사공", + "서문", + "선우", + "제갈", + "황보", + "독고", + "망절", + + // Chinese, taken from the top 10 Chinese 2-character surnames: + // https://zh.wikipedia.org/wiki/%E8%A4%87%E5%A7%93#.E5.B8.B8.E8.A6.8B.E7.9A.84.E8.A4.87.E5.A7.93 + // Simplified Chinese (mostly mainland China) + "欧阳", + "令狐", + "皇甫", + "上官", + "司徒", + "诸葛", + "司马", + "宇文", + "呼延", + "端木", + // Traditional Chinese (mostly Taiwan) + "張簡", + "歐陽", + "諸葛", + "申屠", + "尉遲", + "司馬", + "軒轅", + "夏侯", + ], + + // All Korean surnames that have more than one character, even the + // rare/ambiguous ones. + KOREAN_MULTI_CHAR_SURNAMES: [ + "강전", + "남궁", + "독고", + "동방", + "망절", + "사공", + "서문", + "선우", + "소봉", + "어금", + "장곡", + "제갈", + "황목", + "황보", + ], + + // The whitespace definition based on + // https://cs.chromium.org/chromium/src/base/strings/string_util_constants.cc?l=9&rcl=b861deff77abecff11ae6a9f6946e9cc844b9817 + WHITESPACE: [ + "\u0009", // CHARACTER TABULATION + "\u000A", // LINE FEED (LF) + "\u000B", // LINE TABULATION + "\u000C", // FORM FEED (FF) + "\u000D", // CARRIAGE RETURN (CR) + "\u0020", // SPACE + "\u0085", // NEXT LINE (NEL) + "\u00A0", // NO-BREAK SPACE + "\u1680", // OGHAM SPACE MARK + "\u2000", // EN QUAD + "\u2001", // EM QUAD + "\u2002", // EN SPACE + "\u2003", // EM SPACE + "\u2004", // THREE-PER-EM SPACE + "\u2005", // FOUR-PER-EM SPACE + "\u2006", // SIX-PER-EM SPACE + "\u2007", // FIGURE SPACE + "\u2008", // PUNCTUATION SPACE + "\u2009", // THIN SPACE + "\u200A", // HAIR SPACE + "\u2028", // LINE SEPARATOR + "\u2029", // PARAGRAPH SEPARATOR + "\u202F", // NARROW NO-BREAK SPACE + "\u205F", // MEDIUM MATHEMATICAL SPACE + "\u3000", // IDEOGRAPHIC SPACE + ], + + // The middle dot is used as a separator for foreign names in Japanese. + MIDDLE_DOT: [ + "\u30FB", // KATAKANA MIDDLE DOT + "\u00B7", // A (common?) typo for "KATAKANA MIDDLE DOT" + ], + + // The Unicode range is based on Wiki: + // https://en.wikipedia.org/wiki/CJK_Unified_Ideographs + // https://en.wikipedia.org/wiki/Hangul + // https://en.wikipedia.org/wiki/Japanese_writing_system + CJK_RANGE: [ + "\u1100-\u11FF", // Hangul Jamo + "\u3040-\u309F", // Hiragana + "\u30A0-\u30FF", // Katakana + "\u3105-\u312C", // Bopomofo + "\u3130-\u318F", // Hangul Compatibility Jamo + "\u31F0-\u31FF", // Katakana Phonetic Extensions + "\u3200-\u32FF", // Enclosed CJK Letters and Months + "\u3400-\u4DBF", // CJK unified ideographs Extension A + "\u4E00-\u9FFF", // CJK Unified Ideographs + "\uA960-\uA97F", // Hangul Jamo Extended-A + "\uAC00-\uD7AF", // Hangul Syllables + "\uD7B0-\uD7FF", // Hangul Jamo Extended-B + "\uFF00-\uFFEF", // Halfwidth and Fullwidth Forms + ], + + HANGUL_RANGE: [ + "\u1100-\u11FF", // Hangul Jamo + "\u3130-\u318F", // Hangul Compatibility Jamo + "\uA960-\uA97F", // Hangul Jamo Extended-A + "\uAC00-\uD7AF", // Hangul Syllables + "\uD7B0-\uD7FF", // Hangul Jamo Extended-B + ], + + _dataLoaded: false, + + // Returns true if |set| contains |token|, modulo a final period. + _containsString(set, token) { + let target = token.replace(/\.$/, "").toLowerCase(); + return set.includes(target); + }, + + // Removes common name prefixes from |name_tokens|. + _stripPrefixes(nameTokens) { + for (let i in nameTokens) { + if (!this._containsString(this.NAME_PREFIXES, nameTokens[i])) { + return nameTokens.slice(i); + } + } + return []; + }, + + // Removes common name suffixes from |name_tokens|. + _stripSuffixes(nameTokens) { + for (let i = nameTokens.length - 1; i >= 0; i--) { + if (!this._containsString(this.NAME_SUFFIXES, nameTokens[i])) { + return nameTokens.slice(0, i + 1); + } + } + return []; + }, + + _isCJKName(name) { + // The name is considered to be a CJK name if it is only CJK characters, + // spaces, and "middle dot" separators, with at least one CJK character, and + // no more than 2 words. + // + // Chinese and Japanese names are usually spelled out using the Han + // characters (logographs), which constitute the "CJK Unified Ideographs" + // block in Unicode, also referred to as Unihan. Korean names are usually + // spelled out in the Korean alphabet (Hangul), although they do have a Han + // equivalent as well. + + if (!name) { + return false; + } + + let previousWasCJK = false; + let wordCount = 0; + + for (let c of name) { + let isMiddleDot = this.MIDDLE_DOT.includes(c); + let isCJK = !isMiddleDot && this.reCJK.test(c); + if (!isCJK && !isMiddleDot && !this.WHITESPACE.includes(c)) { + return false; + } + if (isCJK && !previousWasCJK) { + wordCount++; + } + previousWasCJK = isCJK; + } + + return wordCount > 0 && wordCount < 3; + }, + + // Tries to split a Chinese, Japanese, or Korean name into its given name & + // surname parts. If splitting did not work for whatever reason, returns null. + _splitCJKName(nameTokens) { + // The convention for CJK languages is to put the surname (last name) first, + // and the given name (first name) second. In a continuous text, there is + // normally no space between the two parts of the name. When entering their + // name into a field, though, some people add a space to disambiguate. CJK + // names (almost) never have a middle name. + + let reHangulName = new RegExp( + "^[" + this.HANGUL_RANGE.join("") + this.WHITESPACE.join("") + "]+$", + "u" + ); + let nameParts = { + given: "", + middle: "", + family: "", + }; + + if (nameTokens.length == 1) { + // There is no space between the surname and given name. Try to infer + // where to separate between the two. Most Chinese and Korean surnames + // have only one character, but there are a few that have 2. If the name + // does not start with a surname from a known list, default to one + // character. + let name = nameTokens[0]; + let isKorean = reHangulName.test(name); + let surnameLength = 0; + + // 4-character Korean names are more likely to be 2/2 than 1/3, so use + // the full list of Korean 2-char surnames. (instead of only the common + // ones) + let multiCharSurnames = + isKorean && name.length > 3 + ? this.KOREAN_MULTI_CHAR_SURNAMES + : this.COMMON_CJK_MULTI_CHAR_SURNAMES; + + // Default to 1 character if the surname is not in the list. + surnameLength = multiCharSurnames.some(surname => + name.startsWith(surname) + ) + ? 2 + : 1; + + nameParts.family = name.substr(0, surnameLength); + nameParts.given = name.substr(surnameLength); + } else if (nameTokens.length == 2) { + // The user entered a space between the two name parts. This makes our job + // easier. Family name first, given name second. + nameParts.family = nameTokens[0]; + nameParts.given = nameTokens[1]; + } else { + return null; + } + + return nameParts; + }, + + init() { + if (this._dataLoaded) { + return; + } + this._dataLoaded = true; + + this.reCJK = new RegExp("[" + this.CJK_RANGE.join("") + "]", "u"); + }, + + splitName(name) { + let nameParts = { + given: "", + middle: "", + family: "", + }; + + if (!name) { + return nameParts; + } + + let nameTokens = name.trim().split(/[ ,\u3000\u30FB\u00B7]+/); + nameTokens = this._stripPrefixes(nameTokens); + + if (this._isCJKName(name)) { + let parts = this._splitCJKName(nameTokens); + if (parts) { + return parts; + } + } + + // Don't assume "Ma" is a suffix in John Ma. + if (nameTokens.length > 2) { + nameTokens = this._stripSuffixes(nameTokens); + } + + if (!nameTokens.length) { + // Bad things have happened; just assume the whole thing is a given name. + nameParts.given = name; + return nameParts; + } + + // Only one token, assume given name. + if (nameTokens.length == 1) { + nameParts.given = nameTokens[0]; + return nameParts; + } + + // 2 or more tokens. Grab the family, which is the last word plus any + // recognizable family prefixes. + let familyTokens = [nameTokens.pop()]; + while (nameTokens.length) { + let lastToken = nameTokens[nameTokens.length - 1]; + if (!this._containsString(this.FAMILY_NAME_PREFIXES, lastToken)) { + break; + } + familyTokens.unshift(lastToken); + nameTokens.pop(); + } + nameParts.family = familyTokens.join(" "); + + // Take the last remaining token as the middle name (if there are at least 2 + // tokens). + if (nameTokens.length >= 2) { + nameParts.middle = nameTokens.pop(); + } + + // Remainder is given name. + nameParts.given = nameTokens.join(" "); + + return nameParts; + }, + + joinNameParts({ given, middle, family }) { + if (this._isCJKName(given) && this._isCJKName(family) && !middle) { + return family + given; + } + return [given, middle, family] + .filter(part => part && part.length) + .join(" "); + }, +}; + +FormAutofillNameUtils.init(); diff --git a/browser/extensions/formautofill/FormAutofillParent.jsm b/browser/extensions/formautofill/FormAutofillParent.jsm new file mode 100644 index 0000000000..47a965e7f5 --- /dev/null +++ b/browser/extensions/formautofill/FormAutofillParent.jsm @@ -0,0 +1,878 @@ +/* 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/. */ + +/* + * Implements a service used to access storage and communicate with content. + * + * A "fields" array is used to communicate with FormAutofillContent. Each item + * represents a single input field in the content page as well as its + * @autocomplete properties. The schema is as below. Please refer to + * FormAutofillContent.js for more details. + * + * [ + * { + * section, + * addressType, + * contactType, + * fieldName, + * value, + * index + * }, + * { + * // ... + * } + * ] + */ + +"use strict"; + +// We expose a singleton from this module. Some tests may import the +// constructor via a backstage pass. +var EXPORTED_SYMBOLS = ["FormAutofillParent", "FormAutofillStatus"]; + +const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); + +const { FormAutofill } = ChromeUtils.import( + "resource://formautofill/FormAutofill.jsm" +); + +XPCOMUtils.defineLazyModuleGetters(this, { + BrowserWindowTracker: "resource:///modules/BrowserWindowTracker.jsm", + CreditCard: "resource://gre/modules/CreditCard.jsm", + FormAutofillPreferences: + "resource://formautofill/FormAutofillPreferences.jsm", + FormAutofillDoorhanger: "resource://formautofill/FormAutofillDoorhanger.jsm", + FormAutofillUtils: "resource://formautofill/FormAutofillUtils.jsm", + OSKeyStore: "resource://gre/modules/OSKeyStore.jsm", +}); + +this.log = null; +FormAutofill.defineLazyLogGetter(this, EXPORTED_SYMBOLS[0]); + +const { + ENABLED_AUTOFILL_ADDRESSES_PREF, + ENABLED_AUTOFILL_CREDITCARDS_PREF, +} = FormAutofill; + +const { + ADDRESSES_COLLECTION_NAME, + CREDITCARDS_COLLECTION_NAME, +} = FormAutofillUtils; + +let gMessageObservers = new Set(); + +let FormAutofillStatus = { + _initialized: false, + + /** + * Cache of the Form Autofill status (considering preferences and storage). + */ + _active: null, + + /** + * Initializes observers and registers the message handler. + */ + init() { + if (this._initialized) { + return; + } + this._initialized = true; + + Services.obs.addObserver(this, "privacy-pane-loaded"); + + // Observing the pref and storage changes + Services.prefs.addObserver(ENABLED_AUTOFILL_ADDRESSES_PREF, this); + Services.obs.addObserver(this, "formautofill-storage-changed"); + + // Only listen to credit card related preference if it is available + if (FormAutofill.isAutofillCreditCardsAvailable) { + Services.prefs.addObserver(ENABLED_AUTOFILL_CREDITCARDS_PREF, this); + } + + for (let win of Services.wm.getEnumerator("navigator:browser")) { + this.injectElements(win.document); + } + Services.wm.addListener(this); + + Services.telemetry.setEventRecordingEnabled("creditcard", true); + }, + + /** + * Uninitializes FormAutofillStatus. This is for testing only. + * + * @private + */ + uninit() { + gFormAutofillStorage._saveImmediately(); + + if (!this._initialized) { + return; + } + this._initialized = false; + + this._active = null; + + Services.obs.removeObserver(this, "privacy-pane-loaded"); + Services.prefs.removeObserver(ENABLED_AUTOFILL_ADDRESSES_PREF, this); + Services.wm.removeListener(this); + + if (FormAutofill.isAutofillCreditCardsAvailable) { + Services.prefs.removeObserver(ENABLED_AUTOFILL_CREDITCARDS_PREF, this); + } + }, + + get formAutofillStorage() { + return gFormAutofillStorage; + }, + + /** + * Broadcast the status to frames when the form autofill status changes. + */ + onStatusChanged() { + log.debug("onStatusChanged: Status changed to", this._active); + Services.ppmm.sharedData.set("FormAutofill:enabled", this._active); + // Sync autofill enabled to make sure the value is up-to-date + // no matter when the new content process is initialized. + Services.ppmm.sharedData.flush(); + }, + + /** + * Query preference and storage status to determine the overall status of the + * form autofill feature. + * + * @returns {boolean} whether form autofill is active (enabled and has data) + */ + computeStatus() { + const savedFieldNames = Services.ppmm.sharedData.get( + "FormAutofill:savedFieldNames" + ); + + return ( + (Services.prefs.getBoolPref(ENABLED_AUTOFILL_ADDRESSES_PREF) || + Services.prefs.getBoolPref(ENABLED_AUTOFILL_CREDITCARDS_PREF)) && + savedFieldNames && + savedFieldNames.size > 0 + ); + }, + + /** + * Update the status and trigger onStatusChanged, if necessary. + */ + updateStatus() { + log.debug("updateStatus"); + let wasActive = this._active; + this._active = this.computeStatus(); + if (this._active !== wasActive) { + this.onStatusChanged(); + } + }, + + updateSavedFieldNames() { + log.debug("updateSavedFieldNames"); + + let savedFieldNames; + // Don't access the credit cards store unless it is enabled. + if (FormAutofill.isAutofillCreditCardsAvailable) { + savedFieldNames = new Set([ + ...gFormAutofillStorage.addresses.getSavedFieldNames(), + ...gFormAutofillStorage.creditCards.getSavedFieldNames(), + ]); + } else { + savedFieldNames = gFormAutofillStorage.addresses.getSavedFieldNames(); + } + + Services.ppmm.sharedData.set( + "FormAutofill:savedFieldNames", + savedFieldNames + ); + Services.ppmm.sharedData.flush(); + + this.updateStatus(); + }, + + injectElements(doc) { + Services.scriptloader.loadSubScript( + "chrome://formautofill/content/customElements.js", + doc.ownerGlobal + ); + }, + + onOpenWindow(xulWindow) { + const win = xulWindow.docShell.domWindow; + win.addEventListener( + "load", + () => { + if ( + win.document.documentElement.getAttribute("windowtype") == + "navigator:browser" + ) { + this.injectElements(win.document); + } + }, + { once: true } + ); + }, + + onCloseWindow() {}, + + observe(subject, topic, data) { + log.debug("observe:", topic, "with data:", data); + switch (topic) { + case "privacy-pane-loaded": { + let formAutofillPreferences = new FormAutofillPreferences(); + let document = subject.document; + let prefFragment = formAutofillPreferences.init(document); + let formAutofillGroupBox = document.getElementById( + "formAutofillGroupBox" + ); + formAutofillGroupBox.appendChild(prefFragment); + break; + } + + case "nsPref:changed": { + // Observe pref changes and update _active cache if status is changed. + this.updateStatus(); + break; + } + + case "formautofill-storage-changed": { + // Early exit if only metadata is changed + if (data == "notifyUsed") { + break; + } + + this.updateSavedFieldNames(); + break; + } + + default: { + throw new Error( + `FormAutofillStatus: Unexpected topic observed: ${topic}` + ); + } + } + }, +}; + +// Lazily load the storage JSM to avoid disk I/O until absolutely needed. +// Once storage is loaded we need to update saved field names and inform content processes. +XPCOMUtils.defineLazyGetter(this, "gFormAutofillStorage", () => { + let { formAutofillStorage } = ChromeUtils.import( + "resource://formautofill/FormAutofillStorage.jsm" + ); + log.debug("Loading formAutofillStorage"); + + formAutofillStorage.initialize().then(() => { + // Update the saved field names to compute the status and update child processes. + FormAutofillStatus.updateSavedFieldNames(); + }); + + return formAutofillStorage; +}); + +class FormAutofillParent extends JSWindowActorParent { + constructor() { + super(); + FormAutofillStatus.init(); + } + + static addMessageObserver(observer) { + gMessageObservers.add(observer); + } + + static removeMessageObserver(observer) { + gMessageObservers.delete(observer); + } + + /** + * Handles the message coming from FormAutofillContent. + * + * @param {string} message.name The name of the message. + * @param {object} message.data The data of the message. + */ + async receiveMessage({ name, data }) { + switch (name) { + case "FormAutofill:InitStorage": { + await gFormAutofillStorage.initialize(); + break; + } + case "FormAutofill:GetRecords": { + return FormAutofillParent._getRecords(data); + } + case "FormAutofill:OnFormSubmit": { + this.notifyMessageObservers("onFormSubmitted", data); + await this._onFormSubmit(data); + break; + } + case "FormAutofill:OpenPreferences": { + const win = BrowserWindowTracker.getTopWindow(); + win.openPreferences("privacy-form-autofill"); + break; + } + case "FormAutofill:GetDecryptedString": { + let { cipherText, reauth } = data; + if (!FormAutofillUtils._reauthEnabledByUser) { + log.debug("Reauth is disabled"); + reauth = false; + } + let string; + try { + string = await OSKeyStore.decrypt(cipherText, reauth); + } catch (e) { + if (e.result != Cr.NS_ERROR_ABORT) { + throw e; + } + log.warn("User canceled encryption login"); + } + return string; + } + case "FormAutofill:UpdateWarningMessage": + this.notifyMessageObservers("updateWarningNote", data); + break; + + case "FormAutofill:FieldsIdentified": + this.notifyMessageObservers("fieldsIdentified", data); + break; + + // The remaining Save and Remove messages are invoked only by tests. + case "FormAutofill:SaveAddress": { + if (data.guid) { + await gFormAutofillStorage.addresses.update(data.guid, data.address); + } else { + await gFormAutofillStorage.addresses.add(data.address); + } + break; + } + case "FormAutofill:SaveCreditCard": { + if (!(await FormAutofillUtils.ensureLoggedIn()).authenticated) { + log.warn("User canceled encryption login"); + return undefined; + } + await gFormAutofillStorage.creditCards.add(data.creditcard); + break; + } + case "FormAutofill:RemoveAddresses": { + data.guids.forEach(guid => gFormAutofillStorage.addresses.remove(guid)); + break; + } + case "FormAutofill:RemoveCreditCards": { + data.guids.forEach(guid => + gFormAutofillStorage.creditCards.remove(guid) + ); + break; + } + } + + return undefined; + } + + notifyMessageObservers(callbackName, data) { + for (let observer of gMessageObservers) { + try { + if (callbackName in observer) { + observer[callbackName]( + data, + this.manager.browsingContext.topChromeWindow + ); + } + } catch (ex) { + Cu.reportError(ex); + } + } + } + + /** + * Get the records from profile store and return results back to content + * process. It will decrypt the credit card number and append + * "cc-number-decrypted" to each record if OSKeyStore isn't set. + * + * This is static as a unit test calls this. + * + * @private + * @param {string} data.collectionName + * The name used to specify which collection to retrieve records. + * @param {string} data.searchString + * The typed string for filtering out the matched records. + * @param {string} data.info + * The input autocomplete property's information. + */ + static async _getRecords({ collectionName, searchString, info }) { + let collection = gFormAutofillStorage[collectionName]; + if (!collection) { + return []; + } + + let recordsInCollection = await collection.getAll(); + if (!info || !info.fieldName || !recordsInCollection.length) { + return recordsInCollection; + } + + let isCC = collectionName == CREDITCARDS_COLLECTION_NAME; + // We don't filter "cc-number" + if (isCC && info.fieldName == "cc-number") { + recordsInCollection = recordsInCollection.filter( + record => !!record["cc-number"] + ); + return recordsInCollection; + } + + let records = []; + let lcSearchString = searchString.toLowerCase(); + + for (let record of recordsInCollection) { + let fieldValue = record[info.fieldName]; + if (!fieldValue) { + continue; + } + + if ( + collectionName == ADDRESSES_COLLECTION_NAME && + record.country && + !FormAutofill.supportedCountries.includes(record.country) + ) { + // Address autofill isn't supported for the record's country so we don't + // want to attempt to potentially incorrectly fill the address fields. + continue; + } + + if ( + lcSearchString && + !String(fieldValue) + .toLowerCase() + .startsWith(lcSearchString) + ) { + continue; + } + records.push(record); + } + + return records; + } + + async _onAddressSubmit(address, browser, timeStartedFillingMS) { + let showDoorhanger = null; + if (!FormAutofill.isAutofillAddressesCaptureEnabled) { + return showDoorhanger; + } + if (address.guid) { + // Avoid updating the fields that users don't modify. + let originalAddress = await gFormAutofillStorage.addresses.get( + address.guid + ); + for (let field in address.record) { + if (address.untouchedFields.includes(field) && originalAddress[field]) { + address.record[field] = originalAddress[field]; + } + } + + if ( + !(await gFormAutofillStorage.addresses.mergeIfPossible( + address.guid, + address.record, + true + )) + ) { + this._recordFormFillingTime( + "address", + "autofill-update", + timeStartedFillingMS + ); + + showDoorhanger = async () => { + const description = FormAutofillUtils.getAddressLabel(address.record); + const state = await FormAutofillDoorhanger.show( + browser, + "updateAddress", + description + ); + let changedGUIDs = await gFormAutofillStorage.addresses.mergeToStorage( + address.record, + true + ); + switch (state) { + case "create": + if (!changedGUIDs.length) { + changedGUIDs.push( + await gFormAutofillStorage.addresses.add(address.record) + ); + } + break; + case "update": + if (!changedGUIDs.length) { + await gFormAutofillStorage.addresses.update( + address.guid, + address.record, + true + ); + changedGUIDs.push(address.guid); + } else { + gFormAutofillStorage.addresses.remove(address.guid); + } + break; + } + changedGUIDs.forEach(guid => + gFormAutofillStorage.addresses.notifyUsed(guid) + ); + }; + // Address should be updated + Services.telemetry.scalarAdd( + "formautofill.addresses.fill_type_autofill_update", + 1 + ); + } else { + this._recordFormFillingTime( + "address", + "autofill", + timeStartedFillingMS + ); + gFormAutofillStorage.addresses.notifyUsed(address.guid); + // Address is merged successfully + Services.telemetry.scalarAdd( + "formautofill.addresses.fill_type_autofill", + 1 + ); + } + } else { + let changedGUIDs = await gFormAutofillStorage.addresses.mergeToStorage( + address.record + ); + if (!changedGUIDs.length) { + changedGUIDs.push( + await gFormAutofillStorage.addresses.add(address.record) + ); + } + changedGUIDs.forEach(guid => + gFormAutofillStorage.addresses.notifyUsed(guid) + ); + this._recordFormFillingTime("address", "manual", timeStartedFillingMS); + + // Show first time use doorhanger + if (FormAutofill.isAutofillAddressesFirstTimeUse) { + Services.prefs.setBoolPref( + FormAutofill.ADDRESSES_FIRST_TIME_USE_PREF, + false + ); + showDoorhanger = async () => { + const description = FormAutofillUtils.getAddressLabel(address.record); + const state = await FormAutofillDoorhanger.show( + browser, + "firstTimeUse", + description + ); + if (state !== "open-pref") { + return; + } + + browser.ownerGlobal.openPreferences("privacy-address-autofill"); + }; + } else { + // We want to exclude the first time form filling. + Services.telemetry.scalarAdd( + "formautofill.addresses.fill_type_manual", + 1 + ); + } + } + return showDoorhanger; + } + + async _onCreditCardSubmit(creditCard, browser, timeStartedFillingMS) { + if (FormAutofill.isAutofillCreditCardsHideUI) { + return false; + } + + // Updates the used status for shield/heartbeat to recognize users who have + // used Credit Card Autofill. + let setUsedStatus = status => { + if (FormAutofill.AutofillCreditCardsUsedStatus < status) { + Services.prefs.setIntPref( + FormAutofill.CREDITCARDS_USED_STATUS_PREF, + status + ); + } + }; + + // Remove invalid cc-type values + if ( + creditCard.record["cc-type"] && + !CreditCard.isValidNetwork(creditCard.record["cc-type"]) + ) { + // Let's reset the credit card to empty, and then network auto-detect will + // pick it up. + creditCard.record["cc-type"] = ""; + } + + // If `guid` is present, the form has been autofilled. + if (creditCard.guid) { + // Indicate that the user has used Credit Card Autofill to fill in a form. + setUsedStatus(3); + + let originalCCData = await gFormAutofillStorage.creditCards.get( + creditCard.guid + ); + let recordUnchanged = true; + for (let field in creditCard.record) { + if (creditCard.record[field] === "" && !originalCCData[field]) { + continue; + } + // Avoid updating the fields that users don't modify, but skip number field + // because we don't want to trigger decryption here. + let untouched = creditCard.untouchedFields.includes(field); + if (untouched && field !== "cc-number") { + creditCard.record[field] = originalCCData[field]; + } + // recordUnchanged will be false if one of the field is changed. + recordUnchanged &= untouched; + } + + if (recordUnchanged) { + gFormAutofillStorage.creditCards.notifyUsed(creditCard.guid); + // Add probe to record credit card autofill(without modification). + Services.telemetry.scalarAdd( + "formautofill.creditCards.fill_type_autofill", + 1 + ); + this._recordFormFillingTime( + "creditCard", + "autofill", + timeStartedFillingMS + ); + return false; + } + // Add the probe to record credit card autofill with modification. + Services.telemetry.scalarAdd( + "formautofill.creditCards.fill_type_autofill_modified", + 1 + ); + this._recordFormFillingTime( + "creditCard", + "autofill-update", + timeStartedFillingMS + ); + } else { + // Add the probe to record credit card manual filling. + Services.telemetry.scalarAdd( + "formautofill.creditCards.fill_type_manual", + 1 + ); + this._recordFormFillingTime("creditCard", "manual", timeStartedFillingMS); + + let existingGuid = await gFormAutofillStorage.creditCards.getDuplicateGuid( + creditCard.record + ); + + if (existingGuid) { + creditCard.guid = existingGuid; + + let originalCCData = await gFormAutofillStorage.creditCards.get( + creditCard.guid + ); + + gFormAutofillStorage.creditCards._normalizeRecord(creditCard.record); + + // If the credit card record is a duplicate, check if the fields match the + // record. + let recordUnchanged = true; + for (let field in creditCard.record) { + if (field == "cc-number") { + continue; + } + if (creditCard.record[field] != originalCCData[field]) { + recordUnchanged = false; + break; + } + } + + if (recordUnchanged) { + // Indicate that the user neither sees the doorhanger nor uses Autofill + // but somehow has a duplicate record in the storage. Will be reset to 2 + // if the doorhanger actually shows below. + setUsedStatus(1); + gFormAutofillStorage.creditCards.notifyUsed(creditCard.guid); + return false; + } + } + } + + // Indicate that the user has seen the doorhanger. + setUsedStatus(2); + + return async () => { + // Suppress the pending doorhanger from showing up if user disabled credit card in previous doorhanger. + if (!FormAutofill.isAutofillCreditCardsEnabled) { + return; + } + + let number = + creditCard.record["cc-number"] || + creditCard.record["cc-number-decrypted"]; + let name = creditCard.record["cc-name"]; + const description = await CreditCard.getLabel({ name, number }); + + const telemetryObject = creditCard.guid + ? "update_doorhanger" + : "capture_doorhanger"; + Services.telemetry.recordEvent( + "creditcard", + "show", + telemetryObject, + creditCard.flowId + ); + + const state = await FormAutofillDoorhanger.show( + browser, + creditCard.guid ? "updateCreditCard" : "addCreditCard", + description + ); + if (state == "cancel") { + Services.telemetry.recordEvent( + "creditcard", + "cancel", + telemetryObject, + creditCard.flowId + ); + return; + } + + if (state == "disable") { + Services.prefs.setBoolPref( + "extensions.formautofill.creditCards.enabled", + false + ); + Services.telemetry.recordEvent( + "creditcard", + "disable", + telemetryObject, + creditCard.flowId + ); + return; + } + + if (!(await FormAutofillUtils.ensureLoggedIn()).authenticated) { + log.warn("User canceled encryption login"); + return; + } + + let changedGUIDs = []; + if (creditCard.guid) { + if (state == "update") { + Services.telemetry.recordEvent( + "creditcard", + "update", + telemetryObject, + creditCard.flowId + ); + await gFormAutofillStorage.creditCards.update( + creditCard.guid, + creditCard.record, + true + ); + changedGUIDs.push(creditCard.guid); + } else if ("create") { + Services.telemetry.recordEvent( + "creditcard", + "save", + telemetryObject, + creditCard.flowId + ); + changedGUIDs.push( + await gFormAutofillStorage.creditCards.add(creditCard.record) + ); + } + } else { + changedGUIDs.push( + ...(await gFormAutofillStorage.creditCards.mergeToStorage( + creditCard.record + )) + ); + if (!changedGUIDs.length) { + Services.telemetry.recordEvent( + "creditcard", + "save", + telemetryObject, + creditCard.flowId + ); + changedGUIDs.push( + await gFormAutofillStorage.creditCards.add(creditCard.record) + ); + } + } + changedGUIDs.forEach(guid => + gFormAutofillStorage.creditCards.notifyUsed(guid) + ); + }; + } + + async _onFormSubmit(data) { + let { + profile: { address, creditCard }, + timeStartedFillingMS, + } = data; + + // Don't record filling time if any type of records has more than one section being + // populated. We've been recording the filling time, so the other cases that aren't + // recorded on the same basis should be out of the data samples. E.g. Filling time of + // populating one profile is different from populating two sections, therefore, we + // shouldn't record the later to regress the representation of existing statistics. + if (address.length > 1 || creditCard.length > 1) { + timeStartedFillingMS = null; + } + + let browser = this.manager.browsingContext.top.embedderElement; + + // Transmit the telemetry immediately in the meantime form submitted, and handle these pending + // doorhangers at a later. + await Promise.all( + [ + await Promise.all( + address.map(addrRecord => + this._onAddressSubmit(addrRecord, browser, timeStartedFillingMS) + ) + ), + await Promise.all( + creditCard.map(ccRecord => + this._onCreditCardSubmit(ccRecord, browser, timeStartedFillingMS) + ) + ), + ] + .map(pendingDoorhangers => { + return pendingDoorhangers.filter( + pendingDoorhanger => + !!pendingDoorhanger && typeof pendingDoorhanger == "function" + ); + }) + .map(pendingDoorhangers => + (async () => { + for (const showDoorhanger of pendingDoorhangers) { + await showDoorhanger(); + } + })() + ) + ); + } + + /** + * Set the probes for the filling time with specific filling type and form type. + * + * @private + * @param {string} formType + * 3 type of form (address/creditcard/address-creditcard). + * @param {string} fillingType + * 3 filling type (manual/autofill/autofill-update). + * @param {int|null} startedFillingMS + * Time that form started to filling in ms. Early return if start time is null. + */ + _recordFormFillingTime(formType, fillingType, startedFillingMS) { + if (!startedFillingMS) { + return; + } + let histogram = Services.telemetry.getKeyedHistogramById( + "FORM_FILLING_REQUIRED_TIME_MS" + ); + histogram.add(`${formType}-${fillingType}`, Date.now() - startedFillingMS); + } +} diff --git a/browser/extensions/formautofill/FormAutofillPreferences.jsm b/browser/extensions/formautofill/FormAutofillPreferences.jsm new file mode 100644 index 0000000000..a5e40f49d0 --- /dev/null +++ b/browser/extensions/formautofill/FormAutofillPreferences.jsm @@ -0,0 +1,403 @@ +/* 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/. */ + +/** + * Injects the form autofill section into about:preferences. + */ + +"use strict"; + +var EXPORTED_SYMBOLS = ["FormAutofillPreferences"]; + +// Add addresses enabled flag in telemetry environment for recording the number of +// users who disable/enable the address autofill feature. +const BUNDLE_URI = "chrome://formautofill/locale/formautofill.properties"; +const MANAGE_ADDRESSES_URL = + "chrome://formautofill/content/manageAddresses.xhtml"; +const MANAGE_CREDITCARDS_URL = + "chrome://formautofill/content/manageCreditCards.xhtml"; + +const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); +const { FormAutofill } = ChromeUtils.import( + "resource://formautofill/FormAutofill.jsm" +); +const { FormAutofillUtils } = ChromeUtils.import( + "resource://formautofill/FormAutofillUtils.jsm" +); +ChromeUtils.defineModuleGetter( + this, + "AppConstants", + "resource://gre/modules/AppConstants.jsm" +); +ChromeUtils.defineModuleGetter( + this, + "OSKeyStore", + "resource://gre/modules/OSKeyStore.jsm" +); + +const { + ENABLED_AUTOFILL_ADDRESSES_PREF, + ENABLED_AUTOFILL_CREDITCARDS_PREF, + ENABLED_AUTOFILL_CREDITCARDS_REAUTH_PREF, +} = FormAutofill; +const { + MANAGE_ADDRESSES_KEYWORDS, + EDIT_ADDRESS_KEYWORDS, + MANAGE_CREDITCARDS_KEYWORDS, + EDIT_CREDITCARD_KEYWORDS, +} = FormAutofillUtils; +// Add credit card enabled flag in telemetry environment for recording the number of +// users who disable/enable the credit card autofill feature. + +this.log = null; +FormAutofill.defineLazyLogGetter(this, EXPORTED_SYMBOLS[0]); + +const HTML_NS = "http://www.w3.org/1999/xhtml"; + +function FormAutofillPreferences() { + this.bundle = Services.strings.createBundle(BUNDLE_URI); +} + +FormAutofillPreferences.prototype = { + /** + * Create the Form Autofill preference group. + * + * @param {HTMLDocument} document + * @returns {XULElement} + */ + init(document) { + this.createPreferenceGroup(document); + this.attachEventListeners(); + + return this.refs.formAutofillFragment; + }, + + /** + * Remove event listeners and the preference group. + */ + uninit() { + this.detachEventListeners(); + this.refs.formAutofillGroup.remove(); + }, + + /** + * Create Form Autofill preference group + * + * @param {HTMLDocument} document + */ + createPreferenceGroup(document) { + let addressLearnMoreURL = + Services.urlFormatter.formatURLPref("app.support.baseURL") + + "autofill-card-address"; + let creditCardLearnMoreURL = + Services.urlFormatter.formatURLPref("app.support.baseURL") + + "credit-card-autofill"; + let formAutofillFragment = document.createDocumentFragment(); + let formAutofillGroupBoxLabel = document.createXULElement("label"); + let formAutofillGroupBoxLabelHeading = document.createElementNS( + HTML_NS, + "h2" + ); + let formAutofillGroup = document.createXULElement("vbox"); + let addressAutofill = document.createXULElement("hbox"); + let addressAutofillCheckboxGroup = document.createXULElement("hbox"); + let addressAutofillCheckbox = document.createXULElement("checkbox"); + let addressAutofillLearnMore = document.createXULElement("label", { + is: "text-link", + }); + let savedAddressesBtn = document.createXULElement("button", { + is: "highlightable-button", + }); + // Wrappers are used to properly compute the search tooltip positions + let savedAddressesBtnWrapper = document.createXULElement("hbox"); + let savedCreditCardsBtnWrapper = document.createXULElement("hbox"); + + savedAddressesBtn.className = "accessory-button"; + addressAutofillCheckbox.className = "tail-with-learn-more"; + addressAutofillLearnMore.className = "learnMore"; + + formAutofillGroup.id = "formAutofillGroup"; + addressAutofill.id = "addressAutofill"; + addressAutofillLearnMore.id = "addressAutofillLearnMore"; + + formAutofillGroupBoxLabelHeading.textContent = this.bundle.GetStringFromName( + "autofillHeader" + ); + + addressAutofill.setAttribute("data-subcategory", "address-autofill"); + addressAutofillCheckbox.setAttribute( + "label", + this.bundle.GetStringFromName("autofillAddressesCheckbox") + ); + addressAutofillLearnMore.textContent = this.bundle.GetStringFromName( + "learnMoreLabel" + ); + savedAddressesBtn.setAttribute( + "label", + this.bundle.GetStringFromName("savedAddressesBtnLabel") + ); + // Align the start to keep the savedAddressesBtn as original size + // when addressAutofillCheckboxGroup's height is changed by a longer l10n string + savedAddressesBtnWrapper.setAttribute("align", "start"); + + addressAutofillLearnMore.setAttribute("href", addressLearnMoreURL); + + // Add preferences search support + savedAddressesBtn.setAttribute( + "searchkeywords", + MANAGE_ADDRESSES_KEYWORDS.concat(EDIT_ADDRESS_KEYWORDS) + .map(key => this.bundle.GetStringFromName(key)) + .join("\n") + ); + + // Manually set the checked state + if (FormAutofill.isAutofillAddressesEnabled) { + addressAutofillCheckbox.setAttribute("checked", true); + } + + addressAutofillCheckboxGroup.setAttribute("align", "center"); + addressAutofillCheckboxGroup.flex = 1; + + formAutofillGroupBoxLabel.appendChild(formAutofillGroupBoxLabelHeading); + formAutofillFragment.appendChild(formAutofillGroupBoxLabel); + formAutofillFragment.appendChild(formAutofillGroup); + formAutofillGroup.appendChild(addressAutofill); + addressAutofill.appendChild(addressAutofillCheckboxGroup); + addressAutofillCheckboxGroup.appendChild(addressAutofillCheckbox); + addressAutofillCheckboxGroup.appendChild(addressAutofillLearnMore); + addressAutofill.appendChild(savedAddressesBtnWrapper); + savedAddressesBtnWrapper.appendChild(savedAddressesBtn); + + this.refs = { + formAutofillFragment, + formAutofillGroup, + addressAutofillCheckbox, + savedAddressesBtn, + }; + + if ( + FormAutofill.isAutofillCreditCardsAvailable && + !FormAutofill.isAutofillCreditCardsHideUI + ) { + let creditCardAutofill = document.createXULElement("hbox"); + let creditCardAutofillCheckboxGroup = document.createXULElement("hbox"); + let creditCardAutofillCheckbox = document.createXULElement("checkbox"); + let creditCardAutofillLearnMore = document.createXULElement("label", { + is: "text-link", + }); + let savedCreditCardsBtn = document.createXULElement("button", { + is: "highlightable-button", + }); + savedCreditCardsBtn.className = "accessory-button"; + creditCardAutofillCheckbox.className = "tail-with-learn-more"; + creditCardAutofillLearnMore.className = "learnMore"; + + creditCardAutofill.id = "creditCardAutofill"; + creditCardAutofillLearnMore.id = "creditCardAutofillLearnMore"; + + creditCardAutofill.setAttribute( + "data-subcategory", + "credit-card-autofill" + ); + creditCardAutofillCheckbox.setAttribute( + "label", + this.bundle.GetStringFromName("autofillCreditCardsCheckbox") + ); + creditCardAutofillLearnMore.textContent = this.bundle.GetStringFromName( + "learnMoreLabel" + ); + savedCreditCardsBtn.setAttribute( + "label", + this.bundle.GetStringFromName("savedCreditCardsBtnLabel") + ); + // Align the start to keep the savedCreditCardsBtn as original size + // when creditCardAutofillCheckboxGroup's height is changed by a longer l10n string + savedCreditCardsBtnWrapper.setAttribute("align", "start"); + + creditCardAutofillLearnMore.setAttribute("href", creditCardLearnMoreURL); + + // Add preferences search support + savedCreditCardsBtn.setAttribute( + "searchkeywords", + MANAGE_CREDITCARDS_KEYWORDS.concat(EDIT_CREDITCARD_KEYWORDS) + .map(key => this.bundle.GetStringFromName(key)) + .join("\n") + ); + + // Manually set the checked state + if (FormAutofill.isAutofillCreditCardsEnabled) { + creditCardAutofillCheckbox.setAttribute("checked", true); + } + + creditCardAutofillCheckboxGroup.setAttribute("align", "center"); + creditCardAutofillCheckboxGroup.flex = 1; + + formAutofillGroup.appendChild(creditCardAutofill); + creditCardAutofill.appendChild(creditCardAutofillCheckboxGroup); + creditCardAutofillCheckboxGroup.appendChild(creditCardAutofillCheckbox); + creditCardAutofillCheckboxGroup.appendChild(creditCardAutofillLearnMore); + creditCardAutofill.appendChild(savedCreditCardsBtnWrapper); + savedCreditCardsBtnWrapper.appendChild(savedCreditCardsBtn); + + this.refs.creditCardAutofillCheckbox = creditCardAutofillCheckbox; + this.refs.savedCreditCardsBtn = savedCreditCardsBtn; + + if (OSKeyStore.canReauth()) { + let reauthLearnMoreURL = `${creditCardLearnMoreURL}#w_require-authentication-for-autofill`; + let reauth = document.createXULElement("hbox"); + let reauthCheckboxGroup = document.createXULElement("hbox"); + let reauthCheckbox = document.createXULElement("checkbox"); + let reauthLearnMore = document.createXULElement("label", { + is: "text-link", + }); + + reauthCheckboxGroup.classList.add("indent"); + reauthLearnMore.classList.add("learnMore"); + reauthCheckbox.classList.add("tail-with-learn-more"); + reauthCheckbox.disabled = !FormAutofill.isAutofillCreditCardsEnabled; + + reauth.id = "creditCardReauthenticate"; + reauthLearnMore.id = "creditCardReauthenticateLearnMore"; + + reauth.setAttribute("data-subcategory", "reauth-credit-card-autofill"); + + let autofillReauthCheckboxLabel = "autofillReauthCheckbox"; + // We reuse the if/else order from wizard markup to increase + // odds of consistent behavior. + if (AppConstants.platform == "macosx") { + autofillReauthCheckboxLabel += "Mac"; + } else if (AppConstants.platform == "linux") { + autofillReauthCheckboxLabel += "Lin"; + } else { + autofillReauthCheckboxLabel += "Win"; + } + reauthCheckbox.setAttribute( + "label", + this.bundle.GetStringFromName(autofillReauthCheckboxLabel) + ); + reauthLearnMore.textContent = this.bundle.GetStringFromName( + "learnMoreLabel" + ); + + reauthLearnMore.setAttribute("href", reauthLearnMoreURL); + + // Manually set the checked state + if (FormAutofillUtils._reauthEnabledByUser) { + reauthCheckbox.setAttribute("checked", true); + } + + reauthCheckboxGroup.setAttribute("align", "center"); + reauthCheckboxGroup.flex = 1; + + formAutofillGroup.appendChild(reauth); + reauth.appendChild(reauthCheckboxGroup); + reauthCheckboxGroup.appendChild(reauthCheckbox); + reauthCheckboxGroup.appendChild(reauthLearnMore); + this.refs.reauthCheckbox = reauthCheckbox; + } + } + }, + + /** + * Handle events + * + * @param {DOMEvent} event + */ + async handleEvent(event) { + switch (event.type) { + case "command": { + let target = event.target; + + if (target == this.refs.addressAutofillCheckbox) { + // Set preference directly instead of relying on <Preference> + Services.prefs.setBoolPref( + ENABLED_AUTOFILL_ADDRESSES_PREF, + target.checked + ); + } else if (target == this.refs.creditCardAutofillCheckbox) { + Services.prefs.setBoolPref( + ENABLED_AUTOFILL_CREDITCARDS_PREF, + target.checked + ); + if (this.refs.reauthCheckbox) { + this.refs.reauthCheckbox.disabled = !target.checked; + } + } else if (target == this.refs.reauthCheckbox) { + if (!OSKeyStore.canReauth()) { + break; + } + + let messageTextId = "autofillReauthOSDialog"; + // We reuse the if/else order from wizard markup to increase + // odds of consistent behavior. + if (AppConstants.platform == "macosx") { + messageTextId += "Mac"; + } else if (AppConstants.platform == "linux") { + messageTextId += "Lin"; + } else { + messageTextId += "Win"; + } + + let messageText = this.bundle.GetStringFromName(messageTextId); + + const brandBundle = Services.strings.createBundle( + "chrome://branding/locale/brand.properties" + ); + let win = target.ownerGlobal.docShell.chromeEventHandler.ownerGlobal; + let loggedIn = await OSKeyStore.ensureLoggedIn( + messageText, + brandBundle.GetStringFromName("brandFullName"), + win, + false + ); + if (!loggedIn.authenticated) { + target.checked = !target.checked; + break; + } + + Services.prefs.setBoolPref( + ENABLED_AUTOFILL_CREDITCARDS_REAUTH_PREF, + target.checked + ); + } else if (target == this.refs.savedAddressesBtn) { + target.ownerGlobal.gSubDialog.open(MANAGE_ADDRESSES_URL); + } else if (target == this.refs.savedCreditCardsBtn) { + target.ownerGlobal.gSubDialog.open(MANAGE_CREDITCARDS_URL); + } + break; + } + case "click": { + let target = event.target; + + if (target == this.refs.addressAutofillCheckboxLabel) { + let pref = FormAutofill.isAutofillAddressesEnabled; + Services.prefs.setBoolPref(ENABLED_AUTOFILL_ADDRESSES_PREF, !pref); + this.refs.addressAutofillCheckbox.checked = !pref; + } else if (target == this.refs.creditCardAutofillCheckboxLabel) { + let pref = FormAutofill.isAutofillCreditCardsEnabled; + Services.prefs.setBoolPref(ENABLED_AUTOFILL_CREDITCARDS_PREF, !pref); + this.refs.creditCardAutofillCheckbox.checked = !pref; + this.refs.reauthCheckbox.disabled = pref; + } + break; + } + } + }, + + /** + * Attach event listener + */ + attachEventListeners() { + this.refs.formAutofillGroup.addEventListener("command", this); + this.refs.formAutofillGroup.addEventListener("click", this); + }, + + /** + * Remove event listener + */ + detachEventListeners() { + this.refs.formAutofillGroup.removeEventListener("command", this); + this.refs.formAutofillGroup.removeEventListener("click", this); + }, +}; diff --git a/browser/extensions/formautofill/FormAutofillStorage.jsm b/browser/extensions/formautofill/FormAutofillStorage.jsm new file mode 100644 index 0000000000..1d153f2752 --- /dev/null +++ b/browser/extensions/formautofill/FormAutofillStorage.jsm @@ -0,0 +1,2203 @@ +/* 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/. */ + +/* + * Implements an interface of the storage of Form Autofill. + * + * The data is stored in JSON format, without indentation and the computed + * fields, using UTF-8 encoding. With indentation and computed fields applied, + * the schema would look like this: + * + * { + * version: 1, + * addresses: [ + * { + * guid, // 12 characters + * version, // schema version in integer + * + * // address fields + * given-name, + * additional-name, + * family-name, + * organization, // Company + * street-address, // (Multiline) + * address-level3, // Suburb/Sublocality + * address-level2, // City/Town + * address-level1, // Province (Standardized code if possible) + * postal-code, + * country, // ISO 3166 + * tel, // Stored in E.164 format + * email, + * + * // computed fields (These fields are computed based on the above fields + * // and are not allowed to be modified directly.) + * name, + * address-line1, + * address-line2, + * address-line3, + * country-name, + * tel-country-code, + * tel-national, + * tel-area-code, + * tel-local, + * tel-local-prefix, + * tel-local-suffix, + * + * // metadata + * timeCreated, // in ms + * timeLastUsed, // in ms + * timeLastModified, // in ms + * timesUsed + * _sync: { ... optional sync metadata }, + * } + * ], + * creditCards: [ + * { + * guid, // 12 characters + * version, // schema version in integer + * + * // credit card fields + * billingAddressGUID, // An optional GUID of an autofill address record + * which may or may not exist locally. + * + * cc-name, + * cc-number, // will be stored in masked format (************1234) + * // (see details below) + * cc-exp-month, + * cc-exp-year, // 2-digit year will be converted to 4 digits + * // upon saving + * cc-type, // Optional card network id (instrument type) + * + * // computed fields (These fields are computed based on the above fields + * // and are not allowed to be modified directly.) + * cc-given-name, + * cc-additional-name, + * cc-family-name, + * cc-number-encrypted, // encrypted from the original unmasked "cc-number" + * // (see details below) + * cc-exp, + * + * // metadata + * timeCreated, // in ms + * timeLastUsed, // in ms + * timeLastModified, // in ms + * timesUsed + * _sync: { ... optional sync metadata }, + * } + * ] + * } + * + * + * Encrypt-related Credit Card Fields (cc-number & cc-number-encrypted): + * + * When saving or updating a credit-card record, the storage will encrypt the + * value of "cc-number", store the encrypted number in "cc-number-encrypted" + * field, and replace "cc-number" field with the masked number. These all happen + * in "computeFields". We do reverse actions in "_stripComputedFields", which + * decrypts "cc-number-encrypted", restores it to "cc-number", and deletes + * "cc-number-encrypted". Therefore, calling "_stripComputedFields" followed by + * "computeFields" can make sure the encrypt-related fields are up-to-date. + * + * In general, you have to decrypt the number by your own outside FormAutofillStorage + * when necessary. However, you will get the decrypted records when querying + * data with "rawData=true" to ensure they're ready to sync. + * + * + * Sync Metadata: + * + * Records may also have a _sync field, which consists of: + * { + * changeCounter, // integer - the number of changes made since the last + * // sync. + * lastSyncedFields, // object - hashes of the original values for fields + * // changed since the last sync. + * } + * + * Records with such a field have previously been synced. Records without such + * a field are yet to be synced, so are treated specially in some cases (eg, + * they don't need a tombstone, de-duping logic treats them as special etc). + * Records without the field are always considered "dirty" from Sync's POV + * (meaning they will be synced on the next sync), at which time they will gain + * this new field. + */ + +"use strict"; + +// We expose a singleton from this module. Some tests may import the +// constructor via a backstage pass. +this.EXPORTED_SYMBOLS = ["formAutofillStorage"]; + +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); +const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); +const { OS } = ChromeUtils.import("resource://gre/modules/osfile.jsm"); + +const { FormAutofill } = ChromeUtils.import( + "resource://formautofill/FormAutofill.jsm" +); + +ChromeUtils.defineModuleGetter( + this, + "CreditCard", + "resource://gre/modules/CreditCard.jsm" +); +ChromeUtils.defineModuleGetter( + this, + "JSONFile", + "resource://gre/modules/JSONFile.jsm" +); +ChromeUtils.defineModuleGetter( + this, + "FormAutofillNameUtils", + "resource://formautofill/FormAutofillNameUtils.jsm" +); +ChromeUtils.defineModuleGetter( + this, + "FormAutofillUtils", + "resource://formautofill/FormAutofillUtils.jsm" +); +ChromeUtils.defineModuleGetter( + this, + "OSKeyStore", + "resource://gre/modules/OSKeyStore.jsm" +); +ChromeUtils.defineModuleGetter( + this, + "PhoneNumber", + "resource://formautofill/phonenumberutils/PhoneNumber.jsm" +); + +XPCOMUtils.defineLazyServiceGetter( + this, + "gUUIDGenerator", + "@mozilla.org/uuid-generator;1", + "nsIUUIDGenerator" +); + +const CryptoHash = Components.Constructor( + "@mozilla.org/security/hash;1", + "nsICryptoHash", + "initWithString" +); + +const PROFILE_JSON_FILE_NAME = "autofill-profiles.json"; + +const STORAGE_SCHEMA_VERSION = 1; +const ADDRESS_SCHEMA_VERSION = 1; +const CREDIT_CARD_SCHEMA_VERSION = 3; + +const VALID_ADDRESS_FIELDS = [ + "given-name", + "additional-name", + "family-name", + "organization", + "street-address", + "address-level3", + "address-level2", + "address-level1", + "postal-code", + "country", + "tel", + "email", +]; + +const STREET_ADDRESS_COMPONENTS = [ + "address-line1", + "address-line2", + "address-line3", +]; + +const TEL_COMPONENTS = [ + "tel-country-code", + "tel-national", + "tel-area-code", + "tel-local", + "tel-local-prefix", + "tel-local-suffix", +]; + +const VALID_ADDRESS_COMPUTED_FIELDS = ["name", "country-name"].concat( + STREET_ADDRESS_COMPONENTS, + TEL_COMPONENTS +); + +const VALID_CREDIT_CARD_FIELDS = [ + "billingAddressGUID", + "cc-name", + "cc-number", + "cc-exp-month", + "cc-exp-year", + "cc-type", +]; + +const VALID_CREDIT_CARD_COMPUTED_FIELDS = [ + "cc-given-name", + "cc-additional-name", + "cc-family-name", + "cc-number-encrypted", + "cc-exp", +]; + +const INTERNAL_FIELDS = [ + "guid", + "version", + "timeCreated", + "timeLastUsed", + "timeLastModified", + "timesUsed", +]; + +function sha512(string) { + if (string == null) { + return null; + } + let encoder = new TextEncoder("utf-8"); + let bytes = encoder.encode(string); + let hash = new CryptoHash("sha512"); + hash.update(bytes, bytes.length); + return hash.finish(/* base64 */ true); +} + +/** + * Class that manipulates records in a specified collection. + * + * Note that it is responsible for converting incoming data to a consistent + * format in the storage. For example, computed fields will be transformed to + * the original fields and 2-digit years will be calculated into 4 digits. + */ +class AutofillRecords { + /** + * Creates an AutofillRecords. + * + * @param {JSONFile} store + * An instance of JSONFile. + * @param {string} collectionName + * A key of "store.data". + * @param {Array.<string>} validFields + * A list containing non-metadata field names. + * @param {Array.<string>} validComputedFields + * A list containing computed field names. + * @param {number} schemaVersion + * The schema version for the new record. + */ + constructor( + store, + collectionName, + validFields, + validComputedFields, + schemaVersion + ) { + FormAutofill.defineLazyLogGetter(this, "AutofillRecords:" + collectionName); + + this.VALID_FIELDS = validFields; + this.VALID_COMPUTED_FIELDS = validComputedFields; + + this._store = store; + this._collectionName = collectionName; + this._schemaVersion = schemaVersion; + + this._initializePromise = Promise.all( + this._data.map(async (record, index) => + this._migrateRecord(record, index) + ) + ).then(hasChangesArr => { + let dataHasChanges = hasChangesArr.includes(true); + if (dataHasChanges) { + this._store.saveSoon(); + } + }); + } + + /** + * Gets the schema version number. + * + * @returns {number} + * The current schema version number. + */ + get version() { + return this._schemaVersion; + } + + /** + * Gets the data of this collection. + * + * @returns {array} + * The data object. + */ + get _data() { + return this._store.data[this._collectionName]; + } + + // Ensures that we don't try to apply synced records with newer schema + // versions. This is a temporary measure to ensure we don't accidentally + // bump the schema version without a syncing strategy in place (bug 1377204). + _ensureMatchingVersion(record) { + if (record.version != this.version) { + throw new Error( + `Got unknown record version ${record.version}; want ${this.version}` + ); + } + } + + /** + * Initialize the records in the collection, resolves when the migration completes. + * @returns {Promise} + */ + initialize() { + return this._initializePromise; + } + + /** + * Adds a new record. + * + * @param {Object} record + * The new record for saving. + * @param {boolean} [options.sourceSync = false] + * Did sync generate this addition? + * @returns {Promise<string>} + * The GUID of the newly added item.. + */ + async add(record, { sourceSync = false } = {}) { + this.log.debug("add:", record); + + let recordToSave = this._clone(record); + + if (sourceSync) { + // Remove tombstones for incoming items that were changed on another + // device. Local deletions always lose to avoid data loss. + let index = this._findIndexByGUID(recordToSave.guid, { + includeDeleted: true, + }); + if (index > -1) { + let existing = this._data[index]; + if (existing.deleted) { + this._data.splice(index, 1); + } else { + throw new Error(`Record ${recordToSave.guid} already exists`); + } + } + } else if (!recordToSave.deleted) { + this._normalizeRecord(recordToSave); + // _normalizeRecord shouldn't do any validation (throw) because in the + // `update` case it is called with partial records whereas + // `_validateFields` is called with a complete one. + this._validateFields(recordToSave); + + recordToSave.guid = this._generateGUID(); + recordToSave.version = this.version; + + // Metadata + let now = Date.now(); + recordToSave.timeCreated = now; + recordToSave.timeLastModified = now; + recordToSave.timeLastUsed = 0; + recordToSave.timesUsed = 0; + } + + return this._saveRecord(recordToSave, { sourceSync }); + } + + async _saveRecord(record, { sourceSync = false } = {}) { + if (!record.guid) { + throw new Error("Record missing GUID"); + } + + let recordToSave; + if (record.deleted) { + if (this._findByGUID(record.guid, { includeDeleted: true })) { + throw new Error("a record with this GUID already exists"); + } + recordToSave = { + guid: record.guid, + timeLastModified: record.timeLastModified || Date.now(), + deleted: true, + }; + } else { + this._ensureMatchingVersion(record); + recordToSave = record; + await this.computeFields(recordToSave); + } + + if (sourceSync) { + let sync = this._getSyncMetaData(recordToSave, true); + sync.changeCounter = 0; + } + + this._data.push(recordToSave); + + this.updateUseCountTelemetry(); + + this._store.saveSoon(); + + Services.obs.notifyObservers( + { + wrappedJSObject: { + sourceSync, + guid: record.guid, + collectionName: this._collectionName, + }, + }, + "formautofill-storage-changed", + "add" + ); + return recordToSave.guid; + } + + _generateGUID() { + let guid; + while (!guid || this._findByGUID(guid)) { + guid = gUUIDGenerator + .generateUUID() + .toString() + .replace(/[{}-]/g, "") + .substring(0, 12); + } + return guid; + } + + /** + * Update the specified record. + * + * @param {string} guid + * Indicates which record to update. + * @param {Object} record + * The new record used to overwrite the old one. + * @param {Promise<boolean>} [preserveOldProperties = false] + * Preserve old record's properties if they don't exist in new record. + */ + async update(guid, record, preserveOldProperties = false) { + this.log.debug("update:", guid, record); + + let recordFoundIndex = this._findIndexByGUID(guid); + if (recordFoundIndex == -1) { + throw new Error("No matching record."); + } + + // Clone the record before modifying it to avoid exposing incomplete changes. + let recordFound = this._clone(this._data[recordFoundIndex]); + await this._stripComputedFields(recordFound); + + let recordToUpdate = this._clone(record); + this._normalizeRecord(recordToUpdate, true); + + let hasValidField = false; + for (let field of this.VALID_FIELDS) { + let oldValue = recordFound[field]; + let newValue = recordToUpdate[field]; + + // Resume the old field value in the perserve case + if (preserveOldProperties && newValue === undefined) { + newValue = oldValue; + } + + if (newValue === undefined || newValue === "") { + delete recordFound[field]; + } else { + hasValidField = true; + recordFound[field] = newValue; + } + + this._maybeStoreLastSyncedField(recordFound, field, oldValue); + } + + if (!hasValidField) { + throw new Error("Record contains no valid field."); + } + + // _normalizeRecord above is called with the `record` argument provided to + // `update` which may not contain all resulting fields when + // `preserveOldProperties` is used. This means we need to validate for + // missing fields after we compose the record (`recordFound`) with the stored + // record like we do in the loop above. + this._validateFields(recordFound); + + recordFound.timeLastModified = Date.now(); + let syncMetadata = this._getSyncMetaData(recordFound); + if (syncMetadata) { + syncMetadata.changeCounter += 1; + } + + await this.computeFields(recordFound); + this._data[recordFoundIndex] = recordFound; + + this._store.saveSoon(); + + Services.obs.notifyObservers( + { + wrappedJSObject: { + guid, + collectionName: this._collectionName, + }, + }, + "formautofill-storage-changed", + "update" + ); + } + + /** + * Notifies the storage of the use of the specified record, so we can update + * the metadata accordingly. This does not bump the Sync change counter, since + * we don't sync `timesUsed` or `timeLastUsed`. + * + * @param {string} guid + * Indicates which record to be notified. + */ + notifyUsed(guid) { + this.log.debug("notifyUsed:", guid); + + let recordFound = this._findByGUID(guid); + if (!recordFound) { + throw new Error("No matching record."); + } + + recordFound.timesUsed++; + recordFound.timeLastUsed = Date.now(); + + this.updateUseCountTelemetry(); + + this._store.saveSoon(); + Services.obs.notifyObservers( + { + wrappedJSObject: { + guid, + collectionName: this._collectionName, + }, + }, + "formautofill-storage-changed", + "notifyUsed" + ); + } + + updateUseCountTelemetry() {} + + /** + * Removes the specified record. No error occurs if the record isn't found. + * + * @param {string} guid + * Indicates which record to remove. + * @param {boolean} [options.sourceSync = false] + * Did Sync generate this removal? + */ + remove(guid, { sourceSync = false } = {}) { + this.log.debug("remove:", guid); + + if (sourceSync) { + this._removeSyncedRecord(guid); + } else { + let index = this._findIndexByGUID(guid, { includeDeleted: false }); + if (index == -1) { + this.log.warn("attempting to remove non-existing entry", guid); + return; + } + let existing = this._data[index]; + if (existing.deleted) { + return; // already a tombstone - don't touch it. + } + let existingSync = this._getSyncMetaData(existing); + if (existingSync) { + // existing sync metadata means it has been synced. This means we must + // leave a tombstone behind. + this._data[index] = { + guid, + timeLastModified: Date.now(), + deleted: true, + _sync: existingSync, + }; + existingSync.changeCounter++; + } else { + // If there's no sync meta-data, this record has never been synced, so + // we can delete it. + this._data.splice(index, 1); + } + } + + this.updateUseCountTelemetry(); + + this._store.saveSoon(); + Services.obs.notifyObservers( + { + wrappedJSObject: { + sourceSync, + guid, + collectionName: this._collectionName, + }, + }, + "formautofill-storage-changed", + "remove" + ); + } + + /** + * Returns the record with the specified GUID. + * + * @param {string} guid + * Indicates which record to retrieve. + * @param {boolean} [options.rawData = false] + * Returns a raw record without modifications and the computed fields + * (this includes private fields) + * @returns {Promise<Object>} + * A clone of the record. + */ + async get(guid, { rawData = false } = {}) { + this.log.debug("get:", guid, rawData); + + let recordFound = this._findByGUID(guid); + if (!recordFound) { + return null; + } + + // The record is cloned to avoid accidental modifications from outside. + let clonedRecord = this._cloneAndCleanUp(recordFound); + if (rawData) { + await this._stripComputedFields(clonedRecord); + } else { + this._recordReadProcessor(clonedRecord); + } + return clonedRecord; + } + + /** + * Returns all records. + * + * @param {boolean} [options.rawData = false] + * Returns raw records without modifications and the computed fields. + * @param {boolean} [options.includeDeleted = false] + * Also return any tombstone records. + * @returns {Promise<Array.<Object>>} + * An array containing clones of all records. + */ + async getAll({ rawData = false, includeDeleted = false } = {}) { + this.log.debug("getAll", rawData, includeDeleted); + + let records = this._data.filter(r => !r.deleted || includeDeleted); + // Records are cloned to avoid accidental modifications from outside. + let clonedRecords = records.map(r => this._cloneAndCleanUp(r)); + await Promise.all( + clonedRecords.map(async record => { + if (rawData) { + await this._stripComputedFields(record); + } else { + this._recordReadProcessor(record); + } + }) + ); + return clonedRecords; + } + + /** + * Return all saved field names in the collection. This method + * has to be sync because its caller _updateSavedFieldNames() needs + * to dispatch content message synchronously. + * + * @returns {Set} Set containing saved field names. + */ + getSavedFieldNames() { + this.log.debug("getSavedFieldNames"); + + let records = this._data.filter(r => !r.deleted); + records + .map(record => this._cloneAndCleanUp(record)) + .forEach(record => this._recordReadProcessor(record)); + + let fieldNames = new Set(); + for (let record of records) { + for (let fieldName of Object.keys(record)) { + if (INTERNAL_FIELDS.includes(fieldName) || !record[fieldName]) { + continue; + } + fieldNames.add(fieldName); + } + } + + return fieldNames; + } + + /** + * Functions intended to be used in the support of Sync. + */ + + /** + * Stores a hash of the last synced value for a field in a locally updated + * record. We use this value to rebuild the shared parent, or base, when + * reconciling incoming records that may have changed on another device. + * + * Storing the hash of the values that we last wrote to the Sync server lets + * us determine if a remote change conflicts with a local change. If the + * hashes for the base, current local value, and remote value all differ, we + * have a conflict. + * + * These fields are not themselves synced, and will be removed locally as + * soon as we have successfully written the record to the Sync server - so + * it is expected they will not remain for long, as changes which cause a + * last synced field to be written will itself cause a sync. + * + * We also skip this for updates made by Sync, for internal fields, for + * records that haven't been uploaded yet, and for fields which have already + * been changed since the last sync. + * + * @param {Object} record + * The updated local record. + * @param {string} field + * The field name. + * @param {string} lastSyncedValue + * The last synced field value. + */ + _maybeStoreLastSyncedField(record, field, lastSyncedValue) { + let sync = this._getSyncMetaData(record); + if (!sync) { + // The record hasn't been uploaded yet, so we can't end up with merge + // conflicts. + return; + } + let alreadyChanged = field in sync.lastSyncedFields; + if (alreadyChanged) { + // This field was already changed multiple times since the last sync. + return; + } + let newValue = record[field]; + if (lastSyncedValue != newValue) { + sync.lastSyncedFields[field] = sha512(lastSyncedValue); + } + } + + /** + * Attempts a three-way merge between a changed local record, an incoming + * remote record, and the shared parent that we synthesize from the last + * synced fields - see _maybeStoreLastSyncedField. + * + * @param {Object} strippedLocalRecord + * The changed local record, currently in storage. Computed fields + * are stripped. + * @param {Object} remoteRecord + * The remote record. + * @returns {Object|null} + * The merged record, or `null` if there are conflicts and the + * records can't be merged. + */ + _mergeSyncedRecords(strippedLocalRecord, remoteRecord) { + let sync = this._getSyncMetaData(strippedLocalRecord, true); + + // Copy all internal fields from the remote record. We'll update their + // values in `_replaceRecordAt`. + let mergedRecord = {}; + for (let field of INTERNAL_FIELDS) { + if (remoteRecord[field] != null) { + mergedRecord[field] = remoteRecord[field]; + } + } + + for (let field of this.VALID_FIELDS) { + let isLocalSame = false; + let isRemoteSame = false; + if (field in sync.lastSyncedFields) { + // If the field has changed since the last sync, compare hashes to + // determine if the local and remote values are different. Hashing is + // expensive, but we don't expect this to happen frequently. + let lastSyncedValue = sync.lastSyncedFields[field]; + isLocalSame = lastSyncedValue == sha512(strippedLocalRecord[field]); + isRemoteSame = lastSyncedValue == sha512(remoteRecord[field]); + } else { + // Otherwise, if the field hasn't changed since the last sync, we know + // it's the same locally. + isLocalSame = true; + isRemoteSame = strippedLocalRecord[field] == remoteRecord[field]; + } + + let value; + if (isLocalSame && isRemoteSame) { + // Local and remote are the same; doesn't matter which one we pick. + value = strippedLocalRecord[field]; + } else if (isLocalSame && !isRemoteSame) { + value = remoteRecord[field]; + } else if (!isLocalSame && isRemoteSame) { + // We don't need to bump the change counter when taking the local + // change, because the counter must already be > 0 if we're attempting + // a three-way merge. + value = strippedLocalRecord[field]; + } else if (strippedLocalRecord[field] == remoteRecord[field]) { + // Shared parent doesn't match either local or remote, but the values + // are identical, so there's no conflict. + value = strippedLocalRecord[field]; + } else { + // Both local and remote changed to different values. We'll need to fork + // the local record to resolve the conflict. + return null; + } + + if (value != null) { + mergedRecord[field] = value; + } + } + + return mergedRecord; + } + + /** + * Replaces a local record with a remote or merged record, copying internal + * fields and Sync metadata. + * + * @param {number} index + * @param {Object} remoteRecord + * @param {Promise<boolean>} [options.keepSyncMetadata = false] + * Should we copy Sync metadata? This is true if `remoteRecord` is a + * merged record with local changes that we need to upload. Passing + * `keepSyncMetadata` retains the record's change counter and + * last synced fields, so that we don't clobber the local change if + * the sync is interrupted after the record is merged, but before + * it's uploaded. + */ + async _replaceRecordAt( + index, + remoteRecord, + { keepSyncMetadata = false } = {} + ) { + let localRecord = this._data[index]; + let newRecord = this._clone(remoteRecord); + + await this._stripComputedFields(newRecord); + + this._data[index] = newRecord; + + if (keepSyncMetadata) { + // It's safe to move the Sync metadata from the old record to the new + // record, since we always clone records when we return them, and we + // never hand out references to the metadata object via public methods. + newRecord._sync = localRecord._sync; + } else { + // As a side effect, `_getSyncMetaData` marks the record as syncing if the + // existing `localRecord` is a dupe of `remoteRecord`, and we're replacing + // local with remote. + let sync = this._getSyncMetaData(newRecord, true); + sync.changeCounter = 0; + } + + if ( + !newRecord.timeCreated || + localRecord.timeCreated < newRecord.timeCreated + ) { + newRecord.timeCreated = localRecord.timeCreated; + } + + if ( + !newRecord.timeLastModified || + localRecord.timeLastModified > newRecord.timeLastModified + ) { + newRecord.timeLastModified = localRecord.timeLastModified; + } + + // Copy local-only fields from the existing local record. + for (let field of ["timeLastUsed", "timesUsed"]) { + if (localRecord[field] != null) { + newRecord[field] = localRecord[field]; + } + } + + await this.computeFields(newRecord); + } + + /** + * Clones a local record, giving the clone a new GUID and Sync metadata. The + * original record remains unchanged in storage. + * + * @param {Object} strippedLocalRecord + * The local record. Computed fields are stripped. + * @returns {string} + * A clone of the local record with a new GUID. + */ + async _forkLocalRecord(strippedLocalRecord) { + let forkedLocalRecord = this._cloneAndCleanUp(strippedLocalRecord); + forkedLocalRecord.guid = this._generateGUID(); + + // Give the record fresh Sync metadata and bump its change counter as a + // side effect. This also excludes the forked record from de-duping on the + // next sync, if the current sync is interrupted before the record can be + // uploaded. + this._getSyncMetaData(forkedLocalRecord, true); + + await this.computeFields(forkedLocalRecord); + this._data.push(forkedLocalRecord); + + return forkedLocalRecord; + } + + /** + * Reconciles an incoming remote record into the matching local record. This + * method is only used by Sync; other callers should use `merge`. + * + * @param {Object} remoteRecord + * The incoming record. `remoteRecord` must not be a tombstone, and + * must have a matching local record with the same GUID. Use + * `add` to insert remote records that don't exist locally, and + * `remove` to apply remote tombstones. + * @returns {Promise<Object>} + * A `{forkedGUID}` tuple. `forkedGUID` is `null` if the merge + * succeeded without conflicts, or a new GUID referencing the + * existing locally modified record if the conflicts could not be + * resolved. + */ + async reconcile(remoteRecord) { + this._ensureMatchingVersion(remoteRecord); + if (remoteRecord.deleted) { + throw new Error(`Can't reconcile tombstone ${remoteRecord.guid}`); + } + + let localIndex = this._findIndexByGUID(remoteRecord.guid); + if (localIndex < 0) { + throw new Error(`Record ${remoteRecord.guid} not found`); + } + + let localRecord = this._data[localIndex]; + let sync = this._getSyncMetaData(localRecord, true); + + let forkedGUID = null; + + if (sync.changeCounter === 0) { + // Local not modified. Replace local with remote. + await this._replaceRecordAt(localIndex, remoteRecord, { + keepSyncMetadata: false, + }); + } else { + let strippedLocalRecord = this._clone(localRecord); + await this._stripComputedFields(strippedLocalRecord); + + let mergedRecord = this._mergeSyncedRecords( + strippedLocalRecord, + remoteRecord + ); + if (mergedRecord) { + // Local and remote modified, but we were able to merge. Replace the + // local record with the merged record. + await this._replaceRecordAt(localIndex, mergedRecord, { + keepSyncMetadata: true, + }); + } else { + // Merge conflict. Fork the local record, then replace the original + // with the merged record. + let forkedLocalRecord = await this._forkLocalRecord( + strippedLocalRecord + ); + forkedGUID = forkedLocalRecord.guid; + await this._replaceRecordAt(localIndex, remoteRecord, { + keepSyncMetadata: false, + }); + } + } + + this._store.saveSoon(); + Services.obs.notifyObservers( + { + wrappedJSObject: { + sourceSync: true, + guid: remoteRecord.guid, + forkedGUID, + collectionName: this._collectionName, + }, + }, + "formautofill-storage-changed", + "reconcile" + ); + + return { forkedGUID }; + } + + _removeSyncedRecord(guid) { + let index = this._findIndexByGUID(guid, { includeDeleted: true }); + if (index == -1) { + // Removing a record we don't know about. It may have been synced and + // removed by another device before we saw it. Store the tombstone in + // case the server is later wiped and we need to reupload everything. + let tombstone = { + guid, + timeLastModified: Date.now(), + deleted: true, + }; + + let sync = this._getSyncMetaData(tombstone, true); + sync.changeCounter = 0; + this._data.push(tombstone); + return; + } + + let existing = this._data[index]; + let sync = this._getSyncMetaData(existing, true); + if (sync.changeCounter > 0) { + // Deleting a record with unsynced local changes. To avoid potential + // data loss, we ignore the deletion in favor of the changed record. + this.log.info( + "Ignoring deletion for record with local changes", + existing + ); + return; + } + + if (existing.deleted) { + this.log.info("Ignoring deletion for tombstone", existing); + return; + } + + // Removing a record that's not changed locally, and that's not already + // deleted. Replace the record with a synced tombstone. + this._data[index] = { + guid, + timeLastModified: Date.now(), + deleted: true, + _sync: sync, + }; + } + + /** + * Provide an object that describes the changes to sync. + * + * This is called at the start of the sync process to determine what needs + * to be updated on the server. As the server is updated, sync will update + * entries in the returned object, and when sync is complete it will pass + * the object to pushSyncChanges, which will apply the changes to the store. + * + * @returns {object} + * An object describing the changes to sync. + */ + pullSyncChanges() { + let changes = {}; + + let profiles = this._data; + for (let profile of profiles) { + let sync = this._getSyncMetaData(profile, true); + if (sync.changeCounter < 1) { + if (sync.changeCounter != 0) { + this.log.error("negative change counter", profile); + } + continue; + } + changes[profile.guid] = { + profile, + counter: sync.changeCounter, + modified: profile.timeLastModified, + synced: false, + }; + } + this._store.saveSoon(); + + return changes; + } + + /** + * Apply the metadata changes made by Sync. + * + * This is called with metadata about what was synced - see pullSyncChanges. + * + * @param {object} changes + * The possibly modified object obtained via pullSyncChanges. + */ + pushSyncChanges(changes) { + for (let [guid, { counter, synced }] of Object.entries(changes)) { + if (!synced) { + continue; + } + let recordFound = this._findByGUID(guid, { includeDeleted: true }); + if (!recordFound) { + this.log.warn("No profile found to persist changes for guid " + guid); + continue; + } + let sync = this._getSyncMetaData(recordFound, true); + sync.changeCounter = Math.max(0, sync.changeCounter - counter); + if (sync.changeCounter === 0) { + // Clear the shared parent fields once we've uploaded all pending + // changes, since the server now matches what we have locally. + sync.lastSyncedFields = {}; + } + } + this._store.saveSoon(); + } + + /** + * Reset all sync metadata for all items. + * + * This is called when Sync is disconnected from this device. All sync + * metadata for all items is removed. + */ + resetSync() { + for (let record of this._data) { + delete record._sync; + } + // XXX - we should probably also delete all tombstones? + this.log.info("All sync metadata was reset"); + } + + /** + * Changes the GUID of an item. This should be called only by Sync. There + * must be an existing record with oldID and it must never have been synced + * or an error will be thrown. There must be no existing record with newID. + * + * No tombstone will be created for the old GUID - we check it hasn't + * been synced, so no tombstone is necessary. + * + * @param {string} oldID + * GUID of the existing item to change the GUID of. + * @param {string} newID + * The new GUID for the item. + */ + changeGUID(oldID, newID) { + this.log.debug("changeGUID: ", oldID, newID); + if (oldID == newID) { + throw new Error("changeGUID: old and new IDs are the same"); + } + if (this._findIndexByGUID(newID) >= 0) { + throw new Error("changeGUID: record with destination id exists already"); + } + + let index = this._findIndexByGUID(oldID); + let profile = this._data[index]; + if (!profile) { + throw new Error("changeGUID: no source record"); + } + if (this._getSyncMetaData(profile)) { + throw new Error("changeGUID: existing record has already been synced"); + } + + profile.guid = newID; + + this._store.saveSoon(); + } + + // Used to get, and optionally create, sync metadata. Brand new records will + // *not* have sync meta-data - it will be created when they are first + // synced. + _getSyncMetaData(record, forceCreate = false) { + if (!record._sync && forceCreate) { + // create default metadata and indicate we need to save. + record._sync = { + changeCounter: 1, + lastSyncedFields: {}, + }; + this._store.saveSoon(); + } + return record._sync; + } + + /** + * Finds a local record with matching common fields and a different GUID. + * Sync uses this method to find and update unsynced local records with + * fields that match incoming remote records. This avoids creating + * duplicate profiles with the same information. + * + * @param {Object} remoteRecord + * The remote record. + * @returns {Promise<string|null>} + * The GUID of the matching local record, or `null` if no records + * match. + */ + async findDuplicateGUID(remoteRecord) { + if (!remoteRecord.guid) { + throw new Error("Record missing GUID"); + } + this._ensureMatchingVersion(remoteRecord); + if (remoteRecord.deleted) { + // Tombstones don't carry enough info to de-dupe, and we should have + // handled them separately when applying the record. + throw new Error("Tombstones can't have duplicates"); + } + let localRecords = this._data; + for (let localRecord of localRecords) { + if (localRecord.deleted) { + continue; + } + if (localRecord.guid == remoteRecord.guid) { + throw new Error(`Record ${remoteRecord.guid} already exists`); + } + if (this._getSyncMetaData(localRecord)) { + // This local record has already been uploaded, so it can't be a dupe of + // another incoming item. + continue; + } + + // Ignore computed fields when matching records as they aren't synced at all. + let strippedLocalRecord = this._clone(localRecord); + await this._stripComputedFields(strippedLocalRecord); + + let keys = new Set(Object.keys(remoteRecord)); + for (let key of Object.keys(strippedLocalRecord)) { + keys.add(key); + } + // Ignore internal fields when matching records. Internal fields are synced, + // but almost certainly have different values than the local record, and + // we'll update them in `reconcile`. + for (let field of INTERNAL_FIELDS) { + keys.delete(field); + } + if (!keys.size) { + // This shouldn't ever happen; a valid record will always have fields + // that aren't computed or internal. Sync can't do anything about that, + // so we ignore the dubious local record instead of throwing. + continue; + } + let same = true; + for (let key of keys) { + // For now, we ensure that both (or neither) records have the field + // with matching values. This doesn't account for the version yet + // (bug 1377204). + same = + key in strippedLocalRecord == key in remoteRecord && + strippedLocalRecord[key] == remoteRecord[key]; + if (!same) { + break; + } + } + if (same) { + return strippedLocalRecord.guid; + } + } + return null; + } + + /** + * Internal helper functions. + */ + + _clone(record) { + return Object.assign({}, record); + } + + _cloneAndCleanUp(record) { + let result = {}; + for (let key in record) { + // Do not expose hidden fields and fields with empty value (mainly used + // as placeholders of the computed fields). + if (!key.startsWith("_") && record[key] !== "") { + result[key] = record[key]; + } + } + return result; + } + + _findByGUID(guid, { includeDeleted = false } = {}) { + let found = this._findIndexByGUID(guid, { includeDeleted }); + return found < 0 ? undefined : this._data[found]; + } + + _findIndexByGUID(guid, { includeDeleted = false } = {}) { + return this._data.findIndex(record => { + return record.guid == guid && (!record.deleted || includeDeleted); + }); + } + + async _migrateRecord(record, index) { + let hasChanges = false; + + if (record.deleted) { + return hasChanges; + } + + if (!record.version || isNaN(record.version) || record.version < 1) { + this.log.warn("Invalid record version:", record.version); + + // Force to run the migration. + record.version = 0; + } + + if (record.version < this.version) { + hasChanges = true; + + record = await this._computeMigratedRecord(record); + + if (record.deleted) { + // record is deleted by _computeMigratedRecord(), + // go ahead and put it in the store. + this._data[index] = record; + return hasChanges; + } + + // Compute the computed fields before putting it to store. + await this.computeFields(record); + this._data[index] = record; + + return hasChanges; + } + + hasChanges |= await this.computeFields(record); + return hasChanges; + } + + _normalizeRecord(record, preserveEmptyFields = false) { + this._normalizeFields(record); + + for (let key in record) { + if (!this.VALID_FIELDS.includes(key)) { + throw new Error(`"${key}" is not a valid field.`); + } + if (typeof record[key] !== "string" && typeof record[key] !== "number") { + throw new Error( + `"${key}" contains invalid data type: ${typeof record[key]}` + ); + } + if (!preserveEmptyFields && record[key] === "") { + delete record[key]; + } + } + + if (!Object.keys(record).length) { + throw new Error("Record contains no valid field."); + } + } + + /** + * Merge the record if storage has multiple mergeable records. + * @param {Object} targetRecord + * The record for merge. + * @param {boolean} [strict = false] + * In strict merge mode, we'll treat the subset record with empty field + * as unable to be merged, but mergeable if in non-strict mode. + * @returns {Array.<string>} + * Return an array of the merged GUID string. + */ + async mergeToStorage(targetRecord, strict = false) { + let mergedGUIDs = []; + for (let record of this._data) { + if ( + !record.deleted && + (await this.mergeIfPossible(record.guid, targetRecord, strict)) + ) { + mergedGUIDs.push(record.guid); + } + } + this.log.debug( + "Existing records matching and merging count is", + mergedGUIDs.length + ); + return mergedGUIDs; + } + + /** + * Unconditionally remove all data and tombstones for this collection. + */ + removeAll({ sourceSync = false } = {}) { + this._store.data[this._collectionName] = []; + this._store.saveSoon(); + Services.obs.notifyObservers( + { + wrappedJSObject: { + sourceSync, + collectionName: this._collectionName, + }, + }, + "formautofill-storage-changed", + "removeAll" + ); + } + + /** + * Strip the computed fields based on the record version. + * @param {Object} record The record to migrate + * @returns {Object} Migrated record. + * Record is always cloned, with version updated, + * with computed fields stripped. + * Could be a tombstone record, if the record + * should be discorded. + */ + async _computeMigratedRecord(record) { + if (!record.deleted) { + record = this._clone(record); + await this._stripComputedFields(record); + record.version = this.version; + } + return record; + } + + async _stripComputedFields(record) { + this.VALID_COMPUTED_FIELDS.forEach(field => delete record[field]); + } + + // An interface to be inherited. + _recordReadProcessor(record) {} + + // An interface to be inherited. + async computeFields(record) {} + + /** + * An interface to be inherited to mutate the argument to normalize it. + * + * @param {object} partialRecord containing the record passed by the consumer of + * storage and in the case of `update` with + * `preserveOldProperties` will only include the + * properties that the user is changing so the + * lack of a field doesn't mean that the record + * won't have that field. + */ + _normalizeFields(partialRecord) {} + + /** + * An interface to be inherited to validate that the complete record is + * consistent and isn't missing required fields. Overrides should throw for + * invalid records. + * + * @param {object} record containing the complete record that would be stored + * if this doesn't throw due to an error. + * @throws + */ + _validateFields(record) {} + + // An interface to be inherited. + async mergeIfPossible(guid, record, strict) {} +} + +class Addresses extends AutofillRecords { + constructor(store) { + super( + store, + "addresses", + VALID_ADDRESS_FIELDS, + VALID_ADDRESS_COMPUTED_FIELDS, + ADDRESS_SCHEMA_VERSION + ); + } + + _recordReadProcessor(address) { + if (address.country && !FormAutofill.countries.has(address.country)) { + delete address.country; + delete address["country-name"]; + } + } + + async computeFields(address) { + // NOTE: Remember to bump the schema version number if any of the existing + // computing algorithm changes. (No need to bump when just adding new + // computed fields.) + + // NOTE: Computed fields should be always present in the storage no matter + // it's empty or not. + + let hasNewComputedFields = false; + + if (address.deleted) { + return hasNewComputedFields; + } + + // Compute name + if (!("name" in address)) { + let name = FormAutofillNameUtils.joinNameParts({ + given: address["given-name"], + middle: address["additional-name"], + family: address["family-name"], + }); + address.name = name; + hasNewComputedFields = true; + } + + // Compute address lines + if (!("address-line1" in address)) { + let streetAddress = []; + if (address["street-address"]) { + streetAddress = address["street-address"] + .split("\n") + .map(s => s.trim()); + } + for (let i = 0; i < 3; i++) { + address["address-line" + (i + 1)] = streetAddress[i] || ""; + } + if (streetAddress.length > 3) { + address["address-line3"] = FormAutofillUtils.toOneLineAddress( + streetAddress.splice(2) + ); + } + hasNewComputedFields = true; + } + + // Compute country name + if (!("country-name" in address)) { + if (address.country) { + try { + address[ + "country-name" + ] = Services.intl.getRegionDisplayNames(undefined, [address.country]); + } catch (e) { + address["country-name"] = ""; + } + } else { + address["country-name"] = ""; + } + hasNewComputedFields = true; + } + + // Compute tel + if (!("tel-national" in address)) { + if (address.tel) { + let tel = PhoneNumber.Parse( + address.tel, + address.country || FormAutofill.DEFAULT_REGION + ); + if (tel) { + if (tel.countryCode) { + address["tel-country-code"] = tel.countryCode; + } + if (tel.nationalNumber) { + address["tel-national"] = tel.nationalNumber; + } + + // PhoneNumberUtils doesn't support parsing the components of a telephone + // number so we hard coded the parser for US numbers only. We will need + // to figure out how to parse numbers from other regions when we support + // new countries in the future. + if (tel.nationalNumber && tel.countryCode == "+1") { + let telComponents = tel.nationalNumber.match( + /(\d{3})((\d{3})(\d{4}))$/ + ); + if (telComponents) { + address["tel-area-code"] = telComponents[1]; + address["tel-local"] = telComponents[2]; + address["tel-local-prefix"] = telComponents[3]; + address["tel-local-suffix"] = telComponents[4]; + } + } + } else { + // Treat "tel" as "tel-national" directly if it can't be parsed. + address["tel-national"] = address.tel; + } + } + + TEL_COMPONENTS.forEach(c => { + address[c] = address[c] || ""; + }); + } + + return hasNewComputedFields; + } + + _normalizeFields(address) { + this._normalizeName(address); + this._normalizeAddress(address); + this._normalizeCountry(address); + this._normalizeTel(address); + } + + _normalizeName(address) { + if (address.name) { + let nameParts = FormAutofillNameUtils.splitName(address.name); + if (!address["given-name"] && nameParts.given) { + address["given-name"] = nameParts.given; + } + if (!address["additional-name"] && nameParts.middle) { + address["additional-name"] = nameParts.middle; + } + if (!address["family-name"] && nameParts.family) { + address["family-name"] = nameParts.family; + } + } + delete address.name; + } + + _normalizeAddress(address) { + if (STREET_ADDRESS_COMPONENTS.some(c => !!address[c])) { + // Treat "street-address" as "address-line1" if it contains only one line + // and "address-line1" is omitted. + if ( + !address["address-line1"] && + address["street-address"] && + !address["street-address"].includes("\n") + ) { + address["address-line1"] = address["street-address"]; + delete address["street-address"]; + } + + // Concatenate "address-line*" if "street-address" is omitted. + if (!address["street-address"]) { + address["street-address"] = STREET_ADDRESS_COMPONENTS.map( + c => address[c] + ) + .join("\n") + .replace(/\n+$/, ""); + } + } + STREET_ADDRESS_COMPONENTS.forEach(c => delete address[c]); + } + + _normalizeCountry(address) { + let country; + + if (address.country) { + country = address.country.toUpperCase(); + } else if (address["country-name"]) { + country = FormAutofillUtils.identifyCountryCode(address["country-name"]); + } + + // Only values included in the region list will be saved. + let hasLocalizedName = false; + try { + if (country) { + let localizedName = Services.intl.getRegionDisplayNames(undefined, [ + country, + ]); + hasLocalizedName = localizedName != country; + } + } catch (e) {} + + if (country && hasLocalizedName) { + address.country = country; + } else { + delete address.country; + } + + delete address["country-name"]; + } + + _normalizeTel(address) { + if (address.tel || TEL_COMPONENTS.some(c => !!address[c])) { + FormAutofillUtils.compressTel(address); + + let possibleRegion = address.country || FormAutofill.DEFAULT_REGION; + let tel = PhoneNumber.Parse(address.tel, possibleRegion); + + if (tel && tel.internationalNumber) { + // Force to save numbers in E.164 format if parse success. + address.tel = tel.internationalNumber; + } + } + TEL_COMPONENTS.forEach(c => delete address[c]); + } + + /** + * Merge new address into the specified address if mergeable. + * + * @param {string} guid + * Indicates which address to merge. + * @param {Object} address + * The new address used to merge into the old one. + * @param {boolean} strict + * In strict merge mode, we'll treat the subset record with empty field + * as unable to be merged, but mergeable if in non-strict mode. + * @returns {Promise<boolean>} + * Return true if address is merged into target with specific guid or false if not. + */ + async mergeIfPossible(guid, address, strict) { + this.log.debug("mergeIfPossible:", guid, address); + + let addressFound = this._findByGUID(guid); + if (!addressFound) { + throw new Error("No matching address."); + } + + let addressToMerge = this._clone(address); + this._normalizeRecord(addressToMerge, strict); + let hasMatchingField = false; + + let country = + addressFound.country || + addressToMerge.country || + FormAutofill.DEFAULT_REGION; + let collators = FormAutofillUtils.getSearchCollators(country); + for (let field of this.VALID_FIELDS) { + let existingField = addressFound[field]; + let incomingField = addressToMerge[field]; + if (incomingField !== undefined && existingField !== undefined) { + if (incomingField != existingField) { + // Treat "street-address" as mergeable if their single-line versions + // match each other. + if ( + field == "street-address" && + FormAutofillUtils.compareStreetAddress( + existingField, + incomingField, + collators + ) + ) { + // Keep the street-address in storage if its amount of lines is greater than + // or equal to the incoming one. + if ( + existingField.split("\n").length >= + incomingField.split("\n").length + ) { + // Replace the incoming field with the one in storage so it will + // be further merged back to storage. + addressToMerge[field] = existingField; + } + } else if ( + field != "street-address" && + FormAutofillUtils.strCompare( + existingField, + incomingField, + collators + ) + ) { + addressToMerge[field] = existingField; + } else { + this.log.debug("Conflicts: field", field, "has different value."); + return false; + } + } + hasMatchingField = true; + } + } + + // We merge the address only when at least one field has the same value. + if (!hasMatchingField) { + this.log.debug("Unable to merge because no field has the same value"); + return false; + } + + // Early return if the data is the same or subset. + let noNeedToUpdate = this.VALID_FIELDS.every(field => { + // When addressFound doesn't contain a field, it's unnecessary to update + // if the same field in addressToMerge is omitted or an empty string. + if (addressFound[field] === undefined) { + return !addressToMerge[field]; + } + + // When addressFound contains a field, it's unnecessary to update if + // the same field in addressToMerge is omitted or a duplicate. + return ( + addressToMerge[field] === undefined || + addressFound[field] === addressToMerge[field] + ); + }); + if (noNeedToUpdate) { + return true; + } + + await this.update(guid, addressToMerge, true); + return true; + } +} + +class CreditCards extends AutofillRecords { + constructor(store) { + super( + store, + "creditCards", + VALID_CREDIT_CARD_FIELDS, + VALID_CREDIT_CARD_COMPUTED_FIELDS, + CREDIT_CARD_SCHEMA_VERSION + ); + Services.obs.addObserver(this, "formautofill-storage-changed"); + } + + observe(subject, topic, data) { + switch (topic) { + case "formautofill-storage-changed": + let count = this._data.filter(entry => !entry.deleted).length; + Services.telemetry.scalarSet( + "formautofill.creditCards.autofill_profiles_count", + count + ); + break; + } + } + + async computeFields(creditCard) { + // NOTE: Remember to bump the schema version number if any of the existing + // computing algorithm changes. (No need to bump when just adding new + // computed fields.) + + // NOTE: Computed fields should be always present in the storage no matter + // it's empty or not. + + let hasNewComputedFields = false; + + if (creditCard.deleted) { + return hasNewComputedFields; + } + + if ("cc-number" in creditCard && !("cc-type" in creditCard)) { + let type = CreditCard.getType(creditCard["cc-number"]); + if (type) { + creditCard["cc-type"] = type; + } + } + + // Compute split names + if (!("cc-given-name" in creditCard)) { + let nameParts = FormAutofillNameUtils.splitName(creditCard["cc-name"]); + creditCard["cc-given-name"] = nameParts.given; + creditCard["cc-additional-name"] = nameParts.middle; + creditCard["cc-family-name"] = nameParts.family; + hasNewComputedFields = true; + } + + // Compute credit card expiration date + if (!("cc-exp" in creditCard)) { + if (creditCard["cc-exp-month"] && creditCard["cc-exp-year"]) { + creditCard["cc-exp"] = + String(creditCard["cc-exp-year"]) + + "-" + + String(creditCard["cc-exp-month"]).padStart(2, "0"); + } else { + creditCard["cc-exp"] = ""; + } + hasNewComputedFields = true; + } + + // Encrypt credit card number + if (!("cc-number-encrypted" in creditCard)) { + if ("cc-number" in creditCard) { + let ccNumber = creditCard["cc-number"]; + if (CreditCard.isValidNumber(ccNumber)) { + creditCard["cc-number"] = CreditCard.getLongMaskedNumber(ccNumber); + } else { + // Credit card numbers can be entered on versions of Firefox that don't validate + // the number and then synced to this version of Firefox. Therefore, mask the + // full number if the number is invalid on this version. + creditCard["cc-number"] = "*".repeat(ccNumber.length); + } + creditCard["cc-number-encrypted"] = await OSKeyStore.encrypt(ccNumber); + } else { + creditCard["cc-number-encrypted"] = ""; + } + } + + return hasNewComputedFields; + } + + async _computeMigratedRecord(creditCard) { + if (creditCard["cc-number-encrypted"]) { + switch (creditCard.version) { + case 1: + case 2: { + // We cannot decrypt the data, so silently remove the record for + // the user. + if (creditCard.deleted) { + break; + } + + this.log.warn( + "Removing version", + creditCard.version, + "credit card record to migrate to new encryption:", + creditCard.guid + ); + + // Replace the record with a tombstone record here, + // regardless of existence of sync metadata. + let existingSync = this._getSyncMetaData(creditCard); + creditCard = { + guid: creditCard.guid, + timeLastModified: Date.now(), + deleted: true, + }; + + if (existingSync) { + creditCard._sync = existingSync; + existingSync.changeCounter++; + } + break; + } + + default: + throw new Error( + "Unknown credit card version to migrate: " + creditCard.version + ); + } + } + return super._computeMigratedRecord(creditCard); + } + + async _stripComputedFields(creditCard) { + if (creditCard["cc-number-encrypted"]) { + try { + creditCard["cc-number"] = await OSKeyStore.decrypt( + creditCard["cc-number-encrypted"] + ); + } catch (ex) { + if (ex.result == Cr.NS_ERROR_ABORT) { + throw ex; + } + // Quietly recover from encryption error, + // so existing credit card entry with undecryptable number + // can be updated. + } + } + await super._stripComputedFields(creditCard); + } + + _normalizeFields(creditCard) { + this._normalizeCCName(creditCard); + this._normalizeCCNumber(creditCard); + this._normalizeCCExpirationDate(creditCard); + } + + _normalizeCCName(creditCard) { + if ( + creditCard["cc-given-name"] || + creditCard["cc-additional-name"] || + creditCard["cc-family-name"] + ) { + if (!creditCard["cc-name"]) { + creditCard["cc-name"] = FormAutofillNameUtils.joinNameParts({ + given: creditCard["cc-given-name"], + middle: creditCard["cc-additional-name"], + family: creditCard["cc-family-name"], + }); + } + } + delete creditCard["cc-given-name"]; + delete creditCard["cc-additional-name"]; + delete creditCard["cc-family-name"]; + } + + _normalizeCCNumber(creditCard) { + if (!("cc-number" in creditCard)) { + return; + } + if (!CreditCard.isValidNumber(creditCard["cc-number"])) { + delete creditCard["cc-number"]; + return; + } + let card = new CreditCard({ number: creditCard["cc-number"] }); + creditCard["cc-number"] = card.number; + } + + _normalizeCCExpirationDate(creditCard) { + let normalizedExpiration = CreditCard.normalizeExpiration({ + expirationMonth: creditCard["cc-exp-month"], + expirationYear: creditCard["cc-exp-year"], + expirationString: creditCard["cc-exp"], + }); + if (normalizedExpiration.month) { + creditCard["cc-exp-month"] = normalizedExpiration.month; + } else { + delete creditCard["cc-exp-month"]; + } + if (normalizedExpiration.year) { + creditCard["cc-exp-year"] = normalizedExpiration.year; + } else { + delete creditCard["cc-exp-year"]; + } + delete creditCard["cc-exp"]; + } + + _validateFields(creditCard) { + if (!creditCard["cc-number"]) { + throw new Error("Missing/invalid cc-number"); + } + } + + _ensureMatchingVersion(record) { + if (!record.version || isNaN(record.version) || record.version < 1) { + throw new Error( + `Got invalid record version ${record.version}; want ${this.version}` + ); + } + + if (record.version < this.version) { + switch (record.version) { + case 1: + case 2: + // The difference between version 1 and 2 is only about the encryption + // method used for the cc-number-encrypted field. + // The difference between version 2 and 3 is the name of the OS + // key encryption record. + // As long as the record is already decrypted, it is safe to bump the + // version directly. + if (!record["cc-number-encrypted"]) { + record.version = this.version; + } else { + throw new Error( + "Could not migrate record version:", + record.version, + "->", + this.version + ); + } + break; + default: + throw new Error( + "Unknown credit card version to match: " + record.version + ); + } + } + + return super._ensureMatchingVersion(record); + } + + /** + * Normalize the given record and return the first matched guid if storage has the same record. + * @param {Object} targetCreditCard + * The credit card for duplication checking. + * @returns {Promise<string|null>} + * Return the first guid if storage has the same credit card and null otherwise. + */ + async getDuplicateGuid(targetCreditCard) { + let clonedTargetCreditCard = this._clone(targetCreditCard); + this._normalizeRecord(clonedTargetCreditCard); + if (!clonedTargetCreditCard["cc-number"]) { + return null; + } + + for (let creditCard of this._data) { + if (creditCard.deleted) { + continue; + } + + let decrypted = await OSKeyStore.decrypt( + creditCard["cc-number-encrypted"], + false + ); + + if (decrypted == clonedTargetCreditCard["cc-number"]) { + return creditCard.guid; + } + } + return null; + } + + /** + * Merge new credit card into the specified record if cc-number is identical. + * (Note that credit card records always do non-strict merge.) + * + * @param {string} guid + * Indicates which credit card to merge. + * @param {Object} creditCard + * The new credit card used to merge into the old one. + * @returns {boolean} + * Return true if credit card is merged into target with specific guid or false if not. + */ + async mergeIfPossible(guid, creditCard) { + this.log.debug("mergeIfPossible:", guid, creditCard); + + // Credit card number is required since it also must match. + if (!creditCard["cc-number"]) { + return false; + } + + // Query raw data for comparing the decrypted credit card number + let creditCardFound = await this.get(guid, { rawData: true }); + if (!creditCardFound) { + throw new Error("No matching credit card."); + } + + let creditCardToMerge = this._clone(creditCard); + this._normalizeRecord(creditCardToMerge); + + for (let field of this.VALID_FIELDS) { + let existingField = creditCardFound[field]; + + // Make sure credit card field is existed and have value + if ( + field == "cc-number" && + (!existingField || !creditCardToMerge[field]) + ) { + return false; + } + + if (!creditCardToMerge[field] && typeof existingField != "undefined") { + creditCardToMerge[field] = existingField; + } + + let incomingField = creditCardToMerge[field]; + if (incomingField && existingField) { + if (incomingField != existingField) { + this.log.debug("Conflicts: field", field, "has different value."); + return false; + } + } + } + + // Early return if the data is the same. + let exactlyMatch = this.VALID_FIELDS.every( + field => creditCardFound[field] === creditCardToMerge[field] + ); + if (exactlyMatch) { + return true; + } + + await this.update(guid, creditCardToMerge, true); + return true; + } + + updateUseCountTelemetry() { + let histogram = Services.telemetry.getHistogramById("CREDITCARD_NUM_USES"); + histogram.clear(); + + let records = this._data.filter(r => !r.deleted); + + for (let record of records) { + histogram.add(record.timesUsed); + } + } +} + +function FormAutofillStorage(path) { + this._path = path; + this._initializePromise = null; + this.INTERNAL_FIELDS = INTERNAL_FIELDS; +} + +FormAutofillStorage.prototype = { + get version() { + return STORAGE_SCHEMA_VERSION; + }, + + get addresses() { + if (!this._addresses) { + this._store.ensureDataReady(); + this._addresses = new Addresses(this._store); + } + return this._addresses; + }, + + get creditCards() { + if (!this._creditCards) { + this._store.ensureDataReady(); + this._creditCards = new CreditCards(this._store); + } + return this._creditCards; + }, + + /** + * Loads the profile data from file to memory. + * + * @returns {Promise} + * @resolves When the operation finished successfully. + * @rejects JavaScript exception. + */ + initialize() { + if (!this._initializePromise) { + this._store = new JSONFile({ + path: this._path, + dataPostProcessor: this._dataPostProcessor.bind(this), + }); + this._initializePromise = this._store.load().then(() => { + let initializeAutofillRecords = [this.addresses.initialize()]; + if (FormAutofill.isAutofillCreditCardsAvailable) { + initializeAutofillRecords.push(this.creditCards.initialize()); + } else { + // Make creditCards records unavailable to other modules + // because we never initialize it. + Object.defineProperty(this, "creditCards", { + get() { + throw new Error( + "CreditCards is not initialized. " + + "Please restart if you flip the pref manually." + ); + }, + }); + } + return Promise.all(initializeAutofillRecords); + }); + } + return this._initializePromise; + }, + + _dataPostProcessor(data) { + data.version = this.version; + if (!data.addresses) { + data.addresses = []; + } + if (!data.creditCards) { + data.creditCards = []; + } + return data; + }, + + // For test only. + _saveImmediately() { + return this._store._save(); + }, + + _finalize() { + return this._store.finalize(); + }, +}; + +// The singleton exposed by this module. +this.formAutofillStorage = new FormAutofillStorage( + OS.Path.join(OS.Constants.Path.profileDir, PROFILE_JSON_FILE_NAME) +); diff --git a/browser/extensions/formautofill/FormAutofillSync.jsm b/browser/extensions/formautofill/FormAutofillSync.jsm new file mode 100644 index 0000000000..9f27798f72 --- /dev/null +++ b/browser/extensions/formautofill/FormAutofillSync.jsm @@ -0,0 +1,391 @@ +/* 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 EXPORTED_SYMBOLS = ["AddressesEngine", "CreditCardsEngine"]; + +const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); +const { Changeset, Store, SyncEngine, Tracker } = ChromeUtils.import( + "resource://services-sync/engines.js" +); +const { CryptoWrapper } = ChromeUtils.import( + "resource://services-sync/record.js" +); +const { Utils } = ChromeUtils.import("resource://services-sync/util.js"); +const { SCORE_INCREMENT_XLARGE } = ChromeUtils.import( + "resource://services-sync/constants.js" +); + +ChromeUtils.defineModuleGetter(this, "Log", "resource://gre/modules/Log.jsm"); +ChromeUtils.defineModuleGetter( + this, + "formAutofillStorage", + "resource://formautofill/FormAutofillStorage.jsm" +); + +// A helper to sanitize address and creditcard records suitable for logging. +function sanitizeStorageObject(ob) { + if (!ob) { + return null; + } + const allowList = ["timeCreated", "timeLastUsed", "timeLastModified"]; + let result = {}; + for (let key of Object.keys(ob)) { + let origVal = ob[key]; + if (allowList.includes(key)) { + result[key] = origVal; + } else if (typeof origVal == "string") { + result[key] = "X".repeat(origVal.length); + } else { + result[key] = typeof origVal; // *shrug* + } + } + return result; +} + +function AutofillRecord(collection, id) { + CryptoWrapper.call(this, collection, id); +} + +AutofillRecord.prototype = { + __proto__: CryptoWrapper.prototype, + + toEntry() { + return Object.assign( + { + guid: this.id, + }, + this.entry + ); + }, + + fromEntry(entry) { + this.id = entry.guid; + this.entry = entry; + // The GUID is already stored in record.id, so we nuke it from the entry + // itself to save a tiny bit of space. The formAutofillStorage clones profiles, + // so nuking in-place is OK. + delete this.entry.guid; + }, + + cleartextToString() { + // And a helper so logging a *Sync* record auto sanitizes. + let record = this.cleartext; + return JSON.stringify({ entry: sanitizeStorageObject(record.entry) }); + }, +}; + +// Profile data is stored in the "entry" object of the record. +Utils.deferGetSet(AutofillRecord, "cleartext", ["entry"]); + +function FormAutofillStore(name, engine) { + Store.call(this, name, engine); +} + +FormAutofillStore.prototype = { + __proto__: Store.prototype, + + _subStorageName: null, // overridden below. + _storage: null, + + get storage() { + if (!this._storage) { + this._storage = formAutofillStorage[this._subStorageName]; + } + return this._storage; + }, + + async getAllIDs() { + let result = {}; + for (let { guid } of await this.storage.getAll({ includeDeleted: true })) { + result[guid] = true; + } + return result; + }, + + async changeItemID(oldID, newID) { + this.storage.changeGUID(oldID, newID); + }, + + // Note: this function intentionally returns false in cases where we only have + // a (local) tombstone - and formAutofillStorage.get() filters them for us. + async itemExists(id) { + return Boolean(await this.storage.get(id)); + }, + + async applyIncoming(remoteRecord) { + if (remoteRecord.deleted) { + this._log.trace("Deleting record", remoteRecord); + this.storage.remove(remoteRecord.id, { sourceSync: true }); + return; + } + + if (await this.itemExists(remoteRecord.id)) { + // We will never get a tombstone here, so we are updating a real record. + await this._doUpdateRecord(remoteRecord); + return; + } + + // No matching local record. Try to dedupe a NEW local record. + let localDupeID = await this.storage.findDuplicateGUID( + remoteRecord.toEntry() + ); + if (localDupeID) { + this._log.trace( + `Deduping local record ${localDupeID} to remote`, + remoteRecord + ); + // Change the local GUID to match the incoming record, then apply the + // incoming record. + await this.changeItemID(localDupeID, remoteRecord.id); + await this._doUpdateRecord(remoteRecord); + return; + } + + // We didn't find a dupe, either, so must be a new record (or possibly + // a non-deleted version of an item we have a tombstone for, which add() + // handles for us.) + this._log.trace("Add record", remoteRecord); + let entry = remoteRecord.toEntry(); + await this.storage.add(entry, { sourceSync: true }); + }, + + async createRecord(id, collection) { + this._log.trace("Create record", id); + let record = new AutofillRecord(collection, id); + let entry = await this.storage.get(id, { + rawData: true, + }); + if (entry) { + record.fromEntry(entry); + } else { + // We should consider getting a more authortative indication it's actually deleted. + this._log.debug( + `Failed to get autofill record with id "${id}", assuming deleted` + ); + record.deleted = true; + } + return record; + }, + + async _doUpdateRecord(record) { + this._log.trace("Updating record", record); + + let entry = record.toEntry(); + let { forkedGUID } = await this.storage.reconcile(entry); + if (this._log.level <= Log.Level.Debug) { + let forkedRecord = forkedGUID ? await this.storage.get(forkedGUID) : null; + let reconciledRecord = await this.storage.get(record.id); + this._log.debug("Updated local record", { + forked: sanitizeStorageObject(forkedRecord), + updated: sanitizeStorageObject(reconciledRecord), + }); + } + }, + + // NOTE: Because we re-implement the incoming/reconcilliation logic we leave + // the |create|, |remove| and |update| methods undefined - the base + // implementation throws, which is what we want to happen so we can identify + // any places they are "accidentally" called. +}; + +function FormAutofillTracker(name, engine) { + Tracker.call(this, name, engine); +} + +FormAutofillTracker.prototype = { + __proto__: Tracker.prototype, + async observe(subject, topic, data) { + if (topic != "formautofill-storage-changed") { + return; + } + if ( + subject && + subject.wrappedJSObject && + subject.wrappedJSObject.sourceSync + ) { + return; + } + switch (data) { + case "add": + case "update": + case "remove": + this.score += SCORE_INCREMENT_XLARGE; + break; + default: + this._log.debug("unrecognized autofill notification", data); + break; + } + }, + + onStart() { + Services.obs.addObserver(this, "formautofill-storage-changed"); + }, + + onStop() { + Services.obs.removeObserver(this, "formautofill-storage-changed"); + }, +}; + +// This uses the same conventions as BookmarkChangeset in +// services/sync/modules/engines/bookmarks.js. Specifically, +// - "synced" means the item has already been synced (or we have another reason +// to ignore it), and should be ignored in most methods. +class AutofillChangeset extends Changeset { + constructor() { + super(); + } + + getModifiedTimestamp(id) { + throw new Error("Don't use timestamps to resolve autofill merge conflicts"); + } + + has(id) { + let change = this.changes[id]; + if (change) { + return !change.synced; + } + return false; + } + + delete(id) { + let change = this.changes[id]; + if (change) { + // Mark the change as synced without removing it from the set. We do this + // so that we can update FormAutofillStorage in `trackRemainingChanges`. + change.synced = true; + } + } +} + +function FormAutofillEngine(service, name) { + SyncEngine.call(this, name, service); +} + +FormAutofillEngine.prototype = { + __proto__: SyncEngine.prototype, + + // the priority for this engine is == addons, so will happen after bookmarks + // prefs and tabs, but before forms, history, etc. + syncPriority: 5, + + // We don't use SyncEngine.initialize() for this, as we initialize even if + // the engine is disabled, and we don't want to be the loader of + // FormAutofillStorage in this case. + async _syncStartup() { + await formAutofillStorage.initialize(); + await SyncEngine.prototype._syncStartup.call(this); + }, + + // We handle reconciliation in the store, not the engine. + async _reconcile() { + return true; + }, + + emptyChangeset() { + return new AutofillChangeset(); + }, + + async _uploadOutgoing() { + this._modified.replace(this._store.storage.pullSyncChanges()); + await SyncEngine.prototype._uploadOutgoing.call(this); + }, + + // Typically, engines populate the changeset before downloading records. + // However, we handle conflict resolution in the store, so we can wait + // to pull changes until we're ready to upload. + async pullAllChanges() { + return {}; + }, + + async pullNewChanges() { + return {}; + }, + + async trackRemainingChanges() { + this._store.storage.pushSyncChanges(this._modified.changes); + }, + + _deleteId(id) { + this._noteDeletedId(id); + }, + + async _resetClient() { + await formAutofillStorage.initialize(); + this._store.storage.resetSync(); + }, + + async _wipeClient() { + await formAutofillStorage.initialize(); + this._store.storage.removeAll({ sourceSync: true }); + }, +}; + +// The concrete engines + +function AddressesRecord(collection, id) { + AutofillRecord.call(this, collection, id); +} + +AddressesRecord.prototype = { + __proto__: AutofillRecord.prototype, + _logName: "Sync.Record.Addresses", +}; + +function AddressesStore(name, engine) { + FormAutofillStore.call(this, name, engine); +} + +AddressesStore.prototype = { + __proto__: FormAutofillStore.prototype, + _subStorageName: "addresses", +}; + +function AddressesEngine(service) { + FormAutofillEngine.call(this, service, "Addresses"); +} + +AddressesEngine.prototype = { + __proto__: FormAutofillEngine.prototype, + _trackerObj: FormAutofillTracker, + _storeObj: AddressesStore, + _recordObj: AddressesRecord, + + get prefName() { + return "addresses"; + }, +}; + +function CreditCardsRecord(collection, id) { + AutofillRecord.call(this, collection, id); +} + +CreditCardsRecord.prototype = { + __proto__: AutofillRecord.prototype, + _logName: "Sync.Record.CreditCards", +}; + +function CreditCardsStore(name, engine) { + FormAutofillStore.call(this, name, engine); +} + +CreditCardsStore.prototype = { + __proto__: FormAutofillStore.prototype, + _subStorageName: "creditCards", +}; + +function CreditCardsEngine(service) { + FormAutofillEngine.call(this, service, "CreditCards"); +} + +CreditCardsEngine.prototype = { + __proto__: FormAutofillEngine.prototype, + _trackerObj: FormAutofillTracker, + _storeObj: CreditCardsStore, + _recordObj: CreditCardsRecord, + get prefName() { + return "creditcards"; + }, +}; diff --git a/browser/extensions/formautofill/FormAutofillUtils.jsm b/browser/extensions/formautofill/FormAutofillUtils.jsm new file mode 100644 index 0000000000..d765c3a2eb --- /dev/null +++ b/browser/extensions/formautofill/FormAutofillUtils.jsm @@ -0,0 +1,1125 @@ +/* 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 EXPORTED_SYMBOLS = ["FormAutofillUtils", "AddressDataLoader"]; + +const ADDRESS_METADATA_PATH = "resource://formautofill/addressmetadata/"; +const ADDRESS_REFERENCES = "addressReferences.js"; +const ADDRESS_REFERENCES_EXT = "addressReferencesExt.js"; + +const ADDRESSES_COLLECTION_NAME = "addresses"; +const CREDITCARDS_COLLECTION_NAME = "creditCards"; +const MANAGE_ADDRESSES_KEYWORDS = [ + "manageAddressesTitle", + "addNewAddressTitle", +]; +const EDIT_ADDRESS_KEYWORDS = [ + "givenName", + "additionalName", + "familyName", + "organization2", + "streetAddress", + "state", + "province", + "city", + "country", + "zip", + "postalCode", + "email", + "tel", +]; +const MANAGE_CREDITCARDS_KEYWORDS = [ + "manageCreditCardsTitle", + "addNewCreditCardTitle", +]; +const EDIT_CREDITCARD_KEYWORDS = [ + "cardNumber", + "nameOnCard", + "cardExpiresMonth", + "cardExpiresYear", + "cardNetwork", +]; +const FIELD_STATES = { + NORMAL: "NORMAL", + AUTO_FILLED: "AUTO_FILLED", + PREVIEW: "PREVIEW", +}; +const SECTION_TYPES = { + ADDRESS: "address", + CREDIT_CARD: "creditCard", +}; + +// The maximum length of data to be saved in a single field for preventing DoS +// attacks that fill the user's hard drive(s). +const MAX_FIELD_VALUE_LENGTH = 200; + +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); +const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); +const { FormAutofill } = ChromeUtils.import( + "resource://formautofill/FormAutofill.jsm" +); +XPCOMUtils.defineLazyModuleGetters(this, { + CreditCard: "resource://gre/modules/CreditCard.jsm", + OSKeyStore: "resource://gre/modules/OSKeyStore.jsm", +}); + +let AddressDataLoader = { + // Status of address data loading. We'll load all the countries with basic level 1 + // information while requesting conutry information, and set country to true. + // Level 1 Set is for recording which country's level 1/level 2 data is loaded, + // since we only load this when getCountryAddressData called with level 1 parameter. + _dataLoaded: { + country: false, + level1: new Set(), + }, + + /** + * Load address data and extension script into a sandbox from different paths. + * @param {string} path + * The path for address data and extension script. It could be root of the address + * metadata folder(addressmetadata/) or under specific country(addressmetadata/TW/). + * @returns {object} + * A sandbox that contains address data object with properties from extension. + */ + _loadScripts(path) { + let sandbox = {}; + let extSandbox = {}; + + try { + sandbox = FormAutofillUtils.loadDataFromScript(path + ADDRESS_REFERENCES); + extSandbox = FormAutofillUtils.loadDataFromScript( + path + ADDRESS_REFERENCES_EXT + ); + } catch (e) { + // Will return only address references if extension loading failed or empty sandbox if + // address references loading failed. + return sandbox; + } + + if (extSandbox.addressDataExt) { + for (let key in extSandbox.addressDataExt) { + let addressDataForKey = sandbox.addressData[key]; + if (!addressDataForKey) { + addressDataForKey = sandbox.addressData[key] = {}; + } + + Object.assign(addressDataForKey, extSandbox.addressDataExt[key]); + } + } + return sandbox; + }, + + /** + * Convert certain properties' string value into array. We should make sure + * the cached data is parsed. + * @param {object} data Original metadata from addressReferences. + * @returns {object} parsed metadata with property value that converts to array. + */ + _parse(data) { + if (!data) { + return null; + } + + const properties = [ + "languages", + "sub_keys", + "sub_isoids", + "sub_names", + "sub_lnames", + ]; + for (let key of properties) { + if (!data[key]) { + continue; + } + // No need to normalize data if the value is array already. + if (Array.isArray(data[key])) { + return data; + } + + data[key] = data[key].split("~"); + } + return data; + }, + + /** + * We'll cache addressData in the loader once the data loaded from scripts. + * It'll become the example below after loading addressReferences with extension: + * addressData: { + * "data/US": {"lang": ["en"], ...// Data defined in libaddressinput metadata + * "alternative_names": ... // Data defined in extension } + * "data/CA": {} // Other supported country metadata + * "data/TW": {} // Other supported country metadata + * "data/TW/台北市": {} // Other supported country level 1 metadata + * } + * @param {string} country + * @param {string?} level1 + * @returns {object} Default locale metadata + */ + _loadData(country, level1 = null) { + // Load the addressData if needed + if (!this._dataLoaded.country) { + this._addressData = this._loadScripts(ADDRESS_METADATA_PATH).addressData; + this._dataLoaded.country = true; + } + if (!level1) { + return this._parse(this._addressData[`data/${country}`]); + } + // If level1 is set, load addressReferences under country folder with specific + // country/level 1 for level 2 information. + if (!this._dataLoaded.level1.has(country)) { + Object.assign( + this._addressData, + this._loadScripts(`${ADDRESS_METADATA_PATH}${country}/`).addressData + ); + this._dataLoaded.level1.add(country); + } + return this._parse(this._addressData[`data/${country}/${level1}`]); + }, + + /** + * Return the region metadata with default locale and other locales (if exists). + * @param {string} country + * @param {string?} level1 + * @returns {object} Return default locale and other locales metadata. + */ + getData(country, level1 = null) { + let defaultLocale = this._loadData(country, level1); + if (!defaultLocale) { + return null; + } + + let countryData = this._parse(this._addressData[`data/${country}`]); + let locales = []; + // TODO: Should be able to support multi-locale level 1/ level 2 metadata query + // in Bug 1421886 + if (countryData.languages) { + let list = countryData.languages.filter(key => key !== countryData.lang); + locales = list.map(key => + this._parse(this._addressData[`${defaultLocale.id}--${key}`]) + ); + } + return { defaultLocale, locales }; + }, +}; + +this.FormAutofillUtils = { + get AUTOFILL_FIELDS_THRESHOLD() { + return 3; + }, + + ADDRESSES_COLLECTION_NAME, + CREDITCARDS_COLLECTION_NAME, + MANAGE_ADDRESSES_KEYWORDS, + EDIT_ADDRESS_KEYWORDS, + MANAGE_CREDITCARDS_KEYWORDS, + EDIT_CREDITCARD_KEYWORDS, + MAX_FIELD_VALUE_LENGTH, + FIELD_STATES, + SECTION_TYPES, + + _fieldNameInfo: { + name: "name", + "given-name": "name", + "additional-name": "name", + "family-name": "name", + organization: "organization", + "street-address": "address", + "address-line1": "address", + "address-line2": "address", + "address-line3": "address", + "address-level1": "address", + "address-level2": "address", + "postal-code": "address", + country: "address", + "country-name": "address", + tel: "tel", + "tel-country-code": "tel", + "tel-national": "tel", + "tel-area-code": "tel", + "tel-local": "tel", + "tel-local-prefix": "tel", + "tel-local-suffix": "tel", + "tel-extension": "tel", + email: "email", + "cc-name": "creditCard", + "cc-given-name": "creditCard", + "cc-additional-name": "creditCard", + "cc-family-name": "creditCard", + "cc-number": "creditCard", + "cc-exp-month": "creditCard", + "cc-exp-year": "creditCard", + "cc-exp": "creditCard", + "cc-type": "creditCard", + }, + + _collators: {}, + _reAlternativeCountryNames: {}, + + isAddressField(fieldName) { + return ( + !!this._fieldNameInfo[fieldName] && !this.isCreditCardField(fieldName) + ); + }, + + isCreditCardField(fieldName) { + return this._fieldNameInfo[fieldName] == "creditCard"; + }, + + isCCNumber(ccNumber) { + return CreditCard.isValidNumber(ccNumber); + }, + + ensureLoggedIn(promptMessage) { + return OSKeyStore.ensureLoggedIn( + this._reauthEnabledByUser && promptMessage ? promptMessage : false + ); + }, + + /** + * Get the array of credit card network ids ("types") we expect and offer as valid choices + * + * @returns {Array} + */ + getCreditCardNetworks() { + return CreditCard.SUPPORTED_NETWORKS; + }, + + getCategoryFromFieldName(fieldName) { + return this._fieldNameInfo[fieldName]; + }, + + getCategoriesFromFieldNames(fieldNames) { + let categories = new Set(); + for (let fieldName of fieldNames) { + let info = this.getCategoryFromFieldName(fieldName); + if (info) { + categories.add(info); + } + } + return Array.from(categories); + }, + + getAddressSeparator() { + // The separator should be based on the L10N address format, and using a + // white space is a temporary solution. + return " "; + }, + + /** + * Get address display label. It should display information separated + * by a comma. + * + * @param {object} address + * @param {string?} addressFields Override the fields which can be displayed, but not the order. + * @returns {string} + */ + getAddressLabel(address, addressFields = null) { + // TODO: Implement a smarter way for deciding what to display + // as option text. Possibly improve the algorithm in + // ProfileAutoCompleteResult.jsm and reuse it here. + let fieldOrder = [ + "name", + "-moz-street-address-one-line", // Street address + "address-level3", // Townland / Neighborhood / Village + "address-level2", // City/Town + "organization", // Company or organization name + "address-level1", // Province/State (Standardized code if possible) + "country-name", // Country name + "postal-code", // Postal code + "tel", // Phone number + "email", // Email address + ]; + + address = { ...address }; + let parts = []; + if (addressFields) { + let requiredFields = addressFields.trim().split(/\s+/); + fieldOrder = fieldOrder.filter(name => requiredFields.includes(name)); + } + if (address["street-address"]) { + address["-moz-street-address-one-line"] = this.toOneLineAddress( + address["street-address"] + ); + } + for (const fieldName of fieldOrder) { + let string = address[fieldName]; + if (string) { + parts.push(string); + } + if (parts.length == 2 && !addressFields) { + break; + } + } + return parts.join(", "); + }, + + /** + * Internal method to split an address to multiple parts per the provided delimiter, + * removing blank parts. + * @param {string} address The address the split + * @param {string} [delimiter] The separator that is used between lines in the address + * @returns {string[]} + */ + _toStreetAddressParts(address, delimiter = "\n") { + let array = typeof address == "string" ? address.split(delimiter) : address; + + if (!Array.isArray(array)) { + return []; + } + return array.map(s => (s ? s.trim() : "")).filter(s => s); + }, + + /** + * Converts a street address to a single line, removing linebreaks marked by the delimiter + * @param {string} address The address the convert + * @param {string} [delimiter] The separator that is used between lines in the address + * @returns {string} + */ + toOneLineAddress(address, delimiter = "\n") { + let addressParts = this._toStreetAddressParts(address, delimiter); + return addressParts.join(this.getAddressSeparator()); + }, + + /** + * Compares two addresses, removing internal whitespace + * @param {string} a The first address to compare + * @param {string} b The second address to compare + * @param {array} collators Search collators that will be used for comparison + * @param {string} [delimiter="\n"] The separator that is used between lines in the address + * @returns {boolean} True if the addresses are equal, false otherwise + */ + compareStreetAddress(a, b, collators, delimiter = "\n") { + let oneLineA = this._toStreetAddressParts(a, delimiter) + .map(p => p.replace(/\s/g, "")) + .join(""); + let oneLineB = this._toStreetAddressParts(b, delimiter) + .map(p => p.replace(/\s/g, "")) + .join(""); + return this.strCompare(oneLineA, oneLineB, collators); + }, + + /** + * In-place concatenate tel-related components into a single "tel" field and + * delete unnecessary fields. + * @param {object} address An address record. + */ + compressTel(address) { + let telCountryCode = address["tel-country-code"] || ""; + let telAreaCode = address["tel-area-code"] || ""; + + if (!address.tel) { + if (address["tel-national"]) { + address.tel = telCountryCode + address["tel-national"]; + } else if (address["tel-local"]) { + address.tel = telCountryCode + telAreaCode + address["tel-local"]; + } else if (address["tel-local-prefix"] && address["tel-local-suffix"]) { + address.tel = + telCountryCode + + telAreaCode + + address["tel-local-prefix"] + + address["tel-local-suffix"]; + } + } + + for (let field in address) { + if (field != "tel" && this.getCategoryFromFieldName(field) == "tel") { + delete address[field]; + } + } + }, + + autofillFieldSelector(doc) { + return doc.querySelectorAll("input, select"); + }, + + ALLOWED_TYPES: ["text", "email", "tel", "number", "month"], + isFieldEligibleForAutofill(element) { + let tagName = element.tagName; + if (tagName == "INPUT") { + // `element.type` can be recognized as `text`, if it's missing or invalid. + if (!this.ALLOWED_TYPES.includes(element.type)) { + return false; + } + } else if (tagName != "SELECT") { + return false; + } + + return true; + }, + + loadDataFromScript(url, sandbox = {}) { + Services.scriptloader.loadSubScript(url, sandbox); + return sandbox; + }, + + /** + * Get country address data and fallback to US if not found. + * See AddressDataLoader._loadData for more details of addressData structure. + * @param {string} [country=FormAutofill.DEFAULT_REGION] + * The country code for requesting specific country's metadata. It'll be + * default region if parameter is not set. + * @param {string} [level1=null] + * Return address level 1/level 2 metadata if parameter is set. + * @returns {object|null} + * Return metadata of specific region with default locale and other supported + * locales. We need to return a default country metadata for layout format + * and collator, but for sub-region metadata we'll just return null if not found. + */ + getCountryAddressRawData( + country = FormAutofill.DEFAULT_REGION, + level1 = null + ) { + let metadata = AddressDataLoader.getData(country, level1); + if (!metadata) { + if (level1) { + return null; + } + // Fallback to default region if we couldn't get data from given country. + if (country != FormAutofill.DEFAULT_REGION) { + metadata = AddressDataLoader.getData(FormAutofill.DEFAULT_REGION); + } + } + + // TODO: Now we fallback to US if we couldn't get data from default region, + // but it could be removed in bug 1423464 if it's not necessary. + if (!metadata) { + metadata = AddressDataLoader.getData("US"); + } + return metadata; + }, + + /** + * Get country address data with default locale. + * @param {string} country + * @param {string} level1 + * @returns {object|null} Return metadata of specific region with default locale. + * NOTE: The returned data may be for a default region if the + * specified one cannot be found. Callers who only want the specific + * region should check the returned country code. + */ + getCountryAddressData(country, level1) { + let metadata = this.getCountryAddressRawData(country, level1); + return metadata && metadata.defaultLocale; + }, + + /** + * Get country address data with all locales. + * @param {string} country + * @param {string} level1 + * @returns {array<object>|null} + * Return metadata of specific region with all the locales. + * NOTE: The returned data may be for a default region if the + * specified one cannot be found. Callers who only want the specific + * region should check the returned country code. + */ + getCountryAddressDataWithLocales(country, level1) { + let metadata = this.getCountryAddressRawData(country, level1); + return metadata && [metadata.defaultLocale, ...metadata.locales]; + }, + + /** + * Get the collators based on the specified country. + * @param {string} country The specified country. + * @returns {array} An array containing several collator objects. + */ + getSearchCollators(country) { + // TODO: Only one language should be used at a time per country. The locale + // of the page should be taken into account to do this properly. + // We are going to support more countries in bug 1370193 and this + // should be addressed when we start to implement that bug. + + if (!this._collators[country]) { + let dataset = this.getCountryAddressData(country); + let languages = dataset.languages || [dataset.lang]; + let options = { + ignorePunctuation: true, + sensitivity: "base", + usage: "search", + }; + this._collators[country] = languages.map( + lang => new Intl.Collator(lang, options) + ); + } + return this._collators[country]; + }, + + // Based on the list of fields abbreviations in + // https://github.com/googlei18n/libaddressinput/wiki/AddressValidationMetadata + FIELDS_LOOKUP: { + N: "name", + O: "organization", + A: "street-address", + S: "address-level1", + C: "address-level2", + D: "address-level3", + Z: "postal-code", + n: "newLine", + }, + + /** + * Parse a country address format string and outputs an array of fields. + * Spaces, commas, and other literals are ignored in this implementation. + * For example, format string "%A%n%C, %S" should return: + * [ + * {fieldId: "street-address", newLine: true}, + * {fieldId: "address-level2"}, + * {fieldId: "address-level1"}, + * ] + * + * @param {string} fmt Country address format string + * @returns {array<object>} List of fields + */ + parseAddressFormat(fmt) { + if (!fmt) { + throw new Error("fmt string is missing."); + } + + return fmt.match(/%[^%]/g).reduce((parsed, part) => { + // Take the first letter of each segment and try to identify it + let fieldId = this.FIELDS_LOOKUP[part[1]]; + // Early return if cannot identify part. + if (!fieldId) { + return parsed; + } + // If a new line is detected, add an attribute to the previous field. + if (fieldId == "newLine") { + let size = parsed.length; + if (size) { + parsed[size - 1].newLine = true; + } + return parsed; + } + return parsed.concat({ fieldId }); + }, []); + }, + + /** + * Used to populate dropdowns in the UI (e.g. FormAutofill preferences, Web Payments). + * Use findAddressSelectOption for matching a value to a region. + * + * @param {string[]} subKeys An array of regionCode strings + * @param {string[]} subIsoids An array of ISO ID strings, if provided will be preferred over the key + * @param {string[]} subNames An array of regionName strings + * @param {string[]} subLnames An array of latinised regionName strings + * @returns {Map?} Returns null if subKeys or subNames are not truthy. + * Otherwise, a Map will be returned mapping keys -> names. + */ + buildRegionMapIfAvailable(subKeys, subIsoids, subNames, subLnames) { + // Not all regions have sub_keys. e.g. DE + if ( + !subKeys || + !subKeys.length || + (!subNames && !subLnames) || + (subNames && subKeys.length != subNames.length) || + (subLnames && subKeys.length != subLnames.length) + ) { + return null; + } + + // Overwrite subKeys with subIsoids, when available + if (subIsoids && subIsoids.length && subIsoids.length == subKeys.length) { + for (let i = 0; i < subIsoids.length; i++) { + if (subIsoids[i]) { + subKeys[i] = subIsoids[i]; + } + } + } + + // Apply sub_lnames if sub_names does not exist + let names = subNames || subLnames; + return new Map(subKeys.map((key, index) => [key, names[index]])); + }, + + /** + * Parse a require string and outputs an array of fields. + * Spaces, commas, and other literals are ignored in this implementation. + * For example, a require string "ACS" should return: + * ["street-address", "address-level2", "address-level1"] + * + * @param {string} requireString Country address require string + * @returns {array<string>} List of fields + */ + parseRequireString(requireString) { + if (!requireString) { + throw new Error("requireString string is missing."); + } + + return requireString.split("").map(fieldId => this.FIELDS_LOOKUP[fieldId]); + }, + + /** + * Use alternative country name list to identify a country code from a + * specified country name. + * @param {string} countryName A country name to be identified + * @param {string} [countrySpecified] A country code indicating that we only + * search its alternative names if specified. + * @returns {string} The matching country code. + */ + identifyCountryCode(countryName, countrySpecified) { + let countries = countrySpecified + ? [countrySpecified] + : [...FormAutofill.countries.keys()]; + + for (let country of countries) { + let collators = this.getSearchCollators(country); + let metadata = this.getCountryAddressData(country); + if (country != metadata.key) { + // We hit the fallback logic in getCountryAddressRawData so ignore it as + // it's not related to `country` and use the name from l10n instead. + metadata = { + id: `data/${country}`, + key: country, + name: FormAutofill.countries.get(country), + }; + } + let alternativeCountryNames = metadata.alternative_names || [ + metadata.name, + ]; + let reAlternativeCountryNames = this._reAlternativeCountryNames[country]; + if (!reAlternativeCountryNames) { + reAlternativeCountryNames = this._reAlternativeCountryNames[ + country + ] = []; + } + + for (let i = 0; i < alternativeCountryNames.length; i++) { + let name = alternativeCountryNames[i]; + let reName = reAlternativeCountryNames[i]; + if (!reName) { + reName = reAlternativeCountryNames[i] = new RegExp( + "\\b" + this.escapeRegExp(name) + "\\b", + "i" + ); + } + + if ( + this.strCompare(name, countryName, collators) || + reName.test(countryName) + ) { + return country; + } + } + } + + return null; + }, + + findSelectOption(selectEl, record, fieldName) { + if (this.isAddressField(fieldName)) { + return this.findAddressSelectOption(selectEl, record, fieldName); + } + if (this.isCreditCardField(fieldName)) { + return this.findCreditCardSelectOption(selectEl, record, fieldName); + } + return null; + }, + + /** + * Try to find the abbreviation of the given sub-region name + * @param {string[]} subregionValues A list of inferable sub-region values. + * @param {string} [country] A country name to be identified. + * @returns {string} The matching sub-region abbreviation. + */ + getAbbreviatedSubregionName(subregionValues, country) { + let values = Array.isArray(subregionValues) + ? subregionValues + : [subregionValues]; + + let collators = this.getSearchCollators(country); + for (let metadata of this.getCountryAddressDataWithLocales(country)) { + let { + sub_keys: subKeys, + sub_names: subNames, + sub_lnames: subLnames, + } = metadata; + if (!subKeys) { + // Not all regions have sub_keys. e.g. DE + continue; + } + // Apply sub_lnames if sub_names does not exist + subNames = subNames || subLnames; + + let speculatedSubIndexes = []; + for (const val of values) { + let identifiedValue = this.identifyValue( + subKeys, + subNames, + val, + collators + ); + if (identifiedValue) { + return identifiedValue; + } + + // Predict the possible state by partial-matching if no exact match. + [subKeys, subNames].forEach(sub => { + speculatedSubIndexes.push( + sub.findIndex(token => { + let pattern = new RegExp( + "\\b" + this.escapeRegExp(token) + "\\b" + ); + + return pattern.test(val); + }) + ); + }); + } + let subKey = subKeys[speculatedSubIndexes.find(i => !!~i)]; + if (subKey) { + return subKey; + } + } + return null; + }, + + /** + * Find the option element from select element. + * 1. Try to find the locale using the country from address. + * 2. First pass try to find exact match. + * 3. Second pass try to identify values from address value and options, + * and look for a match. + * @param {DOMElement} selectEl + * @param {object} address + * @param {string} fieldName + * @returns {DOMElement} + */ + findAddressSelectOption(selectEl, address, fieldName) { + let value = address[fieldName]; + if (!value) { + return null; + } + + let collators = this.getSearchCollators(address.country); + + for (let option of selectEl.options) { + if ( + this.strCompare(value, option.value, collators) || + this.strCompare(value, option.text, collators) + ) { + return option; + } + } + + switch (fieldName) { + case "address-level1": { + let { country } = address; + let identifiedValue = this.getAbbreviatedSubregionName( + [value], + country + ); + // No point going any further if we cannot identify value from address level 1 + if (!identifiedValue) { + return null; + } + for (let dataset of this.getCountryAddressDataWithLocales(country)) { + let keys = dataset.sub_keys; + if (!keys) { + // Not all regions have sub_keys. e.g. DE + continue; + } + // Apply sub_lnames if sub_names does not exist + let names = dataset.sub_names || dataset.sub_lnames; + + // Go through options one by one to find a match. + // Also check if any option contain the address-level1 key. + let pattern = new RegExp( + "\\b" + this.escapeRegExp(identifiedValue) + "\\b", + "i" + ); + for (let option of selectEl.options) { + let optionValue = this.identifyValue( + keys, + names, + option.value, + collators + ); + let optionText = this.identifyValue( + keys, + names, + option.text, + collators + ); + if ( + identifiedValue === optionValue || + identifiedValue === optionText || + pattern.test(option.value) + ) { + return option; + } + } + } + break; + } + case "country": { + if (this.getCountryAddressData(value).alternative_names) { + for (let option of selectEl.options) { + if ( + this.identifyCountryCode(option.text, value) || + this.identifyCountryCode(option.value, value) + ) { + return option; + } + } + } + break; + } + } + + return null; + }, + + findCreditCardSelectOption(selectEl, creditCard, fieldName) { + let oneDigitMonth = creditCard["cc-exp-month"] + ? creditCard["cc-exp-month"].toString() + : null; + let twoDigitsMonth = oneDigitMonth ? oneDigitMonth.padStart(2, "0") : null; + let fourDigitsYear = creditCard["cc-exp-year"] + ? creditCard["cc-exp-year"].toString() + : null; + let twoDigitsYear = fourDigitsYear ? fourDigitsYear.substr(2, 2) : null; + let options = Array.from(selectEl.options); + + switch (fieldName) { + case "cc-exp-month": { + if (!oneDigitMonth) { + return null; + } + for (let option of options) { + if ( + [option.text, option.label, option.value].some(s => { + let result = /[1-9]\d*/.exec(s); + return result && result[0] == oneDigitMonth; + }) + ) { + return option; + } + } + break; + } + case "cc-exp-year": { + if (!fourDigitsYear) { + return null; + } + for (let option of options) { + if ( + [option.text, option.label, option.value].some( + s => s == twoDigitsYear || s == fourDigitsYear + ) + ) { + return option; + } + } + break; + } + case "cc-exp": { + if (!oneDigitMonth || !fourDigitsYear) { + return null; + } + let patterns = [ + oneDigitMonth + "/" + twoDigitsYear, // 8/22 + oneDigitMonth + "/" + fourDigitsYear, // 8/2022 + twoDigitsMonth + "/" + twoDigitsYear, // 08/22 + twoDigitsMonth + "/" + fourDigitsYear, // 08/2022 + oneDigitMonth + "-" + twoDigitsYear, // 8-22 + oneDigitMonth + "-" + fourDigitsYear, // 8-2022 + twoDigitsMonth + "-" + twoDigitsYear, // 08-22 + twoDigitsMonth + "-" + fourDigitsYear, // 08-2022 + twoDigitsYear + "-" + twoDigitsMonth, // 22-08 + fourDigitsYear + "-" + twoDigitsMonth, // 2022-08 + fourDigitsYear + "/" + oneDigitMonth, // 2022/8 + twoDigitsMonth + twoDigitsYear, // 0822 + twoDigitsYear + twoDigitsMonth, // 2208 + ]; + + for (let option of options) { + if ( + [option.text, option.label, option.value].some(str => + patterns.some(pattern => str.includes(pattern)) + ) + ) { + return option; + } + } + break; + } + case "cc-type": { + let network = creditCard["cc-type"] || ""; + for (let option of options) { + if ( + [option.text, option.label, option.value].some( + s => s.trim().toLowerCase() == network + ) + ) { + return option; + } + } + break; + } + } + + return null; + }, + + /** + * Try to match value with keys and names, but always return the key. + * @param {array<string>} keys + * @param {array<string>} names + * @param {string} value + * @param {array} collators + * @returns {string} + */ + identifyValue(keys, names, value, collators) { + let resultKey = keys.find(key => this.strCompare(value, key, collators)); + if (resultKey) { + return resultKey; + } + + let index = names.findIndex(name => + this.strCompare(value, name, collators) + ); + if (index !== -1) { + return keys[index]; + } + + return null; + }, + + /** + * Compare if two strings are the same. + * @param {string} a + * @param {string} b + * @param {array} collators + * @returns {boolean} + */ + strCompare(a = "", b = "", collators) { + return collators.some(collator => !collator.compare(a, b)); + }, + + /** + * Escaping user input to be treated as a literal string within a regular + * expression. + * @param {string} string + * @returns {string} + */ + escapeRegExp(string) { + return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + }, + + /** + * Get formatting information of a given country + * @param {string} country + * @returns {object} + * { + * {string} addressLevel3Label + * {string} addressLevel2Label + * {string} addressLevel1Label + * {string} postalCodeLabel + * {object} fieldsOrder + * {string} postalCodePattern + * } + */ + getFormFormat(country) { + let dataset = this.getCountryAddressData(country); + // We hit a country fallback in `getCountryAddressRawData` but it's not relevant here. + if (country != dataset.key) { + // Use a sparse object so the below default values take effect. + dataset = { + /** + * Even though data/ZZ only has address-level2, include the other levels + * in case they are needed for unknown countries. Users can leave the + * unnecessary fields blank which is better than forcing users to enter + * the data in incorrect fields. + */ + fmt: "%N%n%O%n%A%n%C %S %Z", + }; + } + return { + // When particular values are missing for a country, the + // data/ZZ value should be used instead: + // https://chromium-i18n.appspot.com/ssl-aggregate-address/data/ZZ + addressLevel3Label: dataset.sublocality_name_type || "suburb", + addressLevel2Label: dataset.locality_name_type || "city", + addressLevel1Label: dataset.state_name_type || "province", + addressLevel1Options: this.buildRegionMapIfAvailable( + dataset.sub_keys, + dataset.sub_isoids, + dataset.sub_names, + dataset.sub_lnames + ), + countryRequiredFields: this.parseRequireString(dataset.require || "AC"), + fieldsOrder: this.parseAddressFormat(dataset.fmt || "%N%n%O%n%A%n%C"), + postalCodeLabel: dataset.zip_name_type || "postalCode", + postalCodePattern: dataset.zip, + }; + }, + + /** + * Localize "data-localization" or "data-localization-region" attributes. + * @param {Element} element + * @param {string} attributeName + */ + localizeAttributeForElement(element, attributeName) { + switch (attributeName) { + case "data-localization": { + element.textContent = this.stringBundle.GetStringFromName( + element.getAttribute(attributeName) + ); + element.removeAttribute(attributeName); + break; + } + case "data-localization-region": { + let regionCode = element.getAttribute(attributeName); + element.textContent = Services.intl.getRegionDisplayNames(undefined, [ + regionCode, + ]); + element.removeAttribute(attributeName); + return; + } + default: + throw new Error("Unexpected attributeName"); + } + }, + + /** + * Localize elements with "data-localization" or "data-localization-region" attributes. + * @param {Element} root + */ + localizeMarkup(root) { + let elements = root.querySelectorAll("[data-localization]"); + for (let element of elements) { + this.localizeAttributeForElement(element, "data-localization"); + } + + elements = root.querySelectorAll("[data-localization-region]"); + for (let element of elements) { + this.localizeAttributeForElement(element, "data-localization-region"); + } + }, +}; + +this.log = null; +FormAutofill.defineLazyLogGetter(this, EXPORTED_SYMBOLS[0]); + +XPCOMUtils.defineLazyGetter(FormAutofillUtils, "stringBundle", function() { + return Services.strings.createBundle( + "chrome://formautofill/locale/formautofill.properties" + ); +}); + +XPCOMUtils.defineLazyGetter(FormAutofillUtils, "brandBundle", function() { + return Services.strings.createBundle( + "chrome://branding/locale/brand.properties" + ); +}); + +XPCOMUtils.defineLazyPreferenceGetter( + FormAutofillUtils, + "_reauthEnabledByUser", + "extensions.formautofill.reauth.enabled", + false +); diff --git a/browser/extensions/formautofill/ProfileAutoCompleteResult.jsm b/browser/extensions/formautofill/ProfileAutoCompleteResult.jsm new file mode 100644 index 0000000000..f3a8f4b61d --- /dev/null +++ b/browser/extensions/formautofill/ProfileAutoCompleteResult.jsm @@ -0,0 +1,516 @@ +/* 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 EXPORTED_SYMBOLS = ["AddressResult", "CreditCardResult"]; + +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); +const { FormAutofill } = ChromeUtils.import( + "resource://formautofill/FormAutofill.jsm" +); +ChromeUtils.defineModuleGetter( + this, + "FormAutofillUtils", + "resource://formautofill/FormAutofillUtils.jsm" +); +ChromeUtils.defineModuleGetter( + this, + "CreditCard", + "resource://gre/modules/CreditCard.jsm" +); + +XPCOMUtils.defineLazyPreferenceGetter( + this, + "insecureWarningEnabled", + "security.insecure_field_warning.contextual.enabled" +); + +this.log = null; +FormAutofill.defineLazyLogGetter(this, EXPORTED_SYMBOLS[0]); + +class ProfileAutoCompleteResult { + constructor( + searchString, + focusedFieldName, + allFieldNames, + matchingProfiles, + { resultCode = null, isSecure = true, isInputAutofilled = false } + ) { + log.debug("Constructing new ProfileAutoCompleteResult:", [...arguments]); + + // nsISupports + this.QueryInterface = ChromeUtils.generateQI(["nsIAutoCompleteResult"]); + + // The user's query string + this.searchString = searchString; + // The field name of the focused input. + this._focusedFieldName = focusedFieldName; + // The matching profiles contains the information for filling forms. + this._matchingProfiles = matchingProfiles; + // The default item that should be entered if none is selected + this.defaultIndex = 0; + // The reason the search failed + this.errorDescription = ""; + // The value used to determine whether the form is secure or not. + this._isSecure = isSecure; + // The value to indicate whether the focused input has been autofilled or not. + this._isInputAutofilled = isInputAutofilled; + // All fillable field names in the form including the field name of the currently-focused input. + this._allFieldNames = [ + ...this._matchingProfiles.reduce((fieldSet, curProfile) => { + for (let field of Object.keys(curProfile)) { + fieldSet.add(field); + } + + return fieldSet; + }, new Set()), + ].filter(field => allFieldNames.includes(field)); + + // Force return success code if the focused field is auto-filled in order + // to show clear form button popup. + if (isInputAutofilled) { + resultCode = Ci.nsIAutoCompleteResult.RESULT_SUCCESS; + } + // The result code of this result object. + if (resultCode) { + this.searchResult = resultCode; + } else if (matchingProfiles.length) { + this.searchResult = Ci.nsIAutoCompleteResult.RESULT_SUCCESS; + } else { + this.searchResult = Ci.nsIAutoCompleteResult.RESULT_NOMATCH; + } + + // An array of primary and secondary labels for each profile. + this._popupLabels = this._generateLabels( + this._focusedFieldName, + this._allFieldNames, + this._matchingProfiles + ); + } + + /** + * @returns {number} The number of results + */ + get matchCount() { + return this._popupLabels.length; + } + + _checkIndexBounds(index) { + if (index < 0 || index >= this._popupLabels.length) { + throw Components.Exception( + "Index out of range.", + Cr.NS_ERROR_ILLEGAL_VALUE + ); + } + } + + /** + * Get the secondary label based on the focused field name and related field names + * in the same form. + * @param {string} focusedFieldName The field name of the focused input + * @param {Array<Object>} allFieldNames The field names in the same section + * @param {object} profile The profile providing the labels to show. + * @returns {string} The secondary label + */ + _getSecondaryLabel(focusedFieldName, allFieldNames, profile) { + return ""; + } + + _generateLabels(focusedFieldName, allFieldNames, profiles) {} + + /** + * Get the value of the result at the given index. + * + * Always return empty string for form autofill feature to suppress + * AutoCompleteController from autofilling, as we'll populate the + * fields on our own. + * + * @param {number} index The index of the result requested + * @returns {string} The result at the specified index + */ + getValueAt(index) { + this._checkIndexBounds(index); + return ""; + } + + getLabelAt(index) { + this._checkIndexBounds(index); + + let label = this._popupLabels[index]; + if (typeof label == "string") { + return label; + } + return JSON.stringify(label); + } + + /** + * Retrieves a comment (metadata instance) + * @param {number} index The index of the comment requested + * @returns {string} The comment at the specified index + */ + getCommentAt(index) { + this._checkIndexBounds(index); + return JSON.stringify(this._matchingProfiles[index]); + } + + /** + * Retrieves a style hint specific to a particular index. + * @param {number} index The index of the style hint requested + * @returns {string} The style hint at the specified index + */ + getStyleAt(index) { + this._checkIndexBounds(index); + if (index == this.matchCount - 1) { + return "autofill-footer"; + } + if (this._isInputAutofilled) { + return "autofill-clear-button"; + } + + return "autofill-profile"; + } + + /** + * Retrieves an image url. + * @param {number} index The index of the image url requested + * @returns {string} The image url at the specified index + */ + getImageAt(index) { + this._checkIndexBounds(index); + return ""; + } + + /** + * Retrieves a result + * @param {number} index The index of the result requested + * @returns {string} The result at the specified index + */ + getFinalCompleteValueAt(index) { + return this.getValueAt(index); + } + + /** + * Removes a result from the resultset + * @param {number} index The index of the result to remove + */ + removeValueAt(index) { + // There is no plan to support removing profiles via autocomplete. + } +} + +class AddressResult extends ProfileAutoCompleteResult { + constructor(...args) { + super(...args); + } + + _getSecondaryLabel(focusedFieldName, allFieldNames, profile) { + // We group similar fields into the same field name so we won't pick another + // field in the same group as the secondary label. + const GROUP_FIELDS = { + name: ["name", "given-name", "additional-name", "family-name"], + "street-address": [ + "street-address", + "address-line1", + "address-line2", + "address-line3", + ], + "country-name": ["country", "country-name"], + tel: [ + "tel", + "tel-country-code", + "tel-national", + "tel-area-code", + "tel-local", + "tel-local-prefix", + "tel-local-suffix", + ], + }; + + const secondaryLabelOrder = [ + "street-address", // Street address + "name", // Full name + "address-level3", // Townland / Neighborhood / Village + "address-level2", // City/Town + "organization", // Company or organization name + "address-level1", // Province/State (Standardized code if possible) + "country-name", // Country name + "postal-code", // Postal code + "tel", // Phone number + "email", // Email address + ]; + + for (let field in GROUP_FIELDS) { + if (GROUP_FIELDS[field].includes(focusedFieldName)) { + focusedFieldName = field; + break; + } + } + + for (const currentFieldName of secondaryLabelOrder) { + if (focusedFieldName == currentFieldName || !profile[currentFieldName]) { + continue; + } + + let matching = GROUP_FIELDS[currentFieldName] + ? allFieldNames.some(fieldName => + GROUP_FIELDS[currentFieldName].includes(fieldName) + ) + : allFieldNames.includes(currentFieldName); + + if (matching) { + if ( + currentFieldName == "street-address" && + profile["-moz-street-address-one-line"] + ) { + return profile["-moz-street-address-one-line"]; + } + return profile[currentFieldName]; + } + } + + return ""; // Nothing matched. + } + + _generateLabels(focusedFieldName, allFieldNames, profiles) { + if (this._isInputAutofilled) { + return [ + { primary: "", secondary: "" }, // Clear button + { primary: "", secondary: "" }, // Footer + ]; + } + + // Skip results without a primary label. + let labels = profiles + .filter(profile => { + return !!profile[focusedFieldName]; + }) + .map(profile => { + let primaryLabel = profile[focusedFieldName]; + if ( + focusedFieldName == "street-address" && + profile["-moz-street-address-one-line"] + ) { + primaryLabel = profile["-moz-street-address-one-line"]; + } + return { + primary: primaryLabel, + secondary: this._getSecondaryLabel( + focusedFieldName, + allFieldNames, + profile + ), + }; + }); + // Add an empty result entry for footer. Its content will come from + // the footer binding, so don't assign any value to it. + // The additional properties: categories and focusedCategory are required of + // the popup to generate autofill hint on the footer. + labels.push({ + primary: "", + secondary: "", + categories: FormAutofillUtils.getCategoriesFromFieldNames( + this._allFieldNames + ), + focusedCategory: FormAutofillUtils.getCategoryFromFieldName( + this._focusedFieldName + ), + }); + + return labels; + } +} + +class CreditCardResult extends ProfileAutoCompleteResult { + constructor(...args) { + super(...args); + this._cardTypes = this._generateCardTypes( + this._focusedFieldName, + this._allFieldNames, + this._matchingProfiles + ); + } + + _getSecondaryLabel(focusedFieldName, allFieldNames, profile) { + const GROUP_FIELDS = { + "cc-name": [ + "cc-name", + "cc-given-name", + "cc-additional-name", + "cc-family-name", + ], + "cc-exp": ["cc-exp", "cc-exp-month", "cc-exp-year"], + }; + + const secondaryLabelOrder = [ + "cc-number", // Credit card number + "cc-name", // Full name + "cc-exp", // Expiration date + ]; + + for (let field in GROUP_FIELDS) { + if (GROUP_FIELDS[field].includes(focusedFieldName)) { + focusedFieldName = field; + break; + } + } + + for (const currentFieldName of secondaryLabelOrder) { + if (focusedFieldName == currentFieldName || !profile[currentFieldName]) { + continue; + } + + let matching = GROUP_FIELDS[currentFieldName] + ? allFieldNames.some(fieldName => + GROUP_FIELDS[currentFieldName].includes(fieldName) + ) + : allFieldNames.includes(currentFieldName); + + if (matching) { + if (currentFieldName == "cc-number") { + let { affix, label } = CreditCard.formatMaskedNumber( + profile[currentFieldName] + ); + return affix + label; + } + return profile[currentFieldName]; + } + } + + return ""; // Nothing matched. + } + + _generateLabels(focusedFieldName, allFieldNames, profiles) { + if (!this._isSecure) { + if (!insecureWarningEnabled) { + return []; + } + let brandName = FormAutofillUtils.brandBundle.GetStringFromName( + "brandShortName" + ); + + return [ + FormAutofillUtils.stringBundle.formatStringFromName( + "insecureFieldWarningDescription", + [brandName] + ), + ]; + } + + if (this._isInputAutofilled) { + return [ + { primary: "", secondary: "" }, // Clear button + { primary: "", secondary: "" }, // Footer + ]; + } + + // Skip results without a primary label. + let labels = profiles + .filter(profile => { + return !!profile[focusedFieldName]; + }) + .map(profile => { + let primaryAffix; + let primary = profile[focusedFieldName]; + + if (focusedFieldName == "cc-number") { + let { affix, label } = CreditCard.formatMaskedNumber(primary); + primaryAffix = affix; + primary = label; + } + const secondary = this._getSecondaryLabel( + focusedFieldName, + allFieldNames, + profile + ); + // The card type is displayed visually using an image. For a11y, we need + // to expose it as text. We do this using aria-label. However, + // aria-label overrides the text content, so we must include that also. + let ccTypeName; + try { + ccTypeName = FormAutofillUtils.stringBundle.GetStringFromName( + `cardNetwork.${profile["cc-type"]}` + ); + } catch (e) { + ccTypeName = null; // Unknown. + } + const ariaLabel = [ccTypeName, primaryAffix, primary, secondary] + .filter(chunk => !!chunk) // Exclude empty chunks. + .join(" "); + return { + primaryAffix, + primary, + secondary, + ariaLabel, + }; + }); + // Add an empty result entry for footer. + labels.push({ primary: "", secondary: "" }); + + return labels; + } + + // This method needs to return an array that parallels the + // array returned by _generateLabels, above. As a consequence, + // its logic follows very closely. + _generateCardTypes(focusedFieldName, allFieldNames, profiles) { + if (this._isInputAutofilled) { + return [ + "", // Clear button + "", // Footer + ]; + } + + // Skip results without a primary label. + let cardTypes = profiles + .filter(profile => { + return !!profile[focusedFieldName]; + }) + .map(profile => profile["cc-type"]); + + // Add an empty result entry for footer. + cardTypes.push(""); + return cardTypes; + } + + getStyleAt(index) { + this._checkIndexBounds(index); + if (!this._isSecure && insecureWarningEnabled) { + return "autofill-insecureWarning"; + } + + return super.getStyleAt(index); + } + + getImageAt(index) { + const PATH = "chrome://formautofill/content/"; + const THIRD_PARTY_PATH = PATH + "third-party/"; + + this._checkIndexBounds(index); + switch (this._cardTypes[index]) { + case "amex": + return THIRD_PARTY_PATH + "cc-logo-amex.png"; + case "cartebancaire": + return THIRD_PARTY_PATH + "cc-logo-cartebancaire.png"; + case "diners": + return THIRD_PARTY_PATH + "cc-logo-diners.svg"; + case "discover": + return THIRD_PARTY_PATH + "cc-logo-discover.png"; + case "jcb": + return THIRD_PARTY_PATH + "cc-logo-jcb.svg"; + case "mastercard": + return THIRD_PARTY_PATH + "cc-logo-mastercard.svg"; + case "mir": + return THIRD_PARTY_PATH + "cc-logo-mir.svg"; + case "unionpay": + return THIRD_PARTY_PATH + "cc-logo-unionpay.svg"; + case "visa": + return THIRD_PARTY_PATH + "cc-logo-visa.svg"; + default: + return PATH + "icon-credit-card-generic.svg"; + } + } +} diff --git a/browser/extensions/formautofill/addressmetadata/addressReferences.js b/browser/extensions/formautofill/addressmetadata/addressReferences.js new file mode 100644 index 0000000000..5f0e662a20 --- /dev/null +++ b/browser/extensions/formautofill/addressmetadata/addressReferences.js @@ -0,0 +1,2456 @@ +/* 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/. */ + +/* exported addressData */ +/* eslint max-len: 0 */ + +"use strict"; + +// The data below is initially copied from +// https://chromium-i18n.appspot.com/ssl-aggregate-address + +// See https://github.com/googlei18n/libaddressinput/wiki/AddressValidationMetadata for +// documentation on how to use the data. + +// WARNING: DO NOT change any value or add additional properties in addressData. +// We only accept the metadata of the supported countries that is copied from libaddressinput directly. +// Please edit addressReferencesExt.js instead if you want to add new property as complement +// or overwrite the existing properties. + +var addressData = { + "data/AD": { + fmt: "%N%n%O%n%A%n%Z %C", + id: "data/AD", + key: "AD", + lang: "ca", + languages: "ca", + name: "ANDORRA", + posturl: + "http://www.correos.es/comun/CodigosPostales/1010_s-CodPostal.asp?Provincia=", + sub_isoids: "07~02~03~08~04~05~06", + sub_keys: + "Parròquia d'Andorra la Vella~Canillo~Encamp~Escaldes-Engordany~La Massana~Ordino~Sant Julià de Lòria", + sub_names: + "Andorra la Vella~Canillo~Encamp~Escaldes-Engordany~La Massana~Ordino~Sant Julià de Lòria", + sub_zipexs: "AD500~AD100~AD200~AD700~AD400~AD300~AD600", + sub_zips: "AD50[01]~AD10[01]~AD20[01]~AD70[01]~AD40[01]~AD30[01]~AD60[01]", + zip: "AD[1-7]0\\d", + zipex: "AD100,AD501,AD700", + }, + "data/AE": { + fmt: "%N%n%O%n%A%n%S", + id: "data/AE", + key: "AE", + lang: "ar", + languages: "ar", + lfmt: "%N%n%O%n%A%n%S", + name: "UNITED ARAB EMIRATES", + require: "AS", + state_name_type: "emirate", + sub_isoids: "AZ~SH~FU~UQ~DU~RK~AJ", + sub_keys: + "أبو ظبي~إمارة الشارقةّ~الفجيرة~ام القيوين~إمارة دبيّ~إمارة رأس الخيمة~عجمان", + sub_lnames: + "Abu Dhabi~Sharjah~Fujairah~Umm Al Quwain~Dubai~Ras al Khaimah~Ajman", + sub_names: "أبو ظبي~الشارقة~الفجيرة~ام القيوين~دبي~رأس الخيمة~عجمان", + }, + "data/AF": { + fmt: "%N%n%O%n%A%n%C%n%Z", + id: "data/AF", + key: "AF", + name: "AFGHANISTAN", + zip: "\\d{4}", + zipex: "1001,2601,3801", + }, + "data/AG": { + id: "data/AG", + key: "AG", + name: "ANTIGUA AND BARBUDA", + require: "A", + }, + "data/AI": { + fmt: "%N%n%O%n%A%n%C%n%Z", + id: "data/AI", + key: "AI", + name: "ANGUILLA", + zip: "(?:AI-)?2640", + zipex: "2640", + }, + "data/AL": { + fmt: "%N%n%O%n%A%n%Z%n%C", + id: "data/AL", + key: "AL", + name: "ALBANIA", + zip: "\\d{4}", + zipex: "1001,1017,3501", + }, + "data/AM": { + fmt: "%N%n%O%n%A%n%Z%n%C%n%S", + id: "data/AM", + key: "AM", + lang: "hy", + languages: "hy", + lfmt: "%N%n%O%n%A%n%Z%n%C%n%S", + name: "ARMENIA", + sub_isoids: "AG~AR~AV~GR~ER~LO~KT~SH~SU~VD~TV", + sub_keys: + "Արագածոտն~Արարատ~Արմավիր~Գեղարքունիք~Երևան~Լոռի~Կոտայք~Շիրակ~Սյունիք~Վայոց ձոր~Տավուշ", + sub_lnames: + "Aragatsotn~Ararat~Armavir~Gegharkunik~Yerevan~Lori~Kotayk~Shirak~Syunik~Vayots Dzor~Tavush", + sub_zipexs: + "0201,0514~0601,0823~0901,1149~1201,1626~0000,0099~1701,2117~2201,2506~2601,3126~3201,3519~3601,3810~3901,4216", + sub_zips: + "0[2-5]~0[6-8]~09|1[01]~1[2-6]~00~1[7-9]|2[01]~2[2-5]~2[6-9]|3[01]~3[2-5]~3[6-8]~39|4[0-2]", + zip: "(?:37)?\\d{4}", + zipex: "375010,0002,0010", + }, + "data/AO": { id: "data/AO", key: "AO", name: "ANGOLA" }, + "data/AQ": { id: "data/AQ", key: "AQ", name: "ANTARCTICA" }, + "data/AR": { + fmt: "%N%n%O%n%A%n%Z %C%n%S", + id: "data/AR", + key: "AR", + lang: "es", + languages: "es", + name: "ARGENTINA", + posturl: "http://www.correoargentino.com.ar/formularios/cpa", + sub_isoids: "B~K~H~U~C~X~W~E~P~Y~L~F~M~N~Q~R~A~J~D~Z~S~G~V~T", + sub_keys: + "Buenos Aires~Catamarca~Chaco~Chubut~Ciudad Autónoma de Buenos Aires~Córdoba~Corrientes~Entre Ríos~Formosa~Jujuy~La Pampa~La Rioja~Mendoza~Misiones~Neuquén~Río Negro~Salta~San Juan~San Luis~Santa Cruz~Santa Fe~Santiago del Estero~Tierra del Fuego~Tucumán", + sub_names: + "Buenos Aires~Catamarca~Chaco~Chubut~Ciudad Autónoma de Buenos Aires~Córdoba~Corrientes~Entre Ríos~Formosa~Jujuy~La Pampa~La Rioja~Mendoza~Misiones~Neuquén~Río Negro~Salta~San Juan~San Luis~Santa Cruz~Santa Fe~Santiago del Estero~Tierra del Fuego~Tucumán", + sub_zips: + "B?[1-36-8]~K?[45]~H?3~U?[89]~C?1~X?[235-8]~W?3~E?[1-3]~P?[37]~Y?4~L?[3568]~F?5~M?[56]~N?3~Q?[38]~R?[89]~A?[34]~J?5~D?[4-6]~Z?[89]~S?[2368]~G?[2-5]~V?9~T?[45]", + upper: "ACZ", + zip: "((?:[A-HJ-NP-Z])?\\d{4})([A-Z]{3})?", + zipex: "C1070AAM,C1000WAM,B1000TBU,X5187XAB", + }, + "data/AS": { + fmt: "%N%n%O%n%A%n%C %S %Z", + id: "data/AS", + key: "AS", + name: "AMERICAN SAMOA", + posturl: "http://zip4.usps.com/zip4/welcome.jsp", + require: "ACSZ", + state_name_type: "state", + upper: "ACNOS", + zip: "(96799)(?:[ \\-](\\d{4}))?", + zip_name_type: "zip", + zipex: "96799", + }, + "data/AT": { + fmt: "%O%n%N%n%A%n%Z %C", + id: "data/AT", + key: "AT", + name: "AUSTRIA", + posturl: "http://www.post.at/post_subsite_postleitzahlfinder.php", + require: "ACZ", + zip: "\\d{4}", + zipex: "1010,3741", + }, + "data/AU": { + fmt: "%O%n%N%n%A%n%C %S %Z", + id: "data/AU", + key: "AU", + lang: "en", + languages: "en", + locality_name_type: "suburb", + name: "AUSTRALIA", + posturl: "http://www1.auspost.com.au/postcodes/", + require: "ACSZ", + state_name_type: "state", + sub_isoids: "ACT~NSW~NT~QLD~SA~TAS~VIC~WA", + sub_keys: "ACT~NSW~NT~QLD~SA~TAS~VIC~WA", + sub_names: + "Australian Capital Territory~New South Wales~Northern Territory~Queensland~South Australia~Tasmania~Victoria~Western Australia", + sub_zipexs: + "0200,2540,2618,2999~1000,2888,3585,3707~0800,0999~4000,9999~5000~7000,7999~3000,8000~6000,0872", + sub_zips: + "29|2540|260|261[0-8]|02|2620~1|2[0-57-8]|26[2-9]|261[189]|3500|358[56]|3644|3707~0[89]~[49]~5|0872~7~[38]~6|0872", + upper: "CS", + zip: "\\d{4}", + zipex: "2060,3171,6430,4000,4006,3001", + }, + "data/AW": { id: "data/AW", key: "AW", name: "ARUBA" }, + "data/AZ": { + fmt: "%N%n%O%n%A%nAZ %Z %C", + id: "data/AZ", + key: "AZ", + name: "AZERBAIJAN", + postprefix: "AZ ", + zip: "\\d{4}", + zipex: "1000", + }, + "data/BA": { + fmt: "%N%n%O%n%A%n%Z %C", + id: "data/BA", + key: "BA", + name: "BOSNIA AND HERZEGOVINA", + zip: "\\d{5}", + zipex: "71000", + }, + "data/BB": { + fmt: "%N%n%O%n%A%n%C, %S %Z", + id: "data/BB", + key: "BB", + name: "BARBADOS", + state_name_type: "parish", + zip: "BB\\d{5}", + zipex: "BB23026,BB22025", + }, + "data/BD": { + fmt: "%N%n%O%n%A%n%C - %Z", + id: "data/BD", + key: "BD", + name: "BANGLADESH", + posturl: "http://www.bangladeshpost.gov.bd/PostCode.asp", + zip: "\\d{4}", + zipex: "1340,1000", + }, + "data/BE": { + fmt: "%O%n%N%n%A%n%Z %C", + id: "data/BE", + key: "BE", + name: "BELGIUM", + posturl: + "http://www.post.be/site/nl/residential/customerservice/search/postal_codes.html", + require: "ACZ", + zip: "\\d{4}", + zipex: "4000,1000", + }, + "data/BF": { + fmt: "%N%n%O%n%A%n%C %X", + id: "data/BF", + key: "BF", + name: "BURKINA FASO", + }, + "data/BG": { + fmt: "%N%n%O%n%A%n%Z %C", + id: "data/BG", + key: "BG", + name: "BULGARIA (REP.)", + posturl: "http://www.bgpost.bg/?cid=5", + zip: "\\d{4}", + zipex: "1000,1700", + }, + "data/BH": { + fmt: "%N%n%O%n%A%n%C %Z", + id: "data/BH", + key: "BH", + name: "BAHRAIN", + zip: "(?:\\d|1[0-2])\\d{2}", + zipex: "317", + }, + "data/BI": { id: "data/BI", key: "BI", name: "BURUNDI" }, + "data/BJ": { id: "data/BJ", key: "BJ", name: "BENIN", upper: "AC" }, + "data/BL": { + fmt: "%O%n%N%n%A%n%Z %C %X", + id: "data/BL", + key: "BL", + name: "SAINT BARTHELEMY", + posturl: + "http://www.laposte.fr/Particulier/Utiliser-nos-outils-pratiques/Outils-et-documents/Trouvez-un-code-postal", + require: "ACZ", + upper: "ACX", + zip: "9[78][01]\\d{2}", + zipex: "97100", + }, + "data/BM": { + fmt: "%N%n%O%n%A%n%C %Z", + id: "data/BM", + key: "BM", + name: "BERMUDA", + posturl: "http://www.landvaluation.bm/", + zip: "[A-Z]{2} ?[A-Z0-9]{2}", + zipex: "FL 07,HM GX,HM 12", + }, + "data/BN": { + fmt: "%N%n%O%n%A%n%C %Z", + id: "data/BN", + key: "BN", + name: "BRUNEI DARUSSALAM", + posturl: "http://www.post.gov.bn/SitePages/postcodes.aspx", + zip: "[A-Z]{2} ?\\d{4}", + zipex: "BT2328,KA1131,BA1511", + }, + "data/BO": { id: "data/BO", key: "BO", name: "BOLIVIA", upper: "AC" }, + "data/BQ": { + id: "data/BQ", + key: "BQ", + name: "BONAIRE, SINT EUSTATIUS, AND SABA", + }, + "data/BR": { + fmt: "%O%n%N%n%A%n%D%n%C-%S%n%Z", + id: "data/BR", + key: "BR", + lang: "pt", + languages: "pt", + name: "BRAZIL", + posturl: "http://www.buscacep.correios.com.br/", + require: "ASCZ", + state_name_type: "state", + sub_isoids: + "AC~AL~AP~AM~BA~CE~DF~ES~GO~MA~MT~MS~MG~PA~PB~PR~PE~PI~RJ~RN~RS~RO~RR~SC~SP~SE~TO", + sub_keys: + "AC~AL~AP~AM~BA~CE~DF~ES~GO~MA~MT~MS~MG~PA~PB~PR~PE~PI~RJ~RN~RS~RO~RR~SC~SP~SE~TO", + sub_mores: + "true~true~true~true~true~true~true~true~true~true~true~true~true~true~true~true~true~true~true~true~true~true~true~true~true~true~true", + sub_names: + "Acre~Alagoas~Amapá~Amazonas~Bahia~Ceará~Distrito Federal~Espírito Santo~Goiás~Maranhão~Mato Grosso~Mato Grosso do Sul~Minas Gerais~Pará~Paraíba~Paraná~Pernambuco~Piauí~Rio de Janeiro~Rio Grande do Norte~Rio Grande do Sul~Rondônia~Roraima~Santa Catarina~São Paulo~Sergipe~Tocantins", + sub_zipexs: + "69900-000,69999-999~57000-000,57999-999~68900-000,68999-999~69000-000,69400-123~40000-000,48999-999~60000-000,63999-999~70000-000,73500-123~29000-000,29999-999~72800-000,73700-123~65000-000,65999-999~78000-000,78899-999~79000-000,79999-999~30000-000,39999-999~66000-000,68899-999~58000-000,58999-999~80000-000,87999-999~50000-000,56999-999~64000-000,64999-999~20000-000,28999-999~59000-000,59999-999~90000-000,99999-999~76800-000,78900-000,78999-999~69300-000,69399-999~88000-000,89999-999~01000-000,13000-123~49000-000,49999-999~77000-000,77999-999", + sub_zips: + "699~57~689~69[0-24-8]~4[0-8]~6[0-3]~7[0-1]|72[0-7]|73[0-6]~29~72[89]|73[7-9]|7[4-6]~65~78[0-8]~79~3~6[6-7]|68[0-8]~58~8[0-7]~5[0-6]~64~2[0-8]~59~9~76[89]|789~693~8[89]~[01][1-9]~49~77", + sublocality_name_type: "neighborhood", + upper: "CS", + zip: "\\d{5}-?\\d{3}", + zipex: "40301-110,70002-900", + }, + "data/BS": { + fmt: "%N%n%O%n%A%n%C, %S", + id: "data/BS", + key: "BS", + lang: "en", + languages: "en", + name: "BAHAMAS", + state_name_type: "island", + sub_isoids: "~AK~~BY~BI~CI~~~EX~~HI~IN~LI~MG~~RI~RC~SS~SW", + sub_keys: + "Abaco~Acklins~Andros~Berry Islands~Bimini~Cat Island~Crooked Island~Eleuthera~Exuma~Grand Bahama~Harbour Island~Inagua~Long Island~Mayaguana~N.P.~Ragged Island~Rum Cay~San Salvador~Spanish Wells", + sub_names: + "Abaco Islands~Acklins~Andros Island~Berry Islands~Bimini~Cat Island~Crooked Island~Eleuthera~Exuma and Cays~Grand Bahama~Harbour Island~Inagua~Long Island~Mayaguana~New Providence~Ragged Island~Rum Cay~San Salvador~Spanish Wells", + }, + "data/BT": { + fmt: "%N%n%O%n%A%n%C %Z", + id: "data/BT", + key: "BT", + name: "BHUTAN", + posturl: "http://www.bhutanpost.bt/postcodes/", + zip: "\\d{5}", + zipex: "11001,31101,35003", + }, + "data/BV": { id: "data/BV", key: "BV", name: "BOUVET ISLAND" }, + "data/BW": { id: "data/BW", key: "BW", name: "BOTSWANA" }, + "data/BY": { + fmt: "%S%n%Z %C%n%A%n%O%n%N", + id: "data/BY", + key: "BY", + name: "BELARUS", + posturl: "http://ex.belpost.by/addressbook/", + zip: "\\d{6}", + zipex: "223016,225860,220050", + }, + "data/BZ": { id: "data/BZ", key: "BZ", name: "BELIZE" }, + "data/CA": { + fmt: "%N%n%O%n%A%n%C %S %Z", + id: "data/CA", + key: "CA", + lang: "en", + languages: "en~fr", + name: "CANADA", + posturl: "https://www.canadapost.ca/cpo/mc/personal/postalcode/fpc.jsf", + require: "ACSZ", + sub_isoids: "AB~BC~MB~NB~NL~NT~NS~NU~ON~PE~QC~SK~YT", + sub_keys: "AB~BC~MB~NB~NL~NT~NS~NU~ON~PE~QC~SK~YT", + sub_names: + "Alberta~British Columbia~Manitoba~New Brunswick~Newfoundland and Labrador~Northwest Territories~Nova Scotia~Nunavut~Ontario~Prince Edward Island~Quebec~Saskatchewan~Yukon", + sub_zips: + "T~V~R~E~A~X0E|X0G|X1A~B~X0A|X0B|X0C~K|L|M|N|P~C~G|H|J|K1A~S|R8A~Y", + upper: "ACNOSZ", + zip: "[ABCEGHJKLMNPRSTVXY]\\d[ABCEGHJ-NPRSTV-Z] ?\\d[ABCEGHJ-NPRSTV-Z]\\d", + zipex: "H3Z 2Y7,V8X 3X4,T0L 1K0,T0H 1A0,K1A 0B1", + }, + "data/CA--fr": { + fmt: "%N%n%O%n%A%n%C %S %Z", + id: "data/CA--fr", + key: "CA", + lang: "fr", + name: "CANADA", + posturl: "https://www.canadapost.ca/cpo/mc/personal/postalcode/fpc.jsf", + require: "ACSZ", + sub_isoids: "AB~BC~PE~MB~NB~NS~NU~ON~QC~SK~NL~NT~YT", + sub_keys: "AB~BC~PE~MB~NB~NS~NU~ON~QC~SK~NL~NT~YT", + sub_names: + "Alberta~Colombie-Britannique~Île-du-Prince-Édouard~Manitoba~Nouveau-Brunswick~Nouvelle-Écosse~Nunavut~Ontario~Québec~Saskatchewan~Terre-Neuve-et-Labrador~Territoires du Nord-Ouest~Yukon", + sub_zips: + "T~V~C~R~E~B~X0A|X0B|X0C~K|L|M|N|P~G|H|J|K1A~S|R8A~A~X0E|X0G|X1A~Y", + upper: "ACNOSZ", + zip: "[ABCEGHJKLMNPRSTVXY]\\d[ABCEGHJ-NPRSTV-Z] ?\\d[ABCEGHJ-NPRSTV-Z]\\d", + zipex: "H3Z 2Y7,V8X 3X4,T0L 1K0,T0H 1A0,K1A 0B1", + }, + "data/CC": { + fmt: "%O%n%N%n%A%n%C %S %Z", + id: "data/CC", + key: "CC", + name: "COCOS (KEELING) ISLANDS", + upper: "CS", + zip: "6799", + zipex: "6799", + }, + "data/CD": { id: "data/CD", key: "CD", name: "CONGO (DEM. REP.)" }, + "data/CF": { id: "data/CF", key: "CF", name: "CENTRAL AFRICAN REPUBLIC" }, + "data/CG": { id: "data/CG", key: "CG", name: "CONGO (REP.)" }, + "data/CH": { + fmt: "%O%n%N%n%A%nCH-%Z %C", + id: "data/CH", + key: "CH", + name: "SWITZERLAND", + postprefix: "CH-", + posturl: "http://www.post.ch/db/owa/pv_plz_pack/pr_main", + require: "ACZ", + upper: "", + zip: "\\d{4}", + zipex: "2544,1211,1556,3030", + }, + "data/CI": { + fmt: "%N%n%O%n%X %A %C %X", + id: "data/CI", + key: "CI", + name: "COTE D'IVOIRE", + }, + "data/CK": { id: "data/CK", key: "CK", name: "COOK ISLANDS" }, + "data/CL": { + fmt: "%N%n%O%n%A%n%Z %C%n%S", + id: "data/CL", + key: "CL", + lang: "es", + languages: "es", + name: "CHILE", + posturl: "http://www.correos.cl/SitePages/home.aspx", + sub_isoids: "AN~AR~AP~AT~AI~BI~CO~LI~LL~LR~MA~ML~RM~TA~VS", + sub_keys: + "Antofagasta~Araucanía~Arica y Parinacota~Atacama~Aysén~Biobío~Coquimbo~O'Higgins~Los Lagos~Los Ríos~Magallanes~Maule~Región Metropolitana~Tarapacá~Valparaíso", + sub_mores: + "true~true~true~true~true~true~true~true~true~true~true~true~true~true~true", + sub_names: + "Antofagasta~Araucanía~Arica y Parinacota~Atacama~Aysén del General Carlos Ibáñez del Campo~Biobío~Coquimbo~Libertador General Bernardo O'Higgins~Los Lagos~Los Ríos~Magallanes y de la Antártica Chilena~Maule~Metropolitana de Santiago~Tarapacá~Valparaíso", + zip: "\\d{7}", + zipex: "8340457,8720019,1230000,8329100", + }, + "data/CM": { id: "data/CM", key: "CM", name: "CAMEROON" }, + "data/CN": { + fmt: "%Z%n%S%C%D%n%A%n%O%n%N", + id: "data/CN", + key: "CN", + lang: "zh", + languages: "zh", + lfmt: "%N%n%O%n%A%n%D%n%C%n%S, %Z", + name: "CHINA", + posturl: "http://www.ems.com.cn/serviceguide/you_bian_cha_xun.html", + require: "ACSZ", + sub_isoids: + "34~92~11~50~35~62~44~45~52~46~13~41~23~42~43~22~32~36~21~15~64~63~37~14~61~31~51~71~12~54~91~65~53~33", + sub_keys: + "安徽省~澳门~北京市~重庆市~福建省~甘肃省~广东省~广西壮族自治区~贵州省~海南省~河北省~河南省~黑龙江省~湖北省~湖南省~吉林省~江苏省~江西省~辽宁省~内蒙古自治区~宁夏回族自治区~青海省~山东省~山西省~陕西省~上海市~四川省~台湾~天津市~西藏自治区~香港~新疆维吾尔自治区~云南省~浙江省", + sub_lnames: + "Anhui Sheng~Macau~Beijing Shi~Chongqing Shi~Fujian Sheng~Gansu Sheng~Guangdong Sheng~Guangxi Zhuangzuzizhiqu~Guizhou Sheng~Hainan Sheng~Hebei Sheng~Henan Sheng~Heilongjiang Sheng~Hubei Sheng~Hunan Sheng~Jilin Sheng~Jiangsu Sheng~Jiangxi Sheng~Liaoning Sheng~Neimenggu Zizhiqu~Ningxia Huizuzizhiqu~Qinghai Sheng~Shandong Sheng~Shanxi Sheng~Shaanxi Sheng~Shanghai Shi~Sichuan Sheng~Taiwan~Tianjin Shi~Xizang Zizhiqu~Hong Kong~Xinjiang Weiwuerzizhiqu~Yunnan Sheng~Zhejiang Sheng", + sub_mores: + "true~true~true~true~true~true~true~true~true~true~true~true~true~true~true~true~true~true~true~true~true~true~true~true~true~true~true~true~true~true~true~true~true~true", + sub_names: + "安徽省~澳门~北京市~重庆市~福建省~甘肃省~广东省~广西~贵州省~海南省~河北省~河南省~黑龙江省~湖北省~湖南省~吉林省~江苏省~江西省~辽宁省~内蒙古~宁夏~青海省~山东省~山西省~陕西省~上海市~四川省~台湾~天津市~西藏~香港~新疆~云南省~浙江省", + sub_xrequires: "~A~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ACS~~~", + sub_xzips: "~999078~~~~~~~~~~~~~~~~~~~~~~~~~~\\d{3}(\\d{2})?~~~999077~~~", + sublocality_name_type: "district", + upper: "S", + zip: "\\d{6}", + zipex: "266033,317204,100096,100808", + }, + "data/CO": { + fmt: "%N%n%O%n%A%n%C, %S, %Z", + id: "data/CO", + key: "CO", + name: "COLOMBIA", + posturl: "http://www.codigopostal.gov.co/", + require: "AS", + state_name_type: "department", + zip: "\\d{6}", + zipex: "111221,130001,760011", + }, + "data/CR": { + fmt: "%N%n%O%n%A%n%S, %C%n%Z", + id: "data/CR", + key: "CR", + name: "COSTA RICA", + posturl: "https://www.correos.go.cr/nosotros/codigopostal/busqueda.html", + require: "ACS", + zip: "\\d{4,5}|\\d{3}-\\d{4}", + zipex: "1000,2010,1001", + }, + "data/CU": { + fmt: "%N%n%O%n%A%n%C %S%n%Z", + id: "data/CU", + key: "CU", + lang: "es", + languages: "es", + name: "CUBA", + sub_isoids: "15~09~08~06~12~14~11~99~03~10~04~16~01~07~13~05", + sub_keys: + "Artemisa~Camagüey~Ciego de Ávila~Cienfuegos~Granma~Guantánamo~Holguín~Isla de la Juventud~La Habana~Las Tunas~Matanzas~Mayabeque~Pinar del Río~Sancti Spíritus~Santiago de Cuba~Villa Clara", + zip: "\\d{5}", + zipex: "10700", + }, + "data/CV": { + fmt: "%N%n%O%n%A%n%Z %C%n%S", + id: "data/CV", + key: "CV", + lang: "pt", + languages: "pt", + name: "CAPE VERDE", + state_name_type: "island", + sub_isoids: "BV~BR~~MA~SL~~~~SV", + sub_keys: + "Boa Vista~Brava~Fogo~Maio~Sal~Santiago~Santo Antão~São Nicolau~São Vicente", + zip: "\\d{4}", + zipex: "7600", + }, + "data/CW": { id: "data/CW", key: "CW", name: "CURACAO" }, + "data/CX": { + fmt: "%O%n%N%n%A%n%C %S %Z", + id: "data/CX", + key: "CX", + name: "CHRISTMAS ISLAND", + upper: "CS", + zip: "6798", + zipex: "6798", + }, + "data/CY": { + fmt: "%N%n%O%n%A%n%Z %C", + id: "data/CY", + key: "CY", + name: "CYPRUS", + zip: "\\d{4}", + zipex: "2008,3304,1900", + }, + "data/CZ": { + fmt: "%N%n%O%n%A%n%Z %C", + id: "data/CZ", + key: "CZ", + name: "CZECH REP.", + posturl: "http://psc.ceskaposta.cz/CleanForm.action", + require: "ACZ", + zip: "\\d{3} ?\\d{2}", + zipex: "100 00,251 66,530 87,110 00,225 99", + }, + "data/DE": { + fmt: "%N%n%O%n%A%n%Z %C", + id: "data/DE", + key: "DE", + name: "GERMANY", + posturl: "http://www.postdirekt.de/plzserver/", + require: "ACZ", + zip: "\\d{5}", + zipex: "26133,53225", + }, + "data/DJ": { id: "data/DJ", key: "DJ", name: "DJIBOUTI" }, + "data/DK": { + fmt: "%N%n%O%n%A%n%Z %C", + id: "data/DK", + key: "DK", + name: "DENMARK", + posturl: + "http://www.postdanmark.dk/da/Privat/Kundeservice/postnummerkort/Sider/Find-postnummer.aspx", + require: "ACZ", + zip: "\\d{4}", + zipex: "8660,1566", + }, + "data/DM": { id: "data/DM", key: "DM", name: "DOMINICA" }, + "data/DO": { + fmt: "%N%n%O%n%A%n%Z %C", + id: "data/DO", + key: "DO", + name: "DOMINICAN REP.", + posturl: "http://inposdom.gob.do/codigo-postal/", + zip: "\\d{5}", + zipex: "11903,10101", + }, + "data/DZ": { + fmt: "%N%n%O%n%A%n%Z %C", + id: "data/DZ", + key: "DZ", + name: "ALGERIA", + zip: "\\d{5}", + zipex: "40304,16027", + }, + "data/EC": { + fmt: "%N%n%O%n%A%n%Z%n%C", + id: "data/EC", + key: "EC", + name: "ECUADOR", + posturl: "http://www.codigopostal.gob.ec/", + upper: "CZ", + zip: "\\d{6}", + zipex: "090105,092301", + }, + "data/EE": { + fmt: "%N%n%O%n%A%n%Z %C", + id: "data/EE", + key: "EE", + name: "ESTONIA", + posturl: "https://www.omniva.ee/era/sihtnumbrite_otsing", + zip: "\\d{5}", + zipex: "69501,11212", + }, + "data/EG": { + fmt: "%N%n%O%n%A%n%C%n%S%n%Z", + id: "data/EG", + key: "EG", + lang: "ar", + languages: "ar", + lfmt: "%N%n%O%n%A%n%C%n%S%n%Z", + name: "EGYPT", + sub_isoids: + "ASN~AST~ALX~IS~LX~BA~BH~GZ~DK~SUZ~SHR~GH~FYM~C~KB~MNF~MN~WAD~BNS~PTS~JS~DT~SHG~SIN~KN~KFS~MT", + sub_keys: + "أسوان~أسيوط~الإسكندرية~الإسماعيلية~الأقصر~البحر الأحمر~البحيرة~الجيزة~الدقهلية~السويس~الشرقية~الغربية~الفيوم~القاهرة~القليوبية~المنوفية~المنيا~الوادي الجديد~بني سويف~بورسعيد~جنوب سيناء~دمياط~سوهاج~شمال سيناء~قنا~كفر الشيخ~مطروح", + sub_lnames: + "Aswan Governorate~Asyut Governorate~Alexandria Governorate~Ismailia Governorate~Luxor Governorate~Red Sea Governorate~El Beheira Governorate~Giza Governorate~Dakahlia Governorate~Suez Governorate~Ash Sharqia Governorate~Gharbia Governorate~Faiyum Governorate~Cairo Governorate~Qalyubia Governorate~Menofia Governorate~Menia Governorate~New Valley Governorate~Beni Suef Governorate~Port Said Governorate~South Sinai Governorate~Damietta Governorate~Sohag Governorate~North Sinai Governorate~Qena Governorate~Kafr El Sheikh Governorate~Matrouh Governorate", + sub_zipexs: + "81000~71000~21000,23000~41000~85000~84000~22000~12000~35000~43000~44000~31000~63000~11000~13000~32000~61000~72000~62000~42000~46000~34000~82000~45000~83000~33000~51000", + sub_zips: + "81~71~2[13]~41~85~84~22~12~35~43~44~31~63~11~13~32~61~72~62~42~46~34~82~45~83~33~51", + zip: "\\d{5}", + zipex: "12411,11599", + }, + "data/EH": { + fmt: "%N%n%O%n%A%n%Z %C", + id: "data/EH", + key: "EH", + name: "WESTERN SAHARA", + zip: "\\d{5}", + zipex: "70000,72000", + }, + "data/ER": { id: "data/ER", key: "ER", name: "ERITREA" }, + "data/ES": { + fmt: "%N%n%O%n%A%n%Z %C %S", + id: "data/ES", + key: "ES", + lang: "es", + languages: "es~ca~gl~eu", + name: "SPAIN", + posturl: + "http://www.correos.es/contenido/13-MenuRec2/04-MenuRec24/1010_s-CodPostal.asp", + require: "ACSZ", + sub_keys: + "VI~AB~A~AL~O~AV~BA~B~BU~CC~CA~S~CS~CE~CR~CO~CU~GI~GR~GU~SS~H~HU~PM~J~C~LO~GC~LE~L~LU~M~MA~ML~MU~NA~OR~P~PO~SA~TF~SG~SE~SO~T~TE~TO~V~VA~BI~ZA~Z", + sub_names: + "Álava~Albacete~Alicante~Almería~Asturias~Ávila~Badajoz~Barcelona~Burgos~Cáceres~Cádiz~Cantabria~Castellón~Ceuta~Ciudad Real~Córdoba~Cuenca~Girona~Granada~Guadalajara~Guipúzcoa~Huelva~Huesca~Islas Baleares~Jaén~La Coruña~La Rioja~Las Palmas~León~Lérida~Lugo~Madrid~Málaga~Melilla~Murcia~Navarra~Ourense~Palencia~Pontevedra~Salamanca~Santa Cruz de Tenerife~Segovia~Sevilla~Soria~Tarragona~Teruel~Toledo~Valencia~Valladolid~Vizcaya~Zamora~Zaragoza", + sub_zips: + "01~02~03~04~33~05~06~08~09~10~11~39~12~51~13~14~16~17~18~19~20~21~22~07~23~15~26~35~24~25~27~28~29~52~30~31~32~34~36~37~38~40~41~42~43~44~45~46~47~48~49~50", + upper: "CS", + zip: "\\d{5}", + zipex: "28039,28300,28070", + }, + "data/ES--ca": { + fmt: "%N%n%O%n%A%n%Z %C %S", + id: "data/ES--ca", + key: "ES", + lang: "ca", + name: "SPAIN", + posturl: + "http://www.correos.es/contenido/13-MenuRec2/04-MenuRec24/1010_s-CodPostal.asp", + require: "ACSZ", + sub_keys: + "A~AB~AL~VI~O~AV~BA~B~BI~BU~CC~CA~S~CS~CE~CR~CO~CU~GI~GR~GU~SS~H~HU~PM~J~C~LO~GC~LE~L~LU~M~MA~ML~MU~NA~OR~P~PO~SA~TF~SG~SE~SO~T~TE~TO~V~VA~ZA~Z", + sub_names: + "Alacant~Albacete~Almeria~Araba~Asturias~Àvila~Badajoz~Barcelona~Bizkaia~Burgos~Cáceres~Cadis~Cantabria~Castelló~Ceuta~Ciudad Real~Córdoba~Cuenca~Girona~Granada~Guadalajara~Guipúscoa~Huelva~Huesca~Illes Balears~Jaén~La Corunya~La Rioja~Las Palmas~León~Lleida~Lugo~Madrid~Málaga~Melilla~Murcia~Navarra~Ourense~Palencia~Pontevedra~Salamanca~Santa Cruz de Tenerife~Segovia~Sevilla~Soria~Tarragona~Teruel~Toledo~València~Valladolid~Zamora~Zaragoza", + sub_zips: + "03~02~04~01~33~05~06~08~48~09~10~11~39~12~51~13~14~16~17~18~19~20~21~22~07~23~15~26~35~24~25~27~28~29~52~30~31~32~34~36~37~38~40~41~42~43~44~45~46~47~49~50", + upper: "CS", + zip: "\\d{5}", + zipex: "28039,28300,28070", + }, + "data/ES--eu": { + fmt: "%N%n%O%n%A%n%Z %C %S", + id: "data/ES--eu", + key: "ES", + lang: "eu", + name: "SPAIN", + posturl: + "http://www.correos.es/contenido/13-MenuRec2/04-MenuRec24/1010_s-CodPostal.asp", + require: "ACSZ", + sub_keys: + "A~AB~AL~VI~O~AV~BA~B~BI~BU~CC~CA~S~CS~CE~CR~C~CU~SS~GI~GR~GU~H~HU~PM~J~CO~LO~GC~LE~L~LU~M~MA~ML~MU~NA~OR~P~PO~SA~TF~SG~SE~SO~T~TE~TO~V~VA~ZA~Z", + sub_names: + "Alacant~Albacete~Almería~Araba~Asturias~Ávila~Badajoz~Barcelona~Bizkaia~Burgos~Cáceres~Cádiz~Cantabria~Castelló~Ceuta~Ciudad Real~Coruña~Cuenca~Gipuzkoa~Girona~Granada~Guadalajara~Huelva~Huesca~Illes Balears~Jaén~Kordoba~La Rioja~Las Palmas~León~Lleida~Lugo~Madrid~Málaga~Melilla~Murtzia~Nafarroa~Ourense~Palentzia~Pontevedra~Salamanca~Santa Cruz Tenerifekoa~Segovia~Sevilla~Soria~Tarragona~Teruel~Toledo~Valentzia~Valladolid~Zamora~Zaragoza", + sub_zips: + "03~02~04~01~33~05~06~08~48~09~10~11~39~12~51~13~15~16~20~17~18~19~21~22~07~23~14~26~35~24~25~27~28~29~52~30~31~32~34~36~37~38~40~41~42~43~44~45~46~47~49~50", + upper: "CS", + zip: "\\d{5}", + zipex: "28039,28300,28070", + }, + "data/ES--gl": { + fmt: "%N%n%O%n%A%n%Z %C %S", + id: "data/ES--gl", + key: "ES", + lang: "gl", + name: "SPAIN", + posturl: + "http://www.correos.es/contenido/13-MenuRec2/04-MenuRec24/1010_s-CodPostal.asp", + require: "ACSZ", + sub_keys: + "C~A~VI~AB~AL~GC~O~AV~BA~B~BI~BU~CC~CA~S~CS~CE~CR~CO~CU~GR~GU~SS~H~HU~PM~LO~LE~L~LU~M~MA~ML~MU~NA~OR~P~PO~SA~TF~SG~SE~SO~T~TE~TO~V~VA~J~GI~ZA~Z", + sub_names: + "A Coruña~Alacant~Álava~Albacete~Almería~As Palmas~Asturias~Ávila~Badaxoz~Barcelona~Biscaia~Burgos~Cáceres~Cádiz~Cantabria~Castelló~Ceuta~Cidade Real~Córdoba~Cuenca~Granada~Guadalajara~Guipúscoa~Huelva~Huesca~Illas Baleares~La Rioja~León~Lleida~Lugo~Madrid~Málaga~Melilla~Murcia~Navarra~Ourense~Palencia~Pontevedra~Salamanca~Santa Cruz de Tenerife~Segovia~Sevilla~Soria~Tarragona~Teruel~Toledo~Valencia~Valladolid~Xaén~Xirona~Zamora~Zaragoza", + sub_zips: + "15~03~01~02~04~35~33~05~06~08~48~09~10~11~39~12~51~13~14~16~18~19~20~21~22~07~26~24~25~27~28~29~52~30~31~32~34~36~37~38~40~41~42~43~44~45~46~47~23~17~49~50", + upper: "CS", + zip: "\\d{5}", + zipex: "28039,28300,28070", + }, + "data/ET": { + fmt: "%N%n%O%n%A%n%Z %C", + id: "data/ET", + key: "ET", + name: "ETHIOPIA", + zip: "\\d{4}", + zipex: "1000", + }, + "data/FI": { + fmt: "%O%n%N%n%A%nFI-%Z %C", + id: "data/FI", + key: "FI", + name: "FINLAND", + postprefix: "FI-", + posturl: "http://www.verkkoposti.com/e3/postinumeroluettelo", + require: "ACZ", + zip: "\\d{5}", + zipex: "00550,00011", + }, + "data/FJ": { id: "data/FJ", key: "FJ", name: "FIJI" }, + "data/FK": { + fmt: "%N%n%O%n%A%n%C%n%Z", + id: "data/FK", + key: "FK", + name: "FALKLAND ISLANDS (MALVINAS)", + require: "ACZ", + upper: "CZ", + zip: "FIQQ 1ZZ", + zipex: "FIQQ 1ZZ", + }, + "data/FM": { + fmt: "%N%n%O%n%A%n%C %S %Z", + id: "data/FM", + key: "FM", + name: "MICRONESIA (Federated State of)", + posturl: "http://zip4.usps.com/zip4/welcome.jsp", + require: "ACSZ", + state_name_type: "state", + upper: "ACNOS", + zip: "(9694[1-4])(?:[ \\-](\\d{4}))?", + zip_name_type: "zip", + zipex: "96941,96944", + }, + "data/FO": { + fmt: "%N%n%O%n%A%nFO%Z %C", + id: "data/FO", + key: "FO", + name: "FAROE ISLANDS", + postprefix: "FO", + posturl: "http://www.postur.fo/", + zip: "\\d{3}", + zipex: "100", + }, + "data/FR": { + fmt: "%O%n%N%n%A%n%Z %C %X", + id: "data/FR", + key: "FR", + name: "FRANCE", + posturl: + "http://www.laposte.fr/Particulier/Utiliser-nos-outils-pratiques/Outils-et-documents/Trouvez-un-code-postal", + require: "ACZ", + upper: "CX", + zip: "\\d{2} ?\\d{3}", + zipex: "33380,34092,33506", + }, + "data/GA": { id: "data/GA", key: "GA", name: "GABON" }, + "data/GB": { + fmt: "%N%n%O%n%A%n%C%n%Z", + id: "data/GB", + key: "GB", + locality_name_type: "post_town", + name: "UNITED KINGDOM", + posturl: "http://www.royalmail.com/postcode-finder", + require: "ACZ", + upper: "CZ", + zip: + "GIR ?0AA|(?:(?:AB|AL|B|BA|BB|BD|BF|BH|BL|BN|BR|BS|BT|BX|CA|CB|CF|CH|CM|CO|CR|CT|CV|CW|DA|DD|DE|DG|DH|DL|DN|DT|DY|E|EC|EH|EN|EX|FK|FY|G|GL|GY|GU|HA|HD|HG|HP|HR|HS|HU|HX|IG|IM|IP|IV|JE|KA|KT|KW|KY|L|LA|LD|LE|LL|LN|LS|LU|M|ME|MK|ML|N|NE|NG|NN|NP|NR|NW|OL|OX|PA|PE|PH|PL|PO|PR|RG|RH|RM|S|SA|SE|SG|SK|SL|SM|SN|SO|SP|SR|SS|ST|SW|SY|TA|TD|TF|TN|TQ|TR|TS|TW|UB|W|WA|WC|WD|WF|WN|WR|WS|WV|YO|ZE)(?:\\d[\\dA-Z]? ?\\d[ABD-HJLN-UW-Z]{2}))|BFPO ?\\d{1,4}", + zipex: + "EC1Y 8SY,GIR 0AA,M2 5BQ,M34 4AB,CR0 2YR,DN16 9AA,W1A 4ZZ,EC1A 1HQ,OX14 4PG,BS18 8HF,NR25 7HG,RH6 0NP,BH23 6AA,B6 5BA,SO23 9AP,PO1 3AX,BFPO 61", + }, + "data/GD": { id: "data/GD", key: "GD", name: "GRENADA (WEST INDIES)" }, + "data/GE": { + fmt: "%N%n%O%n%A%n%Z %C", + id: "data/GE", + key: "GE", + name: "GEORGIA", + posturl: "http://www.georgianpost.ge/index.php?page=10", + zip: "\\d{4}", + zipex: "0101", + }, + "data/GF": { + fmt: "%O%n%N%n%A%n%Z %C %X", + id: "data/GF", + key: "GF", + name: "FRENCH GUIANA", + posturl: + "http://www.laposte.fr/Particulier/Utiliser-nos-outils-pratiques/Outils-et-documents/Trouvez-un-code-postal", + require: "ACZ", + upper: "ACX", + zip: "9[78]3\\d{2}", + zipex: "97300", + }, + "data/GG": { + fmt: "%N%n%O%n%A%n%C%nGUERNSEY%n%Z", + id: "data/GG", + key: "GG", + name: "CHANNEL ISLANDS", + posturl: "http://www.guernseypost.com/postcode_finder/", + require: "ACZ", + upper: "CZ", + zip: "GY\\d[\\dA-Z]? ?\\d[ABD-HJLN-UW-Z]{2}", + zipex: "GY1 1AA,GY2 2BT", + }, + "data/GH": { id: "data/GH", key: "GH", name: "GHANA" }, + "data/GI": { + fmt: "%N%n%O%n%A%nGIBRALTAR%n%Z", + id: "data/GI", + key: "GI", + name: "GIBRALTAR", + require: "A", + zip: "GX11 1AA", + zipex: "GX11 1AA", + }, + "data/GL": { + fmt: "%N%n%O%n%A%n%Z %C", + id: "data/GL", + key: "GL", + name: "GREENLAND", + require: "ACZ", + zip: "39\\d{2}", + zipex: "3900,3950,3911", + }, + "data/GM": { id: "data/GM", key: "GM", name: "GAMBIA" }, + "data/GN": { + fmt: "%N%n%O%n%Z %A %C", + id: "data/GN", + key: "GN", + name: "GUINEA", + zip: "\\d{3}", + zipex: "001,200,100", + }, + "data/GP": { + fmt: "%O%n%N%n%A%n%Z %C %X", + id: "data/GP", + key: "GP", + name: "GUADELOUPE", + posturl: + "http://www.laposte.fr/Particulier/Utiliser-nos-outils-pratiques/Outils-et-documents/Trouvez-un-code-postal", + require: "ACZ", + upper: "ACX", + zip: "9[78][01]\\d{2}", + zipex: "97100", + }, + "data/GQ": { id: "data/GQ", key: "GQ", name: "EQUATORIAL GUINEA" }, + "data/GR": { + fmt: "%N%n%O%n%A%n%Z %C", + id: "data/GR", + key: "GR", + name: "GREECE", + posturl: "http://www.elta.gr/findapostcode.aspx", + require: "ACZ", + zip: "\\d{3} ?\\d{2}", + zipex: "151 24,151 10,101 88", + }, + "data/GS": { + fmt: "%N%n%O%n%A%n%n%C%n%Z", + id: "data/GS", + key: "GS", + name: "SOUTH GEORGIA", + require: "ACZ", + upper: "CZ", + zip: "SIQQ 1ZZ", + zipex: "SIQQ 1ZZ", + }, + "data/GT": { + fmt: "%N%n%O%n%A%n%Z- %C", + id: "data/GT", + key: "GT", + name: "GUATEMALA", + zip: "\\d{5}", + zipex: "09001,01501", + }, + "data/GU": { + fmt: "%N%n%O%n%A%n%C %Z", + id: "data/GU", + key: "GU", + name: "GUAM", + posturl: "http://zip4.usps.com/zip4/welcome.jsp", + require: "ACZ", + upper: "ACNO", + zip: "(969(?:[12]\\d|3[12]))(?:[ \\-](\\d{4}))?", + zip_name_type: "zip", + zipex: "96910,96931", + }, + "data/GW": { + fmt: "%N%n%O%n%A%n%Z %C", + id: "data/GW", + key: "GW", + name: "GUINEA-BISSAU", + zip: "\\d{4}", + zipex: "1000,1011", + }, + "data/GY": { id: "data/GY", key: "GY", name: "GUYANA" }, + "data/HK": { + fmt: "%S%n%C%n%A%n%O%n%N", + id: "data/HK", + key: "HK", + lang: "zh-Hant", + languages: "zh-Hant~en", + lfmt: "%N%n%O%n%A%n%C%n%S", + locality_name_type: "district", + name: "HONG KONG", + require: "AS", + state_name_type: "area", + sub_keys: "Kowloon~Hong Kong Island~New Territories", + sub_mores: "true~true~true", + sub_names: "九龍~香港島~新界", + upper: "S", + }, + "data/HK--en": { + fmt: "%S%n%C%n%A%n%O%n%N", + id: "data/HK--en", + key: "HK", + lang: "en", + lfmt: "%N%n%O%n%A%n%C%n%S", + locality_name_type: "district", + name: "HONG KONG", + require: "AS", + state_name_type: "area", + sub_keys: "Hong Kong Island~Kowloon~New Territories", + sub_lnames: "Hong Kong Island~Kowloon~New Territories", + sub_mores: "true~true~true", + upper: "S", + }, + "data/HM": { + fmt: "%O%n%N%n%A%n%C %S %Z", + id: "data/HM", + key: "HM", + name: "HEARD AND MCDONALD ISLANDS", + upper: "CS", + zip: "\\d{4}", + zipex: "7050", + }, + "data/HN": { + fmt: "%N%n%O%n%A%n%C, %S%n%Z", + id: "data/HN", + key: "HN", + name: "HONDURAS", + require: "ACS", + zip: "\\d{5}", + zipex: "31301", + }, + "data/HR": { + fmt: "%N%n%O%n%A%nHR-%Z %C", + id: "data/HR", + key: "HR", + name: "CROATIA", + postprefix: "HR-", + posturl: "http://www.posta.hr/default.aspx?pretpum", + zip: "\\d{5}", + zipex: "10000,21001,10002", + }, + "data/HT": { + fmt: "%N%n%O%n%A%nHT%Z %C", + id: "data/HT", + key: "HT", + name: "HAITI", + postprefix: "HT", + zip: "\\d{4}", + zipex: "6120,5310,6110,8510", + }, + "data/HU": { + fmt: "%N%n%O%n%C%n%A%n%Z", + id: "data/HU", + key: "HU", + name: "HUNGARY (Rep.)", + posturl: "http://posta.hu/ugyfelszolgalat/iranyitoszam_kereso", + require: "ACZ", + upper: "ACNO", + zip: "\\d{4}", + zipex: "1037,2380,1540", + }, + "data/ID": { + fmt: "%N%n%O%n%A%n%C%n%S %Z", + id: "data/ID", + key: "ID", + lang: "id", + languages: "id", + name: "INDONESIA", + require: "AS", + sub_isoids: + "AC~BA~BT~BE~YO~JK~GO~JA~JB~JT~JI~KB~KS~KT~KI~KU~BB~KR~LA~MA~MU~NB~NT~PA~PB~RI~SR~SN~ST~SG~SA~SB~SS~SU", + sub_keys: + "Aceh~Bali~Banten~Bengkulu~Daerah Istimewa Yogyakarta~DKI Jakarta~Gorontalo~Jambi~Jawa Barat~Jawa Tengah~Jawa Timur~Kalimantan Barat~Kalimantan Selatan~Kalimantan Tengah~Kalimantan Timur~Kalimantan Utara~Kepulauan Bangka Belitung~Kepulauan Riau~Lampung~Maluku~Maluku Utara~Nusa Tenggara Barat~Nusa Tenggara Timur~Papua~Papua Barat~Riau~Sulawesi Barat~Sulawesi Selatan~Sulawesi Tengah~Sulawesi Tenggara~Sulawesi Utara~Sumatera Barat~Sumatera Selatan~Sumatera Utara", + zip: "\\d{5}", + zipex: "40115", + }, + "data/IE": { + fmt: "%N%n%O%n%A%n%D%n%C%n%S %Z", + id: "data/IE", + key: "IE", + lang: "en", + languages: "en", + name: "IRELAND", + posturl: "https://finder.eircode.ie", + state_name_type: "county", + sub_isoids: + "CW~CN~CE~C~DL~D~G~KY~KE~KK~LS~LM~LK~LD~LH~MO~MH~MN~OY~RN~SO~TA~WD~WH~WX~WW", + sub_keys: + "Co. Carlow~Co. Cavan~Co. Clare~Co. Cork~Co. Donegal~Co. Dublin~Co. Galway~Co. Kerry~Co. Kildare~Co. Kilkenny~Co. Laois~Co. Leitrim~Co. Limerick~Co. Longford~Co. Louth~Co. Mayo~Co. Meath~Co. Monaghan~Co. Offaly~Co. Roscommon~Co. Sligo~Co. Tipperary~Co. Waterford~Co. Westmeath~Co. Wexford~Co. Wicklow", + sublocality_name_type: "townland", + zip: "[\\dA-Z]{3} ?[\\dA-Z]{4}", + zip_name_type: "eircode", + zipex: "A65 F4E2", + }, + "data/IL": { + fmt: "%N%n%O%n%A%n%C %Z", + id: "data/IL", + key: "IL", + name: "ISRAEL", + posturl: "http://www.israelpost.co.il/zipcode.nsf/demozip?openform", + zip: "\\d{5}(?:\\d{2})?", + zipex: "9614303", + }, + "data/IM": { + fmt: "%N%n%O%n%A%n%C%n%Z", + id: "data/IM", + key: "IM", + name: "ISLE OF MAN", + posturl: "https://www.iompost.com/tools-forms/postcode-finder/", + require: "ACZ", + upper: "CZ", + zip: "IM\\d[\\dA-Z]? ?\\d[ABD-HJLN-UW-Z]{2}", + zipex: "IM2 1AA,IM99 1PS", + }, + "data/IN": { + fmt: "%N%n%O%n%A%n%C %Z%n%S", + id: "data/IN", + key: "IN", + lang: "en", + languages: "en~hi", + name: "INDIA", + posturl: "https://www.indiapost.gov.in/vas/pages/FindPinCode.aspx", + require: "ACSZ", + state_name_type: "state", + sub_isoids: + "AN~AP~AR~AS~BR~CH~CT~DN~DD~DL~GA~GJ~HR~HP~JK~JH~KA~KL~LD~MP~MH~MN~ML~MZ~NL~OR~PY~PB~RJ~SK~TN~TG~TR~UP~UT~WB", + sub_keys: + "Andaman and Nicobar Islands~Andhra Pradesh~Arunachal Pradesh~Assam~Bihar~Chandigarh~Chhattisgarh~Dadra and Nagar Haveli~Daman and Diu~Delhi~Goa~Gujarat~Haryana~Himachal Pradesh~Jammu and Kashmir~Jharkhand~Karnataka~Kerala~Lakshadweep~Madhya Pradesh~Maharashtra~Manipur~Meghalaya~Mizoram~Nagaland~Odisha~Puducherry~Punjab~Rajasthan~Sikkim~Tamil Nadu~Telangana~Tripura~Uttar Pradesh~Uttarakhand~West Bengal", + sub_names: + "Andaman & Nicobar~Andhra Pradesh~Arunachal Pradesh~Assam~Bihar~Chandigarh~Chhattisgarh~Dadra & Nagar Haveli~Daman & Diu~Delhi~Goa~Gujarat~Haryana~Himachal Pradesh~Jammu & Kashmir~Jharkhand~Karnataka~Kerala~Lakshadweep~Madhya Pradesh~Maharashtra~Manipur~Meghalaya~Mizoram~Nagaland~Odisha~Puducherry~Punjab~Rajasthan~Sikkim~Tamil Nadu~Telangana~Tripura~Uttar Pradesh~Uttarakhand~West Bengal", + sub_zips: + "744~5[0-3]~79[0-2]~78~8[0-5]~16|1440[3-9]~49~396~396~11~403~3[6-9]~1[23]~17~1[89]~81[4-9]|82|83[0-5]~5[4-9]|53[7-9]~6[7-9]|6010|607008|777~682~4[5-8]|490~4[0-4]~79[56]~79[34]~796~79[78]~7[5-7]~60[579]~1[456]~3[0-4]~737|750~6[0-6]|536~5[0-3]~799~2[0-35-8]|24[0-7]|26[12]~24[46-9]|254|26[23]~7[0-4]", + zip: "\\d{6}", + zip_name_type: "pin", + zipex: "110034,110001", + }, + "data/IN--hi": { + fmt: "%N%n%O%n%A%n%C %Z%n%S", + id: "data/IN--hi", + key: "IN", + lang: "hi", + name: "INDIA", + posturl: "https://www.indiapost.gov.in/vas/pages/FindPinCode.aspx", + require: "ACSZ", + state_name_type: "state", + sub_isoids: + "AN~AR~AS~AP~UP~UT~OR~KA~KL~GJ~GA~CH~CT~JK~JH~TN~TG~TR~DD~DN~DL~NL~PB~WB~PY~BR~MN~MP~MH~MZ~ML~RJ~LD~SK~HR~HP", + sub_keys: + "Andaman & Nicobar~Arunachal Pradesh~Assam~Andhra Pradesh~Uttar Pradesh~Uttarakhand~Odisha~Karnataka~Kerala~Gujarat~Goa~Chandigarh~Chhattisgarh~Jammu & Kashmir~Jharkhand~Tamil Nadu~Telangana~Tripura~Daman & Diu~Dadra & Nagar Haveli~Delhi~Nagaland~Punjab~West Bengal~Puducherry~Bihar~Manipur~Madhya Pradesh~Maharashtra~Mizoram~Meghalaya~Rajasthan~Lakshadweep~Sikkim~Haryana~Himachal Pradesh", + sub_names: + "अंडमान और निकोबार द्वीपसमूह~अरुणाचल प्रदेश~असम~आंध्र प्रदेश~उत्तर प्रदेश~उत्तराखण्ड~ओड़िशा~कर्नाटक~केरल~गुजरात~गोआ~चंडीगढ़~छत्तीसगढ़~जम्मू और कश्मीर~झारखण्ड~तमिल नाडु~तेलंगाना~त्रिपुरा~दमन और दीव~दादरा और नगर हवेली~दिल्ली~नागालैंड~पंजाब~पश्चिम बंगाल~पांडिचेरी~बिहार~मणिपुर~मध्य प्रदेश~महाराष्ट्र~मिजोरम~मेघालय~राजस्थान~लक्षद्वीप~सिक्किम~हरियाणा~हिमाचल प्रदेश", + sub_zips: + "744~79[0-2]~78~5[0-3]~2[0-35-8]|24[0-7]|26[12]~24[46-9]|254|26[23]~7[5-7]~5[4-9]|53[7-9]~6[7-9]|6010|607008|777~3[6-9]~403~16|1440[3-9]~49~1[89]~81[4-9]|82|83[0-5]~6[0-6]|536~5[0-3]~799~396~396~11~79[78]~1[456]~7[0-4]~60[579]~8[0-5]~79[56]~4[5-8]|490~4[0-4]~796~79[34]~3[0-4]~682~737|750~1[23]~17", + zip: "\\d{6}", + zip_name_type: "pin", + zipex: "110034,110001", + }, + "data/IO": { + fmt: "%N%n%O%n%A%n%C%n%Z", + id: "data/IO", + key: "IO", + name: "BRITISH INDIAN OCEAN TERRITORY", + require: "ACZ", + upper: "CZ", + zip: "BBND 1ZZ", + zipex: "BBND 1ZZ", + }, + "data/IQ": { + fmt: "%O%n%N%n%A%n%C, %S%n%Z", + id: "data/IQ", + key: "IQ", + name: "IRAQ", + require: "ACS", + upper: "CS", + zip: "\\d{5}", + zipex: "31001", + }, + "data/IR": { + fmt: "%O%n%N%n%S%n%C, %D%n%A%n%Z", + id: "data/IR", + key: "IR", + lang: "fa", + languages: "fa", + name: "IRAN", + sub_isoids: + "01~02~03~04~32~05~06~07~08~29~30~31~10~11~12~13~14~28~26~16~15~17~18~27~19~20~21~22~23~24~25", + sub_keys: + "استان آذربایجان شرقی~استان آذربایجان غربی~استان اردبیل~استان اصفهان~استان البرز~استان ایلام~استان بوشهر~استان تهران~استان چهارمحال و بختیاری~استان خراسان جنوبی~استان خراسان رضوی~استان خراسان شمالی~استان خوزستان~استان زنجان~استان سمنان~استان سیستان و بلوچستان~استان فارس~استان قزوین~استان قم~استان کردستان~استان کرمان~استان کرمانشاه~استان کهگیلویه و بویراحمد~استان گلستان~استان گیلان~استان لرستان~استان مازندران~استان مرکزی~استان هرمزگان~استان همدان~استان یزد", + sub_lnames: + "East Azerbaijan Province~West Azerbaijan Province~Ardabil Province~Isfahan Province~Alborz Province~Ilam Province~Bushehr Province~Tehran Province~Chaharmahal and Bakhtiari Province~South Khorasan Province~Razavi Khorasan Province~North Khorasan Province~Khuzestan Province~Zanjan Province~Semnan Province~Sistan and Baluchestan Province~Fars Province~Qazvin Province~Qom Province~Kurdistan Province~Kerman Province~Kermanshah Province~Kohgiluyeh and Boyer-Ahmad Province~Golestan Province~Gilan Province~Lorestan Province~Mazandaran Province~Markazi Province~Hormozgan Province~Hamadan Province~Yazd Province", + sublocality_name_type: "neighborhood", + zip: "\\d{5}-?\\d{5}", + zipex: "11936-12345", + }, + "data/IS": { + fmt: "%N%n%O%n%A%n%Z %C", + id: "data/IS", + key: "IS", + name: "ICELAND", + posturl: "http://www.postur.is/einstaklingar/posthus/postnumer/", + zip: "\\d{3}", + zipex: "320,121,220,110", + }, + "data/IT": { + fmt: "%N%n%O%n%A%n%Z %C %S", + id: "data/IT", + key: "IT", + lang: "it", + languages: "it", + name: "ITALY", + posturl: "http://www.poste.it/online/cercacap/", + require: "ACSZ", + sub_isoids: + "AG~AL~AN~AO~AR~AP~AT~AV~BA~BT~BL~BN~BG~BI~BO~BZ~BS~BR~CA~CL~CB~CI~CE~CT~CZ~CH~CO~CS~CR~KR~CN~EN~FM~FE~FI~FG~FC~FR~GE~GO~GR~IM~IS~AQ~SP~LT~LE~LC~LI~LO~LU~MC~MN~MS~MT~VS~ME~MI~MO~MB~NA~NO~NU~OG~OT~OR~PD~PA~PR~PV~PG~PU~PE~PC~PI~PT~PN~PZ~PO~RG~RA~RC~RE~RI~RN~RM~RO~SA~SS~SV~SI~SR~SO~TA~TE~TR~TO~TP~TN~TV~TS~UD~VA~VE~VB~VC~VR~VV~VI~VT", + sub_keys: + "AG~AL~AN~AO~AR~AP~AT~AV~BA~BT~BL~BN~BG~BI~BO~BZ~BS~BR~CA~CL~CB~CI~CE~CT~CZ~CH~CO~CS~CR~KR~CN~EN~FM~FE~FI~FG~FC~FR~GE~GO~GR~IM~IS~AQ~SP~LT~LE~LC~LI~LO~LU~MC~MN~MS~MT~VS~ME~MI~MO~MB~NA~NO~NU~OG~OT~OR~PD~PA~PR~PV~PG~PU~PE~PC~PI~PT~PN~PZ~PO~RG~RA~RC~RE~RI~RN~RM~RO~SA~SS~SV~SI~SR~SO~TA~TE~TR~TO~TP~TN~TV~TS~UD~VA~VE~VB~VC~VR~VV~VI~VT", + sub_names: + "Agrigento~Alessandria~Ancona~Aosta~Arezzo~Ascoli Piceno~Asti~Avellino~Bari~Barletta-Andria-Trani~Belluno~Benevento~Bergamo~Biella~Bologna~Bolzano~Brescia~Brindisi~Cagliari~Caltanissetta~Campobasso~Carbonia-Iglesias~Caserta~Catania~Catanzaro~Chieti~Como~Cosenza~Cremona~Crotone~Cuneo~Enna~Fermo~Ferrara~Firenze~Foggia~Forlì-Cesena~Frosinone~Genova~Gorizia~Grosseto~Imperia~Isernia~L'Aquila~La Spezia~Latina~Lecce~Lecco~Livorno~Lodi~Lucca~Macerata~Mantova~Massa-Carrara~Matera~Medio Campidano~Messina~Milano~Modena~Monza e Brianza~Napoli~Novara~Nuoro~Ogliastra~Olbia-Tempio~Oristano~Padova~Palermo~Parma~Pavia~Perugia~Pesaro e Urbino~Pescara~Piacenza~Pisa~Pistoia~Pordenone~Potenza~Prato~Ragusa~Ravenna~Reggio Calabria~Reggio Emilia~Rieti~Rimini~Roma~Rovigo~Salerno~Sassari~Savona~Siena~Siracusa~Sondrio~Taranto~Teramo~Terni~Torino~Trapani~Trento~Treviso~Trieste~Udine~Varese~Venezia~Verbano-Cusio-Ossola~Vercelli~Verona~Vibo Valentia~Vicenza~Viterbo", + sub_zips: + "92~15~60~11~52~63~14~83~70~76[01]~32~82~24~13[89]~40~39~25~72~0912[1-9]|0913[0-4]|0901[0289]|0902[03468]|0903[0234]|0904|0803[035]|08043~93~860[1-4]|86100~0901[013-7]~81~95~88[01]~66~22~87~26[01]~88[89]~12|18025~94~638|63900~44~50~71~47[015]~03~16~34[01]7~58~18~860[7-9]|86170~67~19~04~73~23[89]~57~26[89]~55~62~46~54~75~0902[012579]|0903[015-9]|09040~98~20~41~208|20900~80~28[01]~080[1-3]|08100~08037|0804[024-9]~08020|0702|0703[08]~090[7-9]|09170|0801[039]|0803[04]~35~90~43~27~06~61~65~29~56~51~330[7-9]|33170~85~59~97~48~89[01]~42~02~47[89]~00~45~84~070[14]|0703[0-79]|07100~17|12071~53~96~23[01]~74~64~05~10~91~38~31~3401|341[0-689]|34062~330[1-5]|33100~21~30~28[89]~13[01]~37~89[89]~36~01", + upper: "CS", + zip: "\\d{5}", + zipex: "00144,47037,39049", + }, + "data/JE": { + fmt: "%N%n%O%n%A%n%C%nJERSEY%n%Z", + id: "data/JE", + key: "JE", + name: "CHANNEL ISLANDS", + posturl: "http://www.jerseypost.com/tools/postcode-address-finder/", + require: "ACZ", + upper: "CZ", + zip: "JE\\d[\\dA-Z]? ?\\d[ABD-HJLN-UW-Z]{2}", + zipex: "JE1 1AA,JE2 2BT", + }, + "data/JM": { + fmt: "%N%n%O%n%A%n%C%n%S %X", + id: "data/JM", + key: "JM", + lang: "en", + languages: "en", + name: "JAMAICA", + require: "ACS", + state_name_type: "parish", + sub_isoids: "13~09~01~12~04~02~06~14~11~08~05~03~07~10", + sub_keys: + "Clarendon~Hanover~Kingston~Manchester~Portland~St. Andrew~St. Ann~St. Catherine~St. Elizabeth~St. James~St. Mary~St. Thomas~Trelawny~Westmoreland", + }, + "data/JO": { + fmt: "%N%n%O%n%A%n%C %Z", + id: "data/JO", + key: "JO", + name: "JORDAN", + zip: "\\d{5}", + zipex: "11937,11190", + }, + "data/JP": { + fmt: "〒%Z%n%S%n%A%n%O%n%N", + id: "data/JP", + key: "JP", + lang: "ja", + languages: "ja", + lfmt: "%N%n%O%n%A, %S%n%Z", + name: "JAPAN", + posturl: "http://www.post.japanpost.jp/zipcode/", + require: "ASZ", + state_name_type: "prefecture", + sub_isoids: + "01~02~03~04~05~06~07~08~09~10~11~12~13~14~15~16~17~18~19~20~21~22~23~24~25~26~27~28~29~30~31~32~33~34~35~36~37~38~39~40~41~42~43~44~45~46~47", + sub_keys: + "北海道~青森県~岩手県~宮城県~秋田県~山形県~福島県~茨城県~栃木県~群馬県~埼玉県~千葉県~東京都~神奈川県~新潟県~富山県~石川県~福井県~山梨県~長野県~岐阜県~静岡県~愛知県~三重県~滋賀県~京都府~大阪府~兵庫県~奈良県~和歌山県~鳥取県~島根県~岡山県~広島県~山口県~徳島県~香川県~愛媛県~高知県~福岡県~佐賀県~長崎県~熊本県~大分県~宮崎県~鹿児島県~沖縄県", + sub_lnames: + "Hokkaido~Aomori~Iwate~Miyagi~Akita~Yamagata~Fukushima~Ibaraki~Tochigi~Gunma~Saitama~Chiba~Tokyo~Kanagawa~Niigata~Toyama~Ishikawa~Fukui~Yamanashi~Nagano~Gifu~Shizuoka~Aichi~Mie~Shiga~Kyoto~Osaka~Hyogo~Nara~Wakayama~Tottori~Shimane~Okayama~Hiroshima~Yamaguchi~Tokushima~Kagawa~Ehime~Kochi~Fukuoka~Saga~Nagasaki~Kumamoto~Oita~Miyazaki~Kagoshima~Okinawa", + sub_zips: + "0[4-9]|00[1-7]~03|018~02~98~01~99~9[67]~3[01]~32|311|349~37|38[49]~3[3-6]~2[6-9]~1[0-8]|19[0-8]|20~2[1-5]|199~9[45]|389~93~92|939~91|922~40~3[89]|949~50~4[1-9]~4[4-9]|431~51|498|647~52~6[0-2]|520~5[3-9]|618|630~6[5-7]|563~63|64[78]~64|519~68~69|68[45]~7[01]~7[23]~7[45]~77~76~79~78~8[0-3]|871~84~85|81[17]|848~86~87|839~88~89~90", + upper: "S", + zip: "\\d{3}-?\\d{4}", + zipex: "154-0023,350-1106,951-8073,112-0001,208-0032,231-0012", + }, + "data/KE": { + fmt: "%N%n%O%n%A%n%C%n%Z", + id: "data/KE", + key: "KE", + name: "KENYA", + zip: "\\d{5}", + zipex: "20100,00100", + }, + "data/KG": { + fmt: "%N%n%O%n%A%n%Z %C", + id: "data/KG", + key: "KG", + name: "KYRGYZSTAN", + zip: "\\d{6}", + zipex: "720001", + }, + "data/KH": { + fmt: "%N%n%O%n%A%n%C %Z", + id: "data/KH", + key: "KH", + name: "CAMBODIA", + zip: "\\d{5}", + zipex: "12203,14206,12000", + }, + "data/KI": { + fmt: "%N%n%O%n%A%n%S%n%C", + id: "data/KI", + key: "KI", + name: "KIRIBATI", + state_name_type: "island", + upper: "ACNOS", + }, + "data/KM": { id: "data/KM", key: "KM", name: "COMOROS", upper: "AC" }, + "data/KN": { + fmt: "%N%n%O%n%A%n%C, %S", + id: "data/KN", + key: "KN", + lang: "en", + languages: "en", + name: "SAINT KITTS AND NEVIS", + require: "ACS", + state_name_type: "island", + sub_isoids: "N~K", + sub_keys: "Nevis~St. Kitts", + }, + "data/KP": { + fmt: "%Z%n%S%n%C%n%A%n%O%n%N", + id: "data/KP", + key: "KP", + lang: "ko", + languages: "ko", + lfmt: "%N%n%O%n%A%n%C%n%S, %Z", + name: "NORTH KOREA", + sub_isoids: "07~13~10~04~02~03~01~08~09~05~06", + sub_keys: + "강원도~라선 특별시~량강도~자강도~평안 남도~평안 북도~평양 직할시~함경 남도~함경 북도~황해남도~황해북도", + sub_lnames: + "Kangwon~Rason~Ryanggang~Chagang~South Pyongan~North Pyongan~Pyongyang~South Hamgyong~North Hamgyong~South Hwanghae~North Hwanghae", + }, + "data/KR": { + fmt: "%S %C%D%n%A%n%O%n%N%n%Z", + id: "data/KR", + key: "KR", + lang: "ko", + languages: "ko", + lfmt: "%N%n%O%n%A%n%D%n%C%n%S%n%Z", + name: "SOUTH KOREA", + posturl: "http://www.epost.go.kr/search/zipcode/search5.jsp", + require: "ACSZ", + state_name_type: "do_si", + sub_isoids: "42~41~48~47~29~27~30~26~11~50~31~28~46~45~49~44~43", + sub_keys: + "강원도~경기도~경상남도~경상북도~광주광역시~대구광역시~대전광역시~부산광역시~서울특별시~세종특별자치시~울산광역시~인천광역시~전라남도~전라북도~제주특별자치도~충청남도~충청북도", + sub_lnames: + "Gangwon-do~Gyeonggi-do~Gyeongsangnam-do~Gyeongsangbuk-do~Gwangju~Daegu~Daejeon~Busan~Seoul~Sejong~Ulsan~Incheon~Jeollanam-do~Jeollabuk-do~Jeju-do~Chungcheongnam-do~Chungcheongbuk-do", + sub_mores: + "true~true~true~true~true~true~true~true~true~true~true~true~true~true~true~true~true", + sub_names: + "강원~경기~경남~경북~광주~대구~대전~부산~서울~세종~울산~인천~전남~전북~제주~충남~충북", + sub_zipexs: + "25627~12410~53286~38540~62394~42456~34316~46706~06321~30065~44782~23024~59222~56445~63563~32832~28006", + sub_zips: + "2[456]\\d{2}~1[0-8]\\d{2}~5[0-3]\\d{2}~(?:3[6-9]|40)\\d{2}~6[12]\\d{2}~4[12]\\d{2}~3[45]\\d{2}~4[6-9]\\d{2}~0[1-8]\\d{2}~30[01]\\d~4[45]\\d{2}~2[1-3]\\d{2}~5[7-9]\\d{2}~5[4-6]\\d{2}~63[0-356]\\d~3[1-3]\\d{2}~2[789]\\d{2}", + sublocality_name_type: "district", + upper: "Z", + zip: "\\d{5}", + zipex: "03051", + }, + "data/KW": { + fmt: "%N%n%O%n%A%n%Z %C", + id: "data/KW", + key: "KW", + name: "KUWAIT", + zip: "\\d{5}", + zipex: "54541,54551,54404,13009", + }, + "data/KY": { + fmt: "%N%n%O%n%A%n%S %Z", + id: "data/KY", + key: "KY", + lang: "en", + languages: "en", + name: "CAYMAN ISLANDS", + posturl: "http://www.caymanpost.gov.ky/", + require: "AS", + state_name_type: "island", + sub_keys: "Cayman Brac~Grand Cayman~Little Cayman", + zip: "KY\\d-\\d{4}", + zipex: "KY1-1100,KY1-1702,KY2-2101", + }, + "data/KZ": { + fmt: "%Z%n%S%n%C%n%A%n%O%n%N", + id: "data/KZ", + key: "KZ", + name: "KAZAKHSTAN", + zip: "\\d{6}", + zipex: "040900,050012", + }, + "data/LA": { + fmt: "%N%n%O%n%A%n%Z %C", + id: "data/LA", + key: "LA", + name: "LAO (PEOPLE'S DEM. REP.)", + zip: "\\d{5}", + zipex: "01160,01000", + }, + "data/LB": { + fmt: "%N%n%O%n%A%n%C %Z", + id: "data/LB", + key: "LB", + name: "LEBANON", + zip: "(?:\\d{4})(?: ?(?:\\d{4}))?", + zipex: "2038 3054,1107 2810,1000", + }, + "data/LC": { id: "data/LC", key: "LC", name: "SAINT LUCIA" }, + "data/LI": { + fmt: "%O%n%N%n%A%nFL-%Z %C", + id: "data/LI", + key: "LI", + name: "LIECHTENSTEIN", + postprefix: "FL-", + posturl: "http://www.post.ch/db/owa/pv_plz_pack/pr_main", + require: "ACZ", + zip: "948[5-9]|949[0-8]", + zipex: "9496,9491,9490,9485", + }, + "data/LK": { + fmt: "%N%n%O%n%A%n%C%n%Z", + id: "data/LK", + key: "LK", + name: "SRI LANKA", + posturl: "http://www.slpost.gov.lk/", + zip: "\\d{5}", + zipex: "20000,00100", + }, + "data/LR": { + fmt: "%N%n%O%n%A%n%Z %C", + id: "data/LR", + key: "LR", + name: "LIBERIA", + zip: "\\d{4}", + zipex: "1000", + }, + "data/LS": { + fmt: "%N%n%O%n%A%n%C %Z", + id: "data/LS", + key: "LS", + name: "LESOTHO", + zip: "\\d{3}", + zipex: "100", + }, + "data/LT": { + fmt: "%O%n%N%n%A%nLT-%Z %C", + id: "data/LT", + key: "LT", + name: "LITHUANIA", + postprefix: "LT-", + posturl: "http://www.post.lt/lt/?id=316", + zip: "\\d{5}", + zipex: "04340,03500", + }, + "data/LU": { + fmt: "%O%n%N%n%A%nL-%Z %C", + id: "data/LU", + key: "LU", + name: "LUXEMBOURG", + postprefix: "L-", + posturl: + "https://www.post.lu/fr/grandes-entreprises/solutions-postales/rechercher-un-code-postal", + require: "ACZ", + zip: "\\d{4}", + zipex: "4750,2998", + }, + "data/LV": { + fmt: "%N%n%O%n%A%n%C, %Z", + id: "data/LV", + key: "LV", + name: "LATVIA", + posturl: "http://www.pasts.lv/lv/uzzinas/nodalas/", + zip: "LV-\\d{4}", + zipex: "LV-1073,LV-1000", + }, + "data/LY": { id: "data/LY", key: "LY", name: "LIBYA" }, + "data/MA": { + fmt: "%N%n%O%n%A%n%Z %C", + id: "data/MA", + key: "MA", + name: "MOROCCO", + zip: "\\d{5}", + zipex: "53000,10000,20050,16052", + }, + "data/MC": { + fmt: "%N%n%O%n%A%nMC-%Z %C %X", + id: "data/MC", + key: "MC", + name: "MONACO", + postprefix: "MC-", + zip: "980\\d{2}", + zipex: "98000,98020,98011,98001", + }, + "data/MD": { + fmt: "%N%n%O%n%A%nMD-%Z %C", + id: "data/MD", + key: "MD", + name: "Rep. MOLDOVA", + postprefix: "MD-", + zip: "\\d{4}", + zipex: "2012,2019", + }, + "data/ME": { + fmt: "%N%n%O%n%A%n%Z %C", + id: "data/ME", + key: "ME", + name: "MONTENEGRO", + zip: "8\\d{4}", + zipex: "81257,81258,81217,84314,85366", + }, + "data/MF": { + fmt: "%O%n%N%n%A%n%Z %C %X", + id: "data/MF", + key: "MF", + name: "SAINT MARTIN", + posturl: + "http://www.laposte.fr/Particulier/Utiliser-nos-outils-pratiques/Outils-et-documents/Trouvez-un-code-postal", + require: "ACZ", + upper: "ACX", + zip: "9[78][01]\\d{2}", + zipex: "97100", + }, + "data/MG": { + fmt: "%N%n%O%n%A%n%Z %C", + id: "data/MG", + key: "MG", + name: "MADAGASCAR", + zip: "\\d{3}", + zipex: "501,101", + }, + "data/MH": { + fmt: "%N%n%O%n%A%n%C %S %Z", + id: "data/MH", + key: "MH", + name: "MARSHALL ISLANDS", + posturl: "http://zip4.usps.com/zip4/welcome.jsp", + require: "ACSZ", + state_name_type: "state", + upper: "ACNOS", + zip: "(969[67]\\d)(?:[ \\-](\\d{4}))?", + zip_name_type: "zip", + zipex: "96960,96970", + }, + "data/MK": { + fmt: "%N%n%O%n%A%n%Z %C", + id: "data/MK", + key: "MK", + name: "MACEDONIA", + zip: "\\d{4}", + zipex: "1314,1321,1443,1062", + }, + "data/ML": { id: "data/ML", key: "ML", name: "MALI" }, + "data/MM": { + fmt: "%N%n%O%n%A%n%C, %Z", + id: "data/MM", + key: "MM", + name: "MYANMAR", + zip: "\\d{5}", + zipex: "11181", + }, + "data/MN": { + fmt: "%N%n%O%n%A%n%C%n%S %Z", + id: "data/MN", + key: "MN", + name: "MONGOLIA", + posturl: "http://www.zipcode.mn/", + zip: "\\d{5}", + zipex: "65030,65270", + }, + "data/MO": { + fmt: "%A%n%O%n%N", + id: "data/MO", + key: "MO", + lfmt: "%N%n%O%n%A", + name: "MACAO", + require: "A", + }, + "data/MP": { + fmt: "%N%n%O%n%A%n%C %S %Z", + id: "data/MP", + key: "MP", + name: "NORTHERN MARIANA ISLANDS", + posturl: "http://zip4.usps.com/zip4/welcome.jsp", + require: "ACSZ", + state_name_type: "state", + upper: "ACNOS", + zip: "(9695[012])(?:[ \\-](\\d{4}))?", + zip_name_type: "zip", + zipex: "96950,96951,96952", + }, + "data/MQ": { + fmt: "%O%n%N%n%A%n%Z %C %X", + id: "data/MQ", + key: "MQ", + name: "MARTINIQUE", + posturl: + "http://www.laposte.fr/Particulier/Utiliser-nos-outils-pratiques/Outils-et-documents/Trouvez-un-code-postal", + require: "ACZ", + upper: "ACX", + zip: "9[78]2\\d{2}", + zipex: "97220", + }, + "data/MR": { id: "data/MR", key: "MR", name: "MAURITANIA", upper: "AC" }, + "data/MS": { id: "data/MS", key: "MS", name: "MONTSERRAT" }, + "data/MT": { + fmt: "%N%n%O%n%A%n%C %Z", + id: "data/MT", + key: "MT", + name: "MALTA", + posturl: "http://postcodes.maltapost.com/", + upper: "CZ", + zip: "[A-Z]{3} ?\\d{2,4}", + zipex: "NXR 01,ZTN 05,GPO 01,BZN 1130,SPB 6031,VCT 1753", + }, + "data/MU": { + fmt: "%N%n%O%n%A%n%Z%n%C", + id: "data/MU", + key: "MU", + name: "MAURITIUS", + upper: "CZ", + zip: "\\d{3}(?:\\d{2}|[A-Z]{2}\\d{3})", + zipex: "42602", + }, + "data/MV": { + fmt: "%N%n%O%n%A%n%C %Z", + id: "data/MV", + key: "MV", + name: "MALDIVES", + posturl: "http://www.maldivespost.com/?lid=10", + zip: "\\d{5}", + zipex: "20026", + }, + "data/MW": { + fmt: "%N%n%O%n%A%n%C %X", + id: "data/MW", + key: "MW", + name: "MALAWI", + }, + "data/MX": { + fmt: "%N%n%O%n%A%n%D%n%Z %C, %S", + id: "data/MX", + key: "MX", + lang: "es", + languages: "es", + name: "MEXICO", + posturl: + "http://www.correosdemexico.gob.mx/ServiciosLinea/Paginas/ccpostales.aspx", + require: "ACZ", + state_name_type: "state", + sub_isoids: + "AGU~BCN~BCS~CAM~CHP~CHH~CMX~COA~COL~DUR~MEX~GUA~GRO~HID~JAL~MIC~MOR~NAY~NLE~OAX~PUE~QUE~ROO~SLP~SIN~SON~TAB~TAM~TLA~VER~YUC~ZAC", + sub_keys: + "Ags.~B.C.~B.C.S.~Camp.~Chis.~Chih.~CDMX~Coah.~Col.~Dgo.~Méx.~Gto.~Gro.~Hgo.~Jal.~Mich.~Mor.~Nay.~N.L.~Oax.~Pue.~Qro.~Q.R.~S.L.P.~Sin.~Son.~Tab.~Tamps.~Tlax.~Ver.~Yuc.~Zac.", + sub_names: + "Aguascalientes~Baja California~Baja California Sur~Campeche~Chiapas~Chihuahua~Ciudad de México~Coahuila de Zaragoza~Colima~Durango~Estado de México~Guanajuato~Guerrero~Hidalgo~Jalisco~Michoacán~Morelos~Nayarit~Nuevo León~Oaxaca~Puebla~Querétaro~Quintana Roo~San Luis Potosí~Sinaloa~Sonora~Tabasco~Tamaulipas~Tlaxcala~Veracruz~Yucatán~Zacatecas", + sub_zipexs: + "20000,20999~21000,22999~23000,23999~24000,24999~29000,30999~31000,33999~00000,16999~25000,27999~28000,28999~34000,35999~50000,57999~36000,38999~39000,41999~42000,43999~44000,49999~58000,61999~62000,62999~63000,63999~64000,67999~68000,71999~72000,75999~76000,76999~77000,77999~78000,79999~80000,82999~83000,85999~86000,86999~87000,89999~90000,90999~91000,96999~97000,97999~98000,99999", + sub_zips: + "20~2[12]~23~24~29|30~3[1-3]~0|1[0-6]~2[5-7]~28~3[45]~5[0-7]~3[6-8]~39|4[01]~4[23]~4[4-9]~5[89]|6[01]~62~63~6[4-7]~6[89]|7[01]~7[2-5]~76~77~7[89]~8[0-2]~8[3-5]~86~8[7-9]~90~9[1-6]~97~9[89]", + sublocality_name_type: "neighborhood", + upper: "CSZ", + zip: "\\d{5}", + zipex: "02860,77520,06082", + }, + "data/MY": { + fmt: "%N%n%O%n%A%n%D%n%Z %C%n%S", + id: "data/MY", + key: "MY", + lang: "ms", + languages: "ms", + name: "MALAYSIA", + posturl: "http://www.pos.com.my", + require: "ACZ", + state_name_type: "state", + sub_isoids: "01~02~03~14~15~04~05~06~08~09~07~16~12~13~10~11", + sub_keys: + "Johor~Kedah~Kelantan~Kuala Lumpur~Labuan~Melaka~Negeri Sembilan~Pahang~Perak~Perlis~Pulau Pinang~Putrajaya~Sabah~Sarawak~Selangor~Terengganu", + sub_zipexs: + "79000,86999~05000,09999,34950~15000,18599~50000,60000~87000,87999~75000,78399~70000,73599~25000,28999,39000,49000,69000~30000,36899,39000~01000,02799~10000,14999~62000,62999~88000,91999~93000,98999~40000,48999,63000,68199~20000,24999", + sub_zips: + "79|8[0-6]~0[5-9]|34950~1[5-9]~5|60~87~7[5-8]~7[0-4]~2[5-8]|[346]9~3[0-6]|39000~0[12]~1[0-4]~62~8[89]|9[01]~9[3-8]~4[0-8]|6[3-8]~2[0-4]", + sublocality_name_type: "village_township", + upper: "CS", + zip: "\\d{5}", + zipex: "43000,50754,88990,50670", + }, + "data/MZ": { + fmt: "%N%n%O%n%A%n%Z %C%S", + id: "data/MZ", + key: "MZ", + lang: "pt", + languages: "pt", + name: "MOZAMBIQUE", + sub_isoids: "P~MPM~G~I~B~L~N~A~S~T~Q", + sub_keys: + "Cabo Delgado~Cidade de Maputo~Gaza~Inhambane~Manica~Maputo~Nampula~Niassa~Sofala~Tete~Zambezia", + zip: "\\d{4}", + zipex: "1102,1119,3212", + }, + "data/NA": { id: "data/NA", key: "NA", name: "NAMIBIA" }, + "data/NC": { + fmt: "%O%n%N%n%A%n%Z %C %X", + id: "data/NC", + key: "NC", + name: "NEW CALEDONIA", + posturl: + "http://poste.opt.nc/index.php?option=com_content&view=article&id=80&Itemid=131", + require: "ACZ", + upper: "ACX", + zip: "988\\d{2}", + zipex: "98814,98800,98810", + }, + "data/NE": { + fmt: "%N%n%O%n%A%n%Z %C", + id: "data/NE", + key: "NE", + name: "NIGER", + zip: "\\d{4}", + zipex: "8001", + }, + "data/NF": { + fmt: "%O%n%N%n%A%n%C %S %Z", + id: "data/NF", + key: "NF", + name: "NORFOLK ISLAND", + upper: "CS", + zip: "2899", + zipex: "2899", + }, + "data/NG": { + fmt: "%N%n%O%n%A%n%D%n%C %Z%n%S", + id: "data/NG", + key: "NG", + lang: "en", + languages: "en", + name: "NIGERIA", + posturl: "http://www.nigeriapostcodes.com/", + state_name_type: "state", + sub_isoids: + "AB~AD~AK~AN~BA~BY~BE~BO~CR~DE~EB~ED~EK~EN~FC~GO~IM~JI~KD~KN~KT~KE~KO~KW~LA~NA~NI~OG~ON~OS~OY~PL~RI~SO~TA~YO~ZA", + sub_keys: + "Abia~Adamawa~Akwa Ibom~Anambra~Bauchi~Bayelsa~Benue~Borno~Cross River~Delta~Ebonyi~Edo~Ekiti~Enugu~Federal Capital Territory~Gombe~Imo~Jigawa~Kaduna~Kano~Katsina~Kebbi~Kogi~Kwara~Lagos~Nasarawa~Niger~Ogun State~Ondo~Osun~Oyo~Plateau~Rivers~Sokoto~Taraba~Yobe~Zamfara", + upper: "CS", + zip: "\\d{6}", + zipex: "930283,300001,931104", + }, + "data/NI": { + fmt: "%N%n%O%n%A%n%Z%n%C, %S", + id: "data/NI", + key: "NI", + lang: "es", + languages: "es", + name: "NICARAGUA", + posturl: "http://www.correos.gob.ni/index.php/codigo-postal-2", + state_name_type: "department", + sub_isoids: "BO~CA~CI~CO~ES~GR~JI~LE~MD~MN~MS~MT~NS~AN~AS~SJ~RI", + sub_keys: + "Boaco~Carazo~Chinandega~Chontales~Esteli~Granada~Jinotega~Leon~Madriz~Managua~Masaya~Matagalpa~Nueva Segovia~Raan~Raas~Rio San Juan~Rivas", + sub_zips: + "5[12]~4[56]~2[5-7]~5[56]~3[12]~4[34]~6[56]~2[12]~3[45]~1[0-6]~4[12]~6[1-3]~3[7-9]~7[12]~8[1-3]~9[12]~4[78]", + upper: "CS", + zip: "\\d{5}", + zipex: "52000", + }, + "data/NL": { + fmt: "%O%n%N%n%A%n%Z %C", + id: "data/NL", + key: "NL", + name: "NETHERLANDS", + posturl: "http://www.postnl.nl/voorthuis/", + require: "ACZ", + zip: "\\d{4} ?[A-Z]{2}", + zipex: "1234 AB,2490 AA", + }, + "data/NO": { + fmt: "%N%n%O%n%A%n%Z %C", + id: "data/NO", + key: "NO", + locality_name_type: "post_town", + name: "NORWAY", + posturl: "http://adressesok.posten.no/nb/postal_codes/search", + require: "ACZ", + zip: "\\d{4}", + zipex: "0025,0107,6631", + }, + "data/NP": { + fmt: "%N%n%O%n%A%n%C %Z", + id: "data/NP", + key: "NP", + name: "NEPAL", + posturl: "http://www.gpo.gov.np/Home/Postalcode", + zip: "\\d{5}", + zipex: "44601", + }, + "data/NR": { + fmt: "%N%n%O%n%A%n%S", + id: "data/NR", + key: "NR", + lang: "en", + languages: "en", + name: "NAURU CENTRAL PACIFIC", + require: "AS", + state_name_type: "district", + sub_isoids: "01~02~03~04~05~06~07~08~09~10~11~12~13~14", + sub_keys: + "Aiwo District~Anabar District~Anetan District~Anibare District~Baiti District~Boe District~Buada District~Denigomodu District~Ewa District~Ijuw District~Meneng District~Nibok District~Uaboe District~Yaren District", + }, + "data/NU": { id: "data/NU", key: "NU", name: "NIUE" }, + "data/NZ": { + fmt: "%N%n%O%n%A%n%D%n%C %Z", + id: "data/NZ", + key: "NZ", + name: "NEW ZEALAND", + posturl: + "http://www.nzpost.co.nz/Cultures/en-NZ/OnlineTools/PostCodeFinder/", + require: "ACZ", + zip: "\\d{4}", + zipex: "6001,6015,6332,8252,1030", + }, + "data/OM": { + fmt: "%N%n%O%n%A%n%Z%n%C", + id: "data/OM", + key: "OM", + name: "OMAN", + zip: "(?:PC )?\\d{3}", + zipex: "133,112,111", + }, + "data/PA": { + fmt: "%N%n%O%n%A%n%C%n%S", + id: "data/PA", + key: "PA", + name: "PANAMA (REP.)", + upper: "CS", + }, + "data/PE": { + fmt: "%N%n%O%n%A%n%C %Z%n%S", + id: "data/PE", + key: "PE", + lang: "es", + languages: "es", + locality_name_type: "district", + name: "PERU", + posturl: "http://www.serpost.com.pe/cpostal/codigo", + sub_isoids: + "AMA~ANC~APU~ARE~AYA~CAJ~CAL~CUS~LIM~HUV~HUC~ICA~JUN~LAL~LAM~LOR~MDD~MOQ~LMA~PAS~PIU~PUN~SAM~TAC~TUM~UCA", + sub_keys: + "Amazonas~Áncash~Apurímac~Arequipa~Ayacucho~Cajamarca~Callao~Cuzco~Gobierno Regional de Lima~Huancavelica~Huánuco~Ica~Junín~La Libertad~Lambayeque~Loreto~Madre de Dios~Moquegua~Municipalidad Metropolitana de Lima~Pasco~Piura~Puno~San Martín~Tacna~Tumbes~Ucayali", + zip: "(?:LIMA \\d{1,2}|CALLAO 0?\\d)|[0-2]\\d{4}", + zipex: "LIMA 23,LIMA 42,CALLAO 2,02001", + }, + "data/PF": { + fmt: "%N%n%O%n%A%n%Z %C %S", + id: "data/PF", + key: "PF", + name: "FRENCH POLYNESIA", + require: "ACSZ", + state_name_type: "island", + upper: "CS", + zip: "987\\d{2}", + zipex: "98709", + }, + "data/PG": { + fmt: "%N%n%O%n%A%n%C %Z %S", + id: "data/PG", + key: "PG", + name: "PAPUA NEW GUINEA", + require: "ACS", + zip: "\\d{3}", + zipex: "111", + }, + "data/PH": { + fmt: "%N%n%O%n%A%n%D, %C%n%Z %S", + id: "data/PH", + key: "PH", + lang: "en", + languages: "en", + name: "PHILIPPINES", + posturl: "http://www.philpost.gov.ph/", + sub_isoids: + "ABR~AGN~AGS~AKL~ALB~ANT~APA~AUR~BAS~BAN~BTN~BTG~BEN~BIL~BOH~BUK~BUL~CAG~CAN~CAS~CAM~CAP~CAT~CAV~CEB~COM~NCO~DAV~DAS~DVO~DAO~DIN~EAS~GUI~IFU~ILN~ILS~ILI~ISA~KAL~LUN~LAG~LAN~LAS~LEY~MAG~MAD~MAS~00~MDC~MDR~MSC~MSR~MOU~NEC~NER~NSA~NUE~NUV~PLW~PAM~PAN~QUE~QUI~RIZ~ROM~WSA~SAR~SIG~SOR~SCO~SLE~SUK~SLU~SUN~SUR~TAR~TAW~ZMB~ZAN~ZAS~ZSI", + sub_keys: + "Abra~Agusan del Norte~Agusan del Sur~Aklan~Albay~Antique~Apayao~Aurora~Basilan~Bataan~Batanes~Batangas~Benguet~Biliran~Bohol~Bukidnon~Bulacan~Cagayan~Camarines Norte~Camarines Sur~Camiguin~Capiz~Catanduanes~Cavite~Cebu~Compostela Valley~Cotabato~Davao del Norte~Davao del Sur~Davao Occidental~Davao Oriental~Dinagat Islands~Eastern Samar~Guimaras~Ifugao~Ilocos Norte~Ilocos Sur~Iloilo~Isabela~Kalinga~La Union~Laguna~Lanao del Norte~Lanao del Sur~Leyte~Maguindanao~Marinduque~Masbate~Metro Manila~Mindoro Occidental~Mindoro Oriental~Misamis Occidental~Misamis Oriental~Mountain Province~Negros Occidental~Negros Oriental~Northern Samar~Nueva Ecija~Nueva Vizcaya~Palawan~Pampanga~Pangasinan~Quezon Province~Quirino~Rizal~Romblon~Samar~Sarangani~Siquijor~Sorsogon~South Cotabato~Southern Leyte~Sultan Kudarat~Sulu~Surigao del Norte~Surigao del Sur~Tarlac~Tawi-Tawi~Zambales~Zamboanga del Norte~Zamboanga del Sur~Zamboanga Sibuguey", + sub_zipexs: + "2800,2826~8600,8611~8500,8513~5600,5616~4500,4517~5700,5717~3800,3806,3808~3200,3207~7300,7306~2100,2114~3900,3905~4200,4234~2600,2615~6543,6550~6300,6337~8700,8723~3000,3024~3500,3528~4600,4612~4400,4436~9100,9104~5800,5816~4800,4810~4100,4126~6000,6053~8800,8810~9400,9417~8100,8120~8000,8010~8015,8013~8200,8210~8426,8412~6800,6822~5044,5046~3600,3610~2900,2922~2700,2733~5000,5043~3300,3336~3807,3809,3814~2500,2520~4000,4033~9200,9223~9300,9321,9700,9716~6500,6542~9600,9619~4900,4905~5400,5421~~5100,5111~5200,5214~7200,7215~9000,9025~2616,2625~6100,6132~6200,6224~6400,6423~3100,3133~3700,3714~5300,5322~2000,2022~2400,2447~4300,4342~3400,3405~1850,1990~5500,5516~6700,6725~8015~6225,6230~4700,4715~9500,9513~6600,6613~9800,9811~7400,7416~8400,8425~8300,8319~2300,2318~7500,7509~2200,2213~7100,7124~7000,7043~7000,7043", + sub_zips: + "28[0-2]~86[01]~85[01]~56[01]~45[01]~57[01]~380[0-68]~320~730~21[01]~390~42[0-3]~26(0|1[0-5])~65(4[3-9]|5)~63[0-3]~87[0-2]~30[0-2]~35[0-2]~46[01]~44[0-3]~910~58[01]~48[01]~41[0-2]~60[0-5]~88[01]~94[01]~81[0-2]~80[01]~801[1-5]~82[01]~84[12]~68[0-2]~504[4-6]~36[01]~29[0-2]~27[0-3]~50([0-3]|4[0-3])~33[0-3]~38(0[79]|1[0-4])~25[0-2]~40[0-3]~92[0-2]~9(3[0-2]|7[01])~65([0-3]|4[0-2])~96[01]~490~54[0-2]~~51[01]~52[01]~72[01]~90[0-2]~26(1[6-9]|2[0-5])~61[0-3]~62[0-2]~64[0-2]~31[0-3]~37[01]~53[0-2]~20[0-2]~24[0-4]~43[0-4]~340~1[89]~55[01]~67[0-2]~8015~62(2[5-9]|30)~47[01]~95[01]~66[10]~98[01]~74[01]~84[0-2]~83[01]~23[01]~750~22[01]~71[0-2]~70[0-4]~70[0-4]", + zip: "\\d{4}", + zipex: "1008,1050,1135,1207,2000,1000", + }, + "data/PK": { + fmt: "%N%n%O%n%A%n%C-%Z", + id: "data/PK", + key: "PK", + name: "PAKISTAN", + posturl: "http://www.pakpost.gov.pk/postcode.php", + zip: "\\d{5}", + zipex: "44000", + }, + "data/PL": { + fmt: "%N%n%O%n%A%n%Z %C", + id: "data/PL", + key: "PL", + name: "POLAND", + posturl: "http://kody.poczta-polska.pl/", + require: "ACZ", + zip: "\\d{2}-\\d{3}", + zipex: "00-950,05-470,48-300,32-015,00-940", + }, + "data/PM": { + fmt: "%O%n%N%n%A%n%Z %C %X", + id: "data/PM", + key: "PM", + name: "ST. PIERRE AND MIQUELON", + require: "ACZ", + upper: "ACX", + zip: "9[78]5\\d{2}", + zipex: "97500", + }, + "data/PN": { + fmt: "%N%n%O%n%A%n%C%n%Z", + id: "data/PN", + key: "PN", + name: "PITCAIRN", + require: "ACZ", + upper: "CZ", + zip: "PCRN 1ZZ", + zipex: "PCRN 1ZZ", + }, + "data/PR": { + fmt: "%N%n%O%n%A%n%C PR %Z", + id: "data/PR", + key: "PR", + name: "PUERTO RICO", + postprefix: "PR ", + posturl: "http://zip4.usps.com/zip4/welcome.jsp", + require: "ACZ", + upper: "ACNO", + zip: "(00[679]\\d{2})(?:[ \\-](\\d{4}))?", + zip_name_type: "zip", + zipex: "00930", + }, + "data/PT": { + fmt: "%N%n%O%n%A%n%Z %C", + id: "data/PT", + key: "PT", + name: "PORTUGAL", + posturl: "http://www.ctt.pt/feapl_2/app/open/tools.jspx?tool=1", + require: "ACZ", + zip: "\\d{4}-\\d{3}", + zipex: "2725-079,1250-096,1201-950,2860-571,1208-148", + }, + "data/PW": { + fmt: "%N%n%O%n%A%n%C %S %Z", + id: "data/PW", + key: "PW", + name: "PALAU", + posturl: "http://zip4.usps.com/zip4/welcome.jsp", + require: "ACSZ", + state_name_type: "state", + upper: "ACNOS", + zip: "(969(?:39|40))(?:[ \\-](\\d{4}))?", + zip_name_type: "zip", + zipex: "96940", + }, + "data/PY": { + fmt: "%N%n%O%n%A%n%Z %C", + id: "data/PY", + key: "PY", + name: "PARAGUAY", + zip: "\\d{4}", + zipex: "1536,1538,1209", + }, + "data/QA": { id: "data/QA", key: "QA", name: "QATAR", upper: "AC" }, + "data/RE": { + fmt: "%O%n%N%n%A%n%Z %C %X", + id: "data/RE", + key: "RE", + name: "REUNION", + posturl: + "http://www.laposte.fr/Particulier/Utiliser-nos-outils-pratiques/Outils-et-documents/Trouvez-un-code-postal", + require: "ACZ", + upper: "ACX", + zip: "9[78]4\\d{2}", + zipex: "97400", + }, + "data/RO": { + fmt: "%N%n%O%n%A%n%Z %C", + id: "data/RO", + key: "RO", + name: "ROMANIA", + posturl: "http://www.posta-romana.ro/zip_codes", + upper: "AC", + zip: "\\d{6}", + zipex: "060274,061357,200716", + }, + "data/RS": { + fmt: "%N%n%O%n%A%n%Z %C", + id: "data/RS", + key: "RS", + name: "REPUBLIC OF SERBIA", + posturl: + "http://www.posta.rs/struktura/lat/aplikacije/pronadji/nadji-postu.asp", + zip: "\\d{5,6}", + zipex: "106314", + }, + "data/RU": { + fmt: "%N%n%O%n%A%n%C%n%S%n%Z", + id: "data/RU", + key: "RU", + lang: "ru", + languages: "ru", + lfmt: "%N%n%O%n%A%n%C%n%S%n%Z", + name: "RUSSIAN FEDERATION", + posturl: "http://info.russianpost.ru/servlet/department", + require: "ACSZ", + state_name_type: "oblast", + sub_isoids: + "ALT~AMU~ARK~AST~BEL~BRY~VLA~VGG~VLG~VOR~YEV~ZAB~IVA~IRK~KB~KGD~KLU~KAM~KC~KEM~KIR~KOS~KDA~KYA~KGN~KRS~LEN~LIP~MAG~MOW~MOS~MUR~NEN~NIZ~NGR~NVS~OMS~ORE~ORL~PNZ~PER~PRI~PSK~AD~AL~BA~BU~DA~IN~KL~KR~KO~~ME~MO~SA~SE~TA~TY~UD~KK~ROS~RYA~SAM~SPE~SAR~SAK~SVE~~SMO~STA~TAM~TVE~TOM~TUL~TYU~ULY~KHA~KHM~CHE~CE~CU~CHU~YAN~YAR", + sub_keys: + "Алтайский край~Амурская область~Архангельская область~Астраханская область~Белгородская область~Брянская область~Владимирская область~Волгоградская область~Вологодская область~Воронежская область~Еврейская автономная область~Забайкальский край~Ивановская область~Иркутская область~Кабардино-Балкарская Республика~Калининградская область~Калужская область~Камчатский край~Карачаево-Черкесская Республика~Кемеровская область~Кировская область~Костромская область~Краснодарский край~Красноярский край~Курганская область~Курская область~Ленинградская область~Липецкая область~Магаданская область~Москва~Московская область~Мурманская область~Ненецкий автономный округ~Нижегородская область~Новгородская область~Новосибирская область~Омская область~Оренбургская область~Орловская область~Пензенская область~Пермский край~Приморский край~Псковская область~Республика Адыгея~Республика Алтай~Республика Башкортостан~Республика Бурятия~Республика Дагестан~Республика Ингушетия~Республика Калмыкия~Республика Карелия~Республика Коми~Автономна Республіка Крим~Республика Марий Эл~Республика Мордовия~Республика Саха (Якутия)~Республика Северная Осетия-Алания~Республика Татарстан~Республика Тыва~Республика Удмуртия~Республика Хакасия~Ростовская область~Рязанская область~Самарская область~Санкт-Петербург~Саратовская область~Сахалинская область~Свердловская область~Севастополь~Смоленская область~Ставропольский край~Тамбовская область~Тверская область~Томская область~Тульская область~Тюменская область~Ульяновская область~Хабаровский край~Ханты-Мансийский автономный округ~Челябинская область~Чеченская Республика~Чувашская Республика~Чукотский автономный округ~Ямало-Ненецкий автономный округ~Ярославская область", + sub_lnames: + "Altayskiy kray~Amurskaya oblast'~Arkhangelskaya oblast'~Astrakhanskaya oblast'~Belgorodskaya oblast'~Bryanskaya oblast'~Vladimirskaya oblast'~Volgogradskaya oblast'~Vologodskaya oblast'~Voronezhskaya oblast'~Evreyskaya avtonomnaya oblast'~Zabaykalskiy kray~Ivanovskaya oblast'~Irkutskaya oblast'~Kabardino-Balkarskaya Republits~Kaliningradskaya oblast'~Kaluzhskaya oblast'~Kamchatskiy kray~Karachaevo-Cherkesskaya Republits~Kemerovskaya oblast'~Kirovskaya oblast'~Kostromskaya oblast'~Krasnodarskiy kray~Krasnoyarskiy kray~Kurganskaya oblast'~Kurskaya oblast'~Leningradskaya oblast'~Lipetskaya oblast'~Magadanskaya oblast'~Moskva~Moskovskaya oblast'~Murmanskaya oblast'~Nenetskiy~Nizhegorodskaya oblast'~Novgorodskaya oblast'~Novosibirskaya oblast'~Omskaya oblast'~Orenburgskaya oblast'~Orlovskaya oblast'~Penzenskaya oblast'~Permskiy kray~Primorskiy kray~Pskovskaya oblast'~Respublika Adygeya~Altay Republits~Bashkortostan Republits~Buryatiya Republits~Dagestan Republits~Ingushetiya Republits~Respublika Kalmykiya~Kareliya Republits~Komi Republits~Respublika Krym~Respublika Mariy El~Respublika Mordoviya~Sakha (Yakutiya) Republits~Respublika Severnaya Osetiya-Alaniya~Respublika Tatarstan~Tyva Republits~Respublika Udmurtiya~Khakasiya Republits~Rostovskaya oblast'~Ryazanskaya oblast'~Samarskaya oblast'~Sankt-Peterburg~Saratovskaya oblast'~Sakhalinskaya oblast'~Sverdlovskaya oblast'~Sevastopol'~Smolenskaya oblast'~Stavropolskiy kray~Tambovskaya oblast'~Tverskaya oblast'~Tomskaya oblast'~Tulskaya oblast'~Tyumenskaya oblast'~Ulyanovskaya oblast'~Khabarovskiy kray~Khanty-Mansiyskiy avtonomnyy okrug~Chelyabinskaya oblast'~Chechenskaya Republits~Chuvashia~Chukotskiy~Yamalo-Nenetskiy~Yaroslavskaya oblast'", + sub_names: + "Алтайский край~Амурская область~Архангельская область~Астраханская область~Белгородская область~Брянская область~Владимирская область~Волгоградская область~Вологодская область~Воронежская область~Еврейская автономная область~Забайкальский край~Ивановская область~Иркутская область~Кабардино-Балкарская Республика~Калининградская область~Калужская область~Камчатский край~Карачаево-Черкесская Республика~Кемеровская область~Кировская область~Костромская область~Краснодарский край~Красноярский край~Курганская область~Курская область~Ленинградская область~Липецкая область~Магаданская область~Москва~Московская область~Мурманская область~Ненецкий автономный округ~Нижегородская область~Новгородская область~Новосибирская область~Омская область~Оренбургская область~Орловская область~Пензенская область~Пермский край~Приморский край~Псковская область~Республика Адыгея~Республика Алтай~Республика Башкортостан~Республика Бурятия~Республика Дагестан~Республика Ингушетия~Республика Калмыкия~Республика Карелия~Республика Коми~Республика Крым~Республика Марий Эл~Республика Мордовия~Республика Саха (Якутия)~Республика Северная Осетия-Алания~Республика Татарстан~Республика Тыва~Республика Удмуртия~Республика Хакасия~Ростовская область~Рязанская область~Самарская область~Санкт-Петербург~Саратовская область~Сахалинская область~Свердловская область~Севастополь~Смоленская область~Ставропольский край~Тамбовская область~Тверская область~Томская область~Тульская область~Тюменская область~Ульяновская область~Хабаровский край~Ханты-Мансийский автономный округ~Челябинская область~Чеченская Республика~Чувашская Республика~Чукотский автономный округ~Ямало-Ненецкий автономный округ~Ярославская область", + sub_zips: + "65[6-9]~67[56]~16[3-5]~41[4-6]~30[89]~24[1-3]~60[0-2]~40[0-4]~16[0-2]~39[4-7]~679~6(?:7[2-4]|87)~15[3-5]~66[4-9]~36[01]~23[6-8]~24[89]~68[348]~369~65[0-4]~61[0-3]~15[67]~35[0-4]~6(?:6[0-3]|4[78])~64[01]~30[5-7]~18[78]~39[89]~68[56]~1(?:0[1-9]|1|2|3[0-5]|4[0-4])~14[0-4]~18[34]~166~60[3-7]~17[3-5]~63[0-3]~64[4-6]~46[0-2]~30[23]~44[0-2]~61[4-9]~69[0-2]~18[0-2]~385~649~45[0-3]~67[01]~36[78]~386~35[89]~18[56]~16[7-9]~29[5-8]~42[45]~43[01]~67[78]~36[23]~42[0-3]~66[78]~42[67]~655~34[4-7]~39[01]~44[3-6]~19~41[0-3]~69[34]~62[0-4]~299~21[4-6]~35[5-7]~39[23]~17[0-2]~63[4-6]~30[01]~62[5-7]~43[23]~68[0-2]~628~45[4-7]~36[4-6]~42[89]~689~629~15[0-2]", + upper: "AC", + zip: "\\d{6}", + zipex: "247112,103375,188300", + }, + "data/RW": { id: "data/RW", key: "RW", name: "RWANDA", upper: "AC" }, + "data/SA": { + fmt: "%N%n%O%n%A%n%C %Z", + id: "data/SA", + key: "SA", + name: "SAUDI ARABIA", + zip: "\\d{5}", + zipex: "11564,11187,11142", + }, + "data/SB": { id: "data/SB", key: "SB", name: "SOLOMON ISLANDS" }, + "data/SC": { + fmt: "%N%n%O%n%A%n%C%n%S", + id: "data/SC", + key: "SC", + name: "SEYCHELLES", + state_name_type: "island", + upper: "S", + }, + "data/SD": { + fmt: "%N%n%O%n%A%n%C%n%Z", + id: "data/SD", + key: "SD", + locality_name_type: "district", + name: "SUDAN", + zip: "\\d{5}", + zipex: "11042,11113", + }, + "data/SE": { + fmt: "%O%n%N%n%A%nSE-%Z %C", + id: "data/SE", + key: "SE", + locality_name_type: "post_town", + name: "SWEDEN", + postprefix: "SE-", + posturl: + "http://www.posten.se/sv/Kundservice/Sidor/Sok-postnummer-resultat.aspx", + require: "ACZ", + zip: "\\d{3} ?\\d{2}", + zipex: "11455,12345,10500", + }, + "data/SG": { + fmt: "%N%n%O%n%A%nSINGAPORE %Z", + id: "data/SG", + key: "SG", + name: "REP. OF SINGAPORE", + posturl: "https://www.singpost.com/find-postal-code", + require: "AZ", + zip: "\\d{6}", + zipex: "546080,308125,408600", + }, + "data/SH": { + fmt: "%N%n%O%n%A%n%C%n%Z", + id: "data/SH", + key: "SH", + name: "SAINT HELENA", + require: "ACZ", + upper: "CZ", + zip: "(?:ASCN|STHL) 1ZZ", + zipex: "STHL 1ZZ", + }, + "data/SI": { + fmt: "%N%n%O%n%A%nSI-%Z %C", + id: "data/SI", + key: "SI", + name: "SLOVENIA", + postprefix: "SI-", + zip: "\\d{4}", + zipex: "4000,1001,2500", + }, + "data/SK": { + fmt: "%N%n%O%n%A%n%Z %C", + id: "data/SK", + key: "SK", + name: "SLOVAKIA", + posturl: "http://psc.posta.sk", + require: "ACZ", + zip: "\\d{3} ?\\d{2}", + zipex: "010 01,023 14,972 48,921 01,975 99", + }, + "data/SL": { id: "data/SL", key: "SL", name: "SIERRA LEONE" }, + "data/SM": { + fmt: "%N%n%O%n%A%n%Z %C", + id: "data/SM", + key: "SM", + name: "SAN MARINO", + posturl: "http://www.poste.it/online/cercacap/", + require: "AZ", + zip: "4789\\d", + zipex: "47890,47891,47895,47899", + }, + "data/SN": { + fmt: "%N%n%O%n%A%n%Z %C", + id: "data/SN", + key: "SN", + name: "SENEGAL", + zip: "\\d{5}", + zipex: "12500,46024,16556,10000", + }, + "data/SO": { + fmt: "%N%n%O%n%A%n%C, %S %Z", + id: "data/SO", + key: "SO", + lang: "so", + languages: "so", + name: "SOMALIA", + require: "ACS", + sub_isoids: "AW~BK~BN~BR~BY~GA~GE~HI~JD~JH~MU~NU~SA~SD~SH~SO~TO~WO", + sub_keys: "AD~BK~BN~BR~BY~GG~GD~HR~JD~JH~MD~NG~SG~SD~SH~SL~TG~WG", + sub_names: + "Awdal~Bakool~Banaadir~Bari~Bay~Galguduud~Gedo~Hiiraan~Jubbada Dhexe~Jubbada Hoose~Mudug~Nugaal~Sanaag~Shabeellaha Dhexe~Shabeellaha Hoose~Sool~Togdheer~Woqooyi Galbeed", + upper: "ACS", + zip: "[A-Z]{2} ?\\d{5}", + zipex: "JH 09010,AD 11010", + }, + "data/SR": { + fmt: "%N%n%O%n%A%n%C%n%S", + id: "data/SR", + key: "SR", + lang: "nl", + languages: "nl", + name: "SURINAME", + sub_isoids: "BR~CM~CR~MA~NI~PR~PM~SA~SI~WA", + sub_keys: + "Brokopondo~Commewijne~Coronie~Marowijne~Nickerie~Para~Paramaribo~Saramacca~Sipaliwini~Wanica", + upper: "AS", + }, + "data/SS": { id: "data/SS", key: "SS", name: "SOUTH SUDAN" }, + "data/ST": { id: "data/ST", key: "ST", name: "SAO TOME AND PRINCIPE" }, + "data/SV": { + fmt: "%N%n%O%n%A%n%Z-%C%n%S", + id: "data/SV", + key: "SV", + lang: "es", + languages: "es", + name: "EL SALVADOR", + require: "ACS", + sub_isoids: "AH~CA~CH~CU~LI~PA~UN~MO~SM~SS~SV~SA~SO~US", + sub_keys: + "Ahuachapan~Cabanas~Calatenango~Cuscatlan~La Libertad~La Paz~La Union~Morazan~San Miguel~San Salvador~San Vicente~Santa Ana~Sonsonate~Usulutan", + sub_names: + "Ahuachapán~Cabañas~Chalatenango~Cuscatlán~La Libertad~La Paz~La Unión~Morazán~San Miguel~San Salvador~San Vicente~Santa Ana~Sonsonate~Usulután", + sub_zipexs: + "CP 2101~CP 1201~CP 1301~CP 1401~CP 1501~CP 1601~CP 3101~CP 3201~CP 3301~CP 1101~CP 1701~CP 2201~CP 2301~CP 3401", + sub_zips: + "CP 21~CP 12~CP 13~CP 14~CP 15~CP 16~CP 31~CP 32~CP 33~CP 11~CP 17~CP 22~CP 23~CP 34", + upper: "CSZ", + zip: "CP [1-3][1-7][0-2]\\d", + zipex: "CP 1101", + }, + "data/SX": { id: "data/SX", key: "SX", name: "SINT MAARTEN" }, + "data/SY": { + id: "data/SY", + key: "SY", + locality_name_type: "district", + name: "SYRIA", + }, + "data/SZ": { + fmt: "%N%n%O%n%A%n%C%n%Z", + id: "data/SZ", + key: "SZ", + name: "SWAZILAND", + posturl: "http://www.sptc.co.sz/swazipost/codes/index.php", + upper: "ACZ", + zip: "[HLMS]\\d{3}", + zipex: "H100", + }, + "data/TC": { + fmt: "%N%n%O%n%A%n%C%n%Z", + id: "data/TC", + key: "TC", + name: "TURKS AND CAICOS ISLANDS", + require: "ACZ", + upper: "CZ", + zip: "TKCA 1ZZ", + zipex: "TKCA 1ZZ", + }, + "data/TD": { id: "data/TD", key: "TD", name: "CHAD" }, + "data/TF": { id: "data/TF", key: "TF", name: "FRENCH SOUTHERN TERRITORIES" }, + "data/TG": { id: "data/TG", key: "TG", name: "TOGO" }, + "data/TH": { + fmt: "%N%n%O%n%A%n%D %C%n%S %Z", + id: "data/TH", + key: "TH", + lang: "th", + languages: "th", + lfmt: "%N%n%O%n%A%n%D, %C%n%S %Z", + name: "THAILAND", + sub_isoids: + "81~10~71~46~62~40~38~22~24~20~18~36~86~57~50~92~23~63~26~73~48~30~80~60~12~96~55~31~13~77~25~94~14~56~82~93~66~65~76~67~54~83~44~49~58~35~95~45~85~21~70~16~52~51~42~33~47~90~91~11~75~74~27~19~17~64~72~84~32~43~39~15~37~41~53~61~34", + sub_keys: + "กระบี่~กรุงเทพมหานคร~กาญจนบุรี~กาฬสินธุ์~กำแพงเพชร~ขอนแก่น~จังหวัด บึงกาฬ~จันทบุรี~ฉะเชิงเทรา~ชลบุรี~ชัยนาท~ชัยภูมิ~ชุมพร~เชียงราย~เชียงใหม่~ตรัง~ตราด~ตาก~นครนายก~นครปฐม~นครพนม~นครราชสีมา~นครศรีธรรมราช~นครสวรรค์~นนทบุรี~นราธิวาส~น่าน~บุรีรัมย์~ปทุมธานี~ประจวบคีรีขันธ์~ปราจีนบุรี~ปัตตานี~พระนครศรีอยุธยา~พะเยา~พังงา~พัทลุง~พิจิตร~พิษณุโลก~เพชรบุรี~เพชรบูรณ์~แพร่~ภูเก็ต~มหาสารคาม~มุกดาหาร~แม่ฮ่องสอน~ยโสธร~ยะลา~ร้อยเอ็ด~ระนอง~ระยอง~ราชบุรี~ลพบุรี~ลำปาง~ลำพูน~เลย~ศรีสะเกษ~สกลนคร~สงขลา~สตูล~สมุทรปราการ~สมุทรสงคราม~สมุทรสาคร~สระแก้ว~สระบุรี~สิงห์บุรี~สุโขทัย~สุพรรณบุรี~สุราษฎร์ธานี~สุรินทร์~หนองคาย~หนองบัวลำภู~อ่างทอง~อำนาจเจริญ~อุดรธานี~อุตรดิตถ์~อุทัยธานี~อุบลราชธานี", + sub_lnames: + "Krabi~Bangkok~Kanchanaburi~Kalasin~Kamphaeng Phet~Khon Kaen~Bueng Kan~Chanthaburi~Chachoengsao~Chon Buri~Chai Nat~Chaiyaphum~Chumpon~Chiang Rai~Chiang Mai~Trang~Trat~Tak~Nakhon Nayok~Nakhon Pathom~Nakhon Phanom~Nakhon Ratchasima~Nakhon Si Thammarat~Nakhon Sawan~Nonthaburi~Narathiwat~Nan~Buri Ram~Pathum Thani~Prachuap Khiri Khan~Prachin Buri~Pattani~Phra Nakhon Si Ayutthaya~Phayao~Phang Nga~Phattalung~Phichit~Phitsanulok~Phetchaburi~Phetchabun~Phrae~Phuket~Maha Sarakham~Mukdahan~Mae Hong Son~Yasothon~Yala~Roi Et~Ranong~Rayong~Ratchaburi~Lop Buri~Lampang~Lamphun~Loei~Si Sa Ket~Sakon Nakhon~Songkhla~Satun~Samut Prakan~Samut Songkhram~Samut Sakhon~Sa Kaeo~Saraburi~Sing Buri~Sukhothai~Suphanburi~Surat Thani~Surin~Nong Khai~Nong Bua Lam Phu~Ang Thong~Amnat Charoen~Udon Thani~Uttaradit~Uthai Thani~Ubon Ratchathani", + sub_zips: + "81~10~71~46~62~40~~22~24~20~17~36~86~57~50~92~23~63~26~73~48~30~80~60~11~96~55~31~12~77~25~94~13~56~82~93~66~65~76~67~54~83~44~49~58~35~95~45~85~21~70~15~52~51~42~33~47~90~91~10~75~74~27~18~16~64~72~84~32~43~39~14~37~41~53~61~34", + upper: "S", + zip: "\\d{5}", + zipex: "10150,10210", + }, + "data/TJ": { + fmt: "%N%n%O%n%A%n%Z %C", + id: "data/TJ", + key: "TJ", + name: "TAJIKISTAN", + zip: "\\d{6}", + zipex: "735450,734025", + }, + "data/TK": { id: "data/TK", key: "TK", name: "TOKELAU" }, + "data/TL": { id: "data/TL", key: "TL", name: "TIMOR-LESTE" }, + "data/TM": { + fmt: "%N%n%O%n%A%n%Z %C", + id: "data/TM", + key: "TM", + name: "TURKMENISTAN", + zip: "\\d{6}", + zipex: "744000", + }, + "data/TN": { + fmt: "%N%n%O%n%A%n%Z %C", + id: "data/TN", + key: "TN", + name: "TUNISIA", + posturl: "http://www.poste.tn/codes.php", + zip: "\\d{4}", + zipex: "1002,8129,3100,1030", + }, + "data/TO": { id: "data/TO", key: "TO", name: "TONGA" }, + "data/TR": { + fmt: "%N%n%O%n%A%n%Z %C/%S", + id: "data/TR", + key: "TR", + lang: "tr", + languages: "tr", + locality_name_type: "district", + name: "TURKEY", + posturl: "http://postakodu.ptt.gov.tr/", + require: "ACZ", + sub_isoids: + "01~02~03~04~68~05~06~07~75~08~09~10~74~72~69~11~12~13~14~15~16~17~18~19~20~21~81~22~23~24~25~26~27~28~29~30~31~76~32~34~35~46~78~70~36~37~38~71~39~40~79~41~42~43~44~45~47~33~48~49~50~51~52~80~53~54~55~56~57~58~63~73~59~60~61~62~64~65~77~66~67", + sub_keys: + "Adana~Adıyaman~Afyon~Ağrı~Aksaray~Amasya~Ankara~Antalya~Ardahan~Artvin~Aydın~Balıkesir~Bartın~Batman~Bayburt~Bilecik~Bingöl~Bitlis~Bolu~Burdur~Bursa~Çanakkale~Çankırı~Çorum~Denizli~Diyarbakır~Düzce~Edirne~Elazığ~Erzincan~Erzurum~Eskişehir~Gaziantep~Giresun~Gümüşhane~Hakkari~Hatay~Iğdır~Isparta~İstanbul~İzmir~Kahramanmaraş~Karabük~Karaman~Kars~Kastamonu~Kayseri~Kırıkkale~Kırklareli~Kırşehir~Kilis~Kocaeli~Konya~Kütahya~Malatya~Manisa~Mardin~Mersin~Muğla~Muş~Nevşehir~Niğde~Ordu~Osmaniye~Rize~Sakarya~Samsun~Siirt~Sinop~Sivas~Şanlıurfa~Şırnak~Tekirdağ~Tokat~Trabzon~Tunceli~Uşak~Van~Yalova~Yozgat~Zonguldak", + sub_zips: + "01~02~03~04~68~05~06~07~75~08~09~10~74~72~69~11~12~13~14~15~16~17~18~19~20~21~81~22~23~24~25~26~27~28~29~30~31~76~32~34~35~46~78~70~36~37~38~71~39~40~79~41~42~43~44~45~47~33~48~49~50~51~52~80~53~54~55~56~57~58~63~73~59~60~61~62~64~65~77~66~67", + zip: "\\d{5}", + zipex: "01960,06101", + }, + "data/TT": { id: "data/TT", key: "TT", name: "TRINIDAD AND TOBAGO" }, + "data/TV": { + fmt: "%N%n%O%n%A%n%C%n%S", + id: "data/TV", + key: "TV", + lang: "tyv", + languages: "tyv", + name: "TUVALU", + state_name_type: "island", + sub_isoids: "FUN~NMG~NMA~~NIT~NUI~NKF~NKL~VAI", + sub_keys: + "Funafuti~Nanumanga~Nanumea~Niulakita~Niutao~Nui~Nukufetau~Nukulaelae~Vaitupu", + upper: "ACS", + }, + "data/TW": { + fmt: "%Z%n%S%C%n%A%n%O%n%N", + id: "data/TW", + key: "TW", + lang: "zh-Hant", + languages: "zh-Hant", + lfmt: "%N%n%O%n%A%n%C, %S %Z", + name: "TAIWAN", + posturl: + "http://www.post.gov.tw/post/internet/f_searchzone/index.jsp?ID=190102", + require: "ACSZ", + state_name_type: "county", + sub_isoids: + "TXG~TPE~TTT~TNN~ILA~HUA~~NAN~PIF~MIA~TAO~KHH~KEE~~YUN~NWT~HSZ~HSQ~CYI~CYQ~CHA~PEN", + sub_keys: + "台中市~台北市~台東縣~台南市~宜蘭縣~花蓮縣~金門縣~南投縣~屏東縣~苗栗縣~桃園市~高雄市~基隆市~連江縣~雲林縣~新北市~新竹市~新竹縣~嘉義市~嘉義縣~彰化縣~澎湖縣", + sub_lnames: + "Taichung City~Taipei City~Taitung County~Tainan City~Yilan County~Hualien County~Kinmen County~Nantou County~Pingtung County~Miaoli County~Taoyuan City~Kaohsiung City~Keelung City~Lienchiang County~Yunlin County~New Taipei City~Hsinchu City~Hsinchu County~Chiayi City~Chiayi County~Changhua County~Penghu County", + sub_mores: + "true~true~true~true~true~true~true~true~true~true~true~true~true~true~true~true~true~true~true~true~true~true", + sub_zipexs: + "400,408,411,439~100,119~950,966~700,745~260,272~970,983~890,896~540,558~900,947~350,369~320,338~800,815,817,852~200,206~209,212~630,655~207,208,220,253~~302,315~~602,625~500,530~880,885", + sub_zips: + "4[0-3]~1[01]~9[56]~7[0-4]~2[67]~9[78]~89~5[45]~9[0-4]~3[56]~3[23]~8[02-5]|81[1-579]~20[0-6]~209|21[012]~6[3-5]~20[78]|2[2345]~300~30[2-8]|31~600~60[1-9]|6[12]~5[0123]~88", + zip: "\\d{3}(?:\\d{2})?", + zipex: "104,106,10603,40867", + }, + "data/TZ": { + fmt: "%N%n%O%n%A%n%Z %C", + id: "data/TZ", + key: "TZ", + name: "TANZANIA (UNITED REP.)", + zip: "\\d{4,5}", + zipex: "6090,34413", + }, + "data/UA": { + fmt: "%N%n%O%n%A%n%C%n%S%n%Z", + id: "data/UA", + key: "UA", + lang: "uk", + languages: "uk", + lfmt: "%N%n%O%n%A%n%C%n%S%n%Z", + name: "UKRAINE", + posturl: "http://services.ukrposhta.com/postindex_new/", + require: "ACSZ", + state_name_type: "oblast", + sub_isoids: + "43~05~07~12~14~18~21~23~26~30~32~35~09~46~48~51~53~56~40~59~61~63~65~68~71~77~74", + sub_keys: + "Автономна Республіка Крим~Вінницька область~Волинська область~Дніпропетровська область~Донецька область~Житомирська область~Закарпатська область~Запорізька область~Івано-Франківська область~місто Київ~Київська область~Кіровоградська область~Луганська область~Львівська область~Миколаївська область~Одеська область~Полтавська область~Рівненська область~місто Севастополь~Сумська область~Тернопільська область~Харківська область~Херсонська область~Хмельницька область~Черкаська область~Чернівецька область~Чернігівська область", + sub_lnames: + "Crimea~Vinnyts'ka oblast~Volyns'ka oblast~Dnipropetrovsk oblast~Donetsk oblast~Zhytomyrs'ka oblast~Zakarpats'ka oblast~Zaporiz'ka oblast~Ivano-Frankivs'ka oblast~Kyiv city~Kiev oblast~Kirovohrads'ka oblast~Luhans'ka oblast~Lviv oblast~Mykolaivs'ka oblast~Odessa oblast~Poltavs'ka oblast~Rivnens'ka oblast~Sevastopol' city~Sums'ka oblast~Ternopil's'ka oblast~Kharkiv oblast~Khersons'ka oblast~Khmel'nyts'ka oblast~Cherkas'ka oblast~Chernivets'ka oblast~Chernihivs'ka oblast", + sub_names: + "Автономна Республіка Крим~Вінницька область~Волинська область~Дніпропетровська область~Донецька область~Житомирська область~Закарпатська область~Запорізька область~Івано-Франківська область~Київ~Київська область~Кіровоградська область~Луганська область~Львівська область~Миколаївська область~Одеська область~Полтавська область~Рівненська область~Севастополь~Сумська область~Тернопільська область~Харківська область~Херсонська область~Хмельницька область~Черкаська область~Чернівецька область~Чернігівська область", + sub_zips: + "9[5-8]~2[1-4]~4[3-5]~49|5[0-3]~8[3-7]~1[0-3]~8[89]|90~69|7[0-2]~7[6-8]~0[1-6]~0[7-9]~2[5-8]~9[1-4]~79|8[0-2]~5[4-7]~6[5-8]~3[6-9]~3[3-5]~99~4[0-2]~4[6-8]~6[1-4]~7[3-5]~29|3[0-2]~1[89]|20~5[89]|60~1[4-7]", + zip: "\\d{5}", + zipex: "15432,01055,01001", + }, + "data/UG": { id: "data/UG", key: "UG", name: "UGANDA" }, + "data/US": { + fmt: "%N%n%O%n%A%n%C, %S %Z", + id: "data/US", + key: "US", + lang: "en", + languages: "en", + name: "UNITED STATES", + posturl: "https://tools.usps.com/go/ZipLookupAction!input.action", + require: "ACSZ", + state_name_type: "state", + sub_isoids: + "AL~AK~~AZ~AR~~~~CA~CO~CT~DE~DC~FL~GA~~HI~ID~IL~IN~IA~KS~KY~LA~ME~~MD~MA~MI~~MN~MS~MO~MT~NE~NV~NH~NJ~NM~NY~NC~ND~~OH~OK~OR~~PA~~RI~SC~SD~TN~TX~UT~VT~~VA~WA~WV~WI~WY", + sub_keys: + "AL~AK~AS~AZ~AR~AA~AE~AP~CA~CO~CT~DE~DC~FL~GA~GU~HI~ID~IL~IN~IA~KS~KY~LA~ME~MH~MD~MA~MI~FM~MN~MS~MO~MT~NE~NV~NH~NJ~NM~NY~NC~ND~MP~OH~OK~OR~PW~PA~PR~RI~SC~SD~TN~TX~UT~VT~VI~VA~WA~WV~WI~WY", + sub_names: + "Alabama~Alaska~American Samoa~Arizona~Arkansas~Armed Forces (AA)~Armed Forces (AE)~Armed Forces (AP)~California~Colorado~Connecticut~Delaware~District of Columbia~Florida~Georgia~Guam~Hawaii~Idaho~Illinois~Indiana~Iowa~Kansas~Kentucky~Louisiana~Maine~Marshall Islands~Maryland~Massachusetts~Michigan~Micronesia~Minnesota~Mississippi~Missouri~Montana~Nebraska~Nevada~New Hampshire~New Jersey~New Mexico~New York~North Carolina~North Dakota~Northern Mariana Islands~Ohio~Oklahoma~Oregon~Palau~Pennsylvania~Puerto Rico~Rhode Island~South Carolina~South Dakota~Tennessee~Texas~Utah~Vermont~Virgin Islands~Virginia~Washington~West Virginia~Wisconsin~Wyoming", + sub_zipexs: + "35000,36999~99500,99999~96799~85000,86999~71600,72999~34000,34099~09000,09999~96200,96699~90000,96199~80000,81999~06000,06999~19700,19999~20000,56999~32000,34999~30000,39901~96910,96932~96700,96899~83200,83999~60000,62999~46000,47999~50000,52999~66000,67999~40000,42799~70000,71599~03900,04999~96960,96979~20600,21999~01000,05544~48000,49999~96941,96944~55000,56799~38600,39799~63000,65999~59000,59999~68000,69999~88900,89999~03000,03899~07000,08999~87000,88499~10000,00544~27000,28999~58000,58999~96950,96952~43000,45999~73000,74999~97000,97999~96940~15000,19699~00600,00999~02800,02999~29000,29999~57000,57999~37000,38599~75000,73344~84000,84999~05000,05999~00800,00899~20100,24699~98000,99499~24700,26999~53000,54999~82000,83414", + sub_zips: + "3[56]~99[5-9]~96799~8[56]~71[6-9]|72~340~09~96[2-6]~9[0-5]|96[01]~8[01]~06~19[7-9]~20[02-5]|569~3[23]|34[1-9]~3[01]|398|39901~969([1-2]\\d|3[12])~967[0-8]|9679[0-8]|968~83[2-9]~6[0-2]~4[67]~5[0-2]~6[67]~4[01]|42[0-7]~70|71[0-5]~039|04~969[67]~20[6-9]|21~01|02[0-7]|05501|05544~4[89]~9694[1-4]~55|56[0-7]~38[6-9]|39[0-7]~6[3-5]~59~6[89]~889|89~03[0-8]~0[78]~87|88[0-4]~1[0-4]|06390|00501|00544~2[78]~58~9695[0-2]~4[3-5]~7[34]~97~969(39|40)~1[5-8]|19[0-6]~00[679]~02[89]~29~57~37|38[0-5]~7[5-9]|885|73301|73344~84~05~008~201|2[23]|24[0-6]~98|99[0-4]~24[7-9]|2[56]~5[34]~82|83[01]|83414", + upper: "CS", + zip: "(\\d{5})(?:[ \\-](\\d{4}))?", + zip_name_type: "zip", + zipex: "95014,22162-1010", + }, + "data/UY": { + fmt: "%N%n%O%n%A%n%Z %C %S", + id: "data/UY", + key: "UY", + lang: "es", + languages: "es", + name: "URUGUAY", + posturl: + "http://www.correo.com.uy/index.asp?codPag=codPost&switchMapa=codPost", + sub_isoids: "AR~CA~CL~CO~DU~FS~FD~LA~MA~MO~PA~RN~RV~RO~SA~SJ~SO~TA~TT", + sub_keys: + "Artigas~Canelones~Cerro Largo~Colonia~Durazno~Flores~Florida~Lavalleja~Maldonado~Montevideo~Paysandú~Río Negro~Rivera~Rocha~Salto~San José~Soriano~Tacuarembó~Treinta y Tres", + sub_zips: + "55~9[01]|1[456]~37~70|75204~97~85~94|9060|97005~30~20~1|91600~60~65|60002~40~27~50~80~75|70003~45~33|30203|30204|30302|37007", + upper: "CS", + zip: "\\d{5}", + zipex: "11600", + }, + "data/UZ": { + fmt: "%N%n%O%n%A%n%Z %C%n%S", + id: "data/UZ", + key: "UZ", + name: "UZBEKISTAN", + posturl: "http://www.pochta.uz/ru/uslugi/indexsearch.html", + upper: "CS", + zip: "\\d{6}", + zipex: "702100,700000", + }, + "data/VA": { + fmt: "%N%n%O%n%A%n%Z %C", + id: "data/VA", + key: "VA", + name: "VATICAN", + zip: "00120", + zipex: "00120", + }, + "data/VC": { + fmt: "%N%n%O%n%A%n%C %Z", + id: "data/VC", + key: "VC", + name: "SAINT VINCENT AND THE GRENADINES (ANTILLES)", + posturl: + "http://www.svgpost.gov.vc/?option=com_content&view=article&id=3&Itemid=16", + zip: "VC\\d{4}", + zipex: "VC0100,VC0110,VC0400", + }, + "data/VE": { + fmt: "%N%n%O%n%A%n%C %Z, %S", + id: "data/VE", + key: "VE", + lang: "es", + languages: "es", + name: "VENEZUELA", + posturl: "http://www.ipostel.gob.ve/index.php/oficinas-postales", + require: "ACS", + state_name_type: "state", + sub_isoids: "Z~B~C~D~E~F~G~H~Y~W~A~I~J~K~L~M~N~O~P~R~S~T~X~U~V", + sub_keys: + "Amazonas~Anzoátegui~Apure~Aragua~Barinas~Bolívar~Carabobo~Cojedes~Delta Amacuro~Dependencias Federales~Distrito Federal~Falcón~Guárico~Lara~Mérida~Miranda~Monagas~Nueva Esparta~Portuguesa~Sucre~Táchira~Trujillo~Vargas~Yaracuy~Zulia", + upper: "CS", + zip: "\\d{4}", + zipex: "1010,3001,8011,1020", + }, + "data/VG": { + fmt: "%N%n%O%n%A%n%C%n%Z", + id: "data/VG", + key: "VG", + name: "VIRGIN ISLANDS (BRITISH)", + require: "A", + zip: "VG\\d{4}", + zipex: "VG1110,VG1150,VG1160", + }, + "data/VI": { + fmt: "%N%n%O%n%A%n%C %S %Z", + id: "data/VI", + key: "VI", + name: "VIRGIN ISLANDS (U.S.)", + posturl: "http://zip4.usps.com/zip4/welcome.jsp", + require: "ACSZ", + state_name_type: "state", + upper: "ACNOS", + zip: "(008(?:(?:[0-4]\\d)|(?:5[01])))(?:[ \\-](\\d{4}))?", + zip_name_type: "zip", + zipex: "00802-1222,00850-9802", + }, + "data/VN": { + fmt: "%N%n%O%n%A%n%C%n%S %Z", + id: "data/VN", + key: "VN", + lang: "vi", + languages: "vi", + lfmt: "%N%n%O%n%A%n%C%n%S %Z", + name: "VIET NAM", + posturl: "http://postcode.vnpost.vn/services/search.aspx", + sub_isoids: + "44~43~55~54~53~56~50~57~31~58~40~59~04~CT~DN~33~72~71~39~45~30~03~63~HN~23~61~HP~73~14~66~34~47~28~01~09~02~35~41~67~22~18~36~68~32~24~27~29~13~25~52~05~37~20~69~21~SG~26~46~51~07~49~70~06", + sub_keys: + "An Giang~Bà Rịa–Vũng Tàu~Bạc Liêu~Bắc Giang~Bắc Kạn~Bắc Ninh~Bến Tre~Bình Dương~Bình Định~Bình Phước~Bình Thuận~Cà Mau~Cao Bằng~Cần Thơ~Đà Nẵng~Đắk Lắk~Đăk Nông~Điện Biên~Đồng Nai~Đồng Tháp~Gia Lai~Hà Giang~Hà Nam~Hà Nội~Hà Tĩnh~Hải Dương~Hải Phòng~Hậu Giang~Hòa Bình~Hưng Yên~Khánh Hòa~Kiên Giang~Kon Tum~Lai Châu~Lạng Sơn~Lào Cai~Lâm Đồng~Long An~Nam Định~Nghệ An~Ninh Bình~Ninh Thuận~Phú Thọ~Phú Yên~Quảng Bình~Quảng Nam~Quảng Ngãi~Quảng Ninh~Quảng Trị~Sóc Trăng~Sơn La~Tây Ninh~Thái Bình~Thái Nguyên~Thanh Hóa~Thành phố Hồ Chí Minh~Thừa Thiên–Huế~Tiền Giang~Trà Vinh~Tuyên Quang~Vĩnh Long~Vĩnh Phúc~Yên Bái", + sub_lnames: + "An Giang Province~Ba Ria-Vung Tau Province~Bac Lieu Province~Bac Giang Province~Bac Kan Province~Bac Ninh Province~Ben Tre Province~Binh Duong Province~Binh Dinh Province~Binh Phuoc Province~Binh Thuan Province~Ca Mau Province~Cao Bang Province~Can Tho City~Da Nang City~Dak Lak Province~Dak Nong Province~Dien Bien Province~Dong Nai Province~Dong Thap Province~Gia Lai Province~Ha Giang Province~Ha Nam Province~Hanoi City~Ha Tinh Province~Hai Duong Province~Haiphong City~Hau Giang Province~Hoa Binh Province~Hung Yen Province~Khanh Hoa Province~Kien Giang Province~Kon Tum Province~Lai Chau Province~Lang Song Province~Lao Cai Province~Lam Dong Province~Long An Province~Nam Dinh Province~Nghe An Province~Ninh Binh Province~Ninh Thuan Province~Phu Tho Province~Phu Yen Province~Quang Binh Province~Quang Nam Province~Quang Ngai Province~Quang Ninh Province~Quang Tri Province~Soc Trang Province~Son La Province~Tay Ninh Province~Thai Binh Province~Thai Nguyen Province~Thanh Hoa Province~Ho Chi Minh City~Thua Thien-Hue Province~Tien Giang Province~Tra Vinh Province~Tuyen Quang Province~Vinh Long Province~Vinh Phuc Province~Yen Bai Province", + zip: "\\d{5}\\d?", + zipex: "70010,55999", + }, + "data/VU": { id: "data/VU", key: "VU", name: "VANUATU" }, + "data/WF": { + fmt: "%O%n%N%n%A%n%Z %C %X", + id: "data/WF", + key: "WF", + name: "WALLIS AND FUTUNA ISLANDS", + require: "ACZ", + upper: "ACX", + zip: "986\\d{2}", + zipex: "98600", + }, + "data/WS": { id: "data/WS", key: "WS", name: "SAMOA" }, + "data/XK": { + fmt: "%N%n%O%n%A%n%Z %C", + id: "data/XK", + key: "XK", + name: "KOSOVO", + zip: "[1-7]\\d{4}", + zipex: "10000", + }, + "data/YE": { id: "data/YE", key: "YE", name: "YEMEN" }, + "data/YT": { + fmt: "%O%n%N%n%A%n%Z %C %X", + id: "data/YT", + key: "YT", + name: "MAYOTTE", + require: "ACZ", + upper: "ACX", + zip: "976\\d{2}", + zipex: "97600", + }, + "data/ZA": { + fmt: "%N%n%O%n%A%n%D%n%C%n%Z", + id: "data/ZA", + key: "ZA", + name: "SOUTH AFRICA", + posturl: "https://www.postoffice.co.za/Questions/postalcode.html", + require: "ACZ", + zip: "\\d{4}", + zipex: "0083,1451,0001", + }, + "data/ZM": { + fmt: "%N%n%O%n%A%n%Z %C", + id: "data/ZM", + key: "ZM", + name: "ZAMBIA", + zip: "\\d{5}", + zipex: "50100,50101", + }, + "data/ZW": { id: "data/ZW", key: "ZW", name: "ZIMBABWE" }, +}; diff --git a/browser/extensions/formautofill/addressmetadata/addressReferencesExt.js b/browser/extensions/formautofill/addressmetadata/addressReferencesExt.js new file mode 100644 index 0000000000..cec20848f5 --- /dev/null +++ b/browser/extensions/formautofill/addressmetadata/addressReferencesExt.js @@ -0,0 +1,29 @@ +/* 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/. */ + +/* exported addressDataExt */ +/* eslint max-len: 0 */ + +"use strict"; + +// "addressDataExt" uses the same key as "addressData" in "addressReferences.js" and +// contains the information we need but absent in "libaddressinput" such as alternative names. + +// TODO: We only support the alternative name of US in MVP. We are going to support more countries in +// bug 1370193. +var addressDataExt = { + "data/US": { + alternative_names: [ + "US", + "United States of America", + "United States", + "America", + "U.S.", + "USA", + "U.S.A.", + "U.S.A", + ], + fmt: "%N%n%A%n%C%S%n%Z%O", + }, +}; diff --git a/browser/extensions/formautofill/api.js b/browser/extensions/formautofill/api.js new file mode 100644 index 0000000000..a14a570e5e --- /dev/null +++ b/browser/extensions/formautofill/api.js @@ -0,0 +1,235 @@ +/* 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"; + +/* globals ExtensionAPI */ + +const CACHED_STYLESHEETS = new WeakMap(); + +const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); + +ChromeUtils.defineModuleGetter( + this, + "FormAutofill", + "resource://formautofill/FormAutofill.jsm" +); +ChromeUtils.defineModuleGetter( + this, + "FormAutofillStatus", + "resource://formautofill/FormAutofillParent.jsm" +); +ChromeUtils.defineModuleGetter( + this, + "FormAutofillParent", + "resource://formautofill/FormAutofillParent.jsm" +); +ChromeUtils.defineModuleGetter( + this, + "AutoCompleteParent", + "resource://gre/actors/AutoCompleteParent.jsm" +); + +XPCOMUtils.defineLazyServiceGetter( + this, + "resProto", + "@mozilla.org/network/protocol;1?name=resource", + "nsISubstitutingProtocolHandler" +); + +const RESOURCE_HOST = "formautofill"; + +function insertStyleSheet(domWindow, url) { + let doc = domWindow.document; + let styleSheetAttr = `href="${url}" type="text/css"`; + let styleSheet = doc.createProcessingInstruction( + "xml-stylesheet", + styleSheetAttr + ); + + doc.insertBefore(styleSheet, doc.documentElement); + + if (CACHED_STYLESHEETS.has(domWindow)) { + CACHED_STYLESHEETS.get(domWindow).push(styleSheet); + } else { + CACHED_STYLESHEETS.set(domWindow, [styleSheet]); + } +} + +function ensureCssLoaded(domWindow) { + if (CACHED_STYLESHEETS.has(domWindow)) { + // This window already has autofill stylesheets. + return; + } + + insertStyleSheet(domWindow, "chrome://formautofill/content/formautofill.css"); + insertStyleSheet( + domWindow, + "chrome://formautofill/content/skin/autocomplete-item-shared.css" + ); + insertStyleSheet( + domWindow, + "chrome://formautofill/content/skin/autocomplete-item.css" + ); +} + +function isAvailable() { + let availablePref = Services.prefs.getCharPref( + "extensions.formautofill.available" + ); + if (availablePref == "on") { + return true; + } else if (availablePref == "detect") { + let region = Services.prefs.getCharPref("browser.search.region", ""); + let supportedCountries = Services.prefs + .getCharPref("extensions.formautofill.supportedCountries") + .split(","); + if ( + !Services.prefs.getBoolPref("extensions.formautofill.supportRTL") && + Services.locale.isAppLocaleRTL + ) { + return false; + } + return supportedCountries.includes(region); + } + return false; +} + +this.formautofill = class extends ExtensionAPI { + onStartup() { + // We have to do this before actually determining if we're enabled, since + // there are scripts inside of the core browser code that depend on the + // FormAutofill JSMs being registered. + let uri = Services.io.newURI("chrome/res/", null, this.extension.rootURI); + resProto.setSubstitution(RESOURCE_HOST, uri); + + let aomStartup = Cc[ + "@mozilla.org/addons/addon-manager-startup;1" + ].getService(Ci.amIAddonManagerStartup); + const manifestURI = Services.io.newURI( + "manifest.json", + null, + this.extension.rootURI + ); + this.chromeHandle = aomStartup.registerChrome(manifestURI, [ + ["content", "formautofill", "chrome/content/"], + ]); + + // Until we move to fluent (bug 1446164), we're stuck with + // chrome.manifest for handling localization since its what the + // build system can handle for localized repacks. + if (this.extension.rootURI instanceof Ci.nsIJARURI) { + this.autofillManifest = this.extension.rootURI.JARFile.QueryInterface( + Ci.nsIFileURL + ).file; + } else if (this.extension.rootURI instanceof Ci.nsIFileURL) { + this.autofillManifest = this.extension.rootURI.file; + } + + if (this.autofillManifest) { + Components.manager.addBootstrappedManifestLocation(this.autofillManifest); + } else { + Cu.reportError( + "Cannot find formautofill chrome.manifest for registring translated strings" + ); + } + + if (!isAvailable()) { + Services.prefs.clearUserPref("dom.forms.autocomplete.formautofill"); + // reset the sync related prefs incase the feature was previously available + // but isn't now. + Services.prefs.clearUserPref("services.sync.engine.addresses.available"); + Services.prefs.clearUserPref( + "services.sync.engine.creditcards.available" + ); + Services.telemetry.scalarSet("formautofill.availability", false); + return; + } + + // This pref is used for web contents to detect the autocomplete feature. + // When it's true, "element.autocomplete" will return tokens we currently + // support -- otherwise it'll return an empty string. + Services.prefs.setBoolPref("dom.forms.autocomplete.formautofill", true); + Services.telemetry.scalarSet("formautofill.availability", true); + + // This pref determines whether the "addresses"/"creditcards" sync engine is + // available (ie, whether it is shown in any UI etc) - it *does not* determine + // whether the engine is actually enabled or not. + Services.prefs.setBoolPref( + "services.sync.engine.addresses.available", + true + ); + if (FormAutofill.isAutofillCreditCardsAvailable) { + Services.prefs.setBoolPref( + "services.sync.engine.creditcards.available", + true + ); + } else { + Services.prefs.clearUserPref( + "services.sync.engine.creditcards.available" + ); + } + + // Listen for the autocomplete popup message + // or the form submitted message (which may trigger a + // doorhanger) to lazily append our stylesheets related + // to the autocomplete feature. + AutoCompleteParent.addPopupStateListener(ensureCssLoaded); + FormAutofillParent.addMessageObserver(this); + this.onFormSubmitted = (data, window) => ensureCssLoaded(window); + + FormAutofillStatus.init(); + + ChromeUtils.registerWindowActor("FormAutofill", { + parent: { + moduleURI: "resource://formautofill/FormAutofillParent.jsm", + }, + child: { + moduleURI: "resource://formautofill/FormAutofillChild.jsm", + events: { + focusin: {}, + DOMFormBeforeSubmit: {}, + }, + }, + allFrames: true, + }); + } + + onShutdown(isAppShutdown) { + if (isAppShutdown) { + return; + } + + resProto.setSubstitution(RESOURCE_HOST, null); + + this.chromeHandle.destruct(); + this.chromeHandle = null; + + if (this.autofillManifest) { + Components.manager.removeBootstrappedManifestLocation( + this.autofillManifest + ); + } + + ChromeUtils.unregisterWindowActor("FormAutofill"); + + AutoCompleteParent.removePopupStateListener(ensureCssLoaded); + FormAutofillParent.removeMessageObserver(this); + + for (let win of Services.wm.getEnumerator("navigator:browser")) { + let cachedStyleSheets = CACHED_STYLESHEETS.get(win); + + if (!cachedStyleSheets) { + continue; + } + + while (cachedStyleSheets.length !== 0) { + cachedStyleSheets.pop().remove(); + } + } + } +}; diff --git a/browser/extensions/formautofill/background.js b/browser/extensions/formautofill/background.js new file mode 100644 index 0000000000..fe6265415f --- /dev/null +++ b/browser/extensions/formautofill/background.js @@ -0,0 +1,15 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/* eslint-env webextensions */ + +"use strict"; + +browser.runtime.onUpdateAvailable.addListener(details => { + // By listening to but ignoring this event, any updates will + // be delayed until the next browser restart. + // Note that if we ever wanted to change this, we should make + // sure we manually invalidate the startup cache using the + // startupcache-invalidate notification. +}); diff --git a/browser/extensions/formautofill/content/autofillEditForms.js b/browser/extensions/formautofill/content/autofillEditForms.js new file mode 100644 index 0000000000..0ccffc6e75 --- /dev/null +++ b/browser/extensions/formautofill/content/autofillEditForms.js @@ -0,0 +1,648 @@ +/* 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/. */ + +/* exported EditAddress, EditCreditCard */ +/* eslint-disable mozilla/balanced-listeners */ // Not relevant since the document gets unloaded. + +"use strict"; + +class EditAutofillForm { + constructor(elements) { + this._elements = elements; + } + + /** + * Fill the form with a record object. + * @param {object} [record = {}] + */ + loadRecord(record = {}) { + for (let field of this._elements.form.elements) { + let value = record[field.id]; + value = typeof value == "undefined" ? "" : value; + + if (record.guid) { + field.value = value; + } else if (field.localName == "select") { + this.setDefaultSelectedOptionByValue(field, value); + } else { + // Use .defaultValue instead of .value to avoid setting the `dirty` flag + // which triggers form validation UI. + field.defaultValue = value; + } + } + if (!record.guid) { + // Reset the dirty value flag and validity state. + this._elements.form.reset(); + } else { + for (let field of this._elements.form.elements) { + this.updatePopulatedState(field); + this.updateCustomValidity(field); + } + } + } + + setDefaultSelectedOptionByValue(select, value) { + for (let option of select.options) { + option.defaultSelected = option.value == value; + } + } + + /** + * Get a record from the form suitable for a save/update in storage. + * @returns {object} + */ + buildFormObject() { + let initialObject = {}; + if (this.hasMailingAddressFields) { + // Start with an empty string for each mailing-address field so that any + // fields hidden for the current country are blanked in the return value. + initialObject = { + "street-address": "", + "address-level3": "", + "address-level2": "", + "address-level1": "", + "postal-code": "", + }; + } + + return Array.from(this._elements.form.elements).reduce((obj, input) => { + if (!input.disabled) { + obj[input.id] = input.value; + } + return obj; + }, initialObject); + } + + /** + * Handle events + * + * @param {DOMEvent} event + */ + handleEvent(event) { + switch (event.type) { + case "change": { + this.handleChange(event); + break; + } + case "input": { + this.handleInput(event); + break; + } + } + } + + /** + * Handle change events + * + * @param {DOMEvent} event + */ + handleChange(event) { + this.updatePopulatedState(event.target); + } + + /** + * Handle input events + * + * @param {DOMEvent} event + */ + handleInput(event) {} + + /** + * Attach event listener + */ + attachEventListeners() { + this._elements.form.addEventListener("input", this); + } + + /** + * Set the field-populated attribute if the field has a value. + * + * @param {DOMElement} field The field that will be checked for a value. + */ + updatePopulatedState(field) { + let span = field.parentNode.querySelector(".label-text"); + if (!span) { + return; + } + span.toggleAttribute("field-populated", !!field.value.trim()); + } + + /** + * Run custom validity routines specific to the field and type of form. + * + * @param {DOMElement} field The field that will be validated. + */ + updateCustomValidity(field) {} +} + +class EditAddress extends EditAutofillForm { + /** + * @param {HTMLElement[]} elements + * @param {object} record + * @param {object} config + * @param {string[]} config.DEFAULT_REGION + * @param {function} config.getFormFormat Function to return form layout info for a given country. + * @param {function} config.findAddressSelectOption Finds the matching select option for a given + select element, address, and fieldName. + * @param {string[]} config.countries + * @param {boolean} [config.noValidate=undefined] Whether to validate the form + */ + constructor(elements, record, config) { + super(elements); + + Object.assign(this, config); + let { form } = this._elements; + Object.assign(this._elements, { + addressLevel3Label: form.querySelector( + "#address-level3-container > .label-text" + ), + addressLevel2Label: form.querySelector( + "#address-level2-container > .label-text" + ), + addressLevel1Label: form.querySelector( + "#address-level1-container > .label-text" + ), + postalCodeLabel: form.querySelector( + "#postal-code-container > .label-text" + ), + country: form.querySelector("#country"), + }); + + this.populateCountries(); + // Need to populate the countries before trying to set the initial country. + // Also need to use this._record so it has the default country selected. + this.loadRecord(record); + this.attachEventListeners(); + + form.noValidate = !!config.noValidate; + } + + loadRecord(record) { + this._record = record; + if (!record) { + record = { + country: this.DEFAULT_REGION, + }; + } + + let { addressLevel1Options } = this.getFormFormat(record.country); + this.populateAddressLevel1(addressLevel1Options, record.country); + + super.loadRecord(record); + this.loadAddressLevel1(record["address-level1"], record.country); + this.formatForm(record.country); + } + + get hasMailingAddressFields() { + let { addressFields } = this._elements.form.dataset; + return ( + !addressFields || + addressFields + .trim() + .split(/\s+/) + .includes("mailing-address") + ); + } + + /** + * `mailing-address` is a special attribute token to indicate mailing fields + country. + * + * @param {object[]} mailingFieldsOrder - `fieldsOrder` from `getFormFormat` + * @param {string} addressFields - white-space-separated string of requested address fields to show + * @returns {object[]} in the same structure as `mailingFieldsOrder` but including non-mail fields + */ + static computeVisibleFields(mailingFieldsOrder, addressFields) { + if (addressFields) { + let requestedFieldClasses = addressFields.trim().split(/\s+/); + let fieldClasses = []; + if (requestedFieldClasses.includes("mailing-address")) { + fieldClasses = fieldClasses.concat(mailingFieldsOrder); + // `country` isn't part of the `mailingFieldsOrder` so add it when filling a mailing-address + requestedFieldClasses.splice( + requestedFieldClasses.indexOf("mailing-address"), + 1, + "country" + ); + } + + for (let fieldClassName of requestedFieldClasses) { + fieldClasses.push({ + fieldId: fieldClassName, + newLine: fieldClassName == "name", + }); + } + return fieldClasses; + } + + // This is the default which is shown in the management interface and includes all fields. + return mailingFieldsOrder.concat([ + { + fieldId: "country", + }, + { + fieldId: "tel", + }, + { + fieldId: "email", + newLine: true, + }, + ]); + } + + /** + * Format the form based on country. The address-level1 and postal-code labels + * should be specific to the given country. + * @param {string} country + */ + formatForm(country) { + const { + addressLevel3Label, + addressLevel2Label, + addressLevel1Label, + addressLevel1Options, + postalCodeLabel, + fieldsOrder: mailingFieldsOrder, + postalCodePattern, + countryRequiredFields, + } = this.getFormFormat(country); + this._elements.addressLevel3Label.dataset.localization = addressLevel3Label; + this._elements.addressLevel2Label.dataset.localization = addressLevel2Label; + this._elements.addressLevel1Label.dataset.localization = addressLevel1Label; + this._elements.postalCodeLabel.dataset.localization = postalCodeLabel; + let addressFields = this._elements.form.dataset.addressFields; + let extraRequiredFields = this._elements.form.dataset.extraRequiredFields; + let fieldClasses = EditAddress.computeVisibleFields( + mailingFieldsOrder, + addressFields + ); + let requiredFields = new Set(countryRequiredFields); + if (extraRequiredFields) { + for (let extraRequiredField of extraRequiredFields.trim().split(/\s+/)) { + requiredFields.add(extraRequiredField); + } + } + this.arrangeFields(fieldClasses, requiredFields); + this.updatePostalCodeValidation(postalCodePattern); + this.populateAddressLevel1(addressLevel1Options, country); + } + + /** + * Update address field visibility and order based on libaddressinput data. + * + * @param {object[]} fieldsOrder array of objects with `fieldId` and optional `newLine` properties + * @param {Set} requiredFields Set of `fieldId` strings that mark which fields are required + */ + arrangeFields(fieldsOrder, requiredFields) { + /** + * @see FormAutofillStorage.VALID_ADDRESS_FIELDS + */ + let fields = [ + // `name` is a wrapper for the 3 name fields. + "name", + "organization", + "street-address", + "address-level3", + "address-level2", + "address-level1", + "postal-code", + "country", + "tel", + "email", + ]; + let inputs = []; + for (let i = 0; i < fieldsOrder.length; i++) { + let { fieldId, newLine } = fieldsOrder[i]; + + let container = this._elements.form.querySelector( + `#${fieldId}-container` + ); + let containerInputs = [ + ...container.querySelectorAll("input, textarea, select"), + ]; + containerInputs.forEach(function(input) { + input.disabled = false; + // libaddressinput doesn't list 'country' or 'name' as required. + // The additional-name field should never get marked as required. + input.required = + (fieldId == "country" || + fieldId == "name" || + requiredFields.has(fieldId)) && + input.id != "additional-name"; + }); + inputs.push(...containerInputs); + container.style.display = "flex"; + container.style.order = i; + container.style.pageBreakAfter = newLine ? "always" : "auto"; + // Remove the field from the list of fields + fields.splice(fields.indexOf(fieldId), 1); + } + for (let i = 0; i < inputs.length; i++) { + // Assign tabIndex starting from 1 + inputs[i].tabIndex = i + 1; + } + // Hide the remaining fields + for (let field of fields) { + let container = this._elements.form.querySelector(`#${field}-container`); + container.style.display = "none"; + for (let input of [ + ...container.querySelectorAll("input, textarea, select"), + ]) { + input.disabled = true; + } + } + } + + updatePostalCodeValidation(postalCodePattern) { + let postalCodeInput = this._elements.form.querySelector("#postal-code"); + if (postalCodePattern && postalCodeInput.style.display != "none") { + postalCodeInput.setAttribute("pattern", postalCodePattern); + } else { + postalCodeInput.removeAttribute("pattern"); + } + } + + /** + * Set the address-level1 value on the form field (input or select, whichever is present). + * + * @param {string} addressLevel1Value Value of the address-level1 from the autofill record + * @param {string} country The corresponding country + */ + loadAddressLevel1(addressLevel1Value, country) { + let field = this._elements.form.querySelector("#address-level1"); + + if (field.localName == "input") { + field.value = addressLevel1Value || ""; + return; + } + + let matchedSelectOption = this.findAddressSelectOption( + field, + { + country, + "address-level1": addressLevel1Value, + }, + "address-level1" + ); + if (matchedSelectOption && !matchedSelectOption.selected) { + field.value = matchedSelectOption.value; + field.dispatchEvent(new Event("input", { bubbles: true })); + field.dispatchEvent(new Event("change", { bubbles: true })); + } else if (addressLevel1Value) { + // If the option wasn't found, insert an option at the beginning of + // the select that matches the stored value. + field.insertBefore( + new Option(addressLevel1Value, addressLevel1Value, true, true), + field.firstChild + ); + } + } + + /** + * Replace the text input for address-level1 with a select dropdown if + * a fixed set of names exists. Otherwise show a text input. + * + * @param {Map?} options Map of options with regionCode -> name mappings + * @param {string} country The corresponding country + */ + populateAddressLevel1(options, country) { + let field = this._elements.form.querySelector("#address-level1"); + + if (field.dataset.country == country) { + return; + } + + if (!options) { + if (field.localName == "input") { + return; + } + + let input = document.createElement("input"); + input.setAttribute("type", "text"); + input.id = "address-level1"; + input.required = field.required; + input.disabled = field.disabled; + input.tabIndex = field.tabIndex; + field.replaceWith(input); + return; + } + + if (field.localName == "input") { + let select = document.createElement("select"); + select.id = "address-level1"; + select.required = field.required; + select.disabled = field.disabled; + select.tabIndex = field.tabIndex; + field.replaceWith(select); + field = select; + } + + field.textContent = ""; + field.dataset.country = country; + let fragment = document.createDocumentFragment(); + fragment.appendChild(new Option(undefined, undefined, true, true)); + for (let [regionCode, regionName] of options) { + let option = new Option(regionName, regionCode); + fragment.appendChild(option); + } + field.appendChild(fragment); + } + + populateCountries() { + let fragment = document.createDocumentFragment(); + // Sort countries by their visible names. + let countries = [...this.countries.entries()].sort((e1, e2) => + e1[1].localeCompare(e2[1]) + ); + for (let country of countries) { + let option = new Option(); + option.value = country[0]; + option.dataset.localizationRegion = country[0].toLowerCase(); + fragment.appendChild(option); + } + this._elements.country.appendChild(fragment); + } + + handleChange(event) { + if (event.target == this._elements.country) { + this.formatForm(event.target.value); + } + super.handleChange(event); + } + + attachEventListeners() { + this._elements.form.addEventListener("change", this); + super.attachEventListeners(); + } +} + +class EditCreditCard extends EditAutofillForm { + /** + * @param {HTMLElement[]} elements + * @param {object} record with a decrypted cc-number + * @param {object} addresses in an object with guid keys for the billing address picker. + * @param {object} config + * @param {function} config.isCCNumber Function to determine if a string is a valid CC number. + * @param {function} config.getSupportedNetworks Function to get the list of card networks + */ + constructor(elements, record, addresses, config) { + super(elements); + + this._addresses = addresses; + Object.assign(this, config); + Object.assign(this._elements, { + ccNumber: this._elements.form.querySelector("#cc-number"), + invalidCardNumberStringElement: this._elements.form.querySelector( + "#invalidCardNumberString" + ), + month: this._elements.form.querySelector("#cc-exp-month"), + year: this._elements.form.querySelector("#cc-exp-year"), + ccType: this._elements.form.querySelector("#cc-type"), + billingAddress: this._elements.form.querySelector("#billingAddressGUID"), + billingAddressRow: this._elements.form.querySelector( + ".billingAddressRow" + ), + }); + + this.attachEventListeners(); + this.loadRecord(record, addresses); + } + + loadRecord(record, addresses, preserveFieldValues) { + // _record must be updated before generateYears and generateBillingAddressOptions are called. + this._record = record; + this._addresses = addresses; + this.generateBillingAddressOptions(preserveFieldValues); + if (!preserveFieldValues) { + // Re-populating the networks will reset the selected option. + this.populateNetworks(); + // Re-generating the months will reset the selected option. + this.generateMonths(); + // Re-generating the years will reset the selected option. + this.generateYears(); + super.loadRecord(record); + } + } + + generateMonths() { + const count = 12; + + // Clear the list + this._elements.month.textContent = ""; + + // Empty month option + this._elements.month.appendChild(new Option()); + + // Populate month list. Format: "month number - month name" + let dateFormat = new Intl.DateTimeFormat(navigator.language, { + month: "long", + }).format; + for (let i = 0; i < count; i++) { + let monthNumber = (i + 1).toString(); + let monthName = dateFormat(new Date(1970, i)); + let option = new Option(); + option.value = monthNumber; + // XXX: Bug 1446164 - Localize this string. + option.textContent = `${monthNumber.padStart(2, "0")} - ${monthName}`; + this._elements.month.appendChild(option); + } + } + + generateYears() { + const count = 11; + const currentYear = new Date().getFullYear(); + const ccExpYear = this._record && this._record["cc-exp-year"]; + + // Clear the list + this._elements.year.textContent = ""; + + // Provide an empty year option + this._elements.year.appendChild(new Option()); + + if (ccExpYear && ccExpYear < currentYear) { + this._elements.year.appendChild(new Option(ccExpYear)); + } + + for (let i = 0; i < count; i++) { + let year = currentYear + i; + let option = new Option(year); + this._elements.year.appendChild(option); + } + + if (ccExpYear && ccExpYear > currentYear + count) { + this._elements.year.appendChild(new Option(ccExpYear)); + } + } + + populateNetworks() { + // Clear the list + this._elements.ccType.textContent = ""; + let frag = document.createDocumentFragment(); + // include an empty first option + frag.appendChild(new Option("", "")); + + let supportedNetworks = this.getSupportedNetworks(); + for (let id of supportedNetworks) { + let option = new Option(); + option.value = id; + option.dataset.localization = "cardNetwork." + id; + frag.appendChild(option); + } + this._elements.ccType.appendChild(frag); + } + + generateBillingAddressOptions(preserveFieldValues) { + let billingAddressGUID; + if (preserveFieldValues && this._elements.billingAddress.value) { + billingAddressGUID = this._elements.billingAddress.value; + } else if (this._record) { + billingAddressGUID = this._record.billingAddressGUID; + } + + this._elements.billingAddress.textContent = ""; + + this._elements.billingAddress.appendChild(new Option("", "")); + + let hasAddresses = false; + for (let [guid, address] of Object.entries(this._addresses)) { + hasAddresses = true; + let selected = guid == billingAddressGUID; + let option = new Option( + this.getAddressLabel(address), + guid, + selected, + selected + ); + this._elements.billingAddress.appendChild(option); + } + + this._elements.billingAddressRow.hidden = !hasAddresses; + } + + attachEventListeners() { + this._elements.form.addEventListener("change", this); + super.attachEventListeners(); + } + + handleInput(event) { + // Clear the error message if cc-number is valid + if ( + event.target == this._elements.ccNumber && + this.isCCNumber(this._elements.ccNumber.value) + ) { + this._elements.ccNumber.setCustomValidity(""); + } + super.handleInput(event); + } + + updateCustomValidity(field) { + super.updateCustomValidity(field); + + // Mark the cc-number field as invalid if the number is empty or invalid. + if (field == this._elements.ccNumber && !this.isCCNumber(field.value)) { + let invalidCardNumberString = this._elements + .invalidCardNumberStringElement.textContent; + field.setCustomValidity(invalidCardNumberString || " "); + } + } +} diff --git a/browser/extensions/formautofill/content/customElements.js b/browser/extensions/formautofill/content/customElements.js new file mode 100644 index 0000000000..a6add23170 --- /dev/null +++ b/browser/extensions/formautofill/content/customElements.js @@ -0,0 +1,423 @@ +/* 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 file is loaded into the browser window scope. +/* eslint-env mozilla/browser-window */ +/* eslint-disable mozilla/balanced-listeners */ // Not relevant since the document gets unloaded. + +"use strict"; + +// Wrap in a block to prevent leaking to window scope. +(() => { + const { Services } = ChromeUtils.import( + "resource://gre/modules/Services.jsm" + ); + + function sendMessageToBrowser(msgName, data) { + let { AutoCompleteParent } = ChromeUtils.import( + "resource://gre/actors/AutoCompleteParent.jsm" + ); + + let actor = AutoCompleteParent.getCurrentActor(); + if (!actor) { + return; + } + + actor.manager.getActor("FormAutofill").sendAsyncMessage(msgName, data); + } + + class MozAutocompleteProfileListitemBase extends MozElements.MozRichlistitem { + constructor() { + super(); + + /** + * For form autofill, we want to unify the selection no matter by + * keyboard navigation or mouseover in order not to confuse user which + * profile preview is being shown. This field is set to true to indicate + * that selectedIndex of popup should be changed while mouseover item + */ + this.selectedByMouseOver = true; + } + + get _stringBundle() { + if (!this.__stringBundle) { + this.__stringBundle = Services.strings.createBundle( + "chrome://formautofill/locale/formautofill.properties" + ); + } + return this.__stringBundle; + } + + _cleanup() { + this.removeAttribute("formautofillattached"); + if (this._itemBox) { + this._itemBox.removeAttribute("size"); + } + } + + _onOverflow() {} + + _onUnderflow() {} + + handleOverUnderflow() {} + + _adjustAutofillItemLayout() { + let outerBoxRect = this.parentNode.getBoundingClientRect(); + + // Make item fit in popup as XUL box could not constrain + // item's width + this._itemBox.style.width = outerBoxRect.width + "px"; + // Use two-lines layout when width is smaller than 150px or + // 185px if an image precedes the label. + let oneLineMinRequiredWidth = this.getAttribute("ac-image") ? 185 : 150; + + if (outerBoxRect.width <= oneLineMinRequiredWidth) { + this._itemBox.setAttribute("size", "small"); + } else { + this._itemBox.removeAttribute("size"); + } + } + } + + MozElements.MozAutocompleteProfileListitem = class MozAutocompleteProfileListitem extends MozAutocompleteProfileListitemBase { + static get markup() { + return ` + <div xmlns="http://www.w3.org/1999/xhtml" class="autofill-item-box"> + <div class="profile-label-col profile-item-col"> + <span class="profile-label-affix"></span> + <span class="profile-label"></span> + </div> + <div class="profile-comment-col profile-item-col"> + <span class="profile-comment"></span> + </div> + </div> + `; + } + + connectedCallback() { + if (this.delayConnectedCallback()) { + return; + } + + this.textContent = ""; + + this.appendChild(this.constructor.fragment); + + this._itemBox = this.querySelector(".autofill-item-box"); + this._labelAffix = this.querySelector(".profile-label-affix"); + this._label = this.querySelector(".profile-label"); + this._comment = this.querySelector(".profile-comment"); + + this.initializeAttributeInheritance(); + this._adjustAcItem(); + } + + static get inheritedAttributes() { + return { + ".autofill-item-box": "ac-image", + }; + } + + set selected(val) { + if (val) { + this.setAttribute("selected", "true"); + } else { + this.removeAttribute("selected"); + } + + sendMessageToBrowser("FormAutofill:PreviewProfile"); + + return val; + } + + get selected() { + return this.getAttribute("selected") == "true"; + } + + _adjustAcItem() { + this._adjustAutofillItemLayout(); + this.setAttribute("formautofillattached", "true"); + this._itemBox.style.setProperty( + "--primary-icon", + `url(${this.getAttribute("ac-image")})` + ); + + let { primaryAffix, primary, secondary, ariaLabel } = JSON.parse( + this.getAttribute("ac-value") + ); + + this._labelAffix.textContent = primaryAffix; + this._label.textContent = primary; + this._comment.textContent = secondary; + if (ariaLabel) { + this.setAttribute("aria-label", ariaLabel); + } + } + }; + + customElements.define( + "autocomplete-profile-listitem", + MozElements.MozAutocompleteProfileListitem, + { extends: "richlistitem" } + ); + + class MozAutocompleteProfileListitemFooter extends MozAutocompleteProfileListitemBase { + static get markup() { + return ` + <div xmlns="http://www.w3.org/1999/xhtml" class="autofill-item-box autofill-footer"> + <div class="autofill-footer-row autofill-warning"></div> + <div class="autofill-footer-row autofill-button"></div> + </div> + `; + } + + constructor() { + super(); + + this.addEventListener("click", event => { + if (event.button != 0) { + return; + } + + if (this._warningTextBox.contains(event.originalTarget)) { + return; + } + + window.openPreferences("privacy-form-autofill"); + }); + } + + connectedCallback() { + if (this.delayConnectedCallback()) { + return; + } + + this.textContent = ""; + this.appendChild(this.constructor.fragment); + + this._itemBox = this.querySelector(".autofill-footer"); + this._optionButton = this.querySelector(".autofill-button"); + this._warningTextBox = this.querySelector(".autofill-warning"); + + /** + * A handler for updating warning message once selectedIndex has been changed. + * + * There're three different states of warning message: + * 1. None of addresses were selected: We show all the categories intersection of fields in the + * form and fields in the results. + * 2. An address was selested: Show the additional categories that will also be filled. + * 3. An address was selected, but the focused category is the same as the only one category: Only show + * the exact category that we're going to fill in. + * + * @private + * @param {Object} data + * Message data + * @param {string[]} data.categories + * The categories of all the fields contained in the selected address. + */ + this.updateWarningNote = data => { + let categories = + data && data.categories ? data.categories : this._allFieldCategories; + // If the length of categories is 1, that means all the fillable fields are in the same + // category. We will change the way to inform user according to this flag. When the value + // is true, we show "Also autofills ...", otherwise, show "Autofills ..." only. + let hasExtraCategories = categories.length > 1; + // Show the categories in certain order to conform with the spec. + let orderedCategoryList = [ + { id: "address", l10nId: "category.address" }, + { id: "name", l10nId: "category.name" }, + { id: "organization", l10nId: "category.organization2" }, + { id: "tel", l10nId: "category.tel" }, + { id: "email", l10nId: "category.email" }, + ]; + let showCategories = hasExtraCategories + ? orderedCategoryList.filter( + category => + categories.includes(category.id) && + category.id != this._focusedCategory + ) + : [ + orderedCategoryList.find( + category => category.id == this._focusedCategory + ), + ]; + + let separator = this._stringBundle.GetStringFromName( + "fieldNameSeparator" + ); + let warningTextTmplKey = hasExtraCategories + ? "phishingWarningMessage" + : "phishingWarningMessage2"; + let categoriesText = showCategories + .map(category => + this._stringBundle.GetStringFromName(category.l10nId) + ) + .join(separator); + + this._warningTextBox.textContent = this._stringBundle.formatStringFromName( + warningTextTmplKey, + [categoriesText] + ); + this.parentNode.parentNode.adjustHeight(); + }; + + this._adjustAcItem(); + } + + _onCollapse() { + if (this.showWarningText) { + let { FormAutofillParent } = ChromeUtils.import( + "resource://formautofill/FormAutofillParent.jsm" + ); + FormAutofillParent.removeMessageObserver(this); + } + this._itemBox.removeAttribute("no-warning"); + } + + _adjustAcItem() { + this._adjustAutofillItemLayout(); + this.setAttribute("formautofillattached", "true"); + + let { AppConstants } = ChromeUtils.import( + "resource://gre/modules/AppConstants.jsm", + {} + ); + + let buttonTextBundleKey; + if (this._itemBox.getAttribute("size") == "small") { + buttonTextBundleKey = + AppConstants.platform == "macosx" + ? "autocompleteFooterOptionOSXShort2" + : "autocompleteFooterOptionShort2"; + } else { + buttonTextBundleKey = + AppConstants.platform == "macosx" + ? "autocompleteFooterOptionOSX2" + : "autocompleteFooterOption2"; + } + + let buttonText = this._stringBundle.GetStringFromName( + buttonTextBundleKey + ); + this._optionButton.textContent = buttonText; + + let value = JSON.parse(this.getAttribute("ac-value")); + + this._allFieldCategories = value.categories; + this._focusedCategory = value.focusedCategory; + this.showWarningText = this._allFieldCategories && this._focusedCategory; + + if (this.showWarningText) { + let { FormAutofillParent } = ChromeUtils.import( + "resource://formautofill/FormAutofillParent.jsm" + ); + FormAutofillParent.addMessageObserver(this); + this.updateWarningNote(); + } else { + this._itemBox.setAttribute("no-warning", "true"); + } + } + } + + customElements.define( + "autocomplete-profile-listitem-footer", + MozAutocompleteProfileListitemFooter, + { extends: "richlistitem" } + ); + + class MozAutocompleteCreditcardInsecureField extends MozAutocompleteProfileListitemBase { + static get markup() { + return ` + <div xmlns="http://www.w3.org/1999/xhtml" class="autofill-insecure-item"></div> + `; + } + + connectedCallback() { + if (this.delayConnectedCallback()) { + return; + } + this.textContent = ""; + this.appendChild(this.constructor.fragment); + + this._itemBox = this.querySelector(".autofill-insecure-item"); + + this._adjustAcItem(); + } + + set selected(val) { + // Make this item unselectable since we see this item as a pure message. + return false; + } + + get selected() { + return this.getAttribute("selected") == "true"; + } + + _adjustAcItem() { + this._adjustAutofillItemLayout(); + this.setAttribute("formautofillattached", "true"); + + let value = this.getAttribute("ac-value"); + this._itemBox.textContent = value; + } + } + + customElements.define( + "autocomplete-creditcard-insecure-field", + MozAutocompleteCreditcardInsecureField, + { extends: "richlistitem" } + ); + + class MozAutocompleteProfileListitemClearButton extends MozAutocompleteProfileListitemBase { + static get markup() { + return ` + <div xmlns="http://www.w3.org/1999/xhtml" class="autofill-item-box autofill-footer"> + <div class="autofill-footer-row autofill-button"></div> + </div> + `; + } + + constructor() { + super(); + + this.addEventListener("click", event => { + if (event.button != 0) { + return; + } + + sendMessageToBrowser("FormAutofill:ClearForm"); + }); + } + + connectedCallback() { + if (this.delayConnectedCallback()) { + return; + } + + this.textContent = ""; + this.appendChild(this.constructor.fragment); + + this._itemBox = this.querySelector(".autofill-item-box"); + this._clearBtn = this.querySelector(".autofill-button"); + + this._adjustAcItem(); + } + + _adjustAcItem() { + this._adjustAutofillItemLayout(); + this.setAttribute("formautofillattached", "true"); + + let clearFormBtnLabel = this._stringBundle.GetStringFromName( + "clearFormBtnLabel2" + ); + this._clearBtn.textContent = clearFormBtnLabel; + } + } + + customElements.define( + "autocomplete-profile-listitem-clear-button", + MozAutocompleteProfileListitemClearButton, + { extends: "richlistitem" } + ); +})(); diff --git a/browser/extensions/formautofill/content/editAddress.xhtml b/browser/extensions/formautofill/content/editAddress.xhtml new file mode 100644 index 0000000000..e574bdd7c5 --- /dev/null +++ b/browser/extensions/formautofill/content/editAddress.xhtml @@ -0,0 +1,126 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- 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/. --> +<!DOCTYPE html [ + <!ENTITY % globalDTD SYSTEM "chrome://global/locale/global.dtd"> + %globalDTD; +]> +<html xmlns="http://www.w3.org/1999/xhtml"> +<head> + <title data-localization="addNewAddressTitle"/> + <link rel="stylesheet" href="chrome://formautofill/content/skin/editDialog-shared.css"/> + <link rel="stylesheet" href="chrome://formautofill/content/skin/editAddress.css"/> + <link rel="stylesheet" href="chrome://formautofill/content/skin/editDialog.css"/> + <script src="chrome://formautofill/content/l10n.js"></script> + <script src="chrome://formautofill/content/editDialog.js"></script> + <script src="chrome://formautofill/content/autofillEditForms.js"></script> +</head> +<body dir="&locale.dir;"> + <form id="form" class="editAddressForm" autocomplete="off"> + <!-- + The <span class="label-text" …/> needs to be after the form field in the same element in + order to get proper label styling with :focus and :moz-ui-invalid. + --> + <div id="name-container" class="container"> + <label id="given-name-container"> + <input id="given-name" type="text" required="required"/> + <span data-localization="givenName" class="label-text"/> + </label> + <label id="additional-name-container"> + <input id="additional-name" type="text"/> + <span data-localization="additionalName" class="label-text"/> + </label> + <label id="family-name-container"> + <input id="family-name" type="text" required="required"/> + <span data-localization="familyName" class="label-text"/> + </label> + </div> + <label id="organization-container" class="container"> + <input id="organization" type="text"/> + <span data-localization="organization2" class="label-text"/> + </label> + <label id="street-address-container" class="container"> + <textarea id="street-address" rows="3"/> + <span data-localization="streetAddress" class="label-text"/> + </label> + <label id="address-level3-container" class="container"> + <input id="address-level3" type="text"/> + <span class="label-text"/> + </label> + <label id="address-level2-container" class="container"> + <input id="address-level2" type="text"/> + <span class="label-text"/> + </label> + <label id="address-level1-container" class="container"> + <!-- The address-level1 input will get replaced by a select dropdown + by autofillEditForms.js when the selected country has provided + specific options. --> + <input id="address-level1" type="text"/> + <span class="label-text"/> + </label> + <label id="postal-code-container" class="container"> + <input id="postal-code" type="text"/> + <span class="label-text"/> + </label> + <label id="country-container" class="container"> + <select id="country" required="required"> + <option/> + </select> + <span data-localization="country" class="label-text"/> + </label> + <label id="tel-container" class="container"> + <input id="tel" type="tel" dir="auto"/> + <span data-localization="tel" class="label-text"/> + </label> + <label id="email-container" class="container"> + <input id="email" type="email" required="required"/> + <span data-localization="email" class="label-text"/> + </label> + </form> + <div id="controls-container"> + <span id="country-warning-message" data-localization="countryWarningMessage2"/> + <button id="cancel" data-localization="cancelBtnLabel"/> + <button id="save" class="primary" data-localization="saveBtnLabel"/> + </div> + <script><![CDATA[ + "use strict"; + + /* import-globals-from l10n.js */ + + let { + DEFAULT_REGION, + countries, + } = FormAutofill; + let { + getFormFormat, + findAddressSelectOption, + } = FormAutofillUtils; + let args = window.arguments || []; + let { + record, + noValidate, + } = args[0] || {}; + + /* import-globals-from autofillEditForms.js */ + var fieldContainer = new EditAddress({ + form: document.getElementById("form"), + }, record, { + DEFAULT_REGION, + getFormFormat: getFormFormat.bind(FormAutofillUtils), + findAddressSelectOption: findAddressSelectOption.bind(FormAutofillUtils), + countries, + noValidate, + }); + + /* import-globals-from editDialog.js */ + new EditAddressDialog({ + title: document.querySelector("title"), + fieldContainer, + controlsContainer: document.getElementById("controls-container"), + cancel: document.getElementById("cancel"), + save: document.getElementById("save"), + }, record); + ]]></script> +</body> +</html> diff --git a/browser/extensions/formautofill/content/editCreditCard.xhtml b/browser/extensions/formautofill/content/editCreditCard.xhtml new file mode 100644 index 0000000000..3ca2f3f1b5 --- /dev/null +++ b/browser/extensions/formautofill/content/editCreditCard.xhtml @@ -0,0 +1,110 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- 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/. --> +<!DOCTYPE html [ + <!ENTITY % globalDTD SYSTEM "chrome://global/locale/global.dtd"> + %globalDTD; +]> +<html xmlns="http://www.w3.org/1999/xhtml"> +<head> + <title data-localization="addNewCreditCardTitle"/> + <link rel="stylesheet" href="chrome://formautofill/content/skin/editDialog-shared.css"/> + <link rel="stylesheet" href="chrome://formautofill/content/skin/editCreditCard.css"/> + <link rel="stylesheet" href="chrome://formautofill/content/skin/editDialog.css"/> + <script src="chrome://formautofill/content/l10n.js"></script> + <script src="chrome://formautofill/content/editDialog.js"></script> + <script src="chrome://formautofill/content/autofillEditForms.js"></script> +</head> +<body dir="&locale.dir;"> + <form id="form" class="editCreditCardForm contentPane" autocomplete="off"> + <!-- + The <span class="label-text" …/> needs to be after the form field in the same element in + order to get proper label styling with :focus and :moz-ui-invalid. + --> + <label id="cc-number-container" class="container" role="none"> + <span id="invalidCardNumberString" hidden="hidden" data-localization="invalidCardNumber"></span> + <!-- Because there is text both before and after the input, a11y will + include the value of the input in the label. Therefore, we override + with aria-labelledby. + --> + <input id="cc-number" type="text" required="required" minlength="14" pattern="[- 0-9]+" aria-labelledby="cc-number-label"/> + <span id="cc-number-label" data-localization="cardNumber" class="label-text"/> + </label> + <label id="cc-exp-month-container" class="container"> + <select id="cc-exp-month" required="required"> + <option/> + </select> + <span data-localization="cardExpiresMonth" class="label-text"/> + </label> + <label id="cc-exp-year-container" class="container"> + <select id="cc-exp-year" required="required"> + <option/> + </select> + <span data-localization="cardExpiresYear" class="label-text"/> + </label> + <label id="cc-name-container" class="container"> + <input id="cc-name" type="text" required="required"/> + <span data-localization="nameOnCard" class="label-text"/> + </label> + <label id="cc-type-container" class="container"> + <select id="cc-type" required="required"> + </select> + <span data-localization="cardNetwork" class="label-text"/> + </label> + <label id="cc-csc-container" class="container" hidden="hidden"> + <!-- The CSC container will get filled in by forms that need a CSC (using csc-input.js) --> + </label> + <div id="billingAddressGUID-container" class="billingAddressRow container rich-picker"> + <select id="billingAddressGUID" required="required"> + </select> + <label for="billingAddressGUID" data-localization="billingAddress" class="label-text"/> + </div> + </form> + <div id="controls-container"> + <button id="cancel" data-localization="cancelBtnLabel"/> + <button id="save" class="primary" data-localization="saveBtnLabel"/> + </div> + <script><![CDATA[ + "use strict"; + + /* import-globals-from l10n.js */ + + (async () => { + let { + getAddressLabel, + isCCNumber, + getCreditCardNetworks, + } = FormAutofillUtils; + let args = window.arguments || []; + let { + record, + } = args[0] || {}; + + let addresses = {}; + for (let address of await formAutofillStorage.addresses.getAll()) { + addresses[address.guid] = address; + } + + /* import-globals-from autofillEditForms.js */ + let fieldContainer = new EditCreditCard({ + form: document.getElementById("form"), + }, record, addresses, + { + getAddressLabel: getAddressLabel.bind(FormAutofillUtils), + isCCNumber: isCCNumber.bind(FormAutofillUtils), + getSupportedNetworks: getCreditCardNetworks.bind(FormAutofillUtils), + }); + + /* import-globals-from editDialog.js */ + new EditCreditCardDialog({ + title: document.querySelector("title"), + fieldContainer, + controlsContainer: document.getElementById("controls-container"), + cancel: document.getElementById("cancel"), + save: document.getElementById("save"), + }, record); + })(); + ]]></script> +</body> +</html> diff --git a/browser/extensions/formautofill/content/editDialog.js b/browser/extensions/formautofill/content/editDialog.js new file mode 100644 index 0000000000..2e32022496 --- /dev/null +++ b/browser/extensions/formautofill/content/editDialog.js @@ -0,0 +1,224 @@ +/* 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/. */ + +/* exported EditAddressDialog, EditCreditCardDialog */ +/* eslint-disable mozilla/balanced-listeners */ // Not relevant since the document gets unloaded. + +"use strict"; + +// eslint-disable-next-line no-unused-vars +const { FormAutofill } = ChromeUtils.import( + "resource://formautofill/FormAutofill.jsm" +); +const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); + +ChromeUtils.defineModuleGetter( + this, + "formAutofillStorage", + "resource://formautofill/FormAutofillStorage.jsm" +); + +class AutofillEditDialog { + constructor(subStorageName, elements, record) { + this._storageInitPromise = formAutofillStorage.initialize(); + this._subStorageName = subStorageName; + this._elements = elements; + this._record = record; + this.localizeDocument(); + window.addEventListener("DOMContentLoaded", this, { once: true }); + } + + async init() { + this.updateSaveButtonState(); + this.attachEventListeners(); + // For testing only: signal to tests that the dialog is ready for testing. + // This is likely no longer needed since retrieving from storage is fully + // handled in manageDialog.js now. + window.dispatchEvent(new CustomEvent("FormReady")); + } + + /** + * Get storage and ensure it has been initialized. + * @returns {object} + */ + async getStorage() { + await this._storageInitPromise; + return formAutofillStorage[this._subStorageName]; + } + + /** + * Asks FormAutofillParent to save or update an record. + * @param {object} record + * @param {string} guid [optional] + */ + async saveRecord(record, guid) { + let storage = await this.getStorage(); + if (guid) { + await storage.update(guid, record); + } else { + await storage.add(record); + } + } + + /** + * Handle events + * + * @param {DOMEvent} event + */ + handleEvent(event) { + switch (event.type) { + case "DOMContentLoaded": { + this.init(); + break; + } + case "click": { + this.handleClick(event); + break; + } + case "input": { + this.handleInput(event); + break; + } + case "keypress": { + this.handleKeyPress(event); + break; + } + case "contextmenu": { + if ( + !(event.target instanceof HTMLInputElement) && + !(event.target instanceof HTMLTextAreaElement) + ) { + event.preventDefault(); + } + break; + } + } + } + + /** + * Handle click events + * + * @param {DOMEvent} event + */ + handleClick(event) { + if (event.target == this._elements.cancel) { + window.close(); + } + if (event.target == this._elements.save) { + this.handleSubmit(); + } + } + + /** + * Handle input events + * + * @param {DOMEvent} event + */ + handleInput(event) { + this.updateSaveButtonState(); + } + + /** + * Handle key press events + * + * @param {DOMEvent} event + */ + handleKeyPress(event) { + if (event.keyCode == KeyEvent.DOM_VK_ESCAPE) { + window.close(); + } + } + + updateSaveButtonState() { + // Toggle disabled attribute on the save button based on + // whether the form is filled or empty. + if (!Object.keys(this._elements.fieldContainer.buildFormObject()).length) { + this._elements.save.setAttribute("disabled", true); + } else { + this._elements.save.removeAttribute("disabled"); + } + } + + /** + * Attach event listener + */ + attachEventListeners() { + window.addEventListener("keypress", this); + window.addEventListener("contextmenu", this); + this._elements.controlsContainer.addEventListener("click", this); + document.addEventListener("input", this); + } + + // An interface to be inherited. + localizeDocument() {} +} + +class EditAddressDialog extends AutofillEditDialog { + constructor(elements, record) { + super("addresses", elements, record); + } + + localizeDocument() { + if (this._record?.guid) { + this._elements.title.dataset.localization = "editAddressTitle"; + } + } + + async handleSubmit() { + await this.saveRecord( + this._elements.fieldContainer.buildFormObject(), + this._record ? this._record.guid : null + ); + window.close(); + } +} + +class EditCreditCardDialog extends AutofillEditDialog { + constructor(elements, record) { + elements.fieldContainer._elements.billingAddress.disabled = true; + super("creditCards", elements, record); + elements.fieldContainer._elements.ccNumber.addEventListener( + "blur", + this._onCCNumberFieldBlur.bind(this) + ); + if (record) { + Services.telemetry.recordEvent("creditcard", "show_entry", "manage"); + } + } + + _onCCNumberFieldBlur() { + let elem = this._elements.fieldContainer._elements.ccNumber; + this._elements.fieldContainer.updateCustomValidity(elem); + } + + localizeDocument() { + if (this._record?.guid) { + this._elements.title.dataset.localization = "editCreditCardTitle"; + } + } + + async handleSubmit() { + let creditCard = this._elements.fieldContainer.buildFormObject(); + if (!this._elements.fieldContainer._elements.form.reportValidity()) { + return; + } + + try { + await this.saveRecord( + creditCard, + this._record ? this._record.guid : null + ); + + if (this._record?.guid) { + Services.telemetry.recordEvent("creditcard", "edit", "manage"); + } else { + Services.telemetry.recordEvent("creditcard", "add", "manage"); + } + + window.close(); + } catch (ex) { + Cu.reportError(ex); + } + } +} diff --git a/browser/extensions/formautofill/content/formautofill.css b/browser/extensions/formautofill/content/formautofill.css new file mode 100644 index 0000000000..550f3fdc08 --- /dev/null +++ b/browser/extensions/formautofill/content/formautofill.css @@ -0,0 +1,53 @@ +/* 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/. */ + +#PopupAutoComplete > richlistbox > richlistitem[originaltype="autofill-profile"], +#PopupAutoComplete > richlistbox > richlistitem[originaltype="autofill-footer"], +#PopupAutoComplete > richlistbox > richlistitem[originaltype="autofill-insecureWarning"], +#PopupAutoComplete > richlistbox > richlistitem[originaltype="autofill-clear-button"] { + display: block; + margin: 0; + padding: 0; + height: auto; + min-height: auto; +} + +/* Treat @collpased="true" as display: none similar to how it is for XUL elements. + * https://developer.mozilla.org/en-US/docs/Web/CSS/visibility#Values */ +#PopupAutoComplete > richlistbox > richlistitem[originaltype="autofill-profile"][collapsed="true"], +#PopupAutoComplete > richlistbox > richlistitem[originaltype="autofill-footer"][collapsed="true"], +#PopupAutoComplete > richlistbox > richlistitem[originaltype="autofill-insecureWarning"][collapsed="true"], +#PopupAutoComplete > richlistbox > richlistitem[originaltype="autofill-clear-button"][collapsed="true"] { + display: none; +} + +#PopupAutoComplete[resultstyles~="autofill-profile"] { + min-width: 150px !important; +} + +#PopupAutoComplete[resultstyles~="autofill-insecureWarning"] { + min-width: 200px !important; +} + +#PopupAutoComplete > richlistbox > richlistitem[disabled="true"] { + opacity: 0.5; +} + +/* Form Autofill Doorhanger */ +#autofill-address-notification popupnotificationcontent > .desc-message-box, +#autofill-credit-card-notification popupnotificationcontent > .desc-message-box { + margin-block-end: 12px; +} +#autofill-credit-card-notification popupnotificationcontent > .desc-message-box > image { + -moz-context-properties: fill; + fill: currentColor; + margin-inline-start: 6px; + width: 16px; + height: 16px; + list-style-image: url(chrome://formautofill/content/icon-credit-card-generic.svg); +} +#autofill-address-notification popupnotificationcontent > .desc-message-box > description, +#autofill-credit-card-notification popupnotificationcontent > .desc-message-box > description { + font-style: italic; +} diff --git a/browser/extensions/formautofill/content/formfill-anchor.svg b/browser/extensions/formautofill/content/formfill-anchor.svg new file mode 100644 index 0000000000..0a9ef19add --- /dev/null +++ b/browser/extensions/formautofill/content/formfill-anchor.svg @@ -0,0 +1,8 @@ +<!-- 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/. --> +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16" fill="context-fill" fill-opacity="context-fill-opacity"> + <path d="M7.3 6h1.5c.1 0 .2-.1.2-.3V2c0-.5-.4-1-1-1s-1 .4-1 1v3.8c0 .1.1.2.3.2z"/> + <path d="M13.5 3H11c-.6 0-1 .4-1 1s.4 1 1 1h2.5c.3 0 .5.2.5.5v7c0 .3-.2.5-.5.5h-11c-.3 0-.5-.3-.5-.5v-7c0-.3.2-.5.5-.5H5c.6 0 1-.4 1-1s-.4-1-1-1H2.5C1.1 3 0 4.1 0 5.5v7C0 13.8 1.1 15 2.5 15h11c1.4 0 2.5-1.1 2.5-2.5v-7C16 4.1 14.9 3 13.5 3z"/> + <path d="M3.6 7h2.8c.3 0 .6.2.6.5v2.8c0 .4-.3.7-.6.7H3.6c-.3 0-.6-.3-.6-.6V7.5c0-.3.3-.5.6-.5zM9.5 8h3c.3 0 .5-.3.5-.5s-.2-.5-.5-.5h-3c-.3 0-.5.2-.5.5s.2.5.5.5zM9.5 9c-.3 0-.5.2-.5.5s.2.5.5.5h2c.3 0 .5-.2.5-.5s-.2-.5-.5-.5h-2z"/> +</svg> diff --git a/browser/extensions/formautofill/content/heuristicsRegexp.js b/browser/extensions/formautofill/content/heuristicsRegexp.js new file mode 100644 index 0000000000..7ca484b934 --- /dev/null +++ b/browser/extensions/formautofill/content/heuristicsRegexp.js @@ -0,0 +1,583 @@ +/* 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/. */ + +/* + * Form Autofill field Heuristics RegExp. + */ + +/* exported HeuristicsRegExp */ + +"use strict"; + +var HeuristicsRegExp = { + RULES: { + email: undefined, + tel: undefined, + organization: undefined, + "street-address": undefined, + "address-line1": undefined, + "address-line2": undefined, + "address-line3": undefined, + "address-level2": undefined, + "address-level1": undefined, + "postal-code": undefined, + country: undefined, + // Note: We place the `cc-name` field for Credit Card first, because + // it is more specific than the `name` field below and we want to check + // for it before we catch the more generic one. + "cc-name": undefined, + name: undefined, + "given-name": undefined, + "additional-name": undefined, + "family-name": undefined, + "cc-number": undefined, + "cc-exp-month": undefined, + "cc-exp-year": undefined, + "cc-exp": undefined, + "cc-type": undefined, + }, + + RULE_SETS: [ + //========================================================================= + // Firefox-specific rules + { + "address-line1": "addrline1|address_1", + "address-line2": "addrline2|address_2", + "address-line3": "addrline3|address_3", + "address-level1": "land", // de-DE + "additional-name": "apellido.?materno|lastlastname", + "cc-number": "(cc|kk)nr", // de-DE + "cc-exp-month": "(cc|kk)month", // de-DE + "cc-exp-year": "(cc|kk)year", // de-DE + "cc-type": "type", + }, + + //========================================================================= + // These are the rules used by Bitwarden [0], converted into RegExp form. + // [0] https://github.com/bitwarden/browser/blob/c2b8802201fac5e292d55d5caf3f1f78088d823c/src/services/autofill.service.ts#L436 + { + email: "(^e-?mail$)|(^email-?address$)", + + tel: + "(^phone$)" + + "|(^mobile$)" + + "|(^mobile-?phone$)" + + "|(^tel$)" + + "|(^telephone$)" + + "|(^phone-?number$)", + + organization: + "(^company$)" + + "|(^company-?name$)" + + "|(^organization$)" + + "|(^organization-?name$)", + + "street-address": + "(^address$)" + + "|(^street-?address$)" + + "|(^addr$)" + + "|(^street$)" + + "|(^mailing-?addr(ess)?$)" + // Modified to not grab lines, below + "|(^billing-?addr(ess)?$)" + // Modified to not grab lines, below + "|(^mail-?addr(ess)?$)" + // Modified to not grab lines, below + "|(^bill-?addr(ess)?$)", // Modified to not grab lines, below + + "address-line1": + "(^address-?1$)" + + "|(^address-?line-?1$)" + + "|(^addr-?1$)" + + "|(^street-?1$)", + + "address-line2": + "(^address-?2$)" + + "|(^address-?line-?2$)" + + "|(^addr-?2$)" + + "|(^street-?2$)", + + "address-line3": + "(^address-?3$)" + + "|(^address-?line-?3$)" + + "|(^addr-?3$)" + + "|(^street-?3$)", + + "address-level2": + "(^city$)" + + "|(^town$)" + + "|(^address-?level-?2$)" + + "|(^address-?city$)" + + "|(^address-?town$)", + + "address-level1": + "(^state$)" + + "|(^province$)" + + "|(^provence$)" + + "|(^address-?level-?1$)" + + "|(^address-?state$)" + + "|(^address-?province$)", + + "postal-code": + "(^postal$)" + + "|(^zip$)" + + "|(^zip2$)" + + "|(^zip-?code$)" + + "|(^postal-?code$)" + + "|(^post-?code$)" + + "|(^address-?zip$)" + + "|(^address-?postal$)" + + "|(^address-?code$)" + + "|(^address-?postal-?code$)" + + "|(^address-?zip-?code$)", + + country: + "(^country$)" + + "|(^country-?code$)" + + "|(^country-?name$)" + + "|(^address-?country$)" + + "|(^address-?country-?name$)" + + "|(^address-?country-?code$)", + + name: "(^name$)|full-?name|your-?name", + + "given-name": + "(^f-?name$)" + + "|(^first-?name$)" + + "|(^given-?name$)" + + "|(^first-?n$)", + + "additional-name": + "(^m-?name$)" + + "|(^middle-?name$)" + + "|(^additional-?name$)" + + "|(^middle-?initial$)" + + "|(^middle-?n$)" + + "|(^middle-?i$)", + + "family-name": + "(^l-?name$)" + + "|(^last-?name$)" + + "|(^s-?name$)" + + "|(^surname$)" + + "|(^family-?name$)" + + "|(^family-?n$)" + + "|(^last-?n$)", + + "cc-name": + "cc-?name" + + "|card-?name" + + "|cardholder-?name" + + "|cardholder" + + // "|(^name$)" + // Removed to avoid overwriting "name", above. + "|(^nom$)", + + "cc-number": + "cc-?number" + + "|cc-?num" + + "|card-?number" + + "|card-?num" + + "|(^number$)" + + "|(^cc$)" + + "|cc-?no" + + "|card-?no" + + "|(^credit-?card$)" + + "|numero-?carte" + + "|(^carte$)" + + "|(^carte-?credit$)" + + "|num-?carte" + + "|cb-?num", + + "cc-exp": + "(^cc-?exp$)" + + "|(^card-?exp$)" + + "|(^cc-?expiration$)" + + "|(^card-?expiration$)" + + "|(^cc-?ex$)" + + "|(^card-?ex$)" + + "|(^card-?expire$)" + + "|(^card-?expiry$)" + + "|(^validite$)" + + "|(^expiration$)" + + "|(^expiry$)" + + "|mm-?yy" + + "|mm-?yyyy" + + "|yy-?mm" + + "|yyyy-?mm" + + "|expiration-?date" + + "|payment-?card-?expiration" + + "|(^payment-?cc-?date$)", + + "cc-exp-month": + "(^exp-?month$)" + + "|(^cc-?exp-?month$)" + + "|(^cc-?month$)" + + "|(^card-?month$)" + + "|(^cc-?mo$)" + + "|(^card-?mo$)" + + "|(^exp-?mo$)" + + "|(^card-?exp-?mo$)" + + "|(^cc-?exp-?mo$)" + + "|(^card-?expiration-?month$)" + + "|(^expiration-?month$)" + + "|(^cc-?mm$)" + + "|(^cc-?m$)" + + "|(^card-?mm$)" + + "|(^card-?m$)" + + "|(^card-?exp-?mm$)" + + "|(^cc-?exp-?mm$)" + + "|(^exp-?mm$)" + + "|(^exp-?m$)" + + "|(^expire-?month$)" + + "|(^expire-?mo$)" + + "|(^expiry-?month$)" + + "|(^expiry-?mo$)" + + "|(^card-?expire-?month$)" + + "|(^card-?expire-?mo$)" + + "|(^card-?expiry-?month$)" + + "|(^card-?expiry-?mo$)" + + "|(^mois-?validite$)" + + "|(^mois-?expiration$)" + + "|(^m-?validite$)" + + "|(^m-?expiration$)" + + "|(^expiry-?date-?field-?month$)" + + "|(^expiration-?date-?month$)" + + "|(^expiration-?date-?mm$)" + + "|(^exp-?mon$)" + + "|(^validity-?mo$)" + + "|(^exp-?date-?mo$)" + + "|(^cb-?date-?mois$)" + + "|(^date-?m$)", + + "cc-exp-year": + "(^exp-?year$)" + + "|(^cc-?exp-?year$)" + + "|(^cc-?year$)" + + "|(^card-?year$)" + + "|(^cc-?yr$)" + + "|(^card-?yr$)" + + "|(^exp-?yr$)" + + "|(^card-?exp-?yr$)" + + "|(^cc-?exp-?yr$)" + + "|(^card-?expiration-?year$)" + + "|(^expiration-?year$)" + + "|(^cc-?yy$)" + + "|(^cc-?y$)" + + "|(^card-?yy$)" + + "|(^card-?y$)" + + "|(^card-?exp-?yy$)" + + "|(^cc-?exp-?yy$)" + + "|(^exp-?yy$)" + + "|(^exp-?y$)" + + "|(^cc-?yyyy$)" + + "|(^card-?yyyy$)" + + "|(^card-?exp-?yyyy$)" + + "|(^cc-?exp-?yyyy$)" + + "|(^expire-?year$)" + + "|(^expire-?yr$)" + + "|(^expiry-?year$)" + + "|(^expiry-?yr$)" + + "|(^card-?expire-?year$)" + + "|(^card-?expire-?yr$)" + + "|(^card-?expiry-?year$)" + + "|(^card-?expiry-?yr$)" + + "|(^an-?validite$)" + + "|(^an-?expiration$)" + + "|(^annee-?validite$)" + + "|(^annee-?expiration$)" + + "|(^expiry-?date-?field-?year$)" + + "|(^expiration-?date-?year$)" + + "|(^cb-?date-?ann$)" + + "|(^expiration-?date-?yy$)" + + "|(^expiration-?date-?yyyy$)" + + "|(^validity-?year$)" + + "|(^exp-?date-?year$)" + + "|(^date-?y$)", + + "cc-type": + "(^cc-?type$)" + + "|(^card-?type$)" + + "|(^card-?brand$)" + + "|(^cc-?brand$)" + + "|(^cb-?type$)", + }, + + //========================================================================= + // These rules are from Chromium source codes [1]. Most of them + // converted to JS format have the same meaning with the original ones except + // the first line of "address-level1". + // [1] https://source.chromium.org/chromium/chromium/src/+/master:components/autofill/core/common/autofill_regex_constants.cc + { + // ==== Email ==== + email: + "e.?mail" + + "|courriel" + // fr + "|correo.*electr(o|ó)nico" + // es-ES + "|メールアドレス" + // ja-JP + "|Электронной.?Почты" + // ru + "|邮件|邮箱" + // zh-CN + "|電郵地址" + // zh-TW + "|ഇ-മെയില്|ഇലക്ട്രോണിക്.?" + + "മെയിൽ" + // ml + "|ایمیل|پست.*الکترونیک" + // fa + "|ईमेल|इलॅक्ट्रॉनिक.?मेल" + // hi + "|(\\b|_)eposta(\\b|_)" + // tr + "|(?:이메일|전자.?우편|[Ee]-?mail)(.?주소)?", // ko-KR + + // ==== Telephone ==== + tel: + "phone|mobile|contact.?number" + + "|telefonnummer" + // de-DE + "|telefono|teléfono" + // es + "|telfixe" + // fr-FR + "|電話" + // ja-JP + "|telefone|telemovel" + // pt-BR, pt-PT + "|телефон" + // ru + "|मोबाइल" + // hi for mobile + "|(\\b|_|\\*)telefon(\\b|_|\\*)" + // tr + "|电话" + // zh-CN + "|മൊബൈല്" + // ml for mobile + "|(?:전화|핸드폰|휴대폰|휴대전화)(?:.?번호)?", // ko-KR + + // ==== Address Fields ==== + organization: + "company|business|organization|organisation" + + "|(?<!con)firma|firmenname" + // de-DE + "|empresa" + // es + "|societe|société" + // fr-FR + "|ragione.?sociale" + // it-IT + "|会社" + // ja-JP + "|название.?компании" + // ru + "|单位|公司" + // zh-CN + "|شرکت" + // fa + "|회사|직장", // ko-KR + + "street-address": "streetaddress|street-address", + + "address-line1": + "^address$|address[_-]?line(one)?|address1|addr1|street" + + "|(?:shipping|billing)address$" + + "|strasse|straße|hausnummer|housenumber" + // de-DE + "|house.?name" + // en-GB + "|direccion|dirección" + // es + "|adresse" + // fr-FR + "|indirizzo" + // it-IT + "|^住所$|住所1" + // ja-JP + "|morada|((?<!identificação do )endereço)" + // pt-BR, pt-PT + "|Адрес" + // ru + "|地址" + // zh-CN + "|(\\b|_)adres(?! (başlığı(nız)?|tarifi))(\\b|_)" + // tr + "|^주소.?$|주소.?1", // ko-KR + + "address-line2": + "address[_-]?line(2|two)|address2|addr2|street|suite|unit(?!e)" + // Firefox adds `(?!e)` to unit to skip `United State` + "|adresszusatz|ergänzende.?angaben" + // de-DE + "|direccion2|colonia|adicional" + // es + "|addresssuppl|complementnom|appartement" + // fr-FR + "|indirizzo2" + // it-IT + "|住所2" + // ja-JP + "|complemento|addrcomplement" + // pt-BR, pt-PT + "|Улица" + // ru + "|地址2" + // zh-CN + "|주소.?2", // ko-KR + + "address-line3": + "address[_-]?line(3|three)|address3|addr3|street|suite|unit(?!e)" + // Firefox adds `(?!e)` to unit to skip `United State` + "|adresszusatz|ergänzende.?angaben" + // de-DE + "|direccion3|colonia|adicional" + // es + "|addresssuppl|complementnom|appartement" + // fr-FR + "|indirizzo3" + // it-IT + "|住所3" + // ja-JP + "|complemento|addrcomplement" + // pt-BR, pt-PT + "|Улица" + // ru + "|地址3" + // zh-CN + "|주소.?3", // ko-KR + + "address-level2": + "city|town" + + "|\\bort\\b|stadt" + // de-DE + "|suburb" + // en-AU + "|ciudad|provincia|localidad|poblacion" + // es + "|ville|commune" + // fr-FR + "|localita" + // it-IT + "|市区町村" + // ja-JP + "|cidade" + // pt-BR, pt-PT + "|Город" + // ru + "|市" + // zh-CN + "|分區" + // zh-TW + "|شهر" + // fa + "|शहर" + // hi for city + "|ग्राम|गाँव" + // hi for village + "|നഗരം|ഗ്രാമം" + // ml for town|village + "|((\\b|_|\\*)([İii̇]l[cç]e(miz|niz)?)(\\b|_|\\*))" + // tr + "|^시[^도·・]|시[·・]?군[·・]?구", // ko-KR + + "address-level1": + "(?<!(united|hist|history).?)state|county|region|province" + + "|county|principality" + // en-UK + "|都道府県" + // ja-JP + "|estado|provincia" + // pt-BR, pt-PT + "|область" + // ru + "|省" + // zh-CN + "|地區" + // zh-TW + "|സംസ്ഥാനം" + // ml + "|استان" + // fa + "|राज्य" + // hi + "|((\\b|_|\\*)(eyalet|[şs]ehir|[İii̇]l(imiz)?|kent)(\\b|_|\\*))" + // tr + "|^시[·・]?도", // ko-KR + + "postal-code": + "zip|postal|post.*code|pcode" + + "|pin.?code" + // en-IN + "|postleitzahl" + // de-DE + "|\\bcp\\b" + // es + "|\\bcdp\\b" + // fr-FR + "|\\bcap\\b" + // it-IT + "|郵便番号" + // ja-JP + "|codigo|codpos|\\bcep\\b" + // pt-BR, pt-PT + "|Почтовый.?Индекс" + // ru + "|पिन.?कोड" + // hi + "|പിന്കോഡ്" + // ml + "|邮政编码|邮编" + // zh-CN + "|郵遞區號" + // zh-TW + "|(\\b|_)posta kodu(\\b|_)" + // tr + "|우편.?번호", // ko-KR + + country: + "country|countries" + + "|país|pais" + // es + "|(\\b|_)land(\\b|_)(?!.*(mark.*))" + // de-DE landmark is a type in india. + "|(?<!(入|出))国" + // ja-JP + "|国家" + // zh-CN + "|국가|나라" + // ko-KR + "|(\\b|_)(ülke|ulce|ulke)(\\b|_)" + // tr + "|کشور", // fa + + // ==== Name Fields ==== + "cc-name": + "card.?(?:holder|owner)|name.*(\\b)?on(\\b)?.*card" + + "|(?:card|cc).?name|cc.?full.?name" + + "|karteninhaber" + // de-DE + "|nombre.*tarjeta" + // es + "|nom.*carte" + // fr-FR + "|nome.*cart" + // it-IT + "|名前" + // ja-JP + "|Имя.*карты" + // ru + "|信用卡开户名|开户名|持卡人姓名" + // zh-CN + "|持卡人姓名", // zh-TW + + name: + "^name|full.?name|your.?name|customer.?name|bill.?name|ship.?name" + + "|name.*first.*last|firstandlastname" + + "|nombre.*y.*apellidos" + // es + "|^nom(?!bre)" + // fr-FR + "|お名前|氏名" + // ja-JP + "|^nome" + // pt-BR, pt-PT + "|نام.*نام.*خانوادگی" + // fa + "|姓名" + // zh-CN + "|(\\b|_|\\*)ad[ı]? soyad[ı]?(\\b|_|\\*)" + // tr + "|성명", // ko-KR + + "given-name": + "first.*name|initials|fname|first$|given.*name" + + "|vorname" + // de-DE + "|nombre" + // es + "|forename|prénom|prenom" + // fr-FR + "|名" + // ja-JP + "|nome" + // pt-BR, pt-PT + "|Имя" + // ru + "|نام" + // fa + "|이름" + // ko-KR + "|പേര്" + // ml + "|(\\b|_|\\*)(isim|ad|ad(i|ı|iniz|ınız)?)(\\b|_|\\*)" + // tr + "|नाम", // hi + + "additional-name": + "middle.*name|mname|middle$|middle.*initial|m\\.i\\.|mi$|\\bmi\\b", + + "family-name": + "last.*name|lname|surname|last$|secondname|family.*name" + + "|nachname" + // de-DE + "|apellidos?" + // es + "|famille|^nom(?!bre)" + // fr-FR + "|cognome" + // it-IT + "|姓" + // ja-JP + "|apelidos|surename|sobrenome" + // pt-BR, pt-PT + "|Фамилия" + // ru + "|نام.*خانوادگی" + // fa + "|उपनाम" + // hi + "|മറുപേര്" + // ml + "|(\\b|_|\\*)(soyisim|soyad(i|ı|iniz|ınız)?)(\\b|_|\\*)" + // tr + "|\\b성(?:[^명]|\\b)", // ko-KR + + // ==== Credit Card Fields ==== + // Note: `cc-name` expression has been moved up, above `name`, in + // order to handle specialization through ordering. + "cc-number": + "(add)?(?:card|cc|acct).?(?:number|#|no|num|field)" + + "|(?<!telefon|haus|person|fødsels)nummer" + // de-DE, sv-SE, no + "|カード番号" + // ja-JP + "|Номер.*карты" + // ru + "|信用卡号|信用卡号码" + // zh-CN + "|信用卡卡號" + // zh-TW + "|카드" + // ko-KR + // es/pt/fr + "|(numero|número|numéro)(?!.*(document|fono|phone|réservation))", + + "cc-exp-month": + "expir|exp.*mo|exp.*date|ccmonth|cardmonth|addmonth" + + "|gueltig|gültig|monat" + // de-DE + "|fecha" + // es + "|date.*exp" + // fr-FR + "|scadenza" + // it-IT + "|有効期限" + // ja-JP + "|validade" + // pt-BR, pt-PT + "|Срок действия карты" + // ru + "|月", // zh-CN + + "cc-exp-year": + "exp|^/|(add)?year" + + "|ablaufdatum|gueltig|gültig|jahr" + // de-DE + "|fecha" + // es + "|scadenza" + // it-IT + "|有効期限" + // ja-JP + "|validade" + // pt-BR, pt-PT + "|Срок действия карты" + // ru + "|年|有效期", // zh-CN + + "cc-exp": + "expir|exp.*date|^expfield$" + + "|gueltig|gültig" + // de-DE + "|fecha" + // es + "|date.*exp" + // fr-FR + "|scadenza" + // it-IT + "|有効期限" + // ja-JP + "|validade" + // pt-BR, pt-PT + "|Срок действия карты", // ru + }, + ], + + _getRule(name) { + let rules = []; + this.RULE_SETS.forEach(set => { + if (set[name]) { + rules.push(`(${set[name]})`.normalize("NFKC")); + } + }); + + const value = new RegExp(rules.join("|"), "iu"); + Object.defineProperty(this.RULES, name, { get: undefined }); + Object.defineProperty(this.RULES, name, { value }); + return value; + }, + + init() { + Object.keys(this.RULES).forEach(field => + Object.defineProperty(this.RULES, field, { + get() { + return HeuristicsRegExp._getRule(field); + }, + }) + ); + }, +}; + +HeuristicsRegExp.init(); diff --git a/browser/extensions/formautofill/content/icon-address-save.svg b/browser/extensions/formautofill/content/icon-address-save.svg new file mode 100644 index 0000000000..8fdcf1cd5f --- /dev/null +++ b/browser/extensions/formautofill/content/icon-address-save.svg @@ -0,0 +1,6 @@ +<!-- 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/. --> +<svg xmlns="http://www.w3.org/2000/svg" fill="context-fill" fill-opacity="context-fill-opacity" viewBox="0 0 32 32"> + <path d="M22 13.7H9.4c-.6 0-1.2.5-1.2 1.2 0 .6.5 1.2 1.2 1.2H22c.6 0 1.2-.5 1.2-1.2s-.6-1.2-1.2-1.2zM6.1 26.6V5.5c0-.8.7-1.5 1.5-1.5h16c.9 0 1.5.6 1.5 1.5V16h2V3.8c0-1-.7-1.8-1.8-1.8H5.9c-1 0-1.8.8-1.8 1.8v24.5c0 1 .8 1.7 1.8 1.7h9.3v-2H7.6c-.8 0-1.5-.6-1.5-1.4zm21.1-1.9h-2.5V20c0-.4-.3-.8-.8-.8h-3.1c-.4 0-.8.3-.8.8v4.6h-2.5c-.6 0-.8.4-.3.8l4.3 4.2c.2.2.5.3.8.3s.6-.1.8-.3l4.3-4.2c.6-.4.4-.7-.2-.7zm-11.3-5.6H9.4c-.6 0-1.2.5-1.2 1.2s.5 1.2 1.2 1.2h6.5c.6 0 1.2-.5 1.2-1.2s-.6-1.2-1.2-1.2zM22 7.8H9.4c-.6 0-1.2.5-1.2 1.2s.5 1.2 1.2 1.2H22c.6 0 1.2-.5 1.2-1.2s-.6-1.2-1.2-1.2z"/> +</svg> diff --git a/browser/extensions/formautofill/content/icon-address-update.svg b/browser/extensions/formautofill/content/icon-address-update.svg new file mode 100644 index 0000000000..1455423fed --- /dev/null +++ b/browser/extensions/formautofill/content/icon-address-update.svg @@ -0,0 +1,6 @@ +<!-- 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/. --> +<svg xmlns="http://www.w3.org/2000/svg" fill="context-fill" fill-opacity="context-fill-opacity" viewBox="0 0 32 32"> + <path d="M22 13.7H9.4c-.6 0-1.2.5-1.2 1.2 0 .6.5 1.2 1.2 1.2H22c.6 0 1.2-.5 1.2-1.2s-.6-1.2-1.2-1.2zM6.1 26.6V5.5c0-.8.7-1.5 1.5-1.5h16c.9 0 1.5.6 1.5 1.5V16h2V3.8c0-1-.7-1.8-1.8-1.8H5.9c-1 0-1.8.8-1.8 1.8v24.5c0 1 .8 1.7 1.8 1.7h9.3v-2H7.6c-.8 0-1.5-.6-1.5-1.4zm9.8-7.5H9.4c-.6 0-1.2.5-1.2 1.2s.5 1.2 1.2 1.2h6.5c.6 0 1.2-.5 1.2-1.2s-.6-1.2-1.2-1.2zM22 7.8H9.4c-.6 0-1.2.5-1.2 1.2s.5 1.2 1.2 1.2H22c.6 0 1.2-.5 1.2-1.2s-.6-1.2-1.2-1.2zm-5.7 16l4.4-4.3c.2-.2.5-.3.8-.3s.6.1.8.3l4.4 4.3c.5.5.3.8-.3.8h-2.6v4.7c0 .4-.4.8-.8.8h-3c-.4 0-.8-.4-.8-.8v-4.7h-2.5c-.7 0-.8-.4-.4-.8z"/> +</svg> diff --git a/browser/extensions/formautofill/content/icon-credit-card-generic.svg b/browser/extensions/formautofill/content/icon-credit-card-generic.svg new file mode 100644 index 0000000000..e1321330b3 --- /dev/null +++ b/browser/extensions/formautofill/content/icon-credit-card-generic.svg @@ -0,0 +1,8 @@ +<!-- 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/. --> +<svg xmlns="http://www.w3.org/2000/svg" fill="context-fill" viewBox="0 0 16 16"> + <path d="M4.5,9.4H3.2c-0.3,0-0.5,0.2-0.5,0.5s0.2,0.5,0.5,0.5h1.3c0.3,0,0.5-0.2,0.5-0.5S4.8,9.4,4.5,9.4z"/> + <path d="M9.3,9.4H6.2c-0.3,0-0.5,0.2-0.5,0.5s0.2,0.5,0.5,0.5h3.2c0.3,0,0.5-0.2,0.5-0.5S9.6,9.4,9.3,9.4z"/> + <path d="M14,2H2C0.9,2,0,2.9,0,4v8c0,1.1,0.9,2,2,2h12c1.1,0,2-0.9,2-2V4C16,2.9,15.1,2,14,2z M14,12H2V7.7h12V12z M14,6H2V4h12V6z"/> +</svg> diff --git a/browser/extensions/formautofill/content/icon-credit-card.svg b/browser/extensions/formautofill/content/icon-credit-card.svg new file mode 100644 index 0000000000..7ec782f880 --- /dev/null +++ b/browser/extensions/formautofill/content/icon-credit-card.svg @@ -0,0 +1,8 @@ +<!-- 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/. --> +<svg xmlns="http://www.w3.org/2000/svg" fill="context-fill" fill-opacity="context-fill-opacity" viewBox="0 0 32 32"> + <path d="M9 22.2H6.4c-.6 0-1 .4-1 1s.4 1 1 1H9c.6 0 1-.4 1-1s-.4-1-1-1z"/> + <path d="M28 7.6v8H4v-4h10v-4H4c-2.2 0-4 1.8-4 4v16c0 2.2 1.8 4 4 4h24c2.2 0 4-1.8 4-4v-16c0-2.2-1.8-4-4-4zm-24 20V19h24v8.6H4z"/> + <path d="M19.2 22.2h-6.3c-.6 0-1 .4-1 1s.4 1 1 1h6.3c.6 0 1-.4 1-1s-.5-1-1-1zM16.3 7.9c-.4.4-.4 1 0 1.4l4 4c.4.4 1 .4 1.4 0l4-4c.4-.4.4-1 0-1.4s-1-.4-1.4 0L22 10.2v-9c0-.5-.4-1-1-1-.5 0-1 .4-1 1v9l-2.3-2.3c-.4-.4-1-.4-1.4 0z"/> +</svg> diff --git a/browser/extensions/formautofill/content/l10n.js b/browser/extensions/formautofill/content/l10n.js new file mode 100644 index 0000000000..85346d41ee --- /dev/null +++ b/browser/extensions/formautofill/content/l10n.js @@ -0,0 +1,61 @@ +/* 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"; + +/** + * This file will be replaced by Fluent but it's a middle ground so we can share + * the edit dialog code with the unprivileged PaymentRequest dialog before the + * Fluent conversion + */ + +const { FormAutofillUtils } = ChromeUtils.import( + "resource://formautofill/FormAutofillUtils.jsm" +); + +const CONTENT_WIN = typeof window != "undefined" ? window : this; + +const L10N_ATTRIBUTES = ["data-localization", "data-localization-region"]; + +// eslint-disable-next-line mozilla/balanced-listeners +CONTENT_WIN.addEventListener("DOMContentLoaded", function onDCL(evt) { + let doc = evt.target; + FormAutofillUtils.localizeMarkup(doc); + + let mutationObserver = new doc.ownerGlobal.MutationObserver( + function onMutation(mutations) { + for (let mutation of mutations) { + switch (mutation.type) { + case "attributes": { + if (!mutation.target.hasAttribute(mutation.attributeName)) { + // The attribute was removed in the meantime. + continue; + } + FormAutofillUtils.localizeAttributeForElement( + mutation.target, + mutation.attributeName + ); + break; + } + + case "childList": { + // We really only care about elements appending inside pages. + if (!mutation.addedNodes || !mutation.target.closest(".page")) { + break; + } + FormAutofillUtils.localizeMarkup(mutation.target); + break; + } + } + } + } + ); + + mutationObserver.observe(doc, { + attributes: true, + attributeFilter: L10N_ATTRIBUTES, + childList: true, + subtree: true, + }); +}); diff --git a/browser/extensions/formautofill/content/manageAddresses.xhtml b/browser/extensions/formautofill/content/manageAddresses.xhtml new file mode 100644 index 0000000000..cf48b6b584 --- /dev/null +++ b/browser/extensions/formautofill/content/manageAddresses.xhtml @@ -0,0 +1,41 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- 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/. --> +<!DOCTYPE html [ + <!ENTITY % globalDTD SYSTEM "chrome://global/locale/global.dtd"> + %globalDTD; +]> +<html xmlns="http://www.w3.org/1999/xhtml"> +<head> + <title data-localization="manageAddressesTitle"/> + <link rel="stylesheet" href="chrome://global/skin/in-content/common.css" /> + <link rel="stylesheet" href="chrome://formautofill/content/manageDialog.css" /> + <script src="chrome://formautofill/content/manageDialog.js"></script> +</head> +<body dir="&locale.dir;"> + <fieldset> + <legend data-localization="addressesListHeader"/> + <select id="addresses" size="9" multiple="multiple"/> + </fieldset> + <div id="controls-container"> + <button id="remove" disabled="disabled" data-localization="removeBtnLabel"/> + <!-- Wrapper is used to properly compute the search tooltip position --> + <div> + <button id="add" data-localization="addBtnLabel"/> + </div> + <button id="edit" disabled="disabled" data-localization="editBtnLabel"/> + </div> + <script> + "use strict"; + /* global ManageAddresses */ + new ManageAddresses({ + records: document.getElementById("addresses"), + controlsContainer: document.getElementById("controls-container"), + remove: document.getElementById("remove"), + add: document.getElementById("add"), + edit: document.getElementById("edit"), + }); + </script> +</body> +</html> diff --git a/browser/extensions/formautofill/content/manageCreditCards.xhtml b/browser/extensions/formautofill/content/manageCreditCards.xhtml new file mode 100644 index 0000000000..35754af6e7 --- /dev/null +++ b/browser/extensions/formautofill/content/manageCreditCards.xhtml @@ -0,0 +1,42 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- 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/. --> +<!DOCTYPE html [ + <!ENTITY % globalDTD SYSTEM "chrome://global/locale/global.dtd"> + %globalDTD; +]> +<html xmlns="http://www.w3.org/1999/xhtml"> +<head> + <title data-localization="manageCreditCardsTitle"/> + <link rel="localization" href="toolkit/payments/payments.ftl"/> + <link rel="stylesheet" href="chrome://global/skin/in-content/common.css" /> + <link rel="stylesheet" href="chrome://formautofill/content/manageDialog.css" /> + <script src="chrome://formautofill/content/manageDialog.js"></script> +</head> +<body dir="&locale.dir;"> + <fieldset> + <legend data-localization="creditCardsListHeader"/> + <select id="credit-cards" size="9" multiple="multiple"/> + </fieldset> + <div id="controls-container"> + <button id="remove" disabled="disabled" data-localization="removeBtnLabel"/> + <!-- Wrapper is used to properly compute the search tooltip position --> + <div> + <button id="add" data-localization="addBtnLabel"/> + </div> + <button id="edit" disabled="disabled" data-localization="editBtnLabel"/> + </div> + <script> + "use strict"; + /* global ManageCreditCards */ + new ManageCreditCards({ + records: document.getElementById("credit-cards"), + controlsContainer: document.getElementById("controls-container"), + remove: document.getElementById("remove"), + add: document.getElementById("add"), + edit: document.getElementById("edit"), + }); + </script> +</body> +</html> diff --git a/browser/extensions/formautofill/content/manageDialog.css b/browser/extensions/formautofill/content/manageDialog.css new file mode 100644 index 0000000000..e89673dcbc --- /dev/null +++ b/browser/extensions/formautofill/content/manageDialog.css @@ -0,0 +1,126 @@ +/* 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/. */ + +html { + /* Prevent unnecessary horizontal scroll bar from showing */ + overflow-x: hidden; +} + +div { + display: flex; +} + +button { + padding-inline: 10px; +} + +fieldset { + margin: 0 4px; + padding: 0; + border: none; +} + +fieldset > legend { + box-sizing: border-box; + width: 100%; + padding: 0.4em 0.7em; + color: #808080; + background-color: var(--in-content-box-background-hover); + border: 1px solid var(--in-content-box-border-color); + border-radius: 2px 2px 0 0; + user-select: none; +} + +option:nth-child(even) { + background-color: var(--in-content-box-background-odd); +} + +#addresses, +#credit-cards { + width: 100%; + height: 16.6em; + margin: 0; + padding-inline: 0; + border-top: none; + border-radius: 0 0 2px 2px; +} + +#addresses > option, +#credit-cards > option { + display: flex; + align-items: center; + height: 1.6em; + padding-inline-start: 0.6em; +} + +#controls-container { + margin-top: 1em; +} + +#remove { + margin-inline-end: auto; +} + +#credit-cards > option::before { + content: ""; + background: url("icon-credit-card-generic.svg") no-repeat; + background-size: contain; + float: inline-start; + width: 16px; + height: 16px; + padding-inline-end: 10px; + -moz-context-properties: fill; + fill: currentColor; +} + +/* + We use .png / @2x.png images where we don't yet have a vector version of a logo +*/ +#credit-cards.branded > option[cc-type="amex"]::before { + background-image: url("third-party/cc-logo-amex.png"); +} + +#credit-cards.branded > option[cc-type="cartebancaire"]::before { + background-image: url("third-party/cc-logo-cartebancaire.png"); +} + +#credit-cards.branded > option[cc-type="diners"]::before { + background-image: url("third-party/cc-logo-diners.svg"); +} + +#credit-cards.branded > option[cc-type="discover"]::before { + background-image: url("third-party/cc-logo-discover.png"); +} + +#credit-cards.branded > option[cc-type="jcb"]::before { + background-image: url("third-party/cc-logo-jcb.svg"); +} + +#credit-cards.branded > option[cc-type="mastercard"]::before { + background-image: url("third-party/cc-logo-mastercard.svg"); +} + +#credit-cards.branded > option[cc-type="mir"]::before { + background-image: url("third-party/cc-logo-mir.svg"); +} + +#credit-cards.branded > option[cc-type="unionpay"]::before { + background-image: url("third-party/cc-logo-unionpay.svg"); +} + +#credit-cards.branded > option[cc-type="visa"]::before { + background-image: url("third-party/cc-logo-visa.svg"); +} + +@media (min-resolution: 1.1dppx) { + #credit-cards.branded > option[cc-type="amex"]::before { + background-image: url("third-party/cc-logo-amex@2x.png"); + } + #credit-cards.branded > option[cc-type="cartebancaire"]::before { + background-image: url("third-party/cc-logo-cartebancaire@2x.png"); + } + #credit-cards.branded > option[cc-type="discover"]::before { + background-image: url("third-party/cc-logo-discover@2x.png"); + } +} diff --git a/browser/extensions/formautofill/content/manageDialog.js b/browser/extensions/formautofill/content/manageDialog.js new file mode 100644 index 0000000000..7ecfe7683c --- /dev/null +++ b/browser/extensions/formautofill/content/manageDialog.js @@ -0,0 +1,486 @@ +/* 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/. */ + +/* exported ManageAddresses, ManageCreditCards */ + +"use strict"; + +const EDIT_ADDRESS_URL = "chrome://formautofill/content/editAddress.xhtml"; +const EDIT_CREDIT_CARD_URL = + "chrome://formautofill/content/editCreditCard.xhtml"; + +const { AppConstants } = ChromeUtils.import( + "resource://gre/modules/AppConstants.jsm" +); +const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); +const { FormAutofill } = ChromeUtils.import( + "resource://formautofill/FormAutofill.jsm" +); + +ChromeUtils.defineModuleGetter( + this, + "CreditCard", + "resource://gre/modules/CreditCard.jsm" +); +ChromeUtils.defineModuleGetter( + this, + "formAutofillStorage", + "resource://formautofill/FormAutofillStorage.jsm" +); +ChromeUtils.defineModuleGetter( + this, + "FormAutofillUtils", + "resource://formautofill/FormAutofillUtils.jsm" +); +ChromeUtils.defineModuleGetter( + this, + "OSKeyStore", + "resource://gre/modules/OSKeyStore.jsm" +); + +XPCOMUtils.defineLazyGetter(this, "reauthPasswordPromptMessage", () => { + const brandShortName = FormAutofillUtils.brandBundle.GetStringFromName( + "brandShortName" + ); + // The string name for Mac is changed because the value needed updating. + const platform = AppConstants.platform.replace("macosx", "macos"); + return FormAutofillUtils.stringBundle.formatStringFromName( + `editCreditCardPasswordPrompt.${platform}`, + [brandShortName] + ); +}); + +this.log = null; +FormAutofill.defineLazyLogGetter(this, "manageAddresses"); + +class ManageRecords { + constructor(subStorageName, elements) { + this._storageInitPromise = formAutofillStorage.initialize(); + this._subStorageName = subStorageName; + this._elements = elements; + this._newRequest = false; + this._isLoadingRecords = false; + this.prefWin = window.opener; + this.localizeDocument(); + window.addEventListener("DOMContentLoaded", this, { once: true }); + } + + async init() { + await this.loadRecords(); + this.attachEventListeners(); + // For testing only: Notify when the dialog is ready for interaction + window.dispatchEvent(new CustomEvent("FormReady")); + } + + uninit() { + log.debug("uninit"); + this.detachEventListeners(); + this._elements = null; + } + + localizeDocument() { + document.documentElement.style.minWidth = FormAutofillUtils.stringBundle.GetStringFromName( + "manageDialogsWidth" + ); + FormAutofillUtils.localizeMarkup(document); + } + + /** + * Get the selected options on the addresses element. + * + * @returns {array<DOMElement>} + */ + get _selectedOptions() { + return Array.from(this._elements.records.selectedOptions); + } + + /** + * Get storage and ensure it has been initialized. + * @returns {object} + */ + async getStorage() { + await this._storageInitPromise; + return formAutofillStorage[this._subStorageName]; + } + + /** + * Load records and render them. This function is a wrapper for _loadRecords + * to ensure any reentrant will be handled well. + */ + async loadRecords() { + // This function can be early returned when there is any reentrant happends. + // "_newRequest" needs to be set to ensure all changes will be applied. + if (this._isLoadingRecords) { + this._newRequest = true; + return; + } + this._isLoadingRecords = true; + + await this._loadRecords(); + + // _loadRecords should be invoked again if there is any multiple entrant + // during running _loadRecords(). This step ensures that the latest request + // still is applied. + while (this._newRequest) { + this._newRequest = false; + await this._loadRecords(); + } + this._isLoadingRecords = false; + + // For testing only: Notify when records are loaded + this._elements.records.dispatchEvent(new CustomEvent("RecordsLoaded")); + } + + async _loadRecords() { + let storage = await this.getStorage(); + let records = await storage.getAll(); + // Sort by last used time starting with most recent + records.sort((a, b) => { + let aLastUsed = a.timeLastUsed || a.timeLastModified; + let bLastUsed = b.timeLastUsed || b.timeLastModified; + return bLastUsed - aLastUsed; + }); + await this.renderRecordElements(records); + this.updateButtonsStates(this._selectedOptions.length); + } + + /** + * Render the records onto the page while maintaining selected options if + * they still exist. + * + * @param {array<object>} records + */ + async renderRecordElements(records) { + let selectedGuids = this._selectedOptions.map(option => option.value); + this.clearRecordElements(); + for (let record of records) { + let { id, args, raw } = this.getLabelInfo(record); + let option = new Option( + raw ?? "", + record.guid, + false, + selectedGuids.includes(record.guid) + ); + if (id) { + document.l10n.setAttributes(option, id, args); + } + + option.record = record; + this._elements.records.appendChild(option); + } + } + + /** + * Remove all existing record elements. + */ + clearRecordElements() { + let parent = this._elements.records; + while (parent.lastChild) { + parent.removeChild(parent.lastChild); + } + } + + /** + * Remove records by selected options. + * + * @param {array<DOMElement>} options + */ + async removeRecords(options) { + let storage = await this.getStorage(); + // Pause listening to storage change event to avoid triggering `loadRecords` + // when removing records + Services.obs.removeObserver(this, "formautofill-storage-changed"); + + for (let option of options) { + storage.remove(option.value); + option.remove(); + } + this.updateButtonsStates(this._selectedOptions); + + // Resume listening to storage change event + Services.obs.addObserver(this, "formautofill-storage-changed"); + // For testing only: notify record(s) has been removed + this._elements.records.dispatchEvent(new CustomEvent("RecordsRemoved")); + } + + /** + * Enable/disable the Edit and Remove buttons based on number of selected + * options. + * + * @param {number} selectedCount + */ + updateButtonsStates(selectedCount) { + log.debug("updateButtonsStates:", selectedCount); + if (selectedCount == 0) { + this._elements.edit.setAttribute("disabled", "disabled"); + this._elements.remove.setAttribute("disabled", "disabled"); + } else if (selectedCount == 1) { + this._elements.edit.removeAttribute("disabled"); + this._elements.remove.removeAttribute("disabled"); + } else if (selectedCount > 1) { + this._elements.edit.setAttribute("disabled", "disabled"); + this._elements.remove.removeAttribute("disabled"); + } + } + + /** + * Handle events + * + * @param {DOMEvent} event + */ + handleEvent(event) { + switch (event.type) { + case "DOMContentLoaded": { + this.init(); + break; + } + case "click": { + this.handleClick(event); + break; + } + case "change": { + this.updateButtonsStates(this._selectedOptions.length); + break; + } + case "unload": { + this.uninit(); + break; + } + case "keypress": { + this.handleKeyPress(event); + break; + } + case "contextmenu": { + event.preventDefault(); + break; + } + } + } + + /** + * Handle click events + * + * @param {DOMEvent} event + */ + handleClick(event) { + if (event.target == this._elements.remove) { + this.removeRecords(this._selectedOptions); + } else if (event.target == this._elements.add) { + this.openEditDialog(); + } else if ( + event.target == this._elements.edit || + (event.target.parentNode == this._elements.records && event.detail > 1) + ) { + this.openEditDialog(this._selectedOptions[0].record); + } + } + + /** + * Handle key press events + * + * @param {DOMEvent} event + */ + handleKeyPress(event) { + if (event.keyCode == KeyEvent.DOM_VK_ESCAPE) { + window.close(); + } + if (event.keyCode == KeyEvent.DOM_VK_DELETE) { + this.removeRecords(this._selectedOptions); + } + } + + observe(subject, topic, data) { + switch (topic) { + case "formautofill-storage-changed": { + this.loadRecords(); + } + } + } + + /** + * Attach event listener + */ + attachEventListeners() { + window.addEventListener("unload", this, { once: true }); + window.addEventListener("keypress", this); + window.addEventListener("contextmenu", this); + this._elements.records.addEventListener("change", this); + this._elements.records.addEventListener("click", this); + this._elements.controlsContainer.addEventListener("click", this); + Services.obs.addObserver(this, "formautofill-storage-changed"); + } + + /** + * Remove event listener + */ + detachEventListeners() { + window.removeEventListener("keypress", this); + window.removeEventListener("contextmenu", this); + this._elements.records.removeEventListener("change", this); + this._elements.records.removeEventListener("click", this); + this._elements.controlsContainer.removeEventListener("click", this); + Services.obs.removeObserver(this, "formautofill-storage-changed"); + } +} + +class ManageAddresses extends ManageRecords { + constructor(elements) { + super("addresses", elements); + elements.add.setAttribute( + "searchkeywords", + FormAutofillUtils.EDIT_ADDRESS_KEYWORDS.map(key => + FormAutofillUtils.stringBundle.GetStringFromName(key) + ).join("\n") + ); + } + + /** + * Open the edit address dialog to create/edit an address. + * + * @param {object} address [optional] + */ + openEditDialog(address) { + this.prefWin.gSubDialog.open(EDIT_ADDRESS_URL, undefined, { + record: address, + // Don't validate in preferences since it's fine for fields to be missing + // for autofill purposes. For PaymentRequest addresses get more validation. + noValidate: true, + }); + } + + getLabelInfo(address) { + return { raw: FormAutofillUtils.getAddressLabel(address) }; + } +} + +class ManageCreditCards extends ManageRecords { + constructor(elements) { + super("creditCards", elements); + elements.add.setAttribute( + "searchkeywords", + FormAutofillUtils.EDIT_CREDITCARD_KEYWORDS.map(key => + FormAutofillUtils.stringBundle.GetStringFromName(key) + ).join("\n") + ); + + Services.telemetry.recordEvent("creditcard", "show", "manage"); + + this._isDecrypted = false; + } + + /** + * Open the edit address dialog to create/edit a credit card. + * + * @param {object} creditCard [optional] + */ + async openEditDialog(creditCard) { + // Ask for reauth if user is trying to edit an existing credit card. + if ( + !creditCard || + (await FormAutofillUtils.ensureLoggedIn(reauthPasswordPromptMessage)) + .authenticated + ) { + let decryptedCCNumObj = {}; + if (creditCard && creditCard["cc-number-encrypted"]) { + try { + decryptedCCNumObj["cc-number"] = await OSKeyStore.decrypt( + creditCard["cc-number-encrypted"] + ); + } catch (ex) { + if (ex.result == Cr.NS_ERROR_ABORT) { + // User shouldn't be ask to reauth here, but it could happen. + // Return here and skip opening the dialog. + return; + } + // We've got ourselves a real error. + // Recover from encryption error so the user gets a chance to re-enter + // unencrypted credit card number. + decryptedCCNumObj["cc-number"] = ""; + Cu.reportError(ex); + } + } + let decryptedCreditCard = Object.assign( + {}, + creditCard, + decryptedCCNumObj + ); + this.prefWin.gSubDialog.open( + EDIT_CREDIT_CARD_URL, + { features: "resizable=no" }, + { + record: decryptedCreditCard, + } + ); + } + } + + /** + * Get credit card display label. It should display masked numbers and the + * cardholder's name, separated by a comma. + * + * @param {object} creditCard + * @returns {string} + */ + getLabelInfo(creditCard) { + // The card type is displayed visually using an image. For a11y, we need + // to expose it as text. We do this using aria-label. However, + // aria-label overrides the text content, so we must include that also. + // Since the text content is generated by Fluent, aria-label must be + // generated by Fluent also. + let type; + try { + type = FormAutofillUtils.stringBundle.GetStringFromName( + `cardNetwork.${creditCard["cc-type"]}` + ); + } catch (e) { + type = ""; // Unknown. + } + return CreditCard.getLabelInfo({ + name: creditCard["cc-name"], + number: creditCard["cc-number"], + month: creditCard["cc-exp-month"], + year: creditCard["cc-exp-year"], + type, + }); + } + + async renderRecordElements(records) { + // Revert back to encrypted form when re-rendering happens + this._isDecrypted = false; + // Display third-party card icons when possible + this._elements.records.classList.toggle( + "branded", + AppConstants.MOZILLA_OFFICIAL + ); + await super.renderRecordElements(records); + + let options = this._elements.records.options; + for (let option of options) { + let record = option.record; + if (record && record["cc-type"]) { + option.setAttribute("cc-type", record["cc-type"]); + } else { + option.removeAttribute("cc-type"); + } + } + } + + async removeRecords(options) { + await super.removeRecords(options); + for (let i = 0; i < options.length; i++) { + Services.telemetry.recordEvent("creditcard", "delete", "manage"); + } + } + + updateButtonsStates(selectedCount) { + super.updateButtonsStates(selectedCount); + } + + handleClick(event) { + super.handleClick(event); + } +} diff --git a/browser/extensions/formautofill/content/third-party/cc-logo-amex.png b/browser/extensions/formautofill/content/third-party/cc-logo-amex.png Binary files differnew file mode 100644 index 0000000000..c51a5be4a0 --- /dev/null +++ b/browser/extensions/formautofill/content/third-party/cc-logo-amex.png diff --git a/browser/extensions/formautofill/content/third-party/cc-logo-amex@2x.png b/browser/extensions/formautofill/content/third-party/cc-logo-amex@2x.png Binary files differnew file mode 100644 index 0000000000..f794641f3e --- /dev/null +++ b/browser/extensions/formautofill/content/third-party/cc-logo-amex@2x.png diff --git a/browser/extensions/formautofill/content/third-party/cc-logo-cartebancaire.png b/browser/extensions/formautofill/content/third-party/cc-logo-cartebancaire.png Binary files differnew file mode 100644 index 0000000000..781c6e4958 --- /dev/null +++ b/browser/extensions/formautofill/content/third-party/cc-logo-cartebancaire.png diff --git a/browser/extensions/formautofill/content/third-party/cc-logo-cartebancaire@2x.png b/browser/extensions/formautofill/content/third-party/cc-logo-cartebancaire@2x.png Binary files differnew file mode 100644 index 0000000000..38158846dd --- /dev/null +++ b/browser/extensions/formautofill/content/third-party/cc-logo-cartebancaire@2x.png diff --git a/browser/extensions/formautofill/content/third-party/cc-logo-diners.svg b/browser/extensions/formautofill/content/third-party/cc-logo-diners.svg new file mode 100644 index 0000000000..9cc4d8b9ff --- /dev/null +++ b/browser/extensions/formautofill/content/third-party/cc-logo-diners.svg @@ -0,0 +1 @@ +<svg width="36" height="30" xmlns="http://www.w3.org/2000/svg"><g fill-rule="nonzero" fill="none"><path d="M19.863 20.068c4.698.022 8.987-3.839 8.987-8.536 0-5.137-4.289-8.688-8.987-8.686h-4.044c-4.755-.002-8.669 3.55-8.669 8.686 0 4.698 3.914 8.559 8.669 8.536h4.044z" fill="#4186CD"/><path d="M15.76 3.535a7.923 7.923 0 0 0 0 15.844 7.923 7.923 0 0 0 0-15.844zm-4.821 7.75c.004-2.122 1.288-3.931 3.1-4.65v9.3c-1.812-.719-3.096-2.527-3.1-4.65zm6.544 4.65v-9.3c1.811.717 3.097 2.527 3.1 4.65-.003 2.123-1.289 3.931-3.1 4.65z" fill="#FFF"/><g fill="#211E1F"><path d="M.65 22.925c0-.71-.375-.663-.733-.671v-.205c.31.015.63.015.94.015.336 0 .79-.015 1.381-.015 2.065 0 3.19 1.365 3.19 2.763 0 .782-.462 2.748-3.286 2.748-.407 0-.782-.016-1.157-.016-.358 0-.71.008-1.068.016v-.205c.478-.048.71-.064.733-.6v-3.83zm.644 3.636c0 .586.437.654.825.654 1.713 0 2.275-1.24 2.275-2.373 0-1.422-.951-2.449-2.48-2.449-.326 0-.476.022-.62.03v4.138zM5.428 27.364h.152c.225 0 .387 0 .387-.25v-2.041c0-.332-.121-.378-.419-.528v-.12c.378-.107.83-.249.861-.272a.301.301 0 0 1 .145-.038c.04 0 .057.046.057.106v2.893c0 .25.177.25.402.25h.137v.196c-.274 0-.556-.015-.845-.015-.29 0-.58.007-.877.015v-.196zm.689-4.627a.36.36 0 0 1-.345-.35c0-.177.169-.338.345-.338.182 0 .344.148.344.337 0 .19-.155.351-.344.351zM7.993 25.117c0-.278-.084-.353-.438-.496v-.143c.325-.106.634-.204.996-.363.022 0 .045.016.045.076v.49c.43-.309.8-.566 1.307-.566.64 0 .867.468.867 1.055v1.944c0 .25.166.25.377.25h.136v.196c-.265 0-.528-.015-.8-.015s-.544.007-.815.015v-.196h.136c.211 0 .362 0 .362-.25v-1.95c0-.43-.263-.642-.694-.642-.241 0-.626.196-.876.362v2.23c0 .25.166.25.378.25h.136v.196c-.264 0-.529-.015-.8-.015-.272 0-.544.007-.816.015v-.196h.137c.21 0 .362 0 .362-.25v-1.997zM11.943 25.569c-.017.072-.017.192 0 .465.049.762.553 1.388 1.212 1.388.453 0 .809-.24 1.113-.537l.115.113c-.38.489-.849.906-1.525.906-1.31 0-1.575-1.236-1.575-1.75 0-1.573 1.089-2.039 1.665-2.039.668 0 1.386.41 1.394 1.26 0 .05 0 .097-.008.145l-.074.049h-2.317zm1.514-.42c.212 0 .237-.077.237-.147 0-.3-.264-.542-.742-.542-.52 0-.877.264-.98.689h1.485zM14.383 27.364h.191c.198 0 .34 0 .34-.25v-2.117c0-.233-.262-.279-.368-.339v-.113c.516-.234.799-.43.863-.43.042 0 .063.023.063.099v.678h.015c.176-.294.474-.777.905-.777.176 0 .402.128.402.4 0 .203-.133.385-.331.385-.22 0-.22-.182-.468-.182-.12 0-.516.174-.516.626v1.77c0 .25.142.25.34.25h.395v.196c-.389-.008-.684-.015-.99-.015-.289 0-.586.007-.84.015v-.196zM17.282 26.668c.102.53.418.98.996.98.465 0 .64-.29.64-.57 0-.948-1.724-.643-1.724-1.935 0-.45.357-1.028 1.226-1.028.252 0 .592.073.9.234l.056.818h-.182c-.079-.505-.355-.795-.862-.795-.316 0-.616.185-.616.53 0 .94 1.834.65 1.834 1.91 0 .53-.42 1.092-1.36 1.092a2.06 2.06 0 0 1-.964-.272l-.087-.924.143-.04zM26.431 23.625h-.192c-.147-.94-.786-1.318-1.649-1.318-.886 0-2.173.618-2.173 2.548 0 1.626 1.11 2.792 2.296 2.792.763 0 1.395-.547 1.55-1.392l.176.048-.177 1.175c-.323.21-1.194.426-1.703.426-1.802 0-2.942-1.214-2.942-3.024 0-1.649 1.41-2.831 2.92-2.831.623 0 1.224.21 1.817.427l.077 1.15zM26.783 27.36h.153c.226 0 .387 0 .387-.253v-4.268c0-.498-.12-.514-.427-.598v-.123c.322-.099.66-.237.83-.33.087-.045.152-.084.176-.084.05 0 .065.046.065.108v5.295c0 .254.177.254.403.254h.136v.199c-.273 0-.555-.016-.845-.016-.29 0-.58.008-.878.016v-.2zM31.775 27.032c0 .136.084.143.214.143.092 0 .206-.007.305-.007v.159c-.328.03-.955.188-1.1.233l-.038-.023v-.61c-.458.37-.81.633-1.353.633-.412 0-.84-.264-.84-.897v-1.93c0-.196-.03-.384-.457-.422v-.143c.275-.007.885-.053.984-.053.085 0 .085.053.085.219v1.944c0 .226 0 .874.664.874.26 0 .604-.195.924-.458v-2.029c0-.15-.366-.233-.64-.309v-.135c.686-.046 1.115-.106 1.19-.106.062 0 .062.053.062.136v2.78zM33.372 24.72c.323-.27.76-.572 1.206-.572.94 0 1.505.804 1.505 1.671 0 1.042-.776 2.085-1.935 2.085-.599 0-.914-.191-1.125-.278l-.243.182-.169-.087a9.26 9.26 0 0 0 .113-1.416v-3.422c0-.518-.122-.534-.43-.621v-.128c.325-.103.664-.246.834-.342.09-.048.154-.088.18-.088.047 0 .064.048.064.112v2.905zm-.044 2.032c0 .301.291.808.834.808.868 0 1.232-.831 1.232-1.535 0-.854-.664-1.565-1.296-1.565-.3 0-.552.19-.77.372v1.92z"/></g></g></svg>
\ No newline at end of file diff --git a/browser/extensions/formautofill/content/third-party/cc-logo-discover.png b/browser/extensions/formautofill/content/third-party/cc-logo-discover.png Binary files differnew file mode 100644 index 0000000000..104f9ee2d6 --- /dev/null +++ b/browser/extensions/formautofill/content/third-party/cc-logo-discover.png diff --git a/browser/extensions/formautofill/content/third-party/cc-logo-discover@2x.png b/browser/extensions/formautofill/content/third-party/cc-logo-discover@2x.png Binary files differnew file mode 100644 index 0000000000..1caaa01995 --- /dev/null +++ b/browser/extensions/formautofill/content/third-party/cc-logo-discover@2x.png diff --git a/browser/extensions/formautofill/content/third-party/cc-logo-jcb.svg b/browser/extensions/formautofill/content/third-party/cc-logo-jcb.svg new file mode 100644 index 0000000000..fc4f556c25 --- /dev/null +++ b/browser/extensions/formautofill/content/third-party/cc-logo-jcb.svg @@ -0,0 +1 @@ +<svg width="30" height="30" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><defs><path d="M3.622.575C1.734.575.009 2.278.009 4.188c0 1.051 0 5.212-.002 9.348.346.217 2.01.752 2.68.799 1.466.103 2.375-.381 2.515-1.603l-.007-4.538h3.243v4.434c-.17 2.54-2.399 3.057-5.79 2.887-.877-.046-2.07-.27-2.64-.42L.004 22.94H5.65c1.54 0 3.544-1.439 3.544-3.627V.575H3.622z" id="a"/><linearGradient x1="-.003%" y1="49.999%" x2="100.002%" y2="49.999%" id="b"><stop stop-color="#313477" offset="0%"/><stop stop-color="#0077BC" offset="100%"/></linearGradient><path d="M0 1.564l.007.001V.007L0 .002v1.562z" id="d"/><linearGradient x1="0%" y1="50.019%" x2="1.21%" y2="50.019%" id="e"><stop stop-color="#313477" offset="0%"/><stop stop-color="#0077BC" offset="100%"/></linearGradient><path d="M3.976.575C2.088.575.363 2.278.363 4.188v4.945c1.132-.885 2.958-1.319 5.281-1.14 1.322.102 2.3.286 2.834.445v1.57c-.588-.294-1.748-.73-2.715-.8-2.191-.158-3.342.868-3.342 2.528 0 1.494.888 2.773 3.331 2.602.806-.056 2.148-.525 2.719-.797l.007 1.523c-.492.155-2.02.488-3.458.5-2.165.017-3.694-.443-4.659-1.189L.36 22.941h5.643c1.54 0 3.546-1.439 3.546-3.627V.575H3.976z" id="g"/><linearGradient x1=".004%" y1="49.999%" x2="99.996%" y2="49.999%" id="h"><stop stop-color="#753136" offset="0%"/><stop stop-color="#ED1746" offset="100%"/></linearGradient><path d="M.123.448L.119 2.424l2.21.007c.43 0 .97-.368.97-1.01a.97.97 0 0 0-.967-.973c-.308.003-.8.001-1.245 0L.375.446C.26.446.17.446.123.448" id="j"/><linearGradient x1="0%" y1="50.008%" x2="99.996%" y2="50.008%" id="k"><stop stop-color="#008049" offset="0%"/><stop stop-color="#62BA44" offset="100%"/></linearGradient><path d="M.115.473l-.008 1.8 2.089.014c.346-.007.834-.325.834-.882 0-.567-.426-.95-.88-.939-.296.008-.702.005-1.078.002L.52.465C.333.465.187.467.115.473" id="m"/><linearGradient x1=".022%" y1="49.994%" x2="100.012%" y2="49.994%" id="n"><stop stop-color="#008049" offset="0%"/><stop stop-color="#62BA44" offset="100%"/></linearGradient><path d="M3.694.575C1.806.575.08 2.278.08 4.188L.08 8.164h5.365c1.067 0 2.324.457 2.324 1.754 0 .696-.37 1.485-1.706 1.74v.03c.78 0 2.132.457 2.132 1.833 0 1.423-1.46 1.817-2.243 1.817l-5.873.006-.001 7.597H5.72c1.54 0 3.544-1.439 3.544-3.627V.575H3.694z" id="p"/><linearGradient x1="-.007%" y1="49.999%" x2="100.004%" y2="49.999%" id="q"><stop stop-color="#008049" offset="0%"/><stop stop-color="#62BA44" offset="100%"/></linearGradient></defs><g fill="none" fill-rule="evenodd"><g transform="translate(0 3.013)"><mask id="c" fill="#fff"><use xlink:href="#a"/></mask><path d="M3.622.575C1.734.575.009 2.278.009 4.188c0 1.051 0 5.212-.002 9.348.346.217 2.01.752 2.68.799 1.466.103 2.375-.381 2.515-1.603l-.007-4.538h3.243v4.434c-.17 2.54-2.399 3.057-5.79 2.887-.877-.046-2.07-.27-2.64-.42L.004 22.94H5.65c1.54 0 3.544-1.439 3.544-3.627V.575H3.622z" fill="url(#b)" mask="url(#c)"/></g><g transform="translate(0 16.543)"><mask id="f" fill="#fff"><use xlink:href="#d"/></mask><path d="M0 1.564l.007.001V.007L0 .002v1.562z" fill="url(#e)" mask="url(#f)"/></g><g transform="translate(10 3.013)"><mask id="i" fill="#fff"><use xlink:href="#g"/></mask><path d="M3.976.575C2.088.575.363 2.278.363 4.188v4.945c1.132-.885 2.958-1.319 5.281-1.14 1.322.102 2.3.286 2.834.445v1.57c-.588-.294-1.748-.73-2.715-.8-2.191-.158-3.342.868-3.342 2.528 0 1.494.888 2.773 3.331 2.602.806-.056 2.148-.525 2.719-.797l.007 1.523c-.492.155-2.02.488-3.458.5-2.165.017-3.694-.443-4.659-1.189L.36 22.941h5.643c1.54 0 3.546-1.439 3.546-3.627V.575H3.976z" fill="url(#h)" mask="url(#i)"/></g><g transform="translate(22.353 14.778)"><mask id="l" fill="#fff"><use xlink:href="#j"/></mask><path d="M.123.448L.119 2.424l2.21.007c.43 0 .97-.368.97-1.01a.97.97 0 0 0-.967-.973c-.308.003-.8.001-1.245 0L.375.446C.26.446.17.446.123.448" fill="url(#k)" mask="url(#l)"/></g><g transform="translate(22.353 11.837)"><mask id="o" fill="#fff"><use xlink:href="#m"/></mask><path d="M.115.473l-.008 1.8 2.089.014c.346-.007.834-.325.834-.882 0-.567-.426-.95-.88-.939-.296.008-.702.005-1.078.002L.52.465C.333.465.187.467.115.473" fill="url(#n)" mask="url(#o)"/></g><g transform="translate(20.588 3.013)"><mask id="r" fill="#fff"><use xlink:href="#p"/></mask><path d="M3.694.575C1.806.575.08 2.278.08 4.188L.08 8.164h5.365c1.067 0 2.324.457 2.324 1.754 0 .696-.37 1.485-1.706 1.74v.03c.78 0 2.132.457 2.132 1.833 0 1.423-1.46 1.817-2.243 1.817l-5.873.006-.001 7.597H5.72c1.54 0 3.544-1.439 3.544-3.627V.575H3.694z" fill="url(#q)" mask="url(#r)"/></g></g></svg>
\ No newline at end of file diff --git a/browser/extensions/formautofill/content/third-party/cc-logo-mastercard.svg b/browser/extensions/formautofill/content/third-party/cc-logo-mastercard.svg new file mode 100644 index 0000000000..3e0f21f9e3 --- /dev/null +++ b/browser/extensions/formautofill/content/third-party/cc-logo-mastercard.svg @@ -0,0 +1 @@ +<svg width="38" height="30" xmlns="http://www.w3.org/2000/svg"><g fill-rule="nonzero" fill="none"><path d="M7.485 29.258v-1.896a1.125 1.125 0 0 0-1.188-1.2 1.17 1.17 0 0 0-1.061.537 1.109 1.109 0 0 0-.999-.537.998.998 0 0 0-.885.448v-.373h-.657v3.021h.664v-1.662a.708.708 0 0 1 .74-.802c.435 0 .656.284.656.796v1.68h.664v-1.674a.71.71 0 0 1 .74-.802c.448 0 .663.284.663.796v1.68l.663-.012zm9.817-3.02h-1.08v-.917h-.664v.916h-.6v.6h.613v1.391c0 .701.271 1.119 1.049 1.119.29 0 .575-.08.821-.234l-.19-.563a1.213 1.213 0 0 1-.58.17c-.317 0-.437-.201-.437-.505v-1.377h1.074l-.006-.6zm5.605-.076a.891.891 0 0 0-.796.442v-.367h-.65v3.021h.656v-1.693c0-.5.215-.778.632-.778.14-.002.28.024.411.076l.202-.632a1.406 1.406 0 0 0-.467-.082l.012.013zm-8.474.316a2.26 2.26 0 0 0-1.232-.316c-.765 0-1.264.366-1.264.966 0 .493.367.797 1.043.891l.316.045c.36.05.53.145.53.316 0 .234-.24.366-.688.366-.361.01-.715-.1-1.005-.316l-.316.512a2.18 2.18 0 0 0 1.308.392c.872 0 1.378-.41 1.378-.986 0-.575-.398-.809-1.056-.904l-.316-.044c-.284-.038-.511-.095-.511-.297 0-.202.214-.354.575-.354.333.004.659.093.947.26l.291-.531zm17.602-.316a.891.891 0 0 0-.796.442v-.367h-.65v3.021h.656v-1.693c0-.5.215-.778.632-.778.14-.002.28.024.411.076l.202-.632a1.406 1.406 0 0 0-.467-.082l.012.013zm-8.467 1.58a1.526 1.526 0 0 0 1.611 1.58 1.58 1.58 0 0 0 1.087-.36l-.316-.532a1.327 1.327 0 0 1-.79.272.97.97 0 0 1 0-1.934c.286.003.563.099.79.272l.316-.53a1.58 1.58 0 0 0-1.087-.361 1.526 1.526 0 0 0-1.611 1.58v.012zm6.155 0v-1.505h-.658v.367a1.147 1.147 0 0 0-.948-.442 1.58 1.58 0 0 0 0 3.16c.37.013.722-.152.948-.443v.366h.658v-1.504zm-2.446 0a.913.913 0 1 1 .916.966.907.907 0 0 1-.916-.967zm-7.93-1.58a1.58 1.58 0 1 0 .044 3.16c.454.023.901-.124 1.254-.411l-.316-.487c-.25.2-.559.311-.878.316a.837.837 0 0 1-.904-.74h2.243v-.252c0-.948-.587-1.58-1.434-1.58l-.01-.006zm0 .587a.749.749 0 0 1 .764.733h-1.58a.777.777 0 0 1 .803-.733h.012zm16.464.999v-2.724h-.632v1.58a1.147 1.147 0 0 0-.948-.442 1.58 1.58 0 0 0 0 3.16c.369.013.722-.152.948-.443v.366h.632v-1.497zm1.096 1.07a.316.316 0 0 1 .218.086.294.294 0 0 1-.098.487.297.297 0 0 1-.12.025.316.316 0 0 1-.284-.183.297.297 0 0 1 .066-.329.316.316 0 0 1 .228-.085h-.01zm0 .535a.224.224 0 0 0 .165-.07.234.234 0 0 0 0-.316.234.234 0 0 0-.165-.07.237.237 0 0 0-.167.07.234.234 0 0 0 0 .316.234.234 0 0 0 .076.05c.032.015.066.021.101.02h-.01zm.02-.376a.126.126 0 0 1 .082.025c.02.016.03.041.028.066a.076.076 0 0 1-.022.057.11.11 0 0 1-.066.029l.091.104h-.072l-.086-.104h-.028v.104h-.06v-.278l.132-.003zm-.07.054v.075h.07a.066.066 0 0 0 .037 0 .032.032 0 0 0 0-.028.032.032 0 0 0 0-.028.066.066 0 0 0-.038 0l-.07-.02zm-3.476-1.283a.913.913 0 1 1 .917.967.907.907 0 0 1-.917-.967zm-22.19 0v-1.51h-.657v.366a1.147 1.147 0 0 0-.948-.442 1.58 1.58 0 1 0 0 3.16c.369.013.722-.152.948-.443v.366h.657v-1.497zm-2.445 0a.913.913 0 1 1 .916.967.907.907 0 0 1-.922-.967h.006z" fill="#231F20"/><path fill="#FF5F00" d="M14.215 3.22h9.953v17.886h-9.953z"/><path d="M14.847 12.165a11.356 11.356 0 0 1 4.345-8.945 11.375 11.375 0 1 0 0 17.886 11.356 11.356 0 0 1-4.345-8.941z" fill="#EB001B"/><path d="M37.596 12.165a11.375 11.375 0 0 1-18.404 8.941 11.375 11.375 0 0 0 0-17.886 11.375 11.375 0 0 1 18.404 8.941v.004zM36.51 19.265v-.412h.148v-.085h-.376v.085h.161v.412h.066zm.73 0v-.497h-.115l-.132.355-.133-.355h-.101v.497h.082v-.373l.123.323h.086l.123-.323v.376l.066-.003z" fill="#F79E1B"/></g></svg>
\ No newline at end of file diff --git a/browser/extensions/formautofill/content/third-party/cc-logo-mir.svg b/browser/extensions/formautofill/content/third-party/cc-logo-mir.svg new file mode 100644 index 0000000000..26a24f985d --- /dev/null +++ b/browser/extensions/formautofill/content/third-party/cc-logo-mir.svg @@ -0,0 +1 @@ +<svg width="36" height="30" xmlns="http://www.w3.org/2000/svg"><defs><linearGradient x1="100%" y1="312.751%" x2=".612%" y2="312.751%" id="a"><stop stop-color="#1E5CD8" offset="0%"/><stop stop-color="#02AFFF" offset="100%"/></linearGradient></defs><g fill-rule="nonzero" fill="none"><path d="M7.812 11.313l-1.326 4.593h-.227l-1.326-4.594A1.823 1.823 0 0 0 3.18 10H0v10h3.184v-5.91h.227L5.234 20H7.51l1.819-5.91h.226V20h3.185V10H9.56c-.81 0-1.522.535-1.75 1.313zM25.442 20h3.204v-2.957h3.223c1.686 0 3.122-.953 3.677-2.293H25.442V20zm-5.676-8.945l-2.241 4.855h-.227V10h-3.184v10h2.703c.712 0 1.357-.414 1.654-1.055l2.242-4.851h.227V20h3.184V10H21.42c-.712 0-1.358.414-1.655 1.055z" fill="#006848"/><path d="M32.186 0c.92 0 1.752.352 2.382.93a3.49 3.49 0 0 1 1.146 2.59c0 .21-.023.417-.058.62H29.74a4.478 4.478 0 0 1-4.272-3.124c-.007-.02-.011-.043-.02-.067-.015-.054-.027-.113-.042-.168A4.642 4.642 0 0 1 25.293 0h6.893z" fill="url(#a)" transform="translate(0 10)"/></g></svg>
\ No newline at end of file diff --git a/browser/extensions/formautofill/content/third-party/cc-logo-unionpay.svg b/browser/extensions/formautofill/content/third-party/cc-logo-unionpay.svg new file mode 100644 index 0000000000..c4fab88a7e --- /dev/null +++ b/browser/extensions/formautofill/content/third-party/cc-logo-unionpay.svg @@ -0,0 +1 @@ +<svg width="36" height="30" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><defs><path id="a" d="M0 .04h17.771v22.433H0z"/><path id="c" d="M.134.04h18.093v22.433H.134z"/><path id="e" d="M.202.04h17.77v22.433H.202z"/></defs><g fill="none" fill-rule="evenodd"><g transform="translate(0 3.179)"><mask id="b" fill="#fff"><use xlink:href="#a"/></mask><path d="M7.023.04h8.952C17.225.04 18 1.057 17.71 2.31l-4.168 17.893c-.294 1.25-1.545 2.269-2.795 2.269h-8.95c-1.248 0-2.027-1.02-1.736-2.269l4.17-17.893C4.52 1.058 5.771.04 7.022.04" fill="#E21837" mask="url(#b)"/></g><g transform="translate(8.073 3.179)"><mask id="d" fill="#fff"><use xlink:href="#c"/></mask><path d="M7.157.04h10.294c1.25 0 .686 1.018.392 2.271l-4.167 17.893c-.292 1.25-.201 2.269-1.453 2.269H1.93c-1.252 0-2.026-1.02-1.732-2.269L4.363 2.311C4.66 1.058 5.907.04 7.157.04" fill="#00457C" mask="url(#d)"/></g><g transform="translate(17.89 3.179)"><mask id="f" fill="#fff"><use xlink:href="#e"/></mask><path d="M7.224.04h8.952c1.251 0 2.028 1.018 1.734 2.271l-4.166 17.893c-.295 1.25-1.547 2.269-2.798 2.269H2c-1.252 0-2.028-1.02-1.735-2.269L4.432 2.311C4.723 1.058 5.972.04 7.224.04" fill="#007B84" mask="url(#f)"/></g><path d="M26.582 16.428L25.49 20.04h.295l-.228.746h-.292l-.069.23h-1.038l.07-.23H22.12l.21-.69h.215l1.106-3.667.22-.739h1.06l-.111.373s.282-.203.55-.272c.266-.07 1.801-.096 1.801-.096l-.227.734h-.362zm-1.866 0l-.28.923s.315-.142.484-.189c.174-.046.434-.061.434-.061l.203-.673h-.841zm-.42 1.38l-.29.96s.321-.163.492-.215c.174-.039.438-.072.438-.072l.205-.673h-.845zm-.675 2.24h.844l.242-.81h-.841l-.245.81z" fill="#FEFEFE"/><path d="M27.05 15.694h1.13l.012.42c-.008.072.054.106.186.106h.23l-.21.695h-.612c-.528.038-.73-.19-.715-.445l-.022-.776zM27.2 18.993H26.12l.185-.619h1.232l.175-.566h-1.216l.207-.698h3.384l-.21.698h-1.135l-.178.566h1.139l-.19.619h-1.229l-.219.26h.5l.121.78c.014.078.014.13.04.162.025.028.175.042.262.042h.152l-.231.759h-.385c-.058 0-.147-.005-.27-.01-.114-.01-.195-.077-.273-.116a.367.367 0 0 1-.202-.265l-.12-.778-.56.766c-.177.243-.417.428-.824.428h-.782l.205-.677h.3a.484.484 0 0 0 .218-.063.336.336 0 0 0 .166-.138l.816-1.15zM15.397 17.298h2.855l-.211.68H16.9l-.179.581h1.168l-.213.702h-1.167l-.284.945c-.034.104.278.117.39.117l.584-.08-.235.778H15.65c-.106 0-.185-.015-.299-.04a.312.312 0 0 1-.209-.153c-.048-.077-.122-.14-.071-.305l.378-1.25H14.8l.215-.714h.65l.173-.581h-.648l.207-.68zM17.317 16.074h1.171l-.212.712h-1.6l-.173.15c-.075.072-.1.042-.198.094-.09.045-.28.136-.525.136h-.513l.207-.684h.154c.13 0 .219-.012.264-.04a.617.617 0 0 0 .171-.222l.296-.535h1.163l-.205.389zM18.991 15.694h.997l-.146.502s.316-.252.536-.343c.22-.081.716-.154.716-.154l1.615-.01-.55 1.832a2.139 2.139 0 0 1-.269.608.7.7 0 0 1-.271.251 1.02 1.02 0 0 1-.375.126c-.106.008-.27.01-.496.014h-1.556l-.437 1.447c-.042.144-.061.213-.034.252a.18.18 0 0 0 .148.073l.686-.065-.235.794h-.766c-.245 0-.422-.006-.547-.015-.118-.01-.242 0-.325-.063-.07-.063-.18-.147-.177-.231.007-.078.04-.209.09-.389l1.396-4.63zm2.117 1.848h-1.634l-.1.33h1.414c.167-.02.202.004.216-.004l.104-.326zm-1.545-.297s.32-.292.867-.387c.124-.023.9-.015.9-.015l.119-.392h-1.647l-.24.794z" fill="#FEFEFE"/><path d="M21.899 18.648l-.093.44c-.04.137-.073.24-.177.328-.11.093-.237.19-.536.19l-.554.023-.005.497c-.005.14.032.126.054.149.026.025.049.035.073.045l.175-.01.529-.03-.22.726h-.606c-.425 0-.74-.01-.842-.091-.103-.065-.116-.146-.115-.286l.04-1.938h.968l-.014.397h.233c.08 0 .134-.008.167-.03a.175.175 0 0 0 .065-.1l.097-.31h.76zM8.082 8.932c-.033.158-.655 3.024-.656 3.026-.134.58-.231.993-.562 1.26a1 1 0 0 1-.66.23c-.409 0-.646-.203-.687-.587l-.007-.132.124-.781s.652-2.611.769-2.957l.01-.039c-1.27.011-1.495 0-1.51-.02-.009.028-.04.19-.04.19l-.666 2.943-.057.25-.11.816c0 .242.047.44.142.607.303.53 1.168.609 1.657.609.63 0 1.222-.134 1.622-.378.694-.41.875-1.051 1.037-1.62l.075-.293s.672-2.712.786-3.065c.004-.02.006-.03.012-.039-.92.01-1.192 0-1.28-.02M11.798 14.319c-.45-.008-.61-.008-1.135.02l-.02-.04c.045-.2.095-.398.14-.6l.065-.275c.097-.425.191-.92.202-1.072.01-.09.042-.317-.218-.317-.109 0-.223.053-.339.107-.063.226-.19.863-.252 1.153-.13.61-.138.681-.197.983l-.038.041a12.946 12.946 0 0 0-1.159.02l-.024-.046c.089-.362.178-.728.263-1.091.224-.986.278-1.362.338-1.863l.044-.03c.52-.073.647-.088 1.21-.202l.048.053-.087.313c.096-.057.187-.114.283-.163.266-.13.562-.17.724-.17.248 0 .518.069.63.355.107.254.036.567-.104 1.184l-.072.316c-.144.686-.168.812-.25 1.283l-.052.041zM13.627 14.319c-.272-.002-.448-.008-.617-.002-.17.002-.335.01-.588.022l-.013-.022-.016-.024c.069-.26.106-.35.14-.443a3.13 3.13 0 0 0 .128-.449c.08-.345.128-.586.16-.797.037-.204.057-.378.085-.58l.02-.015.02-.02c.27-.037.442-.062.618-.09.177-.023.355-.06.635-.113l.01.024.008.025c-.052.214-.105.427-.156.643-.05.217-.103.43-.15.643-.101.453-.142.623-.166.745-.024.115-.03.178-.069.412l-.025.021-.024.02zM17.67 12.768c.159-.692.036-1.015-.119-1.212-.234-.3-.648-.396-1.078-.396-.258 0-.873.025-1.354.468-.345.32-.505.754-.6 1.17-.098.423-.21 1.186.492 1.47.216.093.528.118.73.118.513 0 1.04-.141 1.436-.561.305-.341.445-.848.494-1.057m-1.18-.05c-.022.117-.124.551-.262.736-.097.136-.21.219-.337.219-.037 0-.26 0-.264-.332-.002-.163.031-.33.072-.512.119-.524.258-.964.616-.964.28 0 .3.328.175.853M28.677 14.365c-.544-.004-.7-.004-1.202.017l-.031-.04c.135-.517.272-1.032.393-1.554.158-.678.194-.966.245-1.363l.041-.033c.54-.077.69-.099 1.252-.203l.016.047c-.103.426-.203.85-.304 1.278-.206.893-.281 1.346-.36 1.813l-.05.038z" fill="#FEFEFE"/><path d="M28.935 12.83c.158-.688-.479-.062-.58-.289-.154-.354-.058-1.072-.683-1.312-.24-.095-.804.027-1.29.469-.34.315-.504.747-.597 1.161-.098.418-.21 1.18.488 1.452.222.095.422.123.624.113.702-.038 1.236-1.098 1.633-1.516.305-.333.358.124.405-.079m-1.074-.05c-.027.112-.13.549-.268.732-.092.13-.311.211-.437.211-.036 0-.257 0-.264-.325a2.225 2.225 0 0 1 .073-.512c.12-.515.258-.95.616-.95.28 0 .4.316.28.843M20.746 14.319a12.427 12.427 0 0 0-1.134.02l-.02-.04c.046-.2.097-.398.144-.6l.061-.275c.099-.425.194-.92.203-1.072.01-.09.042-.317-.216-.317-.113 0-.225.053-.341.107-.062.226-.192.863-.255 1.153-.126.61-.136.681-.193.983l-.04.041a12.904 12.904 0 0 0-1.156.02l-.024-.046c.088-.362.177-.728.262-1.091.224-.986.276-1.362.339-1.863l.04-.03c.52-.073.648-.088 1.212-.202l.043.053-.08.313a4.81 4.81 0 0 1 .281-.163c.264-.13.562-.17.724-.17.244 0 .516.069.632.355.105.254.033.567-.108 1.184l-.07.316c-.15.686-.17.812-.25 1.283l-.054.041zM25.133 10.61c-.079.359-.312.66-.61.806-.247.124-.549.134-.86.134h-.201l.015-.08.37-1.608.011-.082.005-.063.149.015.782.067c.302.117.426.418.34.81m-.487-1.68l-.375.003c-.974.012-1.364.008-1.524-.011l-.04.197-.348 1.618-.874 3.597c.85-.01 1.199-.01 1.345.006.034-.161.23-1.121.232-1.121 0 0 .168-.704.178-.73 0 0 .053-.073.106-.102h.078c.732 0 1.56 0 2.209-.477.441-.328.743-.81.877-1.398.035-.144.061-.315.061-.487 0-.225-.045-.447-.176-.62-.33-.464-.99-.472-1.75-.476M33.124 11.185l-.043-.05c-.556.113-.656.131-1.167.2l-.038.038-.005.024-.002-.009c-.38.877-.37.688-.679 1.378l-.003-.084-.077-1.497-.05-.05c-.581.113-.595.131-1.133.2l-.041.038c-.006.017-.006.037-.01.059l.004.007c.067.344.05.267.118.809.032.266.073.533.105.796.053.44.083.656.147 1.327-.363.6-.449.826-.798 1.352l.022.049c.524-.02.646-.02 1.035-.02l.084-.096c.294-.633 2.531-4.47 2.531-4.47M14.12 11.556c.298-.207.335-.493.085-.641-.254-.15-.7-.102-1 .105-.3.203-.333.49-.08.642.25.146.697.103.994-.106" fill="#FEFEFE"/><path d="M30.554 15.709l-.437.75c-.139.256-.395.447-.803.448l-.696-.012.203-.674h.137c.07 0 .121-.003.16-.023.036-.012.062-.04.09-.08l.258-.409h1.088z" fill="#FEFEFE"/></g></svg>
\ No newline at end of file diff --git a/browser/extensions/formautofill/content/third-party/cc-logo-visa.svg b/browser/extensions/formautofill/content/third-party/cc-logo-visa.svg new file mode 100644 index 0000000000..b199bf3650 --- /dev/null +++ b/browser/extensions/formautofill/content/third-party/cc-logo-visa.svg @@ -0,0 +1 @@ +<svg width="44" height="30" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><defs><path d="M22.8 9.786c-.025-1.96 1.765-3.053 3.113-3.703 1.385-.667 1.85-1.095 1.845-1.691-.01-.913-1.105-1.316-2.13-1.332-1.787-.027-2.826.478-3.652.86L21.332.938c.83-.378 2.364-.708 3.956-.722 3.735 0 6.18 1.824 6.193 4.653.014 3.59-5.02 3.79-4.985 5.395.012.486.481 1.005 1.51 1.138.508.066 1.914.117 3.506-.609l.626 2.884a9.623 9.623 0 0 1-3.329.605c-3.516 0-5.99-1.85-6.01-4.497m15.347 4.248a1.621 1.621 0 0 1-1.514-.998L31.296.428h3.733l.743 2.032h4.561l.431-2.032h3.29l-2.87 13.606h-3.038m.522-3.675l1.077-5.11h-2.95l1.873 5.11m-20.394 3.675L15.33.428h3.557l2.942 13.606h-3.556m-8.965-9.26L7.81 12.648c-.176.879-.87 1.386-1.64 1.386H.116l-.084-.395c1.242-.267 2.654-.697 3.51-1.157.523-.282.672-.527.844-1.196L7.224.428h3.76l5.763 13.606H13.01L9.31 4.774z" id="a"/><linearGradient x1="16.148%" y1="34.401%" x2="85.832%" y2="66.349%" id="b"><stop stop-color="#222357" offset="0%"/><stop stop-color="#254AA5" offset="100%"/></linearGradient></defs><g transform="matrix(1 0 0 -1 0 22.674)" fill="none" fill-rule="evenodd"><mask id="c" fill="#fff"><use xlink:href="#a"/></mask><path fill="url(#b)" fill-rule="nonzero" mask="url(#c)" d="M-4.669 12.849l44.237 16.12L49.63 1.929 5.395-14.19"/></g></svg>
\ No newline at end of file diff --git a/browser/extensions/formautofill/docs/heuristics.rst b/browser/extensions/formautofill/docs/heuristics.rst new file mode 100644 index 0000000000..83dde0e2c0 --- /dev/null +++ b/browser/extensions/formautofill/docs/heuristics.rst @@ -0,0 +1,37 @@ +Form Autofill Heuristics +======================== + +Form Autofill Heuristics module is for detecting the field type based on `autocomplete attribute <https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#autofill>`_, `the regular expressions <http://searchfox.org/mozilla-central/source/browser/extensions/formautofill/content/heuristicsRegexp.js>`_ and the customized logic in each parser. + +Debugging +--------- + +The pref ``extensions.formautofill.heuristics.enabled`` is "true" in default. Set it to "false" could be useful to verify the result of autocomplete attribute. + +Dependent APIs +-------------- + +``element.getAutocompleteInfo()`` provides the parsed result of ``autocomplete`` attribute which includes the field name and section information defined in `autofill spec <https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#autofill>`_ + +Regular Expressions +------------------- + +This section is about how the regular expression is applied during parsing fields. All regular expressions are in `heuristicsRegexp.js <https://searchfox.org/mozilla-central/source/browser/extensions/formautofill/content/heuristicsRegexp.js>`_. + +Parser Implementations +---------------------- + +The parsers are for detecting the field type more accurately based on the near context of a field. Each parser uses ``FieldScanner`` to traverse the interested fields with the result from the regular expressions and adjust each field type when it matches to a grammar. + +* _parsePhoneFields + + * related type: ``tel``, ``tel-*`` + +* _parseAddressFields + + * related type: ``address-line[1-3]`` + +* _parseCreditCardExpirationDateFields + + * related type: ``cc-exp``, ``cc-exp-month``, ``cc-exp-year`` + diff --git a/browser/extensions/formautofill/docs/index.rst b/browser/extensions/formautofill/docs/index.rst new file mode 100644 index 0000000000..d728578d96 --- /dev/null +++ b/browser/extensions/formautofill/docs/index.rst @@ -0,0 +1,31 @@ +Form Autofill +============= + +`Wiki <https://wiki.mozilla.org/Firefox/Features/Form_Autofill>`_ | +`IRC: #formfill <ircs://irc.mozilla.org:6697/formfill>`_ | +Mailing List: `autofill@lists.mozilla.org <mailto:autofill@lists.mozilla.org>`_ + +Introduction +------------ + +Form Autofill saves users time and effort when making online purchases by storing their personal information in a profile and automatically populating form fields when the user requires it. + +Our objective is to increase user engagement, satisfaction and retention for frequent online shoppers (those who make an online purchase at least once per month). We believe this can be achieved by enabling users to complete forms and “check out” in e-commerce flows as quickly and securely as possible. + +Debugging +--------- + +Set the pref ``extensions.formautofill.loglevel`` to "Debug". + +Contents +-------- + +.. toctree:: + :maxdepth: 1 + + heuristics + +Report Issues +------------- + +If you find any issues about filling a form with incorrect values, please file a `new bug <https://bugzilla.mozilla.org/enter_bug.cgi?product=Toolkit&component=Form%20Autofill>`_ to Toolkit::Form Autofill component or leave a comment in `bug 1405266 <https://bugzilla.mozilla.org/show_bug.cgi?id=1405266>`_. diff --git a/browser/extensions/formautofill/jar.mn b/browser/extensions/formautofill/jar.mn new file mode 100644 index 0000000000..a767e90ad5 --- /dev/null +++ b/browser/extensions/formautofill/jar.mn @@ -0,0 +1,11 @@ +# 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/. + +[features/formautofill@mozilla.org] chrome.jar: + res/ (*.jsm) + res/phonenumberutils/ (phonenumberutils/*.jsm) + res/addressmetadata/ (addressmetadata/*) + + content/ (content/*) + content/skin/ (skin/shared/*) diff --git a/browser/extensions/formautofill/locales/en-US/formautofill.properties b/browser/extensions/formautofill/locales/en-US/formautofill.properties new file mode 100644 index 0000000000..6bdd5a8352 --- /dev/null +++ b/browser/extensions/formautofill/locales/en-US/formautofill.properties @@ -0,0 +1,230 @@ +# 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/. + +# LOCALIZATION NOTE (saveAddressesMessage): %S is brandShortName. This string is used on the doorhanger to +# notify users that addresses are saved. +saveAddressesMessage = %S now saves addresses so you can fill out forms faster. +# LOCALIZATION NOTE (autofillOptionsLink, autofillOptionsLinkOSX): These strings are used in the doorhanger for +# updating addresses. The link leads users to Form Autofill browser preferences. +autofillOptionsLink = Form Autofill Options +autofillOptionsLinkOSX = Form Autofill Preferences +# LOCALIZATION NOTE (changeAutofillOptions, changeAutofillOptionsOSX): These strings are used on the doorhanger +# that notifies users that addresses are saved. The button leads users to Form Autofill browser preferences. +changeAutofillOptions = Change Form Autofill Options +changeAutofillOptionsOSX = Change Form Autofill Preferences +changeAutofillOptionsAccessKey = C +# LOCALIZATION NOTE (addressesSyncCheckbox): If Sync is enabled, this checkbox is displayed on the doorhanger +# shown when saving addresses. +addressesSyncCheckbox = Share addresses with synced devices +# LOCALIZATION NOTE (creditCardsSyncCheckbox): If Sync is enabled and credit card sync is available, +# this checkbox is displayed on the doorhanger shown when saving credit card. +creditCardsSyncCheckbox = Share credit cards with synced devices +# LOCALIZATION NOTE (updateAddressMessage, updateAddressDescriptionLabel, createAddressLabel, updateAddressLabel): +# Used on the doorhanger when an address change is detected. +updateAddressMessage = Would you like to update your address with this new information? +updateAddressDescriptionLabel = Address to update: +createAddressLabel = Create New Address +createAddressAccessKey = C +updateAddressLabel = Update Address +updateAddressAccessKey = U +# LOCALIZATION NOTE (saveCreditCardMessage, saveCreditCardDescriptionLabel, saveCreditCardLabel, cancelCreditCardLabel, neverSaveCreditCardLabel): +# Used on the doorhanger when users submit payment with credit card. +# LOCALIZATION NOTE (saveCreditCardMessage): %S is brandShortName. +saveCreditCardMessage = Would you like %S to save this credit card? (Security code will not be saved) +saveCreditCardDescriptionLabel = Credit card to save: +saveCreditCardLabel = Save Credit Card +saveCreditCardAccessKey = S +cancelCreditCardLabel = Don’t Save +cancelCreditCardAccessKey = D +neverSaveCreditCardLabel = Never Save Credit Cards +neverSaveCreditCardAccessKey = N +# LOCALIZATION NOTE (updateCreditCardMessage, updateCreditCardDescriptionLabel, createCreditCardLabel, updateCreditCardLabel): +# Used on the doorhanger when an credit card change is detected. +updateCreditCardMessage = Would you like to update your credit card with this new information? +updateCreditCardDescriptionLabel = Credit card to update: +createCreditCardLabel = Create New Credit Card +createCreditCardAccessKey = C +updateCreditCardLabel = Update Credit Card +updateCreditCardAccessKey = U +# LOCALIZATION NOTE (openAutofillMessagePanel): Tooltip label for Form Autofill doorhanger icon on address bar. +openAutofillMessagePanel = Open Form Autofill message panel + +# LOCALIZATION NOTE (autocompleteFooterOption2): +# Used as a label for the button, displayed at the bottom of the dropdown suggestion, to open Form Autofill browser preferences. +autocompleteFooterOption2 = Form Autofill Options +# LOCALIZATION NOTE (autocompleteFooterOptionOSX2): +# Used as a label for the button, displayed at the bottom of the dropdown suggestion, to open Form Autofill browser preferences. +autocompleteFooterOptionOSX2 = Form Autofill Preferences +# LOCALIZATION NOTE (autocompleteFooterOptionShort2): +# Used as a label for the button, displayed at the bottom of the dropdown suggestion, to open Form Autofill browser preferences. +# The short version is used for inputs below a certain width (e.g. 150px). +autocompleteFooterOptionShort2 = Autofill Options +# LOCALIZATION NOTE (autocompleteFooterOptionOSXShort2): +# Used as a label for the button, displayed at the bottom of the dropdown suggestion, to open Form Autofill browser preferences. +# The short version is used for inputs below a certain width (e.g. 150px). +autocompleteFooterOptionOSXShort2 = Autofill Preferences +# LOCALIZATION NOTE (category.address, category.name, category.organization2, category.tel, category.email): +# Used in autofill drop down suggestion to indicate what other categories Form Autofill will attempt to fill. +category.address = address +category.name = name +category.organization2 = organization +category.tel = phone +category.email = email +# LOCALIZATION NOTE (fieldNameSeparator): This is used as a separator between categories. +fieldNameSeparator = ,\u0020 +# LOCALIZATION NOTE (phishingWarningMessage, phishingWarningMessage2): The warning +# text that is displayed for informing users what categories are about to be filled. +# "%S" will be replaced with a list generated from the pre-defined categories. +# The text would be e.g. Also autofills organization, phone, email. +phishingWarningMessage = Also autofills %S +phishingWarningMessage2 = Autofills %S +# LOCALIZATION NOTE (insecureFieldWarningDescription): %S is brandShortName. This string is used in drop down +# suggestion when users try to autofill credit card on an insecure website (without https). +insecureFieldWarningDescription = %S has detected an insecure site. Form Autofill is temporarily disabled. +# LOCALIZATION NOTE (clearFormBtnLabel2): Label for the button in the dropdown menu that used to clear the populated +# form. +clearFormBtnLabel2 = Clear Autofill Form + +autofillHeader = Forms and Autofill +# LOCALIZATION NOTE (autofillAddressesCheckbox): Label for the checkbox that enables autofilling addresses. +autofillAddressesCheckbox = Autofill addresses +# LOCALIZATION NOTE (learnMoreLabel): Label for the link that leads users to the Form Autofill SUMO page. +learnMoreLabel = Learn more +# LOCALIZATION NOTE (savedAddressesBtnLabel): Label for the button that opens a dialog that shows the +# list of saved addresses. +savedAddressesBtnLabel = Saved Addresses… +# LOCALIZATION NOTE (autofillCreditCardsCheckbox): Label for the checkbox that enables autofilling credit cards. +autofillCreditCardsCheckbox = Autofill credit cards +# LOCALIZATION NOTE (savedCreditCardsBtnLabel): Label for the button that opens a dialog that shows the list +# of saved credit cards. +savedCreditCardsBtnLabel = Saved Credit Cards… + +autofillReauthCheckboxMac = Require macOS authentication to autofill, view, or edit stored credit cards. +autofillReauthCheckboxWin = Require Windows authentication to autofill, view, or edit stored credit cards. +autofillReauthCheckboxLin = Require Linux authentication to autofill, view, or edit stored credit cards. + +# LOCALIZATION NOTE (autofillReauthOSDialogMac): This string is +# preceded by the operating system (macOS) with "Firefox is trying to ", and +# has a period added to its end. Make sure to test in your locale. +autofillReauthOSDialogMac = change the authentication settings +autofillReauthOSDialogWin = To change the authentication settings, enter your Windows login credentials. +autofillReauthOSDialogLin = To change the authentication settings, enter your Linux login credentials. + +# LOCALIZATION NOTE (manageAddressesTitle, manageCreditCardsTitle): The dialog title for the list of addresses or +# credit cards in browser preferences. +manageAddressesTitle = Saved Addresses +manageCreditCardsTitle = Saved Credit Cards +# LOCALIZATION NOTE (addressesListHeader, creditCardsListHeader): The header for the list of addresses or credit cards +# in browser preferences. +addressesListHeader = Addresses +creditCardsListHeader = Credit Cards +removeBtnLabel = Remove +addBtnLabel = Add… +editBtnLabel = Edit… +# LOCALIZATION NOTE (manageDialogsWidth): This strings sets the default width for windows used to manage addresses and +# credit cards. +manageDialogsWidth = 560px + +# LOCALIZATION NOTE (addNewAddressTitle, editAddressTitle): The dialog title for creating or editing addresses +# in browser preferences. +addNewAddressTitle = Add New Address +editAddressTitle = Edit Address +givenName = First Name +additionalName = Middle Name +familyName = Last Name +organization2 = Organization +streetAddress = Street Address + +## address-level-3 (Sublocality) names +# LOCALIZATION NOTE (neighborhood): Used in IR, MX +neighborhood = Neighborhood +# LOCALIZATION NOTE (village_township): Used in MY +village_township = Village or Township +island = Island +# LOCALIZATION NOTE (townland): Used in IE +townland = Townland + +## address-level-2 names +city = City +# LOCALIZATION NOTE (district): Used in HK, SD, SY, TR as Address Level-2 +# and used in KR as Sublocality. +district = District +# LOCALIZATION NOTE (post_town): Used in GB, NO, SE +post_town = Post town +# LOCALIZATION NOTE (suburb): Used in AU as Address Level-2 +# and used in ZZ as Sublocality. +suburb = Suburb + +# address-level-1 names +province = Province +state = State +county = County +# LOCALIZATION NOTE (parish): Used in BB, JM +parish = Parish +# LOCALIZATION NOTE (prefecture): Used in JP +prefecture = Prefecture +# LOCALIZATION NOTE (area): Used in HK +area = Area +# LOCALIZATION NOTE (do_si): Used in KR +do_si = Do/Si +# LOCALIZATION NOTE (department): Used in NI, CO +department = Department +# LOCALIZATION NOTE (emirate): Used in AE +emirate = Emirate +# LOCALIZATION NOTE (oblast): Used in RU and UA +oblast = Oblast + +# LOCALIZATION NOTE (pin, postalCode, zip, eircode): Postal code name types +# LOCALIZATION NOTE (pin): Used in IN +pin = Pin +postalCode = Postal Code +zip = ZIP Code +# LOCALIZATION NOTE (eircode): Used in IE +eircode = Eircode + +country = Country or Region +tel = Phone +email = Email +cancelBtnLabel = Cancel +saveBtnLabel = Save +countryWarningMessage2 = Form Autofill is currently available only for certain countries. + +# LOCALIZATION NOTE (addNewCreditCardTitle, editCreditCardTitle): The dialog title for creating or editing +# credit cards in browser preferences. +addNewCreditCardTitle = Add New Credit Card +editCreditCardTitle = Edit Credit Card +cardNumber = Card Number +invalidCardNumber = Please enter a valid card number +nameOnCard = Name on Card +cardExpiresMonth = Exp. Month +cardExpiresYear = Exp. Year +billingAddress = Billing Address +cardNetwork = Card Type +# LOCALIZATION NOTE (cardCVV): Credit card security code https://en.wikipedia.org/wiki/Card_security_code +cardCVV = CVV + +# LOCALIZATION NOTE: (cardNetwork.*): These are brand names and should only be translated when a locale-specific name for that brand is in common use +cardNetwork.amex = American Express +cardNetwork.cartebancaire = Carte Bancaire +cardNetwork.diners = Diners Club +cardNetwork.discover = Discover +cardNetwork.jcb = JCB +cardNetwork.mastercard = MasterCard +cardNetwork.mir = MIR +cardNetwork.unionpay = Union Pay +cardNetwork.visa = Visa + +# LOCALIZATION NOTE (editCreditCardPasswordPrompt.*, useCreditCardPasswordPrompt.*): %S is brandShortName. +editCreditCardPasswordPrompt.win = %S is trying to show credit card information. Confirm access to this Windows account below. +# LOCALIZATION NOTE (editCreditCardPasswordPrompt.macos): This string is +# preceded by the operating system (macOS) with "Firefox is trying to ", and +# has a period added to its end. Make sure to test in your locale. +editCreditCardPasswordPrompt.macos = show credit card information +editCreditCardPasswordPrompt.linux = %S is trying to show credit card information. +useCreditCardPasswordPrompt.win = %S is trying to use stored credit card information. Confirm access to this Windows account below. +# LOCALIZATION NOTE (useCreditCardPasswordPrompt.macos): This string is +# preceded by the operating system (macOS) with "Firefox is trying to ", and +# has a period added to its end. Make sure to test in your locale. +useCreditCardPasswordPrompt.macos = use stored credit card information +useCreditCardPasswordPrompt.linux = %S is trying to use stored credit card information. diff --git a/browser/extensions/formautofill/locales/jar.mn b/browser/extensions/formautofill/locales/jar.mn new file mode 100644 index 0000000000..58c364e55f --- /dev/null +++ b/browser/extensions/formautofill/locales/jar.mn @@ -0,0 +1,8 @@ +#filter substitution +# 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/. + +[features/formautofill@mozilla.org] @AB_CD@.jar: +% locale formautofill @AB_CD@ %locale/@AB_CD@/ + locale/@AB_CD@/formautofill.properties (%formautofill.properties) diff --git a/browser/extensions/formautofill/locales/moz.build b/browser/extensions/formautofill/locales/moz.build new file mode 100644 index 0000000000..d988c0ff9b --- /dev/null +++ b/browser/extensions/formautofill/locales/moz.build @@ -0,0 +1,7 @@ +# -*- 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/. + +JAR_MANIFESTS += ["jar.mn"] diff --git a/browser/extensions/formautofill/manifest.json b/browser/extensions/formautofill/manifest.json new file mode 100644 index 0000000000..b513d05166 --- /dev/null +++ b/browser/extensions/formautofill/manifest.json @@ -0,0 +1,26 @@ +{ + "manifest_version": 2, + "name": "Form Autofill", + "version": "1.0", + + "applications": { + "gecko": { + "id": "formautofill@mozilla.org" + } + }, + + "background": { + "scripts": ["background.js"] + }, + + "experiment_apis": { + "formautofill": { + "schema": "schema.json", + "parent": { + "scopes": ["addon_parent"], + "script": "api.js", + "events": ["startup"] + } + } + } +} diff --git a/browser/extensions/formautofill/moz.build b/browser/extensions/formautofill/moz.build new file mode 100644 index 0000000000..ef46740c3c --- /dev/null +++ b/browser/extensions/formautofill/moz.build @@ -0,0 +1,53 @@ +# -*- 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/. + +DEFINES["MOZ_APP_VERSION"] = CONFIG["MOZ_APP_VERSION"] +DEFINES["MOZ_APP_MAXVERSION"] = CONFIG["MOZ_APP_MAXVERSION"] + +DIRS += ["locales"] + +FINAL_TARGET_FILES.features["formautofill@mozilla.org"] += [ + "api.js", + "background.js", + "manifest.json", + "schema.json", +] + +if CONFIG["MOZ_WIDGET_TOOLKIT"] == "gtk": + FINAL_TARGET_FILES.features["formautofill@mozilla.org"].chrome.content.skin += [ + "skin/linux/autocomplete-item.css", + "skin/linux/editDialog.css", + ] +elif CONFIG["MOZ_WIDGET_TOOLKIT"] == "cocoa": + FINAL_TARGET_FILES.features["formautofill@mozilla.org"].chrome.content.skin += [ + "skin/osx/autocomplete-item.css", + "skin/osx/editDialog.css", + ] +elif CONFIG["MOZ_WIDGET_TOOLKIT"] == "windows": + FINAL_TARGET_FILES.features["formautofill@mozilla.org"].chrome.content.skin += [ + "skin/windows/autocomplete-item.css", + "skin/windows/editDialog.css", + ] + +BROWSER_CHROME_MANIFESTS += [ + "test/browser/browser.ini", + "test/browser/creditCard/browser.ini", + "test/browser/focus-leak/browser.ini", +] + +XPCSHELL_TESTS_MANIFESTS += ["test/unit/xpcshell.ini"] + +MOCHITEST_MANIFESTS += [ + "test/mochitest/creditCard/mochitest.ini", + "test/mochitest/mochitest.ini", +] + +JAR_MANIFESTS += ["jar.mn"] + +SPHINX_TREES["docs"] = "docs" + +with Files("**"): + BUG_COMPONENT = ("Toolkit", "Form Autofill") diff --git a/browser/extensions/formautofill/phonenumberutils/PhoneNumber.jsm b/browser/extensions/formautofill/phonenumberutils/PhoneNumber.jsm new file mode 100644 index 0000000000..587c46a896 --- /dev/null +++ b/browser/extensions/formautofill/phonenumberutils/PhoneNumber.jsm @@ -0,0 +1,478 @@ +/* This Source Code Form is subject to the terms of the Apache License, Version + * 2.0. If a copy of the Apache License was not distributed with this file, You + * can obtain one at https://www.apache.org/licenses/LICENSE-2.0 */ + +// This library came from https://github.com/andreasgal/PhoneNumber.js but will +// be further maintained by our own in Form Autofill codebase. + +"use strict"; + +var EXPORTED_SYMBOLS = ["PhoneNumber"]; + +ChromeUtils.defineModuleGetter( + this, + "PHONE_NUMBER_META_DATA", + "resource://formautofill/phonenumberutils/PhoneNumberMetaData.jsm" +); +ChromeUtils.defineModuleGetter( + this, + "PhoneNumberNormalizer", + "resource://formautofill/phonenumberutils/PhoneNumberNormalizer.jsm" +); +var PhoneNumber = (function(dataBase) { + const MAX_PHONE_NUMBER_LENGTH = 50; + const NON_ALPHA_CHARS = /[^a-zA-Z]/g; + const NON_DIALABLE_CHARS = /[^,#+\*\d]/g; + const NON_DIALABLE_CHARS_ONCE = new RegExp(NON_DIALABLE_CHARS.source); + const SPLIT_FIRST_GROUP = /^(\d+)(.*)$/; + const LEADING_PLUS_CHARS_PATTERN = /^[+\uFF0B]+/g; + + // Format of the string encoded meta data. If the name contains "^" or "$" + // we will generate a regular expression from the value, with those special + // characters as prefix/suffix. + const META_DATA_ENCODING = [ + "region", + "^(?:internationalPrefix)", + "nationalPrefix", + "^(?:nationalPrefixForParsing)", + "nationalPrefixTransformRule", + "nationalPrefixFormattingRule", + "^possiblePattern$", + "^nationalPattern$", + "formats", + ]; + + const FORMAT_ENCODING = [ + "^pattern$", + "nationalFormat", + "^leadingDigits", + "nationalPrefixFormattingRule", + "internationalFormat", + ]; + + let regionCache = Object.create(null); + + // Parse an array of strings into a convenient object. We store meta + // data as arrays since thats much more compact than JSON. + function ParseArray(array, encoding, obj) { + for (let n = 0; n < encoding.length; ++n) { + let value = array[n]; + if (!value) { + continue; + } + let field = encoding[n]; + let fieldAlpha = field.replace(NON_ALPHA_CHARS, ""); + if (field != fieldAlpha) { + value = new RegExp(field.replace(fieldAlpha, value)); + } + obj[fieldAlpha] = value; + } + return obj; + } + + // Parse string encoded meta data into a convenient object + // representation. + function ParseMetaData(countryCode, md) { + let array = JSON.parse(md); + md = ParseArray(array, META_DATA_ENCODING, { countryCode }); + regionCache[md.region] = md; + return md; + } + + // Parse string encoded format data into a convenient object + // representation. + function ParseFormat(md) { + let formats = md.formats; + if (!formats) { + return; + } + // Bail if we already parsed the format definitions. + if (!Array.isArray(formats[0])) { + return; + } + for (let n = 0; n < formats.length; ++n) { + formats[n] = ParseArray(formats[n], FORMAT_ENCODING, {}); + } + } + + // Search for the meta data associated with a region identifier ("US") in + // our database, which is indexed by country code ("1"). Since we have + // to walk the entire database for this, we cache the result of the lookup + // for future reference. + function FindMetaDataForRegion(region) { + // Check in the region cache first. This will find all entries we have + // already resolved (parsed from a string encoding). + let md = regionCache[region]; + if (md) { + return md; + } + for (let countryCode in dataBase) { + let entry = dataBase[countryCode]; + // Each entry is a string encoded object of the form '["US..', or + // an array of strings. We don't want to parse the string here + // to save memory, so we just substring the region identifier + // and compare it. For arrays, we compare against all region + // identifiers with that country code. We skip entries that are + // of type object, because they were already resolved (parsed into + // an object), and their country code should have been in the cache. + if (Array.isArray(entry)) { + for (let n = 0; n < entry.length; n++) { + if (typeof entry[n] == "string" && entry[n].substr(2, 2) == region) { + if (n > 0) { + // Only the first entry has the formats field set. + // Parse the main country if we haven't already and use + // the formats field from the main country. + if (typeof entry[0] == "string") { + entry[0] = ParseMetaData(countryCode, entry[0]); + } + let formats = entry[0].formats; + let current = ParseMetaData(countryCode, entry[n]); + current.formats = formats; + entry[n] = current; + return entry[n]; + } + + entry[n] = ParseMetaData(countryCode, entry[n]); + return entry[n]; + } + } + continue; + } + if (typeof entry == "string" && entry.substr(2, 2) == region) { + dataBase[countryCode] = ParseMetaData(countryCode, entry); + return dataBase[countryCode]; + } + } + } + + // Format a national number for a given region. The boolean flag "intl" + // indicates whether we want the national or international format. + function FormatNumber(regionMetaData, number, intl) { + // We lazily parse the format description in the meta data for the region, + // so make sure to parse it now if we haven't already done so. + ParseFormat(regionMetaData); + let formats = regionMetaData.formats; + if (!formats) { + return null; + } + for (let n = 0; n < formats.length; ++n) { + let format = formats[n]; + // The leading digits field is optional. If we don't have it, just + // use the matching pattern to qualify numbers. + if (format.leadingDigits && !format.leadingDigits.test(number)) { + continue; + } + if (!format.pattern.test(number)) { + continue; + } + if (intl) { + // If there is no international format, just fall back to the national + // format. + let internationalFormat = format.internationalFormat; + if (!internationalFormat) { + internationalFormat = format.nationalFormat; + } + // Some regions have numbers that can't be dialed from outside the + // country, indicated by "NA" for the international format of that + // number format pattern. + if (internationalFormat == "NA") { + return null; + } + // Prepend "+" and the country code. + number = + "+" + + regionMetaData.countryCode + + " " + + number.replace(format.pattern, internationalFormat); + } else { + number = number.replace(format.pattern, format.nationalFormat); + // The region has a national prefix formatting rule, and it can be overwritten + // by each actual number format rule. + let nationalPrefixFormattingRule = + regionMetaData.nationalPrefixFormattingRule; + if (format.nationalPrefixFormattingRule) { + nationalPrefixFormattingRule = format.nationalPrefixFormattingRule; + } + if (nationalPrefixFormattingRule) { + // The prefix formatting rule contains two magic markers, "$NP" and "$FG". + // "$NP" will be replaced by the national prefix, and "$FG" with the + // first group of numbers. + let match = number.match(SPLIT_FIRST_GROUP); + if (match) { + let firstGroup = match[1]; + let rest = match[2]; + let prefix = nationalPrefixFormattingRule; + prefix = prefix.replace("$NP", regionMetaData.nationalPrefix); + prefix = prefix.replace("$FG", firstGroup); + number = prefix + rest; + } + } + } + return number == "NA" ? null : number; + } + return null; + } + + function NationalNumber(regionMetaData, number) { + this.region = regionMetaData.region; + this.regionMetaData = regionMetaData; + this.number = number; + } + + // NationalNumber represents the result of parsing a phone number. We have + // three getters on the prototype that format the number in national and + // international format. Once called, the getters put a direct property + // onto the object, caching the result. + NationalNumber.prototype = { + // +1 949-726-2896 + get internationalFormat() { + let value = FormatNumber(this.regionMetaData, this.number, true); + Object.defineProperty(this, "internationalFormat", { + value, + enumerable: true, + }); + return value; + }, + // (949) 726-2896 + get nationalFormat() { + let value = FormatNumber(this.regionMetaData, this.number, false); + Object.defineProperty(this, "nationalFormat", { + value, + enumerable: true, + }); + return value; + }, + // +19497262896 + get internationalNumber() { + let value = this.internationalFormat + ? this.internationalFormat.replace(NON_DIALABLE_CHARS, "") + : null; + Object.defineProperty(this, "internationalNumber", { + value, + enumerable: true, + }); + return value; + }, + // 9497262896 + get nationalNumber() { + let value = this.nationalFormat + ? this.nationalFormat.replace(NON_DIALABLE_CHARS, "") + : null; + Object.defineProperty(this, "nationalNumber", { + value, + enumerable: true, + }); + return value; + }, + // country name 'US' + get countryName() { + let value = this.region ? this.region : null; + Object.defineProperty(this, "countryName", { value, enumerable: true }); + return value; + }, + // country code '+1' + get countryCode() { + let value = this.regionMetaData.countryCode + ? "+" + this.regionMetaData.countryCode + : null; + Object.defineProperty(this, "countryCode", { value, enumerable: true }); + return value; + }, + }; + + // Check whether the number is valid for the given region. + function IsValidNumber(number, md) { + return md.possiblePattern.test(number); + } + + // Check whether the number is a valid national number for the given region. + /* eslint-disable no-unused-vars */ + function IsNationalNumber(number, md) { + return IsValidNumber(number, md) && md.nationalPattern.test(number); + } + + // Determine the country code a number starts with, or return null if + // its not a valid country code. + function ParseCountryCode(number) { + for (let n = 1; n <= 3; ++n) { + let cc = number.substr(0, n); + if (dataBase[cc]) { + return cc; + } + } + return null; + } + + // Parse a national number for a specific region. Return null if the + // number is not a valid national number (it might still be a possible + // number for parts of that region). + function ParseNationalNumber(number, md) { + if (!md.possiblePattern.test(number) || !md.nationalPattern.test(number)) { + return null; + } + // Success. + return new NationalNumber(md, number); + } + + function ParseNationalNumberAndCheckNationalPrefix(number, md) { + let ret; + + // This is not an international number. See if its a national one for + // the current region. National numbers can start with the national + // prefix, or without. + if (md.nationalPrefixForParsing) { + // Some regions have specific national prefix parse rules. Apply those. + let withoutPrefix = number.replace( + md.nationalPrefixForParsing, + md.nationalPrefixTransformRule || "" + ); + ret = ParseNationalNumber(withoutPrefix, md); + if (ret) { + return ret; + } + } else { + // If there is no specific national prefix rule, just strip off the + // national prefix from the beginning of the number (if there is one). + let nationalPrefix = md.nationalPrefix; + if ( + nationalPrefix && + number.indexOf(nationalPrefix) == 0 && + (ret = ParseNationalNumber(number.substr(nationalPrefix.length), md)) + ) { + return ret; + } + } + ret = ParseNationalNumber(number, md); + if (ret) { + return ret; + } + } + + function ParseNumberByCountryCode(number, countryCode) { + let ret; + + // Lookup the meta data for the region (or regions) and if the rest of + // the number parses for that region, return the parsed number. + let entry = dataBase[countryCode]; + if (Array.isArray(entry)) { + for (let n = 0; n < entry.length; ++n) { + if (typeof entry[n] == "string") { + entry[n] = ParseMetaData(countryCode, entry[n]); + } + if (n > 0) { + entry[n].formats = entry[0].formats; + } + ret = ParseNationalNumberAndCheckNationalPrefix(number, entry[n]); + if (ret) { + return ret; + } + } + return null; + } + if (typeof entry == "string") { + entry = dataBase[countryCode] = ParseMetaData(countryCode, entry); + } + return ParseNationalNumberAndCheckNationalPrefix(number, entry); + } + + // Parse an international number that starts with the country code. Return + // null if the number is not a valid international number. + function ParseInternationalNumber(number) { + // Parse and strip the country code. + let countryCode = ParseCountryCode(number); + if (!countryCode) { + return null; + } + number = number.substr(countryCode.length); + + return ParseNumberByCountryCode(number, countryCode); + } + + // Parse a number and transform it into the national format, removing any + // international dial prefixes and country codes. + function ParseNumber(number, defaultRegion) { + let ret; + + // Remove formating characters and whitespace. + number = PhoneNumberNormalizer.Normalize(number); + + // If there is no defaultRegion or the defaultRegion is the global region, + // we can't parse international access codes. + if ((!defaultRegion || defaultRegion === "001") && number[0] !== "+") { + return null; + } + + // Detect and strip leading '+'. + if (number[0] === "+") { + return ParseInternationalNumber( + number.replace(LEADING_PLUS_CHARS_PATTERN, "") + ); + } + + // If "defaultRegion" is a country code, use it to parse the number directly. + let matches = String(defaultRegion).match(/^\+?(\d+)/); + if (matches) { + let countryCode = ParseCountryCode(matches[1]); + if (!countryCode) { + return null; + } + return ParseNumberByCountryCode(number, countryCode); + } + + // Lookup the meta data for the given region. + let md = FindMetaDataForRegion(defaultRegion.toUpperCase()); + + if (!md) { + dump("Couldn't find Meta Data for region: " + defaultRegion + "\n"); + return null; + } + + // See if the number starts with an international prefix, and if the + // number resulting from stripping the code is valid, then remove the + // prefix and flag the number as international. + if (md.internationalPrefix.test(number)) { + let possibleNumber = number.replace(md.internationalPrefix, ""); + ret = ParseInternationalNumber(possibleNumber); + if (ret) { + return ret; + } + } + + ret = ParseNationalNumberAndCheckNationalPrefix(number, md); + if (ret) { + return ret; + } + + // Now lets see if maybe its an international number after all, but + // without '+' or the international prefix. + ret = ParseInternationalNumber(number); + if (ret) { + return ret; + } + + // If the number matches the possible numbers of the current region, + // return it as a possible number. + if (md.possiblePattern.test(number)) { + return new NationalNumber(md, number); + } + + // We couldn't parse the number at all. + return null; + } + + function IsPlainPhoneNumber(number) { + if (typeof number !== "string") { + return false; + } + + let length = number.length; + let isTooLong = length > MAX_PHONE_NUMBER_LENGTH; + let isEmpty = length === 0; + return !(isTooLong || isEmpty || NON_DIALABLE_CHARS_ONCE.test(number)); + } + + return { + IsPlain: IsPlainPhoneNumber, + Parse: ParseNumber, + }; +})(PHONE_NUMBER_META_DATA); diff --git a/browser/extensions/formautofill/phonenumberutils/PhoneNumberMetaData.jsm b/browser/extensions/formautofill/phonenumberutils/PhoneNumberMetaData.jsm new file mode 100644 index 0000000000..77c7fb429b --- /dev/null +++ b/browser/extensions/formautofill/phonenumberutils/PhoneNumberMetaData.jsm @@ -0,0 +1,230 @@ +/* This Source Code Form is subject to the terms of the Apache License, Version + * 2.0. If a copy of the Apache License was not distributed with this file, You + * can obtain one at https://www.apache.org/licenses/LICENSE-2.0 */ + +/* + * This data was generated base on libphonenumber v8.4.1 via the script in + * https://github.com/andreasgal/PhoneNumber.js + * + * The XML format of libphonenumber has changed since v8.4.2 so we can only stay + * in this version for now. + */ + +var EXPORTED_SYMBOLS = ["PHONE_NUMBER_META_DATA"]; + +var PHONE_NUMBER_META_DATA = { +"46": '["SE","00","0",null,null,"$NP$FG","\\\\d{6,12}","[1-35-9]\\\\d{5,11}|4\\\\d{6,8}",[["(8)(\\\\d{2,3})(\\\\d{2,3})(\\\\d{2})","$1-$2 $3 $4","8",null,"$1 $2 $3 $4"],["([1-69]\\\\d)(\\\\d{2,3})(\\\\d{2})(\\\\d{2})","$1-$2 $3 $4","1[013689]|2[0136]|3[1356]|4[0246]|54|6[03]|90",null,"$1 $2 $3 $4"],["([1-469]\\\\d)(\\\\d{3})(\\\\d{2})","$1-$2 $3","1[136]|2[136]|3[356]|4[0246]|6[03]|90",null,"$1 $2 $3"],["(\\\\d{3})(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1-$2 $3 $4","1[2457]|2(?:[247-9]|5[0138])|3[0247-9]|4[1357-9]|5[0-35-9]|6(?:[124-689]|7[0-2])|9(?:[125-8]|3[0-5]|4[0-3])",null,"$1 $2 $3 $4"],["(\\\\d{3})(\\\\d{2,3})(\\\\d{2})","$1-$2 $3","1[2457]|2(?:[247-9]|5[0138])|3[0247-9]|4[1357-9]|5[0-35-9]|6(?:[124-689]|7[0-2])|9(?:[125-8]|3[0-5]|4[0-3])",null,"$1 $2 $3"],["(7\\\\d)(\\\\d{3})(\\\\d{2})(\\\\d{2})","$1-$2 $3 $4","7",null,"$1 $2 $3 $4"],["(77)(\\\\d{2})(\\\\d{2})","$1-$2$3","7",null,"$1 $2 $3"],["(20)(\\\\d{2,3})(\\\\d{2})","$1-$2 $3","20",null,"$1 $2 $3"],["(9[034]\\\\d)(\\\\d{2})(\\\\d{2})(\\\\d{3})","$1-$2 $3 $4","9[034]",null,"$1 $2 $3 $4"],["(9[034]\\\\d)(\\\\d{4})","$1-$2","9[034]",null,"$1 $2"],["(\\\\d{3})(\\\\d{2})(\\\\d{3})(\\\\d{2})(\\\\d{2})","$1-$2 $3 $4 $5","25[245]|67[3-6]",null,"$1 $2 $3 $4 $5"]]]', +"299": '["GL","00",null,null,null,null,"\\\\d{6}","[1-689]\\\\d{5}",[["(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3",null,null]]]', +"385": '["HR","00","0",null,null,"$NP$FG","\\\\d{6,9}","[1-7]\\\\d{5,8}|[89]\\\\d{6,8}",[["(1)(\\\\d{4})(\\\\d{3})","$1 $2 $3","1",null],["([2-5]\\\\d)(\\\\d{3})(\\\\d{3,4})","$1 $2 $3","[2-5]",null],["(9\\\\d)(\\\\d{3})(\\\\d{3,4})","$1 $2 $3","9",null],["(6[01])(\\\\d{2})(\\\\d{2,3})","$1 $2 $3","6[01]",null],["([67]\\\\d)(\\\\d{3})(\\\\d{3,4})","$1 $2 $3","[67]",null],["(80[01])(\\\\d{2})(\\\\d{2,3})","$1 $2 $3","8",null],["(80[01])(\\\\d{3})(\\\\d{3})","$1 $2 $3","8",null]]]', +"670": '["TL","00",null,null,null,null,"\\\\d{7,8}","[2-489]\\\\d{6}|7\\\\d{6,7}",[["(\\\\d{3})(\\\\d{4})","$1 $2","[2-489]",null],["(\\\\d{4})(\\\\d{4})","$1 $2","7",null]]]', +"258": '["MZ","00",null,null,null,null,"\\\\d{8,9}","[28]\\\\d{7,8}",[["([28]\\\\d)(\\\\d{3})(\\\\d{3,4})","$1 $2 $3","2|8[2-7]",null],["(80\\\\d)(\\\\d{3})(\\\\d{3})","$1 $2 $3","80",null]]]', +"359": '["BG","00","0",null,null,"$NP$FG","\\\\d{5,9}","[23567]\\\\d{5,7}|[489]\\\\d{6,8}",[["(2)(\\\\d)(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4","2",null],["(2)(\\\\d{3})(\\\\d{3,4})","$1 $2 $3","2",null],["(\\\\d{3})(\\\\d{4})","$1 $2","43[124-7]|70[1-9]",null],["(\\\\d{3})(\\\\d{3})(\\\\d{2})","$1 $2 $3","43[124-7]|70[1-9]",null],["(\\\\d{3})(\\\\d{2})(\\\\d{3})","$1 $2 $3","[78]00",null],["(\\\\d{3})(\\\\d{3})(\\\\d{3})","$1 $2 $3","999",null],["(\\\\d{2})(\\\\d{3})(\\\\d{2,3})","$1 $2 $3","[356]|4[124-7]|7[1-9]|8[1-6]|9[1-7]",null],["(\\\\d{2})(\\\\d{3})(\\\\d{3,4})","$1 $2 $3","48|8[7-9]|9[08]",null]]]', +"682": '["CK","00",null,null,null,null,"\\\\d{5}","[2-8]\\\\d{4}",[["(\\\\d{2})(\\\\d{3})","$1 $2",null,null]]]', +"852": '["HK","00(?:[126-9]|30|5[09])?",null,null,null,null,"\\\\d{5,11}","[235-7]\\\\d{7}|8\\\\d{7,8}|9\\\\d{4,10}",[["(\\\\d{4})(\\\\d{4})","$1 $2","[235-7]|[89](?:0[1-9]|[1-9])",null],["(800)(\\\\d{3})(\\\\d{3})","$1 $2 $3","800",null],["(900)(\\\\d{2})(\\\\d{3})(\\\\d{3})","$1 $2 $3 $4","900",null],["(900)(\\\\d{2,5})","$1 $2","900",null]]]', +"998": '["UZ","810","8",null,null,"$NP $FG","\\\\d{7,9}","[679]\\\\d{8}",[["([679]\\\\d)(\\\\d{3})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4",null,null]]]', +"291": '["ER","00","0",null,null,"$NP$FG","\\\\d{6,7}","[178]\\\\d{6}",[["(\\\\d)(\\\\d{3})(\\\\d{3})","$1 $2 $3",null,null]]]', +"95": '["MM","00","0",null,null,"$NP$FG","\\\\d{5,10}","[1478]\\\\d{5,7}|[256]\\\\d{5,8}|9(?:[279]\\\\d{0,2}|[58]|[34]\\\\d{1,2}|6\\\\d?)\\\\d{6}",[["(\\\\d)(\\\\d{3})(\\\\d{3,4})","$1 $2 $3","1|2[245]",null],["(2)(\\\\d{4})(\\\\d{4})","$1 $2 $3","251",null],["(\\\\d)(\\\\d{2})(\\\\d{3})","$1 $2 $3","16|2",null],["(\\\\d{2})(\\\\d{3})(\\\\d{3,4})","$1 $2 $3","67|81",null],["(\\\\d{2})(\\\\d{2})(\\\\d{3,4})","$1 $2 $3","[4-8]",null],["(9)(\\\\d{3})(\\\\d{4,6})","$1 $2 $3","9(?:2[0-4]|[35-9]|4[137-9])",null],["(9)([34]\\\\d{4})(\\\\d{4})","$1 $2 $3","9(?:3[0-36]|4[0-57-9])",null],["(9)(\\\\d{3})(\\\\d{3})(\\\\d{3})","$1 $2 $3 $4","92[56]",null],["(9)(\\\\d{3})(\\\\d{3})(\\\\d{2})","$1 $2 $3 $4","93",null]]]', +"266": '["LS","00",null,null,null,null,"\\\\d{8}","[2568]\\\\d{7}",[["(\\\\d{4})(\\\\d{4})","$1 $2",null,null]]]', +"245": '["GW","00",null,null,null,null,"\\\\d{7,9}","(?:4(?:0\\\\d{5}|4\\\\d{7})|9\\\\d{8})",[["(\\\\d{3})(\\\\d{3})(\\\\d{3})","$1 $2 $3","44|9[567]",null],["(\\\\d{3})(\\\\d{4})","$1 $2","40",null]]]', +"374": '["AM","00","0",null,null,"($NP$FG)","\\\\d{5,8}","[1-9]\\\\d{7}",[["(\\\\d{2})(\\\\d{6})","$1 $2","1|47",null],["(\\\\d{2})(\\\\d{6})","$1 $2","4[1349]|[5-7]|9[1-9]","$NP$FG"],["(\\\\d{3})(\\\\d{5})","$1 $2","[23]",null],["(\\\\d{3})(\\\\d{2})(\\\\d{3})","$1 $2 $3","8|90","$NP $FG"]]]', +"61": ['["AU","(?:14(?:1[14]|34|4[17]|[56]6|7[47]|88))?001[14-689]","0",null,null,null,"\\\\d{6,10}","1\\\\d{4,9}|[2-578]\\\\d{8}",[["([2378])(\\\\d{4})(\\\\d{4})","$1 $2 $3","[2378]","($NP$FG)"],["(\\\\d{3})(\\\\d{3})(\\\\d{3})","$1 $2 $3","[45]|14","$NP$FG"],["(16)(\\\\d{3,4})","$1 $2","16","$NP$FG"],["(16)(\\\\d{3})(\\\\d{2,4})","$1 $2 $3","16","$NP$FG"],["(1[389]\\\\d{2})(\\\\d{3})(\\\\d{3})","$1 $2 $3","1(?:[38]0|90)","$FG"],["(180)(2\\\\d{3})","$1 $2","180","$FG"],["(19\\\\d)(\\\\d{3})","$1 $2","19[13]","$FG"],["(19\\\\d{2})(\\\\d{4})","$1 $2","19[679]","$FG"],["(13)(\\\\d{2})(\\\\d{2})","$1 $2 $3","13[1-9]","$FG"]]]','["CC","(?:14(?:1[14]|34|4[17]|[56]6|7[47]|88))?001[14-689]","0",null,null,null,"\\\\d{6,10}","[1458]\\\\d{5,9}"]','["CX","(?:14(?:1[14]|34|4[17]|[56]6|7[47]|88))?001[14-689]","0",null,null,null,"\\\\d{6,10}","[1458]\\\\d{5,9}"]'], +"500": '["FK","00",null,null,null,null,"\\\\d{5}","[2-7]\\\\d{4}"]', +"261": '["MG","00","0",null,null,"$NP$FG","\\\\d{7,9}","[23]\\\\d{8}",[["([23]\\\\d)(\\\\d{2})(\\\\d{3})(\\\\d{2})","$1 $2 $3 $4",null,null]]]', +"92": '["PK","00","0",null,null,"($NP$FG)","\\\\d{6,12}","1\\\\d{8}|[2-8]\\\\d{5,11}|9(?:[013-9]\\\\d{4,9}|2\\\\d(?:111\\\\d{6}|\\\\d{3,7}))",[["(\\\\d{2})(111)(\\\\d{3})(\\\\d{3})","$1 $2 $3 $4","(?:2[125]|4[0-246-9]|5[1-35-7]|6[1-8]|7[14]|8[16]|91)1",null],["(\\\\d{3})(111)(\\\\d{3})(\\\\d{3})","$1 $2 $3 $4","2[349]|45|54|60|72|8[2-5]|9[2-9]",null],["(\\\\d{2})(\\\\d{7,8})","$1 $2","(?:2[125]|4[0-246-9]|5[1-35-7]|6[1-8]|7[14]|8[16]|91)[2-9]",null],["(\\\\d{3})(\\\\d{6,7})","$1 $2","2[349]|45|54|60|72|8[2-5]|9[2-9]",null],["(3\\\\d{2})(\\\\d{7})","$1 $2","3","$NP$FG"],["([15]\\\\d{3})(\\\\d{5,6})","$1 $2","58[12]|1",null],["(586\\\\d{2})(\\\\d{5})","$1 $2","586",null],["([89]00)(\\\\d{3})(\\\\d{2})","$1 $2 $3","[89]00","$NP$FG"]]]', +"234": '["NG","009","0",null,null,"$NP$FG","\\\\d{5,14}","[1-6]\\\\d{5,8}|9\\\\d{5,9}|[78]\\\\d{5,13}",[["(\\\\d)(\\\\d{3})(\\\\d{3,4})","$1 $2 $3","[12]|9(?:0[3-9]|[1-9])",null],["(\\\\d{2})(\\\\d{3})(\\\\d{2,3})","$1 $2 $3","[3-6]|7(?:[1-79]|0[1-9])|8[2-9]",null],["(\\\\d{3})(\\\\d{3})(\\\\d{3,4})","$1 $2 $3","70|8[01]|90[235-9]",null],["([78]00)(\\\\d{4})(\\\\d{4,5})","$1 $2 $3","[78]00",null],["([78]00)(\\\\d{5})(\\\\d{5,6})","$1 $2 $3","[78]00",null],["(78)(\\\\d{2})(\\\\d{3})","$1 $2 $3","78",null]]]', +"350": '["GI","00",null,null,null,null,"\\\\d{8}","[2568]\\\\d{7}",[["(\\\\d{3})(\\\\d{5})","$1 $2","2",null]]]', +"45": '["DK","00",null,null,null,null,"\\\\d{8}","[2-9]\\\\d{7}",[["(\\\\d{2})(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4",null,null]]]', +"963": '["SY","00","0",null,null,"$NP$FG","\\\\d{6,9}","[1-59]\\\\d{7,8}",[["(\\\\d{2})(\\\\d{3})(\\\\d{3,4})","$1 $2 $3","[1-5]",null],["(9\\\\d{2})(\\\\d{3})(\\\\d{3})","$1 $2 $3","9",null]]]', +"226": '["BF","00",null,null,null,null,"\\\\d{8}","[25-7]\\\\d{7}",[["(\\\\d{2})(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4",null,null]]]', +"974": '["QA","00",null,null,null,null,"\\\\d{7,8}","[2-8]\\\\d{6,7}",[["([28]\\\\d{2})(\\\\d{4})","$1 $2","[28]",null],["([3-7]\\\\d{3})(\\\\d{4})","$1 $2","[3-7]",null]]]', +"218": '["LY","00","0",null,null,"$NP$FG","\\\\d{7,9}","[25679]\\\\d{8}",[["([25679]\\\\d)(\\\\d{7})","$1-$2",null,null]]]', +"51": '["PE","19(?:1[124]|77|90)00","0",null,null,"($NP$FG)","\\\\d{6,9}","[14-9]\\\\d{7,8}",[["(1)(\\\\d{7})","$1 $2","1",null],["([4-8]\\\\d)(\\\\d{6})","$1 $2","[4-7]|8[2-4]",null],["(\\\\d{3})(\\\\d{5})","$1 $2","80",null],["(9\\\\d{2})(\\\\d{3})(\\\\d{3})","$1 $2 $3","9","$FG"]]]', +"62": '["ID","0(?:0[1789]|10(?:00|1[67]))","0",null,null,"$NP$FG","\\\\d{5,12}","(?:[1-79]\\\\d{6,10}|8\\\\d{7,11})",[["(\\\\d{2})(\\\\d{5,8})","$1 $2","2[124]|[36]1","($NP$FG)"],["(\\\\d{3})(\\\\d{5,8})","$1 $2","[4579]|2[035-9]|[36][02-9]","($NP$FG)"],["(8\\\\d{2})(\\\\d{3,4})(\\\\d{3})","$1-$2-$3","8[1-35-9]",null],["(8\\\\d{2})(\\\\d{4})(\\\\d{4,5})","$1-$2-$3","8[1-35-9]",null],["(1)(500)(\\\\d{3})","$1 $2 $3","15","$FG"],["(177)(\\\\d{6,8})","$1 $2","17",null],["(800)(\\\\d{5,7})","$1 $2","800",null],["(804)(\\\\d{3})(\\\\d{4})","$1 $2 $3","804",null],["(80\\\\d)(\\\\d)(\\\\d{3})(\\\\d{3})","$1 $2 $3 $4","80[79]",null]]]', +"298": '["FO","00",null,"(10(?:01|[12]0|88))",null,null,"\\\\d{6}","[2-9]\\\\d{5}",[["(\\\\d{6})","$1",null,null]]]', +"381": '["RS","00","0",null,null,"$NP$FG","\\\\d{5,12}","[126-9]\\\\d{4,11}|3(?:[0-79]\\\\d{3,10}|8[2-9]\\\\d{2,9})",[["([23]\\\\d{2})(\\\\d{4,9})","$1 $2","(?:2[389]|39)0",null],["([1-3]\\\\d)(\\\\d{5,10})","$1 $2","1|2(?:[0-24-7]|[389][1-9])|3(?:[0-8]|9[1-9])",null],["(6\\\\d)(\\\\d{6,8})","$1 $2","6",null],["([89]\\\\d{2})(\\\\d{3,9})","$1 $2","[89]",null],["(7[26])(\\\\d{4,9})","$1 $2","7[26]",null],["(7[08]\\\\d)(\\\\d{4,9})","$1 $2","7[08]",null]]]', +"975": '["BT","00",null,null,null,null,"\\\\d{6,8}","[1-8]\\\\d{6,7}",[["(\\\\d{2})(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4","1|77",null],["([2-8])(\\\\d{3})(\\\\d{3})","$1 $2 $3","[2-68]|7[246]",null]]]', +"34": '["ES","00",null,null,null,null,"\\\\d{9}","[5-9]\\\\d{8}",[["([89]00)(\\\\d{3})(\\\\d{3})","$1 $2 $3","[89]00",null],["([5-9]\\\\d{2})(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4","[568]|[79][0-8]",null]]]', +"881": '["001",null,null,null,null,null,"\\\\d{9}","[67]\\\\d{8}",[["(\\\\d)(\\\\d{3})(\\\\d{5})","$1 $2 $3","[67]",null]]]', +"855": '["KH","00[14-9]","0",null,null,null,"\\\\d{6,10}","[1-9]\\\\d{7,9}",[["(\\\\d{2})(\\\\d{3})(\\\\d{3,4})","$1 $2 $3","1\\\\d[1-9]|[2-9]","$NP$FG"],["(1[89]00)(\\\\d{3})(\\\\d{3})","$1 $2 $3","1[89]0",null]]]', +"420": '["CZ","00",null,null,null,null,"\\\\d{9,12}","[2-8]\\\\d{8}|9\\\\d{8,11}",[["([2-9]\\\\d{2})(\\\\d{3})(\\\\d{3})","$1 $2 $3","[2-8]|9[015-7]",null],["(96\\\\d)(\\\\d{3})(\\\\d{3})(\\\\d{3})","$1 $2 $3 $4","96",null],["(9\\\\d)(\\\\d{3})(\\\\d{3})(\\\\d{3})","$1 $2 $3 $4","9[36]",null]]]', +"216": '["TN","00",null,null,null,null,"\\\\d{8}","[2-57-9]\\\\d{7}",[["(\\\\d{2})(\\\\d{3})(\\\\d{3})","$1 $2 $3",null,null]]]', +"673": '["BN","00",null,null,null,null,"\\\\d{7}","[2-578]\\\\d{6}",[["([2-578]\\\\d{2})(\\\\d{4})","$1 $2",null,null]]]', +"290": ['["SH","00",null,null,null,null,"\\\\d{4,5}","[256]\\\\d{4}"]','["TA","00",null,null,null,null,"\\\\d{4}","8\\\\d{3}"]'], +"882": '["001",null,null,null,null,null,"\\\\d{7,12}","[13]\\\\d{6,11}",[["(\\\\d{2})(\\\\d{4})(\\\\d{3})","$1 $2 $3","3[23]",null],["(\\\\d{2})(\\\\d{5})","$1 $2","16|342",null],["(\\\\d{2})(\\\\d{4})(\\\\d{4})","$1 $2 $3","34[57]",null],["(\\\\d{3})(\\\\d{4})(\\\\d{4})","$1 $2 $3","348",null],["(\\\\d{2})(\\\\d{2})(\\\\d{4})","$1 $2 $3","1",null],["(\\\\d{2})(\\\\d{3,4})(\\\\d{4})","$1 $2 $3","16",null],["(\\\\d{2})(\\\\d{4,5})(\\\\d{5})","$1 $2 $3","16|39",null]]]', +"267": '["BW","00",null,null,null,null,"\\\\d{7,8}","[2-79]\\\\d{6,7}",[["(\\\\d{3})(\\\\d{4})","$1 $2","[2-6]",null],["(7\\\\d)(\\\\d{3})(\\\\d{3})","$1 $2 $3","7",null],["(90)(\\\\d{5})","$1 $2","9",null]]]', +"94": '["LK","00","0",null,null,"$NP$FG","\\\\d{7,9}","[1-9]\\\\d{8}",[["(\\\\d{2})(\\\\d{1})(\\\\d{6})","$1 $2 $3","[1-689]",null],["(\\\\d{2})(\\\\d{3})(\\\\d{4})","$1 $2 $3","7",null]]]', +"356": '["MT","00",null,null,null,null,"\\\\d{8}","[2357-9]\\\\d{7}",[["(\\\\d{4})(\\\\d{4})","$1 $2",null,null]]]', +"375": '["BY","810","8","8?0?",null,null,"\\\\d{5,11}","[1-4]\\\\d{8}|800\\\\d{3,7}|[89]\\\\d{9,10}",[["(\\\\d{2})(\\\\d{3})(\\\\d{2})(\\\\d{2})","$1 $2-$3-$4","17[0-3589]|2[4-9]|[34]","$NP 0$FG"],["(\\\\d{3})(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2-$3-$4","1(?:5[24]|6[235]|7[467])|2(?:1[246]|2[25]|3[26])","$NP 0$FG"],["(\\\\d{4})(\\\\d{2})(\\\\d{3})","$1 $2-$3","1(?:5[169]|6[3-5]|7[179])|2(?:1[35]|2[34]|3[3-5])","$NP 0$FG"],["([89]\\\\d{2})(\\\\d{3})(\\\\d{4})","$1 $2 $3","8[01]|9","$NP $FG"],["(82\\\\d)(\\\\d{4})(\\\\d{4})","$1 $2 $3","82","$NP $FG"],["(800)(\\\\d{3})","$1 $2","800","$NP $FG"],["(800)(\\\\d{2})(\\\\d{2,4})","$1 $2 $3","800","$NP $FG"]]]', +"690": '["TK","00",null,null,null,null,"\\\\d{4,7}","[2-47]\\\\d{3,6}"]', +"507": '["PA","00",null,null,null,null,"\\\\d{7,8}","[1-9]\\\\d{6,7}",[["(\\\\d{3})(\\\\d{4})","$1-$2","[1-57-9]",null],["(\\\\d{4})(\\\\d{4})","$1-$2","6",null]]]', +"692": '["MH","011","1",null,null,null,"\\\\d{7}","[2-6]\\\\d{6}",[["(\\\\d{3})(\\\\d{4})","$1-$2",null,null]]]', +"250": '["RW","00","0",null,null,null,"\\\\d{8,9}","[027-9]\\\\d{7,8}",[["(2\\\\d{2})(\\\\d{3})(\\\\d{3})","$1 $2 $3","2","$FG"],["([7-9]\\\\d{2})(\\\\d{3})(\\\\d{3})","$1 $2 $3","[7-9]","$NP$FG"],["(0\\\\d)(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4","0",null]]]', +"81": '["JP","010","0",null,null,"$NP$FG","\\\\d{8,17}","[1-9]\\\\d{8,9}|00(?:[36]\\\\d{7,14}|7\\\\d{5,7}|8\\\\d{7})",[["(\\\\d{3})(\\\\d{3})(\\\\d{3})","$1-$2-$3","(?:12|57|99)0",null],["(\\\\d{3})(\\\\d{3})(\\\\d{4})","$1-$2-$3","800",null],["(\\\\d{4})(\\\\d{4})","$1-$2","0077","$FG","NA"],["(\\\\d{4})(\\\\d{2})(\\\\d{3,4})","$1-$2-$3","0077","$FG","NA"],["(\\\\d{4})(\\\\d{2})(\\\\d{4})","$1-$2-$3","0088","$FG","NA"],["(\\\\d{4})(\\\\d{3})(\\\\d{3,4})","$1-$2-$3","00(?:37|66)","$FG","NA"],["(\\\\d{4})(\\\\d{4})(\\\\d{4,5})","$1-$2-$3","00(?:37|66)","$FG","NA"],["(\\\\d{4})(\\\\d{5})(\\\\d{5,6})","$1-$2-$3","00(?:37|66)","$FG","NA"],["(\\\\d{4})(\\\\d{6})(\\\\d{6,7})","$1-$2-$3","00(?:37|66)","$FG","NA"],["(\\\\d{2})(\\\\d{4})(\\\\d{4})","$1-$2-$3","[2579]0|80[1-9]",null],["(\\\\d{4})(\\\\d)(\\\\d{4})","$1-$2-$3","1(?:26|3[79]|4[56]|5[4-68]|6[3-5])|5(?:76|97)|499|746|8(?:3[89]|63|47|51)|9(?:49|80|9[16])",null],["(\\\\d{3})(\\\\d{2})(\\\\d{4})","$1-$2-$3","1(?:2[3-6]|3[3-9]|4[2-6]|5[2-8]|[68][2-7]|7[2-689]|9[1-578])|2(?:2[03-689]|3[3-58]|4[0-468]|5[04-8]|6[013-8]|7[06-9]|8[02-57-9]|9[13])|4(?:2[28]|3[689]|6[035-7]|7[05689]|80|9[3-5])|5(?:3[1-36-9]|4[4578]|5[013-8]|6[1-9]|7[2-8]|8[14-7]|9[4-9])|7(?:2[15]|3[5-9]|4[02-9]|6[135-8]|7[0-4689]|9[014-9])|8(?:2[49]|3[3-8]|4[5-8]|5[2-9]|6[35-9]|7[579]|8[03-579]|9[2-8])|9(?:[23]0|4[02-46-9]|5[024-79]|6[4-9]|7[2-47-9]|8[02-7]|9[3-7])",null],["(\\\\d{2})(\\\\d{3})(\\\\d{4})","$1-$2-$3","1|2(?:2[37]|5[5-9]|64|78|8[39]|91)|4(?:2[2689]|64|7[347])|5(?:[2-589]|39)|60|8(?:[46-9]|3[279]|2[124589])|9(?:[235-8]|93)",null],["(\\\\d{3})(\\\\d{2})(\\\\d{4})","$1-$2-$3","2(?:9[14-79]|74|[34]7|[56]9)|82|993",null],["(\\\\d)(\\\\d{4})(\\\\d{4})","$1-$2-$3","3|4(?:2[09]|7[01])|6[1-9]",null],["(\\\\d{2})(\\\\d{3})(\\\\d{4})","$1-$2-$3","[2479][1-9]",null]]]', +"237": '["CM","00",null,null,null,null,"\\\\d{8,9}","[2368]\\\\d{7,8}",[["([26])(\\\\d{2})(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4 $5","[26]",null],["(\\\\d{2})(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4","[23]|88",null],["(800)(\\\\d{2})(\\\\d{3})","$1 $2 $3","80",null]]]', +"351": '["PT","00",null,null,null,null,"\\\\d{9}","[2-46-9]\\\\d{8}",[["(2\\\\d)(\\\\d{3})(\\\\d{4})","$1 $2 $3","2[12]",null],["([2-46-9]\\\\d{2})(\\\\d{3})(\\\\d{3})","$1 $2 $3","2[3-9]|[346-9]",null]]]', +"246": '["IO","00",null,null,null,null,"\\\\d{7}","3\\\\d{6}",[["(\\\\d{3})(\\\\d{4})","$1 $2",null,null]]]', +"227": '["NE","00",null,null,null,null,"\\\\d{8}","[0289]\\\\d{7}",[["(\\\\d{2})(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4","[289]|09",null],["(08)(\\\\d{3})(\\\\d{3})","$1 $2 $3","08",null]]]', +"27": '["ZA","00","0",null,null,"$NP$FG","\\\\d{5,9}","[1-79]\\\\d{8}|8\\\\d{4,8}",[["(860)(\\\\d{3})(\\\\d{3})","$1 $2 $3","860",null],["(\\\\d{2})(\\\\d{3,4})","$1 $2","8[1-4]",null],["(\\\\d{2})(\\\\d{3})(\\\\d{2,3})","$1 $2 $3","8[1-4]",null],["(\\\\d{2})(\\\\d{3})(\\\\d{4})","$1 $2 $3","[1-79]|8(?:[0-57]|6[1-9])",null]]]', +"962": '["JO","00","0",null,null,"$NP$FG","\\\\d{8,9}","[235-9]\\\\d{7,8}",[["(\\\\d)(\\\\d{3})(\\\\d{4})","$1 $2 $3","[2356]|87","($NP$FG)"],["(7)(\\\\d{4})(\\\\d{4})","$1 $2 $3","7[457-9]",null],["(\\\\d{3})(\\\\d{5,6})","$1 $2","70|8[0158]|9",null]]]', +"387": '["BA","00","0",null,null,"$NP$FG","\\\\d{6,9}","[3-9]\\\\d{7,8}",[["(\\\\d{2})(\\\\d{3})(\\\\d{3})","$1 $2-$3","[3-5]",null],["(\\\\d{2})(\\\\d{3})(\\\\d{3})","$1 $2 $3","6[1-356]|[7-9]",null],["(\\\\d{2})(\\\\d{2})(\\\\d{2})(\\\\d{3})","$1 $2 $3 $4","6[047]",null]]]', +"33": '["FR","00","0",null,null,"$NP$FG","\\\\d{9}","[1-9]\\\\d{8}",[["([1-79])(\\\\d{2})(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4 $5","[1-79]",null],["(1\\\\d{2})(\\\\d{3})","$1 $2","11","$FG","NA"],["(8\\\\d{2})(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4","8","$NP $FG"]]]', +"972": '["IL","0(?:0|1[2-9])","0",null,null,"$FG","\\\\d{4,12}","1\\\\d{6,11}|[2-589]\\\\d{3}(?:\\\\d{3,6})?|6\\\\d{3}|7\\\\d{6,9}",[["([2-489])(\\\\d{3})(\\\\d{4})","$1-$2-$3","[2-489]","$NP$FG"],["([57]\\\\d)(\\\\d{3})(\\\\d{4})","$1-$2-$3","[57]","$NP$FG"],["(153)(\\\\d{1,2})(\\\\d{3})(\\\\d{4})","$1 $2 $3 $4","153",null],["(1)([7-9]\\\\d{2})(\\\\d{3})(\\\\d{3})","$1-$2-$3-$4","1[7-9]",null],["(1255)(\\\\d{3})","$1-$2","125",null],["(1200)(\\\\d{3})(\\\\d{3})","$1-$2-$3","120",null],["(1212)(\\\\d{2})(\\\\d{2})","$1-$2-$3","121",null],["(1599)(\\\\d{6})","$1-$2","15",null],["(\\\\d{4})","*$1","[2-689]",null]]]', +"248": '["SC","0(?:[02]|10?)",null,null,null,null,"\\\\d{6,7}","[24689]\\\\d{5,6}",[["(\\\\d)(\\\\d{3})(\\\\d{3})","$1 $2 $3","[246]",null]]]', +"297": '["AW","00",null,null,null,null,"\\\\d{7}","[25-9]\\\\d{6}",[["(\\\\d{3})(\\\\d{4})","$1 $2",null,null]]]', +"421": '["SK","00","0",null,null,"$NP$FG","\\\\d{6,9}","(?:[2-68]\\\\d{5,8}|9\\\\d{6,8})",[["(2)(1[67])(\\\\d{3,4})","$1 $2 $3","21[67]",null],["([3-5]\\\\d)(1[67])(\\\\d{2,3})","$1 $2 $3","[3-5]",null],["(2)(\\\\d{3})(\\\\d{3})(\\\\d{2})","$1/$2 $3 $4","2",null],["([3-5]\\\\d)(\\\\d{3})(\\\\d{2})(\\\\d{2})","$1/$2 $3 $4","[3-5]",null],["([689]\\\\d{2})(\\\\d{3})(\\\\d{3})","$1 $2 $3","[689]",null],["(9090)(\\\\d{3})","$1 $2","9090",null]]]', +"672": '["NF","00",null,null,null,null,"\\\\d{5,6}","[13]\\\\d{5}",[["(\\\\d{2})(\\\\d{4})","$1 $2","1",null],["(\\\\d)(\\\\d{5})","$1 $2","3",null]]]', +"870": '["001",null,null,null,null,null,"\\\\d{9}","[35-7]\\\\d{8}",[["(\\\\d{3})(\\\\d{3})(\\\\d{3})","$1 $2 $3",null,null]]]', +"883": '["001",null,null,null,null,null,"\\\\d{9}(?:\\\\d{3})?","51\\\\d{7}(?:\\\\d{3})?",[["(\\\\d{3})(\\\\d{3})(\\\\d{3})","$1 $2 $3","510",null],["(\\\\d{3})(\\\\d{3})(\\\\d{3})(\\\\d{3})","$1 $2 $3 $4","510",null],["(\\\\d{4})(\\\\d{4})(\\\\d{4})","$1 $2 $3","51[13]",null]]]', +"264": '["NA","00","0",null,null,"$NP$FG","\\\\d{8,9}","[68]\\\\d{7,8}",[["(8\\\\d)(\\\\d{3})(\\\\d{4})","$1 $2 $3","8[1235]",null],["(6\\\\d)(\\\\d{3})(\\\\d{3,4})","$1 $2 $3","6",null],["(88)(\\\\d{3})(\\\\d{3})","$1 $2 $3","88",null],["(870)(\\\\d{3})(\\\\d{3})","$1 $2 $3","870",null]]]', +"878": '["001",null,null,null,null,null,"\\\\d{12}","1\\\\d{11}",[["(\\\\d{2})(\\\\d{5})(\\\\d{5})","$1 $2 $3",null,null]]]', +"239": '["ST","00",null,null,null,null,"\\\\d{7}","[29]\\\\d{6}",[["(\\\\d{3})(\\\\d{4})","$1 $2",null,null]]]', +"357": '["CY","00",null,null,null,null,"\\\\d{8}","[257-9]\\\\d{7}",[["(\\\\d{2})(\\\\d{6})","$1 $2",null,null]]]', +"240": '["GQ","00",null,null,null,null,"\\\\d{9}","[23589]\\\\d{8}",[["(\\\\d{3})(\\\\d{3})(\\\\d{3})","$1 $2 $3","[235]",null],["(\\\\d{3})(\\\\d{6})","$1 $2","[89]",null]]]', +"506": '["CR","00",null,"(19(?:0[012468]|1[09]|20|66|77|99))",null,null,"\\\\d{8,10}","[24-9]\\\\d{7,9}",[["(\\\\d{4})(\\\\d{4})","$1 $2","[24-7]|8[3-9]",null],["(\\\\d{3})(\\\\d{3})(\\\\d{4})","$1-$2-$3","[89]0",null]]]', +"86": '["CN","(1(?:[129]\\\\d{3}|79\\\\d{2}))?00","0","(1(?:[129]\\\\d{3}|79\\\\d{2}))|0",null,null,"\\\\d{4,12}","[1-7]\\\\d{6,11}|8[0-357-9]\\\\d{6,9}|9\\\\d{7,10}",[["(80\\\\d{2})(\\\\d{4})","$1 $2","80[2678]","$NP$FG"],["([48]00)(\\\\d{3})(\\\\d{4})","$1 $2 $3","[48]00",null],["(\\\\d{5,6})","$1","100|95",null,"NA"],["(\\\\d{2})(\\\\d{5,6})","$1 $2","(?:10|2\\\\d)[19]","$NP$FG"],["(\\\\d{3})(\\\\d{5,6})","$1 $2","[3-9]","$NP$FG"],["(\\\\d{3,4})(\\\\d{4})","$1 $2","[2-9]",null,"NA"],["(21)(\\\\d{4})(\\\\d{4,6})","$1 $2 $3","21","$NP$FG"],["([12]\\\\d)(\\\\d{4})(\\\\d{4})","$1 $2 $3","10[1-9]|2[02-9]","$NP$FG"],["(\\\\d{3})(\\\\d{3})(\\\\d{4})","$1 $2 $3","3(?:1[02-9]|35|49|5|7[02-68]|9[1-68])|4(?:1[02-9]|2[179]|[35][2-9]|6[4789]|7\\\\d|8[23])|5(?:3[03-9]|4[36]|5[02-9]|6[1-46]|7[028]|80|9[2-46-9])|6(?:3[1-5]|6[0238]|9[12])|7(?:01|[1579]|2[248]|3[04-9]|4[3-6]|6[2368])|8(?:1[236-8]|2[5-7]|3|5[1-9]|7[02-9]|8[3678]|9[1-7])|9(?:0[1-3689]|1[1-79]|[379]|4[13]|5[1-5])","$NP$FG"],["(\\\\d{3})(\\\\d{4})(\\\\d{4})","$1 $2 $3","3(?:11|7[179])|4(?:[15]1|3[1-35])|5(?:1|2[37]|3[12]|51|7[13-79]|9[15])|7(?:31|5[457]|6[09]|91)|8(?:[57]1|98)","$NP$FG"],["(\\\\d{4})(\\\\d{3})(\\\\d{4})","$1 $2 $3","807","$NP$FG"],["(\\\\d{3})(\\\\d{4})(\\\\d{4})","$1 $2 $3","1[3-578]",null],["(10800)(\\\\d{3})(\\\\d{4})","$1 $2 $3","108",null],["(\\\\d{3})(\\\\d{7,8})","$1 $2","950",null]]]', +"257": '["BI","00",null,null,null,null,"\\\\d{8}","[267]\\\\d{7}",[["(\\\\d{2})(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4",null,null]]]', +"683": '["NU","00",null,null,null,null,"\\\\d{4}","[1-5]\\\\d{3}"]', +"43": '["AT","00","0",null,null,"$NP$FG","\\\\d{3,13}","[1-9]\\\\d{3,12}",[["(116\\\\d{3})","$1","116","$FG"],["(1)(\\\\d{3,12})","$1 $2","1",null],["(5\\\\d)(\\\\d{3,5})","$1 $2","5[079]",null],["(5\\\\d)(\\\\d{3})(\\\\d{3,4})","$1 $2 $3","5[079]",null],["(5\\\\d)(\\\\d{4})(\\\\d{4,7})","$1 $2 $3","5[079]",null],["(\\\\d{3})(\\\\d{3,10})","$1 $2","316|46|51|732|6(?:5[0-3579]|[6-9])|7(?:[28]0)|[89]",null],["(\\\\d{4})(\\\\d{3,9})","$1 $2","2|3(?:1[1-578]|[3-8])|4[2378]|5[2-6]|6(?:[12]|4[1-9]|5[468])|7(?:2[1-8]|35|4[1-8]|[5-79])",null]]]', +"247": '["AC","00",null,null,null,null,"\\\\d{5,6}","[46]\\\\d{4}|[01589]\\\\d{5}"]', +"675": '["PG","00",null,null,null,null,"\\\\d{7,8}","[1-9]\\\\d{6,7}",[["(\\\\d{3})(\\\\d{4})","$1 $2","[13-689]|27",null],["(\\\\d{4})(\\\\d{4})","$1 $2","20|7",null]]]', +"376": '["AD","00",null,null,null,null,"\\\\d{6,9}","[16]\\\\d{5,8}|[37-9]\\\\d{5}",[["(\\\\d{3})(\\\\d{3})","$1 $2","[137-9]|6[0-8]",null],["(\\\\d{4})(\\\\d{4})","$1 $2","180",null],["(\\\\d{3})(\\\\d{3})(\\\\d{3})","$1 $2 $3","690",null]]]', +"63": '["PH","00","0",null,null,null,"\\\\d{5,13}","2\\\\d{5,7}|[3-9]\\\\d{7,9}|1800\\\\d{7,9}",[["(2)(\\\\d{3})(\\\\d{4})","$1 $2 $3","2","($NP$FG)"],["(2)(\\\\d{5})","$1 $2","2","($NP$FG)"],["(\\\\d{4})(\\\\d{4,6})","$1 $2","3(?:23|39|46)|4(?:2[3-6]|[35]9|4[26]|76)|5(?:22|44)|642|8(?:62|8[245])","($NP$FG)"],["(\\\\d{5})(\\\\d{4})","$1 $2","346|4(?:27|9[35])|883","($NP$FG)"],["([3-8]\\\\d)(\\\\d{3})(\\\\d{4})","$1 $2 $3","[3-8]","($NP$FG)"],["(\\\\d{3})(\\\\d{3})(\\\\d{4})","$1 $2 $3","81|9","$NP$FG"],["(1800)(\\\\d{3})(\\\\d{4})","$1 $2 $3","1",null],["(1800)(\\\\d{1,2})(\\\\d{3})(\\\\d{4})","$1 $2 $3 $4","1",null]]]', +"236": '["CF","00",null,null,null,null,"\\\\d{8}","[278]\\\\d{7}",[["(\\\\d{2})(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4",null,null]]]', +"590": ['["GP","00","0",null,null,"$NP$FG","\\\\d{9}","[56]\\\\d{8}",[["([56]90)(\\\\d{2})(\\\\d{4})","$1 $2-$3",null,null]]]','["BL","00","0",null,null,null,"\\\\d{9}","[56]\\\\d{8}"]','["MF","00","0",null,null,null,"\\\\d{9}","[56]\\\\d{8}"]'], +"53": '["CU","119","0",null,null,"($NP$FG)","\\\\d{4,8}","[2-57]\\\\d{5,7}",[["(\\\\d)(\\\\d{6,7})","$1 $2","7",null],["(\\\\d{2})(\\\\d{4,6})","$1 $2","[2-4]",null],["(\\\\d)(\\\\d{7})","$1 $2","5","$NP$FG"]]]', +"64": '["NZ","0(?:0|161)","0",null,null,"$NP$FG","\\\\d{7,11}","6[235-9]\\\\d{6}|[2-57-9]\\\\d{7,10}",[["([34679])(\\\\d{3})(\\\\d{4})","$1-$2 $3","[346]|7[2-57-9]|9[1-9]",null],["(24099)(\\\\d{3})","$1 $2","240",null],["(\\\\d{2})(\\\\d{3})(\\\\d{3})","$1 $2 $3","21",null],["(\\\\d{2})(\\\\d{3})(\\\\d{3,5})","$1 $2 $3","2(?:1[1-9]|[69]|7[0-35-9])|70|86",null],["(2\\\\d)(\\\\d{3,4})(\\\\d{4})","$1 $2 $3","2[028]",null],["(\\\\d{3})(\\\\d{3})(\\\\d{3,4})","$1 $2 $3","2(?:10|74)|5|[89]0",null]]]', +"965": '["KW","00",null,null,null,null,"\\\\d{7,8}","[12569]\\\\d{6,7}",[["(\\\\d{4})(\\\\d{3,4})","$1 $2","[16]|2(?:[0-35-9]|4[0-35-9])|9[024-9]|52[25]",null],["(\\\\d{3})(\\\\d{5})","$1 $2","244|5(?:[015]|66)",null]]]', +"224": '["GN","00",null,null,null,null,"\\\\d{8,9}","[367]\\\\d{7,8}",[["(\\\\d{2})(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4","3",null],["(\\\\d{3})(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4","[67]",null]]]', +"973": '["BH","00",null,null,null,null,"\\\\d{8}","[136-9]\\\\d{7}",[["(\\\\d{4})(\\\\d{4})","$1 $2",null,null]]]', +"32": '["BE","00","0",null,null,"$NP$FG","\\\\d{8,9}","[1-9]\\\\d{7,8}",[["(\\\\d{3})(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4","4[6-9]",null],["(\\\\d)(\\\\d{3})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4","[23]|4[23]|9[2-4]",null],["(\\\\d{2})(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4","[156]|7[018]|8(?:0[1-9]|[1-79])",null],["(\\\\d{3})(\\\\d{2})(\\\\d{3})","$1 $2 $3","(?:80|9)0",null]]]', +"249": '["SD","00","0",null,null,"$NP$FG","\\\\d{9}","[19]\\\\d{8}",[["(\\\\d{2})(\\\\d{3})(\\\\d{4})","$1 $2 $3",null,null]]]', +"678": '["VU","00",null,null,null,null,"\\\\d{5,7}","[2-57-9]\\\\d{4,6}",[["(\\\\d{3})(\\\\d{4})","$1 $2","[579]",null]]]', +"52": '["MX","0[09]","01","0[12]|04[45](\\\\d{10})","1$1","$NP $FG","\\\\d{7,11}","[1-9]\\\\d{9,10}",[["([358]\\\\d)(\\\\d{4})(\\\\d{4})","$1 $2 $3","33|55|81",null],["(\\\\d{3})(\\\\d{3})(\\\\d{4})","$1 $2 $3","[2467]|3[0-2457-9]|5[089]|8[02-9]|9[0-35-9]",null],["(1)([358]\\\\d)(\\\\d{4})(\\\\d{4})","044 $2 $3 $4","1(?:33|55|81)","$FG","$1 $2 $3 $4"],["(1)(\\\\d{3})(\\\\d{3})(\\\\d{4})","044 $2 $3 $4","1(?:[2467]|3[0-2457-9]|5[089]|8[2-9]|9[1-35-9])","$FG","$1 $2 $3 $4"]]]', +"968": '["OM","00",null,null,null,null,"\\\\d{7,9}","(?:5|[279]\\\\d)\\\\d{6}|800\\\\d{5,6}",[["(2\\\\d)(\\\\d{6})","$1 $2","2",null],["([79]\\\\d{3})(\\\\d{4})","$1 $2","[79]",null],["([58]00)(\\\\d{4,6})","$1 $2","[58]",null]]]', +"599": ['["CW","00",null,null,null,null,"\\\\d{7,8}","[169]\\\\d{6,7}",[["(\\\\d{3})(\\\\d{4})","$1 $2","[13-7]",null],["(9)(\\\\d{3})(\\\\d{4})","$1 $2 $3","9",null]]]','["BQ","00",null,null,null,null,"\\\\d{7}","[347]\\\\d{6}"]'], +"800": '["001",null,null,null,null,null,"\\\\d{8}","\\\\d{8}",[["(\\\\d{4})(\\\\d{4})","$1 $2",null,null]]]', +"386": '["SI","00","0",null,null,"$NP$FG","\\\\d{5,8}","[1-7]\\\\d{6,7}|[89]\\\\d{4,7}",[["(\\\\d)(\\\\d{3})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4","[12]|3[24-8]|4[24-8]|5[2-8]|7[3-8]","($NP$FG)"],["([3-7]\\\\d)(\\\\d{3})(\\\\d{3})","$1 $2 $3","[37][01]|4[0139]|51|6",null],["([89][09])(\\\\d{3,6})","$1 $2","[89][09]",null],["([58]\\\\d{2})(\\\\d{5})","$1 $2","59|8[1-3]",null]]]', +"679": '["FJ","0(?:0|52)",null,null,null,null,"\\\\d{7}(?:\\\\d{4})?","[35-9]\\\\d{6}|0\\\\d{10}",[["(\\\\d{3})(\\\\d{4})","$1 $2","[35-9]",null],["(\\\\d{4})(\\\\d{3})(\\\\d{4})","$1 $2 $3","0",null]]]', +"238": '["CV","0",null,null,null,null,"\\\\d{7}","[259]\\\\d{6}",[["(\\\\d{3})(\\\\d{2})(\\\\d{2})","$1 $2 $3",null,null]]]', +"691": '["FM","00",null,null,null,null,"\\\\d{7}","[39]\\\\d{6}",[["(\\\\d{3})(\\\\d{4})","$1 $2",null,null]]]', +"262": ['["RE","00","0",null,null,"$NP$FG","\\\\d{9}","[268]\\\\d{8}",[["([268]\\\\d{2})(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4",null,null]]]','["YT","00","0",null,null,"$NP$FG","\\\\d{9}","[268]\\\\d{8}"]'], +"241": '["GA","00",null,null,null,null,"\\\\d{7,8}","0?\\\\d{7}",[["(\\\\d)(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4","[2-7]","0$FG"],["(\\\\d{2})(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4","0",null]]]', +"370": '["LT","00","8","[08]",null,"($NP-$FG)","\\\\d{8}","[3-9]\\\\d{7}",[["([34]\\\\d)(\\\\d{6})","$1 $2","37|4(?:1|5[45]|6[2-4])",null],["([3-6]\\\\d{2})(\\\\d{5})","$1 $2","3[148]|4(?:[24]|6[09])|528|6",null],["([7-9]\\\\d{2})(\\\\d{2})(\\\\d{3})","$1 $2 $3","[7-9]","$NP $FG"],["(5)(2\\\\d{2})(\\\\d{4})","$1 $2 $3","52[0-79]",null]]]', +"256": '["UG","00[057]","0",null,null,"$NP$FG","\\\\d{5,9}","\\\\d{9}",[["(\\\\d{3})(\\\\d{6})","$1 $2","[7-9]|20(?:[013-8]|2[5-9])|4(?:6[45]|[7-9])",null],["(\\\\d{2})(\\\\d{7})","$1 $2","3|4(?:[1-5]|6[0-36-9])",null],["(2024)(\\\\d{5})","$1 $2","2024",null]]]', +"677": '["SB","0[01]",null,null,null,null,"\\\\d{5,7}","[1-9]\\\\d{4,6}",[["(\\\\d{2})(\\\\d{5})","$1 $2","[7-9]",null]]]', +"377": '["MC","00","0",null,null,"$NP$FG","\\\\d{8,9}","[34689]\\\\d{7,8}",[["(\\\\d{2})(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4","[39]","$FG"],["(\\\\d{2})(\\\\d{3})(\\\\d{3})","$1 $2 $3","4",null],["(6)(\\\\d{2})(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4 $5","6",null],["(\\\\d{3})(\\\\d{3})(\\\\d{2})","$1 $2 $3","8","$FG"]]]', +"382": '["ME","00","0",null,null,"$NP$FG","\\\\d{6,9}","[2-9]\\\\d{7,8}",[["(\\\\d{2})(\\\\d{3})(\\\\d{3})","$1 $2 $3","[2-57-9]|6[036-9]",null]]]', +"231": '["LR","00","0",null,null,"$NP$FG","\\\\d{7,9}","2\\\\d{7,8}|[378]\\\\d{8}|4\\\\d{6}|5\\\\d{6,8}",[["(2\\\\d)(\\\\d{3})(\\\\d{3})","$1 $2 $3","2",null],["([4-5])(\\\\d{3})(\\\\d{3})","$1 $2 $3","[45]",null],["(\\\\d{2})(\\\\d{3})(\\\\d{4})","$1 $2 $3","[23578]",null]]]', +"591": '["BO","00(1\\\\d)?","0","0(1\\\\d)?",null,null,"\\\\d{7,8}","[23467]\\\\d{7}",[["([234])(\\\\d{7})","$1 $2","[234]",null],["([67]\\\\d{7})","$1","[67]",null]]]', +"808": '["001",null,null,null,null,null,"\\\\d{8}","\\\\d{8}",[["(\\\\d{4})(\\\\d{4})","$1 $2",null,null]]]', +"964": '["IQ","00","0",null,null,"$NP$FG","\\\\d{6,10}","[1-7]\\\\d{7,9}",[["(1)(\\\\d{3})(\\\\d{4})","$1 $2 $3","1",null],["([2-6]\\\\d)(\\\\d{3})(\\\\d{3,4})","$1 $2 $3","[2-6]",null],["(7\\\\d{2})(\\\\d{3})(\\\\d{4})","$1 $2 $3","7",null]]]', +"225": '["CI","00",null,null,null,null,"\\\\d{8}","[02-8]\\\\d{7}",[["(\\\\d{2})(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4",null,null]]]', +"992": '["TJ","810","8",null,null,"$FG","\\\\d{3,9}","[3-57-9]\\\\d{8}",[["([349]\\\\d{2})(\\\\d{2})(\\\\d{4})","$1 $2 $3","[34]7|91[78]",null],["([457-9]\\\\d)(\\\\d{3})(\\\\d{4})","$1 $2 $3","4[148]|[578]|9(?:1[59]|[0235-9])",null],["(331700)(\\\\d)(\\\\d{2})","$1 $2 $3","331",null],["(\\\\d{4})(\\\\d)(\\\\d{4})","$1 $2 $3","3[1-5]",null]]]', +"55": '["BR","00(?:1[245]|2[1-35]|31|4[13]|[56]5|99)","0","(?:0|90)(?:(1[245]|2[135]|[34]1)(\\\\d{10,11}))?","$2",null,"\\\\d{8,11}","[1-46-9]\\\\d{7,10}|5(?:[0-4]\\\\d{7,9}|5(?:[2-8]\\\\d{7}|9\\\\d{7,8}))",[["(\\\\d{4})(\\\\d{4})","$1-$2","[2-9](?:[1-9]|0[1-9])","$FG","NA"],["(\\\\d{5})(\\\\d{4})","$1-$2","9(?:[1-9]|0[1-9])","$FG","NA"],["(\\\\d{3,5})","$1","1[125689]","$FG","NA"],["(\\\\d{2})(\\\\d{4})(\\\\d{4})","$1 $2-$3","[1-9][1-9]","($FG)"],["(\\\\d{2})(\\\\d{5})(\\\\d{4})","$1 $2-$3","(?:[14689][1-9]|2[12478]|3[1-578]|5[1-5]|7[13-579])9","($FG)"],["(\\\\d{4})(\\\\d{4})","$1-$2","(?:300|40(?:0|20))",null],["([3589]00)(\\\\d{2,3})(\\\\d{4})","$1 $2 $3","[3589]00","$NP$FG"]]]', +"674": '["NR","00",null,null,null,null,"\\\\d{7}","[458]\\\\d{6}",[["(\\\\d{3})(\\\\d{4})","$1 $2",null,null]]]', +"967": '["YE","00","0",null,null,"$NP$FG","\\\\d{6,9}","[1-7]\\\\d{6,8}",[["([1-7])(\\\\d{3})(\\\\d{3,4})","$1 $2 $3","[1-6]|7[24-68]",null],["(7\\\\d{2})(\\\\d{3})(\\\\d{3})","$1 $2 $3","7[0137]",null]]]', +"49": '["DE","00","0",null,null,"$NP$FG","\\\\d{2,15}","[1-35-9]\\\\d{3,14}|4(?:[0-8]\\\\d{3,12}|9(?:[0-37]\\\\d|4(?:[1-35-8]|4\\\\d?)|5\\\\d{1,2}|6[1-8]\\\\d?)\\\\d{2,8})",[["(1\\\\d{2})(\\\\d{7,8})","$1 $2","1[67]",null],["(15\\\\d{3})(\\\\d{6})","$1 $2","15[0568]",null],["(1\\\\d{3})(\\\\d{7})","$1 $2","15",null],["(\\\\d{2})(\\\\d{3,11})","$1 $2","3[02]|40|[68]9",null],["(\\\\d{3})(\\\\d{3,11})","$1 $2","2(?:\\\\d1|0[2389]|1[24]|28|34)|3(?:[3-9][15]|40)|[4-8][1-9]1|9(?:06|[1-9]1)",null],["(\\\\d{4})(\\\\d{2,11})","$1 $2","[24-6]|[7-9](?:\\\\d[1-9]|[1-9]\\\\d)|3(?:[3569][02-46-9]|4[2-4679]|7[2-467]|8[2-46-8])",null],["(3\\\\d{4})(\\\\d{1,10})","$1 $2","3",null],["(800)(\\\\d{7,12})","$1 $2","800",null],["(\\\\d{3})(\\\\d)(\\\\d{4,10})","$1 $2 $3","(?:18|90)0|137",null],["(1\\\\d{2})(\\\\d{5,11})","$1 $2","181",null],["(18\\\\d{3})(\\\\d{6})","$1 $2","185",null],["(18\\\\d{2})(\\\\d{7})","$1 $2","18[68]",null],["(18\\\\d)(\\\\d{8})","$1 $2","18[2-579]",null],["(700)(\\\\d{4})(\\\\d{4})","$1 $2 $3","700",null],["(138)(\\\\d{4})","$1 $2","138",null],["(15[013-68])(\\\\d{2})(\\\\d{8})","$1 $2 $3","15[013-68]",null],["(15[279]\\\\d)(\\\\d{2})(\\\\d{7})","$1 $2 $3","15[279]",null],["(1[67]\\\\d)(\\\\d{2})(\\\\d{7,8})","$1 $2 $3","1(?:6[023]|7)",null]]]', +"31": '["NL","00","0",null,null,"$NP$FG","\\\\d{5,10}","1\\\\d{4,8}|[2-7]\\\\d{8}|[89]\\\\d{6,9}",[["([1-578]\\\\d)(\\\\d{3})(\\\\d{4})","$1 $2 $3","1[035]|2[0346]|3[03568]|4[0356]|5[0358]|7|8[4578]",null],["([1-5]\\\\d{2})(\\\\d{3})(\\\\d{3})","$1 $2 $3","1[16-8]|2[259]|3[124]|4[17-9]|5[124679]",null],["(6)(\\\\d{8})","$1 $2","6[0-57-9]",null],["(66)(\\\\d{7})","$1 $2","66",null],["(14)(\\\\d{3,4})","$1 $2","14","$FG"],["([89]0\\\\d)(\\\\d{4,7})","$1 $2","80|9",null]]]', +"970": '["PS","00","0",null,null,"$NP$FG","\\\\d{4,10}","[24589]\\\\d{7,8}|1(?:[78]\\\\d{8}|[49]\\\\d{2,3})",[["([2489])(2\\\\d{2})(\\\\d{4})","$1 $2 $3","[2489]",null],["(5[69]\\\\d)(\\\\d{3})(\\\\d{3})","$1 $2 $3","5",null],["(1[78]00)(\\\\d{3})(\\\\d{3})","$1 $2 $3","1[78]","$FG"]]]', +"58": '["VE","00","0",null,null,"$NP$FG","\\\\d{7,10}","[24589]\\\\d{9}",[["(\\\\d{3})(\\\\d{7})","$1-$2",null,null]]]', +"856": '["LA","00","0",null,null,"$NP$FG","\\\\d{6,10}","[2-8]\\\\d{7,9}",[["(20)(\\\\d{2})(\\\\d{3})(\\\\d{3})","$1 $2 $3 $4","20",null],["([2-8]\\\\d)(\\\\d{3})(\\\\d{3})","$1 $2 $3","2[13]|3[14]|[4-8]",null],["(30)(\\\\d{2})(\\\\d{2})(\\\\d{3})","$1 $2 $3 $4","30",null]]]', +"354": '["IS","1(?:0(?:01|10|20)|100)|00",null,null,null,null,"\\\\d{7,9}","[4-9]\\\\d{6}|38\\\\d{7}",[["(\\\\d{3})(\\\\d{4})","$1 $2","[4-9]",null],["(3\\\\d{2})(\\\\d{3})(\\\\d{3})","$1 $2 $3","3",null]]]', +"242": '["CG","00",null,null,null,null,"\\\\d{9}","[028]\\\\d{8}",[["(\\\\d{2})(\\\\d{3})(\\\\d{4})","$1 $2 $3","[02]",null],["(\\\\d)(\\\\d{4})(\\\\d{4})","$1 $2 $3","8",null]]]', +"423": '["LI","00","0","0|10(?:01|20|66)",null,null,"\\\\d{7,9}","6\\\\d{8}|[23789]\\\\d{6}",[["(\\\\d{3})(\\\\d{2})(\\\\d{2})","$1 $2 $3","[23789]",null],["(\\\\d{3})(\\\\d{3})(\\\\d{3})","$1 $2 $3","6[56]",null],["(69)(7\\\\d{2})(\\\\d{4})","$1 $2 $3","697",null]]]', +"213": '["DZ","00","0",null,null,"$NP$FG","\\\\d{8,9}","(?:[1-4]|[5-9]\\\\d)\\\\d{7}",[["([1-4]\\\\d)(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4","[1-4]",null],["([5-8]\\\\d{2})(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4","[5-8]",null],["(9\\\\d)(\\\\d{3})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4","9",null]]]', +"371": '["LV","00",null,null,null,null,"\\\\d{8}","[2689]\\\\d{7}",[["([2689]\\\\d)(\\\\d{3})(\\\\d{3})","$1 $2 $3",null,null]]]', +"503": '["SV","00",null,null,null,null,"\\\\d{7,8}|\\\\d{11}","[267]\\\\d{7}|[89]\\\\d{6}(?:\\\\d{4})?",[["(\\\\d{4})(\\\\d{4})","$1 $2","[267]",null],["(\\\\d{3})(\\\\d{4})","$1 $2","[89]",null],["(\\\\d{3})(\\\\d{4})(\\\\d{4})","$1 $2 $3","[89]",null]]]', +"685": '["WS","0",null,null,null,null,"\\\\d{5,7}","[2-8]\\\\d{4,6}",[["(8\\\\d{2})(\\\\d{3,4})","$1 $2","8",null],["(7\\\\d)(\\\\d{5})","$1 $2","7",null],["(\\\\d{5})","$1","[2-6]",null]]]', +"880": '["BD","00","0",null,null,"$NP$FG","\\\\d{6,10}","[2-79]\\\\d{5,9}|1\\\\d{9}|8[0-7]\\\\d{4,8}",[["(2)(\\\\d{7,8})","$1-$2","2",null],["(\\\\d{2})(\\\\d{4,6})","$1-$2","[3-79]1",null],["(\\\\d{4})(\\\\d{3,6})","$1-$2","1|3(?:0|[2-58]2)|4(?:0|[25]2|3[23]|[4689][25])|5(?:[02-578]2|6[25])|6(?:[0347-9]2|[26][25])|7[02-9]2|8(?:[023][23]|[4-7]2)|9(?:[02][23]|[458]2|6[016])",null],["(\\\\d{3})(\\\\d{3,7})","$1-$2","[3-79][2-9]|8",null]]]', +"265": '["MW","00","0",null,null,"$NP$FG","\\\\d{7,9}","(?:1(?:\\\\d{2})?|[2789]\\\\d{2})\\\\d{6}",[["(\\\\d)(\\\\d{3})(\\\\d{3})","$1 $2 $3","1",null],["(2\\\\d{2})(\\\\d{3})(\\\\d{3})","$1 $2 $3","2",null],["(\\\\d{3})(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4","[1789]",null]]]', +"65": '["SG","0[0-3]\\\\d",null,null,null,null,"\\\\d{8,11}","[36]\\\\d{7}|[17-9]\\\\d{7,10}",[["([3689]\\\\d{3})(\\\\d{4})","$1 $2","[369]|8[1-9]",null],["(1[89]00)(\\\\d{3})(\\\\d{4})","$1 $2 $3","1[89]",null],["(7000)(\\\\d{4})(\\\\d{3})","$1 $2 $3","70",null],["(800)(\\\\d{3})(\\\\d{4})","$1 $2 $3","80",null]]]', +"504": '["HN","00",null,null,null,null,"\\\\d{8}","[237-9]\\\\d{7}",[["(\\\\d{4})(\\\\d{4})","$1-$2",null,null]]]', +"688": '["TV","00",null,null,null,null,"\\\\d{5,7}","[279]\\\\d{4,6}"]', +"84": '["VN","00","0",null,null,"$NP$FG","\\\\d{7,10}","[167]\\\\d{6,9}|[2-59]\\\\d{7,9}|8\\\\d{6,8}",[["([17]99)(\\\\d{4})","$1 $2","[17]99",null],["([48])(\\\\d{4})(\\\\d{4})","$1 $2 $3","4|8(?:[1-57]|6[0-79]|9[0-7])",null],["([235-7]\\\\d)(\\\\d{4})(\\\\d{3})","$1 $2 $3","2[025-79]|3[0136-9]|5[2-9]|6[0-46-8]|7[02-79]",null],["(80)(\\\\d{5})","$1 $2","80",null],["(69\\\\d)(\\\\d{4,5})","$1 $2","69",null],["([235-7]\\\\d{2})(\\\\d{4})(\\\\d{3})","$1 $2 $3","2[0-489]|3[25]|5[01]|65|7[18]",null],["([89]\\\\d)(\\\\d{3})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4","8(?:68|8|9[89])|9",null],["(1[2689]\\\\d)(\\\\d{3})(\\\\d{4})","$1 $2 $3","1(?:[26]|8[68]|99)",null],["(1[89]00)(\\\\d{4,6})","$1 $2","1[89]0","$FG"]]]', +"255": '["TZ","00[056]","0",null,null,"$NP$FG","\\\\d{7,9}","\\\\d{9}",[["([24]\\\\d)(\\\\d{3})(\\\\d{4})","$1 $2 $3","[24]",null],["([67]\\\\d{2})(\\\\d{3})(\\\\d{3})","$1 $2 $3","[67]",null],["([89]\\\\d{2})(\\\\d{2})(\\\\d{4})","$1 $2 $3","[89]",null]]]', +"222": '["MR","00",null,null,null,null,"\\\\d{8}","[2-48]\\\\d{7}",[["([2-48]\\\\d)(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4",null,null]]]', +"230": '["MU","0(?:0|[2-7]0|33)",null,null,null,null,"\\\\d{7,8}","[2-9]\\\\d{6,7}",[["([2-46-9]\\\\d{2})(\\\\d{4})","$1 $2","[2-46-9]",null],["(5\\\\d{3})(\\\\d{4})","$1 $2","5",null]]]', +"592": '["GY","001",null,null,null,null,"\\\\d{7}","[2-46-9]\\\\d{6}",[["(\\\\d{3})(\\\\d{4})","$1 $2",null,null]]]', +"41": '["CH","00","0",null,null,"$NP$FG","\\\\d{9}(?:\\\\d{3})?","[2-9]\\\\d{8}|860\\\\d{9}",[["([2-9]\\\\d)(\\\\d{3})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4","[2-7]|[89]1",null],["([89]\\\\d{2})(\\\\d{3})(\\\\d{3})","$1 $2 $3","8[047]|90",null],["(\\\\d{3})(\\\\d{2})(\\\\d{3})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4 $5","860",null]]]', +"39": ['["IT","00",null,null,null,null,"\\\\d{6,11}","[01589]\\\\d{5,10}|3(?:[12457-9]\\\\d{8}|[36]\\\\d{7,9})",[["(\\\\d{2})(\\\\d{3,4})(\\\\d{4})","$1 $2 $3","0[26]|55",null],["(0[26])(\\\\d{4})(\\\\d{5})","$1 $2 $3","0[26]",null],["(0[26])(\\\\d{4,6})","$1 $2","0[26]",null],["(0\\\\d{2})(\\\\d{3,4})(\\\\d{4})","$1 $2 $3","0[13-57-9][0159]",null],["(\\\\d{3})(\\\\d{3,6})","$1 $2","0[13-57-9][0159]|8(?:03|4[17]|9[245])",null],["(0\\\\d{3})(\\\\d{3})(\\\\d{4})","$1 $2 $3","0[13-57-9][2-46-8]",null],["(0\\\\d{3})(\\\\d{2,6})","$1 $2","0[13-57-9][2-46-8]",null],["(\\\\d{3})(\\\\d{3})(\\\\d{3,4})","$1 $2 $3","[13]|8(?:00|4[08]|9[59])",null],["(\\\\d{4})(\\\\d{4})","$1 $2","894",null],["(\\\\d{3})(\\\\d{4})(\\\\d{4})","$1 $2 $3","3",null]]]','["VA","00",null,null,null,null,"\\\\d{6,11}","(?:0(?:878\\\\d{5}|6698\\\\d{5})|[1589]\\\\d{5,10}|3(?:[12457-9]\\\\d{8}|[36]\\\\d{7,9}))"]'], +"993": '["TM","810","8",null,null,"($NP $FG)","\\\\d{8}","[1-6]\\\\d{7}",[["(\\\\d{2})(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2-$3-$4","12",null],["(\\\\d{2})(\\\\d{6})","$1 $2","6","$NP $FG"],["(\\\\d{3})(\\\\d)(\\\\d{2})(\\\\d{2})","$1 $2-$3-$4","13|[2-5]",null]]]', +"888": '["001",null,null,null,null,null,"\\\\d{11}","\\\\d{11}",[["(\\\\d{3})(\\\\d{3})(\\\\d{5})","$1 $2 $3",null,null]]]', +"353": '["IE","00","0",null,null,"($NP$FG)","\\\\d{5,10}","[124-9]\\\\d{6,9}",[["(1)(\\\\d{3,4})(\\\\d{4})","$1 $2 $3","1",null],["(\\\\d{2})(\\\\d{5})","$1 $2","2[24-9]|47|58|6[237-9]|9[35-9]",null],["(\\\\d{3})(\\\\d{5})","$1 $2","40[24]|50[45]",null],["(48)(\\\\d{4})(\\\\d{4})","$1 $2 $3","48",null],["(818)(\\\\d{3})(\\\\d{3})","$1 $2 $3","81",null],["(\\\\d{2})(\\\\d{3})(\\\\d{3,4})","$1 $2 $3","[24-69]|7[14]",null],["([78]\\\\d)(\\\\d{3,4})(\\\\d{4})","$1 $2 $3","76|8[35-9]","$NP$FG"],["(700)(\\\\d{3})(\\\\d{3})","$1 $2 $3","70","$NP$FG"],["(\\\\d{4})(\\\\d{3})(\\\\d{3})","$1 $2 $3","1(?:8[059]|5)","$FG"]]]', +"966": '["SA","00","0",null,null,"$NP$FG","\\\\d{7,10}","1\\\\d{7,8}|(?:[2-467]|92)\\\\d{7}|5\\\\d{8}|8\\\\d{9}",[["([1-467])(\\\\d{3})(\\\\d{4})","$1 $2 $3","[1-467]",null],["(1\\\\d)(\\\\d{3})(\\\\d{4})","$1 $2 $3","1[1-467]",null],["(5\\\\d)(\\\\d{3})(\\\\d{4})","$1 $2 $3","5",null],["(92\\\\d{2})(\\\\d{5})","$1 $2","92","$FG"],["(800)(\\\\d{3})(\\\\d{4})","$1 $2 $3","80","$FG"],["(811)(\\\\d{3})(\\\\d{3,4})","$1 $2 $3","81",null]]]', +"380": '["UA","00","0",null,null,"$NP$FG","\\\\d{5,9}","[3-9]\\\\d{8}",[["([3-9]\\\\d)(\\\\d{3})(\\\\d{4})","$1 $2 $3","[38]9|4(?:[45][0-5]|87)|5(?:0|6[37]|7[37])|6[36-8]|7|9[1-9]",null],["([3-689]\\\\d{2})(\\\\d{3})(\\\\d{3})","$1 $2 $3","3[1-8]2|4[13678]2|5(?:[12457]2|6[24])|6(?:[49]2|[12][29]|5[24])|8[0-8]|90",null],["([3-6]\\\\d{3})(\\\\d{5})","$1 $2","3(?:5[013-9]|[1-46-8])|4(?:[137][013-9]|6|[45][6-9]|8[4-6])|5(?:[1245][013-9]|6[0135-9]|3|7[4-6])|6(?:[49][013-9]|5[0135-9]|[12][13-8])",null]]]', +"98": '["IR","00","0",null,null,"$NP$FG","\\\\d{4,10}","[1-8]\\\\d{9}|9(?:[0-4]\\\\d{8}|9\\\\d{2,8})",[["(21)(\\\\d{3,5})","$1 $2","21",null],["(\\\\d{2})(\\\\d{4})(\\\\d{4})","$1 $2 $3","[1-8]",null],["(\\\\d{3})(\\\\d{3})","$1 $2","9",null],["(\\\\d{3})(\\\\d{2})(\\\\d{2,3})","$1 $2 $3","9",null],["(\\\\d{3})(\\\\d{3})(\\\\d{3,4})","$1 $2 $3","9",null]]]', +"971": '["AE","00","0",null,null,"$NP$FG","\\\\d{5,12}","[2-79]\\\\d{7,8}|800\\\\d{2,9}",[["([2-4679])(\\\\d{3})(\\\\d{4})","$1 $2 $3","[2-4679][2-8]",null],["(5\\\\d)(\\\\d{3})(\\\\d{4})","$1 $2 $3","5",null],["([479]00)(\\\\d)(\\\\d{5})","$1 $2 $3","[479]0","$FG"],["([68]00)(\\\\d{2,9})","$1 $2","60|8","$FG"]]]', +"30": '["GR","00",null,null,null,null,"\\\\d{10}","[26-9]\\\\d{9}",[["([27]\\\\d)(\\\\d{4})(\\\\d{4})","$1 $2 $3","21|7",null],["(\\\\d{3})(\\\\d{3})(\\\\d{4})","$1 $2 $3","2[2-9]1|[689]",null],["(2\\\\d{3})(\\\\d{6})","$1 $2","2[2-9][02-9]",null]]]', +"228": '["TG","00",null,null,null,null,"\\\\d{8}","[29]\\\\d{7}",[["(\\\\d{2})(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4","[29]",null]]]', +"48": '["PL","00",null,null,null,null,"\\\\d{6,9}","[12]\\\\d{6,8}|[3-57-9]\\\\d{8}|6\\\\d{5,8}",[["(\\\\d{2})(\\\\d{3})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4","[14]|2[0-57-9]|3[2-4]|5[24-689]|6[1-3578]|7[14-7]|8[1-79]|9[145]",null],["(\\\\d{2})(\\\\d{1})(\\\\d{4})","$1 $2 $3","[12]2",null],["(\\\\d{3})(\\\\d{3})(\\\\d{3})","$1 $2 $3","26|39|5[0137]|6[0469]|7[02389]|8[08]",null],["(\\\\d{3})(\\\\d{2})(\\\\d{2,3})","$1 $2 $3","64",null],["(\\\\d{3})(\\\\d{3})","$1 $2","64",null]]]', +"886": '["TW","0(?:0[25679]|19)","0",null,null,"$NP$FG","\\\\d{7,10}","2\\\\d{6,8}|[3-689]\\\\d{7,8}|7\\\\d{7,9}",[["(20)(\\\\d)(\\\\d{4})","$1 $2 $3","202",null],["(20)(\\\\d{3})(\\\\d{4})","$1 $2 $3","20[013-9]",null],["([2-8])(\\\\d{3,4})(\\\\d{4})","$1 $2 $3","2[23-8]|[3-6]|[78][1-9]",null],["([89]\\\\d{2})(\\\\d{3})(\\\\d{3})","$1 $2 $3","80|9",null],["(70)(\\\\d{4})(\\\\d{4})","$1 $2 $3","70",null]]]', +"212": ['["MA","00","0",null,null,"$NP$FG","\\\\d{9}","[5-9]\\\\d{8}",[["([5-7]\\\\d{2})(\\\\d{6})","$1-$2","5(?:2[015-7]|3[0-4])|[67]",null],["([58]\\\\d{3})(\\\\d{5})","$1-$2","5(?:2[2-489]|3[5-9]|92)|892",null],["(5\\\\d{4})(\\\\d{4})","$1-$2","5(?:29|38)",null],["([5]\\\\d{2})(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4","5(?:4[067]|5[03])",null],["(8[09])(\\\\d{7})","$1-$2","8(?:0|9[013-9])",null]]]','["EH","00","0",null,null,"$NP$FG","\\\\d{9}","[5-9]\\\\d{8}"]'], +"372": '["EE","00",null,null,null,null,"\\\\d{4,10}","1\\\\d{3,4}|[3-9]\\\\d{6,7}|800\\\\d{6,7}",[["([3-79]\\\\d{2})(\\\\d{4})","$1 $2","[369]|4[3-8]|5(?:[0-2]|5[0-478]|6[45])|7[1-9]",null],["(70)(\\\\d{2})(\\\\d{4})","$1 $2 $3","70",null],["(8000)(\\\\d{3})(\\\\d{3})","$1 $2 $3","800",null],["([458]\\\\d{3})(\\\\d{3,4})","$1 $2","40|5|8(?:00|[1-5])",null]]]', +"598": '["UY","0(?:1[3-9]\\\\d|0)","0",null,null,null,"\\\\d{7,8}","[2489]\\\\d{6,7}",[["(\\\\d{4})(\\\\d{4})","$1 $2","[24]",null],["(\\\\d{2})(\\\\d{3})(\\\\d{3})","$1 $2 $3","9[1-9]","$NP$FG"],["(\\\\d{3})(\\\\d{4})","$1 $2","[89]0","$NP$FG"]]]', +"502": '["GT","00",null,null,null,null,"\\\\d{8}(?:\\\\d{3})?","[2-7]\\\\d{7}|1[89]\\\\d{9}",[["(\\\\d{4})(\\\\d{4})","$1 $2","[2-7]",null],["(\\\\d{4})(\\\\d{3})(\\\\d{4})","$1 $2 $3","1",null]]]', +"82": '["KR","00(?:[124-68]|3\\\\d{2}|7(?:[0-8]\\\\d|9[0-79]))","0","0(8[1-46-8]|85\\\\d{2})?",null,"$NP$FG","\\\\d{3,14}","007\\\\d{9,11}|[1-7]\\\\d{3,9}|8\\\\d{8}",[["(\\\\d{5})(\\\\d{3,4})(\\\\d{4})","$1 $2 $3","00798","$FG","NA"],["(\\\\d{5})(\\\\d{2})(\\\\d{3})(\\\\d{4})","$1 $2 $3 $4","00798","$FG","NA"],["(\\\\d{2})(\\\\d{4})(\\\\d{4})","$1-$2-$3","1(?:0|1[19]|[69]9|5[458])|[57]0",null],["(\\\\d{2})(\\\\d{3,4})(\\\\d{4})","$1-$2-$3","1(?:[01]|5[1-4]|6[2-8]|[7-9])|[68]0|[3-6][1-9][1-9]",null],["(\\\\d{3})(\\\\d)(\\\\d{4})","$1-$2-$3","131",null],["(\\\\d{3})(\\\\d{2})(\\\\d{4})","$1-$2-$3","131",null],["(\\\\d{3})(\\\\d{3})(\\\\d{4})","$1-$2-$3","13[2-9]",null],["(\\\\d{2})(\\\\d{2})(\\\\d{3})(\\\\d{4})","$1-$2-$3-$4","30",null],["(\\\\d)(\\\\d{3,4})(\\\\d{4})","$1-$2-$3","2[1-9]",null],["(\\\\d)(\\\\d{3,4})","$1-$2","21[0-46-9]",null],["(\\\\d{2})(\\\\d{3,4})","$1-$2","[3-6][1-9]1",null],["(\\\\d{4})(\\\\d{4})","$1-$2","1(?:5[246-9]|6[04678]|8[03579])","$FG"]]]', +"253": '["DJ","00",null,null,null,null,"\\\\d{8}","[27]\\\\d{7}",[["(\\\\d{2})(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4",null,null]]]', +"91": '["IN","00","0",null,null,"$NP$FG","\\\\d{6,13}","008\\\\d{9}|1\\\\d{7,12}|[2-9]\\\\d{9,10}",[["(\\\\d{5})(\\\\d{5})","$1 $2","600|7(?:[02-8]|19|9[037-9])|8(?:0[015-9]|[1-9]|20)|9",null],["(\\\\d{2})(\\\\d{4})(\\\\d{4})","$1 $2 $3","11|2[02]|33|4[04]|79[1-9]|80[2-46]",null],["(\\\\d{3})(\\\\d{3})(\\\\d{4})","$1 $2 $3","1(?:2[0-249]|3[0-25]|4[145]|[59][14]|7[1257]|[68][1-9])|2(?:1[257]|3[013]|4[01]|5[0137]|6[0158]|78|8[1568]|9[14])|3(?:26|4[1-3]|5[34]|6[01489]|7[02-46]|8[159])|4(?:1[36]|2[1-47]|3[15]|5[12]|6[0-26-9]|7[0-24-9]|8[013-57]|9[014-7])|5(?:1[025]|[36][25]|22|4[28]|5[12]|[78]1|9[15])|6(?:12|[2-4]1|5[17]|6[13]|7[14]|80)|7(?:12|2[14]|3[134]|4[47]|5[15]|[67]1|88)|8(?:16|2[014]|3[126]|6[136]|7[078]|8[34]|91)",null],["(\\\\d{4})(\\\\d{3})(\\\\d{3})","$1 $2 $3","1(?:[23579]|[468][1-9])|[2-8]",null],["(\\\\d{2})(\\\\d{3})(\\\\d{4})(\\\\d{3})","$1 $2 $3 $4","008",null],["(\\\\d{3})(\\\\d{3})(\\\\d{4})","$1 $2 $3","140","$FG"],["(\\\\d{4})(\\\\d{2})(\\\\d{4})","$1 $2 $3","160","$FG"],["(\\\\d{4})(\\\\d{4,5})","$1 $2","180","$FG"],["(\\\\d{4})(\\\\d{2,4})(\\\\d{4})","$1 $2 $3","180","$FG"],["(\\\\d{4})(\\\\d{3,4})(\\\\d{4})","$1 $2 $3","186","$FG"],["(\\\\d{4})(\\\\d{3})(\\\\d{3})(\\\\d{3})","$1 $2 $3 $4","18[06]","$FG"]]]', +"389": '["MK","00","0",null,null,"$NP$FG","\\\\d{6,8}","[2-578]\\\\d{7}",[["(2)(\\\\d{3})(\\\\d{4})","$1 $2 $3","2",null],["([347]\\\\d)(\\\\d{3})(\\\\d{3})","$1 $2 $3","[347]",null],["([58]\\\\d{2})(\\\\d)(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4","[58]",null]]]', +"1": ['["US","011","1",null,null,null,"\\\\d{7}(?:\\\\d{3})?","[2-9]\\\\d{9}",[["(\\\\d{3})(\\\\d{4})","$1-$2",null,null,"NA"],["(\\\\d{3})(\\\\d{3})(\\\\d{4})","($1) $2-$3",null,null,"$1-$2-$3"]]]','["AI","011","1",null,null,null,"\\\\d{7}(?:\\\\d{3})?","[2589]\\\\d{9}"]','["AS","011","1",null,null,null,"\\\\d{7}(?:\\\\d{3})?","[5689]\\\\d{9}"]','["BB","011","1",null,null,null,"\\\\d{7}(?:\\\\d{3})?","[2589]\\\\d{9}"]','["BM","011","1",null,null,null,"\\\\d{7}(?:\\\\d{3})?","[4589]\\\\d{9}"]','["BS","011","1",null,null,null,"\\\\d{7}(?:\\\\d{3})?","[2589]\\\\d{9}"]','["CA","011","1",null,null,null,"\\\\d{7}(?:\\\\d{3})?","[2-9]\\\\d{9}|3\\\\d{6}"]','["DM","011","1",null,null,null,"\\\\d{7}(?:\\\\d{3})?","[57-9]\\\\d{9}"]','["DO","011","1",null,null,null,"\\\\d{7}(?:\\\\d{3})?","[589]\\\\d{9}"]','["GD","011","1",null,null,null,"\\\\d{7}(?:\\\\d{3})?","[4589]\\\\d{9}"]','["GU","011","1",null,null,null,"\\\\d{7}(?:\\\\d{3})?","[5689]\\\\d{9}"]','["JM","011","1",null,null,null,"\\\\d{7}(?:\\\\d{3})?","[589]\\\\d{9}"]','["KN","011","1",null,null,null,"\\\\d{7}(?:\\\\d{3})?","[589]\\\\d{9}"]','["KY","011","1",null,null,null,"\\\\d{7}(?:\\\\d{3})?","[3589]\\\\d{9}"]','["LC","011","1",null,null,null,"\\\\d{7}(?:\\\\d{3})?","[5789]\\\\d{9}"]','["MP","011","1",null,null,null,"\\\\d{7}(?:\\\\d{3})?","[5689]\\\\d{9}"]','["MS","011","1",null,null,null,"\\\\d{7}(?:\\\\d{3})?","[5689]\\\\d{9}"]','["PR","011","1",null,null,null,"\\\\d{7}(?:\\\\d{3})?","[5789]\\\\d{9}"]','["SX","011","1",null,null,null,"\\\\d{7}(?:\\\\d{3})?","[5789]\\\\d{9}"]','["TC","011","1",null,null,null,"\\\\d{7}(?:\\\\d{3})?","[5689]\\\\d{9}"]','["TT","011","1",null,null,null,"\\\\d{7}(?:\\\\d{3})?","[589]\\\\d{9}"]','["AG","011","1",null,null,null,"\\\\d{7}(?:\\\\d{3})?","[2589]\\\\d{9}"]','["VC","011","1",null,null,null,"\\\\d{7}(?:\\\\d{3})?","[5789]\\\\d{9}"]','["VG","011","1",null,null,null,"\\\\d{7}(?:\\\\d{3})?","[2589]\\\\d{9}"]','["VI","011","1",null,null,null,"\\\\d{7}(?:\\\\d{3})?","[3589]\\\\d{9}"]'], +"60": '["MY","00","0",null,null,null,"\\\\d{6,10}","[13-9]\\\\d{7,9}",[["([4-79])(\\\\d{3})(\\\\d{4})","$1-$2 $3","[4-79]","$NP$FG"],["(3)(\\\\d{4})(\\\\d{4})","$1-$2 $3","3","$NP$FG"],["([18]\\\\d)(\\\\d{3})(\\\\d{3,4})","$1-$2 $3","1[02-46-9][1-9]|8","$NP$FG"],["(1)([36-8]00)(\\\\d{2})(\\\\d{4})","$1-$2-$3-$4","1[36-8]0",null],["(11)(\\\\d{4})(\\\\d{4})","$1-$2 $3","11","$NP$FG"],["(15[49])(\\\\d{3})(\\\\d{4})","$1-$2 $3","15","$NP$FG"]]]', +"355": '["AL","00","0",null,null,"$NP$FG","\\\\d{5,9}","[2-57]\\\\d{7}|6\\\\d{8}|8\\\\d{5,7}|9\\\\d{5}",[["(4)(\\\\d{3})(\\\\d{4})","$1 $2 $3","4[0-6]",null],["(6\\\\d)(\\\\d{3})(\\\\d{4})","$1 $2 $3","6",null],["(\\\\d{2})(\\\\d{3})(\\\\d{3})","$1 $2 $3","[2358][2-5]|4[7-9]",null],["(\\\\d{3})(\\\\d{3,5})","$1 $2","[235][16-9]|8[016-9]|[79]",null]]]', +"254": '["KE","000","0","005|0",null,"$NP$FG","\\\\d{7,10}","20\\\\d{6,7}|[4-9]\\\\d{6,9}",[["(\\\\d{2})(\\\\d{5,7})","$1 $2","[24-6]",null],["(\\\\d{3})(\\\\d{6})","$1 $2","7",null],["(\\\\d{3})(\\\\d{3})(\\\\d{3,4})","$1 $2 $3","[89]",null]]]', +"223": '["ML","00",null,null,null,null,"\\\\d{8}","[246-9]\\\\d{7}",[["(\\\\d{2})(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4","[246-9]",null],["(\\\\d{4})","$1","67|74",null,"NA"]]]', +"686": '["KI","00",null,"0",null,null,"\\\\d{5,8}","[2458]\\\\d{4}|3\\\\d{4,7}|7\\\\d{7}"]', +"994": '["AZ","00","0",null,null,"($NP$FG)","\\\\d{7,9}","[1-9]\\\\d{8}",[["(\\\\d{2})(\\\\d{3})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4","(?:1[28]|2(?:[45]2|[0-36])|365)",null],["(\\\\d{2})(\\\\d{3})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4","[4-8]","$NP$FG"],["(\\\\d{3})(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4","9","$NP$FG"]]]', +"979": '["001",null,null,null,null,null,"\\\\d{9}","\\\\d{9}",[["(\\\\d)(\\\\d{4})(\\\\d{4})","$1 $2 $3",null,null]]]', +"66": '["TH","00","0",null,null,"$NP$FG","\\\\d{4}|\\\\d{8,10}","[2-9]\\\\d{7,8}|1\\\\d{3}(?:\\\\d{5,6})?",[["(2)(\\\\d{3})(\\\\d{4})","$1 $2 $3","2",null],["([13-9]\\\\d)(\\\\d{3})(\\\\d{3,4})","$1 $2 $3","14|[3-9]",null],["(1[89]00)(\\\\d{3})(\\\\d{3})","$1 $2 $3","1","$FG"]]]', +"233": '["GH","00","0",null,null,"$NP$FG","\\\\d{7,9}","[235]\\\\d{8}|8\\\\d{7}",[["(\\\\d{2})(\\\\d{3})(\\\\d{4})","$1 $2 $3","[235]",null],["(\\\\d{3})(\\\\d{5})","$1 $2","8",null]]]', +"593": '["EC","00","0",null,null,"($NP$FG)","\\\\d{7,11}","1\\\\d{9,10}|[2-8]\\\\d{7}|9\\\\d{8}",[["(\\\\d)(\\\\d{3})(\\\\d{4})","$1 $2-$3","[247]|[356][2-8]",null,"$1-$2-$3"],["(\\\\d{2})(\\\\d{3})(\\\\d{4})","$1 $2 $3","9","$NP$FG"],["(1800)(\\\\d{3})(\\\\d{3,4})","$1 $2 $3","1","$FG"]]]', +"509": '["HT","00",null,null,null,null,"\\\\d{8}","[2-489]\\\\d{7}",[["(\\\\d{2})(\\\\d{2})(\\\\d{4})","$1 $2 $3",null,null]]]', +"54": '["AR","00","0","0?(?:(11|2(?:2(?:02?|[13]|2[13-79]|4[1-6]|5[2457]|6[124-8]|7[1-4]|8[13-6]|9[1267])|3(?:02?|1[467]|2[03-6]|3[13-8]|[49][2-6]|5[2-8]|[67])|4(?:7[3-578]|9)|6(?:[0136]|2[24-6]|4[6-8]?|5[15-8])|80|9(?:0[1-3]|[19]|2\\\\d|3[1-6]|4[02568]?|5[2-4]|6[2-46]|72?|8[23]?))|3(?:3(?:2[79]|6|8[2578])|4(?:0[0-24-9]|[12]|3[5-8]?|4[24-7]|5[4-68]?|6[02-9]|7[126]|8[2379]?|9[1-36-8])|5(?:1|2[1245]|3[237]?|4[1-46-9]|6[2-4]|7[1-6]|8[2-5]?)|6[24]|7(?:[069]|1[1568]|2[15]|3[145]|4[13]|5[14-8]|7[2-57]|8[126])|8(?:[01]|2[15-7]|3[2578]?|4[13-6]|5[4-8]?|6[1-357-9]|7[36-8]?|8[5-8]?|9[124])))?15)?","9$1","$NP$FG","\\\\d{6,11}","11\\\\d{8}|[2368]\\\\d{9}|9\\\\d{10}",[["([68]\\\\d{2})(\\\\d{3})(\\\\d{4})","$1-$2-$3","[68]",null],["(\\\\d{2})(\\\\d{4})","$1-$2","[2-9]","$FG","NA"],["(\\\\d{3})(\\\\d{4})","$1-$2","[2-9]","$FG","NA"],["(\\\\d{4})(\\\\d{4})","$1-$2","[2-9]","$FG","NA"],["(9)(11)(\\\\d{4})(\\\\d{4})","$2 15-$3-$4","911",null,"$1 $2 $3-$4"],["(9)(\\\\d{3})(\\\\d{3})(\\\\d{4})","$2 15-$3-$4","9(?:2[234689]|3[3-8])",null,"$1 $2 $3-$4"],["(9)(\\\\d{4})(\\\\d{2})(\\\\d{4})","$2 15-$3-$4","9[23]",null,"$1 $2 $3-$4"],["(11)(\\\\d{4})(\\\\d{4})","$1 $2-$3","1",null],["(\\\\d{3})(\\\\d{3})(\\\\d{4})","$1 $2-$3","2(?:2[013]|3[067]|49|6[01346]|80|9[147-9])|3(?:36|4[1-358]|5[138]|6[24]|7[069]|8[013578])",null],["(\\\\d{4})(\\\\d{2})(\\\\d{4})","$1 $2-$3","[23]",null],["(\\\\d{3})","$1","1[012]|911","$FG","NA"]]]', +"57": '["CO","00(?:4(?:[14]4|56)|[579])","0","0([3579]|4(?:44|56))?",null,null,"\\\\d{7,11}","(?:[13]\\\\d{0,3}|[24-8])\\\\d{7}",[["(\\\\d)(\\\\d{7})","$1 $2","1(?:8[2-9]|9[0-3]|[2-7])|[24-8]","($FG)"],["(\\\\d{3})(\\\\d{7})","$1 $2","3",null],["(1)(\\\\d{3})(\\\\d{7})","$1-$2-$3","1(?:80|9[04])","$NP$FG","$1 $2 $3"]]]', +"597": '["SR","00",null,null,null,null,"\\\\d{6,7}","[2-8]\\\\d{5,6}",[["(\\\\d{3})(\\\\d{3})","$1-$2","[2-4]|5[2-58]",null],["(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1-$2-$3","56",null],["(\\\\d{3})(\\\\d{4})","$1-$2","[6-8]",null]]]', +"676": '["TO","00",null,null,null,null,"\\\\d{5,7}","[02-8]\\\\d{4,6}",[["(\\\\d{2})(\\\\d{3})","$1-$2","[1-6]|7[0-4]|8[05]",null],["(\\\\d{3})(\\\\d{4})","$1 $2","7[5-9]|8[47-9]",null],["(\\\\d{4})(\\\\d{3})","$1 $2","0",null]]]', +"505": '["NI","00",null,null,null,null,"\\\\d{8}","[12578]\\\\d{7}",[["(\\\\d{4})(\\\\d{4})","$1 $2",null,null]]]', +"850": '["KP","00|99","0",null,null,"$NP$FG","\\\\d{6,8}|\\\\d{10}","1\\\\d{9}|[28]\\\\d{7}",[["(\\\\d{3})(\\\\d{3})(\\\\d{4})","$1 $2 $3","1",null],["(\\\\d)(\\\\d{3})(\\\\d{4})","$1 $2 $3","2",null],["(\\\\d{2})(\\\\d{3})(\\\\d{3})","$1 $2 $3","8",null]]]', +"7": ['["RU","810","8",null,null,"$NP ($FG)","\\\\d{10}","[3489]\\\\d{9}",[["(\\\\d{3})(\\\\d{2})(\\\\d{2})","$1-$2-$3","[1-79]","$FG","NA"],["([3489]\\\\d{2})(\\\\d{3})(\\\\d{2})(\\\\d{2})","$1 $2-$3-$4","[34689]",null],["(7\\\\d{2})(\\\\d{3})(\\\\d{4})","$1 $2 $3","7",null]]]','["KZ","810","8",null,null,null,"\\\\d{10}","(?:33\\\\d|7\\\\d{2}|80[09])\\\\d{7}"]'], +"268": '["SZ","00",null,null,null,null,"\\\\d{8}","[027]\\\\d{7}",[["(\\\\d{4})(\\\\d{4})","$1 $2","[027]",null]]]', +"501": '["BZ","00",null,null,null,null,"\\\\d{7}(?:\\\\d{4})?","[2-8]\\\\d{6}|0\\\\d{10}",[["(\\\\d{3})(\\\\d{4})","$1-$2","[2-8]",null],["(0)(800)(\\\\d{4})(\\\\d{3})","$1-$2-$3-$4","0",null]]]', +"252": '["SO","00","0",null,null,null,"\\\\d{6,9}","[1-9]\\\\d{5,8}",[["(\\\\d{6})","$1","[134]",null],["(\\\\d)(\\\\d{6})","$1 $2","2[0-79]|[13-5]",null],["(\\\\d)(\\\\d{7})","$1 $2","24|[67]",null],["(\\\\d{2})(\\\\d{4})","$1 $2","8[125]",null],["(\\\\d{2})(\\\\d{5,7})","$1 $2","15|28|6[1-35-9]|799|9[2-9]",null],["(\\\\d{3})(\\\\d{3})(\\\\d{3})","$1 $2 $3","3[59]|4[89]|6[24-6]|79|8[08]|90",null]]]', +"229": '["BJ","00",null,null,null,null,"\\\\d{4,8}","[2689]\\\\d{7}|7\\\\d{3}",[["(\\\\d{2})(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4",null,null]]]', +"680": '["PW","01[12]",null,null,null,null,"\\\\d{7}","[2-8]\\\\d{6}",[["(\\\\d{3})(\\\\d{4})","$1 $2",null,null]]]', +"263": '["ZW","00","0",null,null,"$NP$FG","\\\\d{3,10}","2(?:[012457-9]\\\\d{3,8}|6(?:[14]\\\\d{7}|\\\\d{4}))|[13-79]\\\\d{4,9}|8[06]\\\\d{8}",[["([49])(\\\\d{3})(\\\\d{2,4})","$1 $2 $3","4|9[2-9]",null],["(7\\\\d)(\\\\d{3})(\\\\d{3,4})","$1 $2 $3","7",null],["(86\\\\d{2})(\\\\d{3})(\\\\d{3})","$1 $2 $3","86[24]",null],["([2356]\\\\d{2})(\\\\d{3,5})","$1 $2","2(?:0[45]|2[278]|[49]8|[78])|3(?:08|17|3[78]|7[1569]|8[37]|98)|5[15][78]|6(?:[29]8|[38]7|6[78]|75|[89]8)",null],["(\\\\d{3})(\\\\d{3})(\\\\d{3,4})","$1 $2 $3","2(?:1[39]|2[0157]|6[14]|7[35]|84)|329",null],["([1-356]\\\\d)(\\\\d{3,5})","$1 $2","1[3-9]|2[0569]|3[0-69]|5[05689]|6[0-46-9]",null],["([235]\\\\d)(\\\\d{3})(\\\\d{3,4})","$1 $2 $3","[23]9|54",null],["([25]\\\\d{3})(\\\\d{3,5})","$1 $2","(?:25|54)8",null],["(8\\\\d{3})(\\\\d{6})","$1 $2","86",null],["(80\\\\d)(\\\\d{3})(\\\\d{4})","$1 $2 $3","80",null]]]', +"90": '["TR","00","0",null,null,null,"\\\\d{7,10}","[2-589]\\\\d{9}|444\\\\d{4}",[["(\\\\d{3})(\\\\d{3})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4","[23]|4(?:[0-35-9]|4[0-35-9])","($NP$FG)"],["(\\\\d{3})(\\\\d{3})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4","5[02-69]","$NP$FG"],["(\\\\d{3})(\\\\d{3})(\\\\d{4})","$1 $2 $3","51|[89]","$NP$FG"],["(444)(\\\\d{1})(\\\\d{3})","$1 $2 $3","444",null]]]', +"352": '["LU","00",null,"(15(?:0[06]|1[12]|35|4[04]|55|6[26]|77|88|99)\\\\d)",null,null,"\\\\d{4,11}","[24-9]\\\\d{3,10}|3(?:[0-46-9]\\\\d{2,9}|5[013-9]\\\\d{1,8})",[["(\\\\d{2})(\\\\d{3})","$1 $2","[2-5]|7[1-9]|[89](?:[1-9]|0[2-9])",null],["(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3","[2-5]|7[1-9]|[89](?:[1-9]|0[2-9])",null],["(\\\\d{2})(\\\\d{2})(\\\\d{3})","$1 $2 $3","20",null],["(\\\\d{2})(\\\\d{2})(\\\\d{2})(\\\\d{1,2})","$1 $2 $3 $4","2(?:[0367]|4[3-8])",null],["(\\\\d{2})(\\\\d{2})(\\\\d{2})(\\\\d{3})","$1 $2 $3 $4","20",null],["(\\\\d{2})(\\\\d{2})(\\\\d{2})(\\\\d{2})(\\\\d{1,2})","$1 $2 $3 $4 $5","2(?:[0367]|4[3-8])",null],["(\\\\d{2})(\\\\d{2})(\\\\d{2})(\\\\d{1,4})","$1 $2 $3 $4","2(?:[12589]|4[12])|[3-5]|7[1-9]|8(?:[1-9]|0[2-9])|9(?:[1-9]|0[2-46-9])",null],["(\\\\d{3})(\\\\d{2})(\\\\d{3})","$1 $2 $3","70|80[01]|90[015]",null],["(\\\\d{3})(\\\\d{3})(\\\\d{3})","$1 $2 $3","6",null]]]', +"47": ['["NO","00",null,null,null,null,"\\\\d{5}(?:\\\\d{3})?","0\\\\d{4}|[2-9]\\\\d{7}",[["([489]\\\\d{2})(\\\\d{2})(\\\\d{3})","$1 $2 $3","[489]",null],["([235-7]\\\\d)(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4","[235-7]",null]]]','["SJ","00",null,null,null,null,"\\\\d{5}(?:\\\\d{3})?","0\\\\d{4}|[45789]\\\\d{7}"]'], +"243": '["CD","00","0",null,null,"$NP$FG","\\\\d{7,9}","[2-6]\\\\d{6}|[18]\\\\d{6,8}|9\\\\d{8}",[["(\\\\d{2})(\\\\d{3})(\\\\d{4})","$1 $2 $3","12",null],["([89]\\\\d{2})(\\\\d{3})(\\\\d{3})","$1 $2 $3","8[0-2459]|9",null],["(\\\\d{2})(\\\\d{2})(\\\\d{3})","$1 $2 $3","88",null],["(\\\\d{2})(\\\\d{5})","$1 $2","[1-6]",null]]]', +"220": '["GM","00",null,null,null,null,"\\\\d{7}","[2-9]\\\\d{6}",[["(\\\\d{3})(\\\\d{4})","$1 $2",null,null]]]', +"687": '["NC","00",null,null,null,null,"\\\\d{6}","[2-57-9]\\\\d{5}",[["(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1.$2.$3","[2-46-9]|5[0-4]",null]]]', +"995": '["GE","00","0",null,null,null,"\\\\d{6,9}","[34578]\\\\d{8}",[["(\\\\d{3})(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4","[348]","$NP$FG"],["(\\\\d{3})(\\\\d{3})(\\\\d{3})","$1 $2 $3","7","$NP$FG"],["(\\\\d{3})(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4","5","$FG"]]]', +"961": '["LB","00","0",null,null,null,"\\\\d{7,8}","[13-9]\\\\d{6,7}",[["(\\\\d)(\\\\d{3})(\\\\d{3})","$1 $2 $3","[13-6]|7(?:[2-57]|62|8[0-7]|9[04-9])|8[02-9]|9","$NP$FG"],["([7-9]\\\\d)(\\\\d{3})(\\\\d{3})","$1 $2 $3","[89][01]|7(?:[01]|6[013-9]|8[89]|9[1-3])",null]]]', +"40": '["RO","00","0",null,null,"$NP$FG","\\\\d{6,9}","[23]\\\\d{5,8}|[7-9]\\\\d{8}",[["(\\\\d{2})(\\\\d{3})(\\\\d{4})","$1 $2 $3","[23]1",null],["(\\\\d{2})(\\\\d{4})","$1 $2","[23]1",null],["(\\\\d{3})(\\\\d{3})(\\\\d{3})","$1 $2 $3","[23][3-7]|[7-9]",null],["(2\\\\d{2})(\\\\d{3})","$1 $2","2[3-6]",null]]]', +"232": '["SL","00","0",null,null,"($NP$FG)","\\\\d{6,8}","[2-9]\\\\d{7}",[["(\\\\d{2})(\\\\d{6})","$1 $2",null,null]]]', +"594": '["GF","00","0",null,null,"$NP$FG","\\\\d{9}","[56]\\\\d{8}",[["(\\\\d{3})(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4",null,null]]]', +"976": '["MN","001","0",null,null,"$NP$FG","\\\\d{6,10}","[12]\\\\d{7,9}|[57-9]\\\\d{7}",[["([12]\\\\d)(\\\\d{2})(\\\\d{4})","$1 $2 $3","[12]1",null],["([12]2\\\\d)(\\\\d{5,6})","$1 $2","[12]2[1-3]",null],["([12]\\\\d{3})(\\\\d{5})","$1 $2","[12](?:27|[3-5])",null],["(\\\\d{4})(\\\\d{4})","$1 $2","[57-9]","$FG"],["([12]\\\\d{4})(\\\\d{4,5})","$1 $2","[12](?:27|[3-5])",null]]]', +"20": '["EG","00","0",null,null,"$NP$FG","\\\\d{5,10}","1\\\\d{4,9}|[2456]\\\\d{8}|3\\\\d{7}|[89]\\\\d{8,9}",[["(\\\\d)(\\\\d{7,8})","$1 $2","[23]",null],["(\\\\d{3})(\\\\d{3})(\\\\d{4})","$1 $2 $3","1[012]|[89]00",null],["(\\\\d{2})(\\\\d{6,7})","$1 $2","1[35]|[4-6]|[89][2-9]",null]]]', +"689": '["PF","00",null,null,null,null,"\\\\d{6}(?:\\\\d{2})?","4\\\\d{5,7}|8\\\\d{7}",[["(\\\\d{2})(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4","4[09]|8[79]",null],["(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3","44",null]]]', +"56": '["CL","(?:0|1(?:1[0-69]|2[0-57]|5[13-58]|69|7[0167]|8[018]))0","0","0|(1(?:1[0-69]|2[0-57]|5[13-58]|69|7[0167]|8[018]))",null,"$NP$FG","\\\\d{7,11}","(?:[2-9]|600|123)\\\\d{7,8}",[["(\\\\d)(\\\\d{4})(\\\\d{4})","$1 $2 $3","2[23]","($FG)"],["(\\\\d{2})(\\\\d{3})(\\\\d{4})","$1 $2 $3","[357]|4[1-35]|6[13-57]","($FG)"],["(9)(\\\\d{4})(\\\\d{4})","$1 $2 $3","9",null],["(44)(\\\\d{3})(\\\\d{4})","$1 $2 $3","44",null],["([68]00)(\\\\d{3})(\\\\d{3,4})","$1 $2 $3","60|8","$FG"],["(600)(\\\\d{3})(\\\\d{2})(\\\\d{3})","$1 $2 $3 $4","60","$FG"],["(1230)(\\\\d{3})(\\\\d{4})","$1 $2 $3","1","$FG"],["(\\\\d{5})(\\\\d{4})","$1 $2","219","($FG)"],["(\\\\d{4,5})","$1","[1-9]","$FG","NA"]]]', +"596": '["MQ","00","0",null,null,"$NP$FG","\\\\d{9}","[56]\\\\d{8}",[["(\\\\d{3})(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4",null,null]]]', +"508": '["PM","00","0",null,null,"$NP$FG","\\\\d{6}","[45]\\\\d{5}",[["([45]\\\\d)(\\\\d{2})(\\\\d{2})","$1 $2 $3",null,null]]]', +"269": '["KM","00",null,null,null,null,"\\\\d{7}","[3478]\\\\d{6}",[["(\\\\d{3})(\\\\d{2})(\\\\d{2})","$1 $2 $3",null,null]]]', +"358": ['["FI","00|99(?:[02469]|5(?:11|33|5[59]|88|9[09]))","0",null,null,"$NP$FG","\\\\d{5,12}","1\\\\d{4,11}|[2-9]\\\\d{4,10}",[["(\\\\d{3})(\\\\d{3,7})","$1 $2","(?:[1-3]00|[6-8]0)",null],["(116\\\\d{3})","$1","116","$FG"],["(\\\\d{2})(\\\\d{4,10})","$1 $2","[14]|2[09]|50|7[135]",null],["(\\\\d)(\\\\d{4,11})","$1 $2","[25689][1-8]|3",null]]]','["AX","00|99(?:[02469]|5(?:11|33|5[59]|88|9[09]))","0",null,null,"$NP$FG","\\\\d{5,12}","1\\\\d{5,11}|[35]\\\\d{5,9}|[27]\\\\d{4,9}|4\\\\d{5,10}|6\\\\d{7,9}|8\\\\d{6,9}"]'], +"251": '["ET","00","0",null,null,"$NP$FG","\\\\d{7,9}","[1-59]\\\\d{8}",[["([1-59]\\\\d)(\\\\d{3})(\\\\d{4})","$1 $2 $3",null,null]]]', +"681": '["WF","00",null,null,null,null,"\\\\d{6}","[4-8]\\\\d{5}",[["(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3",null,null]]]', +"853": '["MO","00",null,null,null,null,"\\\\d{8}","[268]\\\\d{7}",[["([268]\\\\d{3})(\\\\d{4})","$1 $2",null,null]]]', +"44": ['["GB","00","0",null,null,"$NP$FG","\\\\d{4,10}","\\\\d{7,10}",[["(7\\\\d{3})(\\\\d{6})","$1 $2","7(?:[1-5789]|62)",null],["(\\\\d{2})(\\\\d{4})(\\\\d{4})","$1 $2 $3","2|5[56]|7[06]",null],["(\\\\d{3})(\\\\d{3})(\\\\d{4})","$1 $2 $3","1(?:1|\\\\d1)|3|9[018]",null],["(\\\\d{5})(\\\\d{4,5})","$1 $2","1(?:38|5[23]|69|76|94)",null],["(1\\\\d{3})(\\\\d{5,6})","$1 $2","1",null],["(800)(\\\\d{4})","$1 $2","800",null],["(845)(46)(4\\\\d)","$1 $2 $3","845",null],["(8\\\\d{2})(\\\\d{3})(\\\\d{4})","$1 $2 $3","8(?:4[2-5]|7[0-3])",null],["(80\\\\d)(\\\\d{3})(\\\\d{4})","$1 $2 $3","80",null],["([58]00)(\\\\d{6})","$1 $2","[58]00",null]]]','["GG","00","0",null,null,"$NP$FG","\\\\d{6,10}","[135789]\\\\d{6,9}"]','["IM","00","0",null,null,"$NP$FG","\\\\d{6,10}","[135789]\\\\d{6,9}"]','["JE","00","0",null,null,"$NP$FG","\\\\d{6,10}","[135789]\\\\d{6,9}"]'], +"244": '["AO","00",null,null,null,null,"\\\\d{9}","[29]\\\\d{8}",[["(\\\\d{3})(\\\\d{3})(\\\\d{3})","$1 $2 $3",null,null]]]', +"211": '["SS","00","0",null,null,null,"\\\\d{9}","[19]\\\\d{8}",[["(\\\\d{3})(\\\\d{3})(\\\\d{3})","$1 $2 $3",null,"$NP$FG"]]]', +"373": '["MD","00","0",null,null,"$NP$FG","\\\\d{8}","[235-9]\\\\d{7}",[["(\\\\d{2})(\\\\d{3})(\\\\d{3})","$1 $2 $3","22|3",null],["([25-7]\\\\d{2})(\\\\d{2})(\\\\d{3})","$1 $2 $3","2[13-9]|[5-7]",null],["([89]\\\\d{2})(\\\\d{5})","$1 $2","[89]",null]]]', +"996": '["KG","00","0",null,null,"$NP$FG","\\\\d{5,10}","[235-8]\\\\d{8,9}",[["(\\\\d{3})(\\\\d{3})(\\\\d{3})","$1 $2 $3","[25-7]|31[25]",null],["(\\\\d{4})(\\\\d{5})","$1 $2","3(?:1[36]|[2-9])",null],["(\\\\d{3})(\\\\d{3})(\\\\d)(\\\\d{3})","$1 $2 $3 $4","8",null]]]', +"93": '["AF","00","0",null,null,"$NP$FG","\\\\d{7,9}","[2-7]\\\\d{8}",[["([2-7]\\\\d)(\\\\d{3})(\\\\d{4})","$1 $2 $3","[2-7]",null]]]', +"260": '["ZM","00","0",null,null,"$NP$FG","\\\\d{9}","[289]\\\\d{8}",[["([29]\\\\d)(\\\\d{7})","$1 $2","[29]",null],["(800)(\\\\d{3})(\\\\d{3})","$1 $2 $3","8",null]]]', +"378": '["SM","00",null,"(?:0549)?([89]\\\\d{5})","0549$1",null,"\\\\d{6,10}","[05-7]\\\\d{7,9}",[["(\\\\d{2})(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4","[5-7]",null],["(0549)(\\\\d{6})","$1 $2","0",null,"($1) $2"],["(\\\\d{6})","0549 $1","[89]",null,"(0549) $1"]]]', +"235": '["TD","00|16",null,null,null,null,"\\\\d{8}","[2679]\\\\d{7}",[["(\\\\d{2})(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4",null,null]]]', +"960": '["MV","0(?:0|19)",null,null,null,null,"\\\\d{7,10}","[346-8]\\\\d{6,9}|9(?:00\\\\d{7}|\\\\d{6})",[["(\\\\d{3})(\\\\d{4})","$1-$2","[3467]|9(?:[1-9]|0[1-9])",null],["(\\\\d{3})(\\\\d{3})(\\\\d{4})","$1 $2 $3","[89]00",null]]]', +"221": '["SN","00",null,null,null,null,"\\\\d{9}","[3789]\\\\d{8}",[["(\\\\d{2})(\\\\d{3})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4","[379]",null],["(\\\\d{3})(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4","8",null]]]', +"595": '["PY","00","0",null,null,null,"\\\\d{5,9}","5[0-5]\\\\d{4,7}|[2-46-9]\\\\d{5,8}",[["(\\\\d{2})(\\\\d{5})","$1 $2","(?:[26]1|3[289]|4[124678]|7[123]|8[1236])","($NP$FG)"],["(\\\\d{2})(\\\\d{3})(\\\\d{3,4})","$1 $2 $3","(?:[26]1|3[289]|4[124678]|7[123]|8[1236])","($NP$FG)"],["(\\\\d{3})(\\\\d{3,6})","$1 $2","[2-9]0","$NP$FG"],["(\\\\d{3})(\\\\d{6})","$1 $2","9[1-9]","$NP$FG"],["(\\\\d{2})(\\\\d{3})(\\\\d{4})","$1 $2 $3","8700",null],["(\\\\d{3})(\\\\d{4,5})","$1 $2","[2-8][1-9]","($NP$FG)"],["(\\\\d{3})(\\\\d{3})(\\\\d{3})","$1 $2 $3","[2-8][1-9]","$NP$FG"]]]', +"977": '["NP","00","0",null,null,"$NP$FG","\\\\d{6,10}","[1-8]\\\\d{7}|9(?:[1-69]\\\\d{6,8}|7[2-6]\\\\d{5,7}|8\\\\d{8})",[["(1)(\\\\d{7})","$1-$2","1[2-6]",null],["(\\\\d{2})(\\\\d{6})","$1-$2","1[01]|[2-8]|9(?:[1-69]|7[15-9])",null],["(9\\\\d{2})(\\\\d{7})","$1-$2","9(?:6[013]|7[245]|8)","$FG"]]]', +"36": '["HU","00","06",null,null,"($FG)","\\\\d{6,9}","[1-9]\\\\d{7,8}",[["(1)(\\\\d{3})(\\\\d{4})","$1 $2 $3","1",null],["(\\\\d{2})(\\\\d{3})(\\\\d{3,4})","$1 $2 $3","[2-9]",null]]]', +}; diff --git a/browser/extensions/formautofill/phonenumberutils/PhoneNumberNormalizer.jsm b/browser/extensions/formautofill/phonenumberutils/PhoneNumberNormalizer.jsm new file mode 100644 index 0000000000..c8a2f37b6e --- /dev/null +++ b/browser/extensions/formautofill/phonenumberutils/PhoneNumberNormalizer.jsm @@ -0,0 +1,71 @@ +/* This Source Code Form is subject to the terms of the Apache License, Version + * 2.0. If a copy of the Apache License was not distributed with this file, You + * can obtain one at https://www.apache.org/licenses/LICENSE-2.0 */ + +// This library came from https://github.com/andreasgal/PhoneNumber.js but will +// be further maintained by our own in Form Autofill codebase. + +"use strict"; + +var EXPORTED_SYMBOLS = ["PhoneNumberNormalizer"]; + +var PhoneNumberNormalizer = (function() { + const UNICODE_DIGITS = /[\uFF10-\uFF19\u0660-\u0669\u06F0-\u06F9]/g; + const VALID_ALPHA_PATTERN = /[a-zA-Z]/g; + const LEADING_PLUS_CHARS_PATTERN = /^[+\uFF0B]+/g; + const NON_DIALABLE_CHARS = /[^,#+\*\d]/g; + + // Map letters to numbers according to the ITU E.161 standard + let E161 = { + a: 2, + b: 2, + c: 2, + d: 3, + e: 3, + f: 3, + g: 4, + h: 4, + i: 4, + j: 5, + k: 5, + l: 5, + m: 6, + n: 6, + o: 6, + p: 7, + q: 7, + r: 7, + s: 7, + t: 8, + u: 8, + v: 8, + w: 9, + x: 9, + y: 9, + z: 9, + }; + + // Normalize a number by converting unicode numbers and symbols to their + // ASCII equivalents and removing all non-dialable characters. + function NormalizeNumber(number, numbersOnly) { + if (typeof number !== "string") { + return ""; + } + + number = number.replace(UNICODE_DIGITS, function(ch) { + return String.fromCharCode(48 + (ch.charCodeAt(0) & 0xf)); + }); + if (!numbersOnly) { + number = number.replace(VALID_ALPHA_PATTERN, function(ch) { + return String(E161[ch.toLowerCase()] || 0); + }); + } + number = number.replace(LEADING_PLUS_CHARS_PATTERN, "+"); + number = number.replace(NON_DIALABLE_CHARS, ""); + return number; + } + + return { + Normalize: NormalizeNumber, + }; +})(); diff --git a/browser/extensions/formautofill/schema.json b/browser/extensions/formautofill/schema.json new file mode 100644 index 0000000000..fe51488c70 --- /dev/null +++ b/browser/extensions/formautofill/schema.json @@ -0,0 +1 @@ +[] diff --git a/browser/extensions/formautofill/skin/linux/autocomplete-item.css b/browser/extensions/formautofill/skin/linux/autocomplete-item.css new file mode 100644 index 0000000000..8f782aaa2a --- /dev/null +++ b/browser/extensions/formautofill/skin/linux/autocomplete-item.css @@ -0,0 +1,10 @@ +/* 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/. */ + +@namespace url("http://www.w3.org/1999/xhtml"); + + +.autofill-item-box { + --default-font-size: 14.25; +} diff --git a/browser/extensions/formautofill/skin/linux/editDialog.css b/browser/extensions/formautofill/skin/linux/editDialog.css new file mode 100644 index 0000000000..0f42d34b46 --- /dev/null +++ b/browser/extensions/formautofill/skin/linux/editDialog.css @@ -0,0 +1,8 @@ +/* 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/. */ + +/* Linux specific rules */ +dialog[subdialog] body { + font-size: 0.85rem; +} diff --git a/browser/extensions/formautofill/skin/osx/autocomplete-item.css b/browser/extensions/formautofill/skin/osx/autocomplete-item.css new file mode 100644 index 0000000000..b240fb21c8 --- /dev/null +++ b/browser/extensions/formautofill/skin/osx/autocomplete-item.css @@ -0,0 +1,9 @@ +/* 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/. */ + +@namespace url("http://www.w3.org/1999/xhtml"); + +.autofill-item-box { + --default-font-size: 11; +} diff --git a/browser/extensions/formautofill/skin/osx/editDialog.css b/browser/extensions/formautofill/skin/osx/editDialog.css new file mode 100644 index 0000000000..e22c07ec95 --- /dev/null +++ b/browser/extensions/formautofill/skin/osx/editDialog.css @@ -0,0 +1,5 @@ +/* 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/. */ + +/* OSX specific rules */ diff --git a/browser/extensions/formautofill/skin/shared/autocomplete-item-shared.css b/browser/extensions/formautofill/skin/shared/autocomplete-item-shared.css new file mode 100644 index 0000000000..babc974bea --- /dev/null +++ b/browser/extensions/formautofill/skin/shared/autocomplete-item-shared.css @@ -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/. */ + +@namespace url("http://www.w3.org/1999/xhtml"); +@namespace xul url("http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"); + + +xul|richlistitem[originaltype="autofill-profile"][selected="true"] > .autofill-item-box { + background-color: #F2F2F2; +} + +xul|richlistitem[originaltype="autofill-footer"][selected="true"] > .autofill-item-box > .autofill-button, +xul|richlistitem[originaltype="autofill-clear-button"][selected="true"] > .autofill-item-box > .autofill-button { + background-color: #DCDCDE; +} + +xul|richlistitem[originaltype="autofill-insecureWarning"] { + border-bottom: 1px solid var(--panel-separator-color); + background-color: var(--arrowpanel-dimmed); +} + +.autofill-item-box { + --item-padding-vertical: 7px; + --item-padding-horizontal: 10px; + --col-spacer: 7px; + --item-width: calc(50% - (var(--col-spacer) / 2)); + --label-text-color: #262626; + --comment-text-color: #646464; + --warning-text-color: #646464; + --btn-text-color: FieldText; + + --default-font-size: 12; + --label-affix-font-size: 10; + --label-font-size: 12; + --comment-font-size: 10; + --warning-font-size: 10; + --btn-font-size: 11; +} + +.autofill-item-box[size="small"] { + --item-padding-vertical: 7px; + --col-spacer: 0px; + --row-spacer: 3px; + --item-width: 100%; +} + +.autofill-item-box:not([ac-image=""]) { + --item-padding-vertical: 6.5px; + --comment-font-size: 11; +} + +.autofill-footer, +.autofill-footer[size="small"] { + --item-width: 100%; + --item-padding-vertical: 0; + --item-padding-horizontal: 0; +} + +.autofill-item-box { + box-sizing: border-box; + margin: 0; + border-bottom: 1px solid rgba(38,38,38,.15); + padding: var(--item-padding-vertical) 0; + padding-inline: var(--item-padding-horizontal); + display: flex; + flex-direction: row; + flex-wrap: wrap; + align-items: center; + background-color: #FFFFFF; + color: var(--label-text-color); +} + +.autofill-item-box:last-child { + border-bottom: 0; +} + +.autofill-item-box > .profile-item-col { + box-sizing: border-box; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + width: var(--item-width); +} + +.autofill-item-box > .profile-label-col { + text-align: start; +} + +.autofill-item-box:not([ac-image=""]) > .profile-label-col::before { + margin-inline-end: 5px; + float: inline-start; + content: ""; + width: 16px; + height: 16px; + background-image: var(--primary-icon); + background-size: contain; + background-repeat: no-repeat; + background-position: center; + -moz-context-properties: fill; + fill: #4D4D4D; +} + +.autofill-item-box > .profile-label-col > .profile-label { + font-size: calc(var(--label-font-size) / var(--default-font-size) * 1em); + unicode-bidi: plaintext; +} + +.autofill-item-box > .profile-label-col > .profile-label-affix { + font-weight: lighter; + font-size: calc(var(--label-affix-font-size) / var(--default-font-size) * 1em); +} + +.autofill-item-box > .profile-comment-col { + margin-inline-start: var(--col-spacer); + text-align: end; + color: var(--comment-text-color); +} + +.autofill-item-box > .profile-comment-col > .profile-comment { + font-size: calc(var(--comment-font-size) / var(--default-font-size) * 1em); + unicode-bidi: plaintext; +} + +.autofill-item-box[size="small"] { + flex-direction: column; +} + +.autofill-item-box[size="small"] > .profile-comment-col { + margin-top: var(--row-spacer); + text-align: start; +} + +.autofill-footer { + padding: 0; + flex-direction: column; +} + +.autofill-footer > .autofill-footer-row { + display: flex; + justify-content: center; + align-items: center; + width: var(--item-width); +} + +.autofill-footer > .autofill-warning { + padding: 2.5px 0; + color: var(--warning-text-color); + text-align: center; + background-color: rgba(248,232,28,.2); + border-bottom: 1px solid rgba(38,38,38,.15); + font-size: calc(var(--warning-font-size) / var(--default-font-size) * 1em); +} + +.autofill-footer > .autofill-button { + box-sizing: border-box; + padding: 0 10px; + min-height: 40px; + background-color: #EDEDED; + font-size: calc(var(--btn-font-size) / var(--default-font-size) * 1em); + color: var(--btn-text-color); + text-align: center; +} + +.autofill-footer[no-warning="true"] > .autofill-warning { + display: none; +} + +.autofill-insecure-item { + box-sizing: border-box; + padding: 4px 0; + display: flex; + flex-direction: row; + flex-wrap: nowrap; + align-items: center; + color: GrayText; +} + +.autofill-insecure-item::before { + display: block; + margin-inline: 4px 8px; + content: ""; + width: 16px; + height: 16px; + background-image: url(chrome://global/skin/icons/connection-mixed-active-loaded.svg); + -moz-context-properties: fill; + fill: GrayText; +} diff --git a/browser/extensions/formautofill/skin/shared/editAddress.css b/browser/extensions/formautofill/skin/shared/editAddress.css new file mode 100644 index 0000000000..a193773023 --- /dev/null +++ b/browser/extensions/formautofill/skin/shared/editAddress.css @@ -0,0 +1,122 @@ +/* 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/. */ + +.editAddressForm { + display: flex; + flex-wrap: wrap; + /* Use space-between so --grid-column-row-gap is in between the elements on a row */ + justify-content: space-between; +} + +dialog:not([subdialog]) .editAddressForm { + margin-inline: calc(var(--grid-column-row-gap) / -2); +} + +.editAddressForm .container { + /* !important is needed to override preferences.css's generic label rule. */ + margin-top: var(--grid-column-row-gap) !important; + margin-inline: calc(var(--grid-column-row-gap) / 2); + flex-grow: 1; +} + +#country-container { + /* The country dropdown has a different intrinsic (content) width than the + other fields which are <input>. */ + flex-basis: calc(50% - var(--grid-column-row-gap)); + flex-grow: 0; + /* Country names can be longer than 50% which ruins the symmetry in the grid. */ + max-width: calc(50% - var(--grid-column-row-gap)); +} + + +/* Begin name field rules */ + +#name-container input { + /* Override the default @size="20" on <input>, which acts like a min-width, not + * allowing the fields to shrink with flexbox as small as they need to to match + * the other rows. This is noticeable on narrow viewports e.g. in the + * PaymentRequest dialog on Linux due to the larger font-size. */ + width: 0; +} + +/* When there is focus within any of the name fields, the border of the inputs + * should be the focused color, except for inner ones which get overriden below. */ +#name-container:focus-within input { + border-color: var(--in-content-border-focus); +} + +/* Invalid name fields should show the error outline instead of the focus border */ +#name-container:focus-within input:-moz-ui-invalid { + border-color: transparent; +} + +#given-name-container, +#additional-name-container, +#family-name-container { + display: flex; + /* The 3 pieces inside the name container don't have the .container class so + need to set flex-grow themselves. See `.editAddressForm .container` */ + flex-grow: 1; + /* Remove the bottom margin from the name containers so that the outer + #name-container provides the margin on the outside */ + margin-bottom: 0 !important; + margin-inline: 0; +} + +/* The name fields are placed adjacent to each other. + Remove the border-radius on adjacent fields. */ +#given-name:dir(ltr), +#family-name:dir(rtl) { + border-top-right-radius: 0; + border-bottom-right-radius: 0; + border-right-width: 0; +} + +#given-name:dir(rtl), +#family-name:dir(ltr) { + border-top-left-radius: 0; + border-bottom-left-radius: 0; + border-left-width: 0; +} + +#additional-name { + border-radius: 0; + /* This provides the inner separators between the fields and should never + * change to the focused color. */ + border-inline-color: var(--in-content-box-border-color) !important; +} + +/* Since the name fields are adjacent, there isn't room for the -moz-ui-invalid + box-shadow so raise invalid name fields and their labels above the siblings + so the shadow is shown around all 4 sides. */ +#name-container input:-moz-ui-invalid, +#name-container input:-moz-ui-invalid ~ .label-text { + z-index: 1; +} + +/* End name field rules */ + +#name-container, +#street-address-container { + /* Name and street address are always full-width */ + flex: 0 1 100%; +} + +#street-address { + resize: vertical; +} + +#country-warning-message { + box-sizing: border-box; + font-size: 1rem; + display: flex; + align-items: center; + text-align: start; + opacity: .5; + padding-inline-start: 1em; +} + +dialog:not([subdialog]) #country-warning-message { + display: none; +} diff --git a/browser/extensions/formautofill/skin/shared/editCreditCard.css b/browser/extensions/formautofill/skin/shared/editCreditCard.css new file mode 100644 index 0000000000..9c701af609 --- /dev/null +++ b/browser/extensions/formautofill/skin/shared/editCreditCard.css @@ -0,0 +1,57 @@ +/* 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/. */ + +.editCreditCardForm { + display: grid; + grid-template-areas: + "cc-number cc-exp-month cc-exp-year" + "cc-name cc-type cc-csc" + "billingAddressGUID billingAddressGUID billingAddressGUID"; + grid-template-columns: 4fr 2fr 2fr; + grid-row-gap: var(--grid-column-row-gap); + grid-column-gap: var(--grid-column-row-gap); +} + +.editCreditCardForm label { + /* Remove the margin on these labels since they are styled on top of + the input/select element. */ + margin-inline-start: 0; + margin-inline-end: 0; +} + +.editCreditCardForm .container { + display: flex; +} + +#cc-number-container { + grid-area: cc-number; +} + +#cc-exp-month-container { + grid-area: cc-exp-month; +} + +#cc-exp-year-container { + grid-area: cc-exp-year; +} + +#cc-name-container { + grid-area: cc-name; +} + +#cc-type-container { + grid-area: cc-type; +} + +#cc-csc-container { + grid-area: cc-csc; +} + +#billingAddressGUID-container { + grid-area: billingAddressGUID; +} + +#billingAddressGUID { + grid-area: dropdown; +} diff --git a/browser/extensions/formautofill/skin/shared/editDialog-shared.css b/browser/extensions/formautofill/skin/shared/editDialog-shared.css new file mode 100644 index 0000000000..c2ddf71a60 --- /dev/null +++ b/browser/extensions/formautofill/skin/shared/editDialog-shared.css @@ -0,0 +1,110 @@ +/* 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/. */ + +:root { + --in-field-label-size: .8em; + --grid-column-row-gap: 8px; + /* Use the animation-easing-function that is defined in xul.css. */ + --animation-easing-function: cubic-bezier(.07,.95,0,1); +} + +dialog[subdialog] form { + /* Add extra space to ensure invalid input box is displayed properly */ + padding: 2px; +} + +/* The overly specific input attributes are required to override + padding from common.css */ +form input[type="email"], +form input[type="tel"], +form input[type="text"], +form textarea, +form select { + flex-grow: 1; + padding-top: calc(var(--in-field-label-size) + .4em); +} + +form input[type="tel"] { + text-align: match-parent; +} + +select { + margin: 0; + padding-bottom: 5px; +} + +form label[class="container"] select { + min-width: 0; +} + +form label, +form div { + /* Positioned so that the .label-text and .error-text children will be + positioned relative to this. */ + position: relative; + display: block; + line-height: 1em; +} + +/* Reset margins for inputs and textareas, overriding in-content styles */ +#form textarea, +#form input { + margin: 0; +} + +form :is(label, div) .label-text { + position: absolute; + opacity: .5; + pointer-events: none; + inset-inline-start: 10px; + top: .2em; + transition: top .2s var(--animation-easing-function), + font-size .2s var(--animation-easing-function); +} + +form :is(label, div):focus-within .label-text, +form :is(label, div) .label-text[field-populated] { + top: 0; + font-size: var(--in-field-label-size); +} + +form :is(input, select, textarea):focus ~ .label-text { + color: var(--in-content-item-selected); + opacity: 1; +} + +/* Focused error fields should get a darker text but not the blue one since it + * doesn't look good with the red error outline. */ +form :is(input, select, textarea):focus:-moz-ui-invalid ~ .label-text { + color: var(--in-content-text-color); +} + +form div[required] > label .label-text::after, +form :is(label, div)[required] .label-text::after { + content: attr(fieldRequiredSymbol); +} + +.persist-checkbox label { + display: flex; + flex-direction: row; + align-items: center; + margin-block: var(--grid-column-row-gap); +} + +dialog[subdialog] form { + /* Match the margin-inline-start of the #controls-container buttons + and provide enough padding at the top of the form so button outlines + don't get clipped. */ + padding: 4px 4px 0; +} + +#controls-container { + display: flex; + justify-content: end; + margin: 1em 0 0; +} + +#billingAddressGUID-container { + display: none; +} diff --git a/browser/extensions/formautofill/skin/windows/autocomplete-item.css b/browser/extensions/formautofill/skin/windows/autocomplete-item.css new file mode 100644 index 0000000000..3170c2663f --- /dev/null +++ b/browser/extensions/formautofill/skin/windows/autocomplete-item.css @@ -0,0 +1,21 @@ +/* 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/. */ + +@namespace url("http://www.w3.org/1999/xhtml"); +@namespace xul url("http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"); + +.autofill-item-box { + --default-font-size: 12; +} + +@media (-moz-windows-default-theme: 0) { + xul|richlistitem[originaltype="autofill-profile"][selected="true"] > .autofill-item-box { + background-color: Highlight; + } + + .autofill-item-box { + --label-text-color: FieldText; + --comment-text-color: GrayText; + } +} diff --git a/browser/extensions/formautofill/skin/windows/editDialog.css b/browser/extensions/formautofill/skin/windows/editDialog.css new file mode 100644 index 0000000000..3217fbe901 --- /dev/null +++ b/browser/extensions/formautofill/skin/windows/editDialog.css @@ -0,0 +1,12 @@ +/* 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/. */ + +/* The save button should be on the left and cancel on the right for Windows */ +#controlsContainer > #save { + order: 0; +} + +#controlsContainer > #cancel { + order: 1; +} diff --git a/browser/extensions/formautofill/test/browser/browser.ini b/browser/extensions/formautofill/test/browser/browser.ini new file mode 100644 index 0000000000..0f2100cfa1 --- /dev/null +++ b/browser/extensions/formautofill/test/browser/browser.ini @@ -0,0 +1,29 @@ +[DEFAULT] +head = head.js +support-files = + ../fixtures/autocomplete_basic.html + ../fixtures/autocomplete_iframe.html + ../fixtures/autocomplete_simple_basic.html + +[browser_autocomplete_footer.js] +skip-if = (verify || (!debug && os == "mac")) # perma-fail see Bug 1600059 +[browser_autocomplete_marked_back_forward.js] +skip-if = !debug && os == "mac" # perma-fail see Bug 1600059 +[browser_autocomplete_marked_detached_tab.js] +skip-if = (verify && (os == 'win')) || (os == 'mac') +[browser_check_installed.js] +skip-if = !debug && os == "mac" # perma-fail see Bug 1600059 +[browser_dropdown_layout.js] +skip-if = !debug && os == "mac" # perma-fail see Bug 1600059 +[browser_editAddressDialog.js] +skip-if = !debug && os == "mac" # perma-fail see Bug 1600059 +[browser_first_time_use_doorhanger.js] +skip-if = verify || (!debug && os == "mac") # perma-fail see Bug 1600059 +[browser_manageAddressesDialog.js] +skip-if = !debug && os == "mac" # perma-fail see Bug 1600059 +[browser_remoteiframe.js] +skip-if = !debug && os == "mac" # perma-fail see Bug 1600059 +[browser_submission_in_private_mode.js] +skip-if = !debug && os == "mac" # perma-fail see Bug 1600059 +[browser_update_doorhanger.js] +skip-if = true # bug 1426981 # Bug 1445538 diff --git a/browser/extensions/formautofill/test/browser/browser_autocomplete_footer.js b/browser/extensions/formautofill/test/browser/browser_autocomplete_footer.js new file mode 100644 index 0000000000..0dd1921596 --- /dev/null +++ b/browser/extensions/formautofill/test/browser/browser_autocomplete_footer.js @@ -0,0 +1,124 @@ +"use strict"; + +const URL = BASE_URL + "autocomplete_basic.html"; +const PRIVACY_PREF_URL = "about:preferences#privacy"; + +add_task(async function setup_storage() { + await saveAddress(TEST_ADDRESS_2); + await saveAddress(TEST_ADDRESS_3); + await saveAddress(TEST_ADDRESS_4); + await saveAddress(TEST_ADDRESS_5); +}); + +add_task(async function test_press_enter_on_footer() { + await BrowserTestUtils.withNewTab({ gBrowser, url: URL }, async function( + browser + ) { + const { + autoCompletePopup: { richlistbox: itemsBox }, + } = browser; + + await openPopupOn(browser, "#organization"); + // Navigate to the footer and press enter. + const listItemElems = itemsBox.querySelectorAll( + ".autocomplete-richlistitem" + ); + const prefTabPromise = BrowserTestUtils.waitForNewTab( + gBrowser, + PRIVACY_PREF_URL, + true + ); + for (let i = 0; i < listItemElems.length; i++) { + await BrowserTestUtils.synthesizeKey("VK_DOWN", {}, browser); + } + await BrowserTestUtils.synthesizeKey("VK_RETURN", {}, browser); + info(`expecting tab: about:preferences#privacy opened`); + const prefTab = await prefTabPromise; + info(`expecting tab: about:preferences#privacy removed`); + BrowserTestUtils.removeTab(prefTab); + ok( + true, + "Tab: preferences#privacy was successfully opened by pressing enter on the footer" + ); + + await closePopup(browser); + }); +}); + +add_task(async function test_click_on_footer() { + await BrowserTestUtils.withNewTab({ gBrowser, url: URL }, async function( + browser + ) { + const { + autoCompletePopup: { richlistbox: itemsBox }, + } = browser; + + await openPopupOn(browser, "#organization"); + // Click on the footer + const optionButton = itemsBox.querySelector( + ".autocomplete-richlistitem:last-child" + )._optionButton; + const prefTabPromise = BrowserTestUtils.waitForNewTab( + gBrowser, + PRIVACY_PREF_URL, + true + ); + // Wait for dropdown animation finished to continue mouse synthesizing. + await sleep(3000); + await EventUtils.synthesizeMouseAtCenter(optionButton, {}); + info(`expecting tab: about:preferences#privacy opened`); + const prefTab = await prefTabPromise; + info(`expecting tab: about:preferences#privacy removed`); + BrowserTestUtils.removeTab(prefTab); + ok( + true, + "Tab: preferences#privacy was successfully opened by clicking on the footer" + ); + + await closePopup(browser); + }); +}); + +add_task(async function test_phishing_warning_single_category() { + await BrowserTestUtils.withNewTab({ gBrowser, url: URL }, async function( + browser + ) { + const { + autoCompletePopup: { richlistbox: itemsBox }, + } = browser; + + await openPopupOn(browser, "#tel"); + const warningBox = itemsBox.querySelector( + ".autocomplete-richlistitem:last-child" + )._warningTextBox; + ok(warningBox, "Got phishing warning box"); + + await expectWarningText(browser, "Autofills phone"); + is( + warningBox.ownerGlobal.getComputedStyle(warningBox).backgroundColor, + "rgba(248, 232, 28, 0.2)", + "Check warning text background color" + ); + + await closePopup(browser); + }); +}); + +add_task(async function test_phishing_warning_complex_categories() { + await BrowserTestUtils.withNewTab({ gBrowser, url: URL }, async function( + browser + ) { + await openPopupOn(browser, "#street-address"); + + await expectWarningText(browser, "Also autofills organization, email"); + await BrowserTestUtils.synthesizeKey("VK_DOWN", {}, browser); + await expectWarningText(browser, "Autofills address"); + await BrowserTestUtils.synthesizeKey("VK_DOWN", {}, browser); + await BrowserTestUtils.synthesizeKey("VK_DOWN", {}, browser); + await expectWarningText(browser, "Also autofills organization, email"); + await BrowserTestUtils.synthesizeKey("VK_DOWN", {}, browser); + await expectWarningText(browser, "Also autofills organization, email"); + + await closePopup(browser); + }); +}); diff --git a/browser/extensions/formautofill/test/browser/browser_autocomplete_marked_back_forward.js b/browser/extensions/formautofill/test/browser/browser_autocomplete_marked_back_forward.js new file mode 100644 index 0000000000..e2d4a499ab --- /dev/null +++ b/browser/extensions/formautofill/test/browser/browser_autocomplete_marked_back_forward.js @@ -0,0 +1,67 @@ +/** + * Test that autofill autocomplete works after back/forward navigation + */ + +"use strict"; + +const URL = BASE_URL + "autocomplete_basic.html"; + +function checkPopup(autoCompletePopup) { + let first = autoCompletePopup.view.results[0]; + const { primary, secondary } = JSON.parse(first.label); + ok( + primary.startsWith(TEST_ADDRESS_1["street-address"].split("\n")[0]), + "Check primary label is street address" + ); + is( + secondary, + TEST_ADDRESS_1["address-level2"], + "Check secondary label is address-level2" + ); +} + +add_task(async function setup_storage() { + await saveAddress(TEST_ADDRESS_1); + await saveAddress(TEST_ADDRESS_2); + await saveAddress(TEST_ADDRESS_3); +}); + +add_task(async function test_back_forward() { + await BrowserTestUtils.withNewTab({ gBrowser, url: URL }, async function( + browser + ) { + const { autoCompletePopup } = browser; + + // Check the page after the initial load + await openPopupOn(browser, "#street-address"); + checkPopup(autoCompletePopup); + + // Now navigate forward and make sure autofill autocomplete results are still attached + let loadPromise = BrowserTestUtils.browserLoaded(browser); + BrowserTestUtils.loadURI(browser, `${URL}?load=2`); + info("expecting browser loaded"); + await loadPromise; + + // Check the second page + await openPopupOn(browser, "#street-address"); + checkPopup(autoCompletePopup); + + // Check after hitting back to the first page + let stoppedPromise = BrowserTestUtils.browserStopped(browser); + browser.goBack(); + info("expecting browser stopped"); + await stoppedPromise; + await openPopupOn(browser, "#street-address"); + checkPopup(autoCompletePopup); + + // Check after hitting forward to the second page + stoppedPromise = BrowserTestUtils.browserStopped(browser); + browser.goForward(); + info("expecting browser stopped"); + await stoppedPromise; + await openPopupOn(browser, "#street-address"); + checkPopup(autoCompletePopup); + + await closePopup(browser); + }); +}); diff --git a/browser/extensions/formautofill/test/browser/browser_autocomplete_marked_detached_tab.js b/browser/extensions/formautofill/test/browser/browser_autocomplete_marked_detached_tab.js new file mode 100644 index 0000000000..f4c6a55965 --- /dev/null +++ b/browser/extensions/formautofill/test/browser/browser_autocomplete_marked_detached_tab.js @@ -0,0 +1,60 @@ +/** + * Test that autofill autocomplete works after detaching a tab + */ + +"use strict"; + +const URL = BASE_URL + "autocomplete_basic.html"; + +function checkPopup(autoCompletePopup) { + let first = autoCompletePopup.view.results[0]; + const { primary, secondary } = JSON.parse(first.label); + ok( + primary.startsWith(TEST_ADDRESS_1["street-address"].split("\n")[0]), + "Check primary label is street address" + ); + is( + secondary, + TEST_ADDRESS_1["address-level2"], + "Check secondary label is address-level2" + ); +} + +add_task(async function setup_storage() { + await saveAddress(TEST_ADDRESS_1); + await saveAddress(TEST_ADDRESS_2); + await saveAddress(TEST_ADDRESS_3); +}); + +add_task(async function test_detach_tab_marked() { + let tab = await BrowserTestUtils.openNewForegroundTab({ gBrowser, url: URL }); + let browser = tab.linkedBrowser; + const { autoCompletePopup } = browser; + + // Check the page after the initial load + await openPopupOn(browser, "#street-address"); + checkPopup(autoCompletePopup); + await closePopup(browser); + + // Detach the tab to a new window + info("expecting tab replaced with new window"); + let windowLoadedPromise = BrowserTestUtils.waitForNewWindow(); + let newWin = gBrowser.replaceTabWithWindow( + gBrowser.getTabForBrowser(browser) + ); + await windowLoadedPromise; + + info("tab was detached"); + let newBrowser = newWin.gBrowser.selectedBrowser; + ok(newBrowser, "Found new <browser>"); + let newAutoCompletePopup = newBrowser.autoCompletePopup; + ok(newAutoCompletePopup, "Found new autocomplete popup"); + + await openPopupOn(newBrowser, "#street-address"); + checkPopup(newAutoCompletePopup); + + await closePopup(newBrowser); + let windowRefocusedPromise = BrowserTestUtils.waitForEvent(window, "focus"); + await BrowserTestUtils.closeWindow(newWin); + await windowRefocusedPromise; +}); diff --git a/browser/extensions/formautofill/test/browser/browser_check_installed.js b/browser/extensions/formautofill/test/browser/browser_check_installed.js new file mode 100644 index 0000000000..ac9a11866b --- /dev/null +++ b/browser/extensions/formautofill/test/browser/browser_check_installed.js @@ -0,0 +1,12 @@ +"use strict"; + +add_task(async function test_enabled() { + let addon = await AddonManager.getAddonByID("formautofill@mozilla.org"); + isnot(addon, null, "Check addon exists"); + is(addon.version, "1.0", "Check version"); + is(addon.name, "Form Autofill", "Check name"); + ok(addon.isCompatible, "Check application compatibility"); + ok(!addon.appDisabled, "Check not app disabled"); + ok(addon.isActive, "Check addon is active"); + is(addon.type, "extension", "Check type is 'extension'"); +}); diff --git a/browser/extensions/formautofill/test/browser/browser_dropdown_layout.js b/browser/extensions/formautofill/test/browser/browser_dropdown_layout.js new file mode 100644 index 0000000000..5bebf5e0ea --- /dev/null +++ b/browser/extensions/formautofill/test/browser/browser_dropdown_layout.js @@ -0,0 +1,49 @@ +"use strict"; + +const URL = + "http://example.org/browser/browser/extensions/formautofill/test/browser/autocomplete_basic.html"; + +add_task(async function setup_storage() { + await saveAddress(TEST_ADDRESS_1); + await saveAddress(TEST_ADDRESS_2); + await saveAddress(TEST_ADDRESS_3); +}); + +async function reopenPopupWithResizedInput(browser, selector, newSize) { + await closePopup(browser); + /* eslint no-shadow: ["error", { "allow": ["selector", "newSize"] }] */ + await SpecialPowers.spawn(browser, [{ selector, newSize }], async function({ + selector, + newSize, + }) { + const input = content.document.querySelector(selector); + + input.style.boxSizing = "border-box"; + input.style.width = newSize + "px"; + }); + await openPopupOn(browser, selector); +} + +add_task(async function test_address_dropdown() { + await BrowserTestUtils.withNewTab({ gBrowser, url: URL }, async function( + browser + ) { + const focusInput = "#organization"; + await openPopupOn(browser, focusInput); + const firstItem = getDisplayedPopupItems(browser)[0]; + + is(firstItem.getAttribute("ac-image"), "", "Should not show icon"); + + // The breakpoint of two-lines layout is 150px + await reopenPopupWithResizedInput(browser, focusInput, 140); + is( + firstItem._itemBox.getAttribute("size"), + "small", + "Show two-lines layout" + ); + await reopenPopupWithResizedInput(browser, focusInput, 160); + is(firstItem._itemBox.hasAttribute("size"), false, "Show one-line layout"); + + await closePopup(browser); + }); +}); diff --git a/browser/extensions/formautofill/test/browser/browser_editAddressDialog.js b/browser/extensions/formautofill/test/browser/browser_editAddressDialog.js new file mode 100644 index 0000000000..ad1f5cb7a1 --- /dev/null +++ b/browser/extensions/formautofill/test/browser/browser_editAddressDialog.js @@ -0,0 +1,925 @@ +"use strict"; + +const { FormAutofillUtils } = ChromeUtils.import( + "resource://formautofill/FormAutofillUtils.jsm" +); + +XPCOMUtils.defineLazyModuleGetters(this, { + Region: "resource://gre/modules/Region.jsm", +}); + +requestLongerTimeout(6); + +add_task(async function setup_supportedCountries() { + await SpecialPowers.pushPrefEnv({ + set: [[SUPPORTED_COUNTRIES_PREF, "US,CA,DE"]], + }); +}); + +add_task(async function test_cancelEditAddressDialog() { + await testDialog(EDIT_ADDRESS_DIALOG_URL, win => { + win.document.querySelector("#cancel").click(); + }); +}); + +add_task(async function test_cancelEditAddressDialogWithESC() { + await testDialog(EDIT_ADDRESS_DIALOG_URL, win => { + EventUtils.synthesizeKey("VK_ESCAPE", {}, win); + }); +}); + +add_task(async function test_defaultCountry() { + Region._setHomeRegion("CA", false); + await testDialog(EDIT_ADDRESS_DIALOG_URL, win => { + let doc = win.document; + is( + doc.querySelector("#country").value, + "CA", + "Default country set to Canada" + ); + doc.querySelector("#cancel").click(); + }); + Region._setHomeRegion("DE", false); + await testDialog(EDIT_ADDRESS_DIALOG_URL, win => { + let doc = win.document; + is( + doc.querySelector("#country").value, + "DE", + "Default country set to Germany" + ); + doc.querySelector("#cancel").click(); + }); + // Test unsupported country + Region._setHomeRegion("XX", false); + await testDialog(EDIT_ADDRESS_DIALOG_URL, win => { + let doc = win.document; + is(doc.querySelector("#country").value, "", "Default country set to empty"); + doc.querySelector("#cancel").click(); + }); + Region._setHomeRegion("US", false); +}); + +add_task(async function test_saveAddress() { + await testDialog(EDIT_ADDRESS_DIALOG_URL, win => { + let doc = win.document; + // Verify labels + is( + doc.querySelector("#address-level1-container > .label-text").textContent, + "State", + "US address-level1 label should be 'State'" + ); + is( + doc.querySelector("#postal-code-container > .label-text").textContent, + "ZIP Code", + "US postal-code label should be 'ZIP Code'" + ); + // Input address info and verify move through form with tab keys + const keypresses = [ + "VK_TAB", + TEST_ADDRESS_1["given-name"], + "VK_TAB", + TEST_ADDRESS_1["additional-name"], + "VK_TAB", + TEST_ADDRESS_1["family-name"], + "VK_TAB", + TEST_ADDRESS_1["street-address"], + "VK_TAB", + TEST_ADDRESS_1["address-level2"], + "VK_TAB", + TEST_ADDRESS_1["address-level1"], + "VK_TAB", + TEST_ADDRESS_1["postal-code"], + "VK_TAB", + TEST_ADDRESS_1.organization, + "VK_TAB", + // TEST_ADDRESS_1.country, // Country is already US + "VK_TAB", + TEST_ADDRESS_1.tel, + "VK_TAB", + TEST_ADDRESS_1.email, + "VK_TAB", + "VK_TAB", + "VK_RETURN", + ]; + keypresses.forEach(keypress => { + if ( + doc.activeElement.localName == "select" && + !keypress.startsWith("VK_") + ) { + let field = doc.activeElement; + while (field.value != keypress) { + EventUtils.synthesizeKey(keypress[0], {}, win); + } + } else { + EventUtils.synthesizeKey(keypress, {}, win); + } + }); + }); + let addresses = await getAddresses(); + + is(addresses.length, 1, "only one address is in storage"); + is( + Object.keys(TEST_ADDRESS_1).length, + 11, + "Sanity check number of properties" + ); + for (let [fieldName, fieldValue] of Object.entries(TEST_ADDRESS_1)) { + is(addresses[0][fieldName], fieldValue, "check " + fieldName); + } +}); + +add_task(async function test_editAddress() { + let addresses = await getAddresses(); + await testDialog( + EDIT_ADDRESS_DIALOG_URL, + win => { + EventUtils.synthesizeKey("VK_TAB", {}, win); + EventUtils.synthesizeKey("VK_RIGHT", {}, win); + EventUtils.synthesizeKey("test", {}, win); + + let stateSelect = win.document.querySelector("#address-level1"); + is( + stateSelect.selectedOptions[0].value, + TEST_ADDRESS_1["address-level1"], + "address-level1 should be selected in the dropdown" + ); + + win.document.querySelector("#save").click(); + }, + { + record: addresses[0], + } + ); + addresses = await getAddresses(); + + is(addresses.length, 1, "only one address is in storage"); + is( + addresses[0]["given-name"], + TEST_ADDRESS_1["given-name"] + "test", + "given-name changed" + ); + await removeAddresses([addresses[0].guid]); + + addresses = await getAddresses(); + is(addresses.length, 0, "Address storage is empty"); +}); + +add_task( + async function test_editAddressFrenchCanadianChangedToEnglishRepresentation() { + let addressClone = Object.assign({}, TEST_ADDRESS_CA_1); + addressClone["address-level1"] = "Colombie-Britannique"; + await saveAddress(addressClone); + + let addresses = await getAddresses(); + await testDialog( + EDIT_ADDRESS_DIALOG_URL, + win => { + let stateSelect = win.document.querySelector("#address-level1"); + is( + stateSelect.selectedOptions[0].value, + "BC", + "address-level1 should have 'BC' selected in the dropdown" + ); + + win.document.querySelector("#save").click(); + }, + { + record: addresses[0], + } + ); + addresses = await getAddresses(); + + is(addresses.length, 1, "only one address is in storage"); + is(addresses[0]["address-level1"], "BC", "address-level1 changed"); + await removeAddresses([addresses[0].guid]); + + addresses = await getAddresses(); + is(addresses.length, 0, "Address storage is empty"); + } +); + +add_task(async function test_editSparseAddress() { + let record = { ...TEST_ADDRESS_1 }; + info("delete some usually required properties"); + delete record["street-address"]; + delete record["address-level1"]; + delete record["address-level2"]; + await testDialog( + EDIT_ADDRESS_DIALOG_URL, + win => { + is( + win.document.querySelectorAll(":-moz-ui-invalid").length, + 0, + "Check no fields are visually invalid" + ); + EventUtils.synthesizeKey("VK_TAB", {}, win); + EventUtils.synthesizeKey("VK_RIGHT", {}, win); + EventUtils.synthesizeKey("test", {}, win); + is( + win.document.querySelector("#save").disabled, + false, + "Save button should be enabled after an edit" + ); + win.document.querySelector("#cancel").click(); + }, + { + record, + } + ); +}); + +add_task(async function test_saveAddressCA() { + await testDialog(EDIT_ADDRESS_DIALOG_URL, async win => { + let doc = win.document; + // Change country to verify labels + doc.querySelector("#country").focus(); + EventUtils.synthesizeKey("Canada", {}, win); + + await TestUtils.waitForCondition(() => { + return ( + doc.querySelector("#address-level1-container > .label-text") + .textContent == "Province" + ); + }, "Wait for the mutation observer to change the labels"); + is( + doc.querySelector("#address-level1-container > .label-text").textContent, + "Province", + "CA address-level1 label should be 'Province'" + ); + is( + doc.querySelector("#postal-code-container > .label-text").textContent, + "Postal Code", + "CA postal-code label should be 'Postal Code'" + ); + is( + doc.querySelector("#address-level3-container").style.display, + "none", + "CA address-level3 should be hidden" + ); + + // Input address info and verify move through form with tab keys + doc.querySelector("#given-name").focus(); + const keyInputs = [ + TEST_ADDRESS_CA_1["given-name"], + "VK_TAB", + TEST_ADDRESS_CA_1["additional-name"], + "VK_TAB", + TEST_ADDRESS_CA_1["family-name"], + "VK_TAB", + TEST_ADDRESS_CA_1.organization, + "VK_TAB", + TEST_ADDRESS_CA_1["street-address"], + "VK_TAB", + TEST_ADDRESS_CA_1["address-level2"], + "VK_TAB", + TEST_ADDRESS_CA_1["address-level1"], + "VK_TAB", + TEST_ADDRESS_CA_1["postal-code"], + "VK_TAB", + // TEST_ADDRESS_1.country, // Country is already selected above + "VK_TAB", + TEST_ADDRESS_CA_1.tel, + "VK_TAB", + TEST_ADDRESS_CA_1.email, + "VK_TAB", + "VK_TAB", + "VK_RETURN", + ]; + keyInputs.forEach(input => EventUtils.synthesizeKey(input, {}, win)); + }); + let addresses = await getAddresses(); + for (let [fieldName, fieldValue] of Object.entries(TEST_ADDRESS_CA_1)) { + is(addresses[0][fieldName], fieldValue, "check " + fieldName); + } + await removeAllRecords(); +}); + +add_task(async function test_saveAddressDE() { + await testDialog(EDIT_ADDRESS_DIALOG_URL, async win => { + let doc = win.document; + // Change country to verify labels + doc.querySelector("#country").focus(); + EventUtils.synthesizeKey("Germany", {}, win); + await TestUtils.waitForCondition(() => { + return ( + doc.querySelector("#postal-code-container > .label-text").textContent == + "Postal Code" + ); + }, "Wait for the mutation observer to change the labels"); + is( + doc.querySelector("#postal-code-container > .label-text").textContent, + "Postal Code", + "DE postal-code label should be 'Postal Code'" + ); + is( + doc.querySelector("#address-level1-container").style.display, + "none", + "DE address-level1 should be hidden" + ); + is( + doc.querySelector("#address-level3-container").style.display, + "none", + "DE address-level3 should be hidden" + ); + // Input address info and verify move through form with tab keys + doc.querySelector("#given-name").focus(); + const keyInputs = [ + TEST_ADDRESS_DE_1["given-name"], + "VK_TAB", + TEST_ADDRESS_DE_1["additional-name"], + "VK_TAB", + TEST_ADDRESS_DE_1["family-name"], + "VK_TAB", + TEST_ADDRESS_DE_1.organization, + "VK_TAB", + TEST_ADDRESS_DE_1["street-address"], + "VK_TAB", + TEST_ADDRESS_DE_1["postal-code"], + "VK_TAB", + TEST_ADDRESS_DE_1["address-level2"], + "VK_TAB", + // TEST_ADDRESS_1.country, // Country is already selected above + "VK_TAB", + TEST_ADDRESS_DE_1.tel, + "VK_TAB", + TEST_ADDRESS_DE_1.email, + "VK_TAB", + "VK_TAB", + "VK_RETURN", + ]; + keyInputs.forEach(input => EventUtils.synthesizeKey(input, {}, win)); + }); + let addresses = await getAddresses(); + for (let [fieldName, fieldValue] of Object.entries(TEST_ADDRESS_DE_1)) { + is(addresses[0][fieldName], fieldValue, "check " + fieldName); + } + await removeAllRecords(); +}); + +/** + * Test saving an address for a region from regionNames.properties but not in + * addressReferences.js (libaddressinput). + */ +add_task(async function test_saveAddress_nolibaddressinput() { + const TEST_ADDRESS = { + ...TEST_ADDRESS_IE_1, + ...{ + "address-level3": undefined, + country: "XG", + }, + }; + + isnot( + FormAutofillUtils.getCountryAddressData("XG").key, + "XG", + "Check that the region we're testing with isn't in libaddressinput" + ); + + await testDialog(EDIT_ADDRESS_DIALOG_URL, async win => { + let doc = win.document; + + // Change country to verify labels + doc.querySelector("#country").focus(); + EventUtils.synthesizeKey("Gaza Strip", {}, win); + await TestUtils.waitForCondition(() => { + return ( + doc.querySelector("#postal-code-container > .label-text").textContent == + "Postal Code" + ); + }, "Wait for the mutation observer to change the labels"); + is( + doc.querySelector("#postal-code-container > .label-text").textContent, + "Postal Code", + "XG postal-code label should be 'Postal Code'" + ); + isnot( + doc.querySelector("#address-level1-container").style.display, + "none", + "XG address-level1 should be hidden" + ); + is( + doc.querySelector("#address-level2").localName, + "input", + "XG address-level2 should be an <input>" + ); + // Input address info and verify move through form with tab keys + doc.querySelector("#given-name").focus(); + const keyInputs = [ + TEST_ADDRESS["given-name"], + "VK_TAB", + TEST_ADDRESS["additional-name"], + "VK_TAB", + TEST_ADDRESS["family-name"], + "VK_TAB", + TEST_ADDRESS.organization, + "VK_TAB", + TEST_ADDRESS["street-address"], + "VK_TAB", + TEST_ADDRESS["address-level2"], + "VK_TAB", + TEST_ADDRESS["address-level1"], + "VK_TAB", + TEST_ADDRESS["postal-code"], + "VK_TAB", + // TEST_ADDRESS_1.country, // Country is already selected above + "VK_TAB", + TEST_ADDRESS.tel, + "VK_TAB", + TEST_ADDRESS.email, + "VK_TAB", + "VK_TAB", + "VK_RETURN", + ]; + keyInputs.forEach(input => EventUtils.synthesizeKey(input, {}, win)); + }); + let addresses = await getAddresses(); + for (let [fieldName, fieldValue] of Object.entries(TEST_ADDRESS)) { + is(addresses[0][fieldName], fieldValue, "check " + fieldName); + } + await removeAllRecords(); +}); + +add_task(async function test_saveAddressIE() { + await testDialog(EDIT_ADDRESS_DIALOG_URL, async win => { + let doc = win.document; + // Change country to verify labels + doc.querySelector("#country").focus(); + EventUtils.synthesizeKey("Ireland", {}, win); + await TestUtils.waitForCondition(() => { + return ( + doc.querySelector("#postal-code-container > .label-text").textContent == + "Eircode" + ); + }, "Wait for the mutation observer to change the labels"); + is( + doc.querySelector("#postal-code-container > .label-text").textContent, + "Eircode", + "IE postal-code label should be 'Eircode'" + ); + is( + doc.querySelector("#address-level1-container > .label-text").textContent, + "County", + "IE address-level1 should be 'County'" + ); + is( + doc.querySelector("#address-level3-container > .label-text").textContent, + "Townland", + "IE address-level3 should be 'Townland'" + ); + + // Input address info and verify move through form with tab keys + doc.querySelector("#given-name").focus(); + const keyInputs = [ + TEST_ADDRESS_IE_1["given-name"], + "VK_TAB", + TEST_ADDRESS_IE_1["additional-name"], + "VK_TAB", + TEST_ADDRESS_IE_1["family-name"], + "VK_TAB", + TEST_ADDRESS_IE_1.organization, + "VK_TAB", + TEST_ADDRESS_IE_1["street-address"], + "VK_TAB", + TEST_ADDRESS_IE_1["address-level3"], + "VK_TAB", + TEST_ADDRESS_IE_1["address-level2"], + "VK_TAB", + TEST_ADDRESS_IE_1["address-level1"], + "VK_TAB", + TEST_ADDRESS_IE_1["postal-code"], + "VK_TAB", + // TEST_ADDRESS_1.country, // Country is already selected above + "VK_TAB", + TEST_ADDRESS_IE_1.tel, + "VK_TAB", + TEST_ADDRESS_IE_1.email, + "VK_TAB", + "VK_TAB", + "VK_RETURN", + ]; + keyInputs.forEach(input => EventUtils.synthesizeKey(input, {}, win)); + }); + + let addresses = await getAddresses(); + for (let [fieldName, fieldValue] of Object.entries(TEST_ADDRESS_IE_1)) { + is(addresses[0][fieldName], fieldValue, "check " + fieldName); + } + await removeAllRecords(); +}); + +add_task(async function test_countryAndStateFieldLabels() { + await testDialog(EDIT_ADDRESS_DIALOG_URL, async win => { + let doc = win.document; + // Change country to verify labels + doc.querySelector("#country").focus(); + + let mutatableLabels = [ + "postal-code-container", + "address-level1-container", + "address-level2-container", + "address-level3-container", + ].map(containerID => + doc.getElementById(containerID).querySelector(":scope > .label-text") + ); + + for (let countryOption of doc.querySelector("#country").options) { + if (countryOption.value == "") { + info("Skipping the empty country option"); + continue; + } + + // Clear L10N attributes and textContent to not leave leftovers between country tests + for (let labelEl of mutatableLabels) { + labelEl.textContent = ""; + delete labelEl.dataset.localization; + } + + info(`Selecting '${countryOption.label}' (${countryOption.value})`); + EventUtils.synthesizeKey(countryOption.label, {}, win); + + // Check that the labels were filled + for (let labelEl of mutatableLabels) { + if (!labelEl.textContent) { + await TestUtils.waitForCondition( + () => labelEl.textContent, + "Wait for label to be populated by the mutation observer", + 10 + ); + } + isnot( + labelEl.textContent, + "", + "Ensure textContent is non-empty for: " + countryOption.value + ); + is( + labelEl.dataset.localization, + undefined, + "Ensure data-localization was removed: " + countryOption.value + ); + } + + let stateOptions = doc.querySelector("#address-level1").options; + /* eslint-disable max-len */ + let expectedStateOptions = { + BS: { + // The Bahamas is an interesting testcase because they have some keys that are full names, and others are replaced with ISO IDs. + keys: "Abaco~AK~Andros~BY~BI~CI~Crooked Island~Eleuthera~EX~Grand Bahama~HI~IN~LI~MG~N.P.~RI~RC~SS~SW".split( + "~" + ), + names: "Abaco Islands~Acklins~Andros Island~Berry Islands~Bimini~Cat Island~Crooked Island~Eleuthera~Exuma and Cays~Grand Bahama~Harbour Island~Inagua~Long Island~Mayaguana~New Providence~Ragged Island~Rum Cay~San Salvador~Spanish Wells".split( + "~" + ), + }, + US: { + keys: "AL~AK~AS~AZ~AR~AA~AE~AP~CA~CO~CT~DE~DC~FL~GA~GU~HI~ID~IL~IN~IA~KS~KY~LA~ME~MH~MD~MA~MI~FM~MN~MS~MO~MT~NE~NV~NH~NJ~NM~NY~NC~ND~MP~OH~OK~OR~PW~PA~PR~RI~SC~SD~TN~TX~UT~VT~VI~VA~WA~WV~WI~WY".split( + "~" + ), + names: "Alabama~Alaska~American Samoa~Arizona~Arkansas~Armed Forces (AA)~Armed Forces (AE)~Armed Forces (AP)~California~Colorado~Connecticut~Delaware~District of Columbia~Florida~Georgia~Guam~Hawaii~Idaho~Illinois~Indiana~Iowa~Kansas~Kentucky~Louisiana~Maine~Marshall Islands~Maryland~Massachusetts~Michigan~Micronesia~Minnesota~Mississippi~Missouri~Montana~Nebraska~Nevada~New Hampshire~New Jersey~New Mexico~New York~North Carolina~North Dakota~Northern Mariana Islands~Ohio~Oklahoma~Oregon~Palau~Pennsylvania~Puerto Rico~Rhode Island~South Carolina~South Dakota~Tennessee~Texas~Utah~Vermont~Virgin Islands~Virginia~Washington~West Virginia~Wisconsin~Wyoming".split( + "~" + ), + }, + }; + /* eslint-enable max-len */ + + if (expectedStateOptions[countryOption.value]) { + let { keys, names } = expectedStateOptions[countryOption.value]; + is( + stateOptions.length, + keys.length + 1, + "stateOptions should list all options plus a blank entry" + ); + is(stateOptions[0].value, "", "First State option should be blank"); + for (let i = 1; i < stateOptions.length; i++) { + is( + stateOptions[i].value, + keys[i - 1], + "Each State should be listed in alphabetical name order (key)" + ); + is( + stateOptions[i].text, + names[i - 1], + "Each State should be listed in alphabetical name order (name)" + ); + } + } + } + + doc.querySelector("#cancel").click(); + }); +}); + +add_task(async function test_combined_name_fields() { + await testDialog(EDIT_ADDRESS_DIALOG_URL, async win => { + let doc = win.document; + let givenNameField = doc.querySelector("#given-name"); + let addtlNameField = doc.querySelector("#additional-name"); + let familyNameField = doc.querySelector("#family-name"); + + function getComputedPropertyValue(field, property) { + return win.getComputedStyle(field).getPropertyValue(property); + } + function checkNameComputedPropertiesMatch( + field, + property, + value, + checkFn = is + ) { + checkFn( + getComputedPropertyValue(field, property), + value, + `Check ${field.id}'s ${property} is ${value}` + ); + } + function checkNameFieldBorders(borderColorUnfocused, borderColorFocused) { + info("checking the perimeter colors"); + checkNameComputedPropertiesMatch( + givenNameField, + "border-top-color", + borderColorFocused + ); + checkNameComputedPropertiesMatch( + addtlNameField, + "border-top-color", + borderColorFocused + ); + checkNameComputedPropertiesMatch( + familyNameField, + "border-top-color", + borderColorFocused + ); + checkNameComputedPropertiesMatch( + familyNameField, + "border-right-color", + borderColorFocused + ); + checkNameComputedPropertiesMatch( + givenNameField, + "border-bottom-color", + borderColorFocused + ); + checkNameComputedPropertiesMatch( + addtlNameField, + "border-bottom-color", + borderColorFocused + ); + checkNameComputedPropertiesMatch( + familyNameField, + "border-bottom-color", + borderColorFocused + ); + checkNameComputedPropertiesMatch( + givenNameField, + "border-left-color", + borderColorFocused + ); + + info("checking the internal borders"); + checkNameComputedPropertiesMatch( + givenNameField, + "border-right-width", + "0px" + ); + checkNameComputedPropertiesMatch( + addtlNameField, + "border-left-width", + "2px" + ); + checkNameComputedPropertiesMatch( + addtlNameField, + "border-left-color", + borderColorFocused, + isnot + ); + checkNameComputedPropertiesMatch( + addtlNameField, + "border-right-width", + "2px" + ); + checkNameComputedPropertiesMatch( + addtlNameField, + "border-right-color", + borderColorFocused, + isnot + ); + checkNameComputedPropertiesMatch( + familyNameField, + "border-left-width", + "0px" + ); + } + + // Set these variables since the test doesn't run from a subdialog and + // therefore doesn't get the additional common CSS files injected. + let borderColor = "rgb(0, 255, 0)"; + let borderColorFocused = "rgb(0, 0, 255)"; + doc.body.style.setProperty("--in-content-box-border-color", borderColor); + doc.body.style.setProperty("--in-content-border-focus", borderColorFocused); + + givenNameField.focus(); + checkNameFieldBorders(borderColor, borderColorFocused); + + addtlNameField.focus(); + checkNameFieldBorders(borderColor, borderColorFocused); + + familyNameField.focus(); + checkNameFieldBorders(borderColor, borderColorFocused); + + info("unfocusing the name fields"); + let cancelButton = doc.querySelector("#cancel"); + cancelButton.focus(); + borderColor = getComputedPropertyValue(givenNameField, "border-top-color"); + isnot( + borderColor, + borderColorFocused, + "Check that the border color is different" + ); + checkNameFieldBorders(borderColor, borderColor); + + cancelButton.click(); + }); +}); + +add_task(async function test_combined_name_fields_error() { + await testDialog(EDIT_ADDRESS_DIALOG_URL, async win => { + let doc = win.document; + let givenNameField = doc.querySelector("#given-name"); + info("mark the given name field as invalid"); + givenNameField.value = ""; + givenNameField.focus(); + ok( + givenNameField.matches(":-moz-ui-invalid"), + "Check field is visually invalid" + ); + + let givenNameLabel = doc.querySelector("#given-name-container .label-text"); + // Override pointer-events so that we can use elementFromPoint to know if + // the label text is visible. + givenNameLabel.style.pointerEvents = "auto"; + let givenNameLabelRect = givenNameLabel.getBoundingClientRect(); + // Get the center of the label + let el = doc.elementFromPoint( + givenNameLabelRect.left + givenNameLabelRect.width / 2, + givenNameLabelRect.top + givenNameLabelRect.height / 2 + ); + + is( + el, + givenNameLabel, + "Check that the label text is visible in the error state" + ); + is( + win.getComputedStyle(givenNameField).getPropertyValue("border-top-color"), + "rgba(0, 0, 0, 0)", + "Border should be transparent so that only the error outline shows" + ); + doc.querySelector("#cancel").click(); + }); +}); + +add_task(async function test_hiddenFieldNotSaved() { + await testDialog(EDIT_ADDRESS_DIALOG_URL, win => { + let doc = win.document; + doc.querySelector("#address-level2").focus(); + EventUtils.synthesizeKey(TEST_ADDRESS_1["address-level2"], {}, win); + doc.querySelector("#address-level1").focus(); + EventUtils.synthesizeKey(TEST_ADDRESS_1["address-level1"], {}, win); + doc.querySelector("#country").focus(); + EventUtils.synthesizeKey("Germany", {}, win); + doc.querySelector("#save").focus(); + EventUtils.synthesizeKey("VK_RETURN", {}, win); + }); + let addresses = await getAddresses(); + is(addresses[0].country, "DE", "check country"); + is( + addresses[0]["address-level2"], + TEST_ADDRESS_1["address-level2"], + "check address-level2" + ); + is( + addresses[0]["address-level1"], + undefined, + "address-level1 should not be saved" + ); + + await removeAllRecords(); +}); + +add_task(async function test_hiddenFieldRemovedWhenCountryChanged() { + let addresses = await getAddresses(); + ok(!addresses.length, "no addresses at start of test"); + await testDialog(EDIT_ADDRESS_DIALOG_URL, win => { + let doc = win.document; + doc.querySelector("#address-level2").focus(); + EventUtils.synthesizeKey(TEST_ADDRESS_1["address-level2"], {}, win); + doc.querySelector("#address-level1").focus(); + while ( + doc.querySelector("#address-level1").value != + TEST_ADDRESS_1["address-level1"] + ) { + EventUtils.synthesizeKey(TEST_ADDRESS_1["address-level1"][0], {}, win); + } + doc.querySelector("#save").focus(); + EventUtils.synthesizeKey("VK_RETURN", {}, win); + }); + addresses = await getAddresses(); + is(addresses[0].country, "US", "check country"); + is( + addresses[0]["address-level2"], + TEST_ADDRESS_1["address-level2"], + "check address-level2" + ); + is( + addresses[0]["address-level1"], + TEST_ADDRESS_1["address-level1"], + "check address-level1" + ); + + await testDialog( + EDIT_ADDRESS_DIALOG_URL, + win => { + let doc = win.document; + doc.querySelector("#country").focus(); + EventUtils.synthesizeKey("Germany", {}, win); + win.document.querySelector("#save").click(); + }, + { + record: addresses[0], + } + ); + addresses = await getAddresses(); + + is(addresses.length, 1, "only one address is in storage"); + is( + addresses[0]["address-level2"], + TEST_ADDRESS_1["address-level2"], + "check address-level2" + ); + is( + addresses[0]["address-level1"], + undefined, + "address-level1 should be removed" + ); + is(addresses[0].country, "DE", "country changed"); + await removeAllRecords(); +}); + +add_task(async function test_countrySpecificFieldsGetRequiredness() { + Region._setHomeRegion("RO", false); + await testDialog(EDIT_ADDRESS_DIALOG_URL, async win => { + let doc = win.document; + is( + doc.querySelector("#country").value, + "RO", + "Default country set to Romania" + ); + let provinceField = doc.getElementById("address-level1"); + ok( + !provinceField.required, + "address-level1 should not be marked as required" + ); + ok(provinceField.disabled, "address-level1 should be marked as disabled"); + is( + provinceField.parentNode.style.display, + "none", + "address-level1 is hidden for Romania" + ); + + doc.querySelector("#country").focus(); + EventUtils.synthesizeKey("United States", {}, win); + + await TestUtils.waitForCondition( + () => { + provinceField = doc.getElementById("address-level1"); + return provinceField.parentNode.style.display != "none"; + }, + "Wait for address-level1 to become visible", + 10 + ); + + ok(provinceField.required, "address-level1 should be marked as required"); + ok( + !provinceField.disabled, + "address-level1 should not be marked as disabled" + ); + + doc.querySelector("#country").focus(); + EventUtils.synthesizeKey("Romania", {}, win); + + await TestUtils.waitForCondition( + () => { + provinceField = doc.getElementById("address-level1"); + return provinceField.parentNode.style.display == "none"; + }, + "Wait for address-level1 to become hidden", + 10 + ); + + ok( + provinceField.required, + "address-level1 will still be marked as required" + ); + ok(provinceField.disabled, "address-level1 should be marked as disabled"); + + doc.querySelector("#cancel").click(); + }); +}); diff --git a/browser/extensions/formautofill/test/browser/browser_first_time_use_doorhanger.js b/browser/extensions/formautofill/test/browser/browser_first_time_use_doorhanger.js new file mode 100644 index 0000000000..7f54e340a6 --- /dev/null +++ b/browser/extensions/formautofill/test/browser/browser_first_time_use_doorhanger.js @@ -0,0 +1,140 @@ +/* eslint-disable mozilla/no-arbitrary-setTimeout */ +"use strict"; + +add_task(async function test_first_time_save() { + let addresses = await getAddresses(); + is(addresses.length, 0, "No address in storage"); + await SpecialPowers.pushPrefEnv({ + set: [ + [FTU_PREF, true], + [ENABLED_AUTOFILL_ADDRESSES_PREF, true], + [ENABLED_AUTOFILL_ADDRESSES_CAPTURE_PREF, true], + ], + }); + + await BrowserTestUtils.withNewTab({ gBrowser, url: FORM_URL }, async function( + browser + ) { + let promiseShown = BrowserTestUtils.waitForEvent( + PopupNotifications.panel, + "popupshown" + ); + let tabPromise = BrowserTestUtils.waitForNewTab( + gBrowser, + "about:preferences#privacy" + ); + await SpecialPowers.spawn(browser, [], async function() { + let form = content.document.getElementById("form"); + form.querySelector("#organization").focus(); + form.querySelector("#organization").value = "Sesame Street"; + form.querySelector("#street-address").value = "123 Sesame Street"; + form.querySelector("#tel").value = "1-345-345-3456"; + + // Wait 500ms before submission to make sure the input value applied + await new Promise(resolve => content.setTimeout(resolve, 500)); + form.querySelector("input[type=submit]").click(); + }); + + await promiseShown; + let cb = getDoorhangerCheckbox(); + ok(cb.hidden, "Sync checkbox should be hidden"); + // Open the panel via main button + await clickDoorhangerButton(MAIN_BUTTON); + let tab = await tabPromise; + ok(tab, "Privacy panel opened"); + BrowserTestUtils.removeTab(tab); + }); + + addresses = await getAddresses(); + is(addresses.length, 1, "Address saved"); + let ftuPref = SpecialPowers.getBoolPref(FTU_PREF); + is(ftuPref, false, "First time use flag is false"); +}); + +add_task(async function test_non_first_time_save() { + let addresses = await getAddresses(); + let ftuPref = SpecialPowers.getBoolPref(FTU_PREF); + is(ftuPref, false, "First time use flag is false"); + is(addresses.length, 1, "1 address in storage"); + + await BrowserTestUtils.withNewTab({ gBrowser, url: FORM_URL }, async function( + browser + ) { + await SpecialPowers.spawn(browser, [], async function() { + let form = content.document.getElementById("form"); + form.querySelector("#organization").focus(); + form.querySelector("#organization").value = "Mozilla"; + form.querySelector("#street-address").value = "331 E. Evelyn Avenue"; + form.querySelector("#tel").value = "1-650-903-0800"; + + // Wait 500ms before submission to make sure the input value applied + await new Promise(resolve => content.setTimeout(resolve, 500)); + form.querySelector("input[type=submit]").click(); + }); + + await sleep(1000); + is(PopupNotifications.panel.state, "closed", "Doorhanger is hidden"); + }); + + addresses = await getAddresses(); + is(addresses.length, 2, "Another address saved"); +}); + +add_task(async function test_first_time_save_with_sync_account() { + await SpecialPowers.pushPrefEnv({ + set: [ + [FTU_PREF, true], + [ENABLED_AUTOFILL_ADDRESSES_PREF, true], + [SYNC_USERNAME_PREF, "foo@bar.com"], + ], + }); + + await BrowserTestUtils.withNewTab({ gBrowser, url: FORM_URL }, async function( + browser + ) { + let promiseShown = BrowserTestUtils.waitForEvent( + PopupNotifications.panel, + "popupshown" + ); + let tabPromise = BrowserTestUtils.waitForNewTab( + gBrowser, + "about:preferences#privacy-address-autofill" + ); + await SpecialPowers.spawn(browser, [], async function() { + let form = content.document.getElementById("form"); + form.querySelector("#organization").focus(); + form.querySelector("#organization").value = "Foobar"; + form.querySelector("#email").value = "foo@bar.com"; + form.querySelector("#tel").value = "1-234-567-8900"; + + // Wait 500ms before submission to make sure the input value applied + await new Promise(resolve => content.setTimeout(resolve, 500)); + form.querySelector("input[type=submit]").click(); + }); + + await promiseShown; + let cb = getDoorhangerCheckbox(); + ok(!cb.hidden, "Sync checkbox should be visible"); + is( + SpecialPowers.getBoolPref(SYNC_ADDRESSES_PREF), + false, + "addresses sync should be disabled at first" + ); + + is(cb.checked, false, "Checkbox state should match addresses sync state"); + cb.click(); + is( + SpecialPowers.getBoolPref(SYNC_ADDRESSES_PREF), + true, + "addresses sync should be enabled after checked" + ); + // Open the panel via main button + await clickDoorhangerButton(MAIN_BUTTON); + let tab = await tabPromise; + ok(tab, "Privacy panel opened"); + BrowserTestUtils.removeTab(tab); + }); + + let ftuPref = SpecialPowers.getBoolPref(FTU_PREF); + is(ftuPref, false, "First time use flag is false"); +}); diff --git a/browser/extensions/formautofill/test/browser/browser_manageAddressesDialog.js b/browser/extensions/formautofill/test/browser/browser_manageAddressesDialog.js new file mode 100644 index 0000000000..0cca09f6e0 --- /dev/null +++ b/browser/extensions/formautofill/test/browser/browser_manageAddressesDialog.js @@ -0,0 +1,107 @@ +"use strict"; + +const TEST_SELECTORS = { + selRecords: "#addresses", + btnRemove: "#remove", + btnAdd: "#add", + btnEdit: "#edit", +}; + +const DIALOG_SIZE = "width=600,height=400"; + +add_task(async function test_manageAddressesInitialState() { + await BrowserTestUtils.withNewTab( + { gBrowser, url: MANAGE_ADDRESSES_DIALOG_URL }, + async function(browser) { + await SpecialPowers.spawn(browser, [TEST_SELECTORS], args => { + let selRecords = content.document.querySelector(args.selRecords); + let btnRemove = content.document.querySelector(args.btnRemove); + let btnEdit = content.document.querySelector(args.btnEdit); + let btnAdd = content.document.querySelector(args.btnAdd); + + is(selRecords.length, 0, "No address"); + is(btnAdd.disabled, false, "Add button enabled"); + is(btnRemove.disabled, true, "Remove button disabled"); + is(btnEdit.disabled, true, "Edit button disabled"); + }); + } + ); +}); + +add_task(async function test_cancelManageAddressDialogWithESC() { + let win = window.openDialog(MANAGE_ADDRESSES_DIALOG_URL); + await waitForFocusAndFormReady(win); + let unloadPromise = BrowserTestUtils.waitForEvent(win, "unload"); + EventUtils.synthesizeKey("VK_ESCAPE", {}, win); + await unloadPromise; + ok(true, "Manage addresses dialog is closed with ESC key"); +}); + +add_task(async function test_removingSingleAndMultipleAddresses() { + await saveAddress(TEST_ADDRESS_1); + await saveAddress(TEST_ADDRESS_2); + await saveAddress(TEST_ADDRESS_3); + + let win = window.openDialog(MANAGE_ADDRESSES_DIALOG_URL, null, DIALOG_SIZE); + await waitForFocusAndFormReady(win); + + let selRecords = win.document.querySelector(TEST_SELECTORS.selRecords); + let btnRemove = win.document.querySelector(TEST_SELECTORS.btnRemove); + let btnEdit = win.document.querySelector(TEST_SELECTORS.btnEdit); + + is(selRecords.length, 3, "Three addresses"); + + EventUtils.synthesizeMouseAtCenter(selRecords.children[0], {}, win); + is(btnRemove.disabled, false, "Remove button enabled"); + is(btnEdit.disabled, false, "Edit button enabled"); + EventUtils.synthesizeMouseAtCenter(btnRemove, {}, win); + await BrowserTestUtils.waitForEvent(selRecords, "RecordsRemoved"); + is(selRecords.length, 2, "Two addresses left"); + + EventUtils.synthesizeMouseAtCenter(selRecords.children[0], {}, win); + EventUtils.synthesizeMouseAtCenter( + selRecords.children[1], + { shiftKey: true }, + win + ); + is(btnEdit.disabled, true, "Edit button disabled when multi-select"); + + EventUtils.synthesizeMouseAtCenter(btnRemove, {}, win); + await BrowserTestUtils.waitForEvent(selRecords, "RecordsRemoved"); + is(selRecords.length, 0, "All addresses are removed"); + + win.close(); +}); + +add_task(async function test_removingAdressViaKeyboardDelete() { + await saveAddress(TEST_ADDRESS_1); + let win = window.openDialog(MANAGE_ADDRESSES_DIALOG_URL, null, DIALOG_SIZE); + await waitForFocusAndFormReady(win); + + let selRecords = win.document.querySelector(TEST_SELECTORS.selRecords); + + is(selRecords.length, 1, "One address"); + + EventUtils.synthesizeMouseAtCenter(selRecords.children[0], {}, win); + EventUtils.synthesizeKey("VK_DELETE", {}, win); + await BrowserTestUtils.waitForEvent(selRecords, "RecordsRemoved"); + is(selRecords.length, 0, "No addresses left"); + + win.close(); +}); + +add_task(async function test_addressesDialogWatchesStorageChanges() { + let win = window.openDialog(MANAGE_ADDRESSES_DIALOG_URL, null, DIALOG_SIZE); + await waitForFocusAndFormReady(win); + + let selRecords = win.document.querySelector(TEST_SELECTORS.selRecords); + + await saveAddress(TEST_ADDRESS_1); + await BrowserTestUtils.waitForEvent(selRecords, "RecordsLoaded"); + is(selRecords.length, 1, "One address is shown"); + + await removeAddresses([selRecords.options[0].value]); + await BrowserTestUtils.waitForEvent(selRecords, "RecordsLoaded"); + is(selRecords.length, 0, "Address is removed"); + win.close(); +}); diff --git a/browser/extensions/formautofill/test/browser/browser_remoteiframe.js b/browser/extensions/formautofill/test/browser/browser_remoteiframe.js new file mode 100644 index 0000000000..a4bab12c7f --- /dev/null +++ b/browser/extensions/formautofill/test/browser/browser_remoteiframe.js @@ -0,0 +1,145 @@ +/* eslint-disable mozilla/no-arbitrary-setTimeout */ +"use strict"; + +const IFRAME_URL_PATH = BASE_URL + "autocomplete_iframe.html"; +const PRIVACY_PREF_URL = "about:preferences#privacy"; + +// Start by adding a few addresses to storage. +add_task(async function setup_storage() { + await SpecialPowers.pushPrefEnv({ + set: [ + [ENABLED_AUTOFILL_ADDRESSES_PREF, true], + [ENABLED_AUTOFILL_ADDRESSES_CAPTURE_PREF, true], + ], + }); + await saveAddress(TEST_ADDRESS_2); + await saveAddress(TEST_ADDRESS_4); + await saveAddress(TEST_ADDRESS_5); +}); + +// Verify that form fillin works in a remote iframe, and that changing +// a field updates storage. +add_task(async function test_iframe_autocomplete() { + await SpecialPowers.pushPrefEnv({ + set: [[CREDITCARDS_USED_STATUS_PREF, 0]], + }); + + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + IFRAME_URL_PATH, + true + ); + let browser = tab.linkedBrowser; + let iframeBC = browser.browsingContext.children[1]; + await openPopupForSubframe(browser, iframeBC, "#street-address"); + + // Highlight the first item in the list. We want to verify + // that the warning text is correct to ensure that the preview is + // performed properly. + + await BrowserTestUtils.synthesizeKey("VK_DOWN", {}, iframeBC); + await expectWarningText(browser, "Autofills address"); + + // Highlight and select the second item in the list + await BrowserTestUtils.synthesizeKey("VK_DOWN", {}, iframeBC); + await expectWarningText(browser, "Also autofills organization, email"); + EventUtils.synthesizeKey("VK_RETURN", {}); + + let promiseShown = BrowserTestUtils.waitForEvent( + PopupNotifications.panel, + "popupshown" + ); + + await new Promise(resolve => setTimeout(resolve, 1000)); + + let loadPromise = BrowserTestUtils.browserLoaded(browser, true); + await SpecialPowers.spawn(iframeBC, [], async function() { + Assert.equal( + content.document.getElementById("street-address").value, + "32 Vassar Street MIT Room 32-G524" + ); + Assert.equal(content.document.getElementById("country").value, "US"); + + let org = content.document.getElementById("organization"); + Assert.equal(org.value, "World Wide Web Consortium"); + + // Now, modify the organization. + org.setUserInput("Example Inc."); + + await new Promise(resolve => content.setTimeout(resolve, 1000)); + content.document.querySelector("input[type=submit]").click(); + }); + + await loadPromise; + await promiseShown; + + let onChanged = TestUtils.topicObserved("formautofill-storage-changed"); + await clickDoorhangerButton(MAIN_BUTTON); + await onChanged; + + // Check that the organization was updated properly. + let addresses = await getAddresses(); + is(addresses.length, 3, "Still 1 address in storage"); + is( + addresses[1].organization, + "Example Inc.", + "Verify the organization field" + ); + + // Fill in the details again and then clear the form from the dropdown. + await openPopupForSubframe(browser, iframeBC, "#street-address"); + await BrowserTestUtils.synthesizeKey("VK_DOWN", {}, iframeBC); + EventUtils.synthesizeKey("VK_RETURN", {}); + + await new Promise(resolve => setTimeout(resolve, 1000)); + + // Open the dropdown and select the Clear Form item. + await BrowserTestUtils.synthesizeKey("VK_DOWN", {}, iframeBC); + await BrowserTestUtils.synthesizeKey("VK_DOWN", {}, iframeBC); + EventUtils.synthesizeKey("VK_RETURN", {}); + + await new Promise(resolve => setTimeout(resolve, 1000)); + + await SpecialPowers.spawn(iframeBC, [], async function() { + Assert.equal(content.document.getElementById("street-address").value, ""); + Assert.equal(content.document.getElementById("country").value, ""); + Assert.equal(content.document.getElementById("organization").value, ""); + }); + + await BrowserTestUtils.removeTab(tab); +}); + +// Choose preferences from the autocomplete dropdown within an iframe. +add_task(async function test_iframe_autocomplete_preferences() { + await SpecialPowers.pushPrefEnv({ + set: [[CREDITCARDS_USED_STATUS_PREF, 0]], + }); + + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + IFRAME_URL_PATH, + true + ); + let browser = tab.linkedBrowser; + let iframeBC = browser.browsingContext.children[1]; + await openPopupForSubframe(browser, iframeBC, "#organization"); + + await expectWarningText(browser, "Also autofills address, email"); + + const prefTabPromise = BrowserTestUtils.waitForNewTab( + gBrowser, + PRIVACY_PREF_URL + ); + + // Select the preferences item. + EventUtils.synthesizeKey("VK_DOWN", {}); + EventUtils.synthesizeKey("VK_DOWN", {}); + EventUtils.synthesizeKey("VK_RETURN", {}); + + info(`expecting tab: about:preferences#privacy opened`); + const prefTab = await prefTabPromise; + info(`expecting tab: about:preferences#privacy removed`); + BrowserTestUtils.removeTab(prefTab); + + await BrowserTestUtils.removeTab(tab); +}); diff --git a/browser/extensions/formautofill/test/browser/browser_submission_in_private_mode.js b/browser/extensions/formautofill/test/browser/browser_submission_in_private_mode.js new file mode 100644 index 0000000000..ddb77bc47d --- /dev/null +++ b/browser/extensions/formautofill/test/browser/browser_submission_in_private_mode.js @@ -0,0 +1,33 @@ +/* eslint-disable mozilla/no-arbitrary-setTimeout */ +"use strict"; + +add_task(async function test_add_address() { + let privateWin = await BrowserTestUtils.openNewBrowserWindow({ + private: true, + }); + let addresses = await getAddresses(); + + is(addresses.length, 0, "No address in storage"); + + await BrowserTestUtils.withNewTab( + { gBrowser: privateWin.gBrowser, url: FORM_URL }, + async function(privateBrowser) { + await SpecialPowers.spawn(privateBrowser, [], async function() { + content.document.getElementById("organization").focus(); + content.document.getElementById("organization").value = "Mozilla"; + content.document.getElementById("street-address").value = + "331 E. Evelyn Avenue"; + content.document.getElementById("tel").value = "1-650-903-0800"; + + content.document.querySelector("input[type=submit]").click(); + }); + } + ); + + // Wait 1 second to make sure the profile has not been saved + await new Promise(resolve => setTimeout(resolve, 1000)); + addresses = await getAddresses(); + is(addresses.length, 0, "No address saved in private browsing mode"); + + await BrowserTestUtils.closeWindow(privateWin); +}); diff --git a/browser/extensions/formautofill/test/browser/browser_update_doorhanger.js b/browser/extensions/formautofill/test/browser/browser_update_doorhanger.js new file mode 100644 index 0000000000..9023568983 --- /dev/null +++ b/browser/extensions/formautofill/test/browser/browser_update_doorhanger.js @@ -0,0 +1,187 @@ +/* eslint-disable mozilla/no-arbitrary-setTimeout */ +"use strict"; + +add_task(async function test_update_address() { + await saveAddress(TEST_ADDRESS_1); + let addresses = await getAddresses(); + is(addresses.length, 1, "1 address in storage"); + + await BrowserTestUtils.withNewTab({ gBrowser, url: FORM_URL }, async function( + browser + ) { + let promiseShown = BrowserTestUtils.waitForEvent( + PopupNotifications.panel, + "popupshown" + ); + await openPopupOn(browser, "form #organization"); + await BrowserTestUtils.synthesizeKey("VK_DOWN", {}, browser); + await BrowserTestUtils.synthesizeKey("VK_RETURN", {}, browser); + + await SpecialPowers.spawn(browser, [], async function() { + let form = content.document.getElementById("form"); + let org = form.querySelector("#organization"); + await new Promise(resolve => content.setTimeout(resolve, 1000)); + org.setUserInput("Mozilla"); + + // Wait 1000ms before submission to make sure the input value applied + await new Promise(resolve => content.setTimeout(resolve, 1000)); + form.querySelector("input[type=submit]").click(); + }); + + await promiseShown; + await clickDoorhangerButton(MAIN_BUTTON); + }); + + addresses = await getAddresses(); + is(addresses.length, 1, "Still 1 address in storage"); + is(addresses[0].organization, "Mozilla", "Verify the organization field"); +}); + +add_task(async function test_create_new_address() { + let addresses = await getAddresses(); + is(addresses.length, 1, "1 address in storage"); + + await BrowserTestUtils.withNewTab({ gBrowser, url: FORM_URL }, async function( + browser + ) { + let promiseShown = BrowserTestUtils.waitForEvent( + PopupNotifications.panel, + "popupshown" + ); + await openPopupOn(browser, "form #tel"); + await BrowserTestUtils.synthesizeKey("VK_DOWN", {}, browser); + await BrowserTestUtils.synthesizeKey("VK_RETURN", {}, browser); + + await SpecialPowers.spawn(browser, [], async function() { + let form = content.document.getElementById("form"); + let tel = form.querySelector("#tel"); + await new Promise(resolve => content.setTimeout(resolve, 1000)); + tel.setUserInput("+1234567890"); + + // Wait 1000ms before submission to make sure the input value applied + await new Promise(resolve => content.setTimeout(resolve, 1000)); + form.querySelector("input[type=submit]").click(); + }); + + await promiseShown; + await clickDoorhangerButton(SECONDARY_BUTTON); + }); + + addresses = await getAddresses(); + is(addresses.length, 2, "2 addresses in storage"); + is(addresses[1].tel, "+1234567890", "Verify the tel field"); +}); + +add_task(async function test_create_new_address_merge() { + let addresses = await getAddresses(); + is(addresses.length, 2, "2 addresses in storage"); + + await BrowserTestUtils.withNewTab({ gBrowser, url: FORM_URL }, async function( + browser + ) { + let promiseShown = BrowserTestUtils.waitForEvent( + PopupNotifications.panel, + "popupshown" + ); + await openPopupOn(browser, "form #tel"); + await BrowserTestUtils.synthesizeKey("VK_DOWN", {}, browser); + await BrowserTestUtils.synthesizeKey("VK_RETURN", {}, browser); + + // Choose the latest address and revert to the original phone number + await SpecialPowers.spawn(browser, [], async function() { + let form = content.document.getElementById("form"); + let tel = form.querySelector("#tel"); + tel.setUserInput("+16172535702"); + + // Wait 1000ms before submission to make sure the input value applied + await new Promise(resolve => content.setTimeout(resolve, 1000)); + form.querySelector("input[type=submit]").click(); + }); + + await promiseShown; + await clickDoorhangerButton(SECONDARY_BUTTON); + }); + + addresses = await getAddresses(); + is(addresses.length, 2, "Still 2 addresses in storage"); +}); + +add_task(async function test_submit_untouched_fields() { + let addresses = await getAddresses(); + is(addresses.length, 2, "2 addresses in storage"); + + await BrowserTestUtils.withNewTab({ gBrowser, url: FORM_URL }, async function( + browser + ) { + let promiseShown = BrowserTestUtils.waitForEvent( + PopupNotifications.panel, + "popupshown" + ); + await openPopupOn(browser, "form #organization"); + info("before down"); + await BrowserTestUtils.synthesizeKey("VK_DOWN", {}, browser); + info("after down, before return"); + await BrowserTestUtils.synthesizeKey("VK_RETURN", {}, browser); + info("after return"); + + await SpecialPowers.spawn(browser, [], async function() { + let form = content.document.getElementById("form"); + let org = form.querySelector("#organization"); + await new Promise(resolve => content.setTimeout(resolve, 1000)); + org.setUserInput("Organization"); + + let tel = form.querySelector("#tel"); + await new Promise(resolve => content.setTimeout(resolve, 1000)); + tel.value = "12345"; // ".value" won't change the highlight status. + + // Wait 1000ms before submission to make sure the input value applied + await new Promise(resolve => content.setTimeout(resolve, 1000)); + info("before submit"); + form.querySelector("input[type=submit]").click(); + info("after submit"); + }); + + await promiseShown; + await clickDoorhangerButton(MAIN_BUTTON); + }); + + addresses = await getAddresses(); + is(addresses.length, 2, "Still 2 addresses in storage"); + is(addresses[0].organization, "Organization", "organization should change"); + is(addresses[0].tel, "+16172535702", "tel should remain unchanged"); +}); + +add_task(async function test_submit_reduced_fields() { + let addresses = await getAddresses(); + is(addresses.length, 2, "2 addresses in storage"); + + let url = BASE_URL + "autocomplete_simple_basic.html"; + await BrowserTestUtils.withNewTab({ gBrowser, url }, async function(browser) { + let promiseShown = BrowserTestUtils.waitForEvent( + PopupNotifications.panel, + "popupshown" + ); + await openPopupOn(browser, "form#simple input[name=tel]"); + await BrowserTestUtils.synthesizeKey("VK_DOWN", {}, browser); + await BrowserTestUtils.synthesizeKey("VK_RETURN", {}, browser); + + await SpecialPowers.spawn(browser, [], async function() { + let form = content.document.querySelector("form#simple"); + let tel = form.querySelector("input[name=tel]"); + await new Promise(resolve => content.setTimeout(resolve, 1000)); + tel.setUserInput("123456789"); + + // Wait 1000ms before submission to make sure the input value applied + await new Promise(resolve => content.setTimeout(resolve, 1000)); + form.querySelector("input[type=submit]").click(); + }); + + await promiseShown; + await clickDoorhangerButton(MAIN_BUTTON); + }); + + addresses = await getAddresses(); + is(addresses.length, 2, "Still 2 addresses in storage"); + is(addresses[0].tel, "123456789", "tel should should be changed"); + is(addresses[0]["postal-code"], "02139", "postal code should be kept"); +}); diff --git a/browser/extensions/formautofill/test/browser/creditCard/browser.ini b/browser/extensions/formautofill/test/browser/creditCard/browser.ini new file mode 100644 index 0000000000..9d08463f8f --- /dev/null +++ b/browser/extensions/formautofill/test/browser/creditCard/browser.ini @@ -0,0 +1,32 @@ +[DEFAULT] +prefs = + extensions.formautofill.creditCards.available=true + extensions.formautofill.creditCards.enabled=true + extensions.formautofill.reauth.enabled=true + # lower the interval for event telemetry in the content process to update the parent process + toolkit.telemetry.ipcBatchTimeout=0 +support-files = + ../head.js + !/browser/extensions/formautofill/test/fixtures/autocomplete_basic.html + ../../fixtures/autocomplete_creditcard_basic.html + ../../fixtures/autocomplete_creditcard_iframe.html + head_cc.js + +[browser_creditCard_doorhanger.js] +skip-if = (!debug && os == "mac") || (os == "win" && ccov) # perma-fail see Bug 1655601, Bug 1655600 +[browser_creditCard_dropdown_layout.js] +skip-if = ((os == "mac") || (os == 'linux') || (os == 'win')) +[browser_creditCard_fill_cancel_login.js] +skip-if = ((!debug && os == "mac") || (os == 'linux') || (os == 'win')) +[browser_editCreditCardDialog.js] +skip-if = ((os == 'linux') || (os == "mac") || (os == 'win')) # perma-fail see Bug 1600059 +[browser_insecure_form.js] +skip-if = ((os == 'mac') || (os == 'linux') || (os == 'win')) # bug 1456284 +[browser_manageCreditCardsDialog.js] +skip-if = ((os == 'win') || (os == 'mac') || (os == 'linux')) +[browser_privacyPreferences.js] +skip-if = (( os == "mac") || (os == 'linux') || (os == 'win')) # perma-fail see Bug 1600059 +[browser_anti_clickjacking.js] +skip-if = !debug && os == "mac" # perma-fail see Bug 1600059 +[browser_creditCard_telemetry.js] +skip-if = !debug && os == "mac" # perma-fail see Bug 1600059 diff --git a/browser/extensions/formautofill/test/browser/creditCard/browser_anti_clickjacking.js b/browser/extensions/formautofill/test/browser/creditCard/browser_anti_clickjacking.js new file mode 100644 index 0000000000..6f8ebcb8de --- /dev/null +++ b/browser/extensions/formautofill/test/browser/creditCard/browser_anti_clickjacking.js @@ -0,0 +1,119 @@ +"use strict"; + +const ADDRESS_URL = + "http://example.org/browser/browser/extensions/formautofill/test/browser/autocomplete_basic.html"; +const CC_URL = + "https://example.org/browser/browser/extensions/formautofill/test/browser/creditCard/autocomplete_creditcard_basic.html"; + +add_task(async function setup_storage() { + await saveAddress(TEST_ADDRESS_1); + await saveAddress(TEST_ADDRESS_2); + await saveAddress(TEST_ADDRESS_3); + + await saveCreditCard(TEST_CREDIT_CARD_1); + await saveCreditCard(TEST_CREDIT_CARD_2); + await saveCreditCard(TEST_CREDIT_CARD_3); +}); + +add_task(async function test_active_delay() { + // This is a workaround for the fact that we don't have a way + // to know when the popup was opened exactly and this makes our test + // racy when ensuring that we first test for disabled items before + // the delayed enabling happens. + // + // In the future we should consider adding an event when a popup + // gets opened and listen for it in this test before we check if the item + // is disabled. + await SpecialPowers.pushPrefEnv({ + set: [ + ["security.notification_enable_delay", 1000], + ["extensions.formautofill.reauth.enabled", false], + ], + }); + await BrowserTestUtils.withNewTab({ gBrowser, url: CC_URL }, async function( + browser + ) { + const focusInput = "#cc-number"; + + // Open the popup -- we don't use openPopupOn() because there + // are things we need to check between these steps. + await SimpleTest.promiseFocus(browser); + const start = performance.now(); + await focusAndWaitForFieldsIdentified(browser, focusInput); + await expectPopupOpen(browser); + const firstItem = getDisplayedPopupItems(browser)[0]; + ok(firstItem.disabled, "Popup should be disbled upon opening."); + is( + browser.autoCompletePopup.selectedIndex, + -1, + "No item selected at first" + ); + + // Check that clicking on menu doesn't do anything while + // it is disabled + firstItem.click(); + is( + browser.autoCompletePopup.selectedIndex, + -1, + "No item selected after clicking on disabled item" + ); + + // Check that the delay before enabling is as long as expected + await waitForPopupEnabled(browser); + const delta = performance.now() - start; + info(`Popup was disabled for ${delta} ms`); + ok(delta >= 1000, "Popup was disabled for at least 1000 ms"); + + // Check the clicking on the menu works now + firstItem.click(); + is( + browser.autoCompletePopup.selectedIndex, + 0, + "First item selected after clicking on enabled item" + ); + + // Clean up + await closePopup(browser); + }); +}); + +add_task(async function test_no_delay() { + await SpecialPowers.pushPrefEnv({ + set: [ + ["security.notification_enable_delay", 1000], + ["extensions.formautofill.reauth.enabled", false], + ], + }); + await BrowserTestUtils.withNewTab( + { gBrowser, url: ADDRESS_URL }, + async function(browser) { + const focusInput = "#organization"; + + // Open the popup -- we don't use openPopupOn() because there + // are things we need to check between these steps. + await SimpleTest.promiseFocus(browser); + await focusAndWaitForFieldsIdentified(browser, focusInput); + await BrowserTestUtils.synthesizeKey("VK_DOWN", {}, browser); + await expectPopupOpen(browser); + const firstItem = getDisplayedPopupItems(browser)[0]; + ok(!firstItem.disabled, "Popup should be enabled upon opening."); + is( + browser.autoCompletePopup.selectedIndex, + -1, + "No item selected at first" + ); + + // Check that clicking on menu doesn't do anything while + // it is disabled + firstItem.click(); + is( + browser.autoCompletePopup.selectedIndex, + 0, + "First item selected after clicking on enabled item" + ); + + // Clean up + await closePopup(browser); + } + ); +}); diff --git a/browser/extensions/formautofill/test/browser/creditCard/browser_creditCard_doorhanger.js b/browser/extensions/formautofill/test/browser/creditCard/browser_creditCard_doorhanger.js new file mode 100644 index 0000000000..f98335296c --- /dev/null +++ b/browser/extensions/formautofill/test/browser/creditCard/browser_creditCard_doorhanger.js @@ -0,0 +1,951 @@ +/* eslint-disable mozilla/no-arbitrary-setTimeout */ +"use strict"; + +add_task(async function test_submit_creditCard_cancel_saving() { + await SpecialPowers.pushPrefEnv({ + set: [[CREDITCARDS_USED_STATUS_PREF, 0]], + }); + await BrowserTestUtils.withNewTab( + { gBrowser, url: CREDITCARD_FORM_URL }, + async function(browser) { + let promiseShown = BrowserTestUtils.waitForEvent( + PopupNotifications.panel, + "popupshown" + ); + await SpecialPowers.spawn(browser, [], async function() { + let form = content.document.getElementById("form"); + let name = form.querySelector("#cc-name"); + name.focus(); + name.setUserInput("User 1"); + + let number = form.querySelector("#cc-number"); + number.setUserInput("5038146897157463"); + + // Wait 1000ms before submission to make sure the input value applied + await new Promise(resolve => content.setTimeout(resolve, 1000)); + form.querySelector("input[type=submit]").click(); + }); + + ok( + !SpecialPowers.Services.prefs.prefHasUserValue(SYNC_USERNAME_PREF), + "Sync account should not exist by default" + ); + let cb = getDoorhangerCheckbox(); + ok(cb.hidden, "Sync checkbox should be hidden"); + await promiseShown; + await clickDoorhangerButton(SECONDARY_BUTTON); + } + ); + + await sleep(1000); + let creditCards = await getCreditCards(); + is(creditCards.length, 0, "No credit card saved"); + is( + SpecialPowers.getIntPref(CREDITCARDS_USED_STATUS_PREF), + 2, + "User has seen the doorhanger" + ); + SpecialPowers.clearUserPref(CREDITCARDS_USED_STATUS_PREF); +}); + +add_task(async function test_submit_creditCard_saved() { + await SpecialPowers.pushPrefEnv({ + set: [[CREDITCARDS_USED_STATUS_PREF, 0]], + }); + await BrowserTestUtils.withNewTab( + { gBrowser, url: CREDITCARD_FORM_URL }, + async function(browser) { + let promiseShown = BrowserTestUtils.waitForEvent( + PopupNotifications.panel, + "popupshown" + ); + let onChanged = TestUtils.topicObserved("formautofill-storage-changed"); + await SpecialPowers.spawn(browser, [], async function() { + let form = content.document.getElementById("form"); + let name = form.querySelector("#cc-name"); + name.focus(); + name.setUserInput("User 1"); + + form.querySelector("#cc-number").setUserInput("5038146897157463"); + form.querySelector("#cc-exp-month").setUserInput("12"); + form.querySelector("#cc-exp-year").setUserInput("2017"); + form.querySelector("#cc-type").value = "mastercard"; + + // Wait 1000ms before submission to make sure the input value applied + await new Promise(resolve => content.setTimeout(resolve, 1000)); + form.querySelector("input[type=submit]").click(); + }); + + await promiseShown; + await clickDoorhangerButton(MAIN_BUTTON); + await onChanged; + } + ); + + let creditCards = await getCreditCards(); + is(creditCards.length, 1, "1 credit card in storage"); + is(creditCards[0]["cc-name"], "User 1", "Verify the name field"); + is(creditCards[0]["cc-type"], "mastercard", "Verify the cc-type field"); + is( + SpecialPowers.getIntPref(CREDITCARDS_USED_STATUS_PREF), + 2, + "User has seen the doorhanger" + ); + SpecialPowers.clearUserPref(CREDITCARDS_USED_STATUS_PREF); + await removeAllRecords(); +}); + +add_task(async function test_submit_untouched_creditCard_form() { + if (!OSKeyStoreTestUtils.canTestOSKeyStoreLogin()) { + todo( + OSKeyStoreTestUtils.canTestOSKeyStoreLogin(), + "Cannot test OS key store login on official builds." + ); + return; + } + + await SpecialPowers.pushPrefEnv({ + set: [[CREDITCARDS_USED_STATUS_PREF, 0]], + }); + await saveCreditCard(TEST_CREDIT_CARD_1); + let creditCards = await getCreditCards(); + is(creditCards.length, 1, "1 credit card in storage"); + + let osKeyStoreLoginShown = OSKeyStoreTestUtils.waitForOSKeyStoreLogin(true); + let onUsed = TestUtils.topicObserved( + "formautofill-storage-changed", + (subject, data) => data == "notifyUsed" + ); + await BrowserTestUtils.withNewTab( + { gBrowser, url: CREDITCARD_FORM_URL }, + async function(browser) { + await openPopupOn(browser, "form #cc-name"); + await BrowserTestUtils.synthesizeKey("VK_DOWN", {}, browser); + await BrowserTestUtils.synthesizeKey("VK_RETURN", {}, browser); + await osKeyStoreLoginShown; + await SpecialPowers.spawn(browser, [], async function() { + let form = content.document.getElementById("form"); + + // Wait 1000ms before submission to make sure the input value applied + await new Promise(resolve => content.setTimeout(resolve, 1000)); + form.querySelector("input[type=submit]").click(); + }); + + await sleep(1000); + is(PopupNotifications.panel.state, "closed", "Doorhanger is hidden"); + } + ); + await onUsed; + + creditCards = await getCreditCards(); + is(creditCards.length, 1, "Still 1 credit card"); + is(creditCards[0].timesUsed, 1, "timesUsed field set to 1"); + is( + SpecialPowers.getIntPref(CREDITCARDS_USED_STATUS_PREF), + 3, + "User has used autofill" + ); + SpecialPowers.clearUserPref(CREDITCARDS_USED_STATUS_PREF); + await removeAllRecords(); +}); + +add_task(async function test_submit_untouched_creditCard_form_iframe() { + if (!OSKeyStoreTestUtils.canTestOSKeyStoreLogin()) { + todo( + OSKeyStoreTestUtils.canTestOSKeyStoreLogin(), + "Cannot test OS key store login on official builds." + ); + return; + } + + await SpecialPowers.pushPrefEnv({ + set: [[CREDITCARDS_USED_STATUS_PREF, 0]], + }); + await saveCreditCard(TEST_CREDIT_CARD_1); + let creditCards = await getCreditCards(); + is(creditCards.length, 1, "1 credit card in storage"); + + let osKeyStoreLoginShown = OSKeyStoreTestUtils.waitForOSKeyStoreLogin(true); + let onUsed = TestUtils.topicObserved( + "formautofill-storage-changed", + (subject, data) => data == "notifyUsed" + ); + await BrowserTestUtils.withNewTab( + { gBrowser, url: CREDITCARD_FORM_IFRAME_URL }, + async function(browser) { + let iframeBC = browser.browsingContext.children[0]; + await openPopupForSubframe(browser, iframeBC, "form #cc-name"); + EventUtils.synthesizeKey("VK_DOWN", {}); + EventUtils.synthesizeKey("VK_RETURN", {}); + await osKeyStoreLoginShown; + await SpecialPowers.spawn(iframeBC, [], async function() { + let form = content.document.getElementById("form"); + + // Wait 1000ms before submission to make sure the input value applied + await new Promise(resolve => content.setTimeout(resolve, 1000)); + form.querySelector("input[type=submit]").click(); + }); + + await sleep(1000); + is(PopupNotifications.panel.state, "closed", "Doorhanger is hidden"); + } + ); + await onUsed; + + creditCards = await getCreditCards(); + is(creditCards.length, 1, "Still 1 credit card"); + is(creditCards[0].timesUsed, 2, "timesUsed field set to 2"); + is( + SpecialPowers.getIntPref(CREDITCARDS_USED_STATUS_PREF), + 3, + "User has used autofill" + ); + SpecialPowers.clearUserPref(CREDITCARDS_USED_STATUS_PREF); + await removeAllRecords(); +}); + +add_task(async function test_iframe_unload_save_card() { + await SpecialPowers.pushPrefEnv({ + set: [[CREDITCARDS_USED_STATUS_PREF, 0]], + }); + await BrowserTestUtils.withNewTab( + { gBrowser, url: CREDITCARD_FORM_IFRAME_URL }, + async function(browser) { + let promiseShown = BrowserTestUtils.waitForEvent( + PopupNotifications.panel, + "popupshown" + ); + let iframeBC = browser.browsingContext.children[0]; + let onChanged = TestUtils.topicObserved("formautofill-storage-changed"); + await SpecialPowers.spawn(iframeBC, [], async function() { + let form = content.document.getElementById("form"); + let name = form.querySelector("#cc-name"); + name.focus(); + name.setUserInput("User 1"); + + form.querySelector("#cc-number").setUserInput("4556194630960970"); + form.querySelector("#cc-exp-month").setUserInput("10"); + form.querySelector("#cc-exp-year").setUserInput("2024"); + form.querySelector("#cc-type").value = "visa"; + + // Wait 1000ms before submission to make sure the input value applied + await new Promise(resolve => content.setTimeout(resolve, 1000)); + }); + + info("Removing iframe without submitting"); + await SpecialPowers.spawn(browser, [], async function() { + let frame = content.document.querySelector("iframe"); + frame.remove(); + }); + + await promiseShown; + await clickDoorhangerButton(MAIN_BUTTON); + await onChanged; + } + ); + + let creditCards = await getCreditCards(); + is(creditCards.length, 1, "1 credit card in storage"); + is(creditCards[0]["cc-name"], "User 1", "Verify the name field"); + is(creditCards[0]["cc-type"], "visa", "Verify the cc-type field"); + is( + SpecialPowers.getIntPref(CREDITCARDS_USED_STATUS_PREF), + 2, + "User has seen the doorhanger" + ); + SpecialPowers.clearUserPref(CREDITCARDS_USED_STATUS_PREF); + await removeAllRecords(); +}); + +add_task(async function test_submit_changed_subset_creditCard_form() { + await SpecialPowers.pushPrefEnv({ + set: [[CREDITCARDS_USED_STATUS_PREF, 0]], + }); + await saveCreditCard(TEST_CREDIT_CARD_1); + let creditCards = await getCreditCards(); + is(creditCards.length, 1, "1 credit card in storage"); + + let onUsed = TestUtils.topicObserved( + "formautofill-storage-changed", + (subject, data) => data == "notifyUsed" + ); + await BrowserTestUtils.withNewTab( + { gBrowser, url: CREDITCARD_FORM_URL }, + async function(browser) { + let promiseShown = BrowserTestUtils.waitForEvent( + PopupNotifications.panel, + "popupshown" + ); + await SpecialPowers.spawn(browser, [], async function() { + let form = content.document.getElementById("form"); + let name = form.querySelector("#cc-name"); + + name.focus(); + await new Promise(resolve => content.setTimeout(resolve, 1000)); + name.setUserInput("Mark Smith"); + + form.querySelector("#cc-number").setUserInput("4111111111111111"); + form.querySelector("#cc-exp-month").setUserInput("4"); + form + .querySelector("#cc-exp-year") + .setUserInput(new Date().getFullYear()); + // Wait 1000ms before submission to make sure the input value applied + await new Promise(resolve => content.setTimeout(resolve, 1000)); + form.querySelector("input[type=submit]").click(); + }); + + await promiseShown; + await clickDoorhangerButton(MAIN_BUTTON); + } + ); + await onUsed; + + creditCards = await getCreditCards(); + is(creditCards.length, 1, "Still 1 credit card in storage"); + is(creditCards[0]["cc-name"], "Mark Smith", "name field got updated"); + is( + SpecialPowers.getIntPref(CREDITCARDS_USED_STATUS_PREF), + 2, + "User has seen the doorhanger" + ); + SpecialPowers.clearUserPref(CREDITCARDS_USED_STATUS_PREF); + await removeAllRecords(); +}); + +add_task(async function test_submit_duplicate_creditCard_form() { + await SpecialPowers.pushPrefEnv({ + set: [[CREDITCARDS_USED_STATUS_PREF, 0]], + }); + await saveCreditCard(TEST_CREDIT_CARD_1); + let creditCards = await getCreditCards(); + is(creditCards.length, 1, "1 credit card in storage"); + await BrowserTestUtils.withNewTab( + { gBrowser, url: CREDITCARD_FORM_URL }, + async function(browser) { + await SpecialPowers.spawn(browser, [], async function() { + let form = content.document.getElementById("form"); + let name = form.querySelector("#cc-name"); + name.focus(); + + name.setUserInput("John Doe"); + form.querySelector("#cc-number").setUserInput("4111111111111111"); + form.querySelector("#cc-exp-month").setUserInput("4"); + form + .querySelector("#cc-exp-year") + .setUserInput(new Date().getFullYear()); + form.querySelector("#cc-type").value = "visa"; + + // Wait 1000ms before submission to make sure the input value applied + await new Promise(resolve => content.setTimeout(resolve, 1000)); + form.querySelector("input[type=submit]").click(); + }); + + await sleep(1000); + is(PopupNotifications.panel.state, "closed", "Doorhanger is hidden"); + } + ); + + creditCards = await getCreditCards(); + is(creditCards.length, 1, "Still 1 credit card in storage"); + is( + creditCards[0]["cc-name"], + TEST_CREDIT_CARD_1["cc-name"], + "Verify the name field" + ); + is(creditCards[0].timesUsed, 1, "timesUsed field set to 1"); + is( + SpecialPowers.getIntPref(CREDITCARDS_USED_STATUS_PREF), + 1, + "User neither sees the doorhanger nor uses autofill but somehow has a record in the storage" + ); + SpecialPowers.clearUserPref(CREDITCARDS_USED_STATUS_PREF); + await removeAllRecords(); +}); + +add_task(async function test_submit_unnormailzed_creditCard_form() { + await saveCreditCard(TEST_CREDIT_CARD_1); + let creditCards = await getCreditCards(); + is(creditCards.length, 1, "1 credit card in storage"); + await BrowserTestUtils.withNewTab( + { gBrowser, url: CREDITCARD_FORM_URL }, + async function(browser) { + await SpecialPowers.spawn(browser, [], async function() { + let form = content.document.getElementById("form"); + let name = form.querySelector("#cc-name"); + name.focus(); + + name.setUserInput("John Doe"); + form.querySelector("#cc-number").setUserInput("4111111111111111"); + form.querySelector("#cc-exp-month").setUserInput("4"); + // Set unnormalized year + form.querySelector("#cc-exp-year").setUserInput( + new Date() + .getFullYear() + .toString() + .substr(2, 2) + ); + form.querySelector("#cc-type").value = "visa"; + + // Wait 1000ms before submission to make sure the input value applied + await new Promise(resolve => content.setTimeout(resolve, 1000)); + form.querySelector("input[type=submit]").click(); + }); + + await sleep(1000); + is(PopupNotifications.panel.state, "closed", "Doorhanger is hidden"); + } + ); + + creditCards = await getCreditCards(); + is(creditCards.length, 1, "Still 1 credit card in storage"); + is( + creditCards[0]["cc-exp-year"], + new Date().getFullYear(), + "Verify the expiry year field" + ); + await removeAllRecords(); +}); + +add_task(async function test_submit_creditCard_never_save() { + await SpecialPowers.pushPrefEnv({ + set: [[CREDITCARDS_USED_STATUS_PREF, 0]], + }); + await BrowserTestUtils.withNewTab( + { gBrowser, url: CREDITCARD_FORM_URL }, + async function(browser) { + let promiseShown = BrowserTestUtils.waitForEvent( + PopupNotifications.panel, + "popupshown" + ); + await SpecialPowers.spawn(browser, [], async function() { + let form = content.document.getElementById("form"); + let name = form.querySelector("#cc-name"); + name.focus(); + await new Promise(resolve => content.setTimeout(resolve, 1000)); + name.setUserInput("User 0"); + + let number = form.querySelector("#cc-number"); + number.setUserInput("6387060366272981"); + + // Wait 1000ms before submission to make sure the input value applied + await new Promise(resolve => content.setTimeout(resolve, 1000)); + form.querySelector("input[type=submit]").click(); + }); + + await promiseShown; + await clickDoorhangerButton(MENU_BUTTON, 0); + } + ); + + await sleep(1000); + let creditCards = await getCreditCards(); + let creditCardPref = SpecialPowers.getBoolPref( + ENABLED_AUTOFILL_CREDITCARDS_PREF + ); + is(creditCards.length, 0, "No credit card in storage"); + is(creditCardPref, false, "Credit card is disabled"); + is( + SpecialPowers.getIntPref(CREDITCARDS_USED_STATUS_PREF), + 2, + "User has seen the doorhanger" + ); + SpecialPowers.clearUserPref(CREDITCARDS_USED_STATUS_PREF); + SpecialPowers.clearUserPref(ENABLED_AUTOFILL_CREDITCARDS_PREF); +}); + +add_task(async function test_submit_creditCard_with_sync_account() { + await SpecialPowers.pushPrefEnv({ + set: [ + [SYNC_USERNAME_PREF, "foo@bar.com"], + [SYNC_CREDITCARDS_AVAILABLE_PREF, true], + [ENABLED_AUTOFILL_CREDITCARDS_PREF, true], + ], + }); + + await BrowserTestUtils.withNewTab( + { gBrowser, url: CREDITCARD_FORM_URL }, + async function(browser) { + let promiseShown = BrowserTestUtils.waitForEvent( + PopupNotifications.panel, + "popupshown" + ); + await SpecialPowers.spawn(browser, [], async function() { + let form = content.document.getElementById("form"); + let name = form.querySelector("#cc-name"); + name.focus(); + name.setUserInput("User 2"); + + let number = form.querySelector("#cc-number"); + number.setUserInput("6387060366272981"); + + // Wait 500ms before submission to make sure the input value applied + await new Promise(resolve => content.setTimeout(resolve, 500)); + form.querySelector("input[type=submit]").click(); + }); + + await promiseShown; + let cb = getDoorhangerCheckbox(); + ok(!cb.hidden, "Sync checkbox should be visible"); + is( + SpecialPowers.getBoolPref(SYNC_CREDITCARDS_PREF), + false, + "creditCards sync should be disabled by default" + ); + + // Verify if the checkbox and button state is changed. + let secondaryButton = getDoorhangerButton(SECONDARY_BUTTON); + let menuButton = getDoorhangerButton(MENU_BUTTON); + is( + cb.checked, + false, + "Checkbox state should match creditCards sync state" + ); + is( + secondaryButton.disabled, + false, + "Not saving button should be enabled" + ); + is( + menuButton.disabled, + false, + "Never saving menu button should be enabled" + ); + // Click the checkbox to enable credit card sync. + cb.click(); + is( + SpecialPowers.getBoolPref(SYNC_CREDITCARDS_PREF), + true, + "creditCards sync should be enabled after checked" + ); + is( + secondaryButton.disabled, + true, + "Not saving button should be disabled" + ); + is( + menuButton.disabled, + true, + "Never saving menu button should be disabled" + ); + // Click the checkbox again to disable credit card sync. + cb.click(); + is( + SpecialPowers.getBoolPref(SYNC_CREDITCARDS_PREF), + false, + "creditCards sync should be disabled after unchecked" + ); + is( + secondaryButton.disabled, + false, + "Not saving button should be enabled again" + ); + is( + menuButton.disabled, + false, + "Never saving menu button should be enabled again" + ); + await clickDoorhangerButton(SECONDARY_BUTTON); + } + ); +}); + +add_task(async function test_submit_creditCard_with_synced_already() { + await SpecialPowers.pushPrefEnv({ + set: [ + [SYNC_CREDITCARDS_PREF, true], + [SYNC_USERNAME_PREF, "foo@bar.com"], + [SYNC_CREDITCARDS_AVAILABLE_PREF, true], + ], + }); + + await BrowserTestUtils.withNewTab( + { gBrowser, url: CREDITCARD_FORM_URL }, + async function(browser) { + let promiseShown = BrowserTestUtils.waitForEvent( + PopupNotifications.panel, + "popupshown" + ); + await SpecialPowers.spawn(browser, [], async function() { + let form = content.document.getElementById("form"); + let name = form.querySelector("#cc-name"); + name.focus(); + name.setUserInput("User 2"); + + let number = form.querySelector("#cc-number"); + number.setUserInput("6387060366272981"); + + // Wait 500ms before submission to make sure the input value applied + await new Promise(resolve => content.setTimeout(resolve, 500)); + form.querySelector("input[type=submit]").click(); + }); + + await promiseShown; + let cb = getDoorhangerCheckbox(); + ok(cb.hidden, "Sync checkbox should be hidden"); + await clickDoorhangerButton(SECONDARY_BUTTON); + } + ); +}); + +add_task(async function test_submit_manual_mergeable_creditCard_form() { + await SpecialPowers.pushPrefEnv({ + set: [[CREDITCARDS_USED_STATUS_PREF, 0]], + }); + await saveCreditCard(TEST_CREDIT_CARD_3); + let creditCards = await getCreditCards(); + is(creditCards.length, 1, "1 credit card in storage"); + let onUsed = TestUtils.topicObserved( + "formautofill-storage-changed", + (subject, data) => data == "notifyUsed" + ); + await BrowserTestUtils.withNewTab( + { gBrowser, url: CREDITCARD_FORM_URL }, + async function(browser) { + let promiseShown = BrowserTestUtils.waitForEvent( + PopupNotifications.panel, + "popupshown" + ); + await SpecialPowers.spawn(browser, [], async function() { + let form = content.document.getElementById("form"); + let name = form.querySelector("#cc-name"); + name.focus(); + + name.setUserInput("User 3"); + form.querySelector("#cc-number").setUserInput("5103059495477870"); + form.querySelector("#cc-exp-month").setUserInput("1"); + form.querySelector("#cc-exp-year").setUserInput("2000"); + + // Wait 1000ms before submission to make sure the input value applied + await new Promise(resolve => content.setTimeout(resolve, 1000)); + form.querySelector("input[type=submit]").click(); + }); + await promiseShown; + await clickDoorhangerButton(MAIN_BUTTON); + } + ); + await onUsed; + + creditCards = await getCreditCards(); + is(creditCards.length, 1, "Still 1 credit card in storage"); + is(creditCards[0]["cc-name"], "User 3", "Verify the name field"); + is( + SpecialPowers.getIntPref(CREDITCARDS_USED_STATUS_PREF), + 2, + "User has seen the doorhanger" + ); + SpecialPowers.clearUserPref(CREDITCARDS_USED_STATUS_PREF); + await removeAllRecords(); +}); + +add_task(async function test_update_autofill_form_name() { + if (!OSKeyStoreTestUtils.canTestOSKeyStoreLogin()) { + todo( + OSKeyStoreTestUtils.canTestOSKeyStoreLogin(), + "Cannot test OS key store login on official builds." + ); + return; + } + + await SpecialPowers.pushPrefEnv({ + set: [[CREDITCARDS_USED_STATUS_PREF, 0]], + }); + await saveCreditCard(TEST_CREDIT_CARD_1); + let creditCards = await getCreditCards(); + is(creditCards.length, 1, "1 credit card in storage"); + let osKeyStoreLoginShown = OSKeyStoreTestUtils.waitForOSKeyStoreLogin(true); + let onUsed = TestUtils.topicObserved( + "formautofill-storage-changed", + (subject, data) => data == "notifyUsed" + ); + await BrowserTestUtils.withNewTab( + { gBrowser, url: CREDITCARD_FORM_URL }, + async function(browser) { + let promiseShown = BrowserTestUtils.waitForEvent( + PopupNotifications.panel, + "popupshown" + ); + await openPopupOn(browser, "form #cc-name"); + await BrowserTestUtils.synthesizeKey("VK_DOWN", {}, browser); + await BrowserTestUtils.synthesizeKey("VK_RETURN", {}, browser); + await osKeyStoreLoginShown; + await SpecialPowers.spawn(browser, [], async function() { + await ContentTaskUtils.waitForCondition(() => { + let form = content.document.getElementById("form"); + let name = form.querySelector("#cc-name"); + return name.value == "John Doe"; + }, "Credit card detail never fills"); + let form = content.document.getElementById("form"); + let name = form.querySelector("#cc-name"); + name.setUserInput("User 1"); + + // Wait 1000ms before submission to make sure the input value applied + await new Promise(resolve => content.setTimeout(resolve, 1000)); + form.querySelector("input[type=submit]").click(); + }); + await promiseShown; + await clickDoorhangerButton(MAIN_BUTTON); + } + ); + await onUsed; + + creditCards = await getCreditCards(); + is(creditCards.length, 1, "Still 1 credit card"); + is(creditCards[0]["cc-name"], "User 1", "cc-name field is updated"); + is( + creditCards[0]["cc-number"], + "************1111", + "Verify the card number field" + ); + is( + SpecialPowers.getIntPref(CREDITCARDS_USED_STATUS_PREF), + 3, + "User has used autofill" + ); + SpecialPowers.clearUserPref(CREDITCARDS_USED_STATUS_PREF); + await removeAllRecords(); +}); + +add_task(async function test_update_autofill_form_exp_date() { + if (!OSKeyStoreTestUtils.canTestOSKeyStoreLogin()) { + todo( + OSKeyStoreTestUtils.canTestOSKeyStoreLogin(), + "Cannot test OS key store login on official builds." + ); + return; + } + + await SpecialPowers.pushPrefEnv({ + set: [[CREDITCARDS_USED_STATUS_PREF, 0]], + }); + await saveCreditCard(TEST_CREDIT_CARD_1); + let creditCards = await getCreditCards(); + is(creditCards.length, 1, "1 credit card in storage"); + let osKeyStoreLoginShown = OSKeyStoreTestUtils.waitForOSKeyStoreLogin(true); + let onUsed = TestUtils.topicObserved( + "formautofill-storage-changed", + (subject, data) => data == "notifyUsed" + ); + await BrowserTestUtils.withNewTab( + { gBrowser, url: CREDITCARD_FORM_URL }, + async function(browser) { + let promiseShown = BrowserTestUtils.waitForEvent( + PopupNotifications.panel, + "popupshown" + ); + await openPopupOn(browser, "form #cc-name"); + await BrowserTestUtils.synthesizeKey("VK_DOWN", {}, browser); + await BrowserTestUtils.synthesizeKey("VK_RETURN", {}, browser); + await osKeyStoreLoginShown; + await SpecialPowers.spawn(browser, [], async function() { + await ContentTaskUtils.waitForCondition(() => { + let form = content.document.getElementById("form"); + let name = form.querySelector("#cc-name"); + return name.value == "John Doe"; + }, "Credit card detail never fills"); + let form = content.document.getElementById("form"); + let year = form.querySelector("#cc-exp-year"); + year.setUserInput("2019"); + + // Wait 1000ms before submission to make sure the input value applied + await new Promise(resolve => content.setTimeout(resolve, 1000)); + form.querySelector("input[type=submit]").click(); + }); + await promiseShown; + await clickDoorhangerButton(MAIN_BUTTON); + await osKeyStoreLoginShown; + } + ); + await onUsed; + + creditCards = await getCreditCards(); + is(creditCards.length, 1, "Still 1 credit card"); + is(creditCards[0]["cc-exp-year"], 2019, "cc-exp-year field is updated"); + is( + creditCards[0]["cc-number"], + "************1111", + "Verify the card number field" + ); + is( + SpecialPowers.getIntPref(CREDITCARDS_USED_STATUS_PREF), + 3, + "User has used autofill" + ); + SpecialPowers.clearUserPref(CREDITCARDS_USED_STATUS_PREF); + await removeAllRecords(); +}); + +add_task(async function test_create_new_autofill_form() { + if (!OSKeyStoreTestUtils.canTestOSKeyStoreLogin()) { + todo( + OSKeyStoreTestUtils.canTestOSKeyStoreLogin(), + "Cannot test OS key store login on official builds." + ); + return; + } + + await SpecialPowers.pushPrefEnv({ + set: [[CREDITCARDS_USED_STATUS_PREF, 0]], + }); + await saveCreditCard(TEST_CREDIT_CARD_1); + let creditCards = await getCreditCards(); + is(creditCards.length, 1, "1 credit card in storage"); + let osKeyStoreLoginShown = OSKeyStoreTestUtils.waitForOSKeyStoreLogin(true); + await BrowserTestUtils.withNewTab( + { gBrowser, url: CREDITCARD_FORM_URL }, + async function(browser) { + let promiseShown = BrowserTestUtils.waitForEvent( + PopupNotifications.panel, + "popupshown" + ); + let onChanged = TestUtils.topicObserved("formautofill-storage-changed"); + await openPopupOn(browser, "form #cc-name"); + await BrowserTestUtils.synthesizeKey("VK_DOWN", {}, browser); + await BrowserTestUtils.synthesizeKey("VK_RETURN", {}, browser); + await SpecialPowers.spawn(browser, [], async function() { + await ContentTaskUtils.waitForCondition(() => { + let form = content.document.getElementById("form"); + let name = form.querySelector("#cc-name"); + return name.value == "John Doe"; + }, "Credit card detail never fills"); + let form = content.document.getElementById("form"); + let name = form.querySelector("#cc-name"); + name.setUserInput("User 1"); + + // Wait 1000ms before submission to make sure the input value applied + await new Promise(resolve => content.setTimeout(resolve, 1000)); + form.querySelector("input[type=submit]").click(); + }); + + await promiseShown; + await clickDoorhangerButton(SECONDARY_BUTTON); + await osKeyStoreLoginShown; + await onChanged; + } + ); + + creditCards = await getCreditCards(); + is(creditCards.length, 2, "2 credit cards in storage"); + is( + creditCards[0]["cc-name"], + TEST_CREDIT_CARD_1["cc-name"], + "Original record's cc-name field is unchanged" + ); + is(creditCards[1]["cc-name"], "User 1", "cc-name field in the new record"); + is( + SpecialPowers.getIntPref(CREDITCARDS_USED_STATUS_PREF), + 3, + "User has used autofill" + ); + SpecialPowers.clearUserPref(CREDITCARDS_USED_STATUS_PREF); + await removeAllRecords(); +}); + +add_task(async function test_update_duplicate_autofill_form() { + if (!OSKeyStoreTestUtils.canTestOSKeyStoreLogin()) { + todo( + OSKeyStoreTestUtils.canTestOSKeyStoreLogin(), + "Cannot test OS key store login on official builds." + ); + return; + } + + await SpecialPowers.pushPrefEnv({ + set: [[CREDITCARDS_USED_STATUS_PREF, 0]], + }); + await saveCreditCard({ + "cc-number": "6387060366272981", + }); + await saveCreditCard({ + "cc-number": "5038146897157463", + }); + let creditCards = await getCreditCards(); + is(creditCards.length, 2, "2 credit card in storage"); + let osKeyStoreLoginShown = OSKeyStoreTestUtils.waitForOSKeyStoreLogin(true); + let onUsed = TestUtils.topicObserved( + "formautofill-storage-changed", + (subject, data) => data == "notifyUsed" + ); + await BrowserTestUtils.withNewTab( + { gBrowser, url: CREDITCARD_FORM_URL }, + async function(browser) { + await openPopupOn(browser, "form #cc-number"); + await BrowserTestUtils.synthesizeKey("VK_DOWN", {}, browser); + await BrowserTestUtils.synthesizeKey("VK_RETURN", {}, browser); + await SpecialPowers.spawn(browser, [], async function() { + await ContentTaskUtils.waitForCondition(() => { + let form = content.document.getElementById("form"); + let number = form.querySelector("#cc-number"); + return number.value == "6387060366272981"; + }, "Should be the first credit card number"); + + // Change number to the second credit card number + let form = content.document.getElementById("form"); + let number = form.querySelector("#cc-number"); + number.setUserInput("5038146897157463"); + + // Wait 1000ms before submission to make sure the input value applied + await new Promise(resolve => content.setTimeout(resolve, 1000)); + form.querySelector("input[type=submit]").click(); + }); + + await sleep(1000); + is(PopupNotifications.panel.state, "closed", "Doorhanger is hidden"); + await osKeyStoreLoginShown; + } + ); + await onUsed; + + creditCards = await getCreditCards(); + is(creditCards.length, 2, "Still 2 credit card"); + is( + SpecialPowers.getIntPref(CREDITCARDS_USED_STATUS_PREF), + 1, + "User neither sees the doorhanger nor uses autofill but somehow has a record in the storage" + ); + SpecialPowers.clearUserPref(CREDITCARDS_USED_STATUS_PREF); + await removeAllRecords(); +}); + +add_task(async function test_submit_creditCard_with_invalid_network() { + await BrowserTestUtils.withNewTab( + { gBrowser, url: CREDITCARD_FORM_URL }, + async function(browser) { + let promiseShown = BrowserTestUtils.waitForEvent( + PopupNotifications.panel, + "popupshown" + ); + let onChanged = TestUtils.topicObserved("formautofill-storage-changed"); + await SpecialPowers.spawn(browser, [], async function() { + let form = content.document.getElementById("form"); + let name = form.querySelector("#cc-name"); + name.focus(); + name.setUserInput("User 1"); + + form.querySelector("#cc-number").setUserInput("5038146897157463"); + form.querySelector("#cc-exp-month").setUserInput("12"); + form.querySelector("#cc-exp-year").setUserInput("2017"); + form.querySelector("#cc-type").value = "gringotts"; + + // Wait 1000ms before submission to make sure the input value applied + await new Promise(resolve => content.setTimeout(resolve, 1000)); + form.querySelector("input[type=submit]").click(); + }); + + await promiseShown; + await clickDoorhangerButton(MAIN_BUTTON); + await onChanged; + } + ); + + let creditCards = await getCreditCards(); + is(creditCards.length, 1, "1 credit card in storage"); + is(creditCards[0]["cc-name"], "User 1", "Verify the name field"); + is( + creditCards[0]["cc-type"], + undefined, + "Invalid network/cc-type was not saved" + ); + + SpecialPowers.clearUserPref(CREDITCARDS_USED_STATUS_PREF); + await removeAllRecords(); +}); diff --git a/browser/extensions/formautofill/test/browser/creditCard/browser_creditCard_dropdown_layout.js b/browser/extensions/formautofill/test/browser/creditCard/browser_creditCard_dropdown_layout.js new file mode 100644 index 0000000000..b6115a9508 --- /dev/null +++ b/browser/extensions/formautofill/test/browser/creditCard/browser_creditCard_dropdown_layout.js @@ -0,0 +1,53 @@ +"use strict"; + +const CC_URL = + "https://example.org/browser/browser/extensions/formautofill/test/browser/creditCard/autocomplete_creditcard_basic.html"; + +add_task(async function setup_storage() { + await saveCreditCard(TEST_CREDIT_CARD_1); + await saveCreditCard(TEST_CREDIT_CARD_2); + await saveCreditCard(TEST_CREDIT_CARD_3); +}); + +async function reopenPopupWithResizedInput(browser, selector, newSize) { + await closePopup(browser); + /* eslint no-shadow: ["error", { "allow": ["selector", "newSize"] }] */ + await SpecialPowers.spawn(browser, [{ selector, newSize }], async function({ + selector, + newSize, + }) { + const input = content.document.querySelector(selector); + + input.style.boxSizing = "border-box"; + input.style.width = newSize + "px"; + }); + await openPopupOn(browser, selector); +} + +add_task(async function test_credit_card_dropdown() { + await BrowserTestUtils.withNewTab({ gBrowser, url: CC_URL }, async function( + browser + ) { + const focusInput = "#cc-number"; + await openPopupOn(browser, focusInput); + const firstItem = getDisplayedPopupItems(browser)[0]; + + isnot(firstItem.getAttribute("ac-image"), "", "Should show icon"); + ok( + firstItem.getAttribute("aria-label").startsWith("Visa "), + "aria-label should start with Visa" + ); + + // The breakpoint of two-lines layout is 185px + await reopenPopupWithResizedInput(browser, focusInput, 175); + is( + firstItem._itemBox.getAttribute("size"), + "small", + "Show two-lines layout" + ); + await reopenPopupWithResizedInput(browser, focusInput, 195); + is(firstItem._itemBox.hasAttribute("size"), false, "Show one-line layout"); + + await closePopup(browser); + }); +}); diff --git a/browser/extensions/formautofill/test/browser/creditCard/browser_creditCard_fill_cancel_login.js b/browser/extensions/formautofill/test/browser/creditCard/browser_creditCard_fill_cancel_login.js new file mode 100644 index 0000000000..602b989d04 --- /dev/null +++ b/browser/extensions/formautofill/test/browser/creditCard/browser_creditCard_fill_cancel_login.js @@ -0,0 +1,33 @@ +"use strict"; + +add_task(async function test_fill_creditCard_but_cancel_login() { + if (!OSKeyStoreTestUtils.canTestOSKeyStoreLogin()) { + todo( + OSKeyStoreTestUtils.canTestOSKeyStoreLogin(), + "Cannot test OS key store login on official builds." + ); + return; + } + + await saveCreditCard(TEST_CREDIT_CARD_2); + + let osKeyStoreLoginShown = OSKeyStoreTestUtils.waitForOSKeyStoreLogin(false); // cancel + await BrowserTestUtils.withNewTab( + { gBrowser, url: CREDITCARD_FORM_URL }, + async function(browser) { + await openPopupOn(browser, "#cc-name"); + const ccItem = getDisplayedPopupItems(browser)[0]; + await EventUtils.synthesizeMouseAtCenter(ccItem, {}); + await Promise.all([osKeyStoreLoginShown, expectPopupClose(browser)]); + + await SpecialPowers.spawn(browser, [], async function() { + is(content.document.querySelector("#cc-name").value, "", "Check name"); + is( + content.document.querySelector("#cc-number").value, + "", + "Check number" + ); + }); + } + ); +}); diff --git a/browser/extensions/formautofill/test/browser/creditCard/browser_creditCard_telemetry.js b/browser/extensions/formautofill/test/browser/creditCard/browser_creditCard_telemetry.js new file mode 100644 index 0000000000..48c4d7733a --- /dev/null +++ b/browser/extensions/formautofill/test/browser/creditCard/browser_creditCard_telemetry.js @@ -0,0 +1,758 @@ +/* eslint-disable mozilla/no-arbitrary-setTimeout */ +"use strict"; + +const { TelemetryTestUtils } = ChromeUtils.import( + "resource://testing-common/TelemetryTestUtils.jsm" +); + +const CC_NUM_USES_HISTOGRAM = "CREDITCARD_NUM_USES"; + +async function assertTelemetry(expected_content, expected_parent) { + let snapshots; + + info( + `Waiting for ${expected_content?.length ?? 0} content events and ` + + `${expected_parent?.length ?? 0} parent events` + ); + + await TestUtils.waitForCondition( + () => { + snapshots = Services.telemetry.snapshotEvents( + Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS, + false + ); + return ( + (snapshots.parent?.length ?? 0) >= (expected_parent?.length ?? 0) && + (snapshots.content?.length ?? 0) >= (expected_content?.length ?? 0) + ); + }, + "Wait for telemetry to be collected", + 100, + 100 + ); + + info(JSON.stringify(snapshots, null, 2)); + + if (expected_content !== undefined) { + expected_content = expected_content.map( + ([category, method, object, value, extra]) => { + return { category, method, object, value, extra }; + } + ); + + let clear = expected_parent === undefined; + + TelemetryTestUtils.assertEvents( + expected_content, + { + category: "creditcard", + }, + { clear, process: "content" } + ); + } + + if (expected_parent !== undefined) { + expected_parent = expected_parent.map( + ([category, method, object, value, extra]) => { + return { category, method, object, value, extra }; + } + ); + TelemetryTestUtils.assertEvents( + expected_parent, + { + category: "creditcard", + }, + { process: "parent" } + ); + } +} + +function assertHistogram(histogramId, expectedNonZeroRanges) { + let snapshot = Services.telemetry.getHistogramById(histogramId).snapshot(); + + // Compute the actual ranges in the format { range1: value1, range2: value2 }. + let actualNonZeroRanges = {}; + for (let [range, value] of Object.entries(snapshot.values)) { + if (value > 0) { + actualNonZeroRanges[range] = value; + } + } + + // These are stringified to visualize the differences between the values. + info("Testing histogram: " + histogramId); + Assert.equal( + JSON.stringify(actualNonZeroRanges), + JSON.stringify(expectedNonZeroRanges) + ); +} + +async function useCreditCard(idx) { + let osKeyStoreLoginShown = OSKeyStoreTestUtils.waitForOSKeyStoreLogin(true); + let onUsed = TestUtils.topicObserved( + "formautofill-storage-changed", + (subject, data) => data == "notifyUsed" + ); + await BrowserTestUtils.withNewTab( + { gBrowser, url: CREDITCARD_FORM_URL }, + async function(browser) { + await openPopupOn(browser, "form #cc-name"); + for (let i = 0; i < idx; i++) { + await BrowserTestUtils.synthesizeKey("VK_DOWN", {}, browser); + } + await BrowserTestUtils.synthesizeKey("VK_RETURN", {}, browser); + await osKeyStoreLoginShown; + await SpecialPowers.spawn(browser, [], async function() { + await ContentTaskUtils.waitForCondition(() => { + let form = content.document.getElementById("form"); + let number = form.querySelector("#cc-number"); + return !!number.value.length; + }, "Credit card detail never fills"); + let form = content.document.getElementById("form"); + + // Wait 1000ms before submission to make sure the input value applied + await new Promise(resolve => content.setTimeout(resolve, 1000)); + form.querySelector("input[type=submit]").click(); + }); + + await sleep(1000); + is(PopupNotifications.panel.state, "closed", "Doorhanger is hidden"); + } + ); + await onUsed; +} + +add_task(async function test_popup_opened() { + await SpecialPowers.pushPrefEnv({ + set: [ + [AUTOFILL_CREDITCARDS_AVAILABLE_PREF, true], + [ENABLED_AUTOFILL_CREDITCARDS_PREF, true], + ], + }); + + Services.telemetry.clearEvents(); + Services.telemetry.clearScalars(); + Services.telemetry.setEventRecordingEnabled("creditcard", true); + + await saveCreditCard(TEST_CREDIT_CARD_1); + + await BrowserTestUtils.withNewTab( + { gBrowser, url: CREDITCARD_FORM_URL }, + async function(browser) { + const focusInput = "#cc-number"; + + await openPopupOn(browser, focusInput); + + // Clean up + await closePopup(browser); + } + ); + + await removeAllRecords(); + + await assertTelemetry([ + ["creditcard", "detected", "cc_form"], + ["creditcard", "popup_shown", "cc_form"], + ]); + + TelemetryTestUtils.assertScalar( + TelemetryTestUtils.getProcessScalars("content"), + "formautofill.creditCards.detected_sections_count", + 1, + "There should be 1 section detected." + ); + TelemetryTestUtils.assertScalar( + TelemetryTestUtils.getProcessScalars("content"), + "formautofill.creditCards.submitted_sections_count", + 0, + "There should be no sections submitted." + ); + + SpecialPowers.clearUserPref(AUTOFILL_CREDITCARDS_AVAILABLE_PREF); + SpecialPowers.clearUserPref(ENABLED_AUTOFILL_CREDITCARDS_PREF); +}); + +add_task(async function test_submit_creditCard_new() { + async function test_per_command( + command, + idx, + useCount = {}, + expectChanged = undefined + ) { + await SpecialPowers.pushPrefEnv({ + set: [ + [CREDITCARDS_USED_STATUS_PREF, 0], + [AUTOFILL_CREDITCARDS_AVAILABLE_PREF, true], + [ENABLED_AUTOFILL_CREDITCARDS_PREF, true], + ], + }); + await BrowserTestUtils.withNewTab( + { gBrowser, url: CREDITCARD_FORM_URL }, + async function(browser) { + let promiseShown = BrowserTestUtils.waitForEvent( + PopupNotifications.panel, + "popupshown" + ); + let onChanged = TestUtils.topicObserved("formautofill-storage-changed"); + + await SpecialPowers.spawn(browser, [], async function() { + let form = content.document.getElementById("form"); + let name = form.querySelector("#cc-name"); + + name.focus(); + name.setUserInput("User 1"); + + form.querySelector("#cc-number").setUserInput("5038146897157463"); + form.querySelector("#cc-exp-month").setUserInput("12"); + form.querySelector("#cc-exp-year").setUserInput("2017"); + form.querySelector("#cc-type").value = "mastercard"; + + // Wait 1000ms before submission to make sure the input value applied + await new Promise(resolve => content.setTimeout(resolve, 1000)); + form.querySelector("input[type=submit]").click(); + }); + + await promiseShown; + await clickDoorhangerButton(command, idx); + if (expectChanged !== undefined) { + await onChanged; + TelemetryTestUtils.assertScalar( + TelemetryTestUtils.getProcessScalars("parent"), + "formautofill.creditCards.autofill_profiles_count", + expectChanged, + "There should be ${expectChanged} profile(s) stored." + ); + } + } + ); + + SpecialPowers.clearUserPref(CREDITCARDS_USED_STATUS_PREF); + SpecialPowers.clearUserPref(ENABLED_AUTOFILL_CREDITCARDS_PREF); + SpecialPowers.clearUserPref(AUTOFILL_CREDITCARDS_AVAILABLE_PREF); + + assertHistogram(CC_NUM_USES_HISTOGRAM, useCount); + + await removeAllRecords(); + } + + Services.telemetry.clearEvents(); + Services.telemetry.clearScalars(); + Services.telemetry.getHistogramById(CC_NUM_USES_HISTOGRAM).clear(); + Services.telemetry.setEventRecordingEnabled("creditcard", true); + + let expected_content = [ + ["creditcard", "detected", "cc_form"], + [ + "creditcard", + "submitted", + "cc_form", + undefined, + { + // 5 fields plus submit button + fields_not_auto: "6", + fields_auto: "0", + fields_modified: "0", + }, + ], + ]; + await test_per_command(MAIN_BUTTON, undefined, { 1: 1 }, 1); + await assertTelemetry(expected_content, [ + ["creditcard", "show", "capture_doorhanger"], + ["creditcard", "save", "capture_doorhanger"], + ]); + + await test_per_command(SECONDARY_BUTTON); + await assertTelemetry(expected_content, [ + ["creditcard", "show", "capture_doorhanger"], + ["creditcard", "cancel", "capture_doorhanger"], + ]); + + await test_per_command(MENU_BUTTON, 0); + await assertTelemetry(expected_content, [ + ["creditcard", "show", "capture_doorhanger"], + ["creditcard", "disable", "capture_doorhanger"], + ]); + + TelemetryTestUtils.assertScalar( + TelemetryTestUtils.getProcessScalars("content"), + "formautofill.creditCards.detected_sections_count", + 3, + "There should be 3 sections detected." + ); + TelemetryTestUtils.assertScalar( + TelemetryTestUtils.getProcessScalars("content"), + "formautofill.creditCards.submitted_sections_count", + 3, + "There should be 1 section submitted." + ); +}); + +add_task(async function test_submit_creditCard_autofill() { + if (!OSKeyStoreTestUtils.canTestOSKeyStoreLogin()) { + todo( + OSKeyStoreTestUtils.canTestOSKeyStoreLogin(), + "Cannot test OS key store login on official builds." + ); + return; + } + + Services.telemetry.clearEvents(); + Services.telemetry.getHistogramById(CC_NUM_USES_HISTOGRAM).clear(); + Services.telemetry.setEventRecordingEnabled("creditcard", true); + + await SpecialPowers.pushPrefEnv({ + set: [ + [CREDITCARDS_USED_STATUS_PREF, 0], + [AUTOFILL_CREDITCARDS_AVAILABLE_PREF, true], + [ENABLED_AUTOFILL_CREDITCARDS_PREF, true], + ], + }); + + await saveCreditCard(TEST_CREDIT_CARD_1); + let creditCards = await getCreditCards(); + is(creditCards.length, 1, "1 credit card in storage"); + + await useCreditCard(1); + + assertHistogram(CC_NUM_USES_HISTOGRAM, { + 1: 1, + }); + + SpecialPowers.clearUserPref(CREDITCARDS_USED_STATUS_PREF); + SpecialPowers.clearUserPref(ENABLED_AUTOFILL_CREDITCARDS_PREF); + SpecialPowers.clearUserPref(AUTOFILL_CREDITCARDS_AVAILABLE_PREF); + + await removeAllRecords(); + + await assertTelemetry( + [ + ["creditcard", "detected", "cc_form"], + ["creditcard", "popup_shown", "cc_form"], + ["creditcard", "filled", "cc_form"], + [ + "creditcard", + "submitted", + "cc_form", + undefined, + { + fields_not_auto: "3", + fields_auto: "5", + fields_modified: "0", + }, + ], + ], + [] + ); +}); + +add_task(async function test_submit_creditCard_update() { + if (!OSKeyStoreTestUtils.canTestOSKeyStoreLogin()) { + todo( + OSKeyStoreTestUtils.canTestOSKeyStoreLogin(), + "Cannot test OS key store login on official builds." + ); + return; + } + + async function test_per_command( + command, + idx, + useCount = {}, + expectChanged = undefined + ) { + await SpecialPowers.pushPrefEnv({ + set: [ + [CREDITCARDS_USED_STATUS_PREF, 0], + [AUTOFILL_CREDITCARDS_AVAILABLE_PREF, true], + [ENABLED_AUTOFILL_CREDITCARDS_PREF, true], + ], + }); + + await saveCreditCard(TEST_CREDIT_CARD_1); + let creditCards = await getCreditCards(); + is(creditCards.length, 1, "1 credit card in storage"); + + let osKeyStoreLoginShown = OSKeyStoreTestUtils.waitForOSKeyStoreLogin(true); + await BrowserTestUtils.withNewTab( + { gBrowser, url: CREDITCARD_FORM_URL }, + async function(browser) { + let promiseShown = BrowserTestUtils.waitForEvent( + PopupNotifications.panel, + "popupshown" + ); + let onChanged = TestUtils.topicObserved("formautofill-storage-changed"); + + await openPopupOn(browser, "form #cc-name"); + await BrowserTestUtils.synthesizeKey("VK_DOWN", {}, browser); + await BrowserTestUtils.synthesizeKey("VK_RETURN", {}, browser); + await osKeyStoreLoginShown; + await SpecialPowers.spawn(browser, [], async function() { + await ContentTaskUtils.waitForCondition(() => { + let form = content.document.getElementById("form"); + let name = form.querySelector("#cc-name"); + return name.value == "John Doe"; + }, "Credit card detail never fills"); + let form = content.document.getElementById("form"); + let year = form.querySelector("#cc-exp-year"); + year.setUserInput("2019"); + + // Wait 1000ms before submission to make sure the input value applied + await new Promise(resolve => content.setTimeout(resolve, 1000)); + form.querySelector("input[type=submit]").click(); + }); + await promiseShown; + await clickDoorhangerButton(command, idx); + if (expectChanged !== undefined) { + await onChanged; + TelemetryTestUtils.assertScalar( + TelemetryTestUtils.getProcessScalars("parent"), + "formautofill.creditCards.autofill_profiles_count", + expectChanged, + "There should be ${expectChanged} profile(s) stored." + ); + } + } + ); + + assertHistogram("CREDITCARD_NUM_USES", useCount); + + SpecialPowers.clearUserPref(CREDITCARDS_USED_STATUS_PREF); + SpecialPowers.clearUserPref(ENABLED_AUTOFILL_CREDITCARDS_PREF); + SpecialPowers.clearUserPref(AUTOFILL_CREDITCARDS_AVAILABLE_PREF); + + await removeAllRecords(); + } + Services.telemetry.clearEvents(); + Services.telemetry.getHistogramById(CC_NUM_USES_HISTOGRAM).clear(); + Services.telemetry.setEventRecordingEnabled("creditcard", true); + + let expected_content = [ + ["creditcard", "detected", "cc_form"], + ["creditcard", "popup_shown", "cc_form"], + ["creditcard", "filled", "cc_form"], + [ + "creditcard", + "filled_modified", + "cc_form", + undefined, + { field_name: "cc-exp-year" }, + ], + [ + "creditcard", + "submitted", + "cc_form", + undefined, + { + fields_not_auto: "3", + fields_auto: "5", + fields_modified: "1", + }, + ], + ]; + + await test_per_command(MAIN_BUTTON, undefined, { 1: 1 }, 1); + await assertTelemetry(expected_content, [ + ["creditcard", "show", "update_doorhanger"], + ["creditcard", "update", "update_doorhanger"], + ]); + + await test_per_command(SECONDARY_BUTTON, undefined, { 0: 1, 1: 1 }, 2); + await assertTelemetry(expected_content, [ + ["creditcard", "show", "update_doorhanger"], + ["creditcard", "save", "update_doorhanger"], + ]); +}); + +const TEST_SELECTORS = { + selRecords: "#credit-cards", + btnRemove: "#remove", + btnAdd: "#add", + btnEdit: "#edit", +}; + +const DIALOG_SIZE = "width=600,height=400"; + +add_task(async function test_removingCreditCardsViaKeyboardDelete() { + Services.telemetry.clearEvents(); + Services.telemetry.setEventRecordingEnabled("creditcard", true); + + await SpecialPowers.pushPrefEnv({ + set: [ + [AUTOFILL_CREDITCARDS_AVAILABLE_PREF, true], + [ENABLED_AUTOFILL_CREDITCARDS_PREF, true], + ], + }); + + await saveCreditCard(TEST_CREDIT_CARD_1); + let win = window.openDialog( + MANAGE_CREDIT_CARDS_DIALOG_URL, + null, + DIALOG_SIZE + ); + await waitForFocusAndFormReady(win); + + let selRecords = win.document.querySelector(TEST_SELECTORS.selRecords); + + is(selRecords.length, 1, "One credit card"); + + EventUtils.synthesizeMouseAtCenter(selRecords.children[0], {}, win); + EventUtils.synthesizeKey("VK_DELETE", {}, win); + await BrowserTestUtils.waitForEvent(selRecords, "RecordsRemoved"); + is(selRecords.length, 0, "No credit cards left"); + + win.close(); + + await assertTelemetry(undefined, [ + ["creditcard", "show", "manage"], + ["creditcard", "delete", "manage"], + ]); + + SpecialPowers.clearUserPref(ENABLED_AUTOFILL_CREDITCARDS_PREF); + SpecialPowers.clearUserPref(AUTOFILL_CREDITCARDS_AVAILABLE_PREF); + + await removeAllRecords(); +}); + +add_task(async function test_saveCreditCard() { + Services.telemetry.clearEvents(); + Services.telemetry.setEventRecordingEnabled("creditcard", true); + + await SpecialPowers.pushPrefEnv({ + set: [ + [AUTOFILL_CREDITCARDS_AVAILABLE_PREF, true], + [ENABLED_AUTOFILL_CREDITCARDS_PREF, true], + ], + }); + + await testDialog(EDIT_CREDIT_CARD_DIALOG_URL, win => { + EventUtils.synthesizeKey("VK_TAB", {}, win); + EventUtils.synthesizeKey(TEST_CREDIT_CARD_1["cc-number"], {}, win); + EventUtils.synthesizeKey("VK_TAB", {}, win); + EventUtils.synthesizeKey( + "0" + TEST_CREDIT_CARD_1["cc-exp-month"].toString(), + {}, + win + ); + EventUtils.synthesizeKey("VK_TAB", {}, win); + EventUtils.synthesizeKey( + TEST_CREDIT_CARD_1["cc-exp-year"].toString(), + {}, + win + ); + EventUtils.synthesizeKey("VK_TAB", {}, win); + EventUtils.synthesizeKey(TEST_CREDIT_CARD_1["cc-name"], {}, win); + EventUtils.synthesizeKey("VK_TAB", {}, win); + EventUtils.synthesizeKey(TEST_CREDIT_CARD_1["cc-type"], {}, win); + EventUtils.synthesizeKey("VK_TAB", {}, win); + EventUtils.synthesizeKey("VK_TAB", {}, win); + info("saving credit card"); + EventUtils.synthesizeKey("VK_RETURN", {}, win); + }); + + SpecialPowers.clearUserPref(ENABLED_AUTOFILL_CREDITCARDS_PREF); + SpecialPowers.clearUserPref(AUTOFILL_CREDITCARDS_AVAILABLE_PREF); + + await removeAllRecords(); + + await assertTelemetry(undefined, [["creditcard", "add", "manage"]]); +}); + +add_task(async function test_editCreditCard() { + Services.telemetry.clearEvents(); + Services.telemetry.setEventRecordingEnabled("creditcard", true); + + await SpecialPowers.pushPrefEnv({ + set: [ + [AUTOFILL_CREDITCARDS_AVAILABLE_PREF, true], + [ENABLED_AUTOFILL_CREDITCARDS_PREF, true], + ], + }); + + await saveCreditCard(TEST_CREDIT_CARD_1); + + let creditCards = await getCreditCards(); + is(creditCards.length, 1, "only one credit card is in storage"); + await testDialog( + EDIT_CREDIT_CARD_DIALOG_URL, + win => { + EventUtils.synthesizeKey("VK_TAB", {}, win); + EventUtils.synthesizeKey("VK_TAB", {}, win); + EventUtils.synthesizeKey("VK_TAB", {}, win); + EventUtils.synthesizeKey("VK_TAB", {}, win); + EventUtils.synthesizeKey("VK_RIGHT", {}, win); + EventUtils.synthesizeKey("test", {}, win); + win.document.querySelector("#save").click(); + }, + { + record: creditCards[0], + } + ); + + SpecialPowers.clearUserPref(ENABLED_AUTOFILL_CREDITCARDS_PREF); + SpecialPowers.clearUserPref(AUTOFILL_CREDITCARDS_AVAILABLE_PREF); + + await removeAllRecords(); + await assertTelemetry(undefined, [ + ["creditcard", "show_entry", "manage"], + ["creditcard", "edit", "manage"], + ]); +}); + +add_task(async function test_histogram() { + if (!OSKeyStoreTestUtils.canTestOSKeyStoreLogin()) { + todo( + OSKeyStoreTestUtils.canTestOSKeyStoreLogin(), + "Cannot test OS key store login on official builds." + ); + return; + } + + await SpecialPowers.pushPrefEnv({ + set: [ + [AUTOFILL_CREDITCARDS_AVAILABLE_PREF, true], + [ENABLED_AUTOFILL_CREDITCARDS_PREF, true], + ], + }); + + Services.telemetry.getHistogramById(CC_NUM_USES_HISTOGRAM).clear(); + Services.telemetry.setEventRecordingEnabled("creditcard", true); + + await saveCreditCard(TEST_CREDIT_CARD_1); + await saveCreditCard(TEST_CREDIT_CARD_2); + await saveCreditCard(TEST_CREDIT_CARD_5); + let creditCards = await getCreditCards(); + is(creditCards.length, 3, "3 credit cards in storage"); + + assertHistogram(CC_NUM_USES_HISTOGRAM, { + 0: 3, + }); + + await useCreditCard(1); + assertHistogram(CC_NUM_USES_HISTOGRAM, { + 0: 2, + 1: 1, + }); + + await useCreditCard(2); + assertHistogram(CC_NUM_USES_HISTOGRAM, { + 0: 1, + 1: 2, + }); + + await useCreditCard(1); + assertHistogram(CC_NUM_USES_HISTOGRAM, { + 0: 1, + 1: 1, + 2: 1, + }); + + await useCreditCard(2); + assertHistogram(CC_NUM_USES_HISTOGRAM, { + 0: 1, + 2: 2, + }); + + await useCreditCard(3); + assertHistogram(CC_NUM_USES_HISTOGRAM, { + 1: 1, + 2: 2, + }); + + SpecialPowers.clearUserPref(ENABLED_AUTOFILL_CREDITCARDS_PREF); + SpecialPowers.clearUserPref(AUTOFILL_CREDITCARDS_AVAILABLE_PREF); + + await removeAllRecords(); + + assertHistogram(CC_NUM_USES_HISTOGRAM, {}); +}); + +add_task(async function test_submit_creditCard_new_with_hidden_ui() { + const AUTOFILL_CREDITCARDS_HIDE_UI_PREF = + "extensions.formautofill.creditCards.hideui"; + + Services.telemetry.clearEvents(); + Services.telemetry.clearScalars(); + Services.telemetry.getHistogramById(CC_NUM_USES_HISTOGRAM).clear(); + Services.telemetry.setEventRecordingEnabled("creditcard", true); + + await SpecialPowers.pushPrefEnv({ + set: [ + [CREDITCARDS_USED_STATUS_PREF, 0], + [AUTOFILL_CREDITCARDS_AVAILABLE_PREF, true], + [ENABLED_AUTOFILL_CREDITCARDS_PREF, true], + [AUTOFILL_CREDITCARDS_HIDE_UI_PREF, true], + ], + }); + + await saveCreditCard(TEST_CREDIT_CARD_1); + + await BrowserTestUtils.withNewTab( + { gBrowser, url: CREDITCARD_FORM_URL }, + async function(browser) { + await openPopupOn(browser, "form #cc-number").then( + () => { + return Promise.reject("Popup should not be displayed"); + }, + () => { + ok(true, "Popup has not been displayed"); + } + ); + + await SpecialPowers.spawn(browser, [], async function() { + let form = content.document.getElementById("form"); + let name = form.querySelector("#cc-name"); + + name.focus(); + name.setUserInput("User 1"); + + form.querySelector("#cc-number").setUserInput("5038146897157463"); + form.querySelector("#cc-exp-month").setUserInput("12"); + form.querySelector("#cc-exp-year").setUserInput("2017"); + form.querySelector("#cc-type").value = "mastercard"; + + // Wait 1000ms before submission to make sure the input value applied + await new Promise(resolve => content.setTimeout(resolve, 1000)); + form.querySelector("input[type=submit]").click(); + }); + + await sleep(1000); + is(PopupNotifications.panel.state, "closed", "Doorhanger is hidden"); + } + ); + + SpecialPowers.clearUserPref(CREDITCARDS_USED_STATUS_PREF); + SpecialPowers.clearUserPref(ENABLED_AUTOFILL_CREDITCARDS_PREF); + SpecialPowers.clearUserPref(AUTOFILL_CREDITCARDS_AVAILABLE_PREF); + SpecialPowers.clearUserPref(AUTOFILL_CREDITCARDS_HIDE_UI_PREF); + + assertHistogram(CC_NUM_USES_HISTOGRAM, { 0: 1 }); + + let expected_content = [ + ["creditcard", "detected", "cc_form"], + [ + "creditcard", + "submitted", + "cc_form", + undefined, + { + fields_not_auto: "6", + fields_auto: "0", + fields_modified: "0", + }, + ], + ]; + await assertTelemetry(expected_content, []); + await removeAllRecords(); + + TelemetryTestUtils.assertScalar( + TelemetryTestUtils.getProcessScalars("content"), + "formautofill.creditCards.detected_sections_count", + 1, + "There should be 1 sections detected." + ); + TelemetryTestUtils.assertScalar( + TelemetryTestUtils.getProcessScalars("content"), + "formautofill.creditCards.submitted_sections_count", + 1, + "There should be 1 section submitted." + ); +}); diff --git a/browser/extensions/formautofill/test/browser/creditCard/browser_editCreditCardDialog.js b/browser/extensions/formautofill/test/browser/creditCard/browser_editCreditCardDialog.js new file mode 100644 index 0000000000..e124b78aae --- /dev/null +++ b/browser/extensions/formautofill/test/browser/creditCard/browser_editCreditCardDialog.js @@ -0,0 +1,450 @@ +/* eslint-disable mozilla/no-arbitrary-setTimeout */ + +"use strict"; + +add_task(async function setup() { + let { formAutofillStorage } = ChromeUtils.import( + "resource://formautofill/FormAutofillStorage.jsm" + ); + await formAutofillStorage.initialize(); +}); + +add_task(async function test_cancelEditCreditCardDialog() { + await testDialog(EDIT_CREDIT_CARD_DIALOG_URL, win => { + win.document.querySelector("#cancel").click(); + }); +}); + +add_task(async function test_cancelEditCreditCardDialogWithESC() { + await testDialog(EDIT_CREDIT_CARD_DIALOG_URL, win => { + EventUtils.synthesizeKey("VK_ESCAPE", {}, win); + }); +}); + +add_task(async function test_saveCreditCard() { + await testDialog(EDIT_CREDIT_CARD_DIALOG_URL, win => { + ok( + win.document.documentElement + .querySelector("title") + .textContent.includes("Add"), + "Add card dialog title is correct" + ); + EventUtils.synthesizeKey("VK_TAB", {}, win); + EventUtils.synthesizeKey(TEST_CREDIT_CARD_1["cc-number"], {}, win); + EventUtils.synthesizeKey("VK_TAB", {}, win); + EventUtils.synthesizeKey( + "0" + TEST_CREDIT_CARD_1["cc-exp-month"].toString(), + {}, + win + ); + is( + win.document.activeElement.selectedOptions[0].text, + "04 - April", + "Displayed month should match number and name" + ); + EventUtils.synthesizeKey("VK_TAB", {}, win); + EventUtils.synthesizeKey( + TEST_CREDIT_CARD_1["cc-exp-year"].toString(), + {}, + win + ); + EventUtils.synthesizeKey("VK_TAB", {}, win); + EventUtils.synthesizeKey(TEST_CREDIT_CARD_1["cc-name"], {}, win); + EventUtils.synthesizeKey("VK_TAB", {}, win); + EventUtils.synthesizeKey(TEST_CREDIT_CARD_1["cc-type"], {}, win); + EventUtils.synthesizeKey("VK_TAB", {}, win); + EventUtils.synthesizeKey("VK_TAB", {}, win); + info("saving credit card"); + EventUtils.synthesizeKey("VK_RETURN", {}, win); + }); + let creditCards = await getCreditCards(); + + is(creditCards.length, 1, "only one credit card is in storage"); + for (let [fieldName, fieldValue] of Object.entries(TEST_CREDIT_CARD_1)) { + if (fieldName === "cc-number") { + fieldValue = "*".repeat(fieldValue.length - 4) + fieldValue.substr(-4); + } + is(creditCards[0][fieldName], fieldValue, "check " + fieldName); + } + is(creditCards[0].billingAddressGUID, undefined, "check billingAddressGUID"); + ok(creditCards[0]["cc-number-encrypted"], "cc-number-encrypted exists"); +}); + +add_task(async function test_saveCreditCardWithMaxYear() { + await testDialog(EDIT_CREDIT_CARD_DIALOG_URL, win => { + EventUtils.synthesizeKey("VK_TAB", {}, win); + EventUtils.synthesizeKey(TEST_CREDIT_CARD_2["cc-number"], {}, win); + EventUtils.synthesizeKey("VK_TAB", {}, win); + EventUtils.synthesizeKey( + TEST_CREDIT_CARD_2["cc-exp-month"].toString(), + {}, + win + ); + EventUtils.synthesizeKey("VK_TAB", {}, win); + EventUtils.synthesizeKey( + TEST_CREDIT_CARD_2["cc-exp-year"].toString(), + {}, + win + ); + EventUtils.synthesizeKey("VK_TAB", {}, win); + EventUtils.synthesizeKey(TEST_CREDIT_CARD_2["cc-name"], {}, win); + EventUtils.synthesizeKey("VK_TAB", {}, win); + EventUtils.synthesizeKey(TEST_CREDIT_CARD_1["cc-type"], {}, win); + EventUtils.synthesizeKey("VK_TAB", {}, win); + EventUtils.synthesizeKey("VK_TAB", {}, win); + info("saving credit card"); + EventUtils.synthesizeKey("VK_RETURN", {}, win); + }); + let creditCards = await getCreditCards(); + + is(creditCards.length, 2, "Two credit cards are in storage"); + for (let [fieldName, fieldValue] of Object.entries(TEST_CREDIT_CARD_2)) { + if (fieldName === "cc-number") { + fieldValue = "*".repeat(fieldValue.length - 4) + fieldValue.substr(-4); + } + is(creditCards[1][fieldName], fieldValue, "check " + fieldName); + } + ok(creditCards[1]["cc-number-encrypted"], "cc-number-encrypted exists"); + await removeCreditCards([creditCards[1].guid]); +}); + +add_task(async function test_saveCreditCardWithBillingAddress() { + await saveAddress(TEST_ADDRESS_4); + await saveAddress(TEST_ADDRESS_1); + let addresses = await getAddresses(); + let billingAddress = addresses[0]; + + const TEST_CREDIT_CARD = Object.assign({}, TEST_CREDIT_CARD_2, { + billingAddressGUID: undefined, + }); + + await testDialog(EDIT_CREDIT_CARD_DIALOG_URL, win => { + EventUtils.synthesizeKey("VK_TAB", {}, win); + EventUtils.synthesizeKey(TEST_CREDIT_CARD["cc-number"], {}, win); + EventUtils.synthesizeKey("VK_TAB", {}, win); + EventUtils.synthesizeKey( + TEST_CREDIT_CARD["cc-exp-month"].toString(), + {}, + win + ); + EventUtils.synthesizeKey("VK_TAB", {}, win); + EventUtils.synthesizeKey( + TEST_CREDIT_CARD["cc-exp-year"].toString(), + {}, + win + ); + EventUtils.synthesizeKey("VK_TAB", {}, win); + EventUtils.synthesizeKey(TEST_CREDIT_CARD["cc-name"], {}, win); + EventUtils.synthesizeKey("VK_TAB", {}, win); + EventUtils.synthesizeKey(TEST_CREDIT_CARD["cc-type"], {}, win); + EventUtils.synthesizeKey("VK_TAB", {}, win); + EventUtils.synthesizeKey(billingAddress["given-name"], {}, win); + EventUtils.synthesizeKey("VK_TAB", {}, win); + info("saving credit card"); + EventUtils.synthesizeKey("VK_RETURN", {}, win); + }); + let creditCards = await getCreditCards(); + + is(creditCards.length, 2, "Two credit cards are in storage"); + for (let [fieldName, fieldValue] of Object.entries(TEST_CREDIT_CARD)) { + if (fieldName === "cc-number") { + fieldValue = "*".repeat(fieldValue.length - 4) + fieldValue.substr(-4); + } + is(creditCards[1][fieldName], fieldValue, "check " + fieldName); + } + ok(creditCards[1]["cc-number-encrypted"], "cc-number-encrypted exists"); + await removeCreditCards([creditCards[1].guid]); + await removeAddresses([addresses[0].guid, addresses[1].guid]); +}); + +add_task(async function test_editCreditCard() { + let creditCards = await getCreditCards(); + is(creditCards.length, 1, "only one credit card is in storage"); + await testDialog( + EDIT_CREDIT_CARD_DIALOG_URL, + win => { + ok( + win.document.documentElement + .querySelector("title") + .textContent.includes("Edit"), + "Edit card dialog title is correct" + ); + EventUtils.synthesizeKey("VK_TAB", {}, win); + EventUtils.synthesizeKey("VK_TAB", {}, win); + EventUtils.synthesizeKey("VK_TAB", {}, win); + EventUtils.synthesizeKey("VK_TAB", {}, win); + EventUtils.synthesizeKey("VK_RIGHT", {}, win); + EventUtils.synthesizeKey("test", {}, win); + win.document.querySelector("#save").click(); + }, + { + record: creditCards[0], + } + ); + ok(true, "Edit credit card dialog is closed"); + creditCards = await getCreditCards(); + + is(creditCards.length, 1, "only one credit card is in storage"); + is( + creditCards[0]["cc-name"], + TEST_CREDIT_CARD_1["cc-name"] + "test", + "cc name changed" + ); + await removeCreditCards([creditCards[0].guid]); + + creditCards = await getCreditCards(); + is(creditCards.length, 0, "Credit card storage is empty"); +}); + +add_task(async function test_editCreditCardWithMissingBillingAddress() { + const TEST_CREDIT_CARD = Object.assign({}, TEST_CREDIT_CARD_2, { + billingAddressGUID: "unknown-guid", + }); + await saveCreditCard(TEST_CREDIT_CARD); + + let creditCards = await getCreditCards(); + is(creditCards.length, 1, "one credit card in storage"); + is( + creditCards[0].billingAddressGUID, + TEST_CREDIT_CARD.billingAddressGUID, + "Check saved billingAddressGUID" + ); + await testDialog( + EDIT_CREDIT_CARD_DIALOG_URL, + win => { + EventUtils.synthesizeKey("VK_TAB", {}, win); + EventUtils.synthesizeKey("VK_TAB", {}, win); + EventUtils.synthesizeKey("VK_TAB", {}, win); + EventUtils.synthesizeKey("VK_TAB", {}, win); + EventUtils.synthesizeKey("VK_RIGHT", {}, win); + EventUtils.synthesizeKey("test", {}, win); + win.document.querySelector("#save").click(); + }, + { + record: creditCards[0], + } + ); + ok(true, "Edit credit card dialog is closed"); + creditCards = await getCreditCards(); + + is(creditCards.length, 1, "only one credit card is in storage"); + is( + creditCards[0]["cc-name"], + TEST_CREDIT_CARD["cc-name"] + "test", + "cc name changed" + ); + is( + creditCards[0].billingAddressGUID, + undefined, + "unknown GUID removed upon manual save" + ); + await removeCreditCards([creditCards[0].guid]); + + creditCards = await getCreditCards(); + is(creditCards.length, 0, "Credit card storage is empty"); +}); + +add_task(async function test_addInvalidCreditCard() { + await testDialog(EDIT_CREDIT_CARD_DIALOG_URL, win => { + const unloadHandler = () => + ok(false, "Edit credit card dialog shouldn't be closed"); + win.addEventListener("unload", unloadHandler); + + EventUtils.synthesizeKey("VK_TAB", {}, win); + EventUtils.synthesizeKey("test", {}, win); + EventUtils.synthesizeKey("VK_TAB", {}, win); + EventUtils.synthesizeKey("VK_TAB", {}, win); + EventUtils.synthesizeKey("VK_TAB", {}, win); + EventUtils.synthesizeKey("test name", {}, win); + EventUtils.synthesizeKey("VK_TAB", {}, win); + EventUtils.synthesizeMouseAtCenter( + win.document.querySelector("#save"), + {}, + win + ); + + is( + win.document.querySelector("form").checkValidity(), + false, + "cc-number is invalid" + ); + SimpleTest.requestFlakyTimeout( + "Ensure the window remains open after save attempt" + ); + setTimeout(() => { + win.removeEventListener("unload", unloadHandler); + info("closing"); + win.close(); + }, 500); + }); + info("closed"); + let creditCards = await getCreditCards(); + + is(creditCards.length, 0, "Credit card storage is empty"); +}); + +add_task(async function test_editCardWithInvalidNetwork() { + const TEST_CREDIT_CARD = Object.assign({}, TEST_CREDIT_CARD_2, { + "cc-type": "asiv", + }); + await saveCreditCard(TEST_CREDIT_CARD); + + let creditCards = await getCreditCards(); + is(creditCards.length, 1, "one credit card in storage"); + is( + creditCards[0]["cc-type"], + TEST_CREDIT_CARD["cc-type"], + "Check saved cc-type" + ); + await testDialog( + EDIT_CREDIT_CARD_DIALOG_URL, + win => { + EventUtils.synthesizeKey("VK_TAB", {}, win); + EventUtils.synthesizeKey("VK_TAB", {}, win); + EventUtils.synthesizeKey("VK_TAB", {}, win); + EventUtils.synthesizeKey("VK_TAB", {}, win); + EventUtils.synthesizeKey("VK_RIGHT", {}, win); + EventUtils.synthesizeKey("test", {}, win); + win.document.querySelector("#save").click(); + }, + { + record: creditCards[0], + } + ); + ok(true, "Edit credit card dialog is closed"); + creditCards = await getCreditCards(); + + is(creditCards.length, 1, "only one credit card is in storage"); + is( + creditCards[0]["cc-name"], + TEST_CREDIT_CARD["cc-name"] + "test", + "cc name changed" + ); + is( + creditCards[0]["cc-type"], + "visa", + "unknown cc-type removed and next autodetected to visa upon manual save" + ); + await removeCreditCards([creditCards[0].guid]); + + creditCards = await getCreditCards(); + is(creditCards.length, 0, "Credit card storage is empty"); +}); + +add_task(async function test_editInvalidCreditCardNumber() { + await saveAddress(TEST_ADDRESS_4); + let addresses = await getAddresses(); + let billingAddress = addresses[0]; + + const INVALID_CREDIT_CARD_NUMBER = "123456789"; + const TEST_CREDIT_CARD = Object.assign({}, TEST_CREDIT_CARD_2, { + billingAddressGUID: billingAddress.guid, + guid: "invalid-number", + version: 2, + "cc-number": INVALID_CREDIT_CARD_NUMBER, + }); + + // Directly use FormAutofillStorage so we can set + // sourceSync: true, since saveCreditCard uses FormAutofillParent + // which doesn't expose this option. + let { formAutofillStorage } = ChromeUtils.import( + "resource://formautofill/FormAutofillStorage.jsm" + ); + await formAutofillStorage.initialize(); + // Use `sourceSync: true` to bypass field normalization which will + // fail due to the invalid credit card number. + await formAutofillStorage.creditCards.add(TEST_CREDIT_CARD, { + sourceSync: true, + }); + + let creditCards = await getCreditCards(); + is(creditCards.length, 1, "only one credit card is in storage"); + is( + creditCards[0]["cc-number"], + "*********", + "invalid credit card number stored" + ); + await testDialog( + EDIT_CREDIT_CARD_DIALOG_URL, + win => { + is( + win.document.querySelector("#cc-number").value, + INVALID_CREDIT_CARD_NUMBER, + "cc-number field should be showing invalid credit card number" + ); + is( + win.document.querySelector("#cc-number").checkValidity(), + false, + "cc-number is invalid" + ); + win.document.querySelector("#cancel").click(); + }, + { + record: creditCards[0], + skipDecryption: true, + } + ); + ok(true, "Edit credit card dialog is closed"); + creditCards = await getCreditCards(); + + is(creditCards.length, 1, "only one credit card is in storage"); + is( + creditCards[0]["cc-number"], + "*********", + "invalid cc number still in record" + ); + await removeCreditCards([creditCards[0].guid]); + await removeAddresses([addresses[0].guid]); + + creditCards = await getCreditCards(); + is(creditCards.length, 0, "Credit card storage is empty"); + addresses = await getAddresses(); + is(addresses.length, 0, "Address storage is empty"); +}); + +add_task(async function test_editCreditCardWithInvalidNumber() { + const TEST_CREDIT_CARD = Object.assign({}, TEST_CREDIT_CARD_1); + await saveCreditCard(TEST_CREDIT_CARD); + + let creditCards = await getCreditCards(); + is(creditCards.length, 1, "only one credit card is in storage"); + await testDialog( + EDIT_CREDIT_CARD_DIALOG_URL, + win => { + ok( + win.document.documentElement + .querySelector("title") + .textContent.includes("Edit"), + "Edit card dialog title is correct" + ); + EventUtils.synthesizeKey("VK_TAB", {}, win); + is( + win.document.querySelector("#cc-number").validity.customError, + false, + "cc-number field should not have a custom error" + ); + EventUtils.synthesizeKey("4111111111111112", {}, win); + EventUtils.synthesizeKey("VK_TAB", {}, win); + is( + win.document.querySelector("#cc-number").validity.customError, + true, + "cc-number field should have a custom error" + ); + EventUtils.synthesizeKey("VK_TAB", {}, win); + EventUtils.synthesizeKey("VK_TAB", {}, win); + EventUtils.synthesizeKey("VK_TAB", {}, win); + win.document.querySelector("#cancel").click(); + }, + { + record: creditCards[0], + } + ); + ok(true, "Edit credit card dialog is closed"); + creditCards = await getCreditCards(); + + is(creditCards.length, 1, "only one credit card is in storage"); + await removeCreditCards([creditCards[0].guid]); + + creditCards = await getCreditCards(); + is(creditCards.length, 0, "Credit card storage is empty"); +}); diff --git a/browser/extensions/formautofill/test/browser/creditCard/browser_insecure_form.js b/browser/extensions/formautofill/test/browser/creditCard/browser_insecure_form.js new file mode 100644 index 0000000000..97e829db62 --- /dev/null +++ b/browser/extensions/formautofill/test/browser/creditCard/browser_insecure_form.js @@ -0,0 +1,134 @@ +"use strict"; + +// Remove the scheme from the URLs so we can switch between http: and https: later. +const TEST_URL_PATH_CC = + "://example.org" + + HTTP_TEST_PATH + + "creditCard/autocomplete_creditcard_basic.html"; +const TEST_URL_PATH = + "://example.org" + HTTP_TEST_PATH + "autocomplete_basic.html"; + +add_task(async function setup_storage() { + await saveAddress(TEST_ADDRESS_1); + await saveAddress(TEST_ADDRESS_2); + await saveAddress(TEST_ADDRESS_3); + + await saveCreditCard(TEST_CREDIT_CARD_1); + await saveCreditCard(TEST_CREDIT_CARD_2); + await saveCreditCard(TEST_CREDIT_CARD_3); +}); + +add_task(async function test_insecure_form() { + async function runTest({ + urlPath, + protocol, + focusInput, + expectedType, + expectedResultLength, + }) { + await BrowserTestUtils.withNewTab( + { gBrowser, url: protocol + urlPath }, + async function(browser) { + await openPopupOn(browser, focusInput); + + const items = getDisplayedPopupItems(browser); + is( + items.length, + expectedResultLength, + `Should show correct amount of results in "${protocol}"` + ); + const firstItem = items[0]; + is( + firstItem.getAttribute("originaltype"), + expectedType, + `Item should attach with correct binding in "${protocol}"` + ); + + await closePopup(browser); + } + ); + } + + const testSets = [ + { + urlPath: TEST_URL_PATH, + protocol: "https", + focusInput: "#organization", + expectedType: "autofill-profile", + expectedResultLength: 2, + }, + { + urlPath: TEST_URL_PATH, + protocol: "http", + focusInput: "#organization", + expectedType: "autofill-profile", + expectedResultLength: 2, + }, + { + urlPath: TEST_URL_PATH_CC, + protocol: "https", + focusInput: "#cc-name", + expectedType: "autofill-profile", + expectedResultLength: 3, + }, + { + urlPath: TEST_URL_PATH_CC, + protocol: "http", + focusInput: "#cc-name", + expectedType: "autofill-insecureWarning", // insecure warning field + expectedResultLength: 1, + }, + ]; + + for (const test of testSets) { + await runTest(test); + } +}); + +add_task(async function test_click_on_insecure_warning() { + await BrowserTestUtils.withNewTab( + { gBrowser, url: "http" + TEST_URL_PATH_CC }, + async function(browser) { + await openPopupOn(browser, "#cc-name"); + + const insecureItem = getDisplayedPopupItems(browser)[0]; + await EventUtils.synthesizeMouseAtCenter(insecureItem, {}); + // Check input's value after popup closed to ensure the completion of autofilling. + await expectPopupClose(browser); + const inputValue = await SpecialPowers.spawn( + browser, + [], + async function() { + return content.document.querySelector("#cc-name").value; + } + ); + is(inputValue, ""); + + await closePopup(browser); + } + ); +}); + +add_task(async function test_press_enter_on_insecure_warning() { + await BrowserTestUtils.withNewTab( + { gBrowser, url: "http" + TEST_URL_PATH_CC }, + async function(browser) { + await openPopupOn(browser, "#cc-name"); + + await BrowserTestUtils.synthesizeKey("VK_DOWN", {}, browser); + await BrowserTestUtils.synthesizeKey("VK_RETURN", {}, browser); + // Check input's value after popup closed to ensure the completion of autofilling. + await expectPopupClose(browser); + const inputValue = await SpecialPowers.spawn( + browser, + [], + async function() { + return content.document.querySelector("#cc-name").value; + } + ); + is(inputValue, ""); + + await closePopup(browser); + } + ); +}); diff --git a/browser/extensions/formautofill/test/browser/creditCard/browser_manageCreditCardsDialog.js b/browser/extensions/formautofill/test/browser/creditCard/browser_manageCreditCardsDialog.js new file mode 100644 index 0000000000..4f263da327 --- /dev/null +++ b/browser/extensions/formautofill/test/browser/creditCard/browser_manageCreditCardsDialog.js @@ -0,0 +1,291 @@ +"use strict"; + +const TEST_SELECTORS = { + selRecords: "#credit-cards", + btnRemove: "#remove", + btnAdd: "#add", + btnEdit: "#edit", +}; + +const DIALOG_SIZE = "width=600,height=400"; + +add_task(async function test_manageCreditCardsInitialState() { + await BrowserTestUtils.withNewTab( + { gBrowser, url: MANAGE_CREDIT_CARDS_DIALOG_URL }, + async function(browser) { + await SpecialPowers.spawn(browser, [TEST_SELECTORS], args => { + let selRecords = content.document.querySelector(args.selRecords); + let btnRemove = content.document.querySelector(args.btnRemove); + let btnAdd = content.document.querySelector(args.btnAdd); + let btnEdit = content.document.querySelector(args.btnEdit); + + is(selRecords.length, 0, "No credit card"); + is(btnRemove.disabled, true, "Remove button disabled"); + is(btnAdd.disabled, false, "Add button enabled"); + is(btnEdit.disabled, true, "Edit button disabled"); + }); + } + ); +}); + +add_task(async function test_cancelManageCreditCardsDialogWithESC() { + let win = window.openDialog(MANAGE_CREDIT_CARDS_DIALOG_URL); + await waitForFocusAndFormReady(win); + let unloadPromise = BrowserTestUtils.waitForEvent(win, "unload"); + EventUtils.synthesizeKey("VK_ESCAPE", {}, win); + await unloadPromise; + ok(true, "Manage credit cards dialog is closed with ESC key"); +}); + +add_task(async function test_removingSingleAndMultipleCreditCards() { + await SpecialPowers.pushPrefEnv({ + set: [["privacy.reduceTimerPrecision", false]], + }); + await saveCreditCard(TEST_CREDIT_CARD_1); + await saveCreditCard(TEST_CREDIT_CARD_2); + await saveCreditCard(TEST_CREDIT_CARD_3); + await saveCreditCard(TEST_CREDIT_CARD_4); + await saveCreditCard(TEST_CREDIT_CARD_5); + + let win = window.openDialog( + MANAGE_CREDIT_CARDS_DIALOG_URL, + null, + DIALOG_SIZE + ); + await waitForFocusAndFormReady(win); + + let selRecords = win.document.querySelector(TEST_SELECTORS.selRecords); + let btnRemove = win.document.querySelector(TEST_SELECTORS.btnRemove); + let btnEdit = win.document.querySelector(TEST_SELECTORS.btnEdit); + + const expectedLabels = [ + { + id: "credit-card-label-number-name-2", + args: { number: "**** 1881", name: "Chris P. Bacon", type: "Visa" }, + }, + { + id: "credit-card-label-number-2", + args: { number: "**** 5100", type: "MasterCard" }, + }, + { + id: "credit-card-label-number-expiration-2", + args: { + number: "**** 7870", + month: "1", + year: "2000", + type: "MasterCard", + }, + }, + { + id: "credit-card-label-number-name-expiration-2", + args: { + number: "**** 1045", + name: "Timothy Berners-Lee", + month: "12", + year: (new Date().getFullYear() + 10).toString(), + type: "Visa", + }, + }, + { + id: "credit-card-label-number-name-expiration-2", + args: { + number: "**** 1111", + name: "John Doe", + month: "4", + year: new Date().getFullYear().toString(), + type: "Visa", + }, + }, + ]; + + is( + selRecords.length, + expectedLabels.length, + "Correct number of credit cards" + ); + expectedLabels.forEach((expected, i) => { + const l10nAttrs = document.l10n.getAttributes(selRecords[i]); + is( + l10nAttrs.id, + expected.id, + `l10n id set for credit card ${expectedLabels.length - i}` + ); + Object.keys(expected.args).forEach(arg => { + is( + l10nAttrs.args[arg], + expected.args[arg], + `Set display ${arg} for credit card ${expectedLabels.length - i}` + ); + }); + }); + + EventUtils.synthesizeMouseAtCenter(selRecords.children[0], {}, win); + is(btnRemove.disabled, false, "Remove button enabled"); + is(btnEdit.disabled, false, "Edit button enabled"); + EventUtils.synthesizeMouseAtCenter(btnRemove, {}, win); + await BrowserTestUtils.waitForEvent(selRecords, "RecordsRemoved"); + is(selRecords.length, 4, "Four credit cards left"); + + EventUtils.synthesizeMouseAtCenter(selRecords.children[0], {}, win); + EventUtils.synthesizeMouseAtCenter( + selRecords.children[3], + { shiftKey: true }, + win + ); + is(btnEdit.disabled, true, "Edit button disabled when multi-select"); + + EventUtils.synthesizeMouseAtCenter(btnRemove, {}, win); + await BrowserTestUtils.waitForEvent(selRecords, "RecordsRemoved"); + is(selRecords.length, 0, "All credit cards are removed"); + + win.close(); +}); + +add_task(async function test_removingCreditCardsViaKeyboardDelete() { + await saveCreditCard(TEST_CREDIT_CARD_1); + let win = window.openDialog( + MANAGE_CREDIT_CARDS_DIALOG_URL, + null, + DIALOG_SIZE + ); + await waitForFocusAndFormReady(win); + + let selRecords = win.document.querySelector(TEST_SELECTORS.selRecords); + + is(selRecords.length, 1, "One credit card"); + + EventUtils.synthesizeMouseAtCenter(selRecords.children[0], {}, win); + EventUtils.synthesizeKey("VK_DELETE", {}, win); + await BrowserTestUtils.waitForEvent(selRecords, "RecordsRemoved"); + is(selRecords.length, 0, "No credit cards left"); + + win.close(); +}); + +add_task(async function test_creditCardsDialogWatchesStorageChanges() { + let win = window.openDialog( + MANAGE_CREDIT_CARDS_DIALOG_URL, + null, + DIALOG_SIZE + ); + await waitForFocusAndFormReady(win); + + let selRecords = win.document.querySelector(TEST_SELECTORS.selRecords); + + await saveCreditCard(TEST_CREDIT_CARD_1); + await BrowserTestUtils.waitForEvent(selRecords, "RecordsLoaded"); + is(selRecords.length, 1, "One credit card is shown"); + + await removeCreditCards([selRecords.options[0].value]); + await BrowserTestUtils.waitForEvent(selRecords, "RecordsLoaded"); + is(selRecords.length, 0, "Credit card is removed"); + win.close(); +}); + +add_task(async function test_showCreditCardIcons() { + await SpecialPowers.pushPrefEnv({ + set: [["privacy.reduceTimerPrecision", false]], + }); + await saveCreditCard(TEST_CREDIT_CARD_1); + let unknownCard = Object.assign({}, TEST_CREDIT_CARD_3, { + "cc-type": "gringotts", + }); + await saveCreditCard(unknownCard); + + let win = window.openDialog( + MANAGE_CREDIT_CARDS_DIALOG_URL, + null, + DIALOG_SIZE + ); + await waitForFocusAndFormReady(win); + + let selRecords = win.document.querySelector(TEST_SELECTORS.selRecords); + + is( + selRecords.classList.contains("branded"), + AppConstants.MOZILLA_OFFICIAL, + "record picker has 'branded' class in an MOZILLA_OFFICIAL build" + ); + + let option0 = selRecords.options[0]; + let icon0Url = win.getComputedStyle(option0, "::before").backgroundImage; + let option1 = selRecords.options[1]; + let icon1Url = win.getComputedStyle(option1, "::before").backgroundImage; + + is( + option0.getAttribute("cc-type"), + "gringotts", + "Option has the expected cc-type" + ); + is( + option1.getAttribute("cc-type"), + "visa", + "Option has the expected cc-type" + ); + + if (AppConstants.MOZILLA_OFFICIAL) { + ok( + icon0Url.includes("icon-credit-card-generic.svg"), + "unknown network option ::before element has the generic icon as backgroundImage: " + + icon0Url + ); + ok( + icon1Url.includes("cc-logo-visa.svg"), + "visa option ::before element has the visa icon as backgroundImage " + + icon1Url + ); + } + + await removeCreditCards([option0.value, option1.value]); + await BrowserTestUtils.waitForEvent(selRecords, "RecordsLoaded"); + is(selRecords.length, 0, "Credit card is removed"); + win.close(); +}); + +add_task(async function test_hasEditLoginPrompt() { + if (!OSKeyStoreTestUtils.canTestOSKeyStoreLogin()) { + todo( + OSKeyStoreTestUtils.canTestOSKeyStoreLogin(), + "Cannot test OS key store login on official builds." + ); + return; + } + + await saveCreditCard(TEST_CREDIT_CARD_1); + + let win = window.openDialog( + MANAGE_CREDIT_CARDS_DIALOG_URL, + null, + DIALOG_SIZE + ); + await waitForFocusAndFormReady(win); + + let selRecords = win.document.querySelector(TEST_SELECTORS.selRecords); + let btnRemove = win.document.querySelector(TEST_SELECTORS.btnRemove); + let btnAdd = win.document.querySelector(TEST_SELECTORS.btnAdd); + let btnEdit = win.document.querySelector(TEST_SELECTORS.btnEdit); + + EventUtils.synthesizeMouseAtCenter(selRecords.children[0], {}, win); + + let osKeyStoreLoginShown = OSKeyStoreTestUtils.waitForOSKeyStoreLogin(); // cancel + EventUtils.synthesizeMouseAtCenter(btnEdit, {}, win); + await osKeyStoreLoginShown; + await new Promise(resolve => waitForFocus(resolve, win)); + await new Promise(resolve => executeSoon(resolve)); + + // Login is not required for removing credit cards. + EventUtils.synthesizeMouseAtCenter(btnRemove, {}, win); + await BrowserTestUtils.waitForEvent(selRecords, "RecordsRemoved"); + is(selRecords.length, 0, "Credit card is removed"); + + // gSubDialog.open should be called when trying to add a credit card, + // no OS login dialog is required. + window.gSubDialog = { + open: url => + is(url, EDIT_CREDIT_CARD_DIALOG_URL, "Edit credit card dialog is called"), + }; + EventUtils.synthesizeMouseAtCenter(btnAdd, {}, win); + delete window.gSubDialog; + + win.close(); +}); diff --git a/browser/extensions/formautofill/test/browser/creditCard/browser_privacyPreferences.js b/browser/extensions/formautofill/test/browser/creditCard/browser_privacyPreferences.js new file mode 100644 index 0000000000..94f901ee73 --- /dev/null +++ b/browser/extensions/formautofill/test/browser/creditCard/browser_privacyPreferences.js @@ -0,0 +1,277 @@ +"use strict"; + +const PAGE_PREFS = "about:preferences"; +const PAGE_PRIVACY = PAGE_PREFS + "#privacy"; +const SELECTORS = { + group: "#formAutofillGroupBox", + addressAutofillCheckbox: "#addressAutofill checkbox", + creditCardAutofillCheckbox: "#creditCardAutofill checkbox", + savedAddressesBtn: "#addressAutofill button", + savedCreditCardsBtn: "#creditCardAutofill button", + addressAutofillLearnMore: "#addressAutofillLearnMore", + creditCardAutofillLearnMore: "#creditCardAutofillLearnMore", + reauthCheckbox: "#creditCardReauthenticate checkbox", +}; + +// Visibility of form autofill group should be hidden when opening +// preferences page. +add_task(async function test_aboutPreferences() { + let finalPrefPaneLoaded = TestUtils.topicObserved( + "sync-pane-loaded", + () => true + ); + await BrowserTestUtils.withNewTab( + { gBrowser, url: PAGE_PREFS }, + async function(browser) { + await finalPrefPaneLoaded; + await SpecialPowers.spawn(browser, [SELECTORS], selectors => { + is( + content.document.querySelector(selectors.group).hidden, + true, + "Form Autofill group should be hidden" + ); + }); + } + ); +}); + +// Visibility of form autofill group should be visible when opening +// directly to privacy page. Checkbox is checked by default. +add_task(async function test_aboutPreferencesPrivacy() { + let finalPrefPaneLoaded = TestUtils.topicObserved( + "sync-pane-loaded", + () => true + ); + await BrowserTestUtils.withNewTab( + { gBrowser, url: PAGE_PRIVACY }, + async function(browser) { + await finalPrefPaneLoaded; + await SpecialPowers.spawn(browser, [SELECTORS], selectors => { + is( + content.document.querySelector(selectors.group).hidden, + false, + "Form Autofill group should be visible" + ); + is( + content.document.querySelector(selectors.addressAutofillCheckbox) + .checked, + true, + "Autofill addresses checkbox should be checked" + ); + is( + content.document.querySelector(selectors.creditCardAutofillCheckbox) + .checked, + true, + "Autofill credit cards checkbox should be checked" + ); + ok( + content.document + .querySelector(selectors.addressAutofillLearnMore) + .href.includes("autofill-card-address"), + "Autofill addresses learn more link should contain autofill-card-address" + ); + ok( + content.document + .querySelector(selectors.creditCardAutofillLearnMore) + .href.includes("credit-card-autofill"), + "Autofill credit cards learn more link should contain credit-card-autofill" + ); + }); + } + ); +}); + +add_task(async function test_openManageAutofillDialogs() { + let finalPrefPaneLoaded = TestUtils.topicObserved( + "sync-pane-loaded", + () => true + ); + await BrowserTestUtils.withNewTab( + { gBrowser, url: PAGE_PRIVACY }, + async function(browser) { + await finalPrefPaneLoaded; + const args = [ + SELECTORS, + MANAGE_ADDRESSES_DIALOG_URL, + MANAGE_CREDIT_CARDS_DIALOG_URL, + ]; + await SpecialPowers.spawn( + browser, + [args], + ([selectors, addrUrl, ccUrl]) => { + function testManageDialogOpened(expectedUrl) { + return { + open: openUrl => is(openUrl, expectedUrl, "Manage dialog called"), + }; + } + + let realgSubDialog = content.window.gSubDialog; + content.window.gSubDialog = testManageDialogOpened(addrUrl); + content.document.querySelector(selectors.savedAddressesBtn).click(); + content.window.gSubDialog = testManageDialogOpened(ccUrl); + content.document.querySelector(selectors.savedCreditCardsBtn).click(); + content.window.gSubDialog = realgSubDialog; + } + ); + } + ); +}); + +add_task(async function test_autofillCheckboxes() { + await SpecialPowers.pushPrefEnv({ + set: [[ENABLED_AUTOFILL_ADDRESSES_PREF, false]], + }); + await SpecialPowers.pushPrefEnv({ + set: [[ENABLED_AUTOFILL_CREDITCARDS_PREF, false]], + }); + let finalPrefPaneLoaded = TestUtils.topicObserved( + "sync-pane-loaded", + () => true + ); + // Checkbox should be unchecked when form autofill addresses and credit cards are disabled. + await BrowserTestUtils.withNewTab( + { gBrowser, url: PAGE_PRIVACY }, + async function(browser) { + await finalPrefPaneLoaded; + await SpecialPowers.spawn(browser, [SELECTORS], selectors => { + is( + content.document.querySelector(selectors.group).hidden, + false, + "Form Autofill group should be visible" + ); + is( + content.document.querySelector(selectors.addressAutofillCheckbox) + .checked, + false, + "Checkbox should be unchecked when Autofill Addresses is disabled" + ); + is( + content.document.querySelector(selectors.creditCardAutofillCheckbox) + .checked, + false, + "Checkbox should be unchecked when Autofill Credit Cards is disabled" + ); + content.document + .querySelector(selectors.addressAutofillCheckbox) + .scrollIntoView({ block: "center", behavior: "instant" }); + }); + + info("test toggling the checkboxes"); + await BrowserTestUtils.synthesizeMouseAtCenter( + SELECTORS.addressAutofillCheckbox, + {}, + browser + ); + is( + Services.prefs.getBoolPref(ENABLED_AUTOFILL_ADDRESSES_PREF), + true, + "Check address autofill is now enabled" + ); + + await SpecialPowers.spawn(browser, [SELECTORS], selectors => { + content.document + .querySelector(selectors.creditCardAutofillCheckbox) + .scrollIntoView({ block: "center", behavior: "instant" }); + }); + await BrowserTestUtils.synthesizeMouseAtCenter( + SELECTORS.creditCardAutofillCheckbox, + {}, + browser + ); + is( + Services.prefs.getBoolPref(ENABLED_AUTOFILL_CREDITCARDS_PREF), + true, + "Check credit card autofill is now enabled" + ); + } + ); +}); + +add_task(async function test_creditCardNotAvailable() { + await SpecialPowers.pushPrefEnv({ + set: [[AUTOFILL_CREDITCARDS_AVAILABLE_PREF, false]], + }); + let finalPrefPaneLoaded = TestUtils.topicObserved( + "sync-pane-loaded", + () => true + ); + await BrowserTestUtils.withNewTab( + { gBrowser, url: PAGE_PRIVACY }, + async function(browser) { + await finalPrefPaneLoaded; + await SpecialPowers.spawn(browser, [SELECTORS], selectors => { + is( + content.document.querySelector(selectors.group).hidden, + false, + "Form Autofill group should be visible" + ); + ok( + !content.document.querySelector(selectors.creditCardAutofillCheckbox), + "Autofill credit cards checkbox should not exist" + ); + }); + } + ); +}); + +add_task(async function test_creditCardHiddenUI() { + const AUTOFILL_CREDITCARDS_HIDE_UI_PREF = + "extensions.formautofill.creditCards.hideui"; + + await SpecialPowers.pushPrefEnv({ + set: [[AUTOFILL_CREDITCARDS_HIDE_UI_PREF, true]], + }); + let finalPrefPaneLoaded = TestUtils.topicObserved( + "sync-pane-loaded", + () => true + ); + await BrowserTestUtils.withNewTab( + { gBrowser, url: PAGE_PRIVACY }, + async function(browser) { + await finalPrefPaneLoaded; + await SpecialPowers.spawn(browser, [SELECTORS], selectors => { + is( + content.document.querySelector(selectors.group).hidden, + false, + "Form Autofill group should be visible" + ); + ok( + !content.document.querySelector(selectors.creditCardAutofillCheckbox), + "Autofill credit cards checkbox should not exist" + ); + }); + } + ); + SpecialPowers.clearUserPref(AUTOFILL_CREDITCARDS_HIDE_UI_PREF); +}); + +add_task(async function test_reauth() { + await SpecialPowers.pushPrefEnv({ + set: [[AUTOFILL_CREDITCARDS_AVAILABLE_PREF, true]], + }); + let { OSKeyStore } = ChromeUtils.import( + "resource://gre/modules/OSKeyStore.jsm" + ); + + let finalPrefPaneLoaded = TestUtils.topicObserved( + "sync-pane-loaded", + () => true + ); + await BrowserTestUtils.withNewTab( + { gBrowser, url: PAGE_PRIVACY }, + async function(browser) { + await finalPrefPaneLoaded; + await SpecialPowers.spawn( + browser, + [SELECTORS, OSKeyStore.canReauth()], + (selectors, canReauth) => { + is( + canReauth, + !!content.document.querySelector(selectors.reauthCheckbox), + "Re-authentication checkbox should be available if OSKeyStore.canReauth() is `true`" + ); + } + ); + } + ); +}); diff --git a/browser/extensions/formautofill/test/browser/creditCard/head_cc.js b/browser/extensions/formautofill/test/browser/creditCard/head_cc.js new file mode 100644 index 0000000000..42196e8422 --- /dev/null +++ b/browser/extensions/formautofill/test/browser/creditCard/head_cc.js @@ -0,0 +1 @@ +/* import-globals-from ../head.js */ diff --git a/browser/extensions/formautofill/test/browser/focus-leak/browser.ini b/browser/extensions/formautofill/test/browser/focus-leak/browser.ini new file mode 100644 index 0000000000..66d5e91ad0 --- /dev/null +++ b/browser/extensions/formautofill/test/browser/focus-leak/browser.ini @@ -0,0 +1,10 @@ +[DEFAULT] +support-files = + doc_iframe_typecontent_input_focus.xhtml + doc_iframe_typecontent_input_focus_frame.html + ../head.js + +# This test is used to detect a leak. +# Keep it isolated in a dedicated test folder to make sure the leak is cleaned +# up as a sideeffect of another test. +[browser_iframe_typecontent_input_focus.js] diff --git a/browser/extensions/formautofill/test/browser/focus-leak/browser_iframe_typecontent_input_focus.js b/browser/extensions/formautofill/test/browser/focus-leak/browser_iframe_typecontent_input_focus.js new file mode 100644 index 0000000000..46f63a370b --- /dev/null +++ b/browser/extensions/formautofill/test/browser/focus-leak/browser_iframe_typecontent_input_focus.js @@ -0,0 +1,55 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const URL_ROOT = + "chrome://mochitests/content/browser/browser/extensions/formautofill/test/browser/focus-leak/"; + +const XUL_FRAME_URI = URL_ROOT + "doc_iframe_typecontent_input_focus.xhtml"; +const INNER_HTML_FRAME_URI = + URL_ROOT + "doc_iframe_typecontent_input_focus_frame.html"; + +/** + * Check that focusing an input in a frame with type=content embedded in a xul + * document does not leak. + */ +add_task(async function() { + const doc = gBrowser.ownerDocument; + const linkedBrowser = gBrowser.selectedTab.linkedBrowser; + const browserContainer = gBrowser.getBrowserContainer(linkedBrowser); + + info("Load the test page in a frame with type content"); + const frame = doc.createXULElement("iframe"); + frame.setAttribute("type", "content"); + browserContainer.appendChild(frame); + + info("Wait for the xul iframe to be loaded"); + const onXulFrameLoad = BrowserTestUtils.waitForEvent(frame, "load", true); + frame.setAttribute("src", XUL_FRAME_URI); + await onXulFrameLoad; + + const panelFrame = frame.contentDocument.querySelector("#html-iframe"); + + info("Wait for the html iframe to be loaded"); + const onFrameLoad = BrowserTestUtils.waitForEvent(panelFrame, "load", true); + panelFrame.setAttribute("src", INNER_HTML_FRAME_URI); + await onFrameLoad; + + info("Focus an input inside the iframe"); + const focusMeInput = panelFrame.contentDocument.querySelector(".focusme"); + const onFocus = BrowserTestUtils.waitForEvent(focusMeInput, "focus"); + focusMeInput.focus(); + await onFocus; + + // This assert is not really meaningful, the main purpose of the test is + // to check against leaks. + is( + focusMeInput, + panelFrame.contentDocument.activeElement, + "The .focusme input is the active element" + ); + + info("Remove the focused input"); + focusMeInput.remove(); +}); diff --git a/browser/extensions/formautofill/test/browser/focus-leak/doc_iframe_typecontent_input_focus.xhtml b/browser/extensions/formautofill/test/browser/focus-leak/doc_iframe_typecontent_input_focus.xhtml new file mode 100644 index 0000000000..ec3aaa2648 --- /dev/null +++ b/browser/extensions/formautofill/test/browser/focus-leak/doc_iframe_typecontent_input_focus.xhtml @@ -0,0 +1,7 @@ +<?xml version="1.0" encoding="utf-8"?> +<window xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + xmlns:html="http://www.w3.org/1999/xhtml"> + <box> + <html:iframe id="html-iframe"/> + </box> +</window> diff --git a/browser/extensions/formautofill/test/browser/focus-leak/doc_iframe_typecontent_input_focus_frame.html b/browser/extensions/formautofill/test/browser/focus-leak/doc_iframe_typecontent_input_focus_frame.html new file mode 100644 index 0000000000..00853d8eec --- /dev/null +++ b/browser/extensions/formautofill/test/browser/focus-leak/doc_iframe_typecontent_input_focus_frame.html @@ -0,0 +1,6 @@ +<!DOCTYPE html> +<html> + <body> + <input type="text" name="test" class="focusme"> + </body> +</html> diff --git a/browser/extensions/formautofill/test/browser/head.js b/browser/extensions/formautofill/test/browser/head.js new file mode 100644 index 0000000000..6e69aee691 --- /dev/null +++ b/browser/extensions/formautofill/test/browser/head.js @@ -0,0 +1,527 @@ +/* exported MANAGE_ADDRESSES_DIALOG_URL, MANAGE_CREDIT_CARDS_DIALOG_URL, EDIT_ADDRESS_DIALOG_URL, EDIT_CREDIT_CARD_DIALOG_URL, + BASE_URL, TEST_ADDRESS_1, TEST_ADDRESS_2, TEST_ADDRESS_3, TEST_ADDRESS_4, TEST_ADDRESS_5, TEST_ADDRESS_CA_1, TEST_ADDRESS_DE_1, + TEST_ADDRESS_IE_1, + TEST_CREDIT_CARD_1, TEST_CREDIT_CARD_2, TEST_CREDIT_CARD_3, TEST_CREDIT_CARD_4, TEST_CREDIT_CARD_5, + FORM_URL, CREDITCARD_FORM_URL, CREDITCARD_FORM_IFRAME_URL + FTU_PREF, ENABLED_AUTOFILL_ADDRESSES_PREF, ENABLED_AUTOFILL_ADDRESSES_CAPTURE_PREF, AUTOFILL_CREDITCARDS_AVAILABLE_PREF, ENABLED_AUTOFILL_CREDITCARDS_PREF, + SUPPORTED_COUNTRIES_PREF, + SYNC_USERNAME_PREF, SYNC_ADDRESSES_PREF, SYNC_CREDITCARDS_PREF, SYNC_CREDITCARDS_AVAILABLE_PREF, CREDITCARDS_USED_STATUS_PREF, + sleep, expectPopupOpen, openPopupOn, openPopupForSubframe, expectPopupClose, closePopup, closePopupForSubframe, + clickDoorhangerButton, getAddresses, saveAddress, removeAddresses, saveCreditCard, + getDisplayedPopupItems, getDoorhangerCheckbox, waitForPopupEnabled, + getNotification, getDoorhangerButton, removeAllRecords, expectWarningText, testDialog */ + +"use strict"; + +ChromeUtils.import("resource://gre/modules/OSKeyStore.jsm", this); +ChromeUtils.import("resource://testing-common/OSKeyStoreTestUtils.jsm", this); + +const MANAGE_ADDRESSES_DIALOG_URL = + "chrome://formautofill/content/manageAddresses.xhtml"; +const MANAGE_CREDIT_CARDS_DIALOG_URL = + "chrome://formautofill/content/manageCreditCards.xhtml"; +const EDIT_ADDRESS_DIALOG_URL = + "chrome://formautofill/content/editAddress.xhtml"; +const EDIT_CREDIT_CARD_DIALOG_URL = + "chrome://formautofill/content/editCreditCard.xhtml"; + +const HTTP_TEST_PATH = "/browser/browser/extensions/formautofill/test/browser/"; +const BASE_URL = "http://mochi.test:8888" + HTTP_TEST_PATH; +const FORM_URL = BASE_URL + "autocomplete_basic.html"; +const CREDITCARD_FORM_URL = + "https://example.org" + + HTTP_TEST_PATH + + "creditCard/autocomplete_creditcard_basic.html"; +const CREDITCARD_FORM_IFRAME_URL = + "https://example.org" + + HTTP_TEST_PATH + + "creditCard/autocomplete_creditcard_iframe.html"; + +const FTU_PREF = "extensions.formautofill.firstTimeUse"; +const CREDITCARDS_USED_STATUS_PREF = "extensions.formautofill.creditCards.used"; +const ENABLED_AUTOFILL_ADDRESSES_PREF = + "extensions.formautofill.addresses.enabled"; +const ENABLED_AUTOFILL_ADDRESSES_CAPTURE_PREF = + "extensions.formautofill.addresses.capture.enabled"; +const AUTOFILL_CREDITCARDS_AVAILABLE_PREF = + "extensions.formautofill.creditCards.available"; +const ENABLED_AUTOFILL_CREDITCARDS_PREF = + "extensions.formautofill.creditCards.enabled"; +const SUPPORTED_COUNTRIES_PREF = "extensions.formautofill.supportedCountries"; +const SYNC_USERNAME_PREF = "services.sync.username"; +const SYNC_ADDRESSES_PREF = "services.sync.engine.addresses"; +const SYNC_CREDITCARDS_PREF = "services.sync.engine.creditcards"; +const SYNC_CREDITCARDS_AVAILABLE_PREF = + "services.sync.engine.creditcards.available"; + +const TEST_ADDRESS_1 = { + "given-name": "John", + "additional-name": "R.", + "family-name": "Smith", + organization: "World Wide Web Consortium", + "street-address": "32 Vassar Street\nMIT Room 32-G524", + "address-level2": "Cambridge", + "address-level1": "MA", + "postal-code": "02139", + country: "US", + tel: "+16172535702", + email: "timbl@w3.org", +}; + +const TEST_ADDRESS_2 = { + "street-address": "Some Address", + country: "US", +}; + +const TEST_ADDRESS_3 = { + "street-address": "Other Address", + "postal-code": "12345", +}; + +const TEST_ADDRESS_4 = { + "given-name": "Timothy", + "family-name": "Berners-Lee", + organization: "World Wide Web Consortium", + "street-address": "32 Vassar Street\nMIT Room 32-G524", + country: "US", + email: "timbl@w3.org", +}; + +const TEST_ADDRESS_5 = { + tel: "+16172535702", +}; + +const TEST_ADDRESS_CA_1 = { + "given-name": "John", + "additional-name": "R.", + "family-name": "Smith", + organization: "Mozilla", + "street-address": "163 W Hastings\nSuite 209", + "address-level2": "Vancouver", + "address-level1": "BC", + "postal-code": "V6B 1H5", + country: "CA", + tel: "+17787851540", + email: "timbl@w3.org", +}; + +const TEST_ADDRESS_DE_1 = { + "given-name": "John", + "additional-name": "R.", + "family-name": "Smith", + organization: "Mozilla", + "street-address": + "Geb\u00E4ude 3, 4. Obergeschoss\nSchlesische Stra\u00DFe 27", + "address-level2": "Berlin", + "postal-code": "10997", + country: "DE", + tel: "+4930983333000", + email: "timbl@w3.org", +}; + +const TEST_ADDRESS_IE_1 = { + "given-name": "Bob", + "additional-name": "Z.", + "family-name": "Builder", + organization: "Best Co.", + "street-address": "123 Kilkenny St.", + "address-level3": "Some Townland", + "address-level2": "Dublin", + "address-level1": "Co. Dublin", + "postal-code": "A65 F4E2", + country: "IE", + tel: "+13534564947391", + email: "ie@example.com", +}; + +const TEST_CREDIT_CARD_1 = { + "cc-name": "John Doe", + "cc-number": "4111111111111111", + "cc-exp-month": 4, + "cc-exp-year": new Date().getFullYear(), + "cc-type": "visa", +}; + +const TEST_CREDIT_CARD_2 = { + "cc-name": "Timothy Berners-Lee", + "cc-number": "4929001587121045", + "cc-exp-month": 12, + "cc-exp-year": new Date().getFullYear() + 10, + "cc-type": "visa", +}; + +const TEST_CREDIT_CARD_3 = { + "cc-number": "5103059495477870", + "cc-exp-month": 1, + "cc-exp-year": 2000, + "cc-type": "mastercard", +}; + +const TEST_CREDIT_CARD_4 = { + "cc-number": "5105105105105100", + "cc-type": "mastercard", +}; + +const TEST_CREDIT_CARD_5 = { + "cc-name": "Chris P. Bacon", + "cc-number": "4012888888881881", + "cc-type": "visa", +}; + +const MAIN_BUTTON = "button"; +const SECONDARY_BUTTON = "secondaryButton"; +const MENU_BUTTON = "menubutton"; + +function getDisplayedPopupItems( + browser, + selector = ".autocomplete-richlistitem" +) { + info("getDisplayedPopupItems"); + const { + autoCompletePopup: { richlistbox: itemsBox }, + } = browser; + const listItemElems = itemsBox.querySelectorAll(selector); + + return [...listItemElems].filter( + item => item.getAttribute("collapsed") != "true" + ); +} + +async function sleep(ms = 500) { + await new Promise(resolve => setTimeout(resolve, ms)); +} + +async function focusAndWaitForFieldsIdentified(browserOrContext, selector) { + info("expecting the target input being focused and identified"); + /* eslint no-shadow: ["error", { "allow": ["selector", "previouslyFocused", "previouslyIdentified"] }] */ + + const { FormAutofillParent } = ChromeUtils.import( + "resource://formautofill/FormAutofillParent.jsm" + ); + + // If the input is previously focused, no more notifications will be + // sent as the notification goes along with focus event. + let fieldsIdentifiedPromiseResolver; + let fieldsIdentifiedObserver = { + fieldsIdentified() { + fieldsIdentifiedPromiseResolver(); + }, + }; + + let fieldsIdentifiedPromise = new Promise(resolve => { + fieldsIdentifiedPromiseResolver = resolve; + FormAutofillParent.addMessageObserver(fieldsIdentifiedObserver); + }); + + const { previouslyFocused, previouslyIdentified } = await SpecialPowers.spawn( + browserOrContext, + [selector], + async function(selector) { + const { FormLikeFactory } = ChromeUtils.import( + "resource://gre/modules/FormLikeFactory.jsm" + ); + const input = content.document.querySelector(selector); + const rootElement = FormLikeFactory.findRootForField(input); + const previouslyFocused = content.document.activeElement == input; + const previouslyIdentified = rootElement.hasAttribute( + "test-formautofill-identified" + ); + + input.focus(); + + return { previouslyFocused, previouslyIdentified }; + } + ); + + // Only wait for the fields identified notification if the + // focus was not previously assigned to the input. + if (previouslyFocused) { + fieldsIdentifiedPromiseResolver(); + } else { + info("!previouslyFocused"); + } + + // If a browsing context was supplied, focus its parent frame as well. + if ( + browserOrContext instanceof BrowsingContext && + browserOrContext.parent != browserOrContext + ) { + await SpecialPowers.spawn( + browserOrContext.parent, + [browserOrContext], + async function(browsingContext) { + browsingContext.embedderElement.focus(); + } + ); + } + + if (previouslyIdentified) { + info("previouslyIdentified"); + FormAutofillParent.removeMessageObserver(fieldsIdentifiedObserver); + return; + } + + // Wait 500ms to ensure that "markAsAutofillField" is completely finished. + await fieldsIdentifiedPromise; + info("FieldsIdentified"); + FormAutofillParent.removeMessageObserver(fieldsIdentifiedObserver); + + await sleep(); + await SpecialPowers.spawn(browserOrContext, [], async function() { + const { FormLikeFactory } = ChromeUtils.import( + "resource://gre/modules/FormLikeFactory.jsm" + ); + FormLikeFactory.findRootForField( + content.document.activeElement + ).setAttribute("test-formautofill-identified", "true"); + }); +} + +async function expectPopupOpen(browser) { + info("expectPopupOpen"); + const { autoCompletePopup } = browser; + await BrowserTestUtils.waitForCondition( + () => autoCompletePopup.popupOpen, + "popup should be open" + ); + await BrowserTestUtils.waitForCondition(() => { + const listItemElems = getDisplayedPopupItems(browser); + return ( + !![...listItemElems].length && + [...listItemElems].every(item => { + return ( + (item.getAttribute("originaltype") == "autofill-profile" || + item.getAttribute("originaltype") == "autofill-insecureWarning" || + item.getAttribute("originaltype") == "autofill-footer") && + item.hasAttribute("formautofillattached") + ); + }) + ); + }, "The popup should be a form autofill one"); +} + +async function waitForPopupEnabled(browser) { + const { + autoCompletePopup: { richlistbox: itemsBox }, + } = browser; + const listItemElems = itemsBox.querySelectorAll(".autocomplete-richlistitem"); + await TestUtils.waitForCondition( + () => !listItemElems[0].disabled, + "Wait for list elements to become enabled" + ); +} + +async function openPopupOn(browser, selector) { + await SimpleTest.promiseFocus(browser); + await focusAndWaitForFieldsIdentified(browser, selector); + if (!selector.includes("cc-")) { + info(`openPopupOn: before VK_DOWN on ${selector}`); + await BrowserTestUtils.synthesizeKey("VK_DOWN", {}, browser); + } + await expectPopupOpen(browser); +} + +async function openPopupForSubframe(browser, frameBrowsingContext, selector) { + await SimpleTest.promiseFocus(browser); + await focusAndWaitForFieldsIdentified(frameBrowsingContext, selector); + if (!selector.includes("cc-")) { + info(`openPopupForSubframe: before VK_DOWN on ${selector}`); + await BrowserTestUtils.synthesizeKey("VK_DOWN", {}, frameBrowsingContext); + } + await expectPopupOpen(browser); +} + +async function expectPopupClose(browser) { + await BrowserTestUtils.waitForCondition( + () => !browser.autoCompletePopup.popupOpen, + "popup should have closed" + ); +} + +async function closePopup(browser) { + await SpecialPowers.spawn(browser, [], async function() { + content.document.activeElement.blur(); + }); + + await expectPopupClose(browser); +} + +async function closePopupForSubframe(browser, frameBrowsingContext) { + await SpecialPowers.spawn(frameBrowsingContext, [], async function() { + content.document.activeElement.blur(); + }); + + await expectPopupClose(browser); +} + +function emulateMessageToBrowser(name, data) { + let actor = gBrowser.selectedBrowser.browsingContext.currentWindowGlobal.getActor( + "FormAutofill" + ); + return actor.receiveMessage({ name, data }); +} + +function getRecords(data) { + info(`expecting record retrievals: ${data.collectionName}`); + return emulateMessageToBrowser("FormAutofill:GetRecords", data); +} + +function getAddresses() { + return getRecords({ collectionName: "addresses" }); +} + +function getCreditCards() { + return getRecords({ collectionName: "creditCards" }); +} + +async function saveAddress(address) { + info("expecting address saved"); + let observePromise = TestUtils.topicObserved("formautofill-storage-changed"); + await emulateMessageToBrowser("FormAutofill:SaveAddress", { address }); + await observePromise; +} + +async function saveCreditCard(creditcard) { + info("expecting credit card saved"); + let creditcardClone = Object.assign({}, creditcard); + let observePromise = TestUtils.topicObserved("formautofill-storage-changed"); + await emulateMessageToBrowser("FormAutofill:SaveCreditCard", { + creditcard: creditcardClone, + }); + await observePromise; +} + +async function removeAddresses(guids) { + info("expecting address removed"); + let observePromise = TestUtils.topicObserved("formautofill-storage-changed"); + await emulateMessageToBrowser("FormAutofill:RemoveAddresses", { guids }); + await observePromise; +} + +async function removeCreditCards(guids) { + info("expecting credit card removed"); + let observePromise = TestUtils.topicObserved("formautofill-storage-changed"); + await emulateMessageToBrowser("FormAutofill:RemoveCreditCards", { guids }); + await observePromise; +} + +function getNotification(index = 0) { + let notifications = PopupNotifications.panel.childNodes; + ok(!!notifications.length, "at least one notification displayed"); + ok(true, notifications.length + " notification(s)"); + return notifications[index]; +} + +/** + * Clicks the popup notification button and wait for popup hidden. + * + * @param {string} button The button type in popup notification. + * @param {number} index The action's index in menu list. + */ +async function clickDoorhangerButton(button, index) { + let popuphidden = BrowserTestUtils.waitForEvent( + PopupNotifications.panel, + "popuphidden" + ); + + if (button == MAIN_BUTTON || button == SECONDARY_BUTTON) { + EventUtils.synthesizeMouseAtCenter(getNotification()[button], {}); + } else if (button == MENU_BUTTON) { + // Click the dropmarker arrow and wait for the menu to show up. + info("expecting notification menu button present"); + await BrowserTestUtils.waitForCondition(() => getNotification().menubutton); + await sleep(2000); // menubutton needs extra time for binding + let notification = getNotification(); + ok(notification.menubutton, "notification menupopup displayed"); + let dropdownPromise = BrowserTestUtils.waitForEvent( + notification.menupopup, + "popupshown" + ); + await EventUtils.synthesizeMouseAtCenter(notification.menubutton, {}); + info("expecting notification popup show up"); + await dropdownPromise; + + let actionMenuItem = notification.querySelectorAll("menuitem")[index]; + await EventUtils.synthesizeMouseAtCenter(actionMenuItem, {}); + } + info("expecting notification popup hidden"); + await popuphidden; +} + +function getDoorhangerCheckbox() { + return getNotification().checkbox; +} + +function getDoorhangerButton(button) { + return getNotification()[button]; +} + +async function removeAllRecords() { + let addresses = await getAddresses(); + if (addresses.length) { + await removeAddresses(addresses.map(address => address.guid)); + } + + if (Services.prefs.getBoolPref(AUTOFILL_CREDITCARDS_AVAILABLE_PREF)) { + let creditCards = await getCreditCards(); + if (creditCards.length) { + await removeCreditCards(creditCards.map(cc => cc.guid)); + } + } +} + +async function waitForFocusAndFormReady(win) { + return Promise.all([ + new Promise(resolve => waitForFocus(resolve, win)), + BrowserTestUtils.waitForEvent(win, "FormReady"), + ]); +} + +// Verify that the warning in the autocomplete popup has the expected text. +async function expectWarningText(browser, expectedText) { + const { + autoCompletePopup: { richlistbox: itemsBox }, + } = browser; + let warningBox = itemsBox.querySelector( + ".autocomplete-richlistitem:last-child" + ); + + while (warningBox.collapsed) { + warningBox = warningBox.previousSibling; + } + warningBox = warningBox._warningTextBox; + + await BrowserTestUtils.waitForCondition(() => { + return warningBox.textContent == expectedText; + }, `Waiting for expected warning text: ${expectedText}, Got ${warningBox.textContent}`); + ok(true, `Got expected warning text: ${expectedText}`); +} + +async function testDialog(url, testFn, arg = undefined) { + // Skip this step for test cards that lack an encrypted + // number since they will fail to decrypt. + if ( + url == EDIT_CREDIT_CARD_DIALOG_URL && + arg && + arg.record && + arg.record["cc-number-encrypted"] + ) { + arg.record = Object.assign({}, arg.record, { + "cc-number": await OSKeyStore.decrypt(arg.record["cc-number-encrypted"]), + }); + } + let win = window.openDialog(url, null, "width=600,height=600", arg); + await waitForFocusAndFormReady(win); + let unloadPromise = BrowserTestUtils.waitForEvent(win, "unload"); + await testFn(win); + return unloadPromise; +} + +add_task(function setup() { + OSKeyStoreTestUtils.setup(); +}); + +registerCleanupFunction(removeAllRecords); +registerCleanupFunction(async () => { + await OSKeyStoreTestUtils.cleanup(); +}); diff --git a/browser/extensions/formautofill/test/fixtures/autocomplete_basic.html b/browser/extensions/formautofill/test/fixtures/autocomplete_basic.html new file mode 100644 index 0000000000..5007e9e5bd --- /dev/null +++ b/browser/extensions/formautofill/test/fixtures/autocomplete_basic.html @@ -0,0 +1,52 @@ +<!DOCTYPE html> +<html> +<head> + <meta charset="utf-8"> + <title>Form Autofill Demo Page</title> +</head> +<body> + <h1>Form Autofill Demo Page</h1> + <form id="form"> + <p><label>organization: <input type="text" id="organization" name="organization" autocomplete="organization" /></label></p> + <p><label>streetAddress: <input type="text" id="street-address" name="street-address" autocomplete="street-address" /></label></p> + <p><label>addressLevel2: <input type="text" id="address-level2" name="address-level2" autocomplete="address-level2" /></label></p> + <p><label>addressLevel1: <input type="text" id="address-level1" name="address-level1" autocomplete="address-level1" /></label></p> + <p><label>postalCode: <input type="text" id="postal-code" name="postal-code" autocomplete="postal-code" /></label></p> + <p><label>country: <input type="text" id="country" name="country" autocomplete="country" /></label></p> + <p><label>tel: <input type="text" id="tel" name="tel" autocomplete="tel" /></label></p> + <p><label>email: <input type="text" id="email" name="email" autocomplete="email" /></label></p> + <p><input type="submit" /></p> + <p><button type="reset">Reset</button></p> + </form> + + <form id="formB"> + <p><label>Organization: <input type="text" /></label></p> + <p><label><input type="text" id="B_address-line1" /></label></p> + <p><label><input type="text" name="address-line2" /></label></p> + <p><label><input type="text" id="B_address-line3" name="address-line3" /></label></p> + <p><label>City: <input type="text" name="address-level2" /></label></p> + <p><label>State: <select id="B_address-level1" ></select></label></p> + <p><input type="text" id="B_postal-code" name="postal-code" /></p> + <p><label>Country: <select multiple id="B_country" name="country" ></select></label></p> + <p><label>Telephone: <input id="B_tel" name="tel" /></label></p> + <p><label>Email: <input type="text" id="B_email" name="email" /></label></p> + <hr> + <p><label>cc-number <input type="text" id="B_cc-number" autocomplete="cc-number" /></label></p> + <p><label>cc-name <input type="text" id="B_cc-name" autocomplete="cc-name" /></label></p> + <p><label>cc-exp-month <input type="text" id="B_cc-exp-month" autocomplete="cc-exp-month" /></label></p> + <p><label>cc-exp-year <input type="text" id="B_cc-exp-year" autocomplete="cc-exp-year" /></label></p> + <hr> + <p><input type="submit" /></p> + <p><button type="reset">Reset</button></p> + </form> + + <form id="formC"> + <p><label><input type="text" name="someprefixAddrLine1" /></label></p> + <p><label>City: <input type="text" name="address-level2" /></label></p> + <p><label><input type="text" name="someprefixAddrLine2" /></label></p> + <p><label>Organization: <input type="text" name="organization" /></label></p> + <p><label><input type="text" name="someprefixAddrLine3" /></label></p> + </form> + +</body> +</html> diff --git a/browser/extensions/formautofill/test/fixtures/autocomplete_creditcard_basic.html b/browser/extensions/formautofill/test/fixtures/autocomplete_creditcard_basic.html new file mode 100644 index 0000000000..1c03f2434d --- /dev/null +++ b/browser/extensions/formautofill/test/fixtures/autocomplete_creditcard_basic.html @@ -0,0 +1,29 @@ +<!DOCTYPE html> +<html> +<head> + <meta charset="utf-8"> + <title>Form Autofill Credit Card Demo Page</title> +</head> +<body> + <h1>Form Autofill Credit Card Demo Page</h1> + <form id="form"> + <p><label>Name: <input id="cc-name" autocomplete="cc-name"></label></p> + <p><label>Card Number: <input id="cc-number" autocomplete="cc-number"></label></p> + <p><label>Expiration month: <input id="cc-exp-month" autocomplete="cc-exp-month"></label></p> + <p><label>Expiration year: <input id="cc-exp-year" autocomplete="cc-exp-year"></label></p> + <p><label>CSC: <input id="cc-csc" autocomplete="cc-csc"></label></p> + <p><label>Card Type: <select id="cc-type" autocomplete="cc-type"> + <option></option> + <option value="discover">Discover</option> + <option value="jcb">JCB</option> + <option value="visa">Visa</option> + <option value="mastercard">MasterCard</option> + <option value="gringotts">Unknown card network</option> + </select></label></p> + <p> + <input type="submit" value="Submit"> + <button type="reset">Reset</button> + </p> + </form> +</body> +</html> diff --git a/browser/extensions/formautofill/test/fixtures/autocomplete_creditcard_iframe.html b/browser/extensions/formautofill/test/fixtures/autocomplete_creditcard_iframe.html new file mode 100644 index 0000000000..506deb396b --- /dev/null +++ b/browser/extensions/formautofill/test/fixtures/autocomplete_creditcard_iframe.html @@ -0,0 +1,12 @@ +<!DOCTYPE html> +<html> +<head> + <meta charset="utf-8"> + <title>Form Autofill Credit Card With Remote IFrame Demo Page</title> +</head> +<body> + <iframe src="https://test1.example.com:443/browser/browser/extensions/formautofill/test/browser/creditCard/autocomplete_creditcard_basic.html" width="400" height="400"> + </iframe> + +</body> +</html> diff --git a/browser/extensions/formautofill/test/fixtures/autocomplete_iframe.html b/browser/extensions/formautofill/test/fixtures/autocomplete_iframe.html new file mode 100644 index 0000000000..84fcf54e67 --- /dev/null +++ b/browser/extensions/formautofill/test/fixtures/autocomplete_iframe.html @@ -0,0 +1,13 @@ +<!DOCTYPE html> +<html> +<head> + <meta charset="utf-8"> + <title>Form Autofill With Remote IFrame Demo Page</title> +</head> +<body> + <iframe id="unused" src="data:text/html,<body>Just here to ensure code doesn't always pick the first child iframe.</body>"></iframe> + <iframe src="https://test1.example.com:443/browser/browser/extensions/formautofill/test/browser/autocomplete_basic.html" width="400" height="400"> + </iframe> + +</body> +</html> diff --git a/browser/extensions/formautofill/test/fixtures/autocomplete_simple_basic.html b/browser/extensions/formautofill/test/fixtures/autocomplete_simple_basic.html new file mode 100644 index 0000000000..9e78e6392f --- /dev/null +++ b/browser/extensions/formautofill/test/fixtures/autocomplete_simple_basic.html @@ -0,0 +1,19 @@ +<!DOCTYPE html> +<html> +<head> + <meta charset="utf-8"> + <title>Form Autofill Demo Page for Simplified Form Case</title> +</head> +<body> + <h1>Form Autofill Demo Page for Simplified Form Case</h1> + + <form id="simple"> + <p><label>Organization: <input type="text" /></label></p> + <p><label>Telephone: <input id="simple_tel" name="tel" /></label></p> + <p><label>Email: <input type="text" id="simple_email" name="email" /></label></p> + <p><input type="submit" /></p> + <p><button type="reset">Reset</button></p> + </form> + +</body> +</html> diff --git a/browser/extensions/formautofill/test/fixtures/heuristics_cc_exp.html b/browser/extensions/formautofill/test/fixtures/heuristics_cc_exp.html new file mode 100644 index 0000000000..e14f1520a6 --- /dev/null +++ b/browser/extensions/formautofill/test/fixtures/heuristics_cc_exp.html @@ -0,0 +1,73 @@ +<!DOCTYPE html> +<html> +<head> + <meta charset="utf-8"> + <title>Heuristics cc-exp field test page</title> +</head> +<body> + <h1>Heuristics cc-exp field test page</h1> + + <form id="form1"> + <p><label>Name: <input id="cc-name" autocomplete="cc-name"></label></p> + <p><label>Card Number: <input id="cc-number" autocomplete="cc-number"></label></p> + <p><label>Expiration month: <input id="cc-exp-month" autocomplete="cc-exp-month"></label></p> + <p><label>Expiration year: <input id="cc-exp-year" autocomplete="cc-exp-year"></label></p> + <p><label>CSC: <input id="cc-csc" autocomplete="cc-csc"></label></p> + </form> + + <form id="form2"> + <p><label>Card Number: <input id="cc-number" autocomplete="cc-number"></label></p> + <p><label>Expiration Date: <input autocomplete="cc-exp"></label></p> + </form> + + <form id="form3"> + <p><label>Card Number: <input id="cc-number" autocomplete="cc-number"></label></p> + <p><label>Expiration Date: <input type="text"></label></p> + </form> + + <form id="form4"> + <p><label>Card Number: <input id="cc-number" autocomplete="cc-number"></label></p> + <p> + <label>Exp: + <select> + <option value="1"></option> + <option value="2"></option> + <option value="3"></option> + <option value="4"></option> + <option value="5"></option> + <option value="6"></option> + <option value="7"></option> + <option value="8"></option> + <option value="9"></option> + <option value="10"></option> + <option value="11"></option> + <option value="12"></option> + </select> + </label> + </p> + <p> + <label>Exp: + <select> + <option value="2015"></option> + <option value="2016"></option> + <option value="2017"></option> + <option value="2018"></option> + <option value="2019"></option> + <option value="2020"></option> + <option value="2021"></option> + <option value="2022"></option> + <option value="2023"></option> + <option value="2024"></option> + <option value="2025"></option> + </select> + </label> + </p> + </form> + + <form id="form5"> + <input class="expire-date" maxlength="2" id="expiry-month" placeholder="MM" name="expireMonth" type="text"> + <input id="expiry-year" class="expire-date" placeholder="YY" maxlength="2" name="expireYear" type="text"> + <input maxlength="3" name="cvc" type="text"> + </form> +</body> +</html> diff --git a/browser/extensions/formautofill/test/fixtures/heuristics_de_fields.html b/browser/extensions/formautofill/test/fixtures/heuristics_de_fields.html new file mode 100644 index 0000000000..2056b2a23b --- /dev/null +++ b/browser/extensions/formautofill/test/fixtures/heuristics_de_fields.html @@ -0,0 +1,66 @@ +<!DOCTYPE html> +<html> +<head> + <meta charset="utf-8"> + <title>Heuristics de-DE fields test page</title> +</head> +<body> + <h1>Heuristics de-DE fields test page</h1> + <form autocomplete="off"> + <div> + <div>Karteninhaber</div> + <input id="creditCardHolder" name="creditCardHolder" maxlength="30" type="text"> + </div> + <div> + <div>Kartentyp</div> + <select id="CCBrand" name="CCBrand"> + <option> + </option> + <option>AMEX</option> + <option>VISA</option> + <option>MasterCard</option> + <option>Maestro</option> + </select> + </div> + <div> + <div>Kartennummer</div> + <input id="CCNr" name="CCNr" maxlength="19" type="text"> + </div> + <div> + <div>gültig bis</div> + <select id="KKMonth" name="KKMonth"> + <option value="MM">MM</option> + <option value="01">01</option> + <option value="02">02</option> + <option value="03">03</option> + <option value="04">04</option> + <option value="05">05</option> + <option value="06">06</option> + <option value="07">07</option> + <option value="08">08</option> + <option value="09">09</option> + <option value="10">10</option> + <option value="11">11</option> + <option value="12">12</option> + </select> + <select id="KKYear" name="KKYear"> + <option value="YYYY">JJJJ</option> + <option value="2018">2018</option> + <option value="2019">2019</option> + <option value="2020">2020</option> + <option value="2021">2021</option> + <option value="2022">2022</option> + <option value="2023">2023</option> + <option value="2024">2024</option> + <option value="2025">2025</option> + <option value="2026">2026</option> + <option value="2027">2027</option> + </select> + </div> + <div> + <div>Prüfnummer</div> + <input name="cccvc" id="CVV" maxlength="4" size="5" type="password"> + </div> + </form> +</body> +</html> diff --git a/browser/extensions/formautofill/test/fixtures/multiple_section.html b/browser/extensions/formautofill/test/fixtures/multiple_section.html new file mode 100644 index 0000000000..451a622521 --- /dev/null +++ b/browser/extensions/formautofill/test/fixtures/multiple_section.html @@ -0,0 +1,84 @@ +<!DOCTYPE html> +<html> +<head> + <meta charset="utf-8"> + <title>Form Autofill Demo Page</title> +</head> +<body> + <h1>Form Autofill Demo Page</h1> + <form> + <label>Name: <input id="name" autocomplete="name"></label><br/> + <label>Organization: <input id="organization" autocomplete="organization"></label><br/> + + <br/> + <label>Street Address: <input id="street-address-a" autocomplete="shipping street-address"></label><br/> + <label>Address Level 2: <input id="address-level2-a" autocomplete="shipping address-level2"></label><br/> + <label>Address Level 1: <input id="address-level1-a" autocomplete="shipping address-level1"></label><br/> + <label>Postal Code: <input id="postal-code-a" autocomplete="shipping postal-code"></label><br/> + <label>Country: <input id="country-a" autocomplete="shipping country"></label><br/> + + <br/> + <label>Street Address: <input id="street-address-b" autocomplete="billing street-address"></label><br/> + <label>Address Level 2: <input id="address-level2-b" autocomplete="billing address-level2"></label><br/> + <label>Address Level 1: <input id="address-level1-b" autocomplete="billing address-level1"></label><br/> + <label>Postal Code: <input id="postal-code-b" autocomplete="billing postal-code"></label><br/> + <label>Country: <input id="country-b" autocomplete="billing country"></label><br/> + + <br/> + <label>Street Address: <input id="street-address-c" autocomplete="section-my street-address"></label><br/> + <label>Address Level 2: <input id="address-level2-c" autocomplete="section-my address-level2"></label><br/> + <label>Address Level 1: <input id="address-level1-c" autocomplete="section-my address-level1"></label><br/> + <label>Postal Code: <input id="postal-code-c" autocomplete="section-my postal-code"></label><br/> + <label>Country: <input id="country-c" autocomplete="section-my country"></label><br/> + + <br/> + <label>Telephone: <input id="tel-a" autocomplete="work tel"></label><br/> + <label>Email: <input id="email-a" autocomplete="work email"></label><br/> + <br/> + <label>Telephone: <input id="tel-b" autocomplete="home tel"></label><br/> + <label>Email: <input id="email-b" autocomplete="home email"></label><br/> + <p> + <input type="submit" value="Submit"> + <button type="reset">Reset</button> + </p> + </form> + + <form> + <label>Name: <input autocomplete="name"></label><br/> + <label>Organization: <input autocomplete="organization"></label><br/> + + <br/> + <label>Street Address: <input autocomplete="street-address"></label><br/> + <label>Address Level 2: <input autocomplete="address-level2"></label><br/> + <label>Address Level 1: <input autocomplete="address-level1"></label><br/> + <label>Postal Code: <input autocomplete="postal-code"></label><br/> + <label>Country: <input autocomplete="country"></label><br/> + + <br/> + <label>Street Address: <input autocomplete="street-address"></label><br/> + <label>Address Level 2: <input autocomplete="address-level2"></label><br/> + <label>Address Level 1: <input autocomplete="address-level1"></label><br/> + <label>Postal Code: <input autocomplete="postal-code"></label><br/> + <label>Country: <input autocomplete="country"></label><br/> + + <br/> + <label>Street Address: <input autocomplete="street-address"></label><br/> + <label>Address Level 2: <input autocomplete="address-level2"></label><br/> + <label>Address Level 1: <input autocomplete="address-level1"></label><br/> + <label>Postal Code: <input autocomplete="postal-code"></label><br/> + <label>Country: <input autocomplete="country"></label><br/> + + <br/> + <label>Telephone: <input autocomplete="work tel"></label><br/> + <label>Email: <input autocomplete="work email"></label><br/> + <br/> + <label>Telephone: <input autocomplete="home tel"></label><br/> + <label>Email: <input autocomplete="home email"></label><br/> + <p> + <input type="submit" value="Submit"> + <button type="reset">Reset</button> + </p> + </form> + +</body> +</html> diff --git a/browser/extensions/formautofill/test/fixtures/third_party/BestBuy/Checkout_Payment.html b/browser/extensions/formautofill/test/fixtures/third_party/BestBuy/Checkout_Payment.html new file mode 100644 index 0000000000..41bece3ef2 --- /dev/null +++ b/browser/extensions/formautofill/test/fixtures/third_party/BestBuy/Checkout_Payment.html @@ -0,0 +1,283 @@ +<!DOCTYPE html> +<html lang="en"> + <head> + <title>Checkout – Best Buy</title> + </head> + <body> + <form name="frmSearch" action="https://www.bestbuy.com/site/searchpage.jsp" method="GET"> + <input type="text" value="" name="st" maxlength="90" placeholder="Search Best Buy" id="gh-search-input" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false"> + <input type="hidden" value="UTF-8" name="_dyncharset"> + <input type="hidden" value="pcat17071" name="id"> + <input type="hidden" value="page" name="type"> + <input type="hidden" value="Global" name="sc"> + <input type="hidden" value="1" name="cp"> + <input type="hidden" value="" name="nrp"> + <input type="hidden" value="" name="sp"> + <input type="hidden" value="" name="qp"> + <input type="hidden" value="n" name="list"> + <input type="hidden" value="true" name="af"> + <input type="hidden" value="y" name="iht"> + <input type="hidden" value="All Categories" name="usc"> + <input type="hidden" value="960" name="ks"> + <input type="hidden" id="keys" value="keys" name="keys"> + </form> + <form action="https://www-ssl.bestbuy.com/site/olspage.jsp?id=pcat17009&type=page&fastTrack=true" id="footer-email-form"> + <label for="footerEmailSignup">GET THE LATEST DEALS & MORE</label> + <input type="text" id="footerEmailSignup" name="email" placeholder="Enter E-Mail Address"> + <input type="submit" value="Sign Up" +title="Sign Up"> + </form> + <form action="javascript://"> + <div> + <label for="fulfillment.fulfillmentGroups.0.fulfillment.address.firstName"> + <span> + <p>First Name</p> + </span> + <div> + <input type="text" id="fulfillment.fulfillmentGroups.0.fulfillment.address.firstName" name="firstName" maxlength="29" value=""> + </div> + </label> + </div> + <div> + <label for="fulfillment.fulfillmentGroups.0.fulfillment.address.lastName"> + <span> + <p>Last Name</p> + </span> + <div> + <input type="text" id="fulfillment.fulfillmentGroups.0.fulfillment.address.lastName" name="lastName" maxlength="30" value=""> + </div> + </label> + </div> + <div> + <label for="fulfillment.fulfillmentGroups.0.fulfillment.address.street"> + <span> + <p>Address</p> + </span> + <div> + <input type="text" id="fulfillment.fulfillmentGroups.0.fulfillment.address.street" name="street" maxlength="35" value=""> + </div> + </label> + </div> + <div> + <label id="fulfillment.fulfillmentGroups.0.fulfillment.address.city" for="fulfillment.fulfillmentGroups.0.fulfillment.address.city"> + <span> + <p>City</p> + </span> + <div> + <input type="text" id="fulfillment.fulfillmentGroups.0.fulfillment.address.city" name="city" maxlength="30" value=""> + </div> + </label> + </div> + <div> + <label for="fulfillment.fulfillmentGroups.0.fulfillment.address.state"> + <span> + <p>State</p> + </span> + <div> + <select id="fulfillment.fulfillmentGroups.0.fulfillment.address.state" name="state"> + <option value="">Select a state</option> + <option value="AL">AL - Alabama</option> + <option value="AK">AK - Alaska</option> + <option value="AP">AP - Armed Forces Pacific</option> + <option value="AE">AE - Armed Force Europe</option> + <option value="AA">AA - Armed Forces America</option> + <option value="AZ">AZ - Arizona</option> + <option value="AR">AR - Arkansas</option> + <option value="CA">CA - California</option> + <option value="CO">CO - Colorado</option> + <option value="CT">CT - Connecticut</option> + <option value="DC">DC - Washington D.C.</option> + <option value="DE">DE - Delaware</option> + <option value="FL">FL - Florida</option> + <option value="GA">GA - Georgia</option> + <option value="GU">GU - Guam</option> + <option value="HI">HI - Hawaii</option> + <option value="ID">ID - Idaho</option> + <option value="IL">IL - Illinois</option> + <option value="IN">IN - Indiana</option> + <option value="IA">IA - Iowa</option> + <option value="KS">KS - Kansas</option> + <option value="KY">KY - Kentucky</option> + <option value="LA">LA - Louisiana</option> + <option value="ME">ME - Maine</option> + <option value="MD">MD - Maryland</option> + <option value="MA">MA - Massachusetts</option> + <option value="MI">MI - Michigan</option> + <option value="MN">MN - Minnesota</option> + <option value="MS">MS - Mississippi</option> + <option value="MO">MO - Missouri</option> + <option value="MT">MT - Montana</option> + <option value="NE">NE - Nebraska</option> + <option value="NV">NV - Nevada</option> + <option value="NH">NH - New Hampshire</option> + <option value="NJ">NJ - New Jersey</option> + <option value="NM">NM - New Mexico</option> + <option value="NY">NY - New York</option> + <option value="NC">NC - North Carolina</option> + <option value="ND">ND - North Dakota</option> + <option value="OH">OH - Ohio</option> + <option value="OK">OK - Oklahoma</option> + <option value="OR">OR - Oregon</option> + <option value="PA">PA - Pennsylvania</option> + <option value="RI">RI - Rhode Island</option> + <option value="SC">SC - South Carolina</option> + <option value="SD">SD - South Dakota</option> + <option value="TN">TN - Tennessee</option> + <option value="TX">TX - Texas</option> + <option value="UT">UT - Utah</option> + <option value="VT">VT - Vermont</option> + <option value="VA">VA - Virginia</option> + <option value="VI">VI - Virgin Islands</option> + <option value="WA">WA - Washington</option> + <option value="WV">WV - West Virginia</option> + <option value="WI">WI - Wisconsin</option> + <option value="WY">WY - Wyoming</option> + </select> + </div> + </label> + </div> + <div> + <label for="fulfillment.fulfillmentGroups.0.fulfillment.address.zipcode"> + <span> + <p>ZIP Code</p> + </span> + <div> + <input type="tel" id="fulfillment.fulfillmentGroups.0.fulfillment.address.zipcode" name="zipcode" maxlength="5" value=""> + </div> + </label> + </div> + </form> + <div role="search"> + <div id="app"> + <div> + <div> + <div> + <div> + <section> + <div> + <div> + <section> + <section> + <div> + <ul> + <li> + <label for="ispu-fulfillmentci779178028329"> + <div> + <input type="radio" id="ispu-fulfillmentci779178028329" name="fulfillment-options-list__radio_0" value=""> +<i> +</i> + </div> + <strong> + <span>Tomorrow</span> + <span> + <p> at a Best Buy store</p> + </span> + </strong> + <div> + <span> + <p>Store pick ups are usually ready within one hour and held for up to 8 days</p> + </span> + </div> + </label> + </li> + </ul> + </div> + <div> + <ul> + <li> + <label for="0losTwo Day"> + <div> + <input type="radio" id="0losTwo Day" name="fulfillment-options-list__radio_0" value=""> +<i> +</i> + </div> + <strong> +<span>Wed, Mar 22</span> +</strong>- Two Day Shipping + </label> + </li> + <li> + <label for="0losOne Day"> + <div> + <input type="radio" id="0losOne Day" name="fulfillment-options-list__radio_0" value=""> +<i> +</i> + </div> + <strong> +<span>Tue, Mar 21</span> +</strong>- One Day Shipping + </label> + </li> + <li> + <label for="0losSame Day"> + <div> + <input type="radio" id="0losSame Day" name="fulfillment-options-list__radio_0" value=""> +<i> +</i> + </div> + <strong> +<span>Tomorrow</span> +</strong>- Same Day Shipping + </label> + </li> + </ul> + </div> + </section> + <section> + <label for="save-for-billing-address-0"> + <div> + <input type="checkbox" id="save-for-billing-address-0" value=""> +<i> +</i> + </div> + <span> + <p>Save this as my billing address</p> + </span> + </label> + </section> + </section> + </div> + <div> + <section> + <div> + <label for="user.emailAddress"> + <span> + <p>E-mail Address</p> + </span> + <div> + <input id="user.emailAddress" name="emailAddress" value=""> + </div> + </label> + </div> + <div> + <label for="user.phone"> + <span> + <p>Phone Number</p> + </span> + <div> + <input type="tel" id="user.phone" name="phone" maxlength="12" value=""> + </div> + </label> + </div> + <div> + <label for="text-updates"> + <div> + <input type="checkbox" id="text-updates" value=""> + </div> + <span> + <p>Send me text notifications for my order</p> + </span> + </label> + </div> + </section> + </div> + </div> + </section> + </div> + </div> + </div> + </div> + </div> + </div> + </body> +</html> diff --git a/browser/extensions/formautofill/test/fixtures/third_party/BestBuy/Checkout_ShippingAddress.html b/browser/extensions/formautofill/test/fixtures/third_party/BestBuy/Checkout_ShippingAddress.html new file mode 100644 index 0000000000..f8b88d0778 --- /dev/null +++ b/browser/extensions/formautofill/test/fixtures/third_party/BestBuy/Checkout_ShippingAddress.html @@ -0,0 +1,326 @@ +<!DOCTYPE html> +<html lang="en"> + <head> + <title>Checkout – Best Buy</title> + </head> + <body> + <form name="frmSearch" action="https://www.bestbuy.com/site/searchpage.jsp" method="GET"> + <label for="gh-search-input">Search Best Buy</label> + <input type="text" value="" name="st" maxlength="90" placeholder="Search Best Buy" id="gh-search-input" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false" /> + <input type="hidden" value="UTF-8" name="_dyncharset" /> + <input type="hidden" value="pcat17071" name="id" /> + <input type="hidden" value="page" name="type" /> + <input type="hidden" value="Global" name="sc" /> + <input type="hidden" value="1" name="cp" /> + <input type="hidden" value="" name="nrp" /> + <input type="hidden" value="" name="sp" /> + <input type="hidden" value="" name="qp" /> + <input type="hidden" value="n" name="list" /> + <input type="hidden" value="true" name="af" /> + <input type="hidden" value="y" name="iht" /> + <input type="hidden" value="All Categories" name="usc" /> + <input type="hidden" value="960" name="ks" /> + <input type="hidden" id="keys" value="keys" name="keys" /> + </form> + <form action="javascript://"> + <div> + <label for="fulfillment.fulfillmentGroups.0.fulfillment.address.firstName"> + <span> + <p>First Name</p> + </span> + <div> + <input type="text" id="fulfillment.fulfillmentGroups.0.fulfillment.address.firstName" name="firstName" maxlength="29" value="" +title="overall type: NAME_FIRST + server type: NAME_FIRST + heuristic type: NAME_FIRST + label: First Name + parseable name: firstName + field signature: 1855613035 + form signature: 670076259790528644" +autofill-prediction="NAME_FIRST" +/> + </div> + </label> + </div> + <div> + <label for="fulfillment.fulfillmentGroups.0.fulfillment.address.lastName"> + <span> + <p>Last Name</p> + </span> + <div> + <input type="text" id="fulfillment.fulfillmentGroups.0.fulfillment.address.lastName" name="lastName" maxlength="30" value="" +title="overall type: NAME_LAST + server type: NAME_LAST + heuristic type: NAME_LAST + label: Last Name + parseable name: lastName + field signature: 4163345999 + form signature: 670076259790528644" +autofill-prediction="NAME_LAST" +/> + </div> + </label> + </div> + <div> + <label for="fulfillment.fulfillmentGroups.0.fulfillment.address.street"> + <span> + <p>Address</p> + </span> + <div> + <input type="text" id="fulfillment.fulfillmentGroups.0.fulfillment.address.street" name="street" maxlength="35" value="" +title="overall type: ADDRESS_HOME_STREET_ADDRESS + server type: ADDRESS_HOME_STREET_ADDRESS + heuristic type: ADDRESS_HOME_LINE1 + label: Address + parseable name: street + field signature: 3370790275 + form signature: 670076259790528644" +autofill-prediction="ADDRESS_HOME_STREET_ADDRESS" +/> + </div> + </label> + </div> + <div> + <label id="fulfillment.fulfillmentGroups.0.fulfillment.address.city" for="fulfillment.fulfillmentGroups.0.fulfillment.address.city"> + <span> + <p>City</p> + </span> + <div> + <input type="text" id="fulfillment.fulfillmentGroups.0.fulfillment.address.city" name="city" maxlength="30" value="" +title="overall type: ADDRESS_HOME_CITY + server type: ADDRESS_HOME_CITY + heuristic type: ADDRESS_HOME_CITY + label: City + parseable name: city + field signature: 2098554694 + form signature: 670076259790528644" +autofill-prediction="ADDRESS_HOME_CITY" +/> + </div> + </label> + </div> + <div> + <label for="fulfillment.fulfillmentGroups.0.fulfillment.address.state"> + <span> + <p>State</p> + </span> + <div> + <select id="fulfillment.fulfillmentGroups.0.fulfillment.address.state" name="state" +title="overall type: ADDRESS_HOME_STATE + server type: ADDRESS_HOME_STATE + heuristic type: ADDRESS_HOME_STATE + label: State + parseable name: state + field signature: 1878375253 + form signature: 670076259790528644" +autofill-prediction="ADDRESS_HOME_STATE" +/> + <option value="">Select a state</option> + <option value="AL">AL - Alabama</option> + <option value="AK">AK - Alaska</option> + <option value="AP">AP - Armed Forces Pacific</option> + <option value="AE">AE - Armed Forces Europe</option> + <option value="AA">AA - Armed Forces America</option> + <option value="AZ">AZ - Arizona</option> + <option value="AR">AR - Arkansas</option> + <option value="CA">CA - California</option> + <option value="CO">CO - Colorado</option> + <option value="CT">CT - Connecticut</option> + <option value="DC">DC - Washington D.C.</option> + <option value="DE">DE - Delaware</option> + <option value="FL">FL - Florida</option> + <option value="GA">GA - Georgia</option> + <option value="GU">GU - Guam</option> + <option value="HI">HI - Hawaii</option> + <option value="ID">ID - Idaho</option> + <option value="IL">IL - Illinois</option> + <option value="IN">IN - Indiana</option> + <option value="IA">IA - Iowa</option> + <option value="KS">KS - Kansas</option> + <option value="KY">KY - Kentucky</option> + <option value="LA">LA - Louisiana</option> + <option value="ME">ME - Maine</option> + <option value="MD">MD - Maryland</option> + <option value="MA">MA - Massachusetts</option> + <option value="MI">MI - Michigan</option> + <option value="MN">MN - Minnesota</option> + <option value="MS">MS - Mississippi</option> + <option value="MO">MO - Missouri</option> + <option value="MT">MT - Montana</option> + <option value="NE">NE - Nebraska</option> + <option value="NV">NV - Nevada</option> + <option value="NH">NH - New Hampshire</option> + <option value="NJ">NJ - New Jersey</option> + <option value="NM">NM - New Mexico</option> + <option value="NY">NY - New York</option> + <option value="NC">NC - North Carolina</option> + <option value="ND">ND - North Dakota</option> + <option value="OH">OH - Ohio</option> + <option value="OK">OK - Oklahoma</option> + <option value="OR">OR - Oregon</option> + <option value="PA">PA - Pennsylvania</option> + <option value="RI">RI - Rhode Island</option> + <option value="SC">SC - South Carolina</option> + <option value="SD">SD - South Dakota</option> + <option value="TN">TN - Tennessee</option> + <option value="TX">TX - Texas</option> + <option value="UT">UT - Utah</option> + <option value="VT">VT - Vermont</option> + <option value="VA">VA - Virginia</option> + <option value="VI">VI - Virgin Islands</option> + <option value="WA">WA - Washington</option> + <option value="WV">WV - West Virginia</option> + <option value="WI">WI - Wisconsin</option> + <option value="WY">WY - Wyoming</option> + </select> + </div> + </label> + </div> + <div> + <label for="fulfillment.fulfillmentGroups.0.fulfillment.address.zipcode"> + <span> + <p>ZIP Code</p> + </span> + <div> + <input type="tel" id="fulfillment.fulfillmentGroups.0.fulfillment.address.zipcode" name="zipcode" maxlength="5" value="" +title="overall type: ADDRESS_HOME_ZIP + server type: ADDRESS_HOME_ZIP + heuristic type: ADDRESS_HOME_ZIP + label: ZIP Code + parseable name: zipcode + field signature: 390262106 + form signature: 670076259790528644" +autofill-prediction="ADDRESS_HOME_ZIP" +/> + </div> + </label> + </div> + </form> + <form action="https://www-ssl.bestbuy.com/site/olspage.jsp?id=pcat17009&type=page&fastTrack=true" id="footer-email-form"> +<label for="footerEmailSignup">GET THE LATEST DEALS & MORE</label> + <input type="text" id="footerEmailSignup" name="email" placeholder="Enter E-Mail Address" /> + <input type="submit" value="Sign Up" +title="Sign Up" /> + </form> + <div id="checkout-container"> + <div id="app"> + <div> + <div> + <div> + <div> + <section> + <div> + <div> + <section> + <section> + <div> + <ul> + <li> + <label for="ispu-fulfillmentci779178028329"> + <div> + <input type="radio" id="ispu-fulfillmentci779178028329" name="fulfillment-options-list__radio_0" value="" /> + </div> + <strong> + <span>Tomorrow</span> + <span> + <p> at a Best Buy store</p> + </span> + </strong> + <div> + <span> + <p>Store pick ups are usually ready within one hour and held for up to 8 days</p> + </span> + </div> + </label> + </li> + </ul> + </div> + <div> + <ul> + <li> + <label for="0losTwo Day"> + <div> + <input type="radio" id="0losTwo Day" name="fulfillment-options-list__radio_0" value="" /> + </div> + <strong> +<span>Wed, Mar 22</span> +</strong> - Two Day Shipping + <p> + Some items may arrive slower. See + <span> +<span> +<p>Order Summary</p> +</span> +</span> for details. + </p> + </label> + </li> + <li> + <label for="0losOne Day"> + <div> + <input type="radio" id="0losOne Day" name="fulfillment-options-list__radio_0" value="" /> + </div> + <strong> +<span>Tue, Mar 21</span> +</strong> - One Day Shipping + </label> + </li> + </ul> + </div> + </section> + <section> + <label for="save-for-billing-address-0"> + <div> + <input type="checkbox" id="save-for-billing-address-0" value="" /> + </div> + <span> + <p>Save this as my billing address</p> + </span> + </label> + </section> + </section> + </div> + <div> + <section> + <div> + <label for="user.emailAddress"> + <span> + <p>E-mail Address</p> + </span> + <div> + <input id="user.emailAddress" name="emailAddress" value="" /> + </div> + </label> + </div> + <div> + <label for="user.phone"> + <span> + <p>Phone Number</p> + </span> + <div> + <input type="tel" id="user.phone" name="phone" maxlength="12" value="" /> + </div> + </label> + </div> + <div> + <label for="text-updates"> + <div> + <input type="checkbox" id="text-updates" value="" /> + </div> + <span> + <p>Send me text notifications for my order</p> + </span> + </label> + </div> + </section> + </div> + </div> + </section> + </div> + </div> + </div> + </div> + </div> + </div> + </body> +</html> diff --git a/browser/extensions/formautofill/test/fixtures/third_party/BestBuy/SignIn.html b/browser/extensions/formautofill/test/fixtures/third_party/BestBuy/SignIn.html new file mode 100644 index 0000000000..8111d49c37 --- /dev/null +++ b/browser/extensions/formautofill/test/fixtures/third_party/BestBuy/SignIn.html @@ -0,0 +1,21 @@ +<!DOCTYPE html> +<html lang="en"> + <head> + <title>Sign In to BestBuy.com</title> + </head> + <body> + <form action="https://www-ssl.bestbuy.com/" name="ciaSignOn" > + <div> + <label for="fld-e">E-Mail Address</label> + <input type="email" name="email_MAGIC_HASH_1" id="fld-e" required="" /> + </div> + <div> + <label for="fld-p1">Password</label> + <div> + <input type="password" name="password_MAGIC_HASH_2" id="fld-p1" required="" /> + </div> + </div> + <input type="hidden" name="Salmon" value="MAGIC_HASH_3" /> + </form> + </body> +</html> diff --git a/browser/extensions/formautofill/test/fixtures/third_party/CDW/Checkout_BillingPaymentInfo.html b/browser/extensions/formautofill/test/fixtures/third_party/CDW/Checkout_BillingPaymentInfo.html new file mode 100644 index 0000000000..35adee68b9 --- /dev/null +++ b/browser/extensions/formautofill/test/fixtures/third_party/CDW/Checkout_BillingPaymentInfo.html @@ -0,0 +1,469 @@ +<!DOCTYPE html> +<html> + <head> + <meta http-equiv="Content-Type" content="text/html; charset=UTF-8"> + <title> + Checkout + </title> + </head> + <body id="MasterPageBodyTag"> + <form name="form1" method="post" action="https://www.cdw.com/shop/checkout/guest/BillingAndPayment.aspx" id="form1"> + <div> + </div> + <div> + <input type="hidden" name="__VIEWSTATEGENERATOR" id="__VIEWSTATEGENERATOR" value="669B25B9"> + <input type="hidden" name="__VIEWSTATEENCRYPTED" id="__VIEWSTATEENCRYPTED" value=""> + </div> + <div> + <div> + <div> + </div> + <div> + <div> + </div> + <div id="newBillingAddressOptions"> + <div> + <div> + <input value="" name="ctl00$ctl00$MainContentRoot$Body$addressOption" type="radio" id="sameAsShippingAddress" checked="checked" +title="overall type: UNKNOWN_TYPE + server type: NO_SERVER_DATA + heuristic type: UNKNOWN_TYPE + label: Use my Shipping Address as my Billing Address + parseable name: addressOption + field signature: 825932642 + form signature: 11231346808802434240" +autofill-prediction="UNKNOWN_TYPE" +> + <label id="lbl1" for="sameAsShippingAddress">Use my Shipping Address as my Billing Address</label> + </div> + <div> + <input value="" name="ctl00$ctl00$MainContentRoot$Body$addressOption" type="radio" id="createNewAddress" +title="overall type: UNKNOWN_TYPE + server type: NO_SERVER_DATA + heuristic type: UNKNOWN_TYPE + label: Enter Billing Address + parseable name: addressOption + field signature: 825932642 + form signature: 11231346808802434240" +autofill-prediction="UNKNOWN_TYPE" +> + <label id="lbl2" for="createNewAddress">Enter Billing Address</label> + </div> + </div> + </div> + <div id="newBillingAddress"> + <fieldset> + <div> + <label for="firstName">First Name (required)</label> + <input name="ctl00$ctl00$MainContentRoot$Body$ctl00$firstName" type="text" id="firstName" maxlength="75" +title="overall type: NAME_FIRST + server type: NAME_FIRST + heuristic type: NAME_FIRST + label: First Name (required) + parseable name: ctl00$firstName + field signature: 759447197 + form signature: 11231346808802434240" +autofill-prediction="NAME_FIRST" +> + </div> + <div> + <label for="lastName">Last Name (required)</label> + <input name="ctl00$ctl00$MainContentRoot$Body$ctl00$lastName" type="text" id="lastName" maxlength="75" +title="overall type: NAME_LAST + server type: NAME_LAST + heuristic type: NAME_LAST + label: Last Name (required) + parseable name: ctl00$lastName + field signature: 2226109235 + form signature: 11231346808802434240" +autofill-prediction="NAME_LAST" +> + </div> + <div> + <label for="company">Company</label> + <input name="ctl00$ctl00$MainContentRoot$Body$ctl00$company" type="text" id="company" maxlength="100" +title="overall type: COMPANY_NAME + server type: COMPANY_NAME + heuristic type: COMPANY_NAME + label: Company + parseable name: ctl00$company + field signature: 474096225 + form signature: 11231346808802434240" +autofill-prediction="COMPANY_NAME" +> + </div> + <div> + <label for="address1">Address Line 1 (required)</label> + <input name="ctl00$ctl00$MainContentRoot$Body$ctl00$address1" type="text" id="address1" maxlength="30" +title="overall type: ADDRESS_HOME_LINE1 + server type: ADDRESS_HOME_LINE1 + heuristic type: ADDRESS_HOME_LINE1 + label: Address Line 1 (required) + parseable name: ctl00$address1 + field signature: 3936848337 + form signature: 11231346808802434240" +autofill-prediction="ADDRESS_HOME_LINE1" +> + </div> + <div> + <label for="address2">Address Line 2 </label> + <input name="ctl00$ctl00$MainContentRoot$Body$ctl00$address2" type="text" id="address2" maxlength="30" +title="overall type: ADDRESS_HOME_LINE2 + server type: ADDRESS_HOME_LINE2 + heuristic type: ADDRESS_HOME_LINE2 + label: Address Line 2 + parseable name: ctl00$address2 + field signature: 3389805014 + form signature: 11231346808802434240" +autofill-prediction="ADDRESS_HOME_LINE2" +> + </div> + <div> + <div> + <label for="city">City (required)</label> + <input name="ctl00$ctl00$MainContentRoot$Body$ctl00$city" type="text" id="city" maxlength="25" +title="overall type: ADDRESS_HOME_CITY + server type: ADDRESS_HOME_CITY + heuristic type: ADDRESS_HOME_CITY + label: City (required) + parseable name: ctl00$city + field signature: 794505091 + form signature: 11231346808802434240" +autofill-prediction="ADDRESS_HOME_CITY" +> + </div> + <div> + <label for="stateProvince">State (required)</label> + <select name="ctl00$ctl00$MainContentRoot$Body$ctl00$stateProvince" id="stateProvince" +title="overall type: ADDRESS_HOME_STATE + server type: NO_SERVER_DATA + heuristic type: ADDRESS_HOME_STATE + label: State (required) + parseable name: ctl00$stateProvince + field signature: 548222440 + form signature: 11231346808802434240" +autofill-prediction="ADDRESS_HOME_STATE" +> + <option selected="selected" value="">Choose a state</option> + <option value="AL-US">AL-Alabama</option> + <option value="AK-US">AK-Alaska</option> + <option value="AS-AS">AS-American Samoa</option> + <option value="AZ-US">AZ-Arizona</option> + <option value="AR-US">AR-Arkansas</option> + <option value="AE-US">AE-Armed Forces Africa</option> + <option value="AA-US">AA-Armed Forces Americas</option> + <option value="AE-US">AE-Armed Forces Canada</option> + <option value="AE-US">AE-Armed Forces Europe</option> + <option value="AE-US">AE-Armed Forces Middle East</option> + <option value="AP-US">AP-Armed Forces Pacific</option> + <option value="CA-US">CA-California</option> + <option value="CO-US">CO-Colorado</option> + <option value="CT-US">CT-Connecticut</option> + <option value="DE-US">DE-Delaware</option> + <option value="DC-US">DC-District of Columbia</option> + <option value="FM-FM">FM-Federated States of Micronesia</option> + <option value="FL-US">FL-Florida</option> + <option value="GA-US">GA-Georgia</option> + <option value="GU-GU">GU-Guam</option> + <option value="HI-US">HI-Hawaii</option> + <option value="ID-US">ID-Idaho</option> + <option value="IL-US">IL-Illinois</option> + <option value="IN-US">IN-Indiana</option> + <option value="IA-US">IA-Iowa</option> + <option value="KS-US">KS-Kansas</option> + <option value="KY-US">KY-Kentucky</option> + <option value="LA-US">LA-Louisiana</option> + <option value="ME-US">ME-Maine</option> + <option value="MH-MH">MH-Marshall Islands</option> + <option value="MD-US">MD-Maryland</option> + <option value="MA-US">MA-Massachusetts</option> + <option value="MI-US">MI-Michigan</option> + <option value="MN-US">MN-Minnesota</option> + <option value="MS-US">MS-Mississippi</option> + <option value="MO-US">MO-Missouri</option> + <option value="MT-US">MT-Montana</option> + <option value="NE-US">NE-Nebraska</option> + <option value="NV-US">NV-Nevada</option> + <option value="NH-US">NH-New Hampshire</option> + <option value="NJ-US">NJ-New Jersey</option> + <option value="NM-US">NM-New Mexico</option> + <option value="NY-US">NY-New York</option> + <option value="NC-US">NC-North Carolina</option> + <option value="ND-US">ND-North Dakota</option> + <option value="MP-MP">MP-Norther Mariana Islands</option> + <option value="OH-US">OH-Ohio</option> + <option value="OK-US">OK-Oklahoma</option> + <option value="OR-US">OR-Oregon</option> + <option value="PA-US">PA-Pennsylvania</option> + <option value="PR-PR">PR-Puerto Rico</option> + <option value="PW-PW">PW-Palau</option> + <option value="RI-US">RI-Rhode Island</option> + <option value="SC-US">SC-South Carolina</option> + <option value="SD-US">SD-South Dakota</option> + <option value="TN-US">TN-Tennessee</option> + <option value="TX-US">TX-Texas</option> + <option value="UT-US">UT-Utah</option> + <option value="VT-US">VT-Vermont</option> + <option value="VI-US">VI-Virgin Islands</option> + <option value="VA-US">VA-Virginia</option> + <option value="WA-US">WA-Washington</option> + <option value="WV-US">WV-West Virginia</option> + <option value="WI-US">WI-Wisconsin</option> + <option value="WY-US">WY-Wyoming</option> + </select> + </div> + <div> + <label for="zipCode">ZIP Code (required)</label> + <input name="ctl00$ctl00$MainContentRoot$Body$ctl00$zipCode" type="text" id="zipCode" maxlength="5" +title="overall type: ADDRESS_HOME_ZIP + server type: ADDRESS_HOME_ZIP + heuristic type: ADDRESS_HOME_ZIP + label: ZIP Code (required) + parseable name: ctl00$zipCode + field signature: 4227103349 + form signature: 11231346808802434240" +autofill-prediction="ADDRESS_HOME_ZIP" +> + </div> + <div> + <label for="zipCodeExtn">ZIP Extn</label> + <input name="ctl00$ctl00$MainContentRoot$Body$ctl00$zipCodeExtn" type="text" id="zipCodeExtn" maxlength="4" +title="overall type: UNKNOWN_TYPE + server type: NO_SERVER_DATA + heuristic type: UNKNOWN_TYPE + label: ZIP Extn + parseable name: ctl00$zipCodeExtn + field signature: 2328453303 + form signature: 11231346808802434240" +autofill-prediction="UNKNOWN_TYPE" +> + </div> + </div> + </fieldset> + </div> + </div> + <div> + <div> The billing address above must match what appears on this credit card's statement.</div> + <div> + <div> + <label for="creditCardType">Card Type (required)</label> + <select name="ctl00$ctl00$MainContentRoot$Body$creditCardType" id="creditCardType" +title="overall type: CREDIT_CARD_TYPE + server type: NO_SERVER_DATA + heuristic type: CREDIT_CARD_TYPE + label: Card Type (required) + parseable name: creditCardType + field signature: 4008988516 + form signature: 11231346808802434240" +autofill-prediction="CREDIT_CARD_TYPE" +> + <option value="Select Type">Select Type</option> + <option value="American Express">American Express</option> + <option value="Discover Network">Discover Network</option> + <option value="MasterCard">MasterCard</option> + <option value="Visa">Visa</option> + </select> + </div> + <div> + <label for="creditCardNumber">Credit Card Number (req)</label> + <input name="ctl00$ctl00$MainContentRoot$Body$creditCardNumber" type="text" id="creditCardNumber" maxlength="16" autocomplete="off" +title="overall type: CREDIT_CARD_NUMBER + server type: NO_SERVER_DATA + heuristic type: CREDIT_CARD_NUMBER + label: Credit Card Number (req) + parseable name: creditCardNumber + field signature: 466166649 + form signature: 11231346808802434240" +autofill-prediction="CREDIT_CARD_NUMBER" +> + <input name="ctl00$ctl00$MainContentRoot$Body$creditCardNumber_en" type="hidden" id="creditCardNumber_en" keydelimiter="**" exponent="010001" clearonsubmit="true" > + </div> + <div> + <label for="expiryMonth">Expiration Date (req)</label> + <span> + <select name="ctl00$ctl00$MainContentRoot$Body$expiryMonth" id="expiryMonth" +title="overall type: CREDIT_CARD_EXP_MONTH + server type: CREDIT_CARD_EXP_MONTH + heuristic type: CREDIT_CARD_EXP_MONTH + label: Expiration Date (req) CVV (req) + parseable name: expiryMonth + field signature: 1744226145 + form signature: 11231346808802434240" +autofill-prediction="CREDIT_CARD_EXP_MONTH" +> + <option value="">mm</option> + <option value="1">01</option> + <option value="2">02</option> + <option value="3">03</option> + <option value="4">04</option> + <option value="5">05</option> + <option value="6">06</option> + <option value="7">07</option> + <option value="8">08</option> + <option value="9">09</option> + <option value="10">10</option> + <option value="11">11</option> + <option value="12">12</option> + </select> + <select name="ctl00$ctl00$MainContentRoot$Body$expiryYear" id="expiryYear" +title="overall type: CREDIT_CARD_EXP_4_DIGIT_YEAR + server type: NO_SERVER_DATA + heuristic type: CREDIT_CARD_EXP_4_DIGIT_YEAR + label: Expiration Date (req) + parseable name: expiryYear + field signature: 3338586057 + form signature: 11231346808802434240" +autofill-prediction="CREDIT_CARD_EXP_4_DIGIT_YEAR" +> + <option value="">yy</option> + <option value="2017">17</option> + <option value="2018">18</option> + <option value="2019">19</option> + <option value="2020">20</option> + <option value="2021">21</option> + <option value="2022">22</option> + <option value="2023">23</option> + <option value="2024">24</option> + <option value="2025">25</option> + <option value="2026">26</option> + </select> + </span> + </div> + <div> + <label for="expiryMonth">CVV (req)</label> + <input name="ctl00$ctl00$MainContentRoot$Body$CreditCardCvvText" type="text" id="CreditCardCvvText" maxlength="4" +title="overall type: CREDIT_CARD_VERIFICATION_CODE + server type: NO_SERVER_DATA + heuristic type: CREDIT_CARD_VERIFICATION_CODE + label: CVV (req) + parseable name: CreditCardCvvText + field signature: 2577719477 + form signature: 11231346808802434240" +autofill-prediction="CREDIT_CARD_VERIFICATION_CODE" +> + <div> + <i> +</i> + <div> + <div> + <div> + <span>What is a CVV?</span> +<br> + <span>For Visa, MasterCard & Discover, the three digits on the back of your card.</span> +<br> + <span> For American Express, the 4 digits on the front of your card.</span> +<br> + </div> + </div> + </div> + </div> + </div> + </div> + </div> + <div> + <input type="submit" name="ctl00$ctl00$MainContentRoot$Body$saveButton" value="Next" id="saveButton"> + <div> +<a +title="Go to Verisign"> + <img src="./Checkout-BillingPaymentInfo_files/verisign.gif" alt="Verisign Secured" border="0"> +</a> + </div> + <div> +<a target="_blank" +title="Go to BBB"> + <img src="./Checkout-BillingPaymentInfo_files/BetterBusinessBureau-horizontal" alt="BBB Accredited Busines"> +</a> + </div> + </div> + </div> + <div> + <ul> + <li id="shippingAddressStep"> + <a id="shippingAddressEdit">Edit</a> + <div id="shippingAddressStepDetails"> + </div> + </li> + <li id="shippingMethodStep"> + <a id="shippingMethodEdit">Edit</a> + <div id="shippingMethodStepDetails"> + <div>Shipping Method</div> + <div> + <div id="shippingMethodName">UPS Ground (2-3 days)</div> + <div id="shippingMethodDesc">2-3 business days</div> + <div id="shippingMethodCost">$19.99</div> + </div> + </div> + </li> + <li id="billingAndPaymentStep"> + <a id="billingAndPaymentEdit">Edit</a> + <div id="billingAndPaymentStepDetails"> + <div> + Billing Address + </div> + <div> + <div> + <span id="billingAddressFirstName"> +</span> + <span id="billingAddressLastName"> +</span> + </div> + <div id="billingAddressEmail"> +</div> + <div id="billingAddressLine1"> +</div> + <div id="billingAddressLine2"> +</div> + <div> + <span id="billingAddressCity"> +</span>, + <span id="billingAddressState"> +</span> + <span id="billingAddressPostalCode"> +</span> + </div> + </div> + <div>Payment Method</div> + <div id="paymentMethod"> +</div> + </div> + </li> + <li> + </li> + </ul> + </div> + <input type="text" name="Representative" id="Representative" value="" +title="overall type: UNKNOWN_TYPE + server type: NO_SERVER_DATA + heuristic type: UNKNOWN_TYPE + label: Billing and Payment + parseable name: Representative + field signature: 716948211 + form signature: 11231346808802434240" +autofill-prediction="UNKNOWN_TYPE" +> + </div> + <input id="__RequestVerificationTokencw" name="__RequestVerificationTokencw" type="hidden" > + </form> + <div> + <div> + <div> + </div> + <ul> + <li> + <a id="button-log-on">Account Log On</a> + <span> or </span> + <a tabindex="2" id="button-create-account">Create Account</a> + </li> + <li> +<a id="button-cart"> + <i> +</i> Cart (<span id="headerCartCount">1</span>) + <span id="headerCartTotal"> - $6,568.99</span> +</a> + </li> + </ul> + </div> + <input type="hidden" id="HdnFreeShippingProductCartIndicator" clientidmode="static" value="0"> + </div> + </body> +</html> diff --git a/browser/extensions/formautofill/test/fixtures/third_party/CDW/Checkout_Logon.html b/browser/extensions/formautofill/test/fixtures/third_party/CDW/Checkout_Logon.html new file mode 100644 index 0000000000..6ee46c8873 --- /dev/null +++ b/browser/extensions/formautofill/test/fixtures/third_party/CDW/Checkout_Logon.html @@ -0,0 +1,118 @@ +<!DOCTYPE html> +<html> + <head> + <meta http-equiv="Content-Type" content="text/html; charset=UTF-8"> + <title> + Logon Checkout + </title> + </head> + <body id="MasterPageBodyTag"> + <form name="LogonFormServer" method="post" action="https://www.cdw.com/shop/eaccount/logon/logon.aspx?site=" id="LogonFormServer" autocomplete="off"> + <div> + <input type="hidden" name="__EVENTTARGET" id="__EVENTTARGET" value=""> + <input type="hidden" name="__EVENTARGUMENT" id="__EVENTARGUMENT" value=""> + <input type="hidden" name="__VIEWSTATE" id="__VIEWSTATE"> + </div> + <div> + <input type="hidden" name="__VIEWSTATEGENERATOR" id="__VIEWSTATEGENERATOR" value="C774B3FE"> + <input type="hidden" name="__VIEWSTATEENCRYPTED" id="__VIEWSTATEENCRYPTED" value=""> + </div> + <p>You don't need an account to place an order but you will have the option to create one after completing your purchase.</p> + <div id="gcoVisualCaptchaContainer"> + <div id="divCaptcha" valign="top"> + <div id="VisualCaptchaContainer"> + <p>Click or touch the <span>House</span> +</p> + <div> + <div> +<img > +</div> + </div> + <div> +<a +title="Refresh"> +</a> +</div> + </div> + <input type="hidden" name="ctl01$ctl00$MainContentRoot$Body$guestCheckoutButton$hidVisualCaptchaToken" id="hidVisualCaptchaToken" value="72fcbb43-d2de-4d9f-8ba3-17d3df45888e"> + <input type="hidden" name="ctl01$ctl00$MainContentRoot$Body$guestCheckoutButton$hidVisualCaptchaSelectedXAxis" id="hidVisualCaptchaSelectedXAxis" value=""> + <input type="hidden" name="ctl01$ctl00$MainContentRoot$Body$guestCheckoutButton$hidVisualCaptchaSelectedYAxis" id="hidVisualCaptchaSelectedYAxis" value=""> + <div> + <span id="valVisualCaptchaInvalid"> +<span>!</span> The validation code entered is incorrect</span> + </div> + </div> + <div> + <a id="guestbutton">Checkout as Guest</a> + <input type="submit" name="ctl01$ctl00$MainContentRoot$Body$guestCheckoutButton$guestCheckOutButton" value="Continue" id="guestCheckOutButton" disabled="disabled"> + </div> + </div> + <br> + <div id="sitePolicy"> + <div> + <a +title="Go to Privacy Policy" target="_blank">Privacy Policy</a> | <a title="Go to Terms and Conditions" target="_blank">Terms and Conditions</a> + </div> + <a +title="Go to Verisign"> + <img src="./Logon Checkout_files/verisign.gif" border="0"> + </a> + </div> + <input id="__RequestVerificationTokencw" name="__RequestVerificationTokencw" type="hidden"> + </form> + <form name="LogonForm" id="LogonForm" method="post" action="https://www.cdw.com/shop/Eaccount/logon/LogOnProcessor.aspx?UI=CheckoutSimplifiedUI" autocomplete="off"> + <div id="divLogon"> + <section> + <div> + <span id="lblUserName">User Name</span> + <a tabindex="70">Forgot user name?</a> + <div id="divUserName"> + <input name="ctl01$ctl00$MainContentRoot$Body$LogonControl$UserName" type="text" id="UserName" tabindex="10" maxlength="50"> + </div> + </div> + <div> + <input name="ctl01$ctl00$MainContentRoot$Body$LogonControl$SavePassword" type="checkbox" id="SavePassword" tabindex="12" value="1"> + <label for="SavePassword"> + Remember my user name on this computer + <img id="question-image" src="./Logon Checkout_files/tooltip-question-mark.jpg" +title=""> +</label> + <div id="remember-tooltip"> + <img src="./Logon Checkout_files/remember-me-tooltip.jpg" usemap="#closepopup"> + <map name="closepopup" id="closepopup"> + <area alt="" +title="" shape="circle" coords="368,23,15.5"> + </map> + </div> + </div> + <div> + <span id="lblUserPass">Password</span> + <a tabindex="80">Forgot password?</a> + <div id="divPassword"> + <input name="ctl01$ctl00$MainContentRoot$Body$LogonControl$UserPassword" type="password" id="UserPassword" tabindex="11" maxlength="50"> + </div> + </div> + <div id="divCaptcha" valign="top"> + </div> + <div id="DivInvalidCredentialsErrorMessage"> + <span>!</span> You have entered an invalid username and/or password. Please re-enter your information. + </div> + <div id="DivInvalidCaptcha"> + <span>!</span> + <span id="CaptchaErrorMessage"> +</span> + </div> + <input name="ctl01$ctl00$MainContentRoot$Body$LogonControl$LogOnButton" type="submit" id="LogOnButton" tabindex="13" value="Log On" border="0"> + </section> + </div> + <input name="ctl01$ctl00$MainContentRoot$Body$LogonControl$WebSite" type="hidden" id="WebSite"> + <input name="ctl01$ctl00$MainContentRoot$Body$LogonControl$Target" type="hidden" id="Target" value="/shop/Checkout/ValidateCheckout.aspx?Standard=1&cm_re=CRT-_-PZ-_-SC+Standard+Checkout+Button"> + <input name="ctl01$ctl00$MainContentRoot$Body$LogonControl$ErrorCount" type="hidden" id="ErrorCount"> + <span id="tagManEventControl"> +</span> + </form> + <div> + <input type="hidden" id="HdnFreeShippingProductCartIndicator" clientidmode="static" value="0"> + </div> + </body> +</html> diff --git a/browser/extensions/formautofill/test/fixtures/third_party/CDW/Checkout_ShippingInfo.html b/browser/extensions/formautofill/test/fixtures/third_party/CDW/Checkout_ShippingInfo.html new file mode 100644 index 0000000000..d461a0050b --- /dev/null +++ b/browser/extensions/formautofill/test/fixtures/third_party/CDW/Checkout_ShippingInfo.html @@ -0,0 +1,376 @@ +<!DOCTYPE html> +<html> + <head> + <meta http-equiv="Content-Type" content="text/html; charset=UTF-8"> + <title> + Checkout + </title> + </head> + <body id="MasterPageBodyTag"> + <form name="form1" method="post" action="https://www.cdw.com/shop/checkout/guest/ShippingAddress.aspx" id="form1"> + <div> + <input type="hidden" name="__EVENTTARGET" id="__EVENTTARGET" value=""> + <input type="hidden" name="__EVENTARGUMENT" id="__EVENTARGUMENT" value=""> + <input type="hidden" name="__VIEWSTATE" id="__VIEWSTATE"> + </div> + <div> + <input type="hidden" name="__VIEWSTATEGENERATOR" id="__VIEWSTATEGENERATOR"> + <input type="hidden" name="__VIEWSTATEENCRYPTED" id="__VIEWSTATEENCRYPTED" value=""> + </div> + <div> + <div> + <div> + </div> + <div> + <div> + <div> + <div> + <label for="firstName">First Name (required)</label> + <input name="ctl00$ctl00$MainContentRoot$Body$ctl00$firstName" type="text" id="firstName" maxlength="75" +title="overall type: NAME_FIRST + server type: NAME_FIRST + heuristic type: NAME_FIRST + label: First Name (required) + parseable name: tl00$firstName + field signature: 759447197 + form signature: 7628530229511417656" +autofill-prediction="NAME_FIRST" +> + </div> + <div> + <label for="lastName">Last Name (required)</label> + <input name="ctl00$ctl00$MainContentRoot$Body$ctl00$lastName" type="text" id="lastName" maxlength="75" +title="overall type: NAME_LAST + server type: NAME_LAST + heuristic type: NAME_LAST + label: Last Name (required) + parseable name: tl00$lastName + field signature: 2226109235 + form signature: 7628530229511417656" +autofill-prediction="NAME_LAST" +> + </div> + <div> + <label for="company">Company</label> + <input name="ctl00$ctl00$MainContentRoot$Body$ctl00$company" type="text" id="company" maxlength="100" +title="overall type: COMPANY_NAME + server type: COMPANY_NAME + heuristic type: COMPANY_NAME + label: Company + parseable name: tl00$company + field signature: 474096225 + form signature: 7628530229511417656" +autofill-prediction="COMPANY_NAME" +> + </div> + <div> + <label for="address1">Address Line 1 (required)</label> + <input name="ctl00$ctl00$MainContentRoot$Body$ctl00$address1" type="text" id="address1" maxlength="30" +title="overall type: ADDRESS_HOME_LINE1 + server type: ADDRESS_HOME_LINE1 + heuristic type: ADDRESS_HOME_LINE1 + label: Address Line 1 (required) + parseable name: tl00$address1 + field signature: 3936848337 + form signature: 7628530229511417656" +autofill-prediction="ADDRESS_HOME_LINE1" +> + </div> + <div> + <label for="address2">Address Line 2 </label> + <input name="ctl00$ctl00$MainContentRoot$Body$ctl00$address2" type="text" id="address2" maxlength="30" +title="overall type: ADDRESS_HOME_LINE2 + server type: ADDRESS_HOME_LINE2 + heuristic type: ADDRESS_HOME_LINE2 + label: Address Line 2 + parseable name: tl00$address2 + field signature: 3389805014 + form signature: 7628530229511417656" +autofill-prediction="ADDRESS_HOME_LINE2" +> + </div> + <div> + <div> + <label for="city">City (required)</label> + <input name="ctl00$ctl00$MainContentRoot$Body$ctl00$city" type="text" id="city" maxlength="25" +title="overall type: ADDRESS_HOME_CITY + server type: ADDRESS_HOME_CITY + heuristic type: ADDRESS_HOME_CITY + label: City (required) + parseable name: tl00$city + field signature: 794505091 + form signature: 7628530229511417656" +autofill-prediction="ADDRESS_HOME_CITY" +> + </div> + <div> + <label for="stateProvince">State (required)</label> + <select name="ctl00$ctl00$MainContentRoot$Body$ctl00$stateProvince" id="stateProvince" +title="overall type: ADDRESS_HOME_STATE + server type: NO_SERVER_DATA + heuristic type: ADDRESS_HOME_STATE + label: State (required) + parseable name: tl00$stateProvince + field signature: 548222440 + form signature: 7628530229511417656" +autofill-prediction="ADDRESS_HOME_STATE" +> + <option selected="selected" value="">Choose a state</option> + <option value="AL-US">AL-Alabama</option> + <option value="AK-US">AK-Alaska</option> + <option value="AS-AS">AS-American Samoa</option> + <option value="AZ-US">AZ-Arizona</option> + <option value="AR-US">AR-Arkansas</option> + <option value="AE-US">AE-Armed Forces Africa</option> + <option value="AA-US">AA-Armed Forces Americas</option> + <option value="AE-US">AE-Armed Forces Canada</option> + <option value="AE-US">AE-Armed Forces Europe</option> + <option value="AE-US">AE-Armed Forces Middle East</option> + <option value="AP-US">AP-Armed Forces Pacific</option> + <option value="CA-US">CA-California</option> + <option value="CO-US">CO-Colorado</option> + <option value="CT-US">CT-Connecticut</option> + <option value="DE-US">DE-Delaware</option> + <option value="DC-US">DC-District of Columbia</option> + <option value="FM-FM">FM-Federated States of Micronesia</option> + <option value="FL-US">FL-Florida</option> + <option value="GA-US">GA-Georgia</option> + <option value="GU-GU">GU-Guam</option> + <option value="HI-US">HI-Hawaii</option> + <option value="ID-US">ID-Idaho</option> + <option value="IL-US">IL-Illinois</option> + <option value="IN-US">IN-Indiana</option> + <option value="IA-US">IA-Iowa</option> + <option value="KS-US">KS-Kansas</option> + <option value="KY-US">KY-Kentucky</option> + <option value="LA-US">LA-Louisiana</option> + <option value="ME-US">ME-Maine</option> + <option value="MH-MH">MH-Marshall Islands</option> + <option value="MD-US">MD-Maryland</option> + <option value="MA-US">MA-Massachusetts</option> + <option value="MI-US">MI-Michigan</option> + <option value="MN-US">MN-Minnesota</option> + <option value="MS-US">MS-Mississippi</option> + <option value="MO-US">MO-Missouri</option> + <option value="MT-US">MT-Montana</option> + <option value="NE-US">NE-Nebraska</option> + <option value="NV-US">NV-Nevada</option> + <option value="NH-US">NH-New Hampshire</option> + <option value="NJ-US">NJ-New Jersey</option> + <option value="NM-US">NM-New Mexico</option> + <option value="NY-US">NY-New York</option> + <option value="NC-US">NC-North Carolina</option> + <option value="ND-US">ND-North Dakota</option> + <option value="MP-MP">MP-Norther Mariana Islands</option> + <option value="OH-US">OH-Ohio</option> + <option value="OK-US">OK-Oklahoma</option> + <option value="OR-US">OR-Oregon</option> + <option value="PA-US">PA-Pennsylvania</option> + <option value="PR-PR">PR-Puerto Rico</option> + <option value="PW-PW">PW-Palau</option> + <option value="RI-US">RI-Rhode Island</option> + <option value="SC-US">SC-South Carolina</option> + <option value="SD-US">SD-South Dakota</option> + <option value="TN-US">TN-Tennessee</option> + <option value="TX-US">TX-Texas</option> + <option value="UT-US">UT-Utah</option> + <option value="VT-US">VT-Vermont</option> + <option value="VI-US">VI-Virgin Islands</option> + <option value="VA-US">VA-Virginia</option> + <option value="WA-US">WA-Washington</option> + <option value="WV-US">WV-West Virginia</option> + <option value="WI-US">WI-Wisconsin</option> + <option value="WY-US">WY-Wyoming</option> + </select> + </div> + <div> + <label for="zipCode">ZIP Code (required)</label> + <input name="ctl00$ctl00$MainContentRoot$Body$ctl00$zipCode" type="text" id="zipCode" maxlength="5" +title="overall type: ADDRESS_HOME_ZIP + server type: ADDRESS_HOME_ZIP + heuristic type: ADDRESS_HOME_ZIP + label: ZIP Code (required) + parseable name: tl00$zipCode + field signature: 4227103349 + form signature: 7628530229511417656" +autofill-prediction="ADDRESS_HOME_ZIP" +> + </div> + <div> + <label for="zipCodeExtn">ZIP Extn</label> + <input name="ctl00$ctl00$MainContentRoot$Body$ctl00$zipCodeExtn" type="text" id="zipCodeExtn" maxlength="4" +title="overall type: UNKNOWN_TYPE + server type: NO_SERVER_DATA + heuristic type: UNKNOWN_TYPE + label: ZIP Extn + parseable name: tl00$zipCodeExtn + field signature: 2328453303 + form signature: 7628530229511417656" +autofill-prediction="UNKNOWN_TYPE" +> + </div> + </div> + </div> + <div> + <div>We will only contact you about your order and shipping.</div> + <div> + <div> + <label for="contactEmail">Email (required)</label> + <input name="ctl00$ctl00$MainContentRoot$Body$contactEmail" type="text" id="contactEmail" maxlength="75" +title="overall type: EMAIL_ADDRESS + server type: EMAIL_ADDRESS + heuristic type: EMAIL_ADDRESS + label: Email (required) + parseable name: ontactEmail + field signature: 123947042 + form signature: 7628530229511417656" +autofill-prediction="EMAIL_ADDRESS" +> + </div> + <div> + <label for="contactPhoneNumber">Phone (required)</label> + <input name="ctl00$ctl00$MainContentRoot$Body$contactPhoneNumber" type="text" id="contactPhoneNumber" maxlength="15" +title="overall type: PHONE_HOME_CITY_AND_NUMBER + server type: PHONE_HOME_CITY_AND_NUMBER + heuristic type: PHONE_HOME_WHOLE_NUMBER + label: Phone (required) + parseable name: ontactPhoneNumber + field signature: 1588916982 + form signature: 7628530229511417656" +autofill-prediction="PHONE_HOME_CITY_AND_NUMBER" +> + </div> + <div> + <label for="contactPhoneExtension">Extn </label> + <input name="ctl00$ctl00$MainContentRoot$Body$contactPhoneExtension" type="text" id="contactPhoneExtension" maxlength="5" +title="overall type: PHONE_HOME_CITY_CODE + server type: PHONE_HOME_CITY_CODE + heuristic type: PHONE_HOME_EXTENSION + label: Extn + parseable name: ontactPhoneExtension + field signature: 1782290665 + form signature: 7628530229511417656" +autofill-prediction="PHONE_HOME_CITY_CODE" +> + </div> + </div> + </div> + </div> + </div> + <button id="saveButton" type="button">Next</button> + <input type="text" name="Representative" id="Representative" value="" +title="overall type: ADDRESS_HOME_STREET_ADDRESS + server type: ADDRESS_HOME_STREET_ADDRESS + heuristic type: ADDRESS_HOME_LINE1 + label: Shipping Address Next + parseable name: Representative + field signature: 716948211 + form signature: 7628530229511417656" +autofill-prediction="ADDRESS_HOME_STREET_ADDRESS" +> + </div> + <div> + <ul> + <li id="shippingAddressStep"> + <a id="shippingAddressEdit">Edit</a> + <div id="shippingAddressStepDetails"> + <div> + Address: + </div> + <div> + <div> + <span id="shippingAddressFirstName"> +</span> + <span id="shippingAddressLastName"> +</span> + </div> + <div> + <span id="shippingCompany"> +</span> + </div> + <div id="shippingAddressLine1"> +</div> + <div id="shippingAddressLine2"> +</div> + <div> + <span id="shippingAddressCity"> +</span>, + <span id="shippingAddressState"> +</span> + <span id="shippingAddressPostalCode"> +</span> + </div> + </div> + <div> + <div> + Contact Info: + </div> + <div> + <div> + <span id="contactEmail"> +</span> + </div> + <div> + <span id="contactPhone"> +</span> + </div> + </div> + </div> + </div> + </li> + <li id="shippingMethodStep"> + <a id="shippingMethodEdit">Edit</a> + <div id="shippingMethodStepDetails"> + <div>Shipping Method</div> + <div> + <div id="shippingMethodName">-</div> + <div id="shippingMethodDesc">-</div> + <div id="shippingMethodCost">-</div> + </div> + </div> + </li> + <li id="billingAndPaymentStep"> + <a id="billingAndPaymentEdit">Edit</a> + <div id="billingAndPaymentStepDetails"> + <div> + Billing Address + </div> + <div> + <div> + <span id="billingAddressFirstName"> +</span> + <span id="billingAddressLastName"> +</span> + </div> + <div id="billingAddressEmail"> +</div> + <div id="billingAddressLine1"> +</div> + <div id="billingAddressLine2"> +</div> + <div> + <span id="billingAddressCity"> +</span>, + <span id="billingAddressState"> +</span> + <span id="billingAddressPostalCode"> +</span> + </div> + </div> + <div>Payment Method</div> + <div id="paymentMethod"> +</div> + </div> + </li> + <li> + </li> + </ul> + </div> + </div> + <input id="__RequestVerificationTokencw" name="__RequestVerificationTokencw" type="hidden"> + </form> + <div> + <input type="hidden" id="HdnFreeShippingProductCartIndicator" clientidmode="static" value="0"> + </div> + </body> +</html> diff --git a/browser/extensions/formautofill/test/fixtures/third_party/CostCo/Payment.html b/browser/extensions/formautofill/test/fixtures/third_party/CostCo/Payment.html new file mode 100644 index 0000000000..8b11a6a49e --- /dev/null +++ b/browser/extensions/formautofill/test/fixtures/third_party/CostCo/Payment.html @@ -0,0 +1,892 @@ +<!DOCTYPE html> +<html lang="en"> + <head> + <meta http-equiv="Content-Type" content="text/html; charset=UTF-8"> + <meta feature="9.1.4"> + <title>Costco - Payment</title> + <meta name="currentBuildNumber" content="3.0.29057.0"> + <meta name="ServerName" content="www.costco.com "> + <meta name="LocalAddress" content="xxx.xxx.xxx.48"> + <meta name="LocalName" content="TP26"> + </head> + <body> + <form name="CheckoutPaymentForm" id="CheckoutPaymentForm" method="post" action="https://www.costco.com/CostcoBillingPayment"> + <input type="hidden" name="selfAddressId" id="hiddenSelfAddressId" value=""> + <input type="hidden" id="billHiddenInput0" name="billHideenInput" value=""> + <input type="hidden" id="billAddrId" name="billAddrId" value=""> + <input type="hidden" id="membershipNumber" name="membershipNum"> + <input type="hidden" name="orderItemsCount" value="1"> + <input type="hidden" value="" id="selectedAddressId" name="selectedAddressId"> + <input type="hidden" name="orderId" value="644156669" id="WC_CheckoutPaymentsAndBillingAddressf_orderId"> + <input type="hidden" name="storeId" value="10301" id="CheckoutPayment_inputs_1"> + <input type="hidden" name="catalogId" value="10701" id="CheckoutPayment_inputs_2"> + <input type="hidden" name="langId" value="-1" id="CheckoutPayment_inputs_3"> + <input type="hidden" name="curr_year" value="2017" id="CheckoutPayment_inputs_5"> + <input type="hidden" name="curr_month" value="3" id="CheckoutPayment_inputs_6"> + <input type="hidden" name="curr_date" value="19" id="CheckoutPayment_inputs_7"> + <input type="hidden" name="URL" value="OrderPrepare?URL=CheckoutReviewView"> + <input type="hidden" name="xCreditCardId" value=""> + <input type="hidden" name="ccLastFour" value=""> + <input type="hidden" name="checkCCValue" value="false"> + <input type="hidden" name="backURL" value=""> + <input type="hidden" name="piAmount" value="84.99000"> + <input type="hidden" name="cardNumberValueHolder" value=""> + <input type="hidden" name="authToken" value="312"> + <div> + <label for="billMeLater"> + <input type="radio" checked="checked" id="billMeLater" name="billMeLater" value="no" +title="overall type: UNKNOWN_TYPE + server type: NO_SERVER_DATA + heuristic type: UNKNOWN_TYPE + label: Payment Method + parseable name: billMeLater + field signature: 1700925171 + form signature: 4979691664972472743" +autofill-prediction="UNKNOWN_TYPE" +> + </label> + <br> + <div> + <div> + <label for="payMethodId">Card Type</label> + <select name="payMethodId" id="payMethodId" +title="overall type: CREDIT_CARD_TYPE + server type: CREDIT_CARD_TYPE + heuristic type: CREDIT_CARD_TYPE + label: Card Type + parseable name: payMethodId + field signature: 3668211827 + form signature: 4979691664972472743" +autofill-prediction="CREDIT_CARD_TYPE" +> + <option value="Costco Credit Card">Costco Credit Card</option> + <option value="Discover">Discover</option> + <option value="Master Card">MasterCard</option> + <option value="VISA" selected="selected">VISA</option> + </select> + </div> + <div> + <label for="account">Card number</label> + <input +title="overall type: CREDIT_CARD_NUMBER + server type: NO_SERVER_DATA + heuristic type: CREDIT_CARD_NUMBER + label: Card number + parseable name: account + field signature: 3715653537 + form signature: 4979691664972472743" type="text" id="account" name="account" value="" autocomplete="off" +autofill-prediction="CREDIT_CARD_NUMBER" +> + </div> + </div> + <div> + <div> + <label for="expire_month">Expiration Date</label> + <select +title="overall type: CREDIT_CARD_EXP_MONTH + server type: CREDIT_CARD_EXP_MONTH + heuristic type: CREDIT_CARD_EXP_MONTH + label: Expiration Date + parseable name: expire_month + field signature: 3078539387 + form signature: 4979691664972472743" id="expire_month" name="expire_month" +autofill-prediction="CREDIT_CARD_EXP_MONTH" +> + <option value="Month">Month</option> + <option value="01">1</option> + <option value="02">2</option> + <option value="03">3</option> + <option value="04">4</option> + <option value="05">5</option> + <option value="06">6</option> + <option value="07">7</option> + <option value="08">8</option> + <option value="09">9</option> + <option value="10">10</option> + <option value="11">11</option> + <option value="12">12</option> + </select> + <select +title="overall type: CREDIT_CARD_EXP_4_DIGIT_YEAR + server type: CREDIT_CARD_EXP_4_DIGIT_YEAR + heuristic type: CREDIT_CARD_EXP_4_DIGIT_YEAR + label: Expiration Date + parseable name: expire_year + field signature: 2521850425 + form signature: 4979691664972472743" name="expire_year" +autofill-prediction="CREDIT_CARD_EXP_4_DIGIT_YEAR" +> + <option value="Year">Year</option> + <option value="2017">2017</option> + <option value="2018">2018</option> + <option value="2019">2019</option> + <option value="2020">2020</option> + <option value="2021">2021</option> + <option value="2022">2022</option> + <option value="2023">2023</option> + <option value="2024">2024</option> + <option value="2025">2025</option> + <option value="2026">2026</option> + <option value="2027">2027</option> + </select> + </div> + <div> + <label for="cc_cvc">CVV Code</label> + <input +title="overall type: CREDIT_CARD_VERIFICATION_CODE + server type: NO_SERVER_DATA + heuristic type: CREDIT_CARD_VERIFICATION_CODE + label: CVV Code + parseable name: cc_cvc + field signature: 1956128288 + form signature: 4979691664972472743" type="text" id="cc_cvc" name="cc_cvc" value="" maxlength="4" autocomplete="off" +autofill-prediction="CREDIT_CARD_VERIFICATION_CODE" +> + <span> <span>?</span> +</span> + </div> + </div> + <div> + <label for="cc_nameoncard">Cardholder Name</label> + <input +title="overall type: CREDIT_CARD_NAME_FULL + server type: CREDIT_CARD_NAME_FULL + heuristic type: CREDIT_CARD_NAME_FULL + label: Cardholder Name + parseable name: cc_nameoncard + field signature: 1086986730 + form signature: 4979691664972472743" type="text" id="cc_nameoncard" name="cc_nameoncard" value="" autocomplete="off" +autofill-prediction="CREDIT_CARD_NAME_FULL" +> + </div> + <div> + <input +title="overall type: UNKNOWN_TYPE + server type: NO_SERVER_DATA + heuristic type: UNKNOWN_TYPE + label: Save this as my default payment card + parseable name: save_CC + field signature: 3060743727 + form signature: 4979691664972472743" type="checkbox" name="save_CC" id="save_CC" +autofill-prediction="UNKNOWN_TYPE" +> + <label for="save_CC">Save this as my default payment card</label> + </div> + </div> + </form> + <form name="CashCardForm" method="post" action="https://www.costco.com/CostcoCashCardProcess" id="CashCardForm"> + <input type="hidden" name="storeId" value="10301"> + <input type="hidden" name="langId" value="-1"> + <input type="hidden" name="orderId" value="644156669"> + <input type="hidden" name="catalogId" value="10701"> + <input type="hidden" name="addressId" value=""> + <input type="hidden" name="cc_payMethodId" value="CostcoCashCard"> + <input type="hidden" name="action" value=""> + <input type="hidden" name="URL" value="CheckoutPaymentView"> + <input type="hidden" name="account" value=""> + <input type="hidden" name="expire_month" value=""> + <input type="hidden" name="expire_year" value=""> + <input type="hidden" name="cc_nameoncard" value=""> + <input type="hidden" name="payMethodId" value=""> + <input type="hidden" name="xCreditCardId" value=""> + <input type="hidden" name="ccLastFour" value=""> + <input type="hidden" name="authToken" value="312404731%2cKsqvty%2bpMJ%2bCAl3XeIkCxSEgLa4%3d"> + <div> + <div> + <label for="cash_account">Costco Cash Card Number</label> + <input +title="Costco Cash Card Number" type="text" id="cash_account" name="cash_account" value="" maxlength="19" autocomplete="off"> + </div> + <div> + <label for="cash_pin">PIN</label> + <input +title="PIN" type="password" id="cash_pin" name="cash_pin" value="" maxlength="8" autocomplete="off"> + <span> <span>?</span> +</span> + <div> + <span> More Info - Costco Cash Card</span> + <span> + <i>Costco Cash</i> Card Number</span> + <span>This image highlights the unique number</span> + <img src="./Costco - Payment_files/cashcard-us.gif" alt="cashcard-us.gif"> + <span>used to identify your <i>Costco Cash</i> card.</span> + <span> Pin Number</span> + <span>This number is used to access your</span> + <span> + <i>Costco Cash</i> card. The image shows</span> + <span>where this number is located.</span> + <span>@ 1998-2016 Costco Wholesale Corporation. All rights reserved.</span> + </div> + </div> + </div> + </form> + <form name="RefreshBilling" method="post" action="https://www.costco.com/CostcoBillingPayment" id="RefreshBilling"> + <input type="hidden" name="storeId" value="10301"> + <input type="hidden" name="langId" value="-1"> + <input type="hidden" name="orderId" value="644156669"> + <input type="hidden" name="catalogId" value="10701"> + <input type="hidden" name="actionType" value="refresh"> + <input type="hidden" name="authToken" value="312404731%2cKsqvty%2bpMJ%2bCAl3XeIkCxSEgLa4%3d"> + <input type="hidden" name="deviceId" value=""> + </form> + <form name="PromotionCodeForm" method="post" action="https://www.costco.com/CostcoManagePromotionCmd" id="PromotionCodeForm"> + <input type="hidden" name="storeId" value="10301"> + <input type="hidden" name="langId" value="-1"> + <input type="hidden" name="orderId" value="644156669"> + <input type="hidden" name="catalogId" value="10701"> + <input type="hidden" name="taskType" value="A"> + <input type="hidden" name="URL" value="OrderCalculate?updatePrices=1&calculationUsageId=-1&URL=OrderPrepare?URL=CostcoPostPromotionCodeAddRemove&orderId=."> + <input type="hidden" name="errorViewName" value="CheckoutPaymentView"> + <input type="hidden" name="account" value=""> + <input type="hidden" name="expire_month" value=""> + <input type="hidden" name="expire_year" value=""> + <input type="hidden" name="cc_nameoncard" value=""> + <input type="hidden" name="payMethodId" value=""> + <input type="hidden" name="xCreditCardId" value=""> + <input type="hidden" name="ccLastFour" value=""> + <input type="hidden" name="checkCCValue" value="false"> + <input type="hidden" id="billHiddenInput0" name="billHideenInput" value=""> + <input type="hidden" id="billAddrId" name="billAddrId" value=""> + <input type="hidden" name="addressId" value=""> + <input type="hidden" name="cc_cvc" value=""> + <input type="hidden" name="piAmount" value="84.99000"> + <div> + <label for="PromotionCodeForm_1">Promo Code</label> + <input +title="Promo Code" type="text" size="10" name="promoCode" id="PromotionCodeForm_1" value=""> + </div> + </form> + <form id="AddressFormModal-Form" name="AddressFormModal-Form" autocomplete="on" method="post"> + <div> + <input type="hidden" name="addressType" value="B"> + <p id="addressFormModalRequired" tabindex="-1"> +<span>*</span> Required fields</p> + <div id="personName"> + <div> + <label for="addressFormModalFirstName">FIRST NAME<span>*</span> +</label> + <input id="addressFormModalFirstName" name="addressFormModalFirstName" +title="overall type: NAME_FIRST + server type: NAME_FIRST + heuristic type: NAME_FIRST + label: FIRST NAME* + parseable name: addressFormModalFirstName + field signature: 2222266781 + form signature: 8397269939060577503" type="text" maxlength="40" +autofill-prediction="NAME_FIRST" +> + </div> + <div> + <label for="addressFormModalMiddleInitial">M.I.</label> + <input id="addressFormModalMiddleInitial" name="addressFormModalMiddleInitial" +title="overall type: NAME_MIDDLE_INITIAL + server type: NAME_MIDDLE_INITIAL + heuristic type: NAME_MIDDLE_INITIAL + label: M.I. + parseable name: addressFormModalMiddleInitial + field signature: 3540652809 + form signature: 8397269939060577503" type="text" maxlength="1" +autofill-prediction="NAME_MIDDLE_INITIAL" +> + </div> + <div> + <label for="addressFormModalLastName">LAST NAME<span>*</span> +</label> + <input id="addressFormModalLastName" name="addressFormModalLastName" +title="overall type: NAME_LAST + server type: NAME_LAST + heuristic type: NAME_LAST + label: LAST NAME* + parseable name: addressFormModalLastName + field signature: 4218996568 + form signature: 8397269939060577503" type="text" maxlength="40" +autofill-prediction="NAME_LAST" +> + </div> + </div> + <div> + <label for="addressFormModalCompany">COMPANY NAME</label> + <input id="addressFormModalCompany" name="addressFormModalCompany" type="text" maxlength="40" +title="overall type: COMPANY_NAME + server type: COMPANY_NAME + heuristic type: COMPANY_NAME + label: COMPANY NAME + parseable name: addressFormModalCompany + field signature: 1845178698 + form signature: 8397269939060577503" +autofill-prediction="COMPANY_NAME" +> + </div> + <div> + <label for="addressFormModalCountry">COUNTRY<span>*</span> +</label> + <select id="addressFormModalCountry" name="addressFormModalCountry" +title="overall type: ADDRESS_HOME_COUNTRY + server type: ADDRESS_HOME_COUNTRY + heuristic type: ADDRESS_HOME_COUNTRY + label: COUNTRY* + parseable name: addressFormModalCountry + field signature: 4052501735 + form signature: 8397269939060577503" +autofill-prediction="ADDRESS_HOME_COUNTRY" +> + <option value="CA">Canada</option> + <option value="US">United States</option> + </select> + </div> + <div id="streetAddress"> + <legend> + <label for="addressFormModalAddressLine1">STREET ADDRESS<span>*</span> +</label> + </legend> + <div> + <input id="addressFormModalAddressLine1" name="addressFormModalAddressLine1" placeholder="Address Line 1" type="text" maxlength="30" +title="overall type: ADDRESS_HOME_LINE1 + server type: ADDRESS_HOME_LINE1 + heuristic type: ADDRESS_HOME_LINE1 + label: STREET ADDRESS* + parseable name: addressFormModalAddressLine1 + field signature: 1532865404 + form signature: 8397269939060577503" +autofill-prediction="ADDRESS_HOME_LINE1" +> + </div> + <div> + <input id="addressFormModalAddressLine2" name="addressFormModalAddressLine2" placeholder="Address Line 2" type="text" maxlength="30" +title="overall type: ADDRESS_HOME_LINE2 + server type: ADDRESS_HOME_LINE2 + heuristic type: ADDRESS_HOME_LINE2 + label: Address Line 2 + parseable name: addressFormModalAddressLine2 + field signature: 2315514959 + form signature: 8397269939060577503" +autofill-prediction="ADDRESS_HOME_LINE2" +> + </div> + </div> + <div id="city"> + <label for="addressFormModalCity">CITY<span>*</span> +</label> + <input id="addressFormModalCity" name="addressFormModalCity" type="text" maxlength="40" +title="overall type: ADDRESS_HOME_CITY + server type: ADDRESS_HOME_CITY + heuristic type: ADDRESS_HOME_CITY + label: CITY* + parseable name: addressFormModalCity + field signature: 4130865920 + form signature: 8397269939060577503" +autofill-prediction="ADDRESS_HOME_CITY" +> + </div> + <div id="stateAndZip"> + <div> + <label for="addressFormModalState">STATE / PROVINCE<span>*</span> +</label> + <select id="addressFormModalState" name="addressFormModalState" +title="overall type: ADDRESS_HOME_STATE + server type: ADDRESS_HOME_STATE + heuristic type: ADDRESS_HOME_STATE + label: STATE / PROVINCE* + parseable name: addressFormModalState + field signature: 4026908515 + form signature: 8397269939060577503" +autofill-prediction="ADDRESS_HOME_STATE" +> + <option value="NO_STATE_TYPE_SELECTED" selected="selected">* Select</option> + <option value="Aa">AA - Armed Forces America</option> + <option value="Ae">AE - Armed Forces Europe</option> + <option value="AL">Alabama</option> + <option value="AK">Alaska</option> + <option value="Ap">AP - Armed Forces Pacific</option> + <option value="AZ">Arizona</option> + <option value="AR">Arkansas</option> + <option value="CA">California</option> + <option value="CO">Colorado</option> + <option value="CT">Connecticut</option> + <option value="DE">Delaware</option> + <option value="DC">District of Columbia</option> + <option value="FL">Florida</option> + <option value="GA">Georgia</option> + <option value="HI">Hawaii</option> + <option value="ID">Idaho</option> + <option value="IL">Illinois</option> + <option value="IN">Indiana</option> + <option value="IA">Iowa</option> + <option value="KS">Kansas</option> + <option value="KY">Kentucky</option> + <option value="LA">Louisiana</option> + <option value="ME">Maine</option> + <option value="MD">Maryland</option> + <option value="MA">Massachusetts</option> + <option value="MI">Michigan</option> + <option value="MN">Minnesota</option> + <option value="MS">Mississippi</option> + <option value="MO">Missouri</option> + <option value="MT">Montana</option> + <option value="NE">Nebraska</option> + <option value="NV">Nevada</option> + <option value="NH">New Hampshire</option> + <option value="NJ">New Jersey</option> + <option value="NM">New Mexico</option> + <option value="NY">New York</option> + <option value="NC">North Carolina</option> + <option value="ND">North Dakota</option> + <option value="OH">Ohio</option> + <option value="OK">Oklahoma</option> + <option value="OR">Oregon</option> + <option value="PA">Pennsylvania</option> + <option value="PR">Puerto Rico</option> + <option value="RI">Rhode Island</option> + <option value="SC">South Carolina</option> + <option value="SD">South Dakota</option> + <option value="TN">Tennessee</option> + <option value="TX">Texas</option> + <option value="UT">Utah</option> + <option value="VT">Vermont</option> + <option value="VA">Virginia</option> + <option value="WA">Washington</option> + <option value="WV">West Virginia</option> + <option value="WI">Wisconsin</option> + <option value="WY">Wyoming</option> + </select> + </div> + <div> + <label for="addressFormModalZip">ZIP / POSTAL CODE<span>*</span> +</label> + <input id="addressFormModalZip" name="addressFormModalZip" type="text" maxlength="10" +title="overall type: ADDRESS_HOME_ZIP + server type: ADDRESS_HOME_ZIP + heuristic type: ADDRESS_HOME_ZIP + label: ZIP / POSTAL CODE* + parseable name: addressFormModalZip + field signature: 2383002781 + form signature: 8397269939060577503" +autofill-prediction="ADDRESS_HOME_ZIP" +> + </div> + </div> + <div id="phoneNumber"> + <label for="addressFormModalPhoneNumber">PHONE NUMBER<span>*</span> +</label> + <input id="addressFormModalPhoneNumber" name="addressFormModalPhoneNumber" type="text" maxlength="32" +title="overall type: PHONE_HOME_CITY_AND_NUMBER + server type: PHONE_HOME_CITY_AND_NUMBER + heuristic type: PHONE_HOME_WHOLE_NUMBER + label: PHONE NUMBER* + parseable name: addressFormModalPhoneNumber + field signature: 1884423068 + form signature: 8397269939060577503" +autofill-prediction="PHONE_HOME_CITY_AND_NUMBER" +> + </div> + <div id="email"> + <label for="addressFormModalEmail" id="addressFormModalEmailLabel">EMAIL<span>*</span> +</label> + <input id="addressFormModalEmail" name="addressFormModalEmail" type="text" maxlength="40" +title="overall type: EMAIL_ADDRESS + server type: EMAIL_ADDRESS + heuristic type: EMAIL_ADDRESS + label: EMAIL* + parseable name: addressFormModalEmail + field signature: 1977954575 + form signature: 8397269939060577503" +autofill-prediction="EMAIL_ADDRESS" +> + </div> + <div id="addressNickname"> + <label for="addressFormModalAddressNickName">ADDRESS NICKNAME<span>*</span> + <span> <span>?</span> +</span> + <span>The Address Nickname is a short name you create to help you easily identify this address within your address book.</span> + </label> + <input id="addressFormModalAddressNickName" name="addressFormModalAddressNickName" type="text" maxlength="35" placeholder="Holly at school, Mom, etc." +title="overall type: ADDRESS_HOME_STREET_ADDRESS + server type: ADDRESS_HOME_STREET_ADDRESS + heuristic type: UNKNOWN_TYPE + label: ADDRESS NICKNAME* The Address Nickname is a short name you create to help you easily identify this a + parseable name: addressFormModalAddressNickName + field signature: 605446661 + form signature: 8397269939060577503" +autofill-prediction="ADDRESS_HOME_STREET_ADDRESS" +> + </div> + <div> + <input name="saveAddressCheckbox" id="saveAddressCheckbox" type="checkbox" +title="overall type: UNKNOWN_TYPE + server type: NO_SERVER_DATA + heuristic type: UNKNOWN_TYPE + label: Add to address book. + parseable name: saveAddressCheckbox + field signature: 784127875 + form signature: 8397269939060577503" +autofill-prediction="UNKNOWN_TYPE" +> + <label for="saveAddressCheckbox">Add to address book. </label> + </div> + <div> + <input name="setDefaultCheckbox" id="setDefaultCheckbox" type="checkbox" disabled="true" +title="overall type: UNKNOWN_TYPE + server type: NO_SERVER_DATA + heuristic type: UNKNOWN_TYPE + label: Save as default shipping address in Address Book + parseable name: setDefaultCheckbox + field signature: 1479095059 + form signature: 8397269939060577503" +autofill-prediction="UNKNOWN_TYPE" +> + <label for="setDefaultCheckbox" id="setDefaultCheckboxModalLabel">Save as default billing address in Address Book</label> + </div> + </div> + </form> + <form id="AddressFormInline-Form" name="AddressFormInline-Form" autocomplete="on" method="post"> + <div> + <input type="hidden" name="addressType" value="B"> + <p id="addressFormInlineRequired" tabindex="-1"> +<span>*</span> Required fields</p> + <div id="personName"> + <div> + <label for="addressFormInlineFirstName">FIRST NAME<span>*</span> +</label> + <div> + <input id="addressFormInlineFirstName" name="addressFormInlineFirstName" +title="overall type: NAME_FIRST + server type: NAME_FIRST + heuristic type: NAME_FIRST + label: FIRST NAME* + parseable name: addressFormInlineFirstName + field signature: 3938958812 + form signature: 16870043504464996221" type="text" maxlength="40" +autofill-prediction="NAME_FIRST" +> + </div> + </div> + <div> + <label for="addressFormInlineMiddleInitial">M.I.</label> + <div> + <input id="addressFormInlineMiddleInitial" name="addressFormInlineMiddleInitial" +title="overall type: NAME_MIDDLE_INITIAL + server type: NAME_MIDDLE_INITIAL + heuristic type: NAME_MIDDLE_INITIAL + label: M.I. + parseable name: addressFormInlineMiddleInitial + field signature: 3429701181 + form signature: 16870043504464996221" type="text" maxlength="1" +autofill-prediction="NAME_MIDDLE_INITIAL" +> + </div> + </div> + <div> + <label for="addressFormInlineLastName">LAST NAME<span>*</span> +</label> + <div> + <input id="addressFormInlineLastName" name="addressFormInlineLastName" +title="overall type: NAME_LAST + server type: NAME_LAST + heuristic type: NAME_LAST + label: LAST NAME* + parseable name: addressFormInlineLastName + field signature: 2108416564 + form signature: 16870043504464996221" type="text" maxlength="40" +autofill-prediction="NAME_LAST" +> + </div> + </div> + </div> + <div id="city"> + <label for="addressFormInlineCompany">COMPANY NAME</label> + <div> + <input id="addressFormInlineCompany" name="addressFormInlineCompany" type="text" maxlength="40" +title="overall type: COMPANY_NAME + server type: COMPANY_NAME + heuristic type: COMPANY_NAME + label: COMPANY NAME + parseable name: addressFormInlineCompany + field signature: 4087238350 + form signature: 16870043504464996221" +autofill-prediction="COMPANY_NAME" +> + </div> + </div> + <div> + <label for="addressFormInlineCountry">COUNTRY<span>*</span> +</label> + <div> + <select id="addressFormInlineCountry" name="addressFormInlineCountry" +title="overall type: ADDRESS_HOME_COUNTRY + server type: ADDRESS_HOME_COUNTRY + heuristic type: ADDRESS_HOME_COUNTRY + label: COUNTRY* + parseable name: addressFormInlineCountry + field signature: 695762362 + form signature: 16870043504464996221" +autofill-prediction="ADDRESS_HOME_COUNTRY" +> + <option value="CA">Canada</option> + <option value="US">United States</option> + </select> + </div> + </div> + <div id="streetAddress"> + <legend> + <label for="addressFormInlineAddressLine1">STREET ADDRESS<span>*</span> +</label> + </legend> + <div> + <input id="addressFormInlineAddressLine1" name="addressFormInlineAddressLine1" placeholder="Address Line 1" type="text" maxlength="30" +title="overall type: ADDRESS_HOME_LINE1 + server type: ADDRESS_HOME_LINE1 + heuristic type: ADDRESS_HOME_LINE1 + label: STREET ADDRESS* + parseable name: addressFormInlineAddressLine1 + field signature: 1040409778 + form signature: 16870043504464996221" +autofill-prediction="ADDRESS_HOME_LINE1" +> + </div> + <input id="addressFormInlineAddressLine2" name="addressFormInlineAddressLine2" placeholder="Address Line 2" type="text" maxlength="30" +title="overall type: ADDRESS_HOME_LINE2 + server type: ADDRESS_HOME_LINE2 + heuristic type: ADDRESS_HOME_LINE2 + label: Address Line 2 + parseable name: addressFormInlineAddressLine2 + field signature: 1640842807 + form signature: 16870043504464996221" +autofill-prediction="ADDRESS_HOME_LINE2" +> + </div> + <div id="city"> + <label for="addressFormInlineCity">CITY<span>*</span> +</label> + <div> + <input id="addressFormInlineCity" name="addressFormInlineCity" type="text" maxlength="40" +title="overall type: ADDRESS_HOME_CITY + server type: ADDRESS_HOME_CITY + heuristic type: ADDRESS_HOME_CITY + label: CITY* + parseable name: addressFormInlineCity + field signature: 2829321141 + form signature: 16870043504464996221" +autofill-prediction="ADDRESS_HOME_CITY" +> + </div> + </div> + <div id="state"> + <div> + <label for="addressFormInlineState">STATE / PROVINCE<span>*</span> +</label> + <div> + <select id="addressFormInlineState" name="addressFormInlineState" +title="overall type: ADDRESS_HOME_STATE + server type: ADDRESS_HOME_STATE + heuristic type: ADDRESS_HOME_STATE + label: STATE / PROVINCE* + parseable name: addressFormInlineState + field signature: 3295167441 + form signature: 16870043504464996221" +autofill-prediction="ADDRESS_HOME_STATE" +> + <option value="NO_STATE_TYPE_SELECTED" selected="selected">* Select</option> + <option value="Aa">AA - Armed Forces America</option> + <option value="Ae">AE - Armed Forces Europe</option> + <option value="AL">Alabama</option> + <option value="AK">Alaska</option> + <option value="Ap">AP - Armed Forces Pacific</option> + <option value="AZ">Arizona</option> + <option value="AR">Arkansas</option> + <option value="CA">California</option> + <option value="CO">Colorado</option> + <option value="CT">Connecticut</option> + <option value="DE">Delaware</option> + <option value="DC">District of Columbia</option> + <option value="FL">Florida</option> + <option value="GA">Georgia</option> + <option value="HI">Hawaii</option> + <option value="ID">Idaho</option> + <option value="IL">Illinois</option> + <option value="IN">Indiana</option> + <option value="IA">Iowa</option> + <option value="KS">Kansas</option> + <option value="KY">Kentucky</option> + <option value="LA">Louisiana</option> + <option value="ME">Maine</option> + <option value="MD">Maryland</option> + <option value="MA">Massachusetts</option> + <option value="MI">Michigan</option> + <option value="MN">Minnesota</option> + <option value="MS">Mississippi</option> + <option value="MO">Missouri</option> + <option value="MT">Montana</option> + <option value="NE">Nebraska</option> + <option value="NV">Nevada</option> + <option value="NH">New Hampshire</option> + <option value="NJ">New Jersey</option> + <option value="NM">New Mexico</option> + <option value="NY">New York</option> + <option value="NC">North Carolina</option> + <option value="ND">North Dakota</option> + <option value="OH">Ohio</option> + <option value="OK">Oklahoma</option> + <option value="OR">Oregon</option> + <option value="PA">Pennsylvania</option> + <option value="PR">Puerto Rico</option> + <option value="RI">Rhode Island</option> + <option value="SC">South Carolina</option> + <option value="SD">South Dakota</option> + <option value="TN">Tennessee</option> + <option value="TX">Texas</option> + <option value="UT">Utah</option> + <option value="VT">Vermont</option> + <option value="VA">Virginia</option> + <option value="WA">Washington</option> + <option value="WV">West Virginia</option> + <option value="WI">Wisconsin</option> + <option value="WY">Wyoming</option> + </select> + </div> + </div> + <div> + <label for="addressFormInlineZip">ZIP / POSTAL CODE<span>*</span> +</label> + <div> + <input id="addressFormInlineZip" name="addressFormInlineZip" type="text" maxlength="10" +title="overall type: ADDRESS_HOME_ZIP + server type: ADDRESS_HOME_ZIP + heuristic type: ADDRESS_HOME_ZIP + label: ZIP / POSTAL CODE* + parseable name: addressFormInlineZip + field signature: 3060672026 + form signature: 16870043504464996221" +autofill-prediction="ADDRESS_HOME_ZIP" +> + </div> + </div> + </div> + <div id="phoneNumber"> + <label for="addressFormInlinePhoneNumber">PHONE NUMBER<span>*</span> +</label> + <div> + <input id="addressFormInlinePhoneNumber" name="addressFormInlinePhoneNumber" type="text" maxlength="32" +title="overall type: PHONE_HOME_CITY_AND_NUMBER + server type: PHONE_HOME_CITY_AND_NUMBER + heuristic type: PHONE_HOME_WHOLE_NUMBER + label: PHONE NUMBER* + parseable name: addressFormInlinePhoneNumber + field signature: 1198968276 + form signature: 16870043504464996221" +autofill-prediction="PHONE_HOME_CITY_AND_NUMBER" +> + </div> + </div> + <div id="email"> + <label for="addressFormInlineEmail" id="addressFormInlineEmailLabel">EMAIL<span>*</span> +</label> + <div> + <input id="addressFormInlineEmail" name="addressFormInlineEmail" type="text" maxlength="40" value="" +title="overall type: EMAIL_ADDRESS + server type: EMAIL_ADDRESS + heuristic type: EMAIL_ADDRESS + label: EMAIL* + parseable name: addressFormInlineEmail + field signature: 2460631353 + form signature: 16870043504464996221" +autofill-prediction="EMAIL_ADDRESS" +> + </div> + </div> + <div id="addressNickname"> + <label for="addressFormInlineAddressNickName">ADDRESS NICKNAME<span>*</span> +<span> <span>?</span> +</span> +<span>The Address Nickname is a short name you create to help you easily identify this address within your address book.</span> +</label> + <div> + <input id="addressFormInlineAddressNickName" name="addressFormInlineAddressNickName" type="text" maxlength="35" placeholder="Holly at school, Mom, etc." +title="overall type: UNKNOWN_TYPE + server type: NO_SERVER_DATA + heuristic type: UNKNOWN_TYPE + label: ADDRESS NICKNAME* The Address Nickname is a short name you create to help you easily identify this a + parseable name: addressFormInlineAddressNickName + field signature: 2948011243 + form signature: 16870043504464996221" +autofill-prediction="UNKNOWN_TYPE" +> + </div> + </div> + <div> + <input name="saveAddressCheckboxInline" id="saveAddressCheckboxInline" type="checkbox" +title="overall type: UNKNOWN_TYPE + server type: NO_SERVER_DATA + heuristic type: UNKNOWN_TYPE + label: Add to address book. + parseable name: saveAddressCheckboxInline + field signature: 3323717546 + form signature: 16870043504464996221" +autofill-prediction="UNKNOWN_TYPE" checked="checked" +> + <label for="saveAddressCheckboxInline">Add to address book. </label> + </div> + <div> + <input name="setDefaultCheckboxInline" id="setDefaultCheckboxInline" type="checkbox" +title="overall type: UNKNOWN_TYPE + server type: NO_SERVER_DATA + heuristic type: UNKNOWN_TYPE + label: Save as default shipping address in Address Book + parseable name: setDefaultCheckboxInline + field signature: 2923970107 + form signature: 16870043504464996221" +autofill-prediction="UNKNOWN_TYPE" +> + <label for="setDefaultCheckboxInline" id="setDefaultCheckboxInlineLabel">Save as default billing address in Address Book</label> + </div> + <div id="defaultAddressChangeInline"> + <div id="WC_ContentAreaESpot_div_1_rx-DefaultAddrConfirm"> + <div id="WC_ContentAreaESpot_div_2_rx-DefaultAddrConfirm">[rx-DefaultAddrConfirm]</div> + <div> + <ul> + <li value="1"> + You are changing your Costco Default Shipping Address. All future orders from Costco.com, including Pharmacy Prescription Orders, will be sent to this Address. + </li> + </ul> + </div> + </div> + </div> + <div id="button-container"> + <div> + <div> + <button id="addressFormInlineButton" type="button"> +<span> +<span>Save Address</span> +</span> +</button> + </div> + </div> + </div> + </div> + </form> + <div id="footer-find-warehouse-block"> + <label for="footer-search-field">Find a Warehouse</label> + <form id="WarehouseSearchForm" action="https://www.costco.com/warehouse-locations" novalidate="novalidate"> + <div> + <input id="footer-search-field" type="search" name="location" tabindex="1" placeholder="City, state or zip" value="" +title="Search"> + <input type="submit" id="searchClear" value="Clear"> + <input type="hidden" id="fromWLocSubmit" name="fromWLocSubmit" value="true"> + <input type="hidden" id="numOfWarehouses" name="numOfWarehouses" value="10"> + </div> + </form> + </div> + <div id="footer-email-offers-block"> + <label for="footer-email-offers">Get Email Offers</label> + <form +title="" action="https://www.costco.com/EmailSubscription" id="EmailOffersForm"> + <div> + <input type="text" name="emailSignUp" id="footer-email-offers" placeholder="Enter your email"> + <span> + <button type="submit" alt="Go">Go</button> + </span> + </div> + </form> + </div> + <input type="hidden" name="typeAheadDisabled" id="typeAheadDisabled" value="false"> + </body> +</html> diff --git a/browser/extensions/formautofill/test/fixtures/third_party/CostCo/ShippingAddress.html b/browser/extensions/formautofill/test/fixtures/third_party/CostCo/ShippingAddress.html new file mode 100644 index 0000000000..1740ebb84d --- /dev/null +++ b/browser/extensions/formautofill/test/fixtures/third_party/CostCo/ShippingAddress.html @@ -0,0 +1,527 @@ +<!DOCTYPE html> +<html lang="en"> + <head> + <meta name="generator" content="HTML Tidy for HTML5 for Mac OS X version 5.4.0"> + <meta http-equiv="Content-Type" content="text/html; charset=utf-8"> + <meta feature="9.1.2"> + <title>Shipping</title> + <meta name="currentBuildNumber" content="3.0.29057.0"> + <meta name="ServerName" content="www.costco.com"> + <meta name="LocalAddress" content="xxx.xxx.xxx.48"> + <meta name="LocalName" content="TP26"> + </head> + <body> + <form name="ShipAsCompleteForm" method="post" action="https://www.costco.com/CostcoMultiShippingCmd" id="ShipAsCompleteForm"> + <input type="hidden" name="storeId" value="10301"> + <input type="hidden" name="langId" value="-1"> + <input type="hidden" name="orderId" value="644156669"> + <input type="hidden" name="catalogId" value="10701"> + <input type="hidden" name="multiAddressShipping" value="false"> + <input type="hidden" name="URL" value="CheckoutPaymentView"> + </form> + <form name="NewAddressSingleShippingForm" method="post" action="https://www.costco.com/CostcoSelectShippingCmd" id="NewAddressSingleShippingForm"> + <input type="hidden" name="storeId" value="10301"> + <input type="hidden" name="langId" value="-1"> + <input type="hidden" name="catalogId" value="10701"> + <input type="hidden" name="action" value="SingleShipping"> + <input type="hidden" name="addressId" value=""> + <input type="hidden" name="authToken" value=""> + </form> + <form id="AddressFormModal-Form" name="AddressFormModal-Form" autocomplete="on" method="post"> + <div> + <input type="hidden" name="addressType" value="S"> + <p id="addressFormModalRequired" tabindex="-1"> + <span>*</span> Required fields</p> + <div id="personName"> + <div> +<label for="addressFormModalFirstName">FIRST NAME<span>*</span> +</label> + <input id="addressFormModalFirstName" name="addressFormModalFirstName" +title="overall type: NAME_FIRST server type: NAME_FIRST heuristic type: NAME_FIRST label: FIRST NAME* parseable name: addressFormModalFirstName field signature: 2222266781 form signature: 8397269939060577503" type="text" maxlength="40" +autofill-prediction="NAME_FIRST" +> + </div> + <div> +<label for="addressFormModalMiddleInitial">M.I.</label> + <input id="addressFormModalMiddleInitial" name="addressFormModalMiddleInitial" +title="overall type: NAME_MIDDLE_INITIAL server type: NAME_MIDDLE_INITIAL heuristic type: NAME_MIDDLE_INITIAL label: M.I. parseable name: addressFormModalMiddleInitial field signature: 3540652809 form signature: 8397269939060577503" type="text" maxlength="1" +autofill-prediction="NAME_MIDDLE_INITIAL" +> + </div> + <div> +<label for="addressFormModalLastName">LAST NAME<span>*</span> +</label> + <input id="addressFormModalLastName" name="addressFormModalLastName" +title="overall type: NAME_LAST server type: NAME_LAST heuristic type: NAME_LAST label: LAST NAME* parseable name: addressFormModalLastName field signature: 4218996568 form signature: 8397269939060577503" type="text" maxlength="40" +autofill-prediction="NAME_LAST" +> + </div> + </div> + <div> +<label for="addressFormModalCompany">COMPANY NAME</label> + <input id="addressFormModalCompany" name="addressFormModalCompany" type="text" maxlength="40" +title="overall type: COMPANY_NAME server type: COMPANY_NAME heuristic type: COMPANY_NAME label: COMPANY NAME parseable name: addressFormModalCompany field signature: 1845178698 form signature: 8397269939060577503" +autofill-prediction="COMPANY_NAME" +> + </div> + <div> + <label for="addressFormModalCountry">COUNTRY<span>*</span> +</label> + <select id="addressFormModalCountry" name="addressFormModalCountry" +title="overall type: ADDRESS_HOME_COUNTRY server type: ADDRESS_HOME_COUNTRY heuristic type: ADDRESS_HOME_COUNTRY label: COUNTRY* parseable name: addressFormModalCountry field signature: 4052501735 form signature: 8397269939060577503" +autofill-prediction="ADDRESS_HOME_COUNTRY" +> + <option value="US">United States</option> + </select> + </div> + <div id="streetAddress"> + <legend> +<label for="addressFormModalAddressLine1">STREET ADDRESS<span>*</span> +</label> +</legend> + <div> + <input id="addressFormModalAddressLine1" name="addressFormModalAddressLine1" placeholder="Address Line 1" type="text" maxlength="30" +title="overall type: ADDRESS_HOME_LINE1 server type: ADDRESS_HOME_LINE1 heuristic type: ADDRESS_HOME_LINE1 label: STREET ADDRESS* parseable name: addressFormModalAddressLine1 field signature: 1532865404 form signature: 8397269939060577503" +autofill-prediction="ADDRESS_HOME_LINE1" +> + </div> + <div> + <input id="addressFormModalAddressLine2" name="addressFormModalAddressLine2" placeholder="Address Line 2" type="text" maxlength="30" +title="overall type: ADDRESS_HOME_LINE2 server type: ADDRESS_HOME_LINE2 heuristic type: ADDRESS_HOME_LINE2 label: Address Line 2 parseable name: addressFormModalAddressLine2 field signature: 2315514959 form signature: 8397269939060577503" +autofill-prediction="ADDRESS_HOME_LINE2" +> + </div> + </div> + <div id="city"> +<label for="addressFormModalCity">CITY<span>*</span> +</label> + <input id="addressFormModalCity" name="addressFormModalCity" type="text" maxlength="40" +title="overall type: ADDRESS_HOME_CITY server type: ADDRESS_HOME_CITY heuristic type: ADDRESS_HOME_CITY label: CITY* parseable name: addressFormModalCity field signature: 4130865920 form signature: 8397269939060577503" +autofill-prediction="ADDRESS_HOME_CITY" +> + </div> + <div id="stateAndZip"> + <div> + <label for="addressFormModalState">STATE / PROVINCE<span>*</span> +</label> + <select id="addressFormModalState" name="addressFormModalState" +title="overall type: ADDRESS_HOME_STATE server type: ADDRESS_HOME_STATE heuristic type: ADDRESS_HOME_STATE label: STATE / PROVINCE* parseable name: addressFormModalState field signature: 4026908515 form signature: 8397269939060577503" +autofill-prediction="ADDRESS_HOME_STATE" +> + <option value="NO_STATE_TYPE_SELECTED" selected="selected">* Select</option> + <option value="Aa">AA - Armed Forces America</option> + <option value="Ae">AE - Armed Forces Europe</option> + <option value="AL"> Alabama</option> + <option value="AK"> Alaska</option> + <option value="Ap">AP - Armed Forces Pacific</option> + <option value="AZ"> Arizona</option> + <option value="AR"> Arkansas</option> + <option value="CA"> California</option> + <option value="CO"> Colorado</option> + <option value="CT"> Connecticut</option> + <option value="DE"> Delaware</option> + <option value="DC"> District of Columbia</option> + <option value="FL"> Florida</option> + <option value="GA"> Georgia</option> + <option value="HI"> Hawaii</option> + <option value="ID"> Idaho</option> + <option value="IL"> Illinois</option> + <option value="IN"> Indiana</option> + <option value="IA"> Iowa</option> + <option value="KS"> Kansas</option> + <option value="KY"> Kentucky</option> + <option value="LA"> Louisiana</option> + <option value="ME"> Maine</option> + <option value="MD"> Maryland</option> + <option value="MA"> Massachusetts</option> + <option value="MI"> Michigan</option> + <option value="MN"> Minnesota</option> + <option value="MS"> Mississippi</option> + <option value="MO"> Missouri</option> + <option value="MT"> Montana</option> + <option value="NE"> Nebraska</option> + <option value="NV"> Nevada</option> + <option value="NH">New Hampshire</option> + <option value="NJ">New Jersey</option> + <option value="NM">New Mexico</option> + <option value="NY">New York</option> + <option value="NC">North Carolina</option> + <option value="ND">North Dakota</option> + <option value="OH"> Ohio</option> + <option value="OK"> Oklahoma</option> + <option value="OR"> Oregon</option> + <option value="PA"> Pennsylvania</option> + <option value="PR">Puerto Rico</option> + <option value="RI">Rhode Island</option> + <option value="SC">South Carolina</option> + <option value="SD">South Dakota</option> + <option value="TN"> Tennessee</option> + <option value="TX"> Texas</option> + <option value="UT"> Utah</option> + <option value="VT"> Vermont</option> + <option value="VA"> Virginia</option> + <option value="WA"> Washington</option> + <option value="WV">West Virginia</option> + <option value="WI"> Wisconsin</option> + <option value="WY"> Wyoming</option> + </select> + </div> + <div> +<label for="addressFormModalZip">ZIP / POSTAL CODE<span>*</span> +</label> + <input id="addressFormModalZip" name="addressFormModalZip" type="text" maxlength="10" +title="overall type: ADDRESS_HOME_ZIP server type: ADDRESS_HOME_ZIP heuristic type: ADDRESS_HOME_ZIP label: ZIP / POSTAL CODE* parseable name: addressFormModalZip field signature: 2383002781 form signature: 8397269939060577503" +autofill-prediction="ADDRESS_HOME_ZIP" +> + </div> + </div> + <div id="phoneNumber"> +<label for="addressFormModalPhoneNumber">PHONE NUMBER<span>*</span> +</label> + <input id="addressFormModalPhoneNumber" name="addressFormModalPhoneNumber" type="text" maxlength="32" +title="overall type: PHONE_HOME_CITY_AND_NUMBER server type: PHONE_HOME_CITY_AND_NUMBER heuristic type: PHONE_HOME_WHOLE_NUMBER label: PHONE NUMBER* parseable name: addressFormModalPhoneNumber field signature: 1884423068 form signature: 8397269939060577503" +autofill-prediction="PHONE_HOME_CITY_AND_NUMBER" +> + </div> + <div id="email"> +<label for="addressFormModalEmail" id="addressFormModalEmailLabel">EMAIL<span>*</span> +</label> + <input id="addressFormModalEmail" name="addressFormModalEmail" type="text" maxlength="40" +title="overall type: EMAIL_ADDRESS server type: EMAIL_ADDRESS heuristic type: EMAIL_ADDRESS label: EMAIL* parseable name: addressFormModalEmail field signature: 1977954575 form signature: 8397269939060577503" +autofill-prediction="EMAIL_ADDRESS" +> + </div> + <div id="addressNickname"> +<label for="addressFormModalAddressNickName">ADDRESS NICKNAME<span>*</span> +<span> <span>?</span> +</span> +<span>The Address Nickname is a short name you create to help you easily identify this address within your address book.</span> +</label> + <input id="addressFormModalAddressNickName" name="addressFormModalAddressNickName" type="text" maxlength="35" placeholder="Holly at school, Mom, etc." +title="overall type: ADDRESS_HOME_STREET_ADDRESS server type: ADDRESS_HOME_STREET_ADDRESS heuristic type: UNKNOWN_TYPE label: ADDRESS NICKNAME* The Address Nickname is a short name you create to help you easily identify this a parseable name: addressFormModalAddressNickName field signature: 605446661 form signature: 8397269939060577503" +autofill-prediction="ADDRESS_HOME_STREET_ADDRESS" +> + </div> + <div> + <input name="saveAddressCheckbox" id="saveAddressCheckbox" type="checkbox" +title="overall type: UNKNOWN_TYPE server type: NO_SERVER_DATA heuristic type: UNKNOWN_TYPE label: Add to address book. parseable name: saveAddressCheckbox field signature: 784127875 form signature: 8397269939060577503" +autofill-prediction="UNKNOWN_TYPE" +> +<label for="saveAddressCheckbox">Add to address book.</label> + </div> + <div> + <input name="setDefaultCheckbox" id="setDefaultCheckbox" type="checkbox" disabled="true" +title="overall type: UNKNOWN_TYPE server type: NO_SERVER_DATA heuristic type: UNKNOWN_TYPE label: Save as default shipping address in Address Book parseable name: setDefaultCheckbox field signature: 1479095059 form signature: 8397269939060577503" +autofill-prediction="UNKNOWN_TYPE" +> +<label for="setDefaultCheckbox" id="setDefaultCheckboxModalLabel">Save as default shipping address in Address Book</label> + </div> + </div> + </form> + <form id="AddressFormInline-Form" name="AddressFormInline-Form" autocomplete="on" method="post"> + <div> + <input type="hidden" name="addressType" value="S"> + <p id="addressFormInlineRequired" tabindex="-1"> +<span>*</span> Required fields</p> + <div id="personName"> + <div> + <label for="addressFormInlineFirstName">FIRST NAME<span>*</span> +</label> + <div> + <input id="addressFormInlineFirstName" name="addressFormInlineFirstName" +title="overall type: NAME_FIRST server type: NAME_FIRST heuristic type: NAME_FIRST label: FIRST NAME* parseable name: addressFormInlineFirstName field signature: 3938958812 form signature: 9207149805122018522" type="text" maxlength="40" +autofill-prediction="NAME_FIRST" +> + </div> + </div> + <div> + <label for="addressFormInlineMiddleInitial">M.I.</label> + <div> + <input id="addressFormInlineMiddleInitial" name="addressFormInlineMiddleInitial" +title="overall type: NAME_MIDDLE_INITIAL server type: NAME_MIDDLE_INITIAL heuristic type: NAME_MIDDLE_INITIAL label: M.I. parseable name: addressFormInlineMiddleInitial field signature: 3429701181 form signature: 9207149805122018522" type="text" maxlength="1" +autofill-prediction="NAME_MIDDLE_INITIAL" +> + </div> + </div> + <div> + <label for="addressFormInlineLastName">LAST NAME<span>*</span> +</label> + <div> + <input id="addressFormInlineLastName" name="addressFormInlineLastName" +title="overall type: NAME_LAST server type: NAME_LAST heuristic type: NAME_LAST label: LAST NAME* parseable name: addressFormInlineLastName field signature: 2108416564 form signature: 9207149805122018522" type="text" maxlength="40" +autofill-prediction="NAME_LAST" +> + </div> + </div> + </div> + <div id="city"> + <label for="addressFormInlineCompany">COMPANY NAME</label> + <div> + <input id="addressFormInlineCompany" name="addressFormInlineCompany" type="text" maxlength="40" +title="overall type: COMPANY_NAME server type: COMPANY_NAME heuristic type: COMPANY_NAME label: COMPANY NAME parseable name: addressFormInlineCompany field signature: 4087238350 form signature: 9207149805122018522" +autofill-prediction="COMPANY_NAME" +> + </div> + </div> + <div> + <label for="addressFormInlineCountry">COUNTRY<span>*</span> +</label> + <div> + <select id="addressFormInlineCountry" name="addressFormInlineCountry" +title="overall type: ADDRESS_HOME_COUNTRY server type: ADDRESS_HOME_COUNTRY heuristic type: ADDRESS_HOME_COUNTRY label: COUNTRY* parseable name: addressFormInlineCountry field signature: 695762362 form signature: 9207149805122018522" +autofill-prediction="ADDRESS_HOME_COUNTRY" +> + <option value="US">United States</option> + </select> + </div> + </div> + <div id="streetAddress"> + <legend> +<label for="addressFormInlineAddressLine1">STREET ADDRESS<span>*</span> +</label> + </legend> + <div> + <input id="addressFormInlineAddressLine1" name="addressFormInlineAddressLine1" placeholder="Address Line 1" type="text" maxlength="30" +title="overall type: ADDRESS_HOME_LINE1 server type: ADDRESS_HOME_LINE1 heuristic type: ADDRESS_HOME_LINE1 label: STREET ADDRESS* parseable name: addressFormInlineAddressLine1 field signature: 1040409778 form signature: 9207149805122018522" +autofill-prediction="ADDRESS_HOME_LINE1" +> + </div> + <input id="addressFormInlineAddressLine2" name="addressFormInlineAddressLine2" placeholder="Address Line 2" type="text" maxlength="30" +title="overall type: ADDRESS_HOME_LINE2 server type: ADDRESS_HOME_LINE2 heuristic type: ADDRESS_HOME_LINE2 label: Address Line 2 parseable name: addressFormInlineAddressLine2 field signature: 1640842807 form signature: 9207149805122018522" +autofill-prediction="ADDRESS_HOME_LINE2" +> + </div> + <div id="city"> + <label for="addressFormInlineCity">CITY<span>*</span> +</label> + <div> + <input id="addressFormInlineCity" name="addressFormInlineCity" type="text" maxlength="40" +title="overall type: ADDRESS_HOME_CITY server type: ADDRESS_HOME_CITY heuristic type: ADDRESS_HOME_CITY label: CITY* parseable name: addressFormInlineCity field signature: 2829321141 form signature: 9207149805122018522" +autofill-prediction="ADDRESS_HOME_CITY" +> + </div> + </div> + <div id="state"> + <div> + <label for="addressFormInlineState">STATE / PROVINCE<span>*</span> +</label> + <div> + <select id="addressFormInlineState" name="addressFormInlineState" +title="overall type: ADDRESS_HOME_STATE server type: ADDRESS_HOME_STATE heuristic type: ADDRESS_HOME_STATE label: STATE / PROVINCE* parseable name: addressFormInlineState field signature: 3295167441 form signature: 9207149805122018522" +autofill-prediction="ADDRESS_HOME_STATE" +> + <option value="NO_STATE_TYPE_SELECTED" selected="selected">* Select</option> + <option value="Aa">AA - Armed Forces America</option> + <option value="Ae">AE - Armed Forces Europe</option> + <option value="AL"> Alabama</option> + <option value="AK"> Alaska</option> + <option value="Ap">AP - Armed Forces Pacific</option> + <option value="AZ"> Arizona</option> + <option value="AR"> Arkansas</option> + <option value="CA"> California</option> + <option value="CO"> Colorado</option> + <option value="CT"> Connecticut</option> + <option value="DE"> Delaware</option> + <option value="DC"> District of Columbia</option> + <option value="FL"> Florida</option> + <option value="GA"> Georgia</option> + <option value="HI"> Hawaii</option> + <option value="ID"> Idaho</option> + <option value="IL"> Illinois</option> + <option value="IN"> Indiana</option> + <option value="IA"> Iowa</option> + <option value="KS"> Kansas</option> + <option value="KY"> Kentucky</option> + <option value="LA"> Louisiana</option> + <option value="ME"> Maine</option> + <option value="MD"> Maryland</option> + <option value="MA"> Massachusetts</option> + <option value="MI"> Michigan</option> + <option value="MN"> Minnesota</option> + <option value="MS"> Mississippi</option> + <option value="MO"> Missouri</option> + <option value="MT"> Montana</option> + <option value="NE"> Nebraska</option> + <option value="NV"> Nevada</option> + <option value="NH">New Hampshire</option> + <option value="NJ">New Jersey</option> + <option value="NM">New Mexico</option> + <option value="NY">New York</option> + <option value="NC">North Carolina</option> + <option value="ND">North Dakota</option> + <option value="OH"> Ohio</option> + <option value="OK"> Oklahoma</option> + <option value="OR"> Oregon</option> + <option value="PA"> Pennsylvania</option> + <option value="PR">Puerto Rico</option> + <option value="RI">Rhode Island</option> + <option value="SC">South Carolina</option> + <option value="SD">South Dakota</option> + <option value="TN"> Tennessee</option> + <option value="TX"> Texas</option> + <option value="UT"> Utah</option> + <option value="VT"> Vermont</option> + <option value="VA"> Virginia</option> + <option value="WA"> Washington</option> + <option value="WV">West Virginia</option> + <option value="WI"> Wisconsin</option> + <option value="WY"> Wyoming</option> + </select> + </div> + </div> + <div> + <label for="addressFormInlineZip">ZIP / POSTAL CODE<span>*</span> +</label> + <div> + <input id="addressFormInlineZip" name="addressFormInlineZip" type="text" maxlength="10" +title="overall type: ADDRESS_HOME_ZIP server type: ADDRESS_HOME_ZIP heuristic type: ADDRESS_HOME_ZIP label: ZIP / POSTAL CODE* parseable name: addressFormInlineZip field signature: 3060672026 form signature: 9207149805122018522" +autofill-prediction="ADDRESS_HOME_ZIP" +> + </div> + </div> + </div> + <div id="phoneNumber"> + <label for="addressFormInlinePhoneNumber">PHONE NUMBER<span>*</span> +</label> + <div> + <input id="addressFormInlinePhoneNumber" name="addressFormInlinePhoneNumber" type="text" maxlength="32" +title="overall type: PHONE_HOME_CITY_AND_NUMBER server type: PHONE_HOME_CITY_AND_NUMBER heuristic type: PHONE_HOME_WHOLE_NUMBER label: PHONE NUMBER* parseable name: addressFormInlinePhoneNumber field signature: 1198968276 form signature: 9207149805122018522" +autofill-prediction="PHONE_HOME_CITY_AND_NUMBER" +> + </div> + </div> + <div id="email"> + <label for="addressFormInlineEmail" id="addressFormInlineEmailLabel">EMAIL<span>*</span> +</label> + <div> + <input id="addressFormInlineEmail" name="addressFormInlineEmail" type="text" maxlength="40" value="" +title="overall type: EMAIL_ADDRESS server type: EMAIL_ADDRESS heuristic type: EMAIL_ADDRESS label: EMAIL* parseable name: addressFormInlineEmail field signature: 2460631353 form signature: 9207149805122018522" +autofill-prediction="EMAIL_ADDRESS" +> + </div> + </div> + <div id="addressNickname"> + <label for="addressFormInlineAddressNickName">ADDRESS NICKNAME<span>*</span> +<span> <span>?</span> +</span> +<span>The Address Nickname is a short name you create to help you easily identify this address within your address book.</span> +</label> + <div> + <input id="addressFormInlineAddressNickName" name="addressFormInlineAddressNickName" type="text" maxlength="35" placeholder="Holly at school, Mom, etc." +title="overall type: UNKNOWN_TYPE server type: NO_SERVER_DATA heuristic type: UNKNOWN_TYPE label: ADDRESS NICKNAME* ?The Address Nickname is a short name you create to help you easily identify this parseable name: addressFormInlineAddressNickName field signature: 2948011243 form signature: 9207149805122018522" +autofill-prediction="UNKNOWN_TYPE" +> + </div> + </div> + <div> + <input name="saveAddressCheckboxInline" id="saveAddressCheckboxInline" type="checkbox" +title="overall type: UNKNOWN_TYPE server type: NO_SERVER_DATA heuristic type: UNKNOWN_TYPE label: Add to address book. parseable name: saveAddressCheckboxInline field signature: 3323717546 form signature: 9207149805122018522" +autofill-prediction="UNKNOWN_TYPE" checked="checked" +> +<label for="saveAddressCheckboxInline">Add to address book.</label> + </div> + <div> + <input name="setDefaultCheckboxInline" id="setDefaultCheckboxInline" type="checkbox" +title="overall type: UNKNOWN_TYPE server type: NO_SERVER_DATA heuristic type: UNKNOWN_TYPE label: Save as default shipping address in Address Book parseable name: setDefaultCheckboxInline field signature: 2923970107 form signature: 9207149805122018522" +autofill-prediction="UNKNOWN_TYPE" +> +<label for="setDefaultCheckboxInline" id="setDefaultCheckboxInlineLabel">Save as default shipping address in Address Book</label> + </div> + <div id="defaultAddressChangeInline"> + <div id="WC_ContentAreaESpot_div_1_rx-DefaultAddrConfirm"> + <div id="WC_ContentAreaESpot_div_2_rx-DefaultAddrConfirm"> + [rx-DefaultAddrConfirm] + </div> + <div> + <ul> + <li value="1"> You are changing your Costco Default Shipping Address. All future orders from Costco.com, including Pharmacy Prescription Orders, will be sent to this Address. + </li> + </ul> + </div> + </div> + </div> + <div> + <input name="copyShippingCheckboxInline" id="copyShippingCheckboxInline" type="checkbox" +title="overall type: UNKNOWN_TYPE server type: NO_SERVER_DATA heuristic type: UNKNOWN_TYPE label: Use as my default billing address parseable name: copyShippingCheckboxInline field signature: 1184640612 form signature: 9207149805122018522" +autofill-prediction="UNKNOWN_TYPE" +> +<label for="copyShippingCheckboxInline" id="copyShippingCheckboxInlineLabel">Use as my default billing address</label> + </div> + <div id="billingNicknameDiv"> + <label>Billing Address Nickname + <span>*</span> +<span> <span>?</span> +</span> +<span>The Address Nickname is a short name you create to help you easily identify this address within your address book.</span> +</label> + <input type="text" id="billingNickname" name="billingNickname" maxlength="40" value="New Billing" +title="overall type: ADDRESS_HOME_STREET_ADDRESS server type: ADDRESS_HOME_STREET_ADDRESS heuristic type: UNKNOWN_TYPE label: Billing Address Nickname * ?The Address Nickname is a short name you create to help you easily ident parseable name: billingNickname field signature: 4200328961 form signature: 9207149805122018522" +autofill-prediction="ADDRESS_HOME_STREET_ADDRESS" +> + </div> + <div id="button-container"> + <div> + <div> +<button id="addressFormInlineButton" type="button"> + <span> +<span>Ship to this Address</span> +</span> +</button> + </div> + </div> + </div> + </div> + </form> + <div id="footer-find-warehouse-block"> + <label for="footer-search-field" >Find a Warehouse</label> + <form id="WarehouseSearchForm" action="https://www.costco.com/warehouse-locations" novalidate="novalidate" name="WarehouseSearchForm"> + <div> + <input id="footer-search-field" type="search" name="location" tabindex="1" placeholder="City, state or zip" value="" +title="Search"> + <input type="submit" id="searchClear" value="Clear"> + <input type="hidden" id="fromWLocSubmit" name="fromWLocSubmit" value="true"> + <input type="hidden" id="numOfWarehouses" name="numOfWarehouses" value="10"> + </div> + </form> + </div> + <div id="footer-email-offers-block"> + <label for="footer-email-offers">Get Email Offers</label> + <form +title="" action="https://www.costco.com/EmailSubscription" id="EmailOffersForm" name="EmailOffersForm"> + <div> + <input type="text" name="emailSignUp" id="footer-email-offers" placeholder="Enter your email"> +<span> +<button type="submit" alt="Go"> +<span> +<span>Go</span> +</span> +</button> +</span> + </div> + </form> + </div> + <div> + <label>Follow Us</label> + <ul> + <li> +<a> +<label>facebook</label> +</a> +</li> + <li> +<a> +<label>pinterest</label> +</a> +</li> + </ul> + </div> + <form name="SingleShippingForm" method="post" action="https://www.costco.com/CostcoSelectShippingCmd" id="SingleShippingForm"> + <input type="hidden" name="storeId" value="10301"> + <input type="hidden" name="langId" value="-1"> + <input type="hidden" name="catalogId" value="10701"> + <input type="hidden" name="action" value="SingleShipping"> + <input type="hidden" name="addressId" value=""> + <input type="hidden" name="authToken" value=""> + </form> + </body> +</html> diff --git a/browser/extensions/formautofill/test/fixtures/third_party/CostCo/SignIn.html b/browser/extensions/formautofill/test/fixtures/third_party/CostCo/SignIn.html new file mode 100644 index 0000000000..afcd5fe6f0 --- /dev/null +++ b/browser/extensions/formautofill/test/fixtures/third_party/CostCo/SignIn.html @@ -0,0 +1,374 @@ +<!DOCTYPE html> +<html lang="en"> + <head> + <meta name="generator" content="HTML Tidy for HTML5 for Mac OS X version 5.4.0"> + <meta http-equiv="Content-Type" content="text/html; charset=utf-8"> + <title>Sign In</title> + <meta name="currentBuildNumber" content="3.0.29057.0"> + <meta name="ServerName" content="www.costco.com"> + <meta name="LocalAddress" content="xxx.xxx.xxx.48"> + <meta name="LocalName" content="TP26"> + </head> + <body waid71fa0d88-5390-4b5b-a2f4-e45fa93d85e2="SA password protect entry checker"> + <form action="https://www.costco.com/CatalogSearch"> + <input type="submit" value="Submit"> +<label for="search-field">Search</label> + <div> + <label>Search Icon</label> +<span style=/"position: relative; display: inline-block;"> + <input type="text" tabindex="-1" +title="Search" readonly autocomplete="off" spellcheck="false" dir="ltr"> + <input id="search-field" type="text" name="keyword" tabindex="1" placeholder="Search Costco" +title="Search" autocomplete="off" spellcheck="false" dir="auto" > + </span> + <div> + <div> +</div> + </div> + </div> + <input type="submit" value="Submit" tabindex="-1"> + </form> + <form id="warehouse_locator_search" action='https://www.costco.com/warehouse-locations'> + <div> + <input id="warehouse-search-field" +title="Warehouse Search Field" name="location" type="search" value=""/> + </div> + <input type="hidden" name="tiresCheckout" value="" /> + <input type="hidden" name="orderitemId" value="" /> + <input type="hidden" name="storeId" value="10301" /> + <input type="hidden" name="catalogId" value="10701" /> + <input type="hidden" name="fromPage" value="" /> + <label for="locator_search_filters">Show Warehouses with:</label> + <div id="locator_search_filters"> + <div> + <div> + <input id="hasGas" type="checkbox" name="hasGas" value="true" +title="Gas Station" /> + <label for="hasGas" +title="Gas Station"> +<i> +</i> + <span>Gas Station</span> + </label> + </div> + <div> + <input id="hasTires" type="checkbox" name="hasTires" value="true" +title="Tire Service" /> + <label for="hasTires" +title="Tire Service"> +<i> +</i> + <span>Tire Center</span> + </label> + </div> + <div> + <input id="hasFood" type="checkbox" name="hasFood" value="true" +title="Food Court" /> + <label for="hasFood" +title="Food Court"> +<i> +</i> + <span>Food Court</span> + </label> + </div> + <div> + <input id="hasHearing" type="checkbox" name="hasHearing" value="true" +title="Hearing Aids" /> + <label for="hasHearing" +title="Hearing Aids"> +<i> +</i> + <span>Hearing Aids</span> + </label> + </div> + </div> + <div> + <div> + <input id="hasOptical" type="checkbox" name="hasOptical" value="true" +title="Optical Dept" /> + <label for="hasOptical" +title="Optical Dept"> +<i> +</i> + <span>Optical</span> + </label> + </div> + <div> + <input id="hasPharmacy" type="checkbox" name="hasPharmacy" value="true" +title="Pharmacy" /> + <label for="hasPharmacy" +title="Pharmacy"> +<i> +</i> + <span>Pharmacy</span> + </label> + </div> + <div> + <input id="hasBusiness" type="checkbox" name="hasBusiness" value="true" +title="Business" /> + <label for="hasBusiness" +title="Business"> +<i> +</i> + <span>Business Center</span> + </label> + </div> + </div> + </div> + <input type="hidden" id="fromWLocSubmit" name="fromWLocSubmit" value="true" /> + <input type="hidden" id="numOfWarehouses" name="numOfWarehouses" value="10" /> + <input type="submit" value="Find a Warehouse"/> + </form> + <div id="email-offer-popover-container"> + <label for="header_emailSignUpEmail">Get Email Offers</label> + <label>Sign up for great offers from Costco.com!</label> + <form +title="" action="/EmailSubscription" id="header_emailSignup"> + <div> + <input id="header_emailSignUpEmail" type="text" name="emailSignUp" placeholder="Enter your email"> + <span> + <button type="submit" alt="Go">Go</button> + </span> + </div> + </form> + </div> + <form action="https://www.costco.com/Logoff?URL=TopCategoriesDisplay"> + <li> + <input type="submit" value="Sign Out"/> + </li> + </form> + <form action="/EmailSubscription"> + <div> + <label for="modal_email_offers">Sign up for great offers from Costco.com!</label> + <input type="text" id="modal_email_offers" name="emailSignUp" placeholder="Enter your email"/> + </div> + </form> + <form +title="" name="LogonForm" method="post" action="https://www.costco.com/Logon" id="LogonForm"> + <input type="hidden" name="storeId" value="10301" id="WC_AccountDisplay_FormInput_storeId_In_Logon_1"> +<input type="hidden" name="catalogId" value="10701" id="WC_AccountDisplay_FormInput_catalogId_In_Logon_1"> +<input type="hidden" name="langId" value="-1" id="WC_AccountDisplay_FormInput_langId_In__1"> +<input type="hidden" name="reLogonURL" value="LogonForm" id="WC_AccountDisplay_FormInput_reLogonURL_In_Logon_1"> +<input type="hidden" name="isPharmacy" value="" id="WC_AccountDisplay_FormInput_isPharmacy_In_Logon_1"> +<input type="hidden" name="authToken" value="312404731%2cKsqvty%2bpMJ%2bCAl3XeIkCxSEgLa4%3d"> +<input type="hidden" name="URL" value="CheckOutCmd?orderId=644156669&storeId=10301&storeId=10301&authToken=312404731%252cKsqvty%252bpMJ%252bCAl3XeIkCxSEgLa4%253d&authToken=312404731%252cKsqvty%252bpMJ%252bCAl3XeIkCxSEgLa4%253d&orderErrMsgObj=%7B%7D&itemMessage=1.0&langId=-1&langId=-1&catalogId=10701&catalogId=10701" id="WC_AccountDisplay_FormInput_URL_In_Logon_1"> + <p>Please provide your email address and password to access your account.† + </p> + <div> +<label for="logonId">Email Address<span>*</span> +</label> +<input id="logonId" name="logonId" maxlength="254" type="text" +title="Email Address" value=""> +<br> + </div> + <div> + <label for="logonPassword">Password:<span>*</span> +</label> + <input name="logonPassword" id="logonPassword" maxlength="40" type="password" autocomplete="off" +title="Password:"> + <p>Passwords are case sensitive.</p> + <br> + </div> + <div> + <input id="option1" name="option1" type="checkbox"> +<label for="option1">Remember me</label> + </div> + <input type="hidden" name="submitButton" value="signIn"> + <div> +<button type="submit" +title="Sign in"> +<span> +<span>Sign in</span> +</span> +</button> +</div> + </form> + <form +title="Reset password" name="ResetPasswordForm" method="post" action="https://www.costco.com/ResetPassword" id="ResetPasswordForm"> + <input type="hidden" name="challengeAnswer" value="-" id="WC_PasswordResetForm_FormInput_challengeAnswer_In__1"> + <input type="hidden" name="storeId" value="10301" id="WC_PasswordResetForm_FormInput_storeId_In__1"> +<input type="hidden" name="catalogId" value="10701" id="WC_PasswordResetForm_FormInput_catalogId_In__1"> +<input type="hidden" name="langId" value="-1" id="WC_PasswordResetForm_FormInput_langId_In__1"> +<input type="hidden" name="state" value="passwdconfirm" id="WC_PasswordResetForm_FormInput_state_In__1"> +<input type="hidden" name="URL" value="ResetPasswordSuccessView" id="WC_PasswordResetForm_FormInput_URL_In__1"> +<input type="hidden" name="errorViewName" value="RememberMeLogonFormView" id="WC_PasswordResetForm_FormInput_errorViewName_In__1"> +<input type="hidden" name="subject" value="New Costco.com Password" id="WC_PasswordResetForm_FormInput_subject_In__1"> +<input type="hidden" name="sender" value="no-reply@costco.com" id="WC_PasswordResetForm_FormInput_sender_In__1"> +<input type="hidden" name="isPharmacy" value="" id="WC_PasswordResetForm_FormInput_isPharmacy_In_Logon_1"> + <p>To reset your password, enter the email address associated with your Costco.com account. Instructions to create a new password will be sent to your address. + </p> + <div> +<label for="forgotPassword_email">Email address<span>*</span> +</label> +<input id="forgotPassword_email" name="logonId" type="text" +title="Email address"> + </div> + <input type="hidden" name="submitButton" value="forgotPassword"> + <div> +<button type="submit" +title="Reset password"> +<span> +<span>Reset password</span> +</span> +</button> +</div> + </form> + <form +title="" name="RegisterForm" method="post" action="https://www.costco.com/UserRegistrationAdd" id="RegisterForm"> + <input type="hidden" name="new" value="Y" id="WC_UserRegistrationAddForm_FormInput_new_In_Register_1"> + <input type="hidden" name="storeId" value="10301" id="WC_UserRegistrationAddForm_FormInput_storeId_In_Register_1"> + <input type="hidden" name="catalogId" value="10701" id="WC_UserRegistrationAddForm_FormInput_catalogId_In_Register_1"> + <input type="hidden" name="langId" value="-1" id="WC_UserRegistrationAddForm_FormInput_langId_In__1"> +<input type="hidden" name="URL" value="CheckOutCmd?orderId=644156669&storeId=10301&storeId=10301&authToken=312404731%252cKsqvty%252bpMJ%252bCAl3XeIkCxSEgLa4%253d&authToken=312404731%252cKsqvty%252bpMJ%252bCAl3XeIkCxSEgLa4%253d&orderErrMsgObj=%7B%7D&itemMessage=1.0&langId=-1&langId=-1&catalogId=10701&catalogId=10701" id="WC_UserRegistrationAddForm_FormInput_URL_In_Register_1"> + <input type="hidden" name="userField1" value="" id="WC_UserRegistrationAddForm_FormInput_userField1_In_Register_1"> + <input type="hidden" name="addressField1" value="" id="WC_UserRegistrationAddForm_FormInput_addressField1_In_Register_1"> + <input type="hidden" name="addressType" value="B" id="WC_UserRegistrationAddForm_FormInput_addressType_In_Register_1"> + <input type="hidden" name="nickName" value="Self Address" id="WC_UserRegistrationAddForm_FormInput_nickName_In_Register_1"> + <input type="hidden" name="errorViewName" value="LogonForm" id="WC_UserRegistrationAddForm_FormInput_errorViewName_In_Register_1"> + <input type="hidden" name="validated" id="validated" value="true"> + <input type="hidden" name="primary" value="false" id="WC_UserRegistrationAddForm_FormInput_primary_In_Register_1"> + <input type="hidden" name="challengeQuestion" value="-" id="WC_UserRegistrationAddForm_FormInput_challengeQuestion_In_Register_1"> + <input type="hidden" name="challengeAnswer" value="-" id="WC_UserRegistrationAddForm_FormInput_challengeAnswer_In_Register_1"> + <input type="hidden" name="fromPage" value="LogonForm" id="WC_UserRegistrationAddForm_FormInput_fromPage_In_Register_1"> + <input type="hidden" name="isPharmacy" value="" id="WC_UserRegistrationAddForm_FormInput_isPharmacy_In_Logon_1"> + <input type="hidden" name="page" value="account" id="WC_UserRegistrationAddForm_FormInput_page_In_Register_1"> + <input type="hidden" name="parentMember" value="o=costco us bc sellers,o=costco na sellers,o=extended sites seller organization,o=root organization"> + <p>Enter your email address and create a password below to register.† + </p> + <div> +<span>*</span> Required fields + </div> + <div> +<label for="register_email1">Email Address<span>*</span> +</label> +<input id="register_email1" name="email1" type="text" maxlength="40" +title="overall type: EMAIL_ADDRESS server type: EMAIL_ADDRESS heuristic type: EMAIL_ADDRESS label: Email Address* parseable name: email1 field signature: 1119374200 form signature: 14385182823106756929" value="" +autofill-prediction="EMAIL_ADDRESS" +> + </div> + <div id="passwordField"> + <label for="register_logonPassword">Password:</label> +<input id="register_logonPassword" name="logonPassword" maxlength="20" type="password" value="" +title="overall type: ACCOUNT_CREATION_PASSWORD server type: ACCOUNT_CREATION_PASSWORD heuristic type: UNKNOWN_TYPE label: Password: parseable name: logonPassword field signature: 354853082 form signature: 14385182823106756929" +autofill-prediction="ACCOUNT_CREATION_PASSWORD" +> + <div id="PasswordStrength"> + <img src="./Sign%20In_files/Password_Strength_Arrow.png"> + <div> + <p>Password must meet the following:</p> + <div> + <ul> + <li>Use between 8 and 20 characters</li> + <li>Include at least one letter</li> + <li>Does not contain blank spaces or the following special characters: < > " \ . + </li> + <li>Passwords match</li> + </ul> + </div> + <p>Password Strength : <span id="strengthText"> +</span> +</p> + <ul> + <li>Too Short</li> + <li>Weak</li> + <li>Fair</li> + <li>Good</li> + <li>Strong</li> + </ul> + <p id="passwordStrengthBar"> +</p> + <p>To improve strength, increase password length and use capital letters, numbers, and special characters + (except < > " \ .) + </p> + </div> + </div> + </div> + <div> +<label for="register_logonPasswordVerify">Confirm Password<span>*</span> +</label> +<input id="register_logonPasswordVerify" name="logonPasswordVerify" maxlength="20" type="password" value="" +title="overall type: UNKNOWN_TYPE server type: NO_SERVER_DATA heuristic type: UNKNOWN_TYPE label: Confirm Password* parseable name: logonPasswordVerify field signature: 1976176530 form signature: 14385182823106756929" +autofill-prediction="UNKNOWN_TYPE" +> +</div> + <div> +<label for="register_userField2">Costco Membership Number</label> +<input id="register_userField2" name="userField2" type="text" maxlength="16" +title="overall type: UNKNOWN_TYPE server type: NO_SERVER_DATA heuristic type: UNKNOWN_TYPE label: Costco Membership Number parseable name: userField2 field signature: 1051463506 form signature: 14385182823106756929" value="" +autofill-prediction="UNKNOWN_TYPE" +> + </div> + <p> +<b>Non-members may be assessed an additional surcharge. The surcharge does not apply to prescription items. Executive Members need to provide a membership number to receive credit for their 2% rebate.</b> + </p> + <div> + <input id="register_sendMeEmail" name="sendMeEmail" type="checkbox" checked="checked" +title="overall type: UNKNOWN_TYPE server type: NO_SERVER_DATA heuristic type: UNKNOWN_TYPE label: Yes, I would like to receive emails about special offers and new product information from Costco. Co parseable name: sendMeEmail field signature: 3147026083 form signature: 14385182823106756929" +autofill-prediction="UNKNOWN_TYPE" +> +<label for="register_sendMeEmail">Yes, I would like to receive emails about special offers and new product information from Costco. Costco will not rent or sell your email address.</label> + </div> + <input name="submitButton" value="Register" type="hidden"> + <div> +<button type="submit" +title="Register"> +<span> +<span>Register</span> +</span> +</button> +</div> + </form> + <form action="https://www.costco.com/EmailSubscription" id="footer_emailSignup" name="footer_emailSignup"> + <input id="footer_emailSignUpEmail" name="emailSignUp" type="text"> + </form> + <div id="language-region-modal-container"> + <div> + ???LANGUAGE_REGION_MODAL_TITLE??? + </div> + <div> + <div id="language-radio-buttons"> + <p>???LANGUAGE_REGION_MODAL_CHOOSE_LANGUAGE???</p> + <label> + <input type="radio" name="language" value="-1">???HEADER_LANGUAGE_NAME_-1???</label> + </div> + <hr> + <div id="region-radio-buttons"> + <p>???LANGUAGE_REGION_MODAL_CHOOSE_REGION???</p> + <div> + <label> + <input type="radio" name="region" value="AB">Alberta - AB</label> + <label> + <input type="radio" name="region" value="BC">British Columbia - BC</label> + <label> + <input type="radio" name="region" value="MB">Manitoba - MB</label> + <label> + <input type="radio" name="region" value="NB">New Brunswick - NB</label> + <label> + <input type="radio" name="region" value="NL">Newfoundland and Labrador - NL</label> + <label> + <input type="radio" name="region" value="NT">Northwest Territories - NT</label> + <label> + <input type="radio" name="region" value="NS">Nova Scotia - NS</label> + </div> + <div> + <label> + <input type="radio" name="region" value="NU">Nunavut - NU</label> + <label> + <input type="radio" name="region" value="ON">Ontario - ON</label> + <label> + <input type="radio" name="region" value="PE">Prince Edward Island - PE</label> + <label> + <input type="radio" name="region" value="QC">Quebec - QC</label> + <label> + <input type="radio" name="region" value="SK">Saskatchewan - SK</label> + <label> + <input type="radio" name="region" value="YT">Yukon - YT</label> + </div> + </div> + <div> + <input id="language-region-set" type="submit" value="???LANGUAGE_REGION_MODAL_SUBMIT???"> + </div> + </div> + </div> + </body> +</html> diff --git a/browser/extensions/formautofill/test/fixtures/third_party/HomeDepot/Checkout_ShippingPayment.html b/browser/extensions/formautofill/test/fixtures/third_party/HomeDepot/Checkout_ShippingPayment.html new file mode 100644 index 0000000000..1825350651 --- /dev/null +++ b/browser/extensions/formautofill/test/fixtures/third_party/HomeDepot/Checkout_ShippingPayment.html @@ -0,0 +1,381 @@ +<!DOCTYPE html> +<html> + <head> + <meta http-equiv="Content-Type" content="text/html; charset=windows-1252"> + <meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>The Home Depot - Checkout</title> + </head> + <body> + <checkout-form name="pickupDetailsSection"> + <shipping> + <div> + <hd-address type="shipping"> + <hd-address-field label="Shipping Address" type="type"> + <div name="addressFieldForm"> + <div> + <div> + <div> + <hd-name-field name="firstName" label="First Name" analytics-tag="pickup options"> + <span name="firstName"> + <label for="inputField"> +<span>First Name</span> +</label> +<input type="text" id="firstName" name="inputField" maxlength="30" placeholder="" required="required"> + </span> + </hd-name-field> + </div> + <div> + <hd-name-field name="lastName" label="Last Name" analytics-tag="pickup options"> + <span name="lastName"> + <label for="inputField"> +<span>Last Name</span> +</label> +<input type="text" id="lastName" name="inputField" maxlength="30" placeholder="" required="required"> + </span> + </hd-name-field> + </div> + </div> + <div> + <div> + <hd-email-field name="emailInput" label="Email" placeholder="you@domain.com" analytics-tag="pickup options"> + <span name="emailInput"> + <label for="inputField"> +<span>Email</span> +</label> +<input id="emailInput" type="email" name="inputField" placeholder="you@domain.com" required="required"> + </span> + </hd-email-field> + </div> + </div> + <div> + <create-account> + <div> + <div> + <div> +<span role="button" tabindex="0">Create an account</span> to track your order history and check out faster - all we need is a password.</div> + <div> + <p>Check out faster, access past orders, and organize products into lists.</p> + </div> + </div> + <div> + <div> + <hd-password-field label="Password" name="password"> +<span name="hdPasswordField"> +<label for="textPasswordInput"> +<span>Password</span> +</label> +<input type="password" name="inputField"> +<span> +</span> +</span> +</hd-password-field> + </div> + <div> + <hd-password-field name="confirmPassword" label="Confirm Password"> +<span name="hdPasswordField"> +<label for="textPasswordInput"> +<span>Confirm Password</span> +</label> +<input type="password" name="inputField"> +<span> +</span> +</span> +</hd-password-field> + </div> + <div> + <div> + <hd-check-box field-value="" label="SHOW PASSWORD" tab-index-hd="-1"> + <div> + <div> +<input tabindex="-1" type="checkbox" name="hdCheckBox_3" id="hdCheckBox_3"> +<label for="hdCheckBox_3">SHOW PASSWORD</label> +</div> + </div> + </hd-check-box> + </div> + <div> + <span>Passwords are case sensitive and must be at least 8 characters.</span> + <div> + <span>Create a strong password by:</span> + <ul> + <li>Including numbers or symbols</li> + <li>Mixing upper/lowercase</li> + </ul> + </div> + </div> + <div> +<a target="_blank">Terms & Conditions</a> | <a target="_blank">Privacy & Security</a> +</div> + </div> + <create-account-button> +</create-account-button> + </div> + </div> + </create-account> + </div> + <div> + <div> + <hd-phone-field name="phone" label="Phone" analytics-tag="pickup options"> + <span name="phone"> + <label for="inputField"> +<span>Phone</span> +</label> +<input id="phone" name="inputField" type="tel" inputmode="numeric" placeholder="(___) ___-____" required="required"> + </span> + </hd-phone-field> + </div> + </div> + </div> + <div> + <div> + <div> + <label for="billingAddress"> +<span>Shipping Address</span> +</label> + </div> + <span name="streetInput"> + <hd-type-ahead id="billingAddress" name="billingAddress" label="Billing Street Address" placeholder="Address Line 1" pause="700" input-class="form-input__field"> + <div> + <input id="billingAddress_value" name="billingAddress" type="text" maxlength="30" placeholder="Address Line 1" required=""> + </div> + </hd-type-ahead> + </span> + </div> + </div> + <div> +<span role="button" tabindex="0">Add an apartment, suite, building, etc.</span> +</div> + <div> + <div> + <span name="zipInput"> + <label for="zip"> +<span>ZIP Code</span> +</label> +<input type="tel" name="zip" maxlength="5" required=""> + </span> + </div> + <div> + <span> + <label> +<span>City, State</span> +</label> + <span> +<b>MOUNTAIN VIEW, CA</b> +</span> + <div> + <span name="stateInput"> + <select id="cityStateListSelector" autocomplete="billing street-address" name="pickupLocation"> + <option value="? object:null ?" selected="selected"> +</option> + </select> + </span> + </div> + </div> + </div> + </div> + </hd-address-field> + </hd-address> + <div> + <hd-check-box field-value="checkoutFlags.hideBillingAddress" label="Use as Billing Address" checked="checked"> + <div> + <div> +<input tabindex="" type="checkbox" name="hdCheckBox_1" id="hdCheckBox_1" checked="checked"> +<label for="hdCheckBox_1">Use as Billing Address</label> +</div> + </div> + </hd-check-box> + </div> + </div> + </shipping> + </checkout-form> + <card-field form-is-valid="formIsValid"> + <div name="cardForm"> + <div> +<input name="expiryMonth" type="tel" autocomplete="cc-exp-month" tabindex="-1"> +<input name="expiryYear" type="tel" autocomplete="cc-exp-year" tabindex="-1"> +</div> + <div> + <div> + <span>Payment</span> + </div> + </div> + <div> + <div> + <input type="radio" value="PayPal" id="payPal" name="paymentOptions"> + <label for="payPal" tabindex="10"> + <span> +</span> + <div> +</div> + </label> + </div> + <div> +<input type="radio" select-payment="" value="creditCard" id="creditCard" name="paymentOptions" checked="checked"> +<label for="creditCard" tabindex="10"> +<span> +</span>Credit Card</label> +</div> + <div> + <span> + <label> + </label> + <input id="cardNumber" name="cardNumber" type="tel" mask-field="" mask="0000 0000 0000 0000 0000 0" maxlength="26" placeholder="Enter credit card number" required="required"> +<span id="ccIcon" tabindex="-1" role="button"> +</span> + </span> + </div> + <div> + <div> + <div> + <div> + <span>Expiration</span> + <span> + <select id="ccMonth" name="ccMonth" required=""> + <option value="" selected="selected">Month</option> + <option label="01 - January" value="object:17">01 - January</option> + <option label="02 - February" value="object:18">02 - February</option> + <option label="03 - March" value="object:19">03 - March</option> + <option label="04 - April" value="object:20">04 - April</option> + <option label="05 - May" value="object:21">05 - May</option> + <option label="06 - June" value="object:22">06 - June</option> + <option label="07 - July" value="object:23">07 - July</option> + <option label="08 - August" value="object:24">08 - August</option> + <option label="09 - September" value="object:25">09 - September</option> + <option label="10 - October" value="object:26">10 - October</option> + <option label="11 - November" value="object:27">11 - November</option> + <option label="12 - December" value="object:28">12 - December</option> + </select> + </span> + </div> + </div> + <div> + <div> + <span> + <select id="ccYear" name="ccYear" required=""> + <option value="" selected="selected">Year</option> + <option label="2017" value="object:29">2017</option> + <option label="2018" value="object:30">2018</option> + <option label="2019" value="object:31">2019</option> + <option label="2020" value="object:32">2020</option> + <option label="2021" value="object:33">2021</option> + <option label="2022" value="object:34">2022</option> + <option label="2023" value="object:35">2023</option> + <option label="2024" value="object:36">2024</option> + <option label="2025" value="object:37">2025</option> + <option label="2026" value="object:38">2026</option> + <option label="2027" value="object:39">2027</option> + <option label="2028" value="object:40">2028</option> + <option label="2029" value="object:41">2029</option> + <option label="2030" value="object:42">2030</option> + <option label="2031" value="object:43">2031</option> + <option label="2032" value="object:44">2032</option> + <option label="2033" value="object:45">2033</option> + <option label="2034" value="object:46">2034</option> + <option label="2035" value="object:47">2035</option> + </select> + </span> + </div> + </div> + </div> + <div> + <span> + <label> + <span> + CVV (on back) + </span> + </label> + <input id="cvv" name="cvv" type="tel" placeholder="ñññ" minlength="3" required=""> +<span id="ccIcon"> +</span> + </span> + </div> + </div> + <div> + <span role="button" tabindex="0">Apply a Gift Card</span> + <span> | </span> + <span role="button" tabindex="0">Have a PO/Job Code for this order?</span> + </div> + </div> + <div> + </div> + <div> + </div> + </div> + </card-field> + <base> + <div> + <ui-view> + <div id="checkout" analytics=""> + <div name="checkout"> + <div> + <div> + <div> + <myapron-display> + <div> + <div> +<span name="myApronID"> +<label for="myapron"> +<span>myApron ID (Optional)</span> +</label> +<input type="tel" name="myapron" maxlength="10"> +</span> +</div> + </div> + </myapron-display> + <email-subscribe> + <div> + <div> + <hd-check-box field-value="user.emailSubscribed" name="emailSubscribed" label="Yes, I would like to receive emails about unadvertised & online only specials, new products and store promotions." checked="true"> + <div> + <div> +<input tabindex="" type="checkbox" name="emailSubscribed" id="hdCheckBox_2" checked="checked"> +<label for="hdCheckBox_2">Yes, I would like to receive emails about unadvertised & online only specials, new products and store promotions.</label> +</div> + </div> + </hd-check-box> + </div> + </div> + </email-subscribe> + </div> + </div> + </div> + </div> + <div> + <right-rail pick-up-options="pickUpOptions" messages="checkoutModel.messagesSummary"> + <div> + <div> + <promotions-summary promotions="order.promotionsModel" messages="messages"> + <div name="promoForm"> + <div> +<span role="button" tabindex="0">Have a promo code?</span> +</div> + <div> + <div> +<span> +<span> +</span> +</span> +</div> + </div> + <div> + <span> +<input id="promoId" name="promoId" type="text"> +</span> +<span> +<a> +<span>Apply</span> +</a> +</span> + </div> + </div> + </promotions-summary> + </div> + </div> + </right-rail> + </div> + </div> + </ui-view> + </div> + </body> +</html> diff --git a/browser/extensions/formautofill/test/fixtures/third_party/HomeDepot/SignIn.html b/browser/extensions/formautofill/test/fixtures/third_party/HomeDepot/SignIn.html new file mode 100644 index 0000000000..b741ce5f0c --- /dev/null +++ b/browser/extensions/formautofill/test/fixtures/third_party/HomeDepot/SignIn.html @@ -0,0 +1,83 @@ +<!DOCTYPE html> +<html> + <head> + <meta http-equiv="Content-Type" content="text/html; charset=utf-8"> + <title>The Home Depot - SignIn</title> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <meta content="yes" name="apple-mobile-web-app-capable"> + <meta content="black" name="apple-mobile-web-app-status-bar-style"> + <meta content=" at The Home Depot - Tablet" name="description"> + <meta content="" name="keywords"> + </head> + <body> + <form name="shoppingCartForm" id="shopCartForm" method="post"> + <input type="hidden" id="lineItemId" name="lineItemId" value="480de313-1be8-41ed-b8f9-1b1e371a7eb9_480de313-1be8-41ed-b8f9-1b1e371a7eb9"> + <input type="hidden" id="quantity" name="quantity" value=""> + <input type="hidden" id="fulfillmentMethod" name="fulfillmentMethod" value="ShipToHome"> + <input type="hidden" id="fulfillmentLocation" name="fulfillmentLocation" value=""> + <input type="hidden" id="LOCAL_STORE_ID" name="LOCAL_STORE_ID" value=""> + <input type="hidden" id="orderId" name="orderId" value="711220840"> + <input type="hidden" name="proceedAsGuest" value="yes"> + <input type="hidden" id="currentPage" name="currentPage" value="LogonPage"> + <input type="hidden" id="cartVisited" name="cartVisited" value="false"> + <input type="hidden" id="isEligibleForOPC" name="isEligibleForOPC" value="true"> + <input type="hidden" id="paymentType" name="paymentType" value="regularCheckout"> + <div> +<span> +<label> +<span>Email Address:</span> +</label> + <input type="email" placeholder="you@domain.com" id="guestEmail" name="guestLoginValue" value="" required="" errorkey="email"> +</span> + </div> + <div> + <p>You will have the opportunity to create an account and track your order once you complete your checkout. + </p> + <p> +</p> + </div> + <div> +<button> +<span>Continue</span> +</button> +</div> + </form> + <form name="checkOutLogonForm" id="checkOutLogonForm" method="post" action="https://secure2.homedepot.com/MCCCheckout/Checkout/checkoutLogon.do"> + <input type="hidden" name="dologin" value="yes"> + <input type="hidden" id="orderId" name="orderId" value="711220840"> + <h3>I'm a Returning Customer + </h3> + <div> + <span>Your sign in is incorrect. Please enter your email address or password. Note: One more invalid attempt will lock your account.</span> + </div> + <div> + <span>We periodically require password updates. Please <a>reset your password</a> or continue as a guest.</span> + </div> + <div> +<span> +<label> +<span>Email Address:</span> +</label> + <input type="email" placeholder="you@domain.com" name="logonId" id="email"> +</span> + </div> + <div> +<span> +<label> +<span>Password:</span> +</label> +<label id="toogleBtn">Show</label> +<input type="password" name="logonPassword" id="password"> +</span> +</div> + <div> + <div id="SignInPart"> + <input id="signInNow" type="submit" value="Sign In"> + </div> + <div> + <a>Reset Password</a> + </div> + </div> + </form> + </body> +</html> diff --git a/browser/extensions/formautofill/test/fixtures/third_party/Lush/index.html b/browser/extensions/formautofill/test/fixtures/third_party/Lush/index.html new file mode 100644 index 0000000000..a77fd13132 --- /dev/null +++ b/browser/extensions/formautofill/test/fixtures/third_party/Lush/index.html @@ -0,0 +1,421 @@ +<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd"> +<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" data-page-type="select-pmm-page" data-page-model="one-page" data-shopperlocale="en_GB"><head><meta charset="utf-8"><meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src 'self' data:; media-src 'self' data:; style-src 'self' data: 'unsafe-inline'; font-src 'self' data:; frame-src 'self' data:"><meta http-equiv="Memento-Datetime" content="Thu, 04 Jun 2020 09:28:03 GMT"><link rel="original" href="https://live.adyen.com/hpp/pay.shtml"> + <meta http-equiv="Content-Type" content="text/html; charset=utf-8"> + + <meta name="viewport" content="width=device-width, initial-scale=1"> + + <title>Step 1: Choose your Payment Method</title> + + <link rel="shortcut icon" href="resources/EN_B357a/1.ico" data-original-href="https://live.adyen.com/sf/q2TgsJh7/img/favicon.ico"> + + <link rel="stylesheet" type="text/css" href="resources/EN_B357a/2.css" data-original-href="https://live.adyen.com/hpp/css/reset.css;jsessionid=B450B7268B623F8AA545472EE35D974D.live110e?v=8160"> + <link rel="stylesheet" media="screen" type="text/css" href="resources/EN_B357a/3.css" data-original-href="https://live.adyen.com/sf/q2TgsJh7/css/screen.css"> + <link rel="stylesheet" media="print" type="text/css" href="resources/EN_B357a/4.css" data-original-href="https://live.adyen.com/sf/q2TgsJh7/css/print.css"> + + + <!--[if lt IE 7]> + <link rel="stylesheet" type="text/css" href="/sf/q2TgsJh7/css/screen_ie6.css" /> + <![endif]--> +</head> +<body> + + + + <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=0"> +<meta name="apple-mobile-web-app-capable" content="yes"> +<div class="swatch-black nav desktop-nav-checkout"> + <div class="desktop-container"> + <div class="container-padding"> + <ul class="logo-banner"> + <li class="logo"></li> + </ul> + <div class="desktop-checkout-help mobileHide"> + <p class="colour-black50 size-7 break-2-size-9">Need help?</p> + <p class="colour-white size-8 break-2-size-9">Call 01202 668 545</p> + </div> + </div> + </div> +</div> +<div class="desktop-container layout-main-content-region mobileHide"> + <div class="container-padding"> + <div class="block--commerce-checkout-progress-indication"> + <ol class="commerce-checkout-progress"> + <li class="addresses"> + <span class="checkout-progress-img"></span> + <span class="checkout-progress-line"></span> + <span class="size-6">Addresses</span> + </li> + <li class="next summary" title="Review your order before continuing."> + <span class="checkout-progress-img"></span> + <span class="checkout-progress-line"></span> + <span class="size-6">Order summary</span> + </li> + <li class="active payment" title="Use the button below to proceed to the payment server."> + <span class="checkout-progress-img"></span> + <span class="checkout-progress-line"></span> + <span class="size-6">Payment</span> + </li> + <li class="complete" style="display:none;">Checkout complete</li> + </ol> + </div> + </div> +</div> +<div class="centerpage"> + + <form id="pageform" action="https://live.adyen.com/hpp/completeCard.shtml" method="post" autocomplete="off"> + <div id="content"> + <!-- <div id="logoheader"> + <div class="SmartLogo"></div> + <div class="IAlogo"></div> +</div> --> + +<div id="logoheader2"> +</div> + +<div id="pmcontent" data-fathom="form"> + +<div class="InfoBox"> + <div class="Payment"> </div> + + </div> + <div class="paddiv1"></div> + + + + <input type="hidden" id="displayGroup" name="displayGroup" value="card"> +<h2 id="stageheader">Step 1: Please select your payment method</h2> + +<div id="displayAmount"> + Total payment amount GBP 31.40 <span id="extraCostAmount"></span> +</div> + + +<ul id="paymentMethods"> + + + + + <li style="list-style-type: none;" data-variant="givex"> + <input type="submit" name="brandName" value="LUSH Gift Card" class="imgB pmB pmBgivex" data-fathom="ccPaymentType"> + <span id="pmmextracosts-givex" class="pmmextracosts"> + </span> + + <span id="pmgivexdescription" class="pmmdescription"></span> + <div id="pmmdetails-givex" class="pmmdetails" style="overflow: hidden; visibility: visible; height: 0px;"> + + + + +<br><br> +<table class="basetable"> +<tbody><tr> + <td><div>Name</div></td> + <td><div class="fieldDiv"><input type="text" class="inputField" id="givex.cardHolderName" name="givex.cardHolderName" value="" size="19" maxlength="30"></div></td> +</tr> +<tr> + <td><div>Card Number</div></td> + <td> + <div class="fieldDiv"><input type="text" class="inputField" id="givex.cardNumber" name="givex.cardNumber" value="" size="22" maxlength="22"></div> + </td> +</tr> + <tr> + <td colspan="2"><div class="l"> + <input type="checkbox" value="true" name="givex.partialPayments" id="givex.partialPayments" checked="checked"> If there is not enough balance on my card, pay the rest of the payment amount with an other payment method. + </div></td> + </tr> + <input type="hidden" value="false" name="disablePartialPaymentsInHPP" id="disablePartialPaymentsInHPP"> + <tr> + <td colspan="2"><div class="r"> + <input class="paySubmit paySubmitgivex" type="submit" name="pay" value="Pay"> + </div></td> + </tr> + </tbody></table> + +<br><br> + + + + + </div></li><li style="list-style-type: none;" data-variant="amex"> + <input type="submit" name="brandName" value="Card Payment" class="imgB pmB pmBcard" data-fathom="ccPaymentType"> + <span id="pmmextracosts-card" class="pmmextracosts"> + </span> + + <span id="pmcarddescription" class="pmmdescription"></span> + <div id="pmmdetails-card" class="pmmdetails" style="overflow: hidden; visibility: visible; height: 475px;" data-collapse="collapsed"> + + + + +<!-- useNewCardId = true, groupName = card --> + + + + +<table class="basetable"> + + + +<tbody><tr id="card.cclogoTr"> + <td class="mid"> + <div id="card.cclogoheader" style="display: none">Card Type</div> + </td> + <td class="mid"> + <div style="height: 25px" id="card.cclogo"> + <img alt="" id="card.cclogo0" style="display: inline;" class="mid" src="resources/EN_B357a/5.png" data-original-src="https://live.adyen.com/hpp//img/pm/amex_small.png"> + <img alt="" id="card.cclogo1" style="display: inline;" class="mid" src="resources/EN_B357a/6.png" data-original-src="https://live.adyen.com/hpp//img/pm/mc_small.png"> + <img alt="" id="card.cclogo2" style="display: inline;" class="mid" src="resources/EN_B357a/7.png" data-original-src="https://live.adyen.com/hpp//img/pm/visa_small.png"> + <img alt="" id="card.cclogo3" style="display: none" class="mid" src="resources/EN_B357a/8.png" data-original-src="https://live.adyen.com/hpp/img/pm/unknown_small.png;jsessionid=B450B7268B623F8AA545472EE35D974D.live110e"> + <img alt="" id="card.cclogo4" style="display: none" class="mid" src="resources/EN_B357a/8.png" data-original-src="https://live.adyen.com/hpp/img/pm/unknown_small.png;jsessionid=B450B7268B623F8AA545472EE35D974D.live110e"> + <img alt="" id="card.cclogo5" style="display: none" class="mid" src="resources/EN_B357a/8.png" data-original-src="https://live.adyen.com/hpp/img/pm/unknown_small.png;jsessionid=B450B7268B623F8AA545472EE35D974D.live110e"> + <img alt="" id="card.cclogo6" style="display: none" class="mid" src="resources/EN_B357a/8.png" data-original-src="https://live.adyen.com/hpp/img/pm/unknown_small.png;jsessionid=B450B7268B623F8AA545472EE35D974D.live110e"> + <img alt="" id="card.cclogo7" style="display: none" class="mid" src="resources/EN_B357a/8.png" data-original-src="https://live.adyen.com/hpp/img/pm/unknown_small.png;jsessionid=B450B7268B623F8AA545472EE35D974D.live110e"> + <img alt="" id="card.cclogo8" style="display: none" class="mid" src="resources/EN_B357a/8.png" data-original-src="https://live.adyen.com/hpp/img/pm/unknown_small.png;jsessionid=B450B7268B623F8AA545472EE35D974D.live110e"> + + </div> + </td> +</tr> + +<tr id="card.cardNumberTr"> + <td class="cardNumberTitle"><div>Card Number</div></td> + <td><div class="fieldDiv"><input type="text" class="inputField" id="card.cardNumber" autocomplete="cc-number" name="card.cardNumber" value="" size="24" maxlength="23" data-fathom="number"></div></td> +</tr> + +<tr> + <td><div>Card Holder Name</div></td> + <td><div class="fieldDiv"> + <input type="text" class="inputField" id="card.cardHolderName" name="card.cardHolderName" value="" size="19" maxlength="30" data-fathom="name"> + </div></td> +</tr> + + +<tr> + <td><div>Card Expiry Date</div></td><td> + <div class="fieldDiv" id="card.expiryContainer"> + <select class="inputField hpp-expiry-month" name="card.expiryMonth" id="card.expiryMonth" size="1" data-fathom="expirationMonth"> + <option value=""> </option> + <option value="01">01</option> + <option value="02">02</option> + <option value="03">03</option> + <option value="04">04</option> + <option value="05">05</option> + <option value="06">06</option> + <option value="07">07</option> + <option value="08">08</option> + <option value="09">09</option> + <option value="10">10</option> + <option value="11">11</option> + <option value="12">12</option> + </select> + / + <select class="inputField hpp-expiry-year" name="card.expiryYear" id="card.expiryYear" size="1" data-fathom="expirationYear"> + <option value=""> </option> + <option value="2020">2020</option> + <option value="2021">2021</option> + <option value="2022">2022</option> + <option value="2023">2023</option> + <option value="2024">2024</option> + <option value="2025">2025</option> + <option value="2026">2026</option> + <option value="2027">2027</option> + <option value="2028">2028</option> + <option value="2029">2029</option> + <option value="2030">2030</option> + <option value="2031">2031</option> + <option value="2032">2032</option> + <option value="2033">2033</option> + <option value="2034">2034</option> + <option value="2035">2035</option> + <option value="2036">2036</option> + <option value="2037">2037</option> + <option value="2038">2038</option> + <option value="2039">2039</option> + <option value="2040">2040</option> + <option value="2041">2041</option> + <option value="2042">2042</option> + <option value="2043">2043</option> + <option value="2044">2044</option> + </select> + </div> + </td> +</tr> + + <tr> + <!-- brandCodeUndef --> + <td><div id="card.cvcName">CVC/CVV/CID </div></td> + <td><div class="fieldDiv"><input class="inputField" type="text" name="card.cvcCode" value="" id="card.cvcCode" size="7" maxlength="3" data-fathom="security"> + <a href="https://live.adyen.com/hpp/pay.shtml#"> + <span id="card.cvcWhatIs">What is CVC/CVV/CID?</span></a></div></td> + </tr> + + + + + + + <tr> + <td colspan="2"><div class="r"> + <input class="paySubmit paySubmitcard" type="submit" name="pay" value="Pay"> + </div></td> + </tr> +</tbody></table> + +<div class="popupMsg popupMsgOPP " style="display: none;" id="card.cvcFrame"> + <h3>What is CVC/CVV/CID?</h3> + <span style="width: 15px; height: 15px; border: 1px solid black; border-radius: 15px; position: absolute; right: 10px; top: 10px; text-align: center;">x</span> + <p>The Card Security Code (CVC/CVV/CID) is an <i>additional</i> + three or four digit security code that is printed (not embossed) on the front or the back + of your card.</p> + <p>The CVC/CVV/CID is an extra security measure to ensure that you are in possession of the card.</p> + </div> + + + + + + + + + + </div></li><li style="list-style-type: none;" data-variant="paypal"> + <input type="submit" name="brandName" value="PayPal" class="imgB pmB pmBpaypal" data-fathom="ccPaymentType"> + <span id="pmmextracosts-paypal" class="pmmextracosts"> + </span> + + <span id="pmpaypaldescription" class="pmmdescription"></span> + <div id="pmmdetails-paypal" class="pmmdetails" style="overflow: hidden; visibility: visible; height: 0px;"> + + + + <table class="basetable"> + + <tbody><tr> + <td> + Pay using your PayPal account. You will be redirected to the PayPal system to complete the payment. + </td> + </tr> + <tr> + <td colspan="2"><div class="r"> + <input class="paySubmit paySubmitpaypal" type="submit" name="pay" value="Pay"> + </div></td> + </tr> +</tbody></table> + </div> + </li> + + +</ul> + + + <div id="errorFrame" style="display: none;" class="popupMsg errorFrame"> + <div id="errorFrameValidationErrors"> + </div> + </div> + <div id="okFrame" style="display: none;" class="popupMsg okFrame"> + <div id="okFrameMessages"> + </div> +</div> + + + +<input type="text" style="display: none"> + <input type="hidden" name="sig" value="DYjKpO5ZYg5WdOnHGxBGZg8cxbY="> + <input type="hidden" name="merchantReference" value="UK-LW-18894662"> + <input type="hidden" name="brandCode" value="brandCodeUndef"> + <input type="hidden" name="paymentAmount" value="3140"> + <input type="hidden" name="currencyCode" value="GBP"> + + <input type="hidden" name="skinCode" value="q2TgsJh7"> + <input type="hidden" name="merchantAccount" value="LushCOMUK"> + <input type="hidden" name="shopperLocale" value="en_GB"> + <input type="hidden" name="stage" value="pay"> + <input type="hidden" name="sessionId" value="Kn5uBswMGbS8dZfKIOc5JBomZXXIscszA2tii46aF9A="> + + + <input type="hidden" name="sessionValidity" value="2020-06-04T12:25:13+01:00"> + + + + <input type="hidden" name="countryCode" value="US"> + + <input type="hidden" name="shopperEmail" value="alabalaa12@yahoo.com"> + <input type="hidden" name="shopperReference" value="alabalaa12@yahoo.com"> + + <input type="hidden" name="recurringContract" value="RECURRING"> + <input type="hidden" name="resURL" value="https://uk.lush.com/checkout/18894662/payment/return/ryQWZ_B6eE5h1k03sTpHUomc1Rc2NOsjxDuy0zioQNU"> + + + + <input type="hidden" name="merchantReturnData" value="ryQWZ_B6eE5h1k03sTpHUomc1Rc2NOsjxDuy0zioQNU"> + + + + <input type="hidden" name="originalSession" value="H4sIAAAAAAAAAJ1WbXPiNhD+Kx5/aucuYDtACDOdKW9NuBCc8Ja5Ozo3wl6wipBcSebia/vfuwJMCNiXaz8wDLuPdp99dlfiL5tyqkeaLMFu2DFJ7fe2hAVICXIy7KMt0jpWjVl5Vk5WJZaoqBSI9awcRBCsRKJnZbdev67Uat6sjMfXwDWGUJGIY5B9ERBmAgP/ctPahlb/N+qsLEEnkuN3+vj06UurBt1q5K6cSzWObydiHbjDwBv46o/nTpI636h4HEwwZSASrmXaFqEhMhmhaR+xuTYuu3HpVhzEJVgzDzLgTethWweNW7AQEjpEo5knjL231yCDiHA9NELhmW3gu4v+00XG2hxdUb6P9ac3XqoP0ZX9crQZBLvkdh+Lb/v3kzv0ChmCxEwky6RAKSr4lDAaUp0i3HM858KpXTiVses1vGrDvXznuA3HeVG9uyaUIZQwMscPcb1fUxIJYSR+QR2TL0BmZH1D6wi/4ybBSEb5si1QYBKYYobd9mQ47A1u8DRhTHyF8B50JEKVnZozEazOrHtOOIcatiN0prRp/U4Z+7/1XywWCg4BlcZeHrhQxpB/MwxxLtU4jQ+eEBjdgExzXHuqxyZUKyY8PTa9Dl1SWoIhYXte1bFG1r3vD7ofrVZ/2rHPwJFIFAyS9RykLwdkbTp0jlJGK18+SLGhuy62m+eoWCCO7eewfu3Wrs8xwW6y+s2RNe3eNEd/D6Y5oN0aZSt0ItAbBZ6iCyo8D5pb4ikst8ZTUH6RZ6jXVe57XVpQqfSeZ0+ROTBGjtyUL+jzroTMxMjhwFBE5NuRawkc1+k1PDSFLlpU6qhDUn9xjysVFUK+7/0I5CS6BgZxJPhecnRWq9dXFcep1t0jmBIBJWxk1hrFOmDNDsXAKd8IGgAmIiW+a92iTzko0/QcDA4PeCWK+5zds7ZXqzjfRU6JfsCNxwtg9x4Vo3Nv6zyoeyDbwwSGrFsY9EeRbkb2UFkRUbcUggokjTXe4oi7xSakVs96IiqC0BpHYN3RUFmfYynCJNC/vyXPmxm9k4w9rkFyYn4RZv3UFthckD9bn83TFuOCF6V0f7gj+H8h4WHnVdrh1mZpYWX7YmV7UJzsUFzVKS5vQ3Qbh30ptms6QEELY+ZCsyfFCLOUW2FKentz27cPxVOUO3BZrBFdou3x2V/PR9Npc1D99JvP6/zd1Vc36DE+6cvprFyjtU0wrt/2SC+c/GL/8y/bjcnWewkAAA=="> + + + + <input type="hidden" name="billingAddress.street" value="2250 S MOONEY BLVD"> <input type="hidden" name="billingAddress.houseNumberOrName" value=""> <input type="hidden" name="billingAddress.city" value="LAS VEGAS|NV"> <input type="hidden" name="billingAddress.postalCode" value="89169"> <input type="hidden" name="billingAddress.stateOrProvince" value="CA"> <input type="hidden" name="billingAddress.country" value="US"> + <input type="hidden" name="deliveryAddress.street" value="2250 S MOONEY BLVD"> <input type="hidden" name="deliveryAddress.houseNumberOrName" value=""> <input type="hidden" name="deliveryAddress.city" value="LAS VEGAS|NV"> <input type="hidden" name="deliveryAddress.postalCode" value="89169"> <input type="hidden" name="deliveryAddress.stateOrProvince" value="CA"> <input type="hidden" name="deliveryAddress.country" value="US"> + <input type="hidden" name="shopper.firstName" value="Isabella"> <input type="hidden" name="shopper.lastName" value="Rohaz"> <input type="hidden" name="shopper.telephoneNumber" value="5597400581"> + + + + <input type="hidden" name="openinvoicedata.numberOfLines" value="2"> + <input type="hidden" name="openinvoicedata.line2.itemAmount" value="2640"> + <input type="hidden" name="openinvoicedata.line2.itemVatPercentage" value="0"> + <input type="hidden" name="openinvoicedata.line2.currencyCode" value="GBP"> + <input type="hidden" name="openinvoicedata.line1.numberOfItems" value="1"> + <input type="hidden" name="openinvoicedata.line2.numberOfItems" value="1"> + <input type="hidden" name="openinvoicedata.line1.itemVatAmount" value="0"> + <input type="hidden" name="openinvoicedata.line1.description" value="Honey I Washed The Kids [product]"> + <input type="hidden" name="openinvoicedata.line2.itemVatAmount" value="0"> + <input type="hidden" name="openinvoicedata.line2.description" value="International (Courier) [shipping]"> + <input type="hidden" name="openinvoicedata.line1.itemVatPercentage" value="0"> + <input type="hidden" name="openinvoicedata.refundDescription" value="Refund to Isabella Rohaz"> + <input type="hidden" name="openinvoicedata.line1.itemAmount" value="500"> + <input type="hidden" name="openinvoicedata.line2.vatCategory" value="None"> + <input type="hidden" name="openinvoicedata.line1.vatCategory" value="None"> + <input type="hidden" name="merchantIntegration.type" value="HPP"> + <input type="hidden" name="openinvoicedata.line1.currencyCode" value="GBP"> + + + <input type="hidden" name="referrerURL" value="https://uk.lush.com/checkout/18894662/payment"> + + + + + + + <div id="df_swf_c" style="display:none;"></div> + <input type="hidden" name="dfValue" id="dfValue" value="1B2M2Y8Asg0010000000000000CEENawQ48p0050271576cVB94iKzBGs1JtJP2ftZ1B2M2Y8Asg000joETVQLPRH00000qZkTE1BXw4QyCgloH7n6Gldz2JXe0J:40"> + + <input type="hidden" name="usingFrame" id="usingFrame" value="false"> + <input type="hidden" name="usingPopUp" id="usingPopUp" value="false"> + + <div class="paddiv2"></div> + </div> + </div> + <div id="foot"> + <div id="footc"> + <div id="nextstep"> + <div id="nextstepc">Next Step: Enter your Payment Details</div> + </div> + <div id="footerb2div"> + </div> + <div id="footerb1div"> + <input name="back" id="mainBack" value="Previous" type="submit" class="hideforprint footerB backB"> + </div> + </div> + </div> + + <input type="hidden" name="shopperBehaviorLog" id="hpp-sbLog" value="{"numberBind":"1","holderNameBind":"1","cvcBind":"1","deactivate":"11","activate":"10","numberFieldFocusCount":"2","numberFieldLog":"fo@533,bl@544,fo@662,bl@662","numberFieldBlurCount":"2","holderNameFieldFocusCount":"2","holderNameFieldLog":"fo@662,bl@675,fo@793,bl@793","holderNameFieldBlurCount":"2","cvcFieldFocusCount":"2","cvcFieldLog":"fo@1136,bl@1149,fo@1288,bl@1288","cvcFieldBlurCount":"2"}"> + + </form> + </div> + + + + + + +</body></html> diff --git a/browser/extensions/formautofill/test/fixtures/third_party/Macys/Checkout_Payment.html b/browser/extensions/formautofill/test/fixtures/third_party/Macys/Checkout_Payment.html new file mode 100644 index 0000000000..85717a4eaa --- /dev/null +++ b/browser/extensions/formautofill/test/fixtures/third_party/Macys/Checkout_Payment.html @@ -0,0 +1,474 @@ +<!DOCTYPE html> +<html lang="en"> + <head> + <meta http-equiv="Content-Type" content="text/html; charset=UTF-8"> + <title>Macy's Checkout</title> + <meta http-equiv="generator" content="JACPKMALPHTCSJDTCR"> + <meta http-equiv="X-UA-Compatible" content="IE=edge"> + <meta name="format-detection" content="telephone=no"> + </head> + <body> + <form id="rc-payment-info-form" novalidate=""> + <div> + <div> + </div> + </div> + <div id="rc-payment-selection-row"> + <div> + <input type="radio" id="rc-creditcard" for="rc-creditcard-label" name="payment.type" checked="checked" value="CREDITCARD" +title="overall type: UNKNOWN_TYPE + server type: NO_SERVER_DATA + heuristic type: UNKNOWN_TYPE + label: Credit card + parseable name: payment.type + field signature: 2449554739 + form signature: 4053649612452005841" +autofill-prediction="UNKNOWN_TYPE" +> + <label id="rc-creditcard-label" for="rc-creditcard">Credit card</label> + </div> + <div> + <input type="radio" id="rc-paypal" name="payment.type" value="PAYPAL" +title="overall type: UNKNOWN_TYPE + server type: NO_SERVER_DATA + heuristic type: UNKNOWN_TYPE + label: Choose payment method + parseable name: payment.type + field signature: 2449554739 + form signature: 4053649612452005841" +autofill-prediction="UNKNOWN_TYPE" +> + <span for="rc-paypal"> +</span> + </div> + </div> + <div id="rc-paypal-disclaimer-cc-row"> + <div id="rc-paypal-disclaimer"> + <b>Note: </b>PayPal can't be used with Gift Cards, Reward Cards and Credit Cards. + Plenti points can be earned but not used with PayPal. + </div> + </div> + <fieldset id="rc-credit-card-container"> + <div> + <div> + <div> +</div> + </div> + <div> + <p>Secure payment + <a target="_blank">more info</a> + </p> + </div> + </div> + <div> + <div> + <label for="rc-payment-card-type">Card type</label> + <select name="creditCard.cardType.code" id="rc-payment-card-type" autocomplete="off" +title="overall type: CREDIT_CARD_TYPE + server type: NO_SERVER_DATA + heuristic type: CREDIT_CARD_TYPE + label: Card type + parseable name: creditCard.cardType.code + field signature: 1958753038 + form signature: 4053649612452005841" +autofill-prediction="CREDIT_CARD_TYPE" +> + <option value="-1">Select</option> + <option value="Y">Macy's</option> + <option value="B">Macy's American Express</option> + <option value="A">American Express</option> + <option value="V">Visa</option> + <option value="M">MasterCard</option> + <option value="O">Discover</option> + <option value="F">Employee Card</option> + </select> + <div id="payment-aria-info" tabindex="-1">Your Shipping, Plenti, and Gift Card information can be found and verified at the top of this page"</div> + </div> + </div> + <div id="rc-payment-card-number-row"> + <div> + <label for="rc-payment-card-number">Card number</label> + <input type="text" maxlength="20" pattern="\d*" name="creditCard.cardNumber" id="rc-payment-card-number" value="" autocomplete="off" autocorrect="off" +title="overall type: CREDIT_CARD_NUMBER + server type: CREDIT_CARD_NUMBER + heuristic type: CREDIT_CARD_NUMBER + label: Card number + parseable name: creditCard.cardNumber + field signature: 2117159926 + form signature: 4053649612452005841" +autofill-prediction="CREDIT_CARD_NUMBER" +> + <input type="hidden" name="creditCard.maskedCreditCardNumber" value=""> + </div> + </div> + <div> + <div> + <label id="rc-payment-expiration-label">Expiration date</label> + </div> + </div> + <div> + <div> + <select name="creditCard.expMonth" id="rc-payment-card-month" autocomplete="off" +title="overall type: CREDIT_CARD_EXP_MONTH + server type: CREDIT_CARD_EXP_MONTH + heuristic type: CREDIT_CARD_EXP_MONTH + label: Expiration date + parseable name: creditCard.expMonth + field signature: 989675451 + form signature: 4053649612452005841" +autofill-prediction="CREDIT_CARD_EXP_MONTH" +> + <option>01</option> + <option>02</option> + <option>03</option> + <option>04</option> + <option>05</option> + <option>06</option> + <option>07</option> + <option>08</option> + <option>09</option> + <option>10</option> + <option>11</option> + <option>12</option> + </select> + </div> + <div> + <select name="creditCard.expYear" id="rc-payment-card-year" autocomplete="off" +title="overall type: CREDIT_CARD_EXP_4_DIGIT_YEAR + server type: CREDIT_CARD_EXP_4_DIGIT_YEAR + heuristic type: CREDIT_CARD_EXP_4_DIGIT_YEAR + label: Expiration date + parseable name: creditCard.expYear + field signature: 891328465 + form signature: 4053649612452005841" +autofill-prediction="CREDIT_CARD_EXP_4_DIGIT_YEAR" +> + <option>2017</option> + <option>2018</option> + <option>2019</option> + <option>2020</option> + <option>2021</option> + <option>2022</option> + <option>2023</option> + <option>2024</option> + <option>2025</option> + <option>2026</option> + </select> + </div> + </div> + <div> + <div> + <label for="rc-payment-scode" id="rc-payment-scode-label">Security code</label> + </div> + </div> + <div id="rc-payment-scode-row"> + <div> + <input type="text" name="fake-password" id="rc-fake-password" autocomplete="off" +title="overall type: CREDIT_CARD_VERIFICATION_CODE + server type: NO_SERVER_DATA + heuristic type: CREDIT_CARD_VERIFICATION_CODE + label: Please enter the 4 digit security code on the front of your credit card Please enter the 3 digit sec + parseable name: fake-password + field signature: 3761992124 + form signature: 4053649612452005841" +autofill-prediction="CREDIT_CARD_VERIFICATION_CODE" +> + <input type="text" maxlength="4" pattern="\d*" name="creditCard.securityCode" id="rc-payment-scode" autocomplete="off" +title="overall type: CREDIT_CARD_VERIFICATION_CODE + server type: NO_SERVER_DATA + heuristic type: CREDIT_CARD_VERIFICATION_CODE + label: Security code + parseable name: creditCard.securityCode + field signature: 789650537 + form signature: 4053649612452005841" +autofill-prediction="CREDIT_CARD_VERIFICATION_CODE" +> + </div> + <div id="amex-cvv-info" tabindex="-1">Please enter the 4 digit security code on the front of your credit card</div> + <div id="not-amex-cvv-info" tabindex="-1">Please enter the 3 digit security code on the back of your credit card</div> + <div id="rc-cvv-icon-row"> + <div id="rc-cvv-icon"> + <span id="rc-cvv-img"> +</span> + <span id="rc-cvv-hint"> +</span> + </div> + </div> + </div> + </fieldset> + <section id="rc-paypal-container"> + <div> + <div id="rc-paypal-disclaimer"> + <b>Note: </b>PayPal can't be used with Gift Cards, Reward Cards and Credit Cards. + Plenti points can be earned but not used with PayPal. + </div> + </div> + <div id="rc-paypal-login-disclaimer"> + <div> + You will login on PayPal's site on the next page and review your order, then you will finish the transaction at macy's.com + </div> + </div> + </section> + <fieldset id="rc-billing-address-container"> + <div> + <div> + </div> + </div> + <div id="rc-use-shipping-container"> + <div> + <input type="checkbox" id="rc-use-shipping" name="useMyShippingAddress" value="" checked="" +title="overall type: UNKNOWN_TYPE + server type: NO_SERVER_DATA + heuristic type: UNKNOWN_TYPE + label: Use my shipping address + parseable name: useMyShippingAddress + field signature: 1490259836 + form signature: 4053649612452005841" +autofill-prediction="UNKNOWN_TYPE" +> + </div> + <div> + <label id="rc-use-shipping-label" for="rc-use-shipping">Use my shipping address</label> + </div> + </div> + <div id="co-useshippingAddress-summary"> + <div> + </div> + </div> + <div id="rc-billing-address"> + <div> + <div> + <label for="rc-payment-firstName">First name</label> + <input type="text" maxlength="20" name="billingContact.firstName" id="rc-payment-firstName" value="" autocorrect="off" autocomplete="given-name" +title="overall type: HTML_TYPE_GIVEN_NAME + server type: NAME_FIRST + heuristic type: NAME_FIRST + label: First name + parseable name: billingContact.firstName + field signature: 1110479879 + form signature: 4053649612452005841" +autofill-prediction="HTML_TYPE_GIVEN_NAME" +> + </div> + </div> + <div> + <div> + <label for="rc-payment-lastName">Last name</label> + <input type="text" maxlength="30" name="billingContact.lastName" id="rc-payment-lastName" value="" autocorrect="off" autocomplete="family-name" +title="overall type: HTML_TYPE_FAMILY_NAME + server type: NAME_LAST + heuristic type: NAME_LAST + label: Last name + parseable name: billingContact.lastName + field signature: 724121833 + form signature: 4053649612452005841" +autofill-prediction="HTML_TYPE_FAMILY_NAME" +> + </div> + </div> + <div> + <div> + <label for="rc-payment-line1">Address line 1</label> + <input type="text" maxlength="35" name="billingAddress.addressLine1" id="rc-payment-line1" value="" autocorrect="off" autocomplete="address-line1" +title="overall type: HTML_TYPE_ADDRESS_LINE1 + server type: ADDRESS_HOME_LINE1 + heuristic type: ADDRESS_HOME_LINE1 + label: Address line 1 + parseable name: billingAddress.addressLine1 + field signature: 3436138513 + form signature: 4053649612452005841" +autofill-prediction="HTML_TYPE_ADDRESS_LINE1" +> + </div> + </div> + <div> + <div> + <label for="rc-payment-line2">Address line 2 (optional)</label> + <input type="text" maxlength="35" name="billingAddress.addressLine2" id="rc-payment-line2" autocorrect="off" autocomplete="address-line2" value="" +title="overall type: HTML_TYPE_ADDRESS_LINE2 + server type: ADDRESS_HOME_LINE2 + heuristic type: ADDRESS_HOME_LINE2 + label: Address line 2 (optional) + parseable name: billingAddress.addressLine2 + field signature: 3817149136 + form signature: 4053649612452005841" +autofill-prediction="HTML_TYPE_ADDRESS_LINE2" +> + </div> + </div> + <div> + <div> + <label for="rc-payment-city">City</label> + <input type="text" maxlength="25" name="billingAddress.city" id="rc-payment-city" value="" autocorrect="off" autocomplete="address-level2" +title="overall type: HTML_TYPE_ADDRESS_LEVEL2 + server type: ADDRESS_HOME_CITY + heuristic type: ADDRESS_HOME_CITY + label: City + parseable name: billingAddress.city + field signature: 2433902552 + form signature: 4053649612452005841" +autofill-prediction="HTML_TYPE_ADDRESS_LEVEL2" +> + </div> + </div> + <div id="rc-paystateZipRow"> + <div> + <label for="rc-payment-state">State</label> + <select name="billingAddress.state" id="rc-payment-state" autocomplete="address-level1" +title="overall type: HTML_TYPE_ADDRESS_LEVEL1 + server type: ADDRESS_HOME_STATE + heuristic type: ADDRESS_HOME_STATE + label: State + parseable name: billingAddress.state + field signature: 333754698 + form signature: 4053649612452005841" +autofill-prediction="HTML_TYPE_ADDRESS_LEVEL1" +> + <option value="-1">Select</option> + <option value="AL">AL</option> + <option value="AK">AK</option> + <option value="AS">AS</option> + <option value="AZ">AZ</option> + <option value="AR">AR</option> + <option value="AA">AA</option> + <option value="AE">AE</option> + <option value="AP">AP</option> + <option value="CA">CA</option> + <option value="CO">CO</option> + <option value="CT">CT</option> + <option value="DE">DE</option> + <option value="DC">DC</option> + <option value="FM">FM</option> + <option value="FL">FL</option> + <option value="GA">GA</option> + <option value="GU">GU</option> + <option value="HI">HI</option> + <option value="ID">ID</option> + <option value="IL">IL</option> + <option value="IN">IN</option> + <option value="IA">IA</option> + <option value="KS">KS</option> + <option value="KY">KY</option> + <option value="LA">LA</option> + <option value="ME">ME</option> + <option value="MH">MH</option> + <option value="MD">MD</option> + <option value="MA">MA</option> + <option value="MI">MI</option> + <option value="MN">MN</option> + <option value="MS">MS</option> + <option value="MO">MO</option> + <option value="MT">MT</option> + <option value="NE">NE</option> + <option value="NV">NV</option> + <option value="NH">NH</option> + <option value="NJ">NJ</option> + <option value="NM">NM</option> + <option value="NY">NY</option> + <option value="NC">NC</option> + <option value="ND">ND</option> + <option value="MP">MP</option> + <option value="OH">OH</option> + <option value="OK">OK</option> + <option value="OR">OR</option> + <option value="PW">PW</option> + <option value="PA">PA</option> + <option value="PR">PR</option> + <option value="RI">RI</option> + <option value="SC">SC</option> + <option value="SD">SD</option> + <option value="TN">TN</option> + <option value="TX">TX</option> + <option value="VI">VI</option> + <option value="UT">UT</option> + <option value="VT">VT</option> + <option value="VA">VA</option> + <option value="WA">WA</option> + <option value="WV">WV</option> + <option value="WI">WI</option> + <option value="WY">WY</option> + </select> + </div> + <div> + <label for="rc-payment-zipCode">ZIP code</label> + <input type="text" maxlength="5" pattern="\d*" name="billingAddress.zipCode" id="rc-payment-zipCode" value="" autocorrect="off" autocomplete="postal-code" +title="overall type: HTML_TYPE_POSTAL_CODE + server type: ADDRESS_HOME_ZIP + heuristic type: ADDRESS_HOME_ZIP + label: ZIP code + parseable name: billingAddress.zipCode + field signature: 837276327 + form signature: 4053649612452005841" +autofill-prediction="HTML_TYPE_POSTAL_CODE" +> + </div> + </div> + </div> + </fieldset> + <div> + <div> + </div> + </div> + <div> + <div> + We'll only contact you if we have questions about this order. + </div> + </div> + <div> + <div> + <label for="rc-payment-phone">Phone number</label> + <input type="text" maxlength="14" pattern="\d*" name="billingAddress.phone" id="rc-payment-phone" value="" autocorrect="off" autocomplete="tel" +title="overall type: HTML_TYPE_TEL + server type: PHONE_HOME_CITY_AND_NUMBER + heuristic type: PHONE_HOME_WHOLE_NUMBER + label: Phone number + parseable name: billingAddress.phone + field signature: 2467847771 + form signature: 4053649612452005841" +autofill-prediction="HTML_TYPE_TEL" +> + </div> + </div> + <div> + <div> + <label for="rc-payment-email">Email address</label> + <input type="email" maxlength="75" name="billingContact.email" id="rc-payment-email" value="" autocapitalize="off" autocorrect="off" autocomplete="email" +title="overall type: HTML_TYPE_EMAIL + server type: EMAIL_ADDRESS + heuristic type: EMAIL_ADDRESS + label: Email address + parseable name: billingContact.email + field signature: 4157735572 + form signature: 4053649612452005841" +autofill-prediction="HTML_TYPE_EMAIL" +> + </div> + </div> + <fieldset> + <div id="rc-paypalcontinue-row"> + <div> + <button type="button" id="rc-paypal-continue">Continue to Paypal</button> + </div> + </div> + <div id="rc-normalpaycontinue-row"> + <div> + <button type="submit" id="rc-payment-continue">Continue</button> + </div> + </div> + </fieldset> + </form> + <input type="hidden" id="gmeUrl" value="https://www.googleapis.com/mapsengine/v1/tables/06739517320133004748-11853667273131550346/features?version=published&key=AIzaSyCzwiHW1tSp_4FXaFuORRffbxBzQUN1qs4"> + <input type="hidden" id="gmClientId" value="gme-macysinc"> + <input type="hidden" id="gmeAPIKey" value="AIzaSyCzwiHW1tSp_4FXaFuORRffbxBzQUN1qs4"> + <input type="hidden" id="gmeTableId" value="06739517320133004748-11853667273131550346"> + <input type="hidden" id="gmeToSdpEnabled" value="true"> + <input type="hidden" id="macysCookieDomain" value=".macys.com"> + <input type="hidden" id="MACYS_secureHostName" value="https://www.macys.com"> + <input type="hidden" id="MACYS_baseHostName" value="https://www.macys.com"> + <input type="hidden" id="MACYS_assetsHostName" value="https://www.macys.com"> + <input type="hidden" id="MACYS_imageHostName" value="/img/ts/is/image/MCY"> + <input type="hidden" id="AKAMAI_LOGIC" value="hybrid"> + <input type="hidden" id="searchBoxPlaceholderGuest" value="Search or enter web ID"> + <input type="hidden" id="searchBoxPlaceholderUser" value="[userName], Search or enter web ID"> + </body> +</html> diff --git a/browser/extensions/formautofill/test/fixtures/third_party/Macys/Checkout_ShippingAddress.html b/browser/extensions/formautofill/test/fixtures/third_party/Macys/Checkout_ShippingAddress.html new file mode 100644 index 0000000000..7ed68344fa --- /dev/null +++ b/browser/extensions/formautofill/test/fixtures/third_party/Macys/Checkout_ShippingAddress.html @@ -0,0 +1,439 @@ +<!DOCTYPE html> +<html lang="en"> + <head> + <meta http-equiv="Content-Type" content="text/html; charset=UTF-8"> + <title>Macy's Checkout</title> + <meta http-equiv="generator" content="JACPKMALPHTCSJDTCR"> + <meta http-equiv="X-UA-Compatible" content="IE=edge"> + <meta name="format-detection" content="telephone=no"> + <meta name="viewport" content="width=device-width"> + </head> + <body> + <form id="rc-shipping-info-form"> + <fieldset> + <fieldset id="rc-shipping-address-wrapper"> + <div> + <div> + <label for="rc-shipping-firstName">First name</label> + <input type="text" maxlength="20" name="contact.firstName" id="rc-shipping-firstName" value="" autocorrect="off" autocomplete="given-name" +title="overall type: HTML_TYPE_GIVEN_NAME + server type: NO_SERVER_DATA + heuristic type: NAME_FIRST + label: First name + parseable name: contact.firstName + field signature: 2682246885 + form signature: 6571413743856647727" +autofill-prediction="HTML_TYPE_GIVEN_NAME" +> +<small>Please enter a first name.</small> + <div> + <div> +</div> + </div> + </div> + </div> + <div> + <div> + <label for="rc-shipping-lastName">Last name</label> + <input type="text" maxlength="30" name="contact.lastName" id="rc-shipping-lastName" value="" autocorrect="off" autocomplete="family-name" +title="overall type: HTML_TYPE_FAMILY_NAME + server type: NO_SERVER_DATA + heuristic type: NAME_LAST + label: Last name + parseable name: contact.lastName + field signature: 4271897545 + form signature: 6571413743856647727" +autofill-prediction="HTML_TYPE_FAMILY_NAME" +> + </div> + </div> + <div> + <div> + <label for="rc-shipping-line1">Address line 1</label> + <input type="text" maxlength="35" name="address.addressLine1" id="rc-shipping-line1" value="" autocorrect="off" autocomplete="address-line1" +title="overall type: HTML_TYPE_ADDRESS_LINE1 + server type: NO_SERVER_DATA + heuristic type: ADDRESS_HOME_LINE1 + label: Address line 1 + parseable name: address.addressLine1 + field signature: 2758559035 + form signature: 6571413743856647727" +autofill-prediction="HTML_TYPE_ADDRESS_LINE1" +> + </div> + </div> + <div> + <div> + <label for="rc-shipping-line2">Address line 2 (optional)</label> + <input type="text" maxlength="35" name="address.addressLine2" id="rc-shipping-line2" value="" autocorrect="off" autocomplete="address-line2" placeholder="Apt, Suite, Bldg, Floor, etc" +title="overall type: HTML_TYPE_ADDRESS_LINE2 + server type: NO_SERVER_DATA + heuristic type: ADDRESS_HOME_LINE2 + label: Address line 2 (optional) + parseable name: address.addressLine2 + field signature: 1355777065 + form signature: 6571413743856647727" +autofill-prediction="HTML_TYPE_ADDRESS_LINE2" +> + </div> + </div> + <div> + <div> + <label for="rc-shipping-city">City</label> + <input type="text" maxlength="25" name="address.city" id="rc-shipping-city" value="" autocorrect="off" autocomplete="address-level2" +title="overall type: HTML_TYPE_ADDRESS_LEVEL2 + server type: NO_SERVER_DATA + heuristic type: ADDRESS_HOME_CITY + label: City + parseable name: address.city + field signature: 242591127 + form signature: 6571413743856647727" +autofill-prediction="HTML_TYPE_ADDRESS_LEVEL2" +> + </div> + </div> + <div id="rc-stateZipRow"> + <div> + <label for="rc-shipping-state">State</label> + <select name="address.state" id="rc-shipping-state" autocomplete="address-level1" +title="overall type: HTML_TYPE_ADDRESS_LEVEL1 + server type: NO_SERVER_DATA + heuristic type: ADDRESS_HOME_STATE + label: State + parseable name: address.state + field signature: 1305630158 + form signature: 6571413743856647727" +autofill-prediction="HTML_TYPE_ADDRESS_LEVEL1" +> + <option value="-1">Select</option> + <option value="AL">AL</option> + <option value="AK">AK</option> + <option value="AS">AS</option> + <option value="AZ">AZ</option> + <option value="AR">AR</option> + <option value="AA">AA</option> + <option value="AE">AE</option> + <option value="AP">AP</option> + <option value="CA">CA</option> + <option value="CO">CO</option> + <option value="CT">CT</option> + <option value="DE">DE</option> + <option value="DC">DC</option> + <option value="FM">FM</option> + <option value="FL">FL</option> + <option value="GA">GA</option> + <option value="GU">GU</option> + <option value="HI">HI</option> + <option value="ID">ID</option> + <option value="IL">IL</option> + <option value="IN">IN</option> + <option value="IA">IA</option> + <option value="KS">KS</option> + <option value="KY">KY</option> + <option value="LA">LA</option> + <option value="ME">ME</option> + <option value="MH">MH</option> + <option value="MD">MD</option> + <option value="MA">MA</option> + <option value="MI">MI</option> + <option value="MN">MN</option> + <option value="MS">MS</option> + <option value="MO">MO</option> + <option value="MT">MT</option> + <option value="NE">NE</option> + <option value="NV">NV</option> + <option value="NH">NH</option> + <option value="NJ">NJ</option> + <option value="NM">NM</option> + <option value="NY">NY</option> + <option value="NC">NC</option> + <option value="ND">ND</option> + <option value="MP">MP</option> + <option value="OH">OH</option> + <option value="OK">OK</option> + <option value="OR">OR</option> + <option value="PW">PW</option> + <option value="PA">PA</option> + <option value="PR">PR</option> + <option value="RI">RI</option> + <option value="SC">SC</option> + <option value="SD">SD</option> + <option value="TN">TN</option> + <option value="TX">TX</option> + <option value="VI">VI</option> + <option value="UT">UT</option> + <option value="VT">VT</option> + <option value="VA">VA</option> + <option value="WA">WA</option> + <option value="WV">WV</option> + <option value="WI">WI</option> + <option value="WY">WY</option> + </select> + </div> + <div> + <label for="rc-shipping-postal-code">ZIP code</label> + <input type="text" maxlength="5" name="address.zipCode" id="rc-shipping-postal-code" value="" autocorrect="off" autocomplete="postal-code" pattern="\d*" +title="overall type: HTML_TYPE_POSTAL_CODE + server type: NO_SERVER_DATA + heuristic type: ADDRESS_HOME_ZIP + label: ZIP code + parseable name: address.zipCode + field signature: 1541909938 + form signature: 6571413743856647727" +autofill-prediction="HTML_TYPE_POSTAL_CODE" +> + </div> + </div> + <div> + <div> + <label for="rc-shipping-phone">Phone number</label> + <input type="text" maxlength="14" pattern="\d*" name="address.phone" id="rc-shipping-phone" autocomplete="tel" value="" autocorrect="off" +title="overall type: HTML_TYPE_TEL + server type: NO_SERVER_DATA + heuristic type: PHONE_HOME_WHOLE_NUMBER + label: Phone number + parseable name: address.phone + field signature: 3405332267 + form signature: 6571413743856647727" +autofill-prediction="HTML_TYPE_TEL" +> + </div> + </div> + </fieldset> + </fieldset> + <fieldset id="rc-shipping-method-set"> + <div> + <div> + <legend tabindex="-1">Shipping method</legend> + </div> + </div> + <div> + <div> + <div> + <input type="radio" name="shippingMethod.methodCode" value="G" id="rc-shipping-shipping-methodG" checked="checked" +title="overall type: UNKNOWN_TYPE + server type: NO_SERVER_DATA + heuristic type: UNKNOWN_TYPE + label: Standard Transit time: 3-6 business days + parseable name: shippingMethod.methodCode + field signature: 1185913307 + form signature: 6571413743856647727" +autofill-prediction="UNKNOWN_TYPE" +> + </div> + <div> + <label for="rc-shipping-shipping-methodG">Standard + <br> + Transit time: 3-6 business days + </label> + </div> + <div> + <div>$10.95 + </div> + </div> + </div> + <div> + <div> + <input type="radio" name="shippingMethod.methodCode" value="2" id="rc-shipping-shipping-method2" disabled="" +title="overall type: UNKNOWN_TYPE + server type: NO_SERVER_DATA + heuristic type: UNKNOWN_TYPE + label: Premium Transit time: 2-3 business days + parseable name: shippingMethod.methodCode + field signature: 1185913307 + form signature: 6571413743856647727" +autofill-prediction="UNKNOWN_TYPE" +> + </div> + <div> + <label for="rc-shipping-shipping-method2">Premium + <br> + Transit time: 2-3 business days + </label> + </div> + <div> + <div>$19.95 + </div> + </div> + </div> + <div> + <div> + <input type="radio" name="shippingMethod.methodCode" value="O" id="rc-shipping-shipping-methodO" disabled="" +title="overall type: UNKNOWN_TYPE + server type: NO_SERVER_DATA + heuristic type: UNKNOWN_TYPE + label: Express Transit time: 1-2 business days + parseable name: shippingMethod.methodCode + field signature: 1185913307 + form signature: 6571413743856647727" +autofill-prediction="UNKNOWN_TYPE" +> + </div> + <div> + <label for="rc-shipping-shipping-methodO">Express + <br> + Transit time: 1-2 business days + </label> + </div> + <div> + <div>$29.95 + </div> + </div> + </div> + <div> + <p> +<b>Note: We'll send you an email to schedule your delivery.</b> +</p> + <p> +<b>Note: Some items in your order may ship separately. Transit time is the time between leaving our fulfillment center & delivery to you.</b> +</p> + </div> + </div> + </fieldset> + <fieldset> + <div> + <div> + <legend tabindex="-1"> + Gift Options + </legend> + </div> + </div> + <div> + <div> + <input type="checkbox" name="giftOrder" id="rc-giftoption-isgift" value="true" +title="overall type: UNKNOWN_TYPE + server type: NO_SERVER_DATA + heuristic type: UNKNOWN_TYPE + label: This order contains a gift + parseable name: giftOrder + field signature: 2509042763 + form signature: 6571413743856647727" +autofill-prediction="UNKNOWN_TYPE" +> + <div id="aria-giftcardinfo" tabindex="-1"> + "Selecting this checkbox will expand additional gift options" + </div> + <label for="rc-giftoption-isgift">This order contains a gift</label> + <input type="hidden" value="true" name="_giftOrder"> + </div> + </div> + <div id="rc-giftoptions-additional"> + <div> + <div> + <input type="checkbox" name="giftOptions.haveGiftMessage" value="true" id="rc-giftoption-giftMsg" +title="overall type: UNKNOWN_TYPE + server type: NO_SERVER_DATA + heuristic type: UNKNOWN_TYPE + label: Gift message (optional) + parseable name: giftOptions.haveGiftMessage + field signature: 1542269083 + form signature: 6571413743856647727" +autofill-prediction="UNKNOWN_TYPE" +> + <label for="rc-giftoption-giftMsg">Gift message (optional)</label> + <input type="hidden" value="true" name="_giftOptions.haveGiftMessage"> +<br> + </div> + </div> + <div id="rc-giftmsg-text"> + <div> + <div> + <span id="rc-giftmsg-additional-info">Write a personal message. We'll print it on a card & send it along with the order.</span> + </div> + </div> + <div> + <div> + <label for="rc-gift-msg1">Message line 1 (max 45 characters)</label> + <input type="text" maxlength="45" name="giftOptions.senderName" id="rc-gift-msg1" value="" autocomplete="off" placeholder="Message line 1 (max 45 characters)" +title="overall type: UNKNOWN_TYPE + server type: NO_SERVER_DATA + heuristic type: UNKNOWN_TYPE + label: Message line 1 (max 45 characters) + parseable name: giftOptions.senderName + field signature: 851756973 + form signature: 6571413743856647727" +autofill-prediction="UNKNOWN_TYPE" +> + </div> + </div> + <div> + <div> + <label for="rc-gift-msg2">Message line 2 (max 45 characters)</label> + <input type="text" maxlength="45" name="giftOptions.giftMessage1" id="rc-gift-msg2" value="" autocomplete="off" placeholder="Message line 2 (max 45 characters)" +title="overall type: UNKNOWN_TYPE + server type: NO_SERVER_DATA + heuristic type: UNKNOWN_TYPE + label: Message line 2 (max 45 characters) + parseable name: giftOptions.giftMessage1 + field signature: 893448548 + form signature: 6571413743856647727" +autofill-prediction="UNKNOWN_TYPE" +> + </div> + </div> + <div> + <div> + <label for="rc-gift-msg3">Message line 3 (max 45 characters)</label> + <input type="text" maxlength="45" name="giftOptions.giftMessage2" id="rc-gift-msg3" value="" autocomplete="off" placeholder="Message line 3 (max 45 characters)" +title="overall type: UNKNOWN_TYPE + server type: NO_SERVER_DATA + heuristic type: UNKNOWN_TYPE + label: Message line 3 (max 45 characters) + parseable name: giftOptions.giftMessage2 + field signature: 3147898435 + form signature: 6571413743856647727" +autofill-prediction="UNKNOWN_TYPE" +> + </div> + </div> + </div> + <div> + <div> + <input type="checkbox" name="giftOptions.packageInGiftBox" value="true" id="rc-giftoption-giftBox" +title="overall type: UNKNOWN_TYPE + server type: NO_SERVER_DATA + heuristic type: UNKNOWN_TYPE + label: Send in a gift box ($6.00 per order) + parseable name: giftOptions.packageInGiftBox + field signature: 1556860808 + form signature: 6571413743856647727" +autofill-prediction="UNKNOWN_TYPE" +> + <label for="rc-giftoption-giftBox">Send in a gift box ($6.00 per order)</label> + <input type="hidden" value="true" name="_giftOptions.packageInGiftBox"> + </div> + </div> + <div> + <div> + <input type="checkbox" name="giftOptions.sendGiftReceipt" value="true" id="rc-giftoption-packingSlip" checked="checked" +title="overall type: UNKNOWN_TYPE + server type: NO_SERVER_DATA + heuristic type: UNKNOWN_TYPE + label: Hide prices on the packing slip + parseable name: giftOptions.sendGiftReceipt + field signature: 367416642 + form signature: 6571413743856647727" +autofill-prediction="UNKNOWN_TYPE" +> + <label for="rc-giftoption-packingSlip">Hide prices on the packing slip</label> + <input type="hidden" value="true" name="_giftOptions.sendGiftReceipt"> + </div> + </div> + </div> + </fieldset> + </form> + <input type="hidden" id="gmeUrl" value="https://www.googleapis.com/mapsengine/v1/tables/06739517320133004748-11853667273131550346/features?version=published&key=AIzaSyCzwiHW1tSp_4FXaFuORRffbxBzQUN1qs4"> + <input type="hidden" id="gmClientId" value="gme-macysinc"> + <input type="hidden" id="gmeAPIKey" value="AIzaSyCzwiHW1tSp_4FXaFuORRffbxBzQUN1qs4"> + <input type="hidden" id="gmeTableId" value="06739517320133004748-11853667273131550346"> + <input type="hidden" id="gmeToSdpEnabled" value="true"> + <input type="hidden" id="macysCookieDomain" value=".macys.com"> + <input type="hidden" id="MACYS_secureHostName" value="https://www.macys.com"> + <input type="hidden" id="MACYS_baseHostName" value="https://www.macys.com"> + <input type="hidden" id="MACYS_assetsHostName" value="https://www.macys.com"> + <input type="hidden" id="MACYS_imageHostName" value="/img/ts/is/image/MCY"> + <input type="hidden" id="AKAMAI_LOGIC" value="hybrid"> + <input type="hidden" id="searchBoxPlaceholderGuest" value="Search or enter web ID"> + <input type="hidden" id="searchBoxPlaceholderUser" value="[userName], Search or enter web ID"> + </body> +</html> diff --git a/browser/extensions/formautofill/test/fixtures/third_party/Macys/SignIn.html b/browser/extensions/formautofill/test/fixtures/third_party/Macys/SignIn.html new file mode 100644 index 0000000000..51dff05d04 --- /dev/null +++ b/browser/extensions/formautofill/test/fixtures/third_party/Macys/SignIn.html @@ -0,0 +1,208 @@ +<!DOCTYPE html> +<html lang="en" id="yui_3_8_1_1_1489978541398_168"> + <head> + <meta http-equiv="Content-Type" content="text/html; charset=windows-1252"> + <title>Sign In - Macy's Checkout</title> + <meta http-equiv="generator" content="JACPKMALPHTCSJDTCR"> + <meta http-equiv="X-UA-Compatible" content="IE=edge"> + <meta name="format-detection" content="telephone=no"> + <meta name="viewport" content="width=device-width"> + </head> + <body id="yui_3_8_1_1_1489978541398_167"> + <form id="signInForm" action="https://www.macys.com/account/signin?fromCheckout=fromCheckout" method="post"> + <div id="yui_3_8_1_1_1489978541398_165"> + <ul id="yui_3_8_1_1_1489978541398_164"> + <li id="yui_3_8_1_1_1489978541398_163"> + <div> + <label for="emailAddr">Email address:</label> + </div> + <div id="yui_3_8_1_1_1489978541398_162"> + <input id="emailAddr" name="email" type="text" value="" maxlength="75"> + </div> + <div> </div> + </li> + <li id="yui_3_8_1_1_1489978541398_175"> + <div> + <label for="password"> + Password: + </label> + </div> + <div id="yui_3_8_1_1_1489978541398_174"> + <input id="password" name="password" type="password" value="" maxlength="16"> + </div> + <div> </div> + </li> + </ul> + <div> + <div> +<span>Password is case sensitive</span> +</div> + <div> + <a id="frgtPwd">Forgot Your Password?</a> + </div> + <div> + <button type="submit" id="isnormalcheckout" name="accountSignIn"> +<span>checkout</span> +</button> + </div> + </div> + </div> + </form> + <form id="emailForm" action="https://www.macys.com/account/signin?fromCheckout=fromCheckout" method="post"> + <fieldset> + <ul> + <li id="overlaySubmitDiv"> + <label name="" for="forgotEmail" csserrorclass="error"> + Get started by entering the email address you use to sign in: + </label> + <input id="forgotEmail" name="passwordRecovery.email" type="text" value=""> + </li> + <li id="overlaySubmitbtnDiv"> + <button id="verifySubmitBtn" type="submit"> +<span>continue </span> +</button> + </li> + </ul> + </fieldset> + </form> + <form id="capthaForm" action="https://www.macys.com/account/signin?fromCheckout=fromCheckout" method="post"> + <fieldset> + <ul> + <li> + <label name="email" for="email" csserrorclass="error"> + Your email address: + </label> + <span> +<span id="emailID"> +</span> +</span> + </li> + <li id="Captchapart"> + <img id="captchaImg" alt="enter the letters in the below field"> + <div> +<a> +<img id="refreshbtn" src="./Sign In - Macy's Checkout_files/new_image.gif" alt="new_image"> +</a> +</div> + </li> + <li> + <label path="securityCode" for="securityCode" csserrorclass="error"> + Please enter the characters shown above and we'll email you a link to reset your password: + </label> + <input id="securityCode" name="passwordRecovery.securityCode" type="text" value="" maxlength="50"> + <input id="refreshCaptcha" name="passwordRecovery.refreshCaptcha" type="hidden" value=""> + </li> + <li> + <input id="verifyCaptchaBtn" type="submit" value="submit"> + </li> + </ul> + </fieldset> + </form> + <form id="securityQAForm" action="https://www.macys.com/account/signin?fromCheckout=fromCheckout" method="post"> + <fieldset> + <ul> + <li> + <label name="email" for="email" csserrorclass="error"> + Your email address: + <span> + <div id="secureEmailID"> +</div> + </span> + </label> + <input id="hiddenEmail" name="passwordRecovery.email" type="hidden" value=""> + <input id="hiddenSecQue" name="passwordRecovery.question" type="hidden" value=""> + </li> + <li> + <div id="question"> +</div> + </li> + <li> + <input id="secureAnswer" name="passwordRecovery.secureAnswer" type="text" value=""> + </li> + <li> + <button type="submit" id="verifysecurityBtn"> +<span>continue </span> +</button> + </li> + </ul> + </fieldset> + </form> + <form id="resetPasswordForm" action="https://www.macys.com/account/signin?fromCheckout=fromCheckout" method="post"> + <fieldset> + <ul> + <li> + <div> + <div> + <label name="password" for="passwordfield" csserrorclass="error inline" id="resetPasswordLabel"> + Enter new password: </label> + </div> + <div> + <div> + <input id="passwordfield" name="passwordRecovery.password" +title="overall type: UNKNOWN_TYPE + server type: NO_SERVER_DATA + heuristic type: UNKNOWN_TYPE + label: Enter new password: + parseable name: passwordRecovery.password + field signature: 457433414 + form signature: 10250648745005274367" type="password" value="" maxlength="16" +autofill-prediction="UNKNOWN_TYPE" +> + </div> + <div> + <div> +</div> + </div> + </div> + </div> + </li> + <li> + <div> + <div> + <label path="verifyPassword" for="verifyPassword" csserrorclass="error inline" id="confirmPasswordLabel"> + Confirm password: + </label> + </div> + <div> + <div> + <input id="verifyPasswordfield" name="passwordRecovery.verifyPassword" +title="overall type: UNKNOWN_TYPE + server type: NO_SERVER_DATA + heuristic type: UNKNOWN_TYPE + label: Confirm password: + parseable name: passwordRecovery.verifyPassword + field signature: 1462365960 + form signature: 10250648745005274367" type="password" value="" maxlength="16" +autofill-prediction="UNKNOWN_TYPE" +> + </div> + <div> + <div> +</div> + </div> + </div> + </div> + </li> + <li> + <button id="verifyResetPasswordBtn" type="submit"> +<span>Save & SignIn</span> +</button> + </li> + </ul> + </fieldset> + </form> + <input type="hidden" id="gmeUrl" value="https://www.googleapis.com/mapsengine/v1/tables/06739517320133004748-11853667273131550346/features?version=published&key=AIzaSyCzwiHW1tSp_4FXaFuORRffbxBzQUN1qs4"> + <input type="hidden" id="gmClientId" value="gme-macysinc"> + <input type="hidden" id="gmeAPIKey" value="AIzaSyCzwiHW1tSp_4FXaFuORRffbxBzQUN1qs4"> + <input type="hidden" id="gmeTableId" value="06739517320133004748-11853667273131550346"> + <input type="hidden" id="gmeToSdpEnabled" value="true"> + <input type="hidden" id="macysCookieDomain" value=".macys.com"> + <input type="hidden" id="MACYS_secureHostName" value="https://www.macys.com"> + <input type="hidden" id="MACYS_baseHostName" value="https://www.macys.com"> + <input type="hidden" id="MACYS_assetsHostName" value="https://www.macys.com"> + <input type="hidden" id="MACYS_imageHostName" value="/img/ts/is/image/MCY"> + <input type="hidden" id="AKAMAI_LOGIC" value="hybrid"> + <input type="hidden" id="searchBoxPlaceholderGuest" value="Search or enter web ID"> + <input type="hidden" id="searchBoxPlaceholderUser" value="[userName], Search or enter web ID"> + </body> +</html> diff --git a/browser/extensions/formautofill/test/fixtures/third_party/NewEgg/BillingInfo.html b/browser/extensions/formautofill/test/fixtures/third_party/NewEgg/BillingInfo.html new file mode 100644 index 0000000000..d44bc01b70 --- /dev/null +++ b/browser/extensions/formautofill/test/fixtures/third_party/NewEgg/BillingInfo.html @@ -0,0 +1,1074 @@ +<!DOCTYPE html> +<html lang="en-us"> + <head> + <meta http-equiv="Content-Type" content="text/html; charset=UTF-8"> + <title>Newegg.com - Billing Info</title> + <meta name="keywords" content="Newegg.com - Once You Know, You Newegg"> + <meta name="description" content="Newegg.com offers the best prices on computer parts, laptop computers, digital cameras, electronics and more with fast shipping and top-rated customer service. Once you know, you Newegg!"> + <meta name="google-translate-customization" content="d08b8c829bab9a46-ac914e23c0a3607a-g7fba27d07436db8a-e"> + <meta name="language" content="english"> + <meta name="copyright" content="© 2000-2017 Newegg Inc."> + <meta http-equiv="X-UA-Compatible" content="IE=edge"> + <meta name="robots" content="index,follow"> + </head> + <body> + <form id="checkout" name="checkout" action="https://secure.newegg.com/GlobalShopping/CheckoutStep2.aspx?CartID=415%2b903ZRCECSZWBJE0&IsCombineGuest=1" method="post" novalidate="novalidate"> + <div> + Redeem Newegg gift cards<span> +</span> + </div> + <div> + <div> + <div> + <div> + <label>Card Number</label> + <input type="text" name="GiftCode1" +title="overall type: EMAIL_ADDRESS + server type: EMAIL_ADDRESS + heuristic type: UNKNOWN_TYPE + label: Card Number + parseable name: GiftCode1 + field signature: 2868641577 + form signature: 16660402300910943442" +autofill-prediction="EMAIL_ADDRESS" +> + <input type="text" name="GiftCode" id="GiftCode" size="20" maxlength="16" autocomplete="off" +title="overall type: EMAIL_ADDRESS + server type: EMAIL_ADDRESS + heuristic type: UNKNOWN_TYPE + label: Card Number + parseable name: GiftCode + field signature: 1229209062 + form signature: 16660402300910943442" +autofill-prediction="EMAIL_ADDRESS" +> + </div> + <div> + <label>Security Code</label> + <input type="password" name="ScurityCode1" +title="overall type: UNKNOWN_TYPE + server type: NO_SERVER_DATA + heuristic type: UNKNOWN_TYPE + label: Security Code + parseable name: ScurityCode1 + field signature: 828427756 + form signature: 16660402300910943442" +autofill-prediction="UNKNOWN_TYPE" +> + <input type="password" maxlength="4" name="SecurityCode" id="SecurityCode" autocomplete="off" +title="overall type: CREDIT_CARD_VERIFICATION_CODE + server type: NO_SERVER_DATA + heuristic type: CREDIT_CARD_VERIFICATION_CODE + label: Security Code + parseable name: SecurityCode + field signature: 3977890449 + form signature: 16660402300910943442" +autofill-prediction="CREDIT_CARD_VERIFICATION_CODE" +> + </div> + </div> + <div> + <a> + <i> +</i> + Enter A Gift Card + <i> +</i> + </a> + </div> + <input type="hidden" name="AllGiftCodes" id="AllGiftCodes" value=""> + <input type="hidden" name="AllGiftPwds" id="AllGiftPwds" value=""> + <input type="hidden" name="GiftMethodAction" id="GiftMethodAction" value="0"> + </div> + <div> + <img +title="EggPoints" alt="EggPoints" src="./Newegg.com - Billing Info_files/none.gif"> + </div> + </div> + <ul> + <li> + <div id="GiftCardEmptyDiv"> + <div id="GiftCardEmptyMsg"> + <div> + <div> + <div> +</div> + <div> +<span>Missing Information </span>Card Number and Security Code fields cannot be empty. Please enter valid information and try again.</div> + </div> + </div> + </div> + </div> + </li> + </ul> + <div> + Payment Methods + <a rel="modal" +title="What's this?" id="PaymentMethodDisabled"> +</a> + <div id="PaymentMethodDisabled_Content"> + <p>Some payment methods may not be eligible for your order. Please review the full list by clicking <a>here</a> for payment restrictions.</p> + </div> + <span> +</span> + </div> + <div> + <ul> + <li> + <input type="radio" name="paymentmethod" id="NSCC" +title="overall type: UNKNOWN_TYPE + server type: NO_SERVER_DATA + heuristic type: UNKNOWN_TYPE + label: Newegg Store Credit Card + parseable name: paymentmethod + field signature: 1614873398 + form signature: 16660402300910943442" +autofill-prediction="UNKNOWN_TYPE" +> + <label for="NSCC"> + <strong>Newegg Store Credit Card</strong> + </label> + <div> + <div> + <div> + <img src="./Newegg.com - Billing Info_files/newegg-cc.png" alt="Newegg Store Credit Card"> + <p> + </p> + </div> + <div> + <strong>Newegg Store Credit Card</strong> + </div> + </div> + <div> + <div> + <div> + <label for="StoreCard_HolderName"> + Cardholder Name + <span>(exactly as shown on card)</span> + </label> + <input +title="overall type: CREDIT_CARD_NAME_FULL + server type: CREDIT_CARD_NAME_FULL + heuristic type: CREDIT_CARD_NAME_FULL + label: Cardholder Name (exactly as shown on card) + parseable name: StoreCard_HolderName + field signature: 853107187 + form signature: 16660402300910943442" type="text" maxlength="80" name="StoreCard_HolderName" id="StoreCard_HolderName" value="" +autofill-prediction="CREDIT_CARD_NAME_FULL" +> + </div> + <div> + <label for="StoreCard_Number"> + Card Number + </label> + <input +title="overall type: CREDIT_CARD_NUMBER + server type: CREDIT_CARD_NUMBER + heuristic type: CREDIT_CARD_NUMBER + label: Card Number + parseable name: StoreCard_Number + field signature: 3054139089 + form signature: 16660402300910943442" datavalid="60345916" autocomplete="off" id="StoreCard_Number" type="text" name="StoreCard_Number" value="" focus="false" +autofill-prediction="CREDIT_CARD_NUMBER" +> + </div> + <div> + <div> + <label> + <input type="checkbox" checked="checked" name="saveNsccCard" id="saveCard" +title="overall type: UNKNOWN_TYPE + server type: NO_SERVER_DATA + heuristic type: UNKNOWN_TYPE + label: Save for future orders + parseable name: saveNsccCard + field signature: 2599746278 + form signature: 16660402300910943442" +autofill-prediction="UNKNOWN_TYPE" +> + Save for future orders + </label> + </div> + </div> + </div> + <div> +</div> + </div> + <div>Do not have a Newegg Store Credit Card? <a>Learn More</a> +</div> + </div> + </li> + <li> + <input type="radio" name="paymentmethod" value="Amex Express Checkout" id="amex" +title="overall type: UNKNOWN_TYPE + server type: NO_SERVER_DATA + heuristic type: UNKNOWN_TYPE + label: Amex Express Checkout + parseable name: paymentmethod + field signature: 1614873398 + form signature: 16660402300910943442" +autofill-prediction="UNKNOWN_TYPE" +> + <label for="amex"> + <strong>Amex Express Checkout</strong> + </label> + <div> + <div> + <div> + <img src="./Newegg.com - Billing Info_files/AEC_PaymentMark_Blue_48x30.png" alt="Amex Express Checkout Button"> + <p> + </p> + </div> + <div> + <strong>Checkout faster with American Express</strong> + </div> + </div> + </div> + </li> + <li> + <input type="radio" name="paymentmethod" id="bitcoin" disabled="disabled" +title="overall type: UNKNOWN_TYPE + server type: NO_SERVER_DATA + heuristic type: UNKNOWN_TYPE + label: Bitcoin + parseable name: paymentmethod + field signature: 1614873398 + form signature: 16660402300910943442" +autofill-prediction="UNKNOWN_TYPE" +> + <label for="bitcoin"> + <strong>Bitcoin</strong> + </label> + <div> + <div> + <div> + <img alt="Bitcoin accepted here" +title="Bitcoin accepted here" src="./Newegg.com - Billing Info_files/bitcoin_logo.png"> + <p> + </p> + </div> + <div> + <strong>Bitcoin is the safest and most secure way to pay online.</strong> + <ul> + <li>No identity theft risk; no payment information is ever stored.</li> + <li>Your payment completes immediately.</li> + <li> +<a target="_blank">Learn more + </a> + </li> + </ul> + </div> + <div> + <div> + <div> + <div> +</div> + <div> +<span>NOTE: </span>Please note that when using Bitcoin as your payment method, once you have clicked the "bitcoin checkout now" button, you will have only 15 minutes to complete your payment. If you are unable to complete your payment, you will have two options: You can try again later to place a new order or you can change your payment method later from Order History in My Account.<br> +<br> + All orders fully paid by Bitcoin are final and cannot be returned for Bitcoin or hard currency. All returns will be made in the form of a Newegg Gift Card. All returns follow our return policy. + </div> + </div> + </div> + </div> + </div> + </div> + </li> + <input type="hidden" name="IsMasterPassLightBoxEnable" id="IsMasterPassLightBoxEnable" value="True"> + <li> + <input type="radio" name="paymentmethod" id="masterpass" +title="overall type: UNKNOWN_TYPE + server type: NO_SERVER_DATA + heuristic type: UNKNOWN_TYPE + label: MasterPass + parseable name: paymentmethod + field signature: 1614873398 + form signature: 16660402300910943442" +autofill-prediction="UNKNOWN_TYPE" +> + <label for="masterpass"> + <strong>MasterPass</strong> + </label> + <div> + <div> + <div> + <img src="./Newegg.com - Billing Info_files/mp_mc_acc_038px_gif.gif" alt="MasterPass"> + <p> + </p> + </div> + <div> + <strong>MasterPass is a free service that is a fast, simple and safe way to check out online. It cuts down on the time and effort it takes to buy the things you want and need. And because it's from MasterCard, you can trust that it's secure. + </strong> + <ul> + <li> + When you are ready to check out online, click on the "Buy with MasterPass" button. + </li> + <li> + Next, unlock your MasterPass account to choose your payment method and shipping address. + </li> + <li> + Then simply confirm your purchase, and you are done! + </li> + <li> + Use it with all major credit, debit, and prepaid cards. + </li> + <li> + Use it to easily store all your cards and addresses in one place. + </li> + <li> + Use it on all your connected devices. + </li> + </ul> + </div> + </div> + </div> + </li> + <li> + <input type="radio" name="paymentmethod" id="visacheckout" +title="overall type: UNKNOWN_TYPE + server type: NO_SERVER_DATA + heuristic type: UNKNOWN_TYPE + label: Visa Checkout + parseable name: paymentmethod + field signature: 1614873398 + form signature: 16660402300910943442" +autofill-prediction="UNKNOWN_TYPE" +> + <label for="visacheckout"> + <strong>Visa Checkout</strong> + </label> + <div> + <div> + <div> + <span id="#visacheckoutlearnmore"> +<img src="./Newegg.com - Billing Info_files/Cart_LearnMore_Pop-Up_left.png" alt="Visa Checkout"> +</span> + <img src="./Newegg.com - Billing Info_files/POS_horizontal_99x34.png" alt="Visa Checkout"> + <ul> + <li> +<a> + Learn more + </a> + </li> + </ul> + <p> + </p> + </div> + </div> + </div> + </li> + <li> + <input type="radio" name="paymentmethod" id="paypal" +title="overall type: UNKNOWN_TYPE + server type: NO_SERVER_DATA + heuristic type: UNKNOWN_TYPE + label: PayPal + parseable name: paymentmethod + field signature: 1614873398 + form signature: 16660402300910943442" +autofill-prediction="UNKNOWN_TYPE" +> + <label for="paypal"> + <strong>PayPal</strong> + </label> + <div> + <div> + <div> + <img src="./Newegg.com - Billing Info_files/pp-acceptance-medium.png" alt="newegg"> + <ul> + <li> + Speed through checkout. + </li> + <li> + PayPal is the safer, easier way to pay. + </li> + <li> +<a> + What is PayPal? + </a> + </li> + </ul> + <p> + </p> + </div> + <div> + <strong> + PayPal is the safer, easier way to pay + </strong> + <ul> + <li> + Never expose your credit card number. + </li> + <li> + Speed through checkout all over the web. One account, one password - no need to retype your shipping or financial information. + </li> + </ul> + </div> + </div> + </div> + </li> + <li> + <input type="radio" name="paymentmethod" id="creditcard" +title="overall type: UNKNOWN_TYPE + server type: NO_SERVER_DATA + heuristic type: UNKNOWN_TYPE + label: Credit Card + parseable name: paymentmethod + field signature: 1614873398 + form signature: 16660402300910943442" +autofill-prediction="UNKNOWN_TYPE" +> + <label for="creditcard"> + <strong>Credit Card</strong> + </label> + <div id="CreditCardArea"> + <dl id="CardList"> + <div id="div_cardcvv2"> + <div> + <label> + <span>Security Code (CVV2)</span> + <input +title="overall type: CREDIT_CARD_VERIFICATION_CODE + server type: NO_SERVER_DATA + heuristic type: CREDIT_CARD_VERIFICATION_CODE + label: Security Code (CVV2) + parseable name: creditCardCVV2 + field signature: 2862210423 + form signature: 16660402300910943442" data-msg-validatecvv2="Security Code is invalid." type="text" value="" maxlength="4" id="creditCardCVV2" autocomplete="off" +autofill-prediction="CREDIT_CARD_VERIFICATION_CODE" +> + <a +title="What is this?" rel="modal" id="CVV2CodeHelper"> + <img src="./Newegg.com - Billing Info_files/cvv2_sm.gif" alt="cvv2"> +</a> + </label> + </div> + </div> + <dt> + <input id="AddNewCareditCard" name="cardList_R" type="radio" value="new" +title="overall type: UNKNOWN_TYPE + server type: NO_SERVER_DATA + heuristic type: UNKNOWN_TYPE + label: Add New Credit Card + parseable name: cardList_R + field signature: 513212207 + form signature: 16660402300910943442" +autofill-prediction="UNKNOWN_TYPE" +> + <div> + <label for="AddNewCareditCard">Add New Credit Card</label> + </div> + </dt> + <dd> + <div> + <div> + <p> + <strong>All major credit cards accepted:</strong> + <span> + <img id="ImgDiscover" graysrc="https://ssl-images.newegg.com/WebResource/Themes/2005/Nest/img_ccDnetwork_v1_grey.gif" src="./Newegg.com - Billing Info_files/img_ccDnetwork_v1.gif" alt="Major Cards Accepted"> + <img id="ImgMastercard" graysrc="https://ssl-images.newegg.com/WebResource/Themes/2005/Nest/img_ccMastercard_grey.gif" src="./Newegg.com - Billing Info_files/img_ccMastercard.gif" alt="Major Cards Accepted"> + <img id="ImgAmex" graysrc="https://ssl-images.newegg.com/WebResource/Themes/2005/Nest/img_ccAmex_grey.gif" src="./Newegg.com - Billing Info_files/img_ccAmex.gif" alt="Major Cards Accepted"> + <img id="ImgVisa" graysrc="https://ssl-images.newegg.com/WebResource/Themes/2005/Nest/img_ccVisa_grey.gif" src="./Newegg.com - Billing Info_files/img_ccVisa.gif" alt="Major Cards Accepted"> + </span> + </p> + </div> + <div> + <label for="Card_HolderNameNew"> + Cardholder Name + <span>(exactly as shown on card)</span> + </label> + <input +title="overall type: CREDIT_CARD_NAME_FULL + server type: CREDIT_CARD_NAME_FULL + heuristic type: CREDIT_CARD_NAME_FULL + label: Cardholder Name (exactly as shown on card) + parseable name: Card_HolderNameNew + field signature: 3439434497 + form signature: 16660402300910943442" type="text" maxlength="80" name="Card_HolderNameNew" id="Card_HolderNameNew" value="" focus="false" +autofill-prediction="CREDIT_CARD_NAME_FULL" +> + </div> + <div> + <label for="Card_CCNUMBERNEW"> + Card Number + </label> + <input +title="overall type: CREDIT_CARD_NUMBER + server type: CREDIT_CARD_NUMBER + heuristic type: CREDIT_CARD_NUMBER + label: Card Number + parseable name: Card_CCNUMBERNEW + field signature: 1223289945 + form signature: 16660402300910943442" autocomplete="off" id="Card_CCNUMBERNEW" type="text" name="Card_CCNUMBERNEW" value="" onkeyup="if ('True' == 'True') { + Biz.Payment.AmexPoints.OnChangeOfCardNumberForNewInGlobalCheckoutStep2();}" focus="false" +autofill-prediction="CREDIT_CARD_NUMBER" +> + <img alt="Major Cards Accepted"> + </div> + <div> + <label for="Card_exp_monthNew"> + Expiration Date + </label> + <select name="Card_exp_monthNew" id="Card_exp_monthNew" size="1" focus="false" +title="overall type: CREDIT_CARD_EXP_MONTH + server type: CREDIT_CARD_EXP_MONTH + heuristic type: CREDIT_CARD_EXP_MONTH + label: Expiration Date + parseable name: Card_exp_monthNew + field signature: 52703496 + form signature: 16660402300910943442" +autofill-prediction="CREDIT_CARD_EXP_MONTH" +> + <option value="Month">Month</option> + <option value="01">01</option> + <option value="02">02</option> + <option value="03">03</option> + <option value="04">04</option> + <option value="05">05</option> + <option value="06">06</option> + <option value="07">07</option> + <option value="08">08</option> + <option value="09">09</option> + <option value="10">10</option> + <option value="11">11</option> + <option value="12">12</option> + </select> + <select name="Card_exp_yearNew" id="Card_exp_yearNew" size="1" focus="false" +title="overall type: CREDIT_CARD_EXP_4_DIGIT_YEAR + server type: CREDIT_CARD_EXP_4_DIGIT_YEAR + heuristic type: CREDIT_CARD_EXP_4_DIGIT_YEAR + label: Expiration Date + parseable name: Card_exp_yearNew + field signature: 3677977052 + form signature: 16660402300910943442" +autofill-prediction="CREDIT_CARD_EXP_4_DIGIT_YEAR" +> + <option value="Year">Year</option> + <option value="2017">2017</option> + <option value="2018">2018</option> + <option value="2019">2019</option> + <option value="2020">2020</option> + <option value="2021">2021</option> + <option value="2022">2022</option> + <option value="2023">2023</option> + <option value="2024">2024</option> + <option value="2025">2025</option> + <option value="2026">2026</option> + <option value="2027">2027</option> + <option value="2028">2028</option> + <option value="2029">2029</option> + <option value="2030">2030</option> + </select> + </div> + <div> + <label for="cvv2code"> + <span>Security Code (CVV2)</span> + </label> + <input +title="overall type: CREDIT_CARD_VERIFICATION_CODE + server type: NO_SERVER_DATA + heuristic type: CREDIT_CARD_VERIFICATION_CODE + label: Security Code (CVV2) + parseable name: cvv2code + field signature: 1678191663 + form signature: 16660402300910943442" data-msg-validatecvv2="Security Code is invalid." type="text" value="" maxlength="4" name="cvv2code" id="cvv2code" autocomplete="off" +autofill-prediction="CREDIT_CARD_VERIFICATION_CODE" +> + <a +title="What is this?" rel="modal" id="CVV2CodeHelper"> + <img src="./Newegg.com - Billing Info_files/cvv2_sm.gif" alt="cvv2"> +</a> + </div> + <div> + <div> + <label> + <input type="checkbox" checked="checked" name="saveCard" id="saveCard" +title="overall type: UNKNOWN_TYPE + server type: NO_SERVER_DATA + heuristic type: UNKNOWN_TYPE + label: Save for future orders + parseable name: saveCard + field signature: 4084541912 + form signature: 16660402300910943442" +autofill-prediction="UNKNOWN_TYPE" +> + Save for future orders + </label> + </div> + <div> + <label> + Save Credit Card As: + <input type="text" value="untitled" maxlength="80" id="Card_PaytermLabel" name="Card_PaytermLabel" +title="overall type: UNKNOWN_TYPE + server type: NO_SERVER_DATA + heuristic type: UNKNOWN_TYPE + label: Save Credit Card As: + parseable name: Card_PaytermLabel + field signature: 3910487729 + form signature: 16660402300910943442" +autofill-prediction="UNKNOWN_TYPE" +> + </label> + </div> + <div> + <label> + <input type="checkbox" checked="checked" name="IsNotDefault" id="makeDefaultOptionNew" +title="overall type: UNKNOWN_TYPE + server type: NO_SERVER_DATA + heuristic type: UNKNOWN_TYPE + label: Set as default credit card + parseable name: IsNotDefault + field signature: 3972010007 + form signature: 16660402300910943442" +autofill-prediction="UNKNOWN_TYPE" +> + Set as default credit card + </label> + </div> + </div> + </div> + <div> +</div> + <input type="hidden" name="Card_BankPhone" id="Card_BankPhone" value="888-888-8888"> + <input type="hidden" name="Card_TransactionNumber" id="Card_TransactionNumber" value=""> + <input type="hidden" name="Card_HolderName" id="Card_HolderName" value=""> + <input type="hidden" name="Card_Number" id="Card_Number" value=""> + <input type="hidden" name="ReEnter_Card_Number" id="ReEnter_Card_Number" value=""> + <input type="hidden" name="IS_Mark_Default" id="IS_Mark_Default" value=""> + <input type="hidden" name="Card_exp_month" id="Card_exp_month" value=""> + <input type="hidden" name="Card_exp_year" id="Card_exp_year" value=""> + <input type="hidden" name="Card_CVV2" id="Card_CVV2" value=""> + <input type="hidden" name="Card_IsDefault" id="Card_IsDefault" value=""> + <input type="hidden" name="Card_CCTYPE" id="Card_CCTYPE" value=""> + <input type="hidden" name="IsUsedAmexSaveCard" id="IsUsedAmexSaveCard" value="false"> + <input type="hidden" id="defalutSelectCreditCardRow" value="AddNewCareditCard"> + <input type="hidden" name="supportCTypes" id="supportCTypes" value="4,5,6,3"> + <input type="hidden" name="IsAMEXDistributedCard" value="False"> + <input type="hidden" name="AMEXDistributedCard" value=""> + <input type="hidden" name="AMEXDistributedCardVI" value=""> + <input type="hidden" name="AMEXDistributedCardRequestID" value=""> + <input type="hidden" id="cardNumber-1" value=""> + </dd> + </dl> + </div> + </li> + </ul> + <input type="hidden" id="defaultSelectPaytermMethod" value="creditcard" readonly="readonly"> + </div> + <div> + Billing Address + <span> +</span> + </div> + <ul> + <li id="address-chooser"> + <input type="checkbox" id="billingsameaddress" name="IsBilling" checked="" value="yes" +title="overall type: UNKNOWN_TYPE + server type: NO_SERVER_DATA + heuristic type: UNKNOWN_TYPE + label: Same as the shipping address + parseable name: IsBilling + field signature: 1298880426 + form signature: 16660402300910943442" +autofill-prediction="UNKNOWN_TYPE" +> + <label for="billingsameaddress"> + Same as the shipping address + </label> + </li> + <li id="same-address"> + </li> + <li id="new-address"> + <div> + <input type="hidden" name="STransNumber" value="0"> + <input type="hidden" name="action" value=""> + <input type="hidden" name="OldContactWith" value=""> + <ul> + <li> + <label> + Country + <span>(<strong>NOTE: </strong> +<em>Billing country cannot be different from shipping country you selected in previous step.</em>)</span> + </label> + <select name="SCountry_Option" selectcountry="USA" id="countryOption" tabindex="0" size="1" +title="overall type: ADDRESS_HOME_COUNTRY + server type: ADDRESS_HOME_COUNTRY + heuristic type: ADDRESS_HOME_COUNTRY + label: Country (NOTE: Billing country cannot be different from shipping country you selected in previous st + parseable name: SCountry_Option + field signature: 3459145984 + form signature: 16660402300910943442" +autofill-prediction="ADDRESS_HOME_COUNTRY" +> + <option value="USA">United States</option> + </select> + </li> + <li> + <label>Address</label> + <input +title="overall type: ADDRESS_HOME_LINE1 + server type: ADDRESS_HOME_LINE1 + heuristic type: ADDRESS_HOME_LINE1 + label: Address + parseable name: SAddress1 + field signature: 328501296 + form signature: 16660402300910943442" type="text" name="SAddress1" id="SAddress1" maxlength="100" value="" placeholder="" +autofill-prediction="ADDRESS_HOME_LINE1" +> + </li> + <li> + <label> + Address 2 + <span> +<strong>(OPTIONAL)</strong> +<em>Apartment, suite, floor, etc.</em> +</span> +</label> + <input +title="overall type: ADDRESS_HOME_LINE2 + server type: ADDRESS_HOME_LINE2 + heuristic type: ADDRESS_HOME_LINE2 + label: Address 2 (OPTIONAL) Apartment, suite, floor, etc. + parseable name: SAddress2 + field signature: 26232370 + form signature: 16660402300910943442" type="text" name="SAddress2" id="SAddress2" maxlength="100" value="" placeholder="" +autofill-prediction="ADDRESS_HOME_LINE2" +> + </li> + <li> + <ul> + <li> + <div> + <label id="lblCity">City</label> + <input +title="overall type: ADDRESS_HOME_CITY + server type: ADDRESS_HOME_CITY + heuristic type: ADDRESS_HOME_CITY + label: City + parseable name: SCity + field signature: 1173963820 + form signature: 16660402300910943442" type="text" name="SCity" id="SCity" maxlength="45" value="" +autofill-prediction="ADDRESS_HOME_CITY" +> + </div> + </li> + <li> + <div> + <label for="stateDropDownList" id="lblState">State/Province/Region</label> + <div> + <select selectstate="CA" name="SState_Option" id="SState_Option_USA" tabindex="0" size="1" +title="overall type: ADDRESS_HOME_STATE + server type: ADDRESS_HOME_STATE + heuristic type: ADDRESS_HOME_STATE + label: State/Province/Region + parseable name: SState_Option + field signature: 3077158397 + form signature: 16660402300910943442" +autofill-prediction="ADDRESS_HOME_STATE" +> + <option value="AA">AA</option> + <option value="AP">AP</option> + <option value="AE">AE</option> + <option value="AL">ALABAMA</option> + <option value="AK">ALASKA</option> + <option value="AS">AMERICAN SAMOA</option> + <option value="AZ">ARIZONA</option> + <option value="AR">ARKANSAS</option> + <option value="CA">CALIFORNIA</option> + <option value="CO">COLORADO</option> + <option value="CT">CONNECTICUT</option> + <option value="DE">DELAWARE</option> + <option value="DC">DISTRICT OF COLUMBIA</option> + <option value="FL">FLORIDA</option> + <option value="GA">GEORGIA</option> + <option value="HI">HAWAII</option> + <option value="ID">IDAHO</option> + <option value="IL">ILLINOIS</option> + <option value="IN">INDIANA</option> + <option value="IA">IOWA</option> + <option value="KS">KANSAS</option> + <option value="KY">KENTUCKY</option> + <option value="LA">LOUISIANA</option> + <option value="ME">MAINE</option> + <option value="MD">MARYLAND</option> + <option value="MA">MASSACHUSETTS</option> + <option value="MI">MICHIGAN</option> + <option value="MN">MINNESOTA</option> + <option value="MS">MISSISSIPPI</option> + <option value="MO">MISSOURI</option> + <option value="MT">MONTANA</option> + <option value="NE">NEBRASKA</option> + <option value="NV">NEVADA</option> + <option value="NH">NEW HAMPSHIRE</option> + <option value="NJ">NEW JERSEY</option> + <option value="NM">NEW MEXICO</option> + <option value="NY">NEW YORK</option> + <option value="NC">NORTH CAROLINA</option> + <option value="ND">NORTH DAKOTA</option> + <option value="OH">OHIO</option> + <option value="OK">OKLAHOMA</option> + <option value="OR">OREGON</option> + <option value="PA">PENNSYLVANIA</option> + <option value="PR">PUERTO RICO</option> + <option value="RI">RHODE ISLAND</option> + <option value="SC">SOUTH CAROLINA</option> + <option value="SD">SOUTH DAKOTA</option> + <option value="TN">TENNESSEE</option> + <option value="TX">TEXAS</option> + <option value="VI">U.S. Virgin Islands</option> + <option value="UT">UTAH</option> + <option value="VT">VERMONT</option> + <option value="VA">VIRGINIA</option> + <option value="WA">WASHINGTON</option> + <option value="WV">WEST VIRGINIA</option> + <option value="WI">WISCONSIN</option> + <option value="WY">WYOMING</option> + </select> + </div> + </div> + <div> + <label id="lblZip">Zip/Postal Code</label> + <input +title="overall type: ADDRESS_HOME_ZIP + server type: ADDRESS_HOME_ZIP + heuristic type: ADDRESS_HOME_ZIP + label: Zip/Postal Code + parseable name: SZip + field signature: 3887080504 + form signature: 16660402300910943442" maxlength="20" size="20" type="text" name="SZip" id="SZip" value="" +autofill-prediction="ADDRESS_HOME_ZIP" +> + </div> + </li> + <li> + <label> + Phone + </label> + <input +title="overall type: PHONE_HOME_CITY_AND_NUMBER + server type: PHONE_HOME_CITY_AND_NUMBER + heuristic type: PHONE_HOME_WHOLE_NUMBER + label: Phone + parseable name: ShippingPhone + field signature: 3574122515 + form signature: 16660402300910943442" type="text" name="ShippingPhone" maxlength="30" id="ShippingPhone" value="" +autofill-prediction="PHONE_HOME_CITY_AND_NUMBER" +> + </li> + <li> + <label> + + </label> + <label> + </label> + </li> + <input type="hidden" id="IsDefault" name="IsDefault"> + <input type="hidden" name="SContactWith" value="Tester Mo"> + </ul> + </li> + </ul> + </div> + </li> + <input type="hidden" name="BCountry" value="USA"> + <input type="hidden" name="BAddress1" value="331 E. Evelyn Avenue"> + <input type="hidden" name="BAddress2" value=""> + <input type="hidden" name="BCity" value="Mountain View"> + <input type="hidden" name="BState" value="CA"> + <input type="hidden" name="BZip" value="94041"> + <input type="hidden" name="CPCPostalCodeForDisplay" value=""> + <input type="hidden" name="IsCanadaPost" value="False"> + <input type="hidden" name="BPhone" value="650-903-0800"> + <input type="hidden" name="BContactWith" value="Tester Mo"> + <input type="hidden" name="hiddenSCountry" value="USA"> + <input type="hidden" name="BIsInPost" value="False"> + </ul> + <input type="hidden" id="cfAppendix" name="cfAppendix"> + <input type="hidden" id="isIgnoreBillingFocus" value="False"> + <input type="hidden" name="IsReEnterCreditCardValid" id="IsReEnterCreditCardValid" value="True"> + <input type="hidden" name="VMESuccessfulReturnInfo" id="VMESuccessfulReturnInfo"> + <input type="hidden" name="SubmitTypeValue" id="SubmitTypeValue" value=""> + <input type="hidden" name="lastPurchaseDate" id="lastPurchaseDate" value="1/1/0001 12:00:00 AM"> + <input type="hidden" id="GoogleWalletMaskedRequest" name="GoogleWalletMaskedRequest" value=""> + <input type="hidden" id="GoogleWalletMaskedResponse" name="GoogleWalletMaskedResponse"> + <input type="hidden" name="IsEnoughGCAmount" value="0"> + <input type="hidden" name="CustomerCardCVV2IsRequired" id="CustomerCardCVV2IsRequired" value="1"> + <input type="hidden" name="CustomerAppendix" id="CustomerAppendix" value="+KrrFNdT/kbgvDIajN498xXVkpXHtGV4IGEz2zWZhtCaE5W/pB02+iKMnppgs68Q"> + </form> + <form action="https://secure.newegg.com/Common/Ajax/SavePaymentByAjax.aspx" method="POST" id="creditCardCommandEdit_Form" name="creditCardCommandEdit_Form" novalidate="novalidate"> + <div> + <div> +</div> + <ul> + <li> + <label for="Card_PaytermLabel"> + Save As + <span> + <em>(a nickname for easy recognition)</em> + </span> + </label> + <input type="text" value="" id="Card_PaytermLabel" name="Card_PaytermLabel" maxlength="80" +title="overall type: UNKNOWN_TYPE + server type: NO_SERVER_DATA + heuristic type: UNKNOWN_TYPE + label: Save As (a nickname for easy recognition) + parseable name: Card_PaytermLabel + field signature: 3910487729 + form signature: 15313939527407611188" +autofill-prediction="UNKNOWN_TYPE" +> + </li> + <li> + <label for="Card_HolderName"> + Cardholder Name + <span> + <em>(exactly as shown on card)</em> + </span> + </label> + <input +title="overall type: CREDIT_CARD_NAME_FULL + server type: CREDIT_CARD_NAME_FULL + heuristic type: CREDIT_CARD_NAME_FULL + label: Cardholder Name (exactly as shown on card) + parseable name: Card_HolderName + field signature: 2535895036 + form signature: 15313939527407611188" type="text" maxlength="80" name="Card_HolderName" id="Card_HolderName" focus="false" +autofill-prediction="CREDIT_CARD_NAME_FULL" +> + </li> + <li> + <label for="Card_Number"> + Card Number + </label> + <input +title="overall type: CREDIT_CARD_NUMBER + server type: CREDIT_CARD_NUMBER + heuristic type: CREDIT_CARD_NUMBER + label: Card Number + parseable name: Card_Number + field signature: 3930555619 + form signature: 15313939527407611188" autocomplete="off" id="Card_Number" type="text" name="Card_Number" focus="false" +autofill-prediction="CREDIT_CARD_NUMBER" +> + <img id="CardImg" name="CardImg" alt="Major Cards Accepted"> + </li> + <li> + <label for="Card_exp_month"> + Expiration Date + </label> + <select name="Card_exp_month" id="Card_exp_month" size="1" focus="false" +title="overall type: CREDIT_CARD_EXP_MONTH + server type: CREDIT_CARD_EXP_MONTH + heuristic type: CREDIT_CARD_EXP_MONTH + label: Expiration Date + parseable name: Card_exp_month + field signature: 3840696256 + form signature: 15313939527407611188" +autofill-prediction="CREDIT_CARD_EXP_MONTH" +> + <option value="Month">Month</option> + <option value="01">01</option> + <option value="02">02</option> + <option value="03">03</option> + <option value="04">04</option> + <option value="05">05</option> + <option value="06">06</option> + <option value="07">07</option> + <option value="08">08</option> + <option value="09">09</option> + <option value="10">10</option> + <option value="11">11</option> + <option value="12">12</option> + </select> + <select name="Card_exp_year" id="Card_exp_year" size="1" focus="false" +title="overall type: CREDIT_CARD_EXP_4_DIGIT_YEAR + server type: CREDIT_CARD_EXP_4_DIGIT_YEAR + heuristic type: CREDIT_CARD_EXP_4_DIGIT_YEAR + label: Expiration Date + parseable name: Card_exp_year + field signature: 185580832 + form signature: 15313939527407611188" +autofill-prediction="CREDIT_CARD_EXP_4_DIGIT_YEAR" +> + <option value="Year">Year</option> + <option value="2017">2017</option> + <option value="2018">2018</option> + <option value="2019">2019</option> + <option value="2020">2020</option> + <option value="2021">2021</option> + <option value="2022">2022</option> + <option value="2023">2023</option> + <option value="2024">2024</option> + <option value="2025">2025</option> + <option value="2026">2026</option> + <option value="2027">2027</option> + <option value="2028">2028</option> + <option value="2029">2029</option> + <option value="2030">2030</option> + </select> + </li> + <li> + <label id="makeCCDefaultOptionNew"> + <input type="checkbox" id="makeDefaultOptionNew" name="Card_IsDefault" +title="overall type: UNKNOWN_TYPE + server type: NO_SERVER_DATA + heuristic type: UNKNOWN_TYPE + label: Set as default credit card + parseable name: Card_IsDefault + field signature: 1494055862 + form signature: 15313939527407611188" +autofill-prediction="UNKNOWN_TYPE" +> + Set as default credit card + </label> + </li> + <li> +</li> + </ul> + </div> + <input type="hidden" name="IsInBillingPage" id="IsInBillingPage" value="True"> + <input type="hidden" name="action" value="edit"> + <input type="hidden" name="selectIndex" value=""> + <input type="hidden" name="Card_CCTYPE" id="Card_CCTYPE" value=""> + <input type="hidden" name="Card_BankPhone" id="Card_BankPhone" value="888-888-8888"> + <input type="hidden" name="Card_TransactionNumber" id="Card_TransactionNumber" value=""> + <input type="hidden" name="supportCTypes" id="supportCTypes" value="4,5,6,3"> + </form> + <form action="https://secure.newegg.com/Common/Ajax/SavePaymentByAjax.aspx" method="POST" id="creditCardCommandRemove_Form" name="creditCardCommandRemove_Form" novalidate="novalidate"> + <div> + <div> +</div> + </div> + <input type="hidden" name="action" value="remove"> + <input type="hidden" name="selectIndex" value=""> + <input type="hidden" name="Card_TransactionNumber" value=""> + </form> + <form action="https://secure.newegg.com/Common/Ajax/SavePaymentByAjax.aspx" method="POST" id="NsccCardCommandEdit_Form" name="NsccCardCommandEdit_Form" novalidate="novalidate"> + <div> + <div> +</div> + <ul> + <li> + <label for="Card_HolderName"> + Cardholder Name + <span> + <em>(exactly as shown on card)</em> + </span> + </label> + <input +title="Cardholder Name" type="text" maxlength="80" name="StoreCard_HolderName" id="StoreCard_HolderName" focus="false"> + </li> + <li> + <label for="StoreCard_Number"> + Card Number + </label> + <input +title="Card Number" datavalid="60345916" autocomplete="off" id="StoreCard_Number" type="text" name="StoreCard_Number" focus="false"> + </li> + <li> + </li> + <input type="hidden" name="action" value="nsccedit"> + <input type="hidden" name="StoreCard_TransactionNumber" value=""> + </ul> + </div> + </form> + </body> +</html> diff --git a/browser/extensions/formautofill/test/fixtures/third_party/NewEgg/Login.html b/browser/extensions/formautofill/test/fixtures/third_party/NewEgg/Login.html new file mode 100644 index 0000000000..a5f149b683 --- /dev/null +++ b/browser/extensions/formautofill/test/fixtures/third_party/NewEgg/Login.html @@ -0,0 +1,156 @@ +<!DOCTYPE html> +<html lang="en-us"> + <head> + <meta http-equiv="Content-Type" content="text/html; charset=UTF-8"> + <title>Newegg.com - Login</title> + <meta name="keywords" content="Newegg.com - Once You Know, You Newegg"> + <meta name="description" content="Newegg.com offers the best prices on computer parts, laptop computers, digital cameras, electronics and more with fast shipping and top-rated customer service. Once you know, you Newegg!"> + <meta name="google-translate-customization" content="d08b8c829bab9a46-ac914e23c0a3607a-g7fba27d07436db8a-e"> + <meta name="language" content="english"> + <meta name="copyright" content="© 2000-2017 Newegg Inc."> + <meta http-equiv="X-UA-Compatible" content="IE=edge"> + <meta name="robots" content="index,follow"> + </head> + <body> + <form method="post" action="https://secure.newegg.com/Shopping/ShoppingLogin.aspx" name="loginForm" id="loginForm" novalidate="novalidate"> + <span id="CaptchaS"> +</span> + <input type="hidden" name="nextpage" value="https://secure.newegg.com/GlobalShopping/CheckoutStep1.aspx?CartID=797%2b903ZRCECSZWBJE0&PaymentType=SIMPLECHECKOUT&IsFromCart=1"> + <input type="hidden" name="StuTargetPage" value=""> + <input type="hidden" name="action" value="login"> + <input type="hidden" name="ShoppingLoginFlag" value="0"> + <input type="hidden" name="SPSign" value=""> + <input type="hidden" name="LoginAppendix" value="x0kFyaA7FPQ7xpTt6JRs7tjF3jD2dY/TYhVkLYYFvo6cyOJwvqVUNlxxUjSZYUB0"> + <ul> + <li> + <label for="UserName"> + Newegg ID + <span> + (Typically your email address) + </span> + </label> + <input type="text" tabindex="5" maxlength="128" size="15" +title="Email Address" id="UserName" name="UserName" value=""> + </li> + <li> + <label for="UserPwd"> + Password + </label> + <input type="password" tabindex="6" maxlength="30" size="17" +title="Password" id="UserPwd" name="UserPwd"> + </li> + <li> + <input type="checkbox" value="1" id="IsRemember" name="IsRemember" tabindex="8"> +<label for="IsRemember">Remember Me</label> + </li> + <li id="guestloginforgot"> +<a +title="Forgot your Newegg ID or Password?"> + Forgot your Newegg ID or Password? + </a> + </li> + <li> + <div> + <div> + </div> + <a tabindex="7" id="submit"> + SIGN IN <span>►</span> + </a> + </div> + </li> + <li> + <input type="checkbox" value="1" id="IsSubscribe" name="Newsletter" tabindex="9"> +<label for="IsSubscribe">Subscribe for exclusive e-mail offers and discounts</label> + </li> + </ul> + <input type="hidden" id="cfAppendix" name="cfAppendix"> + <input type="hidden" name="LoginAdditional" value="V6QdNc8GTJxKoxj5ef1cRLAlFLzcWhGcVj7od0iDpBUAd2FabNw7ApfN0g6LtbiB7EHyhgAaqpOdFbyt3NLuwPc9y1fkPs72+5qzSWHzJu19uWH+4mAefmVtLbXxTeIyGjEzvpanJrgib/dw61t1qZBot9DAigxz"> + <input type="hidden" name="IsPrevCaptcha" value="False"> + <input type="hidden" id="GoogleReCAPTCHA" name="GoogleReCAPTCHA" value="False"> + <input type="hidden" id="hiddenServerDateTimeNow" value="Mon, 20 Mar 2017 03:43:47 GMT"> + <input type="hidden" id="hiddenAllowClientTimeDiffInMinute" value="10"> + </form> + <form method="post" action="https://secure.newegg.com/Shopping/ShoppingLogin.aspx" name="guestCheckOutForm" id="guestCheckOutForm" novalidate="novalidate"> + <input type="hidden" name="action" value="guestckeckout"> + <a rel="">CONTINUE AS A GUEST <span>►</span> +</a> + </form> + <form name="registerForm" id="registerForm" method="post" action="https://secure.newegg.com/Shopping/ShoppingLogin.aspx" novalidate="novalidate"> + <input type="hidden" value="https://secure.newegg.com/GlobalShopping/CheckoutStep1.aspx?CartID=797%2b903ZRCECSZWBJE0&PaymentType=SIMPLECHECKOUT&IsFromCart=1" name="NextPage"> + <input type="hidden" name="ShoppingLoginFlag" value="0"> + <input type="hidden" name="action" value="register"> + <input type="hidden" name="SPSign" value=""> + <input type="hidden" name="LoginAppendix" id="LoginAppendix" value="x0kFyaA7FPQ7xpTt6JRs7tjF3jD2dY/TYhVkLYYFvo6cyOJwvqVUNlxxUjSZYUB0"> + <input type="hidden" name="IsPrevCaptcha" value="False"> + <ul> + <li> +<label for="LoginName">Email Address</label> +<input name="LoginName" id="LoginName" tabindex="10" maxlength="128" +title="overall type: EMAIL_ADDRESS + server type: EMAIL_ADDRESS + heuristic type: EMAIL_ADDRESS + label: Email Address + parseable name: LoginName + field signature: 1679789274 + form signature: 17613573414325428514" value="" type="text" +autofill-prediction="EMAIL_ADDRESS" +> +</li> + <li> +<label for="Password">Password</label> +<input name="Password" id="Password" tabindex="12" type="password" +title="overall type: ACCOUNT_CREATION_PASSWORD + server type: ACCOUNT_CREATION_PASSWORD + heuristic type: UNKNOWN_TYPE + label: Password + parseable name: Password + field signature: 3935904101 + form signature: 17613573414325428514" +autofill-prediction="ACCOUNT_CREATION_PASSWORD" +> +</li> + <li> +<label for="LoginName1">Confirm Email Address</label> +<input name="LoginName1" tabindex="11" maxlength="128" id="LoginName1" +title="overall type: EMAIL_ADDRESS + server type: EMAIL_ADDRESS + heuristic type: EMAIL_ADDRESS + label: Confirm Email Address + parseable name: LoginName1 + field signature: 2268781658 + form signature: 17613573414325428514" value="" type="text" +autofill-prediction="EMAIL_ADDRESS" +> +</li> + <li> +<label for="Password1">Confirm Password</label> +<input name="Password1" id="Password1" tabindex="13" +title="overall type: UNKNOWN_TYPE + server type: NO_SERVER_DATA + heuristic type: UNKNOWN_TYPE + label: Confirm Password + parseable name: Password1 + field signature: 1108250005 + form signature: 17613573414325428514" type="password" +autofill-prediction="UNKNOWN_TYPE" +> +</li> + <li> +</li> + <li> + <input id="IsSubscribe2" type="checkbox" value="1" name="Newsletter" tabindex="15" checked="" +title="overall type: UNKNOWN_TYPE + server type: NO_SERVER_DATA + heuristic type: UNKNOWN_TYPE + label: Subscribe for exclusive e-mail offers and discounts + parseable name: Newsletter + field signature: 2681942707 + form signature: 17613573414325428514" +autofill-prediction="UNKNOWN_TYPE"> +<label for="IsSubscribe2" +>Subscribe for exclusive e-mail offers and discounts</label> + </li> + </ul> + </form> + </body> +</html> diff --git a/browser/extensions/formautofill/test/fixtures/third_party/NewEgg/ShippingInfo.html b/browser/extensions/formautofill/test/fixtures/third_party/NewEgg/ShippingInfo.html new file mode 100644 index 0000000000..14bd9502af --- /dev/null +++ b/browser/extensions/formautofill/test/fixtures/third_party/NewEgg/ShippingInfo.html @@ -0,0 +1,270 @@ +<!DOCTYPE html> +<html lang="en-us"> + <head> + <meta http-equiv="Content-Type" content="text/html; charset=UTF-8"> + <title>Newegg.com - Shipping Info</title> + <meta name="keywords" content="Newegg.com - Once You Know, You Newegg"> + <meta name="description" content="Newegg.com offers the best prices on computer parts, laptop computers, digital cameras, electronics and more with fast shipping and top-rated customer service. Once you know, you Newegg!"> + <meta name="google-translate-customization" content="d08b8c829bab9a46-ac914e23c0a3607a-g7fba27d07436db8a-e"> + <meta name="language" content="english"> + <meta name="copyright" content="© 2000-2017 Newegg Inc."> + <meta http-equiv="X-UA-Compatible" content="IE=edge"> + <meta name="robots" content="index,follow"> + </head> + <body> + <form id="checkout-shipping" action="https://secure.newegg.com/GlobalShopping/CheckoutStep1.aspx?CartID=415%2b903ZRCECSZWBJE0&IsCombineGuest=1" method="POST" novalidate="novalidate"> + <input type="hidden" name="SubmitFlag" value="true"> + <input type="hidden" name="STransNumber" value="0"> + <input type="hidden" name="action" value=""> + <input type="hidden" name="OldContactWith" value=""> + <ul> + <li> + <label> + First Name + </label> + <input validgroup="g1" +title="overall type: NAME_FIRST + server type: NAME_FIRST + heuristic type: NAME_FIRST + label: First Name + parseable name: SFirstName + field signature: 3883597444 + form signature: 7987654844929240962" type="text" name="SFirstName" id="SFirstName" maxlength="80" value="" +autofill-prediction="NAME_FIRST" enablevalid="true" +> + </li> + <li> + <label> + Last Name + </label> + <input validgroup="g1" +title="overall type: NAME_LAST + server type: NAME_LAST + heuristic type: NAME_LAST + label: Last Name + parseable name: SLastName + field signature: 1256168210 + form signature: 7987654844929240962" type="text" name="SLastName" id="SLastName" maxlength="80" value="" +autofill-prediction="NAME_LAST" enablevalid="true" +> + </li> + <li> + <label> + Country + </label> + <select name="SCountry_Option" selectcountry="USA" id="countryOption" tabindex="0" size="1" +title="overall type: ADDRESS_HOME_COUNTRY + server type: ADDRESS_HOME_COUNTRY + heuristic type: ADDRESS_HOME_COUNTRY + label: Country + parseable name: SCountry_Option + field signature: 3459145984 + form signature: 7987654844929240962" +autofill-prediction="ADDRESS_HOME_COUNTRY" +> + <option value="USA">United States</option> + </select> + </li> + <li> + <label>Address</label> + <input +title="overall type: ADDRESS_HOME_LINE1 + server type: ADDRESS_HOME_LINE1 + heuristic type: ADDRESS_HOME_LINE1 + label: Address + parseable name: SAddress1 + field signature: 328501296 + form signature: 7987654844929240962" type="text" name="SAddress1" id="SAddress1" maxlength="100" value="" placeholder="" +autofill-prediction="ADDRESS_HOME_LINE1" +> + </li> + <li> + <label> + Address 2 + <span> +<strong>(OPTIONAL)</strong> +<em>Apartment, suite, floor, etc.</em> +</span> +</label> + <input +title="overall type: ADDRESS_HOME_LINE2 + server type: ADDRESS_HOME_LINE2 + heuristic type: ADDRESS_HOME_LINE2 + label: Address 2 (OPTIONAL) Apartment, suite, floor, etc. + parseable name: SAddress2 + field signature: 26232370 + form signature: 7987654844929240962" type="text" name="SAddress2" id="SAddress2" maxlength="100" value="" placeholder="" +autofill-prediction="ADDRESS_HOME_LINE2" +> + </li> + <li> + <ul> + <li> + <div> + <label id="lblCity">City</label> + <input +title="overall type: ADDRESS_HOME_CITY + server type: ADDRESS_HOME_CITY + heuristic type: ADDRESS_HOME_CITY + label: City + parseable name: SCity + field signature: 1173963820 + form signature: 7987654844929240962" type="text" name="SCity" id="SCity" maxlength="45" value="" +autofill-prediction="ADDRESS_HOME_CITY" +> + </div> + </li> + <li> + <div> + <label for="stateDropDownList" id="lblState">State/Province/Region</label> + <div> + <select selectstate="" name="SState_Option" id="SState_Option_USA" tabindex="0" size="1" +title="overall type: ADDRESS_HOME_STATE + server type: ADDRESS_HOME_STATE + heuristic type: ADDRESS_HOME_STATE + label: State/Province/Region + parseable name: SState_Option + field signature: 3077158397 + form signature: 7987654844929240962" +autofill-prediction="ADDRESS_HOME_STATE" +> + <option value="AA">AA</option> + <option value="AP">AP</option> + <option value="AE">AE</option> + <option value="AL">ALABAMA</option> + <option value="AK">ALASKA</option> + <option value="AS">AMERICAN SAMOA</option> + <option value="AZ">ARIZONA</option> + <option value="AR">ARKANSAS</option> + <option value="CA">CALIFORNIA</option> + <option value="CO">COLORADO</option> + <option value="CT">CONNECTICUT</option> + <option value="DE">DELAWARE</option> + <option value="DC">DISTRICT OF COLUMBIA</option> + <option value="FL">FLORIDA</option> + <option value="GA">GEORGIA</option> + <option value="HI">HAWAII</option> + <option value="ID">IDAHO</option> + <option value="IL">ILLINOIS</option> + <option value="IN">INDIANA</option> + <option value="IA">IOWA</option> + <option value="KS">KANSAS</option> + <option value="KY">KENTUCKY</option> + <option value="LA">LOUISIANA</option> + <option value="ME">MAINE</option> + <option value="MD">MARYLAND</option> + <option value="MA">MASSACHUSETTS</option> + <option value="MI">MICHIGAN</option> + <option value="MN">MINNESOTA</option> + <option value="MS">MISSISSIPPI</option> + <option value="MO">MISSOURI</option> + <option value="MT">MONTANA</option> + <option value="NE">NEBRASKA</option> + <option value="NV">NEVADA</option> + <option value="NH">NEW HAMPSHIRE</option> + <option value="NJ">NEW JERSEY</option> + <option value="NM">NEW MEXICO</option> + <option value="NY">NEW YORK</option> + <option value="NC">NORTH CAROLINA</option> + <option value="ND">NORTH DAKOTA</option> + <option value="OH">OHIO</option> + <option value="OK">OKLAHOMA</option> + <option value="OR">OREGON</option> + <option value="PA">PENNSYLVANIA</option> + <option value="PR">PUERTO RICO</option> + <option value="RI">RHODE ISLAND</option> + <option value="SC">SOUTH CAROLINA</option> + <option value="SD">SOUTH DAKOTA</option> + <option value="TN">TENNESSEE</option> + <option value="TX">TEXAS</option> + <option value="VI">U.S. Virgin Islands</option> + <option value="UT">UTAH</option> + <option value="VT">VERMONT</option> + <option value="VA">VIRGINIA</option> + <option value="WA">WASHINGTON</option> + <option value="WV">WEST VIRGINIA</option> + <option value="WI">WISCONSIN</option> + <option value="WY">WYOMING</option> + </select> + </div> + </div> + <div> + <label id="lblZip">Zip/Postal Code</label> + <input +title="overall type: ADDRESS_HOME_ZIP + server type: ADDRESS_HOME_ZIP + heuristic type: ADDRESS_HOME_ZIP + label: Zip/Postal Code + parseable name: SZip + field signature: 3887080504 + form signature: 7987654844929240962" maxlength="20" size="20" type="text" name="SZip" id="SZip" value="" +autofill-prediction="ADDRESS_HOME_ZIP" +> + </div> + </li> + <li> + <label> + Phone + </label> + <input +title="overall type: PHONE_HOME_CITY_AND_NUMBER + server type: PHONE_HOME_CITY_AND_NUMBER + heuristic type: PHONE_HOME_WHOLE_NUMBER + label: Phone + parseable name: ShippingPhone + field signature: 3574122515 + form signature: 7987654844929240962" type="text" name="ShippingPhone" maxlength="30" id="ShippingPhone" value="" +autofill-prediction="PHONE_HOME_CITY_AND_NUMBER" +> + </li> + <li> + <label> + Email + </label> + <input +title="overall type: EMAIL_ADDRESS + server type: EMAIL_ADDRESS + heuristic type: EMAIL_ADDRESS + label: Email + parseable name: GuestEmail + field signature: 658796670 + form signature: 7987654844929240962" type="text" name="GuestEmail" id="email" value="" maxlength="128" +autofill-prediction="EMAIL_ADDRESS" +> + </li> + <li> + <label> + <span> + <strong>NOTE:</strong> Your phone number is needed to contact you for shipping-related questions and your email address will be used only to send you information about your order. + </span> + <p> + <span> + Please visit our <a>Privacy Policy</a> for more information.</span> + </p> + </label> + </li> + <input type="hidden" id="IsDefault" name="IsDefault"> + </ul> + </li> + </ul> + <input type="hidden" value="True" id="CheckGuestEmail" name="CheckGuestEmail"> + </form> + <input type="hidden" id="FedExShippingIsDefault" value="False"> + <input type="hidden" id="CanadaPostIsDefault" value="False"> + <input type="hidden" id="IsBrowserBack" value="false"> + <ul> + <li> + <input type="radio" name="shippingAddressPanel" value="creditcard" id="shippingAddressAdd"> + <label for="shippingAddressAdd"> + <strong>Enter a new shipping address</strong> + </label> + <div id="checkoutShippingAddPanel"> + </div> + </li> + </ul> + <div> + <input type="checkbox" id="makeDefaultOption" name="makeDefaultOption"> + <label for="makeDefaultOption">Set as default credit card</label> + </div> + </body> +</html> diff --git a/browser/extensions/formautofill/test/fixtures/third_party/OfficeDepot/Payment.html b/browser/extensions/formautofill/test/fixtures/third_party/OfficeDepot/Payment.html new file mode 100644 index 0000000000..06b9f8e763 --- /dev/null +++ b/browser/extensions/formautofill/test/fixtures/third_party/OfficeDepot/Payment.html @@ -0,0 +1,672 @@ +<!DOCTYPE html> +<html lang="en-US"> + <head> + <meta http-equiv="Content-Type" content="text/html; charset=utf-8"> + <meta http-equiv="X-UA-Compatible" content="IE=Edge,chrome=1"> + <meta http-equiv="expires" content="0"> + <meta http-equiv="pragma" content="no-cache"> + <meta http-equiv="cache-control" content="no-cache"> + <meta http-equiv="pragma-directive" content="no-cache"> + <meta http-equiv="cache-directive" content="no-cache"> + <meta name="robots" content="NOODP, NOYDIR"> + <title> +</title> + </head> + <body> + <meta name="apple-itunes-app" content="app-id=471037434"> + <title>Office Supplies, Furniture, Technology at Offic Depot</title> + <meta name="description" content="Shop office supplies, furniture & technology at Office Depot. For paper, ink, toner & more, find trusted brands at everyday low prices."> + <meta name="keywords" content="office supplies, office furniture, technology, electronics"> + <meta property="og:image" content="https://secure.www.odcdn.com/images/us/od/brand.png"> + <form name="anonymousConfirmForm" method="post" action="https://www.officedepot.com/checkout/anonymousConfirmRouter.do" id="confirmFormId" novalidate="novalidate"> + <input type="hidden" name="partialReg" value="false"> + <input type="hidden" name="cartIsNotAllPickup" value="true"> + <input type="hidden" name="step" value="bill"> + <input type="hidden" name="orderNumber" value="914646582" id="orderNumber"> + <input type="hidden" name="orderSubNumber" value="001" id="orderSubNumber"> + <input type="hidden" name="linked" value="false"> + <input type="hidden" name="billToID" value=""> + <input type="hidden" name="requestFromPage" value="true"> + <input type="hidden" name="nececessaryToRevalidate" id="nececessaryToRevalidate" value="false"> + <input type="hidden" name="revalidateTrigger" id="revalidateTrigger" value=""> + <input type="submit" name="cmd_confirm" tabindex="1"> + <input type="hidden" name="proceedFromBill" value="true"> + <input type="hidden" name="flowMode" id="flowMode" value="ANONYMOUS"> + <div id="checkoutBillingV3"> + <div> + <div> + <div> + <input type="hidden" name="guestEmailOptIn" value="true"> + <input type="hidden" id="isAnonCheckout" value="true"> + <div> + <input type="hidden" name="payWithSavedCard" id="payWithSavedCard" value="false"> + <input type="hidden" id="cardTypeForMaskedValue" value=""> + <input type="hidden" id="showCardList" value="false"> + <div id="payWithCreditCard"> + <div id="iFrameFields"> + <div id="creditCardIframe"> + <div id="cardValidationError"> +</div> + <div id="preventFlicker"> + <div id="creditCardFrame"> + <div id="iFrameExpDateFields"> + <label>Expiration Date</label> + <select name="paymentFormInfo.payPageFormInfo.creditCardExpMonth" +title="overall type: CREDIT_CARD_EXP_MONTH server type: CREDIT_CARD_EXP_MONTH heuristic type: UNKNOWN_TYPE label: Expiration Date parseable name: paymentFormInfo.payPageFormInfo.creditCardExpMonth field signature: 1909413716 form signature: 4882000530220881601" tabindex="2" +autofill-prediction="CREDIT_CARD_EXP_MONTH" +> + <option value=" "> + -- + </option> + <option value="01"> + 01 + </option> + <option value="02"> + 02 + </option> + <option value="03"> + 03 + </option> + <option value="04"> + 04 + </option> + <option value="05"> + 05 + </option> + <option value="06"> + 06 + </option> + <option value="07"> + 07 + </option> + <option value="08"> + 08 + </option> + <option value="09"> + 09 + </option> + <option value="10"> + 10 + </option> + <option value="11"> + 11 + </option> + <option value="12"> + 12 + </option> + </select> + <select name="paymentFormInfo.payPageFormInfo.creditCardExpYear" +title="overall type: CREDIT_CARD_EXP_4_DIGIT_YEAR server type: CREDIT_CARD_EXP_4_DIGIT_YEAR heuristic type: CREDIT_CARD_EXP_DATE_4_DIGIT_YEAR label: Expiration Date parseable name: paymentFormInfo.payPageFormInfo.creditCardExpYear field signature: 884603578 form signature: 4882000530220881601" tabindex="3" +autofill-prediction="CREDIT_CARD_EXP_4_DIGIT_YEAR" +> + <option value=""> + -- + </option> + <option value="17"> + 2017 + </option> + <option value="18"> + 2018 + </option> + <option value="19"> + 2019 + </option> + <option value="20"> + 2020 + </option> + <option value="21"> + 2021 + </option> + <option value="22"> + 2022 + </option> + <option value="23"> + 2023 + </option> + <option value="24"> + 2024 + </option> + <option value="25"> + 2025 + </option> + <option value="26"> + 2026 + </option> + <option value="27"> + 2027 + </option> + <option value="28"> + 2028 + </option> + <option value="29"> + 2029 + </option> + <option value="30"> + 2030 + </option> + <option value="31"> + 2031 + </option> + <option value="32"> + 2032 + </option> + <option value="33"> + 2033 + </option> + <option value="34"> + 2034 + </option> + <option value="35"> + 2035 + </option> + </select> + </div> + <div id="frameFields"> + <input type="hidden" id="getMerchantIdPrefix" value="CKWWW"> + <input type="hidden" id="paypageRegistrationId" name="paymentFormInfo.payPageFormInfo.payPageRegistrationId" > + <input type="hidden" id="bin" name="paymentFormInfo.payPageFormInfo.binRange"> + <input type="hidden" id="merchantTxnId" name="paymentFormInfo.payPageFormInfo.merchantTxnId" value="CKWWW-201732012117"> + <input type="hidden" id="orderId" name="paymentFormInfo.payPageFormInfo.orderId" value="914646582-001"> + <input type="hidden" id="code" name="paymentFormInfo.payPageFormInfo.code"> + <input type="hidden" id="responseTime" name="paymentFormInfo.payPageFormInfo.responseTime" > + <input type="hidden" id="message" name="paymentFormInfo.payPageFormInfo.message" size="100"> + <input type="hidden" id="litleTxnId" name="paymentFormInfo.payPageFormInfo.litleTxnId"> + <input type="hidden" id="type" name="paymentFormInfo.payPageFormInfo.cardType"> + <input type="hidden" id="firstSix" name="paymentFormInfo.payPageFormInfo.firstSix"> + <input type="hidden" id="lastFour" name="paymentFormInfo.payPageFormInfo.lastFour"> + <input type="hidden" id="timeoutMessage" name="paymentFormInfo.payPageFormInfo.timeoutMessage" > + </div> + </div> + </div> + </div> + </div> + <div> + <div> + <label for="paymentFormInfo.tenderType" > + <input type="radio" name="paymentFormInfo.tenderType" value="CR" checked +title="overall type: UNKNOWN_TYPE server type: NO_SERVER_DATA heuristic type: UNKNOWN_TYPE label: Credit Card parseable name: paymentFormInfo.tenderType field signature: 920121368 form signature: 4882000530220881601" +autofill-prediction="UNKNOWN_TYPE" +> Credit Card</label> + </div> + </div> + </div> + <div id="payWithPayPal"> + <input type="hidden" name="paypalInfoInSession" id="paypalInfoInSession" value="false"> + <div> + <label for="paymentFormInfo.tenderType" > + <input type="radio" name="paymentFormInfo.tenderType" value="PL" +title="overall type: UNKNOWN_TYPE server type: NO_SERVER_DATA heuristic type: UNKNOWN_TYPE label: Pay with PayPal You'll be redirected to the PayPal site to sign in and confirm your payment. You wil parseable name: paymentFormInfo.tenderType field signature: 920121368 form signature: 4882000530220881601" +autofill-prediction="UNKNOWN_TYPE" +> + </label> + </div> + </div> + <div id="payWithMasterPass"> + <div> + <label for="paymentFormInfo.tenderType" > + <input type="radio" name="paymentFormInfo.tenderType" value="MS" +title="overall type: UNKNOWN_TYPE server type: NO_SERVER_DATA heuristic type: UNKNOWN_TYPE label: Pay with MasterPass You'll be redirected to the MasterPass site to sign in and confirm your payment. parseable name: paymentFormInfo.tenderType field signature: 920121368 form signature: 4882000530220881601" +autofill-prediction="UNKNOWN_TYPE" +> +</label> + </div> + </div> + </div> + </div> + <div id="checkoutBillingAddressSection"> + <div id="shippingInfo"> + <input type="hidden" name="addrsForm[2].firstName" value="Tester" id="firstName-2"> + <input type="hidden" name="addrsForm[2].lastName" value="Mo" id="lastName-2"> + <input type="hidden" name="addrsForm[2].shiptoName" value="Mozilla" id="shiptoName-2"> + <input type="hidden" name="addrsForm[2].address1" value="331 E. Evelyn Avenue" id="address1-2"> + <input type="hidden" name="addrsForm[2].address2" value="" id="address2-2"> + <input type="hidden" name="addrsForm[2].city" value="MOUNTAIN VIEW" id="city-2"> + <input type="hidden" name="addrsForm[2].state" value="CA" id="state-2"> + <input type="hidden" name="addrsForm[2].postalCode1" value="94041" id="postalCode1-2"> + <input type="hidden" name="addrsForm[2].country" value="USA" id="country-2"> + <input type="hidden" name="addrsForm[2].phoneNumber1" value="650" id="phoneNumber1-2"> + <input type="hidden" name="addrsForm[2].phoneNumber2" value="903" id="phoneNumber2-2"> + <input type="hidden" name="addrsForm[2].phoneNumber3" value="0800" id="phoneNumber3-2"> + <input type="hidden" name="addrsForm[2].phoneNumber4" value="0800" id="phoneNumber4-2"> + <input type="hidden" name="addrsForm[2].email" value="formautofilltester@gmail.com" id="email-2"> + <input type="hidden" name="shippingEmailPreferences.optInSelected" value="true"> + </div> + <div id="billingInfo"> + <div> + <label> + <input type="checkbox" name="sameAsBilling" id="billingAddressSameAsShipping" checked tabindex="4" +title="overall type: UNKNOWN_TYPE server type: NO_SERVER_DATA heuristic type: UNKNOWN_TYPE label: Same as Shipping parseable name: sameAsBilling field signature: 2684212655 form signature: 4882000530220881601" +autofill-prediction="UNKNOWN_TYPE" +> Same as Shipping</label> + </div> + <div id="reg_billingInfo"> + <div id="anonymousBillingInfoForm"> + <input type="hidden" id="country-0" name="addrsForm[0].country" value="USA"> + <div> + <div> + <div> + <div> + <label for="firstName-0"> +<em>*</em>First Name:</label> + <input type="text" id="firstName-0" name="addrsForm[0].firstName" value="" maxlength="30" +title="overall type: CREDIT_CARD_NAME_FIRST server type: NAME_FIRST heuristic type: CREDIT_CARD_NAME_FIRST label: *First Name: parseable name: addrsForm[0].firstName field signature: 923482701 form signature: 4882000530220881601" +autofill-prediction="CREDIT_CARD_NAME_FIRST" +> + </div> + </div> + <div> + <div> + <label for="lastName-0"> +<em>*</em>Last Name:</label> + <input type="text" id="lastName-0" name="addrsForm[0].lastName" value="" maxlength="30" +title="overall type: CREDIT_CARD_NAME_LAST server type: NAME_LAST heuristic type: CREDIT_CARD_NAME_LAST label: *Last Name: parseable name: addrsForm[0].lastName field signature: 3226352217 form signature: 4882000530220881601" +autofill-prediction="CREDIT_CARD_NAME_LAST" +> + </div> + </div> + </div> + <div> + <label for="shipToName-0">Company Name:</label> + <input type="text" id="shipToName-0" name="addrsForm[0].shiptoName" value="" maxlength="30" +title="overall type: COMPANY_NAME server type: COMPANY_NAME heuristic type: COMPANY_NAME label: Company Name: parseable name: addrsForm[0].shiptoName field signature: 2508018426 form signature: 4882000530220881601" +autofill-prediction="COMPANY_NAME" +> + </div> + <div> + <label for="address1-0"> +<em>*</em>Address:</label> + <input type="text" id="address1-0" name="addrsForm[0].address1" value="" maxlength="25" +title="overall type: ADDRESS_HOME_LINE1 server type: ADDRESS_HOME_LINE1 heuristic type: ADDRESS_HOME_LINE1 label: *Address: parseable name: addrsForm[0].address1 field signature: 3908367322 form signature: 4882000530220881601" +autofill-prediction="ADDRESS_HOME_LINE1" +> + </div> + <div> + <label for="address2-0">Address Line 2: <span>(optional)</span> +</label> + <input type="text" id="address2-0" name="addrsForm[0].address2" value="" maxlength="25" +title="overall type: ADDRESS_HOME_LINE2 server type: ADDRESS_HOME_LINE2 heuristic type: ADDRESS_HOME_LINE2 label: Address Line 2: (optional) parseable name: addrsForm[0].address2 field signature: 3866764654 form signature: 4882000530220881601" +autofill-prediction="ADDRESS_HOME_LINE2" +> + </div> + <div> + <div> + <div> + <label for="postalCode1-0"> +<em>*</em>Postal Code:</label> + <input type="text" id="postalCode1-0" name="addrsForm[0].postalCode1" value="" maxlength="9" +title="overall type: ADDRESS_HOME_ZIP server type: ADDRESS_HOME_ZIP heuristic type: ADDRESS_HOME_ZIP label: *Postal Code: parseable name: addrsForm[0].postalCode1 field signature: 1708240695 form signature: 4882000530220881601" +autofill-prediction="ADDRESS_HOME_ZIP" +> + </div> + </div> + </div> + <div> + <div> + <div > + <label for="checkoutCityAndState"> +<em>*</em>City & State</label> + <select id="checkoutCityAndState" +title="overall type: ADDRESS_HOME_CITY server type: NO_SERVER_DATA heuristic type: ADDRESS_HOME_CITY label: *City & State parseable name: checkoutCityAndState field signature: 3899416585 form signature: 4882000530220881601" +autofill-prediction="ADDRESS_HOME_CITY" +> + </select> + </div> + </div> + </div> + <div> + <div> + <div> + <label for="city-0"> +<em>*</em>City:</label> + <input type="text" id="city-0" name="addrsForm[0].city" value="" +title="overall type: ADDRESS_HOME_CITY server type: ADDRESS_HOME_CITY heuristic type: ADDRESS_HOME_CITY label: *City: parseable name: addrsForm[0].city field signature: 1634404945 form signature: 4882000530220881601" +autofill-prediction="ADDRESS_HOME_CITY" +> + </div> + </div> + <div> + <div > + <label for="state-0"> +<em>*</em>State:</label> + <select name="addrsForm[0].state" id="state-0" size="1" +title="overall type: ADDRESS_HOME_STATE server type: ADDRESS_HOME_STATE heuristic type: ADDRESS_HOME_STATE label: *State: parseable name: addrsForm[0].state field signature: 3377657622 form signature: 4882000530220881601" +autofill-prediction="ADDRESS_HOME_STATE" +> + <option value="{blank}"> Select a state + </option> + <option value="AK"> AK - Alaska + </option> + <option value="AL"> AL - Alabama + </option> + <option value="AR"> AR - Arkansas + </option> + <option value="AZ"> AZ - Arizona + </option> + <option value="CA"> CA - California + </option> + <option value="CO"> CO - Colorado + </option> + <option value="CT"> CT - Connecticut + </option> + <option value="DC"> DC - District of Columbia + </option> + <option value="DE"> DE - Delaware + </option> + <option value="FL"> FL - Florida + </option> + <option value="GA"> GA - Georgia + </option> + <option value="HI"> HI - Hawaii + </option> + <option value="IA"> IA - Iowa + </option> + <option value="ID"> ID - Idaho + </option> + <option value="IL"> IL - Illinois + </option> + <option value="IN"> IN - Indiana + </option> + <option value="KS"> KS - Kansas + </option> + <option value="KY"> KY - Kentucky + </option> + <option value="LA"> LA - Louisiana + </option> + <option value="MA"> MA - Massachusetts + </option> + <option value="MD"> MD - Maryland + </option> + <option value="ME"> ME - Maine + </option> + <option value="MI"> MI - Michigan + </option> + <option value="MN"> MN - Minnesota + </option> + <option value="MO"> MO - Missouri + </option> + <option value="MS"> MS - Mississippi + </option> + <option value="MT"> MT - Montana + </option> + <option value="NC"> NC - North Carolina + </option> + <option value="ND"> ND - North Dakota + </option> + <option value="NE"> NE - Nebraska + </option> + <option value="NH"> NH - New Hampshire + </option> + <option value="NJ"> NJ - New Jersey + </option> + <option value="NM"> NM - New Mexico + </option> + <option value="NV"> NV - Nevada + </option> + <option value="NY"> NY - New York + </option> + <option value="OH"> OH - Ohio + </option> + <option value="OK"> OK - Oklahoma + </option> + <option value="OR"> OR - Oregon + </option> + <option value="PA"> PA - Pennsylvania + </option> + <option value="PR"> PR - Puerto Rico + </option> + <option value="RI"> RI - Rhode Island + </option> + <option value="SC"> SC - South Carolina + </option> + <option value="SD"> SD - South Dakota + </option> + <option value="TN"> TN - Tennessee + </option> + <option value="TX"> TX - Texas + </option> + <option value="UT"> UT - Utah + </option> + <option value="VA"> VA - Virginia + </option> + <option value="VI"> VI - US Virgin Islands + </option> + <option value="VT"> VT - Vermont + </option> + <option value="WA"> WA - Washington + </option> + <option value="WI"> WI - Wisconsin + </option> + <option value="WV"> WV - West Virginia + </option> + <option value="WY"> WY - Wyoming + </option> + </select> + </div> + </div> + </div> + <div> + <div> + <div> + <label> +<em>*</em>Phone:</label> + <input type="tel" id="phoneNumber1-0" name="addrsForm[0].phoneNumber1" value="" maxlength="3" +title="overall type: PHONE_HOME_CITY_CODE server type: PHONE_HOME_CITY_CODE heuristic type: PHONE_HOME_CITY_CODE label: *Phone: parseable name: addrsForm[0].phoneNumber1 field signature: 1254557631 form signature: 4882000530220881601" +autofill-prediction="PHONE_HOME_CITY_CODE" +> + <input type="tel" id="phoneNumber2-0" name="addrsForm[0].phoneNumber2" value="" maxlength="3" +title="overall type: PHONE_HOME_NUMBER server type: PHONE_HOME_NUMBER heuristic type: PHONE_HOME_NUMBER label: *Phone: parseable name: addrsForm[0].phoneNumber2 field signature: 1999321122 form signature: 4882000530220881601" +autofill-prediction="PHONE_HOME_NUMBER" +> + <input type="tel" id="phoneNumber3-0" name="addrsForm[0].phoneNumber3" value="" maxlength="4" +title="overall type: PHONE_HOME_NUMBER server type: PHONE_HOME_NUMBER heuristic type: PHONE_HOME_NUMBER label: *Phone: parseable name: addrsForm[0].phoneNumber3 field signature: 348537713 form signature: 4882000530220881601" +autofill-prediction="PHONE_HOME_NUMBER" +> + </div> + </div> + <div> + <div> + <label for="phoneNumber4-0">Ext</label> + <input type="tel" id="phoneNumber4-0" name="addrsForm[0].phoneNumber4" value="" maxlength="4" +title="overall type: PHONE_HOME_NUMBER server type: PHONE_HOME_NUMBER heuristic type: PHONE_HOME_EXTENSION label: Ext parseable name: addrsForm[0].phoneNumber4 field signature: 3060772033 form signature: 4882000530220881601" +autofill-prediction="PHONE_HOME_NUMBER" +> + </div> + </div> + </div> + <div> + <div> + <div id="addressChangeEmailConfirm"> + <label for="email-0"> +<em>*</em>Email Address:</label> + <input type="email" id="email-0" name="addrsForm[0].email" value="" maxlength="40" +title="overall type: EMAIL_ADDRESS server type: EMAIL_ADDRESS heuristic type: EMAIL_ADDRESS label: *Email Address: parseable name: addrsForm[0].email field signature: 1389763646 form signature: 4882000530220881601" +autofill-prediction="EMAIL_ADDRESS" +> + </div> + </div> + </div> + </div> + </div> + </div> + <div> + <input type="hidden" name="billingEmailPreferences.emailHtml" value="true"> + <div> + <input type="hidden" name="billingEmailPreferences.optInSelected" value="true"> +<a target="_blank">Privacy Policy</a> + </div> + </div> + </div> + </div> + <div> + <div id="checkoutBillLoyalty"> + <div id="rewardsSections"> + <div id="noWorkLifeRewards"> + <div> + <div> + <div> + <input type="text" name="loyaltyID" +title="overall type: PHONE_HOME_CITY_AND_NUMBER server type: PHONE_HOME_CITY_AND_NUMBER heuristic type: UNKNOWN_TYPE label: Apply parseable name: loyaltyID field signature: 1222391720 form signature: 4882000530220881601" maxlength="10" value="" +autofill-prediction="PHONE_HOME_CITY_AND_NUMBER" +> + <p> +<a + >Member number lookup</a> + </p> + </div> + </div> + </div> + </div> + </div> + </div> + </div> + <div id="checkoutBillingGiftCards"> + <div> + <div> + <div> + <div> + <div> + <label for="gcInput">Gift card or certificate number</label> + <input type="tel" name="paymentFormInfo.storedValueCardNumber" maxlength="19" value="" id="gcInput" +title="overall type: CREDIT_CARD_NUMBER server type: CREDIT_CARD_NUMBER heuristic type: UNKNOWN_TYPE label: Gift card or certificate number parseable name: paymentFormInfo.storedValueCardNumber field signature: 2610516022 form signature: 4882000530220881601" +autofill-prediction="CREDIT_CARD_NUMBER" +> + </div> + </div> + <div> + <div> + <label for="gcPinInput">PIN</label> + <input type="tel" name="paymentFormInfo.storedValueCardPin" maxlength="4" value="" id="gcPinInput" +title="overall type: UNKNOWN_TYPE server type: NO_SERVER_DATA heuristic type: UNKNOWN_TYPE label: PIN parseable name: paymentFormInfo.storedValueCardPin field signature: 3801610036 form signature: 4882000530220881601" +autofill-prediction="UNKNOWN_TYPE"> +<img src="./Office%20Supplies,%20Furniture,%20Technology%20at%20Office%20Depot%20-%20Payment_files/info-blue.png" alt="Need Help?" title="Need Help?" id="giftCardPinTooltipTarget" +> + </div> + </div> + </div> + </div> + </div> + </div> + <div> + <div> + <input type="hidden" id="couponCount" value="0"> + <input type="hidden" id="invalidCouponNumber" value=""> + <input type="hidden" id="couponInWhichStep" value="checkoutV2"> + <input type="hidden" id="couponRemove" value="Remove Coupon"> + <input type="hidden" id="close" value="Close"> + <input type="hidden" id="invalidCouponCode" value="<p>Invalid Coupon Code</p>"> + <input type="hidden" id="validCouponCode" value="<p>Coupon Code Applied</p>"> + <input type="hidden" id="validCouponPrompt" value="This coupon has been applied to your order."> + <input type="hidden" id="couponRemoveFailHeader" value="Remove Coupon Fail"> + <input type="hidden" name="offer.x" value="y"> + <div> + <div id="couponDialog" +title="Coupon Offer"> + <div> + <div> + <ul> + <li> + <input type="button" value="Skip and Continue"> + </li> + <li> + <input type="button" value="See Offer Details"> + </li> + </ul> + </div> + </div> + </div> + <div> + <div> + <div> + <label for="referralCode">Coupon Code:</label> + <input type="tel" name="referralCode" id="referralCode" size="14" maxlength="14" +title="overall type: UNKNOWN_TYPE server type: NO_SERVER_DATA heuristic type: UNKNOWN_TYPE label: Coupon Code: parseable name: referralCode field signature: 2314309716 form signature: 4882000530220881601" +autofill-prediction="UNKNOWN_TYPE" +> + </div> + </div> + </div> + </div> + </div> + </div> + <div> + <input type="hidden" name="customLabelsPermissionsForm.requestor" value="confirm"> + <input type="hidden" name="modifyFormDataWithBackendData" value="true"> + <div id="checkoutBillingAdditionalInfo"> + <div> + <div> + <div> + <div> + <label for="sourceCode">Catalog/source code, Federal government code <img src="./Office%20Supplies,%20Furniture,%20Technology%20at%20Office%20Depot%20-%20Payment_files/info-blue.png" alt="What is a Catalog or Source Code, Federal Government Code?" +title="What is a Catalog or Source Code, Federal Government Code?"> + </label> + <input type="text" name="sourceCode" maxlength="5" size="22" autocomplete="" value="" +title="overall type: UNKNOWN_TYPE server type: NO_SERVER_DATA heuristic type: UNKNOWN_TYPE label: Catalog/source code, Federal government code parseable name: sourceCode field signature: 1650657570 form signature: 4882000530220881601" +autofill-prediction="UNKNOWN_TYPE" +> + </div> + </div> + </div> + <div> + <div> + <div> + <label>Customer PO#</label> + <input type="text" name="poName" maxlength="22" size="22" autocomplete="" value="" +title="overall type: UNKNOWN_TYPE server type: NO_SERVER_DATA heuristic type: UNKNOWN_TYPE label: Customer PO# parseable name: poName field signature: 330999673 form signature: 4882000530220881601" +autofill-prediction="UNKNOWN_TYPE" +> + </div> + </div> + </div> + <div> + <div> + <label>Comments</label> + <p> +<label>Informational purposes only. Not utilized by our delivery carriers.</label> + </p> + <textarea name="commentText" cols="30" rows="3" maxlength="87" +title="overall type: UNKNOWN_TYPE server type: NO_SERVER_DATA heuristic type: UNKNOWN_TYPE label: Comments Informational purposes only. Not utilized by our delivery carriers. parseable name: commentText field signature: 2293147818 form signature: 4882000530220881601" +autofill-prediction="UNKNOWN_TYPE" +> +</textarea> + <span id="">87</span> + </div> + </div> + </div> + </div> + </div> + <div id="paymentsButtonsMainBilling"> + <input type="hidden" id="showPayPalCheckoutButton_toggleCheck" name="showPayPalCheckoutButton_toggleCheck" value="true"> + <input type="hidden" id="showMasterPassCheckoutButton_toggleCheck" name="showMasterPassCheckoutButton_toggleCheck" value="true"> + </div> + </div> + <div> + <div> + <div> + <div> + <table> + <tbody> + <tr> + <td colspan="4"> + <input type="hidden" name="cartRow[0].cartEntryId" value="0"> + <input type="hidden" name="cartRow[0].minQty" value="0"> + <input type="hidden" name="cartRow[0].originalQty" value="0"> + <input type="hidden" name="cartRow[0].skuNoEffort" value="510493"> + <input type="hidden" name="cartRow[0].qtyMinimumLimitation" value="1"> + <input type="hidden" name="cartRow[0].qtyIncrementLimitation" value="1"> + </td> + </tr> + </tbody> + </table> + </div> + </div> + </div> + <div id="paymentsOrderSummary"> + <input type="hidden" id="showPayPalCheckoutButton_toggleCheck" name="showPayPalCheckoutButton_toggleCheck" value="true"> + <input type="hidden" id="showMasterPassCheckoutButton_toggleCheck" name="showMasterPassCheckoutButton_toggleCheck" value="true"> + </div> + </div> + </div> + </div> + </form> + </body> +</html> diff --git a/browser/extensions/formautofill/test/fixtures/third_party/OfficeDepot/ShippingAddress.html b/browser/extensions/formautofill/test/fixtures/third_party/OfficeDepot/ShippingAddress.html new file mode 100644 index 0000000000..849e3be495 --- /dev/null +++ b/browser/extensions/formautofill/test/fixtures/third_party/OfficeDepot/ShippingAddress.html @@ -0,0 +1,347 @@ +<!DOCTYPE html> +<html lang="en-US"> + <head> + <meta http-equiv="Content-Type" content="text/html; charset=utf-8"> + <meta http-equiv="X-UA-Compatible" content="IE=Edge,chrome=1"> + <meta http-equiv="expires" content="0"> + <meta http-equiv="pragma" content="no-cache"> + <meta http-equiv="cache-control" content="no-cache"> + <meta http-equiv="pragma-directive" content="no-cache"> + <meta http-equiv="cache-directive" content="no-cache"> + <meta name="robots" content="NOODP, NOYDIR"> + <title> +</title> + </head> + <body> + <meta name="apple-itunes-app" content="app-id=471037434"> + <title>Office Supplies, Furniture, Technology at Office Depot</title> + <meta name="description" content="Shop office supplies, furniture & technology at Office Depot. For paper, ink, toner & more, find trusted brands at everyday low prices."> + <meta name="keywords" content="office supplies, office furniture, technology, electronics"> + <meta property="og:image" content="https://secure.www.odcdn.com/images/us/od/brand.png"> + <form name="anonymousConfirmForm" method="post" action="https://www.officedepot.com/checkout/anonymousConfirmRouter.do" id="confirmFormId" novalidate="novalidate"> + <input type="hidden" name="partialReg" value="false"> +<input type="hidden" name="cartIsNotAllPickup" value="true"> +<input type="hidden" name="returnurl" value="/checkout/checkout/anonymousConfirmRouter.do"> + <div id="shipPageV2"> + <div> + <div> + <div> + <div> + <div> + <div> + <div id="reg_shippingInfo"> + <input id="skipGroup1ShippingAddress" type="hidden" name="skipGroup1ShippingAddress" value="false"> + <div> + <input type="hidden" id="country-2" name="addrsForm[2].country" value="USA"> + <div> + <div> + <div> + <div > + <label for="firstName-2" > + <em>*</em>First Name:</label> + <input type="text" id="firstName-2" name="addrsForm[2].firstName" value="" maxlength="30" tabindex="1" +title="overall type: NAME_FIRST server type: NAME_FIRST heuristic type: NAME_FIRST label: *First Name: parseable name: addrsForm[2].firstName field signature: 3337773590 form signature: 5001876119589580889" +autofill-prediction="NAME_FIRST" +> + </div> + </div> + <div> + <div > + <label for="lastName-2"> + <em>*</em>Last Name:</label> + <input type="text" id="lastName-2" name="addrsForm[2].lastName" value="" maxlength="30" tabindex="2" +title="overall type: NAME_LAST server type: NAME_LAST heuristic type: NAME_LAST label: *Last Name: parseable name: addrsForm[2].lastName field signature: 3075576638 form signature: 5001876119589580889" +autofill-prediction="NAME_LAST" +> + </div> + </div> + </div> + <div> + <label for="shipToName-2"> Company Name:</label> + <input type="text" id="shipToName-2" name="addrsForm[2].shiptoName" value="" maxlength="30" tabindex="3" +title="overall type: COMPANY_NAME server type: COMPANY_NAME heuristic type: COMPANY_NAME label: Company Name: parseable name: addrsForm[2].shiptoName field signature: 2052742641 form signature: 5001876119589580889" +autofill-prediction="COMPANY_NAME" +> + </div> + <div > + <label for="address1-2"> + <em>*</em>Address:</label> + <input type="text" id="address1-2" name="addrsForm[2].address1" value="" maxlength="25" tabindex="4" +title="overall type: ADDRESS_HOME_LINE1 server type: ADDRESS_HOME_LINE1 heuristic type: ADDRESS_HOME_LINE1 label: *Address: parseable name: addrsForm[2].address1 field signature: 2660215956 form signature: 5001876119589580889" +autofill-prediction="ADDRESS_HOME_LINE1" +> + </div> + <div> + <label for="address2-2"> Address Line 2: + <span>(optional)</span> +</label> + <input type="text" id="address2-2" name="addrsForm[2].address2" value="" maxlength="25" tabindex="5" +title="overall type: ADDRESS_HOME_LINE2 server type: ADDRESS_HOME_LINE2 heuristic type: ADDRESS_HOME_LINE2 label: Address Line 2: (optional) parseable name: addrsForm[2].address2 field signature: 2293911247 form signature: 5001876119589580889" +autofill-prediction="ADDRESS_HOME_LINE2" +> + </div> + <div> + <div> + <div > + <label for="postalCode1-2"> + <em>*</em>Postal Code:</label> + <input type="text" id="postalCode1-2" name="addrsForm[2].postalCode1" value="" maxlength="9" tabindex="6" +title="overall type: ADDRESS_HOME_ZIP server type: ADDRESS_HOME_ZIP heuristic type: ADDRESS_HOME_ZIP label: *Postal Code: parseable name: addrsForm[2].postalCode1 field signature: 1044898225 form signature: 5001876119589580889" +autofill-prediction="ADDRESS_HOME_ZIP" +> + </div> + </div> + </div> + <div> + <div> + <div > + <label for="checkoutCityAndState"> +<em>*</em> +</em>City & State</label> + <select id="checkoutCityAndState" +title="overall type: ADDRESS_HOME_CITY server type: NO_SERVER_DATA heuristic type: ADDRESS_HOME_CITY label: *City & State parseable name: checkoutCityAndState field signature: 3899416585 form signature: 5001876119589580889" +autofill-prediction="ADDRESS_HOME_CITY" +> + <option value="0"> MOUNTAIN VIEW, CA + </option> + <option value="other"> Other City and State + </option> + </select> + </div> + </div> + </div> + <div> + <div> + <div > + <label for="city-2"> + <em>*</em>City:</label> + <input type="text" id="city-2" name="addrsForm[2].city" value="" +title="overall type: ADDRESS_HOME_CITY server type: ADDRESS_HOME_CITY heuristic type: ADDRESS_HOME_CITY label: *City: parseable name: addrsForm[2].city field signature: 2341281094 form signature: 5001876119589580889" +autofill-prediction="ADDRESS_HOME_CITY" +> + </div> + </div> + <div> + <div > + <label for="state-2"> + <em>*</em>State:</label> + <select name="addrsForm[2].state" id="state-2" size="1" +title="overall type: ADDRESS_HOME_STATE server type: ADDRESS_HOME_STATE heuristic type: ADDRESS_HOME_STATE label: *State: parseable name: addrsForm[2].state field signature: 3265256938 form signature: 5001876119589580889" +autofill-prediction="ADDRESS_HOME_STATE" +> + <option value="{blank}"> Select a state + </option> + <option value="AK"> AK - Alaska + </option> + <option value="AL"> AL - Alabama + </option> + <option value="AR"> AR - Arkansas + </option> + <option value="AZ"> AZ - Arizona + </option> + <option value="CA"> CA - California + </option> + <option value="CO"> CO - Colorado + </option> + <option value="CT"> CT - Connecticut + </option> + <option value="DC"> DC - District of Columbia + </option> + <option value="DE"> DE - Delaware + </option> + <option value="FL"> FL - Florida + </option> + <option value="GA"> GA - Georgia + </option> + <option value="HI"> HI - Hawaii + </option> + <option value="IA"> IA - Iowa + </option> + <option value="ID"> ID - Idaho + </option> + <option value="IL"> IL - Illinois + </option> + <option value="IN"> IN - Indiana + </option> + <option value="KS"> KS - Kansas + </option> + <option value="KY"> KY - Kentucky + </option> + <option value="LA"> LA - Louisiana + </option> + <option value="MA"> MA - Massachusetts + </option> + <option value="MD"> MD - Maryland + </option> + <option value="ME"> ME - Maine + </option> + <option value="MI"> MI - Michigan + </option> + <option value="MN"> MN - Minnesota + </option> + <option value="MO"> MO - Missouri + </option> + <option value="MS"> MS - Mississippi + </option> + <option value="MT"> MT - Montana + </option> + <option value="NC"> NC - North Carolina + </option> + <option value="ND"> ND - North Dakota + </option> + <option value="NE"> NE - Nebraska + </option> + <option value="NH"> NH - New Hampshire + </option> + <option value="NJ"> NJ - New Jersey + </option> + <option value="NM"> NM - New Mexico + </option> + <option value="NV"> NV - Nevada + </option> + <option value="NY"> NY - New York + </option> + <option value="OH"> OH - Ohio + </option> + <option value="OK"> OK - Oklahoma + </option> + <option value="OR"> OR - Oregon + </option> + <option value="PA"> PA - Pennsylvania + </option> + <option value="PR"> PR - Puerto Rico + </option> + <option value="RI"> RI - Rhode Island + </option> + <option value="SC"> SC - South Carolina + </option> + <option value="SD"> SD - South Dakota + </option> + <option value="TN"> TN - Tennessee + </option> + <option value="TX"> TX - Texas + </option> + <option value="UT"> UT - Utah + </option> + <option value="VA"> VA - Virginia + </option> + <option value="VI"> VI - US Virgin Islands + </option> + <option value="VT"> VT - Vermont + </option> + <option value="WA"> WA - Washington + </option> + <option value="WI"> WI - Wisconsin + </option> + <option value="WV"> WV - West Virginia + </option> + <option value="WY"> WY - Wyoming + </option> + </select> + </div> + </div> + </div> + <div> + <div> + <div> + <label> + <em>*</em>Phone:</label> + <input type="tel" id="phoneNumber1-2" name="addrsForm[2].phoneNumber1" value="" maxlength="3" +title="overall type: PHONE_HOME_CITY_CODE server type: PHONE_HOME_CITY_CODE heuristic type: PHONE_HOME_CITY_CODE label: *Phone: parseable name: addrsForm[2].phoneNumber1 field signature: 3051888398 form signature: 5001876119589580889" tabindex="7" +autofill-prediction="PHONE_HOME_CITY_CODE" +> + <input type="tel" id="phoneNumber2-2" name="addrsForm[2].phoneNumber2" value="" maxlength="3" +title="overall type: PHONE_HOME_NUMBER server type: PHONE_HOME_NUMBER heuristic type: PHONE_HOME_NUMBER label: *Phone: parseable name: addrsForm[2].phoneNumber2 field signature: 4001233923 form signature: 5001876119589580889" tabindex="8" +autofill-prediction="PHONE_HOME_NUMBER" +> + <input type="tel" id="phoneNumber3-2" name="addrsForm[2].phoneNumber3" value="" maxlength="4" +title="overall type: PHONE_HOME_NUMBER server type: PHONE_HOME_NUMBER heuristic type: PHONE_HOME_NUMBER label: *Phone: parseable name: addrsForm[2].phoneNumber3 field signature: 3507119292 form signature: 5001876119589580889" tabindex="9" +autofill-prediction="PHONE_HOME_NUMBER" +> + </div> + </div> + <div> + <div> + <label for="phoneNumber4-2"> Ext</label> + <input type="tel" id="phoneNumber4-2" name="addrsForm[2].phoneNumber4" value="" maxlength="4" tabindex="10" +title="overall type: PHONE_HOME_NUMBER server type: PHONE_HOME_NUMBER heuristic type: PHONE_HOME_EXTENSION label: Ext parseable name: addrsForm[2].phoneNumber4 field signature: 2592995828 form signature: 5001876119589580889" +autofill-prediction="PHONE_HOME_NUMBER" +> + </div> + </div> + </div> + <div> + <div> + <div id="addressChangeEmailConfirm" > + <label for="email-2"> + <em>*</em>Email Address:</label> + <input type="email" id="email-2" name="addrsForm[2].email" value="" maxlength="40" tabindex="11" +title="overall type: EMAIL_ADDRESS server type: EMAIL_ADDRESS heuristic type: EMAIL_ADDRESS label: *Email Address: parseable name: addrsForm[2].email field signature: 3353412459 form signature: 5001876119589580889" +autofill-prediction="EMAIL_ADDRESS" +> + </div> + </div> + </div> + </div> + </div> + </div> + <div> + <input type="checkbox" id="guestEmailOptIn" name="guestEmailOptIn" checked="checked" tabindex="12" +title="overall type: UNKNOWN_TYPE server type: NO_SERVER_DATA heuristic type: UNKNOWN_TYPE label: Send me exclusive coupons and special offers to my inbox. parseable name: guestEmailOptIn field signature: 2624588538 form signature: 5001876119589580889" +autofill-prediction="UNKNOWN_TYPE"> +<input type="hidden" name="guestEmailOptIn" value="false"> +<label for="guestEmailOptIn" +>Send me exclusive coupons and special offers to my inbox.</label> + </div> + <input type="hidden" name="shippingEmailPreferences.emailHtml" value="true"> +<input type="hidden" name="shippingEmailPreferences.optInSelected" value="true"> + </div> + </div> + </div> + </div> + </div> + <div> + <div> + <div> + <div> + <table> + <tbody> + <tr> + <td colspan="4"> +<input type="hidden" name="cartRow[0].cartEntryId" value="0"> +<input type="hidden" name="cartRow[0].minQty" value="0"> + <input type="hidden" name="cartRow[0].originalQty" value="0"> +<input type="hidden" name="cartRow[0].skuNoEffort" value="510493"> +<input type="hidden" name="cartRow[0].qtyMinimumLimitation" value="1"> +<input type="hidden" name="cartRow[0].qtyIncrementLimitation" value="1"> + </td> + </tr> + </tbody> + </table> + </div> + </div> + </div> + </div> + </div> + <input type="hidden" name="step" value="ship"> +<input type="hidden" name="orderNumber" value="914646582"> +<input type="hidden" name="orderSubNumber" value="001"> +<input type="hidden" name="sameAsBilling" value="true"> + <input type="hidden" name="linked" value="false"> +<input type="hidden" name="billToID" value=""> +<input type="hidden" name="nececessaryToRevalidate" id="nececessaryToRevalidate" value="false"> + <input type="hidden" name="revalidateTrigger" id="revalidateTrigger" value=""> +<input type="hidden" name="group1Error" id="group1Error" value="false"> +<input type="hidden" name="flowMode" id="flowMode" value="ANONYMOUS"> + <div id="skipGroupOne"> + <div> + <input type="submit" value="Edit" name="cmd_edit" id="editShipping" +title="Edit"> +<button type="submit" name="cmd_confirm" id="continue">Continue</button> + </div> + </div> + </div> + </form> + <input type="hidden" name="enableGoogleAddr" id="enableGoogleAddr" value="true"> + </body> +</html> diff --git a/browser/extensions/formautofill/test/fixtures/third_party/OfficeDepot/SignIn.html b/browser/extensions/formautofill/test/fixtures/third_party/OfficeDepot/SignIn.html new file mode 100644 index 0000000000..70b55feddd --- /dev/null +++ b/browser/extensions/formautofill/test/fixtures/third_party/OfficeDepot/SignIn.html @@ -0,0 +1,44 @@ +<!DOCTYPE html> +<html lang="en-US"> + <head> + <meta http-equiv="Content-Type" content="text/html; charset=utf-8"> + <meta http-equiv="X-UA-Compatible" content="IE=Edge,chrome=1"> + <meta http-equiv="expires" content="0"> + <meta http-equiv="pragma" content="no-cache"> + <meta http-equiv="cache-control" content="no-cache"> + <meta http-equiv="pragma-directive" content="no-cache"> + <meta http-equiv="cache-directive" content="no-cache"> + <meta name="robots" content="NOODP, NOYDIR"> + <title> +</title> + </head> + <body> + <meta name="apple-itunes-app" content="app-id=471037434"> + <title>Office Supplies, Furniture, Technology at Office Depot</title> + <meta name="description" content="Shop office supplies, furniture & technology at Office Depot. For paper, ink, toner & more, find trusted brands at everyday low prices."> + <meta name="keywords" content="office supplies, office furniture, technology, electronics"> + <meta property="og:image" content="https://secure.www.odcdn.com/images/us/od/brand.png"> + <form name="loginForm" method="post" action="https://www.officedepot.com/account/loginAccountSet.do" autocomplete="off" id="loginForm"> + <input type="hidden" name="confirmationRequired" value="false"> + <input type="hidden" name="requestor" value="accountSummary"> + <input type="hidden" name="loginDestination" value=""> + <input type="hidden" id="isLoginFromRewardsModal" name="isLoginFromRewardsModal"> + <input type="hidden" name="reqLevel" value="ACCOUNT"> + <div> + <label for="loginName-0">Login name or email address</label> + <label>Logging in as a different user may cause pricing changes</label> + <input type="text" name="loginName" maxlength="100" size="10" autocomplete="" value="" id="loginName-0" tabindex="1"> + </div> + <div> + <label for="loginPassword">Password</label> + <input type="password" name="password" maxlength="50" size="10" value="" id="loginPassword" tabindex="2"> + <span id="forgotPasswordLink"> +<a >Forgot login name/password?</a> +</span> + </div> + <div> + <input type="checkbox" name="autoLogin" tabindex="3" value="on"> Keep me logged in + </div> + </form> + </body> +</html> diff --git a/browser/extensions/formautofill/test/fixtures/third_party/QVC/PaymentMethod.html b/browser/extensions/formautofill/test/fixtures/third_party/QVC/PaymentMethod.html new file mode 100644 index 0000000000..d8878692c3 --- /dev/null +++ b/browser/extensions/formautofill/test/fixtures/third_party/QVC/PaymentMethod.html @@ -0,0 +1,527 @@ +<html xmlns="http://www.w3.org/1999/xhtml"> + <head> + <meta http-equiv="Content-Type" content="text/html; charset=utf-8" /> + <meta http-equiv="X-UA-Compatible" content="IE=edge" /> + <meta property="fb:app_id" content="105047196722" /> + <meta property="fb:page_id" content="23797290954" /> + <meta name="format-detection" content="telephone=no" /> + <meta name="viewport" content="width=919" /> + <title>Payment Method</title> + </head> + <body> + <form id="frmEditPaymentMethod" name="frmEditPaymentMethod" method="post" action="https://www.qvc.com/webapp/wcs/stores/servlet/NPOOrderAddPaymentMethods"> + <input type="hidden" name="csrfToken" value="M1E1cTBwME9saUVZaU9iNUdJVnZqSm9JQThHM3gwSUt0elROSE9oSDJaST0=" /> + <input type="hidden" name="ccId" value="" /> + <input type="hidden" name="payMethodExpireMonth" value="" /> + <input type="hidden" name="payMethodExpireYear" value="" /> + <input type="hidden" name="ccDtime" value="" /> + <input type="hidden" name="payMethodNewCard" value="N" /> + <input type="hidden" name="payMethodExpiryEdited" value="N" /> + <input type="hidden" name="payMethodCVV" value="" /> + <input type="hidden" name="orderId" value="476661567" /> + <input type="hidden" name="langId" value="-1" /> + <input type="hidden" name="catalogId" value="10151" /> + <input type="hidden" name="storeId" value="10251" /> + <input type="hidden" name="dummydata" value="" /> + <input type="hidden" id="currentPaymentMethod" name="currentPaymentMethod" value="VI" /> + <input type="hidden" name="checkoutStep" value="3" /> + <input type="hidden" name="fromPaymentPage" value="Y" /> + <input type="hidden" name="URL" value="" /> + <input type="hidden" id="addAnotherGC" name="addAnotherGC" value="N" /> + <input type="hidden" name="BMLFilePath" id="BMLFilePath" value="https://www.qvc.com/wcsstore/US/content/html/popups/BillMeLaterTermsandConditions.html" /> + <div id="divTotalPurchaseSummary"> + <fieldset> + <label id="lblTotalPurchaseAmount" for="spanTotalPurchaseAmount">Total Purchase + <span>(including tax and S&H)</span>:</label> + </fieldset> + </div> + <div id="divEasyPayOptions"> + <fieldset> + <ul> + <li> + <input type="radio" value="Z" id="rb1PaymentsItem_1" name="rbItemEasyPayOption_1" +title="overall type: UNKNOWN_TYPE server type: NO_SERVER_DATA heuristic type: UNKNOWN_TYPE label: 4 Easy Pays of $39.74 parseable name: rbItemEasyPayOption_1 field signature: 915844214 form signature: 12190733459375771907" +autofill-prediction="UNKNOWN_TYPE" +/> + <label for="rb1PaymentsItem_1">4 Easy Pays of $39.74</label> + </li> + <li> + <input type="radio" value="N" id="rb2PaymentsItem_1" name="rbItemEasyPayOption_1" +title="overall type: UNKNOWN_TYPE server type: NO_SERVER_DATA heuristic type: UNKNOWN_TYPE label: 1 payment of $158.96 parseable name: rbItemEasyPayOption_1 field signature: 915844214 form signature: 12190733459375771907" +autofill-prediction="UNKNOWN_TYPE" +/> + <label for="rb2PaymentsItem_1">1 payment of $158.96</label> + </li> + </ul> + <input type="hidden" name="hItemEasyPayOptionPayMthd_1" id="hItemEasyPayOptionPayMthd_1" value="QVAXCBDCSIMCVIBL " /> + <input type="hidden" name="orderItemId_1" id="orderItemId_1" value="660521668" /> + </fieldset> + </div> + <div id="divNewPaymentMethod"> + <input id="ccId_1" type="hidden" name="ccId_1" value="" /> + <table id="tblNewPaymentMethod" border="0" cellspacing="0" cellpadding="0" width="100%" summary="Payment methods available for Checkout"> + <tbody> + <tr id="trBillMeLaterRow-1"> + <td> + <input type="radio" name="rbNewPaymentMethod" id="rbBillLater" value="rbBillLater" +title="overall type: UNKNOWN_TYPE server type: NO_SERVER_DATA heuristic type: UNKNOWN_TYPE label: PayPal Credit parseable name: rbNewPaymentMethod field signature: 95492298 form signature: 12190733459375771907" +autofill-prediction="UNKNOWN_TYPE" +/> + <label for="rbBillLater">PayPal Credit</label> + <span>(formerly Bill Me Later®)</span> + </td> + </tr> + <tr id="trBillMeLaterRow-2"> + <td colspan="5"> + <div id="divBillMeLater"> + <fieldset> + <label id="lblPrimaryPhone" for="txtPrimaryPhone">Home Phone:</label> + <input id="txtPrimaryPhone" type="tel" name="txtPrimaryPhone" autocomplete="off" autocorrect="off" value="" maxlength="14" size="14" +title="overall type: PHONE_HOME_CITY_AND_NUMBER server type: PHONE_HOME_CITY_AND_NUMBER heuristic type: PHONE_HOME_WHOLE_NUMBER label: Home Phone: parseable name: txtPrimaryPhone field signature: 918983855 form signature: 12190733459375771907" +autofill-prediction="PHONE_HOME_CITY_AND_NUMBER" +/> + </fieldset> + <fieldset> + <label id="lblEmailAddress" for="txtEmailAddress">Email Address:</label> + <input id="txtEmailAddress" type="email" name="txtEmailAddress" autocomplete="on" autocorrect="off" value="" +title="overall type: EMAIL_ADDRESS server type: EMAIL_ADDRESS heuristic type: EMAIL_ADDRESS label: Email Address: parseable name: txtEmailAddress field signature: 653947670 form signature: 12190733459375771907" +autofill-prediction="EMAIL_ADDRESS" +/> + </fieldset> + <fieldset> + <label id="lblSsn" for="txtSsn">Social Security Number:</label>XXX-XX-<input id="txtSsn" type="text" pattern="[0-9]*" autocomplete="off" autocorrect="off" maxlength="4" size="4" name="txtLast4SSN" value="" +title="overall type: UNKNOWN_TYPE server type: NO_SERVER_DATA heuristic type: UNKNOWN_TYPE label: Social Security Number: parseable name: txtLast4SSN field signature: 598258955 form signature: 12190733459375771907" +autofill-prediction="UNKNOWN_TYPE" +/> + </fieldset> + <fieldset> + <label id="lblDateOfBirthMonth" for="selDobMonth">Date of Birth:</label> + <select id="selDobMonth" name="dobMonth" size="1" +title="overall type: UNKNOWN_TYPE server type: NO_SERVER_DATA heuristic type: UNKNOWN_TYPE label: Date of Birth: parseable name: dobMonth field signature: 3916402925 form signature: 12190733459375771907" +autofill-prediction="UNKNOWN_TYPE" +> + <option value="month" selected="selected">Month</option> + <option value="01">January</option> + <option value="02">February</option> + <option value="03">March</option> + <option value="04">April</option> + <option value="05">May</option> + <option value="06">June</option> + <option value="07">July</option> + <option value="08">August</option> + <option value="09">September</option> + <option value="10">October</option> + <option value="11">November</option> + <option value="12">December</option> + </select> + <label id="lblDateOfBirthDay" for="selDobDay">Day of Birth:</label> + <select id="selDobDay" name="dobDay" size="1" +title="overall type: UNKNOWN_TYPE server type: NO_SERVER_DATA heuristic type: UNKNOWN_TYPE label: Day of Birth: parseable name: dobDay field signature: 4127787517 form signature: 12190733459375771907" +autofill-prediction="UNKNOWN_TYPE" +> + <option value="day" selected="selected">Day</option> + <option value="1">1</option> + <option value="2">2</option> + <option value="3">3</option> + <option value="4">4</option> + <option value="5">5</option> + <option value="6">6</option> + <option value="7">7</option> + <option value="8">8</option> + <option value="9">9</option> + <option value="10">10</option> + <option value="11">11</option> + <option value="12">12</option> + <option value="13">13</option> + <option value="14">14</option> + <option value="15">15</option> + <option value="16">16</option> + <option value="17">17</option> + <option value="18">18</option> + <option value="19">19</option> + <option value="20">20</option> + <option value="21">21</option> + <option value="22">22</option> + <option value="23">23</option> + <option value="24">24</option> + <option value="25">25</option> + <option value="26">26</option> + <option value="27">27</option> + <option value="28">28</option> + <option value="29">29</option> + <option value="30">30</option> + <option value="31">31</option> + </select> + <label id="lblDateOfBirthYear" for="selDobYear">Year of Birth:</label> + <select id="selDobYear" name="dobYear" size="1" +title="overall type: COMPANY_NAME server type: COMPANY_NAME heuristic type: UNKNOWN_TYPE label: Year of Birth: parseable name: dobYear field signature: 3750696607 form signature: 12190733459375771907" +autofill-prediction="COMPANY_NAME" +> + <option value="year" selected="selected">Year</option> + <option value="1927">1927</option> + <option value="1928">1928</option> + <option value="1929">1929</option> + <option value="1930">1930</option> + <option value="1931">1931</option> + <option value="1932">1932</option> + <option value="1933">1933</option> + <option value="1934">1934</option> + <option value="1935">1935</option> + <option value="1936">1936</option> + <option value="1937">1937</option> + <option value="1938">1938</option> + <option value="1939">1939</option> + <option value="1940">1940</option> + <option value="1941">1941</option> + <option value="1942">1942</option> + <option value="1943">1943</option> + <option value="1944">1944</option> + <option value="1945">1945</option> + <option value="1946">1946</option> + <option value="1947">1947</option> + <option value="1948">1948</option> + <option value="1949">1949</option> + <option value="1950">1950</option> + <option value="1951">1951</option> + <option value="1952">1952</option> + <option value="1953">1953</option> + <option value="1954">1954</option> + <option value="1955">1955</option> + <option value="1956">1956</option> + <option value="1957">1957</option> + <option value="1958">1958</option> + <option value="1959">1959</option> + <option value="1960">1960</option> + <option value="1961">1961</option> + <option value="1962">1962</option> + <option value="1963">1963</option> + <option value="1964">1964</option> + <option value="1965">1965</option> + <option value="1966">1966</option> + <option value="1967">1967</option> + <option value="1968">1968</option> + <option value="1969">1969</option> + <option value="1970">1970</option> + <option value="1971">1971</option> + <option value="1972">1972</option> + <option value="1973">1973</option> + <option value="1974">1974</option> + <option value="1975">1975</option> + <option value="1976">1976</option> + <option value="1977">1977</option> + <option value="1978">1978</option> + <option value="1979">1979</option> + <option value="1980">1980</option> + <option value="1981">1981</option> + <option value="1982">1982</option> + <option value="1983">1983</option> + <option value="1984">1984</option> + <option value="1985">1985</option> + <option value="1986">1986</option> + <option value="1987">1987</option> + <option value="1988">1988</option> + <option value="1989">1989</option> + <option value="1990">1990</option> + <option value="1991">1991</option> + <option value="1992">1992</option> + <option value="1993">1993</option> + <option value="1994">1994</option> + <option value="1995">1995</option> + <option value="1996">1996</option> + <option value="1997">1997</option> + <option value="1998">1998</option> + <option value="1999">1999</option> + <option value="2000">2000</option> + <option value="2001">2001</option> + <option value="2002">2002</option> + <option value="2003">2003</option> + <option value="2004">2004</option> + <option value="2005">2005</option> + <option value="2006">2006</option> + <option value="2007">2007</option> + <option value="2008">2008</option> + <option value="2009">2009</option> + <option value="2010">2010</option> + <option value="2011">2011</option> + <option value="2012">2012</option> + <option value="2013">2013</option> + <option value="2014">2014</option> + <option value="2015">2015</option> + <option value="2016">2016</option> + <option value="2017">2017</option> + </select> + </fieldset> + <div id="monetate_selectorHTML_b234e9d_0"> + <div> + <label for="cbBillMeLaterElectronicConsent" id="lblBillMeLaterElectronicConsent"> <input id="cbBillMeLaterElectronicConsent" name="bmlAcceptTerms" type="checkbox" /> + </label> + </div> + </div> + </div> + <div id="BMLScroll"> + <input name="bmlAcceptTermsTemp" id="cbBillMeLaterElectronicConsentTemp" type="checkbox" +title="overall type: UNKNOWN_TYPE server type: NO_SERVER_DATA heuristic type: UNKNOWN_TYPE label: Note: After scrolling, please remain at the bottom of the Terms and Conditions section to continue. parseable name: bmlAcceptTermsTemp field signature: 1379157860 form signature: 12190733459375771907" +autofill-prediction="UNKNOWN_TYPE" +/> + <label for="cbBillMeLaterElectronicConsent" id="lblBillMeLaterElectronicConsent"> <input name="bmlAcceptTerms" id="cbBillMeLaterElectronicConsent" type="checkbox" +title="overall type: UNKNOWN_TYPE server type: NO_SERVER_DATA heuristic type: UNKNOWN_TYPE label: Note: After scrolling, please remain at the bottom of the Terms and Conditions section to continue. parseable name: bmlAcceptTerms field signature: 4275106371 form signature: 12190733459375771907" +autofill-prediction="UNKNOWN_TYPE" +/> + </label> + </div> + </td> + </tr> + <tr id="trEnterNewCard-1"> + <td colspan="5"> + <input type="radio" name="rbNewPaymentMethod" id="rbNewCard" value="rbNewCard" checked="checked" +title="overall type: UNKNOWN_TYPE server type: NO_SERVER_DATA heuristic type: UNKNOWN_TYPE label: Enter New Card parseable name: rbNewPaymentMethod field signature: 95492298 form signature: 12190733459375771907" +autofill-prediction="UNKNOWN_TYPE" +/> + <label for="rbNewCard"> Enter New Card</label> + </td> + </tr> + <tr id="trEnterNewCard-2"> + <td colspan="5"> + <div id="divEnterNewCard"> + <fieldset> + <label id="lblNewCardType" for="selNewCardType">Type</label> + <select name="NewCardType" id="selNewCardType" size="1" +title="overall type: CREDIT_CARD_TYPE server type: CREDIT_CARD_TYPE heuristic type: CREDIT_CARD_TYPE label: Type parseable name: NewCardType field signature: 3035337803 form signature: 12190733459375771907" +autofill-prediction="CREDIT_CARD_TYPE" +> + <option value="AX">American Express</option> + <option value="CB">Carte Blanc</option> + <option value="DC">Diners Club</option> + <option value="SI">Discover</option> + <option value="MC">MasterCard</option> + <option value="QV">QCard</option> + <option value="VI" selected="selected">Visa</option> + </select> + <label id="lblNewCardNumber" for="txtNewCardNumber">Number:</label> + <input id="txtNewCardNumber" name="NewCardNumber" type="text" maxlength="20" size="21" autocomplete="off" autocorrect="off" value="" pattern="[0-9]*" +title="overall type: CREDIT_CARD_NUMBER server type: CREDIT_CARD_NUMBER heuristic type: CREDIT_CARD_NUMBER label: Number: parseable name: NewCardNumber field signature: 2370218454 form signature: 12190733459375771907" +autofill-prediction="CREDIT_CARD_NUMBER" +/> + <input id="hidNewLastCC" name="hidNewLastCC" type="hidden" value="" /> + <fieldset id="fldExpireDateNewCard"> + <label id="lblNewCard" for="selNewCard">Expiration Date:</label> + <select name="selNewCard" id="selNewCard" size="1" +title="overall type: CREDIT_CARD_EXP_DATE_4_DIGIT_YEAR server type: NO_SERVER_DATA heuristic type: CREDIT_CARD_EXP_DATE_4_DIGIT_YEAR label: Expiration Date: parseable name: selNewCard field signature: 2308816317 form signature: 12190733459375771907" +autofill-prediction="CREDIT_CARD_EXP_DATE_4_DIGIT_YEAR" +> + <option value="03/2017" selected="selected">03/2017</option> + <option value="04/2017">04/2017</option> + <option value="05/2017">05/2017</option> + <option value="06/2017">06/2017</option> + <option value="07/2017">07/2017</option> + <option value="08/2017">08/2017</option> + <option value="09/2017">09/2017</option> + <option value="10/2017">10/2017</option> + <option value="11/2017">11/2017</option> + <option value="12/2017">12/2017</option> + <option value="01/2018">01/2018</option> + <option value="02/2018">02/2018</option> + <option value="03/2018">03/2018</option> + <option value="04/2018">04/2018</option> + <option value="05/2018">05/2018</option> + <option value="06/2018">06/2018</option> + <option value="07/2018">07/2018</option> + <option value="08/2018">08/2018</option> + <option value="09/2018">09/2018</option> + <option value="10/2018">10/2018</option> + <option value="11/2018">11/2018</option> + <option value="12/2018">12/2018</option> + <option value="01/2019">01/2019</option> + <option value="02/2019">02/2019</option> + <option value="03/2019">03/2019</option> + <option value="04/2019">04/2019</option> + <option value="05/2019">05/2019</option> + <option value="06/2019">06/2019</option> + <option value="07/2019">07/2019</option> + <option value="08/2019">08/2019</option> + <option value="09/2019">09/2019</option> + <option value="10/2019">10/2019</option> + <option value="11/2019">11/2019</option> + <option value="12/2019">12/2019</option> + <option value="01/2020">01/2020</option> + <option value="02/2020">02/2020</option> + <option value="03/2020">03/2020</option> + <option value="04/2020">04/2020</option> + <option value="05/2020">05/2020</option> + <option value="06/2020">06/2020</option> + <option value="07/2020">07/2020</option> + <option value="08/2020">08/2020</option> + <option value="09/2020">09/2020</option> + <option value="10/2020">10/2020</option> + <option value="11/2020">11/2020</option> + <option value="12/2020">12/2020</option> + <option value="01/2021">01/2021</option> + <option value="02/2021">02/2021</option> + <option value="03/2021">03/2021</option> + <option value="04/2021">04/2021</option> + <option value="05/2021">05/2021</option> + <option value="06/2021">06/2021</option> + <option value="07/2021">07/2021</option> + <option value="08/2021">08/2021</option> + <option value="09/2021">09/2021</option> + <option value="10/2021">10/2021</option> + <option value="11/2021">11/2021</option> + <option value="12/2021">12/2021</option> + <option value="01/2022">01/2022</option> + <option value="02/2022">02/2022</option> + <option value="03/2022">03/2022</option> + <option value="04/2022">04/2022</option> + <option value="05/2022">05/2022</option> + <option value="06/2022">06/2022</option> + <option value="07/2022">07/2022</option> + <option value="08/2022">08/2022</option> + <option value="09/2022">09/2022</option> + <option value="10/2022">10/2022</option> + <option value="11/2022">11/2022</option> + <option value="12/2022">12/2022</option> + <option value="01/2023">01/2023</option> + <option value="02/2023">02/2023</option> + <option value="03/2023">03/2023</option> + <option value="04/2023">04/2023</option> + <option value="05/2023">05/2023</option> + <option value="06/2023">06/2023</option> + <option value="07/2023">07/2023</option> + <option value="08/2023">08/2023</option> + <option value="09/2023">09/2023</option> + <option value="10/2023">10/2023</option> + <option value="11/2023">11/2023</option> + <option value="12/2023">12/2023</option> + <option value="01/2024">01/2024</option> + <option value="02/2024">02/2024</option> + <option value="03/2024">03/2024</option> + <option value="04/2024">04/2024</option> + <option value="05/2024">05/2024</option> + <option value="06/2024">06/2024</option> + <option value="07/2024">07/2024</option> + <option value="08/2024">08/2024</option> + <option value="09/2024">09/2024</option> + <option value="10/2024">10/2024</option> + <option value="11/2024">11/2024</option> + <option value="12/2024">12/2024</option> + <option value="01/2025">01/2025</option> + <option value="02/2025">02/2025</option> + <option value="03/2025">03/2025</option> + <option value="04/2025">04/2025</option> + <option value="05/2025">05/2025</option> + <option value="06/2025">06/2025</option> + <option value="07/2025">07/2025</option> + <option value="08/2025">08/2025</option> + <option value="09/2025">09/2025</option> + <option value="10/2025">10/2025</option> + <option value="11/2025">11/2025</option> + <option value="12/2025">12/2025</option> + <option value="01/2026">01/2026</option> + <option value="02/2026">02/2026</option> + <option value="03/2026">03/2026</option> + <option value="04/2026">04/2026</option> + <option value="05/2026">05/2026</option> + <option value="06/2026">06/2026</option> + <option value="07/2026">07/2026</option> + <option value="08/2026">08/2026</option> + <option value="09/2026">09/2026</option> + <option value="10/2026">10/2026</option> + <option value="11/2026">11/2026</option> + <option value="12/2026">12/2026</option> + <option value="01/2027">01/2027</option> + <option value="02/2027">02/2027</option> + <option value="03/2027">03/2027</option> + <option value="04/2027">04/2027</option> + <option value="05/2027">05/2027</option> + <option value="06/2027">06/2027</option> + <option value="07/2027">07/2027</option> + <option value="08/2027">08/2027</option> + <option value="09/2027">09/2027</option> + <option value="10/2027">10/2027</option> + <option value="11/2027">11/2027</option> + <option value="12/2027">12/2027</option> + </select> + </fieldset> + </fieldset> + <fieldset id="fldSecurityCodeNewCard"> + <div id="fldSecurityCode"> + <label for="txtSecurityCode">Security Code:</label> <input id="txtSecurityCode" name="SecurityCode" type="text" pattern="[0-9]*" maxlength="5" size="5" value="" +title="overall type: CREDIT_CARD_VERIFICATION_CODE server type: NO_SERVER_DATA heuristic type: CREDIT_CARD_VERIFICATION_CODE label: Security Code: parseable name: SecurityCode field signature: 4107652875 form signature: 12190733459375771907" +autofill-prediction="CREDIT_CARD_VERIFICATION_CODE" +/> + </div> + </fieldset> + <div id="divQButton"> + <span> + <input type="button" id="btnQCard" value="Add My QCard" /> + </span> + <input type="hidden" id="addMyQCard" name="addMyQCard" value="false" /> + <input type="hidden" id="isNPO" name="isNPO" value="true" /> + </div> + </div> + </td> + </tr> + </tbody> + </table> + </div> + <div id="divQvcGiftCardsMethod"> + <div> + <div id="divGiftCardPaymentOption"> + <input type="checkbox" name="cbGiftCard" id="cbGiftCard" +title="overall type: UNKNOWN_TYPE server type: NO_SERVER_DATA heuristic type: UNKNOWN_TYPE label: Use a Gift Card parseable name: cbGiftCard field signature: 2461714937 form signature: 12190733459375771907" +autofill-prediction="UNKNOWN_TYPE" +/> + <label for="cbGiftCard">Use a Gift Card</label> + <div id="divQvcGiftCardEntry"> + <fieldset> + <label for="txtQvcGiftCardNumber">Card Number:</label> + <input id="txtQvcGiftCardNumber" name="txtQvcGiftCardNumber" type="tel" autocomplete="off" autocorrect="off" maxlength="19" size="19" value="" +title="overall type: CREDIT_CARD_NUMBER server type: CREDIT_CARD_NUMBER heuristic type: CREDIT_CARD_NUMBER label: Card Number: parseable name: txtQvcGiftCardNumber field signature: 375442765 form signature: 12190733459375771907" +autofill-prediction="CREDIT_CARD_NUMBER" +/> + <label for="txtQvcGiftCardSecurityIdNumber">Security ID Number:</label> + <input id="txtQvcGiftCardSecurityIdNumber" name="txtQvcGiftCardSecurityIdNumber" type="text" autocomplete="off" autocorrect="off" maxlength="12" size="19" value="" +title="overall type: UNKNOWN_TYPE server type: NO_SERVER_DATA heuristic type: UNKNOWN_TYPE label: Security ID Number: parseable name: txtQvcGiftCardSecurityIdNumber field signature: 383370886 form signature: 12190733459375771907" +autofill-prediction="UNKNOWN_TYPE" +/> + <span> + <input type="button" id="btnQvcGiftCardEnterAnotherCard" name="btnQvcGiftCardEnterAnotherCard" value="Enter Another Card" /> + <span>Enter Another Card</span> + </span> + </fieldset> + </div> + </div> + </div> + </div> + <div id="monetate_selectorHTML_c937b2dd_0"> + <div id="mainVoucherCodeDiv"> + <input id="txtQvcApplyCodeNumber" name="txtQvcApplyCodeNumber" type="text" autocomplete="off" autocorrect="off" maxlength="25" size="19" value="" /> + <input type="button" id="btnApplyCode" value="Apply Code" /> + </div> + </div> + <div id="divButtons"> + <span> + <input type="button" id="btnSubmitChanges" value="Continue Checkout" /> + <span>Continue Checkout</span> + </span> + <span> + <span>Continue Checkout</span> + <input type="button" id="btnQCard2" value="Continue Checkout" /> + </span> + <span> + <input type="button" id="btnReturnToOrder" value="EDIT SHOPPING CART" /> + <span>Edit Shopping cart</span> + </span> + </div> + </form> + <form id="captureFormFooter" method="post" name="captureFormFooter"> + <div id="divEmailFormFooter"> + <label for="emailAddress1Footer">Get sneak previews of special offers and upcoming events delivered to your inbox.</label> + <span id="emailAddressErrorFooter">*</span> + <input id="emailAddress1Footer" type="text" value="Enter email" /> + <input id="emailAddress2Footer" type="text" value="Confirm email" /> + <input id="signUpFooter" type="submit" value="Sign Up" /> + <span id="disclaimerTextFooter">*You're signing up to receive QVC promotional email.</span> + </div> + </form> + </body> +</html> diff --git a/browser/extensions/formautofill/test/fixtures/third_party/QVC/SignIn.html b/browser/extensions/formautofill/test/fixtures/third_party/QVC/SignIn.html new file mode 100644 index 0000000000..a056ccfc5c --- /dev/null +++ b/browser/extensions/formautofill/test/fixtures/third_party/QVC/SignIn.html @@ -0,0 +1,80 @@ +<html xmlns="http://www.w3.org/1999/xhtml"> + <head> + <meta http-equiv="Content-Type" content="text/html; charset=utf-8" /> + <meta property="fb:app_id" content="105047196722" /> + <meta property="fb:page_id" content="23797290954" /> + <meta name="format-detection" content="telephone=no" /> + <meta name="viewport" content="width=919" /> + <title>QVC.com Sign In</title> + </head> + <body> + <form id="frmMastheadSearch" method="get" action="http://www.qvc.com/CatalogSearch"> + <fieldset> + <input type="hidden" name="langId" value="-1" /> + <input type="hidden" name="storeId" value="10251" /> + <input type="hidden" name="catalogId" value="10151" /> + <label for="txtMastheadSearch">Search QVC:</label> + <input id="txtMastheadSearch" name="keyword" type="text" value="" autocomplete="off" autocorrect="off" placeholder="Search QVC" /> + <input id="btnMastheadSearch" type="submit" alt="Go" value="Go" /> + </fieldset> + </form> + <form id="frmSignIn" name="frmSignIn" method="post"> + <input type="hidden" name="csrfToken" value="M1E1cTBwME9saUVZaU9iNUdJVnZqSm9JQThHM3gwSUt0elROSE9oSDJaST0=" /> + <fieldset> + <input type="hidden" name="storeId" value="10251" /> + <input type="hidden" name="catalogId" value="10151" /> + <input type="hidden" name="langId" value="-1" /> + <input type="hidden" name="URL" id="URL" value="http://www.qvc.com/Checkout?orderId=476661567&langId=-1&storeId=10251&catalogId=10151" /> + <input type="hidden" name="reLogonURL" value="LogonForm" /> + <input type="hidden" name="rememberMe" id="rememberMe" value="true" /> + <input type="hidden" name="fromPage" id="fromPage" value="checkout" /> + <input type="hidden" name="orderId" value="476661567" /> + </fieldset> + <div id="signInFields"> + <label id="lblEmailAddress" for="txtEmailAddress">Email Address:</label> + <input id="txtEmailAddress" type="email" value="" maxlength="128" size="30" name="logonId" placeholder="Email Address" /> + <div> + <label id="lblPassword" for="txtPassword">QVC Password:</label> + <input id="txtPassword" type="password" maxlength="24" size="30" name="logonPassword" placeholder="QVC Password" /> + </div> + </div> + <div> + <div id="divUseDefaults"> + <input id="cbUseDefaults" type="checkbox" name="cbReviewOrderTotal" value="on" /> + <label id="lblUseDefaults" for="cbUseDefaults">Using your default shipping and payment information? Check the box to go directly to Order Summary.</label> + <input type="hidden" name="speedBuyTypeInd" id="speedBuyTypeInd" value="C" /> + </div> + </div> + <div id="divFormButtons"> + <div> + <span id="createPasswordSpan"> + <span>Create Password</span> + <input id="btnSignIn" type="button" value="Create Password" /> + </span> + <span id="continueButtonSpan"> + <span>Continue</span> + <input id="btnSignIn" type="button" value="Continue" /> + </span> + </div> + <div> + <span> + <span>Sign In</span> + <input id="btnSignIn" type="submit" value="Sign In" /> + </span> + </div> + </div> + <fieldset> + <input id="userPrefs" type="hidden" value="" name="userPrefs" /> + </fieldset> + </form> + <form id="frmCreateAccount"> + <input type="hidden" name="csrfToken" value="M1E1cTBwME9saUVZaU9iNUdJVnZqSm9JQThHM3gwSUt0elROSE9oSDJaST0=" /> + <div id="divButtons"> + <span> + <span>Continue</span> + <input id="btnSignUp" type="button" value="Continue" /> + </span> + </div> + </form> + </body> +</html> diff --git a/browser/extensions/formautofill/test/fixtures/third_party/QVC/YourInformation.html b/browser/extensions/formautofill/test/fixtures/third_party/QVC/YourInformation.html new file mode 100644 index 0000000000..df5fdc2200 --- /dev/null +++ b/browser/extensions/formautofill/test/fixtures/third_party/QVC/YourInformation.html @@ -0,0 +1,522 @@ +<html xmlns="http://www.w3.org/1999/xhtml"> + <head> + <meta http-equiv="Content-Type" content="text/html; charset=utf-8" /> + <meta http-equiv="X-UA-Compatible" content="IE=edge" /> + <meta property="fb:app_id" content="105047196722" /> + <meta property="fb:page_id" content="23797290954" /> + <meta name="format-detection" content="telephone=no" /> + <meta name="viewport" content="width=919" /> + <title>Payment Method</title> + </head> + <body> + <form id="frmEditPaymentMethod" name="frmEditPaymentMethod" method="post" action="https://www.qvc.com/webapp/wcs/stores/servlet/NPOOrderAddPaymentMethods"> + <input type="hidden" name="csrfToken" value="M1E1cTBwME9saUVZaU9iNUdJVnZqSm9JQThHM3gwSUt0elROSE9oSDJaST0=" /> + <input type="hidden" name="ccId" value="" /> + <input type="hidden" name="payMethodExpireMonth" value="" /> + <input type="hidden" name="payMethodExpireYear" value="" /> + <input type="hidden" name="ccDtime" value="" /> + <input type="hidden" name="payMethodNewCard" value="N" /> + <input type="hidden" name="payMethodExpiryEdited" value="N" /> + <input type="hidden" name="payMethodCVV" value="" /> + <input type="hidden" name="orderId" value="476661567" /> + <input type="hidden" name="langId" value="-1" /> + <input type="hidden" name="catalogId" value="10151" /> + <input type="hidden" name="storeId" value="10251" /> + <input type="hidden" name="dummydata" value="" /> + <input type="hidden" id="currentPaymentMethod" name="currentPaymentMethod" value="VI" /> + <input type="hidden" name="checkoutStep" value="3" /> + <input type="hidden" name="fromPaymentPage" value="Y" /> + <input type="hidden" name="URL" value="" /> + <input type="hidden" id="addAnotherGC" name="addAnotherGC" value="N" /> + <input type="hidden" name="BMLFilePath" id="BMLFilePath" value="https://www.qvc.com/wcsstore/US/content/html/popups/BillMeLaterTermsandConditions.html" /> + <div id="divEasyPayOptions"> + <fieldset> + <ul> + <li> + <input type="radio" value="Z" id="rb1PaymentsItem_1" name="rbItemEasyPayOption_1" +title="overall type: UNKNOWN_TYPE server type: NO_SERVER_DATA heuristic type: UNKNOWN_TYPE label: 4 Easy Pays of $39.74 parseable name: rbItemEasyPayOption_1 field signature: 915844214 form signature: 12190733459375771907" +autofill-prediction="UNKNOWN_TYPE" +/> + <label for="rb1PaymentsItem_1">4 Easy Pays of $39.74</label> + </li> + <li> + <input type="radio" value="N" id="rb2PaymentsItem_1" name="rbItemEasyPayOption_1" +title="overall type: UNKNOWN_TYPE server type: NO_SERVER_DATA heuristic type: UNKNOWN_TYPE label: 1 payment of $158.96 parseable name: rbItemEasyPayOption_1 field signature: 915844214 form signature: 12190733459375771907" +autofill-prediction="UNKNOWN_TYPE" +/> + <label for="rb2PaymentsItem_1">1 payment of $158.96</label> + </li> + </ul> + <input type="hidden" name="hItemEasyPayOptionPayMthd_1" id="hItemEasyPayOptionPayMthd_1" value="QVAXCBDCSIMCVIBL " /> + <input type="hidden" name="orderItemId_1" id="orderItemId_1" value="660521668" /> + </fieldset> + </div> + <div id="divNewPaymentMethod"> + <input id="ccId_1" type="hidden" name="ccId_1" value="" /> + <table id="tblNewPaymentMethod" border="0" cellspacing="0" cellpadding="0" width="100%" summary="Payment methods available for Checkout"> + <tbody> + <tr id="trBillMeLaterRow-1"> + <td> + <input type="radio" name="rbNewPaymentMethod" id="rbBillLater" value="rbBillLater" +title="overall type: UNKNOWN_TYPE server type: NO_SERVER_DATA heuristic type: UNKNOWN_TYPE label: PayPal Credit parseable name: rbNewPaymentMethod field signature: 95492298 form signature: 12190733459375771907" +autofill-prediction="UNKNOWN_TYPE" +/> + <label for="rbBillLater">PayPal Credit</label> + </td> + </tr> + <tr id="trBillMeLaterRow-2"> + <td colspan="5"> + <div id="divBillMeLater"> + <fieldset> + <label id="lblPrimaryPhone" for="txtPrimaryPhone">Home Phone:</label> + <input id="txtPrimaryPhone" type="tel" name="txtPrimaryPhone" autocomplete="off" autocorrect="off" value="" maxlength="14" size="14" +title="overall type: PHONE_HOME_CITY_AND_NUMBER server type: PHONE_HOME_CITY_AND_NUMBER heuristic type: PHONE_HOME_WHOLE_NUMBER label: Home Phone: parseable name: txtPrimaryPhone field signature: 918983855 form signature: 12190733459375771907" +autofill-prediction="PHONE_HOME_CITY_AND_NUMBER" +/> + </fieldset> + <fieldset> + <label id="lblEmailAddress" for="txtEmailAddress">Email Address:</label> + <input id="txtEmailAddress" type="email" name="txtEmailAddress" autocomplete="on" autocorrect="off" value="" +title="overall type: EMAIL_ADDRESS server type: EMAIL_ADDRESS heuristic type: EMAIL_ADDRESS label: Email Address: parseable name: txtEmailAddress field signature: 653947670 form signature: 12190733459375771907" +autofill-prediction="EMAIL_ADDRESS" +/> + </fieldset> + <fieldset> + <label id="lblSsn" for="txtSsn">Social Security Number:</label>XXX-XX-<input id="txtSsn" type="text" pattern="[0-9]*" autocomplete="off" autocorrect="off" maxlength="4" size="4" name="txtLast4SSN" value="" +title="overall type: UNKNOWN_TYPE server type: NO_SERVER_DATA heuristic type: UNKNOWN_TYPE label: Social Security Number: parseable name: txtLast4SSN field signature: 598258955 form signature: 12190733459375771907" +autofill-prediction="UNKNOWN_TYPE" +/> + </fieldset> + <fieldset> + <label id="lblDateOfBirthMonth" for="selDobMonth">Date of Birth:</label> + <select id="selDobMonth" name="dobMonth" size="1" +title="overall type: UNKNOWN_TYPE server type: NO_SERVER_DATA heuristic type: UNKNOWN_TYPE label: Date of Birth: parseable name: dobMonth field signature: 3916402925 form signature: 12190733459375771907" +autofill-prediction="UNKNOWN_TYPE" +> + <option value="month" selected="selected">Month</option> + <option value="01">January</option> + <option value="02">February</option> + <option value="03">March</option> + <option value="04">April</option> + <option value="05">May</option> + <option value="06">June</option> + <option value="07">July</option> + <option value="08">August</option> + <option value="09">September</option> + <option value="10">October</option> + <option value="11">November</option> + <option value="12">December</option> + </select> + <label id="lblDateOfBirthDay" for="selDobDay">Day of Birth:</label> + <select id="selDobDay" name="dobDay" size="1" +title="overall type: UNKNOWN_TYPE server type: NO_SERVER_DATA heuristic type: UNKNOWN_TYPE label: Day of Birth: parseable name: dobDay field signature: 4127787517 form signature: 12190733459375771907" +autofill-prediction="UNKNOWN_TYPE" +> + <option value="day" selected="selected">Day</option> + <option value="1">1</option> + <option value="2">2</option> + <option value="3">3</option> + <option value="4">4</option> + <option value="5">5</option> + <option value="6">6</option> + <option value="7">7</option> + <option value="8">8</option> + <option value="9">9</option> + <option value="10">10</option> + <option value="11">11</option> + <option value="12">12</option> + <option value="13">13</option> + <option value="14">14</option> + <option value="15">15</option> + <option value="16">16</option> + <option value="17">17</option> + <option value="18">18</option> + <option value="19">19</option> + <option value="20">20</option> + <option value="21">21</option> + <option value="22">22</option> + <option value="23">23</option> + <option value="24">24</option> + <option value="25">25</option> + <option value="26">26</option> + <option value="27">27</option> + <option value="28">28</option> + <option value="29">29</option> + <option value="30">30</option> + <option value="31">31</option> + </select> + <label id="lblDateOfBirthYear" for="selDobYear">Year of Birth:</label> + <select id="selDobYear" name="dobYear" size="1" +title="overall type: COMPANY_NAME server type: COMPANY_NAME heuristic type: UNKNOWN_TYPE label: Year of Birth: parseable name: dobYear field signature: 3750696607 form signature: 12190733459375771907" +autofill-prediction="COMPANY_NAME" +> + <option value="year" selected="selected">Year</option> + <option value="1927">1927</option> + <option value="1928">1928</option> + <option value="1929">1929</option> + <option value="1930">1930</option> + <option value="1931">1931</option> + <option value="1932">1932</option> + <option value="1933">1933</option> + <option value="1934">1934</option> + <option value="1935">1935</option> + <option value="1936">1936</option> + <option value="1937">1937</option> + <option value="1938">1938</option> + <option value="1939">1939</option> + <option value="1940">1940</option> + <option value="1941">1941</option> + <option value="1942">1942</option> + <option value="1943">1943</option> + <option value="1944">1944</option> + <option value="1945">1945</option> + <option value="1946">1946</option> + <option value="1947">1947</option> + <option value="1948">1948</option> + <option value="1949">1949</option> + <option value="1950">1950</option> + <option value="1951">1951</option> + <option value="1952">1952</option> + <option value="1953">1953</option> + <option value="1954">1954</option> + <option value="1955">1955</option> + <option value="1956">1956</option> + <option value="1957">1957</option> + <option value="1958">1958</option> + <option value="1959">1959</option> + <option value="1960">1960</option> + <option value="1961">1961</option> + <option value="1962">1962</option> + <option value="1963">1963</option> + <option value="1964">1964</option> + <option value="1965">1965</option> + <option value="1966">1966</option> + <option value="1967">1967</option> + <option value="1968">1968</option> + <option value="1969">1969</option> + <option value="1970">1970</option> + <option value="1971">1971</option> + <option value="1972">1972</option> + <option value="1973">1973</option> + <option value="1974">1974</option> + <option value="1975">1975</option> + <option value="1976">1976</option> + <option value="1977">1977</option> + <option value="1978">1978</option> + <option value="1979">1979</option> + <option value="1980">1980</option> + <option value="1981">1981</option> + <option value="1982">1982</option> + <option value="1983">1983</option> + <option value="1984">1984</option> + <option value="1985">1985</option> + <option value="1986">1986</option> + <option value="1987">1987</option> + <option value="1988">1988</option> + <option value="1989">1989</option> + <option value="1990">1990</option> + <option value="1991">1991</option> + <option value="1992">1992</option> + <option value="1993">1993</option> + <option value="1994">1994</option> + <option value="1995">1995</option> + <option value="1996">1996</option> + <option value="1997">1997</option> + <option value="1998">1998</option> + <option value="1999">1999</option> + <option value="2000">2000</option> + <option value="2001">2001</option> + <option value="2002">2002</option> + <option value="2003">2003</option> + <option value="2004">2004</option> + <option value="2005">2005</option> + <option value="2006">2006</option> + <option value="2007">2007</option> + <option value="2008">2008</option> + <option value="2009">2009</option> + <option value="2010">2010</option> + <option value="2011">2011</option> + <option value="2012">2012</option> + <option value="2013">2013</option> + <option value="2014">2014</option> + <option value="2015">2015</option> + <option value="2016">2016</option> + <option value="2017">2017</option> + </select> + </fieldset> + <div id="monetate_selectorHTML_b234e9d_0"> + <div> + <label for="cbBillMeLaterElectronicConsent" id="lblBillMeLaterElectronicConsent"> <input id="cbBillMeLaterElectronicConsent" name="bmlAcceptTerms" type="checkbox" /> + </label> + </div> + </div> + </div> + <div id="BMLScroll"> + <input name="bmlAcceptTermsTemp" id="cbBillMeLaterElectronicConsentTemp" type="checkbox" +title="overall type: UNKNOWN_TYPE server type: NO_SERVER_DATA heuristic type: UNKNOWN_TYPE label: Note: After scrolling, please remain at the bottom of the Terms and Conditions section to continue. parseable name: bmlAcceptTermsTemp field signature: 1379157860 form signature: 12190733459375771907" +autofill-prediction="UNKNOWN_TYPE" +/> + <label for="cbBillMeLaterElectronicConsent" id="lblBillMeLaterElectronicConsent"> <input name="bmlAcceptTerms" id="cbBillMeLaterElectronicConsent" type="checkbox" +title="overall type: UNKNOWN_TYPE server type: NO_SERVER_DATA heuristic type: UNKNOWN_TYPE label: Note: After scrolling, please remain at the bottom of the Terms and Conditions section to continue. parseable name: bmlAcceptTerms field signature: 4275106371 form signature: 12190733459375771907" +autofill-prediction="UNKNOWN_TYPE" +/> + </label> + </div> + </td> + </tr> + <tr id="trEnterNewCard-1"> + <td colspan="5"> + <input type="radio" name="rbNewPaymentMethod" id="rbNewCard" value="rbNewCard" checked="checked" +title="overall type: UNKNOWN_TYPE server type: NO_SERVER_DATA heuristic type: UNKNOWN_TYPE label: Enter New Card parseable name: rbNewPaymentMethod field signature: 95492298 form signature: 12190733459375771907" +autofill-prediction="UNKNOWN_TYPE" +/> + <label for="rbNewCard"> Enter New Card</label> + </td> + </tr> + <tr id="trEnterNewCard-2"> + <td colspan="5"> + <div id="divEnterNewCard"> + <fieldset> + <label id="lblNewCardType" for="selNewCardType">Type</label> + <select name="NewCardType" id="selNewCardType" size="1" +title="overall type: CREDIT_CARD_TYPE server type: CREDIT_CARD_TYPE heuristic type: CREDIT_CARD_TYPE label: Type parseable name: NewCardType field signature: 3035337803 form signature: 12190733459375771907" +autofill-prediction="CREDIT_CARD_TYPE" +> + <option value="AX">American Express</option> + <option value="CB">Carte Blanc</option> + <option value="DC">Diners Club</option> + <option value="SI">Discover</option> + <option value="MC">MasterCard</option> + <option value="QV">QCard</option> + <option value="VI" selected="selected">Visa</option> + </select> + <label id="lblNewCardNumber" for="txtNewCardNumber">Number:</label> + <input id="txtNewCardNumber" name="NewCardNumber" type="text" maxlength="20" size="21" autocomplete="off" autocorrect="off" value="" pattern="[0-9]*" +title="overall type: CREDIT_CARD_NUMBER server type: CREDIT_CARD_NUMBER heuristic type: CREDIT_CARD_NUMBER label: Number: parseable name: NewCardNumber field signature: 2370218454 form signature: 12190733459375771907" +autofill-prediction="CREDIT_CARD_NUMBER" +/> + <input id="hidNewLastCC" name="hidNewLastCC" type="hidden" value="" /> + <fieldset id="fldExpireDateNewCard"> + <label id="lblNewCard" for="selNewCard">Expiration Date:</label> + <select name="selNewCard" id="selNewCard" size="1" +title="overall type: CREDIT_CARD_EXP_DATE_4_DIGIT_YEAR server type: NO_SERVER_DATA heuristic type: CREDIT_CARD_EXP_DATE_4_DIGIT_YEAR label: Expiration Date: parseable name: selNewCard field signature: 2308816317 form signature: 12190733459375771907" +autofill-prediction="CREDIT_CARD_EXP_DATE_4_DIGIT_YEAR" +> + <option value="03/2017" selected="selected">03/2017</option> + <option value="04/2017">04/2017</option> + <option value="05/2017">05/2017</option> + <option value="06/2017">06/2017</option> + <option value="07/2017">07/2017</option> + <option value="08/2017">08/2017</option> + <option value="09/2017">09/2017</option> + <option value="10/2017">10/2017</option> + <option value="11/2017">11/2017</option> + <option value="12/2017">12/2017</option> + <option value="01/2018">01/2018</option> + <option value="02/2018">02/2018</option> + <option value="03/2018">03/2018</option> + <option value="04/2018">04/2018</option> + <option value="05/2018">05/2018</option> + <option value="06/2018">06/2018</option> + <option value="07/2018">07/2018</option> + <option value="08/2018">08/2018</option> + <option value="09/2018">09/2018</option> + <option value="10/2018">10/2018</option> + <option value="11/2018">11/2018</option> + <option value="12/2018">12/2018</option> + <option value="01/2019">01/2019</option> + <option value="02/2019">02/2019</option> + <option value="03/2019">03/2019</option> + <option value="04/2019">04/2019</option> + <option value="05/2019">05/2019</option> + <option value="06/2019">06/2019</option> + <option value="07/2019">07/2019</option> + <option value="08/2019">08/2019</option> + <option value="09/2019">09/2019</option> + <option value="10/2019">10/2019</option> + <option value="11/2019">11/2019</option> + <option value="12/2019">12/2019</option> + <option value="01/2020">01/2020</option> + <option value="02/2020">02/2020</option> + <option value="03/2020">03/2020</option> + <option value="04/2020">04/2020</option> + <option value="05/2020">05/2020</option> + <option value="06/2020">06/2020</option> + <option value="07/2020">07/2020</option> + <option value="08/2020">08/2020</option> + <option value="09/2020">09/2020</option> + <option value="10/2020">10/2020</option> + <option value="11/2020">11/2020</option> + <option value="12/2020">12/2020</option> + <option value="01/2021">01/2021</option> + <option value="02/2021">02/2021</option> + <option value="03/2021">03/2021</option> + <option value="04/2021">04/2021</option> + <option value="05/2021">05/2021</option> + <option value="06/2021">06/2021</option> + <option value="07/2021">07/2021</option> + <option value="08/2021">08/2021</option> + <option value="09/2021">09/2021</option> + <option value="10/2021">10/2021</option> + <option value="11/2021">11/2021</option> + <option value="12/2021">12/2021</option> + <option value="01/2022">01/2022</option> + <option value="02/2022">02/2022</option> + <option value="03/2022">03/2022</option> + <option value="04/2022">04/2022</option> + <option value="05/2022">05/2022</option> + <option value="06/2022">06/2022</option> + <option value="07/2022">07/2022</option> + <option value="08/2022">08/2022</option> + <option value="09/2022">09/2022</option> + <option value="10/2022">10/2022</option> + <option value="11/2022">11/2022</option> + <option value="12/2022">12/2022</option> + <option value="01/2023">01/2023</option> + <option value="02/2023">02/2023</option> + <option value="03/2023">03/2023</option> + <option value="04/2023">04/2023</option> + <option value="05/2023">05/2023</option> + <option value="06/2023">06/2023</option> + <option value="07/2023">07/2023</option> + <option value="08/2023">08/2023</option> + <option value="09/2023">09/2023</option> + <option value="10/2023">10/2023</option> + <option value="11/2023">11/2023</option> + <option value="12/2023">12/2023</option> + <option value="01/2024">01/2024</option> + <option value="02/2024">02/2024</option> + <option value="03/2024">03/2024</option> + <option value="04/2024">04/2024</option> + <option value="05/2024">05/2024</option> + <option value="06/2024">06/2024</option> + <option value="07/2024">07/2024</option> + <option value="08/2024">08/2024</option> + <option value="09/2024">09/2024</option> + <option value="10/2024">10/2024</option> + <option value="11/2024">11/2024</option> + <option value="12/2024">12/2024</option> + <option value="01/2025">01/2025</option> + <option value="02/2025">02/2025</option> + <option value="03/2025">03/2025</option> + <option value="04/2025">04/2025</option> + <option value="05/2025">05/2025</option> + <option value="06/2025">06/2025</option> + <option value="07/2025">07/2025</option> + <option value="08/2025">08/2025</option> + <option value="09/2025">09/2025</option> + <option value="10/2025">10/2025</option> + <option value="11/2025">11/2025</option> + <option value="12/2025">12/2025</option> + <option value="01/2026">01/2026</option> + <option value="02/2026">02/2026</option> + <option value="03/2026">03/2026</option> + <option value="04/2026">04/2026</option> + <option value="05/2026">05/2026</option> + <option value="06/2026">06/2026</option> + <option value="07/2026">07/2026</option> + <option value="08/2026">08/2026</option> + <option value="09/2026">09/2026</option> + <option value="10/2026">10/2026</option> + <option value="11/2026">11/2026</option> + <option value="12/2026">12/2026</option> + <option value="01/2027">01/2027</option> + <option value="02/2027">02/2027</option> + <option value="03/2027">03/2027</option> + <option value="04/2027">04/2027</option> + <option value="05/2027">05/2027</option> + <option value="06/2027">06/2027</option> + <option value="07/2027">07/2027</option> + <option value="08/2027">08/2027</option> + <option value="09/2027">09/2027</option> + <option value="10/2027">10/2027</option> + <option value="11/2027">11/2027</option> + <option value="12/2027">12/2027</option> + </select> + </fieldset> + </fieldset> + <fieldset id="fldSecurityCodeNewCard"> + <div id="fldSecurityCode"> + <label for="txtSecurityCode">Security Code:</label> <input id="txtSecurityCode" name="SecurityCode" type="text" pattern="[0-9]*" maxlength="5" size="5" value="" +title="overall type: CREDIT_CARD_VERIFICATION_CODE server type: NO_SERVER_DATA heuristic type: CREDIT_CARD_VERIFICATION_CODE label: Security Code: parseable name: SecurityCode field signature: 4107652875 form signature: 12190733459375771907" +autofill-prediction="CREDIT_CARD_VERIFICATION_CODE" +/> + </div> + </fieldset> + <div id="divQButton"> + <span> + <input type="button" id="btnQCard" value="Add My QCard" /> + <span>Add My QCard</span> + </span> + <input type="hidden" id="addMyQCard" name="addMyQCard" value="false" /> + <input type="hidden" id="isNPO" name="isNPO" value="true" /> + </div> + </div> + </td> + </tr> + </tbody> + </table> + </div> + <div id="divQvcGiftCardsMethod"> + <div> + <div id="divGiftCardPaymentOption"> + <input type="checkbox" name="cbGiftCard" id="cbGiftCard" +title="overall type: UNKNOWN_TYPE server type: NO_SERVER_DATA heuristic type: UNKNOWN_TYPE label: Use a Gift Card parseable name: cbGiftCard field signature: 2461714937 form signature: 12190733459375771907" +autofill-prediction="UNKNOWN_TYPE" +/> + <label for="cbGiftCard">Use a Gift Card</label> + <div id="divQvcGiftCardEntry"> + <fieldset> + <label for="txtQvcGiftCardNumber">Card Number:</label> + <input id="txtQvcGiftCardNumber" name="txtQvcGiftCardNumber" type="tel" autocomplete="off" autocorrect="off" maxlength="19" size="19" value="" +title="overall type: CREDIT_CARD_NUMBER server type: CREDIT_CARD_NUMBER heuristic type: CREDIT_CARD_NUMBER label: Card Number: parseable name: txtQvcGiftCardNumber field signature: 375442765 form signature: 12190733459375771907" +autofill-prediction="CREDIT_CARD_NUMBER" +/> + <label for="txtQvcGiftCardSecurityIdNumber">Security ID Number:</label> + <input id="txtQvcGiftCardSecurityIdNumber" name="txtQvcGiftCardSecurityIdNumber" type="text" autocomplete="off" autocorrect="off" maxlength="12" size="19" value="" +title="overall type: UNKNOWN_TYPE server type: NO_SERVER_DATA heuristic type: UNKNOWN_TYPE label: Security ID Number: parseable name: txtQvcGiftCardSecurityIdNumber field signature: 383370886 form signature: 12190733459375771907" +autofill-prediction="UNKNOWN_TYPE" +/> + <span> + <input type="button" id="btnQvcGiftCardEnterAnotherCard" name="btnQvcGiftCardEnterAnotherCard" value="Enter Another Card" /> + <span>Enter Another Card</span> + </span> + <br /> + </fieldset> + </div> + </div> + </div> + </div> + <div id="monetate_selectorHTML_c937b2dd_0"> + <div id="mainVoucherCodeDiv"> + <input id="txtQvcApplyCodeNumber" name="txtQvcApplyCodeNumber" type="text" autocomplete="off" autocorrect="off" maxlength="25" size="19" value="" /> + <input type="button" id="btnApplyCode" value="Apply Code" /> + </div> + </div> + <div id="divButtons"> + <span> + <input type="button" id="btnSubmitChanges" value="Continue Checkout" /> + <span>Continue Checkout</span> + </span> + <span> + <span>Continue Checkout</span> + <input type="button" id="btnQCard2" value="Continue Checkout" /> + </span> + <span> + <input type="button" id="btnReturnToOrder" value="EDIT SHOPPING CART" /> + <span>Edit Shopping cart</span> + </span> + </div> + </form> + <form id="captureFormFooter" method="post" name="captureFormFooter"> + <div id="divEmailFormFooter"> + <label for="emailAddress1Footer">Get sneak previews of special offers and upcoming events delivered to your inbox.</label> + <span id="emailAddressErrorFooter">*</span> + <input id="emailAddress1Footer" type="text" value="Enter email" /> + <input id="emailAddress2Footer" type="text" value="Confirm email" /> + <input id="signUpFooter" type="submit" value="Sign Up" /> + <span id="disclaimerTextFooter">*You're signing up to receive QVC promotional email.</span> + </div> + </form> + </body> +</html> diff --git a/browser/extensions/formautofill/test/fixtures/third_party/README b/browser/extensions/formautofill/test/fixtures/third_party/README new file mode 100644 index 0000000000..ca4750ec08 --- /dev/null +++ b/browser/extensions/formautofill/test/fixtures/third_party/README @@ -0,0 +1,4 @@ +This directory contains pages downloaded from the web for the purpose of testing +Form Autofill against pages from the real world. These files are not made +available under an open source license. + diff --git a/browser/extensions/formautofill/test/fixtures/third_party/Sears/PaymentOptions.html b/browser/extensions/formautofill/test/fixtures/third_party/Sears/PaymentOptions.html new file mode 100644 index 0000000000..160823920e --- /dev/null +++ b/browser/extensions/formautofill/test/fixtures/third_party/Sears/PaymentOptions.html @@ -0,0 +1,566 @@ +<!DOCTYPE html> +<html xmlns="http://www.w3.org/1999/xhtml"> + <head> + <meta http-equiv="Content-Type" content="text/html; charset=utf-8" /> + <title> +</title> + </head> + <body> + <meta http-equiv="imagetoolbar" content="no" /> + <meta http-equiv="X-UA-Compatible" content="IE=edge" /> + <title>Payment Options | Sears PartsDirect</title> + <meta name="viewport" content="width=device-width, initial-scale=1" /> + <meta name="decorator" content="pdwCheckout" /> + <input type="hidden" id="isCQTOFPDPPagesEnabled" value="true" /> + <input type="hidden" id="cqHost" value="//www.searspartsdirect.com" /> + <form id="modelSearchHeader" method="get" action="https://www.searspartsdirect.com/partsdirect/getModel.pd" name="modelSearch"> + <fieldset> + <label>Try searching again:</label> + <label for="searchedModelField">Model Number</label> + <input id="searchedModelField" type="text" +title="Enter model number" value="Enter model number" name="modelNumberPopUp" maxlength="35" /> + <input type="hidden" name="shdMod" /> + <input type="hidden" name="pathTaken" /> + <input type="hidden" name="legacySlrSearch" /> + </fieldset> + </form> + <form id="creditCard" name="creditCard" action="https://www.searspartsdirect.com/partsdirect/checkOut.pd" method="post" pd-form-id="1489978179816"> + <input type="hidden" name="_eventId" value="goAddPaymentOpt" id="creditCard__eventId" /> + <input type="hidden" name="paymentOptionStr" value="" id="creditCard_paymentOptionStr" /> + <input type="hidden" name="userPaymentTypeId" value="" id="creditCard_userPaymentTypeId" /> + <input type="hidden" name="associateDiscountInput" value="" id="creditCard_associateDiscountInput" /> + <input type="hidden" name="saveAssociateId" value="false" id="creditCard_saveAssociateId" /> + <input type="hidden" name="userPaymentCommercial" value="false" id="creditCard_userPaymentCommercial" /> + <input type="hidden" name="paymentType" value="" id="creditCard_paymentType" /> + <div id="GuestCreditCardForm"> + <div> + <div> + <label for="order.paymentType.cardNumber">Card Number</label> + <input type="text" name="maskedCardNumber" maxlength="16" value="" id="maskedCardNumber" placeholder="Card Number" +title="overall type: CREDIT_CARD_NUMBER server type: CREDIT_CARD_NUMBER heuristic type: CREDIT_CARD_NUMBER label: Card Number parseable name: maskedCardNumber field signature: 2745159259 form signature: 7671147655436241539" +autofill-prediction="CREDIT_CARD_NUMBER" +/> + </div> + <div> + <label for="order.paymentType.nameOnCard">Name On Card</label> + <input type="text" name="creditCardPaymentName" value="" id="creditCard_creditCardPaymentName" placeholder="Name On Card" +title="overall type: CREDIT_CARD_NAME_FULL server type: CREDIT_CARD_NAME_FULL heuristic type: CREDIT_CARD_NAME_FULL label: Name On Card parseable name: creditCardPaymentName field signature: 2311472685 form signature: 7671147655436241539" +autofill-prediction="CREDIT_CARD_NAME_FULL" +/> + </div> + </div> + <div> + <div> + <div> + <label for="order.paymentType.securityCode">Security Code</label> + <input type="text" name="securityCode" value="" id="securityCode" placeholder="CVV" +title="overall type: CREDIT_CARD_VERIFICATION_CODE server type: NO_SERVER_DATA heuristic type: CREDIT_CARD_VERIFICATION_CODE label: Security Code parseable name: securityCode field signature: 1305695504 form signature: 7671147655436241539" +autofill-prediction="CREDIT_CARD_VERIFICATION_CODE" +/> + </div> + <div> + <label for="order.paymentType.expirationDate">Expiration Date</label> + <select name="expMonth" id="expMonth" +title="overall type: CREDIT_CARD_EXP_MONTH server type: CREDIT_CARD_EXP_MONTH heuristic type: CREDIT_CARD_EXP_MONTH label: Expiration Date parseable name: expMonth field signature: 2046285420 form signature: 7671147655436241539" +autofill-prediction="CREDIT_CARD_EXP_MONTH" +> + <option value="">Month</option> + <option value="1">01-January</option> + <option value="2">02-February</option> + <option value="3">03-March</option> + <option value="4">04-April</option> + <option value="5">05-May</option> + <option value="6">06-June</option> + <option value="7">07-July</option> + <option value="8">08-August</option> + <option value="9">09-September</option> + <option value="10">10-October</option> + <option value="11">11-November</option> + <option value="12">12-December</option> + </select> + </div> + <div> + <select name="expYear" id="expYear" +title="overall type: CREDIT_CARD_EXP_4_DIGIT_YEAR server type: CREDIT_CARD_EXP_4_DIGIT_YEAR heuristic type: ADDRESS_HOME_CITY label: The City/State/ZIP Code combination you entered is incorrect. Please try again. Billing Address parseable name: expYear field signature: 2532266972 form signature: 7671147655436241539" +autofill-prediction="CREDIT_CARD_EXP_4_DIGIT_YEAR" +> + <option value="">Year</option> + <option value="2017">2017</option> + <option value="2018">2018</option> + <option value="2019">2019</option> + <option value="2020">2020</option> + <option value="2021">2021</option> + <option value="2022">2022</option> + <option value="2023">2023</option> + <option value="2024">2024</option> + <option value="2025">2025</option> + <option value="2026">2026</option> + <option value="2027">2027</option> + <option value="2028">2028</option> + <option value="2029">2029</option> + <option value="2030">2030</option> + </select> + </div> + </div> + </div> + </div> + </form> + <form id="anotherBillingAddress" name="anotherBillingAddress" method="post" pd-form-id="1489978179817"> + <div id="billingAddressForm"> + <div> + <label for="order.billingInfo.firstName">First Name<span>*</span> + </label> + <input type="text" name="order.billingInfo.firstName" maxlength="11" value="" id="order.billingInfo.firstName" placeholder="First Name *" +title="overall type: NAME_FIRST server type: NAME_FIRST heuristic type: NAME_FIRST label: First Name* parseable name: firstName field signature: 3077178767 form signature: 17982067175666068474" +autofill-prediction="NAME_FIRST" +/> + </div> + <div> + <label for="order.billingInfo.lastName">Last Name<span>*</span> + </label> + <input type="text" name="order.billingInfo.lastName" value="" id="order.billingInfo.lastName" placeholder="Last Name *" +title="overall type: NAME_LAST server type: NAME_LAST heuristic type: NAME_LAST label: Last Name* parseable name: lastName field signature: 2325932944 form signature: 17982067175666068474" +autofill-prediction="NAME_LAST" +/> + </div> + <div id="divCityStateZipId_3"> + <div id="divCityStateZipId_2"> + <div> + <div> + <label for="order.billingInfo.address.address1">Street Address<span>*</span> - 24 character limit</label> + <input type="text" name="order.billingInfo.address.address1" value="Island Drvie" id="order.billingInfo.address.address1" placeholder="Street Address *" +title="overall type: ADDRESS_HOME_LINE1 server type: ADDRESS_HOME_LINE1 heuristic type: ADDRESS_HOME_LINE1 label: Street Address* - 24 character limit parseable name: address.address1 field signature: 796482076 form signature: 17982067175666068474" +autofill-prediction="ADDRESS_HOME_LINE1" +/> + </div> + <div> + <label for="order.billingInfo.address.address2">Apt. #</label> + <input type="text" name="order.billingInfo.address.address2" value="" id="order.billingInfo.address.address2" placeholder="Apt. #" +title="overall type: ADDRESS_HOME_LINE2 server type: ADDRESS_HOME_LINE2 heuristic type: ADDRESS_HOME_LINE2 label: Apt. # parseable name: address.address2 field signature: 1242999964 form signature: 17982067175666068474" +autofill-prediction="ADDRESS_HOME_LINE2" +/> + </div> + </div> + <div id="divCityStateZipId"> + <div> + <div> + <label for="order.billingInfo.address.city">City<span>*</span> + </label> + <input type="text" name="order.billingInfo.address.city" value="" id="order.billingInfo.address.city" placeholder="City *" +title="overall type: ADDRESS_HOME_CITY server type: ADDRESS_HOME_CITY heuristic type: ADDRESS_HOME_CITY label: City* parseable name: address.city field signature: 1372321658 form signature: 17982067175666068474" +autofill-prediction="ADDRESS_HOME_CITY" +/> + </div> + <div> + <label for="order.billingInfo.address.state">State<span>*</span> + </label> + <select name="order.billingInfo.address.state" id="order.billingInfo.address.state" +title="overall type: ADDRESS_HOME_STATE server type: ADDRESS_HOME_STATE heuristic type: ADDRESS_HOME_STATE label: State* parseable name: address.state field signature: 2106658457 form signature: 17982067175666068474" +autofill-prediction="ADDRESS_HOME_STATE" +> + <option value="">ST *</option> + <option value="AA">AA</option> + <option value="AE">AE</option> + <option value="AL">AL</option> + <option value="AK">AK</option> + <option value="AP">AP</option> + <option value="AZ">AZ</option> + <option value="AR">AR</option> + <option value="CA">CA</option> + <option value="CO">CO</option> + <option value="CT">CT</option> + <option value="DE">DE</option> + <option value="DC">DC</option> + <option value="FL">FL</option> + <option value="GA">GA</option> + <option value="GU">GU</option> + <option value="HI">HI</option> + <option value="ID">ID</option> + <option value="IL">IL</option> + <option value="IN">IN</option> + <option value="IA">IA</option> + <option value="KS">KS</option> + <option value="KY">KY</option> + <option value="LA">LA</option> + <option value="ME">ME</option> + <option value="MD">MD</option> + <option value="MA">MA</option> + <option value="MI">MI</option> + <option value="MN">MN</option> + <option value="MS">MS</option> + <option value="MO">MO</option> + <option value="MT">MT</option> + <option value="NE">NE</option> + <option value="NV">NV</option> + <option value="NH">NH</option> + <option value="NJ">NJ</option> + <option value="NM">NM</option> + <option value="NY">NY</option> + <option value="NC">NC</option> + <option value="ND">ND</option> + <option value="OH">OH</option> + <option value="OK">OK</option> + <option value="OR">OR</option> + <option value="PA">PA</option> + <option value="PR">PR</option> + <option value="RI">RI</option> + <option value="SC">SC</option> + <option value="SD">SD</option> + <option value="TN">TN</option> + <option value="TX">TX</option> + <option value="UT">UT</option> + <option value="VA">VA</option> + <option value="VI">VI</option> + <option value="VT">VT</option> + <option value="WA">WA</option> + <option value="WV">WV</option> + <option value="WI">WI</option> + <option value="WY">WY</option> + </select> + </div> + </div> + <div> + <label for="order.billingInfo.address.zipCode">ZIP/Postal Code<span>*</span> + </label> + <input type="text" name="order.billingInfo.address.zipCode" value="" id="order.billingInfo.address.zipCode" placeholder="ZIP Code *" +title="overall type: ADDRESS_HOME_ZIP server type: ADDRESS_HOME_ZIP heuristic type: ADDRESS_HOME_ZIP label: ZIP/Postal Code* parseable name: address.zipCode field signature: 1420459778 form signature: 17982067175666068474" +autofill-prediction="ADDRESS_HOME_ZIP" +/> + </div> + <input type="hidden" id="order.billingInfo.address.poBoxOrMillitaryAdd" name="order.billingInfo.address.poBoxOrMillitaryAdd" value="" /> + </div> + </div> + </div> + <div> + <div> + <label for="order.billingInfo.dayTimePhone">Phone Number<span>*</span> + </label> + <input type="text" name="order.billingInfo.dayTimePhone" value="" id="order.billingInfo.dayTimePhone" placeholder="Phone Number *" +title="overall type: PHONE_HOME_CITY_AND_NUMBER server type: PHONE_HOME_CITY_AND_NUMBER heuristic type: PHONE_HOME_WHOLE_NUMBER label: Phone Number* parseable name: dayTimePhone field signature: 2509269658 form signature: 17982067175666068474" +autofill-prediction="PHONE_HOME_CITY_AND_NUMBER" +/> + </div> + <div> + <label for="order.billingInfo.dayTimePhoneExt">Ext.</label> + <input type="text" name="order.billingInfo.dayTimePhoneExt" value="" id="order.billingInfo.dayTimePhoneExt" placeholder="Ext." +title="overall type: PHONE_HOME_CITY_CODE server type: PHONE_HOME_CITY_CODE heuristic type: PHONE_HOME_EXTENSION label: Ext. parseable name: dayTimePhoneExt field signature: 1836849076 form signature: 17982067175666068474" +autofill-prediction="PHONE_HOME_CITY_CODE" +/> + </div> + </div> + </div> + </form> + <form id="eCheck" name="eCheck" action="https://www.searspartsdirect.com/partsdirect/checkOut.pd" method="post" pd-form-id="1489978179818"> + <input type="hidden" name="_eventId" value="goAddPaymentOpt" id="eCheck__eventId" /> + <input type="hidden" name="paymentOptionStr" value="" id="eCheck_paymentOptionStr" /> + <input type="hidden" name="userPaymentTypeId" value="" id="eCheck_userPaymentTypeId" /> + <input type="hidden" name="associateDiscountInput" value="" id="eCheck_associateDiscountInput" /> + <input type="hidden" name="saveAssociateId" value="false" id="eCheck_saveAssociateId" /> + <input type="hidden" name="userPaymentCommercial" value="false" id="eCheck_userPaymentCommercial" /> + <input type="hidden" name="paymentType" value="" id="eCheck_paymentType" /> + <div> + <input type="radio" value="true" name="businessAccountFlag" id="fldBusinessAccountFlag" +title="overall type: UNKNOWN_TYPE server type: NO_SERVER_DATA heuristic type: UNKNOWN_TYPE label: This is a Business Account parseable name: businessAccountFlag field signature: 1078565374 form signature: 11778620883203943321" +autofill-prediction="UNKNOWN_TYPE" +/> + <label for="fldBusinessAccountFlag">This is a Business Account</label> + </div> + <div> + <input type="radio" value="false" checked="checked" name="businessAccountFlag" id="fldPersonalAccountFlag" +title="overall type: UNKNOWN_TYPE server type: NO_SERVER_DATA heuristic type: UNKNOWN_TYPE label: This is a Personal Account parseable name: businessAccountFlag field signature: 1078565374 form signature: 11778620883203943321" +autofill-prediction="UNKNOWN_TYPE" +/> + <label for="fldPersonalAccountFlag">This is a Personal Account</label> + </div> + <label for="echeckFirstName">First name<span>*</span> + </label> + <div> + <input type="text" name="echeckFirstName" value="" id="echeckFirstName" placeholder="First name *" +title="overall type: NAME_FIRST server type: NAME_FIRST heuristic type: NAME_FIRST label: First name* parseable name: echeckFirstName field signature: 721631680 form signature: 11778620883203943321" +autofill-prediction="NAME_FIRST" +/> + </div> + <div> + <label for="echeckLastName">Last name<span>*</span> + </label> + <input type="text" name="echeckLastName" value="" id="echeckLastName" placeholder="Last name *" +title="overall type: NAME_LAST server type: NAME_LAST heuristic type: NAME_LAST label: Last name* parseable name: echeckLastName field signature: 2195362343 form signature: 11778620883203943321" +autofill-prediction="NAME_LAST" +/> + </div> + <div> + <label for="maskedBankRoutingNumber">Bank routing number<span>*</span> + </label> + <input type="text" name="maskedBankRoutingNumber" value="" id="maskedBankRoutingNumber" placeholder="Bank routing number *" +title="overall type: UNKNOWN_TYPE server type: NO_SERVER_DATA heuristic type: UNKNOWN_TYPE label: Bank routing number* parseable name: maskedBankRoutingNumber field signature: 3997200887 form signature: 11778620883203943321" +autofill-prediction="UNKNOWN_TYPE" +/> + </div> + <div> + <label for="maskedCheckingAcctNumber">Checking account number<span>*</span> + </label> + <input type="text" name="maskedCheckingAcctNumber" value="" id="maskedCheckingAcctNumber" placeholder="Checking account number *" +title="overall type: UNKNOWN_TYPE server type: NO_SERVER_DATA heuristic type: UNKNOWN_TYPE label: Checking account number* parseable name: maskedCheckingAcctNumber field signature: 963371530 form signature: 11778620883203943321" +autofill-prediction="UNKNOWN_TYPE" +/> + </div> + <div> + <label for="checkNumber">Check number<span>*</span> + </label> + <input type="text" name="checkNumber" value="" id="checkNumber" placeholder="Check number *" +title="overall type: UNKNOWN_TYPE server type: NO_SERVER_DATA heuristic type: UNKNOWN_TYPE label: Check number* parseable name: checkNumber field signature: 1195469146 form signature: 11778620883203943321" +autofill-prediction="UNKNOWN_TYPE" +/> + </div> + <div id="personalAccountId"> + <div> + <label for="maskedDriversLicence">Driver's license or state identification #<span>*</span> + </label> + <input type="text" name="maskedDriversLicence" value="" id="maskedDriversLicence" placeholder="Driver's license or state identification # *" +title="overall type: ADDRESS_HOME_STATE server type: ADDRESS_HOME_STATE heuristic type: ADDRESS_HOME_STATE label: Driver's license or state identification #* parseable name: maskedDriversLicence field signature: 1753257915 form signature: 11778620883203943321" +autofill-prediction="ADDRESS_HOME_STATE" +/> + </div> + <div> + <label for="state">State issued<span>*</span> + </label> + <select name="state" id="state" +title="overall type: ADDRESS_HOME_STATE server type: ADDRESS_HOME_STATE heuristic type: ADDRESS_HOME_STATE label: State issued* parseable name: state field signature: 1878375253 form signature: 11778620883203943321" +autofill-prediction="ADDRESS_HOME_STATE" +> + <option value="">ST *</option> + <option value="AA">AA</option> + <option value="AE">AE</option> + <option value="AL">AL</option> + <option value="AK">AK</option> + <option value="AP">AP</option> + <option value="AZ">AZ</option> + <option value="AR">AR</option> + <option value="CA">CA</option> + <option value="CO">CO</option> + <option value="CT">CT</option> + <option value="DE">DE</option> + <option value="DC">DC</option> + <option value="FL">FL</option> + <option value="GA">GA</option> + <option value="GU">GU</option> + <option value="HI">HI</option> + <option value="ID">ID</option> + <option value="IL">IL</option> + <option value="IN">IN</option> + <option value="IA">IA</option> + <option value="KS">KS</option> + <option value="KY">KY</option> + <option value="LA">LA</option> + <option value="ME">ME</option> + <option value="MD">MD</option> + <option value="MA">MA</option> + <option value="MI">MI</option> + <option value="MN">MN</option> + <option value="MS">MS</option> + <option value="MO">MO</option> + <option value="MT">MT</option> + <option value="NE">NE</option> + <option value="NV">NV</option> + <option value="NH">NH</option> + <option value="NJ">NJ</option> + <option value="NM">NM</option> + <option value="NY">NY</option> + <option value="NC">NC</option> + <option value="ND">ND</option> + <option value="OH">OH</option> + <option value="OK">OK</option> + <option value="OR">OR</option> + <option value="PA">PA</option> + <option value="PR">PR</option> + <option value="RI">RI</option> + <option value="SC">SC</option> + <option value="SD">SD</option> + <option value="TN">TN</option> + <option value="TX">TX</option> + <option value="UT">UT</option> + <option value="VA">VA</option> + <option value="VI">VI</option> + <option value="VT">VT</option> + <option value="WA">WA</option> + <option value="WV">WV</option> + <option value="WI">WI</option> + <option value="WY">WY</option> + </select> + </div> + <div> + <label>Date of birth<span>*</span> + </label> + <select name="bdayMonth" id="bdayMonth" +title="overall type: UNKNOWN_TYPE server type: NO_SERVER_DATA heuristic type: UNKNOWN_TYPE label: Date of birth* parseable name: bdayMonth field signature: 1907288957 form signature: 11778620883203943321" +autofill-prediction="UNKNOWN_TYPE" +> + <option value="">Month</option> + <option value="1">January</option> + <option value="2">February</option> + <option value="3">March</option> + <option value="4">April</option> + <option value="5">May</option> + <option value="6">June</option> + <option value="7">July</option> + <option value="8">August</option> + <option value="9">September</option> + <option value="10">October</option> + <option value="11">November</option> + <option value="12">December</option> + </select> + <select name="bdayDate" id="bdayDate" +title="overall type: UNKNOWN_TYPE server type: NO_SERVER_DATA heuristic type: UNKNOWN_TYPE label: Date of birth* parseable name: bdayDate field signature: 2056433281 form signature: 11778620883203943321" +autofill-prediction="UNKNOWN_TYPE" +> + <option value="">Date</option> + <option value="1">1</option> + <option value="2">2</option> + <option value="3">3</option> + <option value="4">4</option> + <option value="5">5</option> + <option value="6">6</option> + <option value="7">7</option> + <option value="8">8</option> + <option value="9">9</option> + <option value="10">10</option> + <option value="11">11</option> + <option value="12">12</option> + <option value="13">13</option> + <option value="14">14</option> + <option value="15">15</option> + <option value="16">16</option> + <option value="17">17</option> + <option value="18">18</option> + <option value="19">19</option> + <option value="20">20</option> + <option value="21">21</option> + <option value="22">22</option> + <option value="23">23</option> + <option value="24">24</option> + <option value="25">25</option> + <option value="26">26</option> + <option value="27">27</option> + <option value="28">28</option> + <option value="29">29</option> + <option value="30">30</option> + <option value="31">31</option> + </select> + <select name="bdayYear" id="bdayYear" +title="overall type: UNKNOWN_TYPE server type: NO_SERVER_DATA heuristic type: UNKNOWN_TYPE label: Date of birth* parseable name: bdayYear field signature: 938244373 form signature: 11778620883203943321" +autofill-prediction="UNKNOWN_TYPE" +> + <option value="">Year</option> + <option value="1999">1999</option> + <option value="1998">1998</option> + <option value="1997">1997</option> + <option value="1996">1996</option> + <option value="1995">1995</option> + <option value="1994">1994</option> + <option value="1993">1993</option> + <option value="1992">1992</option> + <option value="1991">1991</option> + <option value="1990">1990</option> + <option value="1989">1989</option> + <option value="1988">1988</option> + <option value="1987">1987</option> + <option value="1986">1986</option> + <option value="1985">1985</option> + <option value="1984">1984</option> + <option value="1983">1983</option> + <option value="1982">1982</option> + <option value="1981">1981</option> + <option value="1980">1980</option> + <option value="1979">1979</option> + <option value="1978">1978</option> + <option value="1977">1977</option> + <option value="1976">1976</option> + <option value="1975">1975</option> + <option value="1974">1974</option> + <option value="1973">1973</option> + <option value="1972">1972</option> + <option value="1971">1971</option> + <option value="1970">1970</option> + <option value="1969">1969</option> + <option value="1968">1968</option> + <option value="1967">1967</option> + <option value="1966">1966</option> + <option value="1965">1965</option> + <option value="1964">1964</option> + <option value="1963">1963</option> + <option value="1962">1962</option> + <option value="1961">1961</option> + <option value="1960">1960</option> + <option value="1959">1959</option> + <option value="1958">1958</option> + <option value="1957">1957</option> + <option value="1956">1956</option> + <option value="1955">1955</option> + <option value="1954">1954</option> + <option value="1953">1953</option> + <option value="1952">1952</option> + <option value="1951">1951</option> + <option value="1950">1950</option> + <option value="1949">1949</option> + <option value="1948">1948</option> + <option value="1947">1947</option> + <option value="1946">1946</option> + <option value="1945">1945</option> + <option value="1944">1944</option> + <option value="1943">1943</option> + <option value="1942">1942</option> + <option value="1941">1941</option> + <option value="1940">1940</option> + <option value="1939">1939</option> + <option value="1938">1938</option> + <option value="1937">1937</option> + <option value="1936">1936</option> + <option value="1935">1935</option> + <option value="1934">1934</option> + <option value="1933">1933</option> + <option value="1932">1932</option> + <option value="1931">1931</option> + <option value="1930">1930</option> + <option value="1929">1929</option> + <option value="1928">1928</option> + <option value="1927">1927</option> + <option value="1926">1926</option> + <option value="1925">1925</option> + <option value="1924">1924</option> + <option value="1923">1923</option> + <option value="1922">1922</option> + <option value="1921">1921</option> + <option value="1920">1920</option> + <option value="1919">1919</option> + <option value="1918">1918</option> + <option value="1917">1917</option> + <option value="1916">1916</option> + <option value="1915">1915</option> + <option value="1914">1914</option> + <option value="1913">1913</option> + <option value="1912">1912</option> + <option value="1911">1911</option> + <option value="1910">1910</option> + <option value="1909">1909</option> + <option value="1908">1908</option> + <option value="1907">1907</option> + <option value="1906">1906</option> + <option value="1905">1905</option> + <option value="1904">1904</option> + <option value="1903">1903</option> + <option value="1902">1902</option> + <option value="1901">1901</option> + <option value="1900">1900</option> + <option value="1899">1899</option> + </select> + </div> + </div> + </form> + <form id="emailForUpdates" name="emailForUpdates" action="https://www.searspartsdirect.com/partsdirect/offerEmailsAction.pd" method="post"> + <fieldset> + <input type="text" id="emailAdd" name="emailAddress" value="" tabindex="4" maxlength="50" /> + <label for="emailAdd">enter email address</label> + </fieldset> + </form> + </body> +</html> diff --git a/browser/extensions/formautofill/test/fixtures/third_party/Sears/ShippingAddress.html b/browser/extensions/formautofill/test/fixtures/third_party/Sears/ShippingAddress.html new file mode 100644 index 0000000000..a9d1f5cd0f --- /dev/null +++ b/browser/extensions/formautofill/test/fixtures/third_party/Sears/ShippingAddress.html @@ -0,0 +1,447 @@ +<!DOCTYPE html> +<html xmlns="http://www.w3.org/1999/xhtml"> + <head> + <meta http-equiv="Content-Type" content="text/html; charset=utf-8" /> + <title> +</title> + </head> + <body> + <meta http-equiv="imagetoolbar" content="no" /> + <meta http-equiv="X-UA-Compatible" content="IE=edge" /> + <title>Shipping address | Sears PartsDirect</title> + <meta name="viewport" content="width=device-width, initial-scale=1" /> + <meta name="decorator" content="pdwCheckout" /> + <input type="hidden" id="isCQTOFPDPPagesEnabled" value="true" /> + <input type="hidden" id="cqHost" value="//www.searspartsdirect.com" /> + <form id="modelSearchHeader" method="get" action="https://www.searspartsdirect.com/partsdirect/getModel.pd" name="modelSearch" autocomplete="off"> + <fieldset> + <label>Try searching again:</label> + <label for="searchedModelField">Model Number</label> + <input id="searchedModelField" type="text" +title="Enter model number" value="Enter model number" name="modelNumberPopUp" maxlength="35" /> + <input type="hidden" name="shdMod" /> + <input type="hidden" name="pathTaken" /> + <input type="hidden" name="legacySlrSearch" /> + </fieldset> + </form> + <form id="forgotModalPswForm" name="forgotModalPswForm" action="https://www.searspartsdirect.com/partsdirect/initCheckoutAction.pd" method="post" autocomplete="off"> + <input type="hidden" name="currentForgotPageURL" value="" id="currentForgotPageURL" /> + <div> + <label for="email">Email</label> + </div> + <div> + <input type="text" name="email" id="forgotPwdFldEmail" /> + </div> + <div> + <input type="hidden" value="true" name="isCaptchaEnabled" id="isCaptchaEnabled" /> + <div> + <div> + <input type="hidden" value="VHM6MjAxNy0wMy0xOVQyMToxMjo1MFphYWExNjExNjM0OHp6ejIwMTctMDMtMTkgMjE6MTI6NTA=" name="captchaKey" id="forgotPwdFldCaptchaKey" /> + <fieldset> + <input type="text" value="" tabindex="111" name="captchaText" id="forgotPwdFldCaptchaTxt" placeholder="Enter text" /> + </fieldset> + </div> + </div> + <p> +</p> + </div> + <input type="hidden" name="commercialUI" value="false" /> + <input type="hidden" name="returnTo" value="#returnToVal" id="forgotModalPswForm_returnTo" /> + <input type="hidden" name="commercialUI" value="#commercialUIVal" id="forgotModalPswForm_commercialUI" /> + </form> + <form id="checkOut" name="checkOut" action="https://www.searspartsdirect.com/partsdirect/checkOut.pd" method="post" autocomplete="off" pd-form-id="1489975972490"> + <input type="hidden" name="_eventId" value="goAddAddress" id="checkOut__eventId" /> + <div id="shippingForm"> + <input type="hidden" name="isShippingAddressChanged" value="false" id="isShippingAddressChanged" /> + <input type="hidden" name="isShippingAddressEdited" value="false" id="isShippingAddressEdited" /> + <div id="shippingFormContainer"> + <div> + <label for="order.shippingInfo.firstName">First Name<span>*</span> + </label> + <input type="text" name="order.shippingInfo.firstName" maxlength="11" value="" id="checkOut_order_shippingInfo_firstName" placeholder="First Name *" +title="overall type: NAME_FIRST server type: NAME_FIRST heuristic type: NAME_FIRST label: First Name* parseable name: order.shippingInfo.firstName field signature: 243029182 form signature: 17155013134718564270" +autofill-prediction="NAME_FIRST" +/> + </div> + <div> + <label>Last Name<span>*</span> + </label> + <input type="text" name="order.shippingInfo.lastName" value="" id="checkOut_order_shippingInfo_lastName" placeholder="Last Name *" +title="overall type: NAME_LAST server type: NAME_LAST heuristic type: NAME_LAST label: Last Name* parseable name: order.shippingInfo.lastName field signature: 1858327987 form signature: 17155013134718564270" +autofill-prediction="NAME_LAST" +/> + </div> + <div id="divCityStateZipId_3"> + <div id="divCityStateZipId_2"> + <div> + <div> + <label for="order.shippingInfo.address.originalAddress">Street Address<span>*</span> - 24 character limit</label> + <input type="text" name="order.shippingInfo.address.originalAddress" value="" id="checkOut_order_shippingInfo_address_originalAddress" placeholder="Street Address *" +title="overall type: ADDRESS_HOME_LINE1 server type: ADDRESS_HOME_LINE1 heuristic type: ADDRESS_HOME_LINE1 label: Street Address* - 24 character limit parseable name: order.shippingInfo.address.originalAddress field signature: 3461422700 form signature: 17155013134718564270" +autofill-prediction="ADDRESS_HOME_LINE1" +/> + <div>24 character limit</div> + </div> + <div> + <label for="order.shippingInfo.address.address2">Apt. + #</label> + <input type="text" name="order.shippingInfo.address.address2" value="" id="checkOut_order_shippingInfo_address_address2" placeholder="Apt. #" +title="overall type: ADDRESS_HOME_LINE2 server type: ADDRESS_HOME_LINE2 heuristic type: ADDRESS_HOME_LINE2 label: Apt. # Apt. # parseable name: order.shippingInfo.address.address2 field signature: 1992141399 form signature: 17155013134718564270" +autofill-prediction="ADDRESS_HOME_LINE2" +/> + </div> + </div> + <div id="divCityStateZipId"> + <div> + <div> + <label for="order.shippingInfo.address.city">City<span>*</span> + </label> + <input type="text" name="order.shippingInfo.address.city" value="" id="checkOut_order_shippingInfo_address_city" placeholder="City *" +title="overall type: ADDRESS_HOME_CITY server type: ADDRESS_HOME_CITY heuristic type: ADDRESS_HOME_CITY label: City* parseable name: order.shippingInfo.address.city field signature: 2864354290 form signature: 17155013134718564270" +autofill-prediction="ADDRESS_HOME_CITY" +/> + </div> + <div> + <label for="order.shippingInfo.address.state">State<span>*</span> + </label> + <select name="order.shippingInfo.address.state" id="checkOut_order_shippingInfo_address_state" +title="overall type: ADDRESS_HOME_STATE server type: ADDRESS_HOME_STATE heuristic type: ADDRESS_HOME_STATE label: State* parseable name: order.shippingInfo.address.state field signature: 296886288 form signature: 17155013134718564270" +autofill-prediction="ADDRESS_HOME_STATE" +> + <option value="">ST *</option> + <option value="AA">AA</option> + <option value="AE">AE</option> + <option value="AL">AL</option> + <option value="AK">AK</option> + <option value="AP">AP</option> + <option value="AZ">AZ</option> + <option value="AR">AR</option> + <option value="CA">CA</option> + <option value="CO">CO</option> + <option value="CT">CT</option> + <option value="DE">DE</option> + <option value="DC">DC</option> + <option value="FL">FL</option> + <option value="GA">GA</option> + <option value="GU">GU</option> + <option value="HI">HI</option> + <option value="ID">ID</option> + <option value="IL">IL</option> + <option value="IN">IN</option> + <option value="IA">IA</option> + <option value="KS">KS</option> + <option value="KY">KY</option> + <option value="LA">LA</option> + <option value="ME">ME</option> + <option value="MD">MD</option> + <option value="MA">MA</option> + <option value="MI">MI</option> + <option value="MN">MN</option> + <option value="MS">MS</option> + <option value="MO">MO</option> + <option value="MT">MT</option> + <option value="NE">NE</option> + <option value="NV">NV</option> + <option value="NH">NH</option> + <option value="NJ">NJ</option> + <option value="NM">NM</option> + <option value="NY">NY</option> + <option value="NC">NC</option> + <option value="ND">ND</option> + <option value="OH">OH</option> + <option value="OK">OK</option> + <option value="OR">OR</option> + <option value="PA">PA</option> + <option value="PR">PR</option> + <option value="RI">RI</option> + <option value="SC">SC</option> + <option value="SD">SD</option> + <option value="TN">TN</option> + <option value="TX">TX</option> + <option value="UT">UT</option> + <option value="VA">VA</option> + <option value="VI">VI</option> + <option value="VT">VT</option> + <option value="WA">WA</option> + <option value="WV">WV</option> + <option value="WI">WI</option> + <option value="WY">WY</option> + </select> + </div> + </div> + <div> + <label for="order.shippingInfo.address.zipCode">ZIP/Postal Code<span>*</span> + </label> + <input type="text" name="order.shippingInfo.address.zipCode" value="" id="checkOut_order_shippingInfo_address_zipCode" placeholder="ZIP Code *" +title="overall type: ADDRESS_HOME_ZIP server type: ADDRESS_HOME_ZIP heuristic type: ADDRESS_HOME_ZIP label: ZIP/Postal Code* parseable name: order.shippingInfo.address.zipCode field signature: 2432211277 form signature: 17155013134718564270" +autofill-prediction="ADDRESS_HOME_ZIP" +/> + </div> + <input type="hidden" id="order.shippingInfo.address.poBoxOrMillitaryAdd" name="order.shippingInfo.address.poBoxOrMillitaryAdd" value="" /> + </div> + </div> + </div> + <div> + <div> + <label for="order.shippingInfo.dayTimePhone">Phone Number<span>*</span> + </label> + <input type="text" name="order.shippingInfo.dayTimePhone" value="" id="checkOut_order_shippingInfo_dayTimePhone" placeholder="Phone Number *" +title="overall type: PHONE_HOME_CITY_AND_NUMBER server type: PHONE_HOME_CITY_AND_NUMBER heuristic type: PHONE_HOME_WHOLE_NUMBER label: Phone Number* parseable name: order.shippingInfo.dayTimePhone field signature: 3680252951 form signature: 17155013134718564270" +autofill-prediction="PHONE_HOME_CITY_AND_NUMBER" +/> + </div> + <div> + <label for="order.shippingInfo.dayTimePhoneExt">Ext.</label> + <input type="text" name="order.shippingInfo.dayTimePhoneExt" value="" id="checkOut_order_shippingInfo_dayTimePhoneExt" placeholder="Ext." +title="overall type: PHONE_HOME_EXTENSION server type: NO_SERVER_DATA heuristic type: PHONE_HOME_EXTENSION label: Ext. parseable name: order.shippingInfo.dayTimePhoneExt field signature: 3095839543 form signature: 17155013134718564270" +autofill-prediction="PHONE_HOME_EXTENSION" +/> + </div> + </div> + <div> + <label for="order.shippingInfo.email">Email Address<span>*</span> + </label> + <input type="text" name="order.shippingInfo.email" value="" id="confirmEmailDiv" placeholder="Email Address *" +title="overall type: EMAIL_ADDRESS server type: EMAIL_ADDRESS heuristic type: EMAIL_ADDRESS label: Email Address* parseable name: order.shippingInfo.email field signature: 928233428 form signature: 17155013134718564270" +autofill-prediction="EMAIL_ADDRESS" +/> + </div> + <div> + <label for="order.shippingInfo.emailConfirm">Confirm Email Address<span>*</span> + </label> + <input type="text" placeholder="Email Address *" name="order.shippingInfo.emailConfirm" value="" +title="overall type: EMAIL_ADDRESS server type: EMAIL_ADDRESS heuristic type: EMAIL_ADDRESS label: Confirm Email Address* parseable name: order.shippingInfo.emailConfirm field signature: 3242992973 form signature: 17155013134718564270" +autofill-prediction="EMAIL_ADDRESS" +/> + </div> + </div> + </div> + <div> + <input type="checkbox" name="order.shippingBillingSame" value="true" checked="checked" id="order.shippingBillingSame" +title="overall type: UNKNOWN_TYPE server type: NO_SERVER_DATA heuristic type: UNKNOWN_TYPE label: Same as shipping address parseable name: order.shippingBillingSame field signature: 1817157586 form signature: 17155013134718564270" +autofill-prediction="UNKNOWN_TYPE" +/> + <input type="hidden" id="__checkbox_order.shippingBillingSame" name="__checkbox_order.shippingBillingSame" value="true" /> + <label for="order.shippingBillingSame">Same as shipping address</label> + </div> + <div id="billingForm"> + <div id="billingFormContainer"> + <div id="billingAddressFormId"> + <div> + <label for="order.billingInfo.firstName">First Name<span>*</span> + </label> + <input type="text" name="order.billingInfo.firstName" maxlength="11" value="" id="checkOut_order_billingInfo_firstName" placeholder="First Name *" +title="overall type: NAME_FIRST server type: NAME_FIRST heuristic type: NAME_FIRST label: First Name* parseable name: order.billingInfo.firstName field signature: 3077178767 form signature: 17155013134718564270" +autofill-prediction="NAME_FIRST" +/> + </div> + <div> + <label for="order.billingInfo.lastName">Last Name<span>*</span> + </label> + <input type="text" name="order.billingInfo.lastName" value="" id="checkOut_order_billingInfo_lastName" placeholder="Last Name *" +title="overall type: NAME_LAST server type: NAME_LAST heuristic type: NAME_LAST label: Last Name* parseable name: order.billingInfo.lastName field signature: 2325932944 form signature: 17155013134718564270" +autofill-prediction="NAME_LAST" +/> + </div> + <div> + <div> + <label for="order.billingInfo.address.address1">Street Address<span>*</span> - 24 character limit</label> + <input type="text" name="order.billingInfo.address.address1" value="" id="checkOut_order_billingInfo_address_address1" placeholder="Street Address *" +title="overall type: ADDRESS_HOME_LINE1 server type: ADDRESS_HOME_LINE1 heuristic type: ADDRESS_HOME_LINE1 label: Street Address* - 24 character limit parseable name: order.billingInfo.address.address1 field signature: 796482076 form signature: 17155013134718564270" +autofill-prediction="ADDRESS_HOME_LINE1" +/> + </div> + <div> + <label for="order.shippingInfo.address.address2">Apt. + #</label> + <input type="text" name="order.billingInfo.address.address2" value="" id="checkOut_order_billingInfo_address_address2" placeholder="Apt. #" +title="overall type: ADDRESS_HOME_LINE2 server type: ADDRESS_HOME_LINE2 heuristic type: ADDRESS_HOME_LINE2 label: Apt. # parseable name: order.billingInfo.address.address2 field signature: 1242999964 form signature: 17155013134718564270" +autofill-prediction="ADDRESS_HOME_LINE2" +/> + </div> + </div> + <div> + <div> + <div> + <label for="order.billingInfo.address.city">City<span>*</span> + </label> + <input type="text" name="order.billingInfo.address.city" value="" id="checkOut_order_billingInfo_address_city" placeholder="City *" +title="overall type: ADDRESS_HOME_CITY server type: ADDRESS_HOME_CITY heuristic type: ADDRESS_HOME_CITY label: City* parseable name: order.billingInfo.address.city field signature: 1372321658 form signature: 17155013134718564270" +autofill-prediction="ADDRESS_HOME_CITY" +/> + </div> + <div> + <label for="order.billingInfo.address.state">State<span>*</span> + </label> + <select name="order.billingInfo.address.state" id="checkOut_order_billingInfo_address_state" +title="overall type: ADDRESS_HOME_STATE server type: ADDRESS_HOME_STATE heuristic type: ADDRESS_HOME_STATE label: State* parseable name: order.billingInfo.address.state field signature: 2106658457 form signature: 17155013134718564270" +autofill-prediction="ADDRESS_HOME_STATE" +> + <option value="">ST *</option> + <option value="AA">AA</option> + <option value="AE">AE</option> + <option value="AL">AL</option> + <option value="AK">AK</option> + <option value="AP">AP</option> + <option value="AZ">AZ</option> + <option value="AR">AR</option> + <option value="CA">CA</option> + <option value="CO">CO</option> + <option value="CT">CT</option> + <option value="DE">DE</option> + <option value="DC">DC</option> + <option value="FL">FL</option> + <option value="GA">GA</option> + <option value="GU">GU</option> + <option value="HI">HI</option> + <option value="ID">ID</option> + <option value="IL">IL</option> + <option value="IN">IN</option> + <option value="IA">IA</option> + <option value="KS">KS</option> + <option value="KY">KY</option> + <option value="LA">LA</option> + <option value="ME">ME</option> + <option value="MD">MD</option> + <option value="MA">MA</option> + <option value="MI">MI</option> + <option value="MN">MN</option> + <option value="MS">MS</option> + <option value="MO">MO</option> + <option value="MT">MT</option> + <option value="NE">NE</option> + <option value="NV">NV</option> + <option value="NH">NH</option> + <option value="NJ">NJ</option> + <option value="NM">NM</option> + <option value="NY">NY</option> + <option value="NC">NC</option> + <option value="ND">ND</option> + <option value="OH">OH</option> + <option value="OK">OK</option> + <option value="OR">OR</option> + <option value="PA">PA</option> + <option value="PR">PR</option> + <option value="RI">RI</option> + <option value="SC">SC</option> + <option value="SD">SD</option> + <option value="TN">TN</option> + <option value="TX">TX</option> + <option value="UT">UT</option> + <option value="VA">VA</option> + <option value="VI">VI</option> + <option value="VT">VT</option> + <option value="WA">WA</option> + <option value="WV">WV</option> + <option value="WI">WI</option> + <option value="WY">WY</option> + </select> + </div> + </div> + <div> + <label for="order.billingInfo.address.zipCode">ZIP/Postal Code<span>*</span> + </label> + <input type="text" name="order.billingInfo.address.zipCode" value="" id="checkOut_order_billingInfo_address_zipCode" placeholder="ZIP Code *" +title="overall type: ADDRESS_HOME_ZIP server type: ADDRESS_HOME_ZIP heuristic type: ADDRESS_HOME_ZIP label: ZIP/Postal Code* parseable name: order.billingInfo.address.zipCode field signature: 1420459778 form signature: 17155013134718564270" +autofill-prediction="ADDRESS_HOME_ZIP" +/> + </div> + <input type="hidden" id="order.shippingInfo.address.poBoxOrMillitaryAdd" name="order.shippingInfo.address.poBoxOrMillitaryAdd" value="" /> + </div> + <div> + <div> + <label for="order.billingInfo.dayTimePhone">Phone Number<span>*</span> + </label> + <input type="text" name="order.billingInfo.dayTimePhone" value="" id="checkOut_order_billingInfo_dayTimePhone" placeholder="Phone Number *" +title="overall type: PHONE_HOME_CITY_AND_NUMBER server type: PHONE_HOME_CITY_AND_NUMBER heuristic type: PHONE_HOME_WHOLE_NUMBER label: Phone Number* parseable name: order.billingInfo.dayTimePhone field signature: 2509269658 form signature: 17155013134718564270" +autofill-prediction="PHONE_HOME_CITY_AND_NUMBER" +/> + </div> + <div> + <label for="order.billingInfo.dayTimePhoneExt">Ext.</label> + <input type="text" name="order.billingInfo.dayTimePhoneExt" value="" id="checkOut_order_billingInfo_dayTimePhoneExt" placeholder="Ext." +title="overall type: PHONE_HOME_CITY_CODE server type: PHONE_HOME_CITY_CODE heuristic type: PHONE_HOME_EXTENSION label: Ext. parseable name: order.billingInfo.dayTimePhoneExt field signature: 1836849076 form signature: 17155013134718564270" +autofill-prediction="PHONE_HOME_CITY_CODE" +/> + </div> + </div> + </div> + </div> + <div> + <input type="checkbox" name="orderSupport.saveShippingAddress" value="true" id="saveShipping" +title="overall type: UNKNOWN_TYPE server type: NO_SERVER_DATA heuristic type: UNKNOWN_TYPE label: Save my shipping address in My Profile. parseable name: orderSupport.saveShippingAddress field signature: 644246410 form signature: 17155013134718564270" +autofill-prediction="UNKNOWN_TYPE" +/> + <input type="hidden" id="__checkbox_saveShipping" name="__checkbox_orderSupport.saveShippingAddress" value="true" /> + <label for="saveShipping">Save my shipping address in My Profile.</label> + <input type="checkbox" name="orderSupport.saveBillingAddress" value="true" id="saveBilling" +title="overall type: UNKNOWN_TYPE server type: NO_SERVER_DATA heuristic type: UNKNOWN_TYPE label: Save my billing address in My Profile. parseable name: orderSupport.saveBillingAddress field signature: 2778625714 form signature: 17155013134718564270" +autofill-prediction="UNKNOWN_TYPE" +/> + <input type="hidden" id="__checkbox_saveBilling" name="__checkbox_orderSupport.saveBillingAddress" value="true" /> + <label for="saveBilling">Save my billing address in My Profile.</label> + </div> + </div> + <div id="createProfileContainer"> + <div> + <label for="orderSupport.profilePassword">Password</label> + <input type="password" placeholder="Password" name="orderSupport.profilePassword" +title="overall type: ACCOUNT_CREATION_PASSWORD server type: ACCOUNT_CREATION_PASSWORD heuristic type: UNKNOWN_TYPE label: Password Retype Password parseable name: orderSupport.profilePassword field signature: 2304557611 form signature: 17155013134718564270" +autofill-prediction="ACCOUNT_CREATION_PASSWORD" +/> + </div> + <div> + <label for="orderSupport.profilePassword">Retype Password</label> + <input type="password" placeholder="Retype Password" name="orderSupport.profilePasswordConfirm" +title="overall type: UNKNOWN_TYPE server type: NO_SERVER_DATA heuristic type: UNKNOWN_TYPE label: Retype Password parseable name: orderSupport.profilePasswordConfirm field signature: 196269180 form signature: 17155013134718564270" +autofill-prediction="UNKNOWN_TYPE" +/> + </div> + </div> + <div> + <input type="checkbox" name="order.shippingInfo.emailPromotion" value="true" checked="checked" id="order.shippingInfo.emailPromotion" +title="overall type: UNKNOWN_TYPE server type: NO_SERVER_DATA heuristic type: UNKNOWN_TYPE label: Send me promotions, discounts and other special information from Sears.com. parseable name: order.shippingInfo.emailPromotion field signature: 2403849306 form signature: 17155013134718564270" +autofill-prediction="UNKNOWN_TYPE" +/> + <input type="hidden" id="__checkbox_order.shippingInfo.emailPromotion" name="__checkbox_order.shippingInfo.emailPromotion" value="true" /> + <label for="order.shippingInfo.emailPromotion">Send me promotions, discounts and other special information from Sears.com.</label> + </div> + <div> + <ul> + <li> + <input type="submit" +title="Continue checkout" value="Shipping Options" /> + </li> + </ul> + </div> + </form> + <form id="verifyAddressForm" name="verifyAddressForm" action="https://www.searspartsdirect.com/partsdirect/checkOut.pd" method="post" autocomplete="off"> + <input type="hidden" name="_eventId" value="goVerifyAddress" id="verifyAddressForm__eventId" /> + <input type="hidden" id="CheckoutFlowAction_geoCode_submitted" name="geoCode" value="" /> + </form> + <form id="emailForUpdates" name="emailForUpdates" action="https://www.searspartsdirect.com/partsdirect/offerEmailsAction.pd" method="post" autocomplete="off"> + <fieldset> + <input type="text" id="emailAdd" name="emailAddress" value="" tabindex="4" maxlength="50" /> + <label for="emailAdd">enter email address</label> + </fieldset> + </form> + <form id="shipSignForm" method="post" action="https://sso.shld.net/shccas/shcLogin" autocomplete="off" name="shipSignForm"> + <input type="hidden" name="s" id="s" /> + <input type="hidden" name="k" id="k" /> + <input type="hidden" name="renew" value="true" id="renew" /> + <input type="hidden" value="21" name="sourceSiteId" id="sourceSiteId" /> + <input type="hidden" name="service" value="https://www.searspartsdirect.com/partsdirect/j_spring_cas_security_check" id="service" /> + <div> + <label for="loginId">Email Address + <span>Required</span> + </label> + <input placeholder="Email Address" type="text" name="loginId" value="" id="email" /> + </div> + <div> + <label for="password">Password<a tabindex="3">Forgot your password?</a> + </label> + <input placeholder="Password" type="password" value="" z-index="2000" name="logonPassword" id="password" /> + </div> + </form> + </body> +</html> diff --git a/browser/extensions/formautofill/test/fixtures/third_party/Staples/Basic.html b/browser/extensions/formautofill/test/fixtures/third_party/Staples/Basic.html new file mode 100644 index 0000000000..cf9e892cb2 --- /dev/null +++ b/browser/extensions/formautofill/test/fixtures/third_party/Staples/Basic.html @@ -0,0 +1,117 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta name="generator" content="HTML Tidy for HTML5 for Mac OS X version 5.4.0"> + <title>It's easy to find the Office Supplies, Copy Paper, + Furniture, Ink, Toner, Cleaning Products, Electronics and the + Technology you need | Staples®</title> + <meta content="checkout" name="PageName"> + <meta content="IE=edge,chrome=1" http-equiv="X-UA-Compatible"> + <meta content="noindex,follow" name="robots"> + <meta content="Shop Staples® for everyday low prices and get everything you need for a home office or business. Staples Rewards� members get free shipping every day and up to 5% back in rewards, some exclusions apply." + name="description"> + <meta name="viewport" content="width=null, initial-scale=1"> + <meta http-equiv="Content-Type" content="text/html; charset=utf-8"> + <meta http-equiv="x-dns-prefetch-control" content="on"> + <meta http-equiv="content-language" content="en-us"> +</head> +<body> + <form> + <input autocomplete="false" name="hidden" type="text"> + <div> + <div> + <div> + <label for="firstName"> +<span>First Name</span> + <span>*</span> +</label> +<br> + <input name="firstName" maxlength="40" autocomplete="off" + placeholder="" type="text"> + </div> + </div> + <div> + <div> + <label for="lastName"> +<span>Last + Name</span> +<span>*</span> +</label> +<br> + <input name="lastName" maxlength="40" autocomplete="off" + placeholder="" type="text"> + </div> + </div> + <div> + <div> + <div> + <div> + <label for="address1">Shipping + Address<span>*</span> +</label> +<br> + <input name="address1" maxlength="35" autocomplete="off" placeholder="" id="oneAutoComplete" type="text"> + </div> + </div> + </div> + </div> + <div> + <div> + <label for="emailId"> +<span>Email + Address</span> +<span>*</span> +</label> +<br> + <input name="emailId" maxlength="80" autocomplete="off" + placeholder="" type="text"> + </div> + </div> + <div> + <div> + <label for="phoneNo"> +<span>Phone</span> +<span>*</span> +</label> +<br> + <input name="phoneNo" autocomplete="off" type="text"> + </div> + </div> + <div> + <div> + <label for="phoneEx"> +<span>Extn.</span> +</label> +<br> + <input name="phoneEx" maxlength="6" autocomplete="off" + placeholder="" type="text"> + </div> + </div> + <div> + <div> + <label for="companyName"> +<span>Company Name + (optional)</span> +</label> +<br> + <input name="companyName" maxlength="50" autocomplete="off" placeholder="" type="text"> + </div> + </div> + <div> + <div> + <div tabindex="0"> + <label>Email me exclusive offers & deals from + Staples</label> +<input value="true" name="isEmailOfferChecked" type="hidden"> + </div> + </div> + </div> + <div> + <div> + <button type="submit">continue</button> + </div> + </div> + </div> + </form> +</body> +</html> diff --git a/browser/extensions/formautofill/test/fixtures/third_party/Staples/Basic_ac_on.html b/browser/extensions/formautofill/test/fixtures/third_party/Staples/Basic_ac_on.html new file mode 100644 index 0000000000..d3ba1116aa --- /dev/null +++ b/browser/extensions/formautofill/test/fixtures/third_party/Staples/Basic_ac_on.html @@ -0,0 +1,117 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta name="generator" content="HTML Tidy for HTML5 for Mac OS X version 5.4.0"> + <title>It's easy to find the Office Supplies, Copy Paper, + Furniture, Ink, Toner, Cleaning Products, Electronics and the + Technology you need | Staples®</title> + <meta content="checkout" name="PageName"> + <meta content="IE=edge,chrome=1" http-equiv="X-UA-Compatible"> + <meta content="noindex,follow" name="robots"> + <meta content="Shop Staples® for everyday low prices and get everything you need for a home office or business. Staples Rewards� members get free shipping every day and up to 5% back in rewards, some exclusions apply." + name="description"> + <meta name="viewport" content="width=null, initial-scale=1"> + <meta http-equiv="Content-Type" content="text/html; charset=utf-8"> + <meta http-equiv="x-dns-prefetch-control" content="on"> + <meta http-equiv="content-language" content="en-us"> +</head> +<body> + <form> + <input name="hidden" type="text"> + <div> + <div> + <div> + <label for="firstName"> +<span>First Name</span> + <span>*</span> +</label> +<br> + <input name="firstName" maxlength="40" placeholder="" + type="text"> + </div> + </div> + <div> + <div> + <label for="lastName"> +<span>Last + Name</span> +<span>*</span> +</label> +<br> + <input name="lastName" maxlength="40" placeholder="" + type="text"> + </div> + </div> + <div> + <div> + <div> + <div> + <label for="address1">Shipping + Address<span>*</span> +</label> +<br> + <input name="address1" maxlength="35" placeholder="" + id="oneAutoComplete" type="text"> + </div> + </div> + </div> + </div> + <div> + <div> + <label for="emailId"> +<span>Email + Address</span> +<span>*</span> +</label> +<br> + <input name="emailId" maxlength="80" placeholder="" type="text"> + </div> + </div> + <div> + <div> + <label for="phoneNo"> +<span>Phone</span> +<span>*</span> +</label> +<br> + <input name="phoneNo" type="text"> + </div> + </div> + <div> + <div> + <label for="phoneEx"> +<span>Extn.</span> +</label> +<br> + <input name="phoneEx" maxlength="6" placeholder="" type="text"> + </div> + </div> + <div> + <div> + <label for="companyName"> +<span>Company Name + (optional)</span> +</label> +<br> + <input name="companyName" maxlength="50" placeholder="" + type="text"> + </div> + </div> + <div> + <div> + <div tabindex="0"> + <label>Email me exclusive offers & deals from + Staples</label> +<input value="true" name="isEmailOfferChecked" type="hidden"> + </div> + </div> + </div> + <div> + <div> + <button type="submit">continue</button> + </div> + </div> + </div> + </form> +</body> +</html> diff --git a/browser/extensions/formautofill/test/fixtures/third_party/Staples/PaymentBilling.html b/browser/extensions/formautofill/test/fixtures/third_party/Staples/PaymentBilling.html new file mode 100644 index 0000000000..37dadeb514 --- /dev/null +++ b/browser/extensions/formautofill/test/fixtures/third_party/Staples/PaymentBilling.html @@ -0,0 +1,99 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta name="generator" content="HTML Tidy for HTML5 for Mac OS X version 5.4.0"> + <title>It's easy to find the Office Supplies, Copy Paper, + Furniture, Ink, Toner, Cleaning Products, Electronics and the + Technology you need | Staples®</title> + <meta content="checkout" name="PageName"> + <meta content="IE=edge,chrome=1" http-equiv="X-UA-Compatible"> + <meta content="noindex,follow" name="robots"> + <meta content="Shop Staples® for everyday low prices and get everything you need for a home office or business. Staples Rewards� members get free shipping every day and up to 5% back in rewards, some exclusions apply." + name="description"> + <meta name="viewport" content="width=null, initial-scale=1"> + <meta http-equiv="Content-Type" content="text/html; charset=utf-8"> + <meta http-equiv="x-dns-prefetch-control" content="on"> + <meta http-equiv="content-language" content="en-us"> +</head> +<body> + <form id="payment-cc" name="payment-cc"> + <input autocomplete="false" name="hidden" type="text"> + <div> + <div> + <div> + <div> + <label for="cardNumber"> +<span>Card + Number</span> +<span>*</span> +</label> +<br> + <input name="cardNumber" autocomplete="off" type="text"> + </div> + </div> + <div> + <div> + <label for="expDate"> +<span>Expiration + Date</span> +<span>*</span> +</label> +<br> + <input name="expDate" placeholder="MM/YY" autocomplete="off" type="text"> + </div> + </div> + <div> + <div> + <label for="secCode"> +<span>Security + Code</span> +<span>*</span> +</label> +<br> + <input name="secCode" value="" autocomplete="off" + maxlength="4" type="password"> + </div> + </div> + </div> + <div> + <input value="" name="cardBrand" type="hidden"> + </div> + <div> + <div> + <div tabindex="0"> + <label>Billing address is the same as shipping + address</label> +<input value="true" name="isBillAddrSameShipAddr" type="hidden"> + </div> + </div> + </div> + <div> + <input value="1F16D1368309E3DBE15C391E7571B371" name="addressId" type="hidden"> + </div> + <div> + <p>Purchase Order # (optional) <span tabindex="0">Add</span> +</p> + </div> + <section id="paymentGuestRadio"> +</section> + <div> + <div> + <button type="submit">place my order</button> + </div> + </div> + <div> + <div> + <p> +<span>By placing your order, you agree to + Staples</span> +<a target="_blank"> +<span>Terms & + Conditions.</span> +</a> +</p> + </div> + </div> + </div> + </form> +</body> +</html> diff --git a/browser/extensions/formautofill/test/fixtures/third_party/Staples/PaymentBilling_ac_on.html b/browser/extensions/formautofill/test/fixtures/third_party/Staples/PaymentBilling_ac_on.html new file mode 100644 index 0000000000..98c9fb8555 --- /dev/null +++ b/browser/extensions/formautofill/test/fixtures/third_party/Staples/PaymentBilling_ac_on.html @@ -0,0 +1,98 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta name="generator" content="HTML Tidy for HTML5 for Mac OS X version 5.4.0"> + <title>It's easy to find the Office Supplies, Copy Paper, + Furniture, Ink, Toner, Cleaning Products, Electronics and the + Technology you need | Staples®</title> + <meta content="checkout" name="PageName"> + <meta content="IE=edge,chrome=1" http-equiv="X-UA-Compatible"> + <meta content="noindex,follow" name="robots"> + <meta content="Shop Staples® for everyday low prices and get everything you need for a home office or business. Staples Rewards� members get free shipping every day and up to 5% back in rewards, some exclusions apply." + name="description"> + <meta name="viewport" content="width=null, initial-scale=1"> + <meta http-equiv="Content-Type" content="text/html; charset=utf-8"> + <meta http-equiv="x-dns-prefetch-control" content="on"> + <meta http-equiv="content-language" content="en-us"> +</head> +<body> + <form id="payment-cc" name="payment-cc"> + <input name="hidden" type="text"> + <div> + <div> + <div> + <div> + <label for="cardNumber"> +<span>Card + Number</span> +<span>*</span> +</label> +<br> + <input name="cardNumber" type="text"> + </div> + </div> + <div> + <div> + <label for="expDate"> +<span>Expiration + Date</span> +<span>*</span> +</label> +<br> + <input name="expDate" placeholder="MM/YY" type="text"> + </div> + </div> + <div> + <div> + <label for="secCode"> +<span>Security + Code</span> +<span>*</span> +</label> +<br> + <input name="secCode" value="" maxlength="4" type="password"> + </div> + </div> + </div> + <div> + <input value="" name="cardBrand" type="hidden"> + </div> + <div> + <div> + <div tabindex="0"> + <label>Billing address is the same as shipping + address</label> +<input value="true" name="isBillAddrSameShipAddr" type="hidden"> + </div> + </div> + </div> + <div> + <input value="1F16D1368309E3DBE15C391E7571B371" name="addressId" type="hidden"> + </div> + <div> + <p>Purchase Order # (optional) <span tabindex="0">Add</span> +</p> + </div> + <section id="paymentGuestRadio"> +</section> + <div> + <div> + <button type="submit">place my order</button> + </div> + </div> + <div> + <div> + <p> +<span>By placing your order, you agree to + Staples</span> +<a target="_blank"> +<span>Terms & + Conditions.</span> +</a> +</p> + </div> + </div> + </div> + </form> +</body> +</html> diff --git a/browser/extensions/formautofill/test/fixtures/third_party/Walmart/Checkout.html b/browser/extensions/formautofill/test/fixtures/third_party/Walmart/Checkout.html new file mode 100644 index 0000000000..ee5ded483e --- /dev/null +++ b/browser/extensions/formautofill/test/fixtures/third_party/Walmart/Checkout.html @@ -0,0 +1,243 @@ +<!DOCTYPE html> +<html lang="en"> + <head> + <meta name="generator" content="HTML Tidy for HTML5 for Mac OS X version 5.4.0"> + <meta http-equiv="content-type" content="text/html; charset=utf-8"> + <meta charset="utf-8"> + <meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1"> + <meta name="viewport" content="width=device-width, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no"> + <title> +</title> + </head> + <body> + <form> + <p>Enter new zip code:</p> + <label> +</label> + <div> + <p> +<label>ZIP Code (required)</label> +</p> + <label> +<input name="zip-code" value="" placeholder="" type="text"> +</label> + </div> + <div> + <div> +<button type="submit">Calculate</button> +</div> + <div> +<button type="button">Cancel</button> +</div> + </div> + </form> + <form> + <label> +<span> +<span>Promo code + (optional)</span> +</span> +</label> + <div> +<input name="promoCode"> +</div> + <button type="submit"> +<span>Apply</span> +</button> + </form> + <form method="post" novalidate=""> + <div> + <div> +<label> +<span>Email address (required)</span> +</label> +</div> + <div> + <div> +<label> +<input +title="Email address" placeholder="" name="email" type="email"> +</label> +</div> + </div> + </div> + <div> + <div> + <div> +<label> +<span>Password (required)</span> +</label> +</div> + <div> + <div> +<label> +<input +title="Password" placeholder="" name="password" + type="password"> +</label> +</div> + <div> +<label> +<button type="button"> +<label>Show</label> +</button> +</label> +</div> + </div> + <div> +<label> +<span>Password (required)</span> +</label> +</div> + <div> + <div> +<label> +<input +title="Password" placeholder="" name="password" + autocomplete="off" tabindex="-1" type="text"> +</label> +</div> + <div> +<label> +<button type="button" tabindex="-1"> +<label>Hide</label> +</button> +</label> +</div> + </div> + </div> + </div> + <div> + <div> + <div> +<button type="button">Forgot password?</button> +</div> + </div> + </div> + <div> +<button type="submit">Sign In</button> +</div> + </form> + <form novalidate="" method="post"> + <div>*required field</div> + <div> +<label> +<span>First name*</span> +</label> +</div> + <div> + <div> +<label> +<input +title="First name" placeholder="" name="firstName" type="text"> +</label> +</div> + </div> + <div> +<label> +<span>Last name*</span> +</label> +</div> + <div> + <div> +<label> +<input +title="Last name" placeholder="" name="lastName" + type="text"> +</label> +</div> + </div> + <div> +<label> +<span>Email address*</span> +</label> +</div> + <div> + <div> +<label> +<input +title="Email address" placeholder="" name="email" autocomplete="off" type="email"> +</label> +</div> + </div> + <div> +<input tabindex="-1" +title="Email Address" type="text"> +<input tabindex="-1" title="Password" type="password"> +</div> + <div> + <div> + <div> +<label> +<span>Password*</span> +</label> +</div> + <div> + <div> +<label> +<input +title="Password" placeholder="" name="password" + autocomplete="new-password" type="password"> +<span>Your password + must be between 6 and 12 characters.</span> +</label> + </div> + <div> +<label> +<button type="button"> +<label>Show</label> +</button> +</label> +</div> + </div> + <div> +<label> +<span>Password*</span> +</label> +</div> + <div> + <div> +<label> +<input +title="Password" placeholder="" name="password" + autocomplete="off" tabindex="-1" type="text"> +<span>Your password + must be between 6 and 12 characters.</span> +</label> + </div> + <div> +<label> +<button type="button" tabindex="-1"> +<label>Hide</label> +</button> +</label> +</div> + </div> + </div> + </div> + <div> +<span> +<span>By clicking Create Account, you acknowledge you + have read and agreed to our</span> +<a target="_blank">Terms of + Use</a> +<span>and</span> +<a target="_blank">Privacy + Policy</a> +<span>.</span> +</span> + </div> + <button type="submit">Create Account</button> + <div> + <div> +<input name="newsletter" id="checkbox-0" value="true" checked="checked" type="checkbox"> +<label for="checkbox-0"> +<span>Email me + about Rollbacks, special pricing, hot new items, gift ideas and + more.</span> +</label> + </div> + </div> + </form> + </body> +</html> diff --git a/browser/extensions/formautofill/test/fixtures/third_party/Walmart/Payment.html b/browser/extensions/formautofill/test/fixtures/third_party/Walmart/Payment.html new file mode 100644 index 0000000000..f6a46387b2 --- /dev/null +++ b/browser/extensions/formautofill/test/fixtures/third_party/Walmart/Payment.html @@ -0,0 +1,235 @@ +<!DOCTYPE html> +<html lang="en"> + <head> + <meta name="generator" content="HTML Tidy for HTML5 for Mac OS X version 5.4.0"> + <meta http-equiv="content-type" content="text/html; charset=utf-8"> + <meta charset="utf-8"> + <meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1"> + <meta name="viewport" content="width=device-width, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no"> + <title>Checkout</title> + <meta property="og:type" content="Website"> + <meta property="og:image" content="http://sphotos-b.xx.fbcdn.net/hphotos-ash4/229244_10150189115584236_162217_n.jpg"> + <meta property="og:site_name" content="Walmart.com"> + <meta property="fb:app_id" content="105223049547814"> + <meta property="twitter:card" content="summary"> + <meta property="twitter:image" content="https://pbs.twimg.com/profile_images/616833885/walmart_logo_youtube_bigger.jpg"> + <meta property="twitter:site" content="@walmart"> + <meta property="og:title" content="Checkout"> + <meta property="twitter:title" content="Checkout"> + </head> + <body> + <form> + <label> +<span> +<span>Promo code + (optional)</span> +</span> +</label> + <div> +<input name="promoCode"> +</div> + <button type="submit"> +<span>Apply</span> +</button> + </form> + <form> + <div> + <div> + <div> + <div>* required field</div> + <div> +<label for="firstName"> +<span>First name on + card*</span> +</label> + </div> + <div> + <div> +<label for="firstName"> +<input id="firstName" value="" + name="firstName" +title="First name" autocomplete="section-payment given-name" maxlength="25"> +</label> +</div> + </div> + <div> +<label for="lastName"> +<span>Last name on + card*</span> +</label> + </div> + <div> + <div> +<label for="lastName"> +<input id="lastName" value="" + name="lastName" +title="Last name" autocomplete="section-payment family-name" maxlength="25"> +</label> +</div> + </div> + <div> +<label for="creditCard"> +<span>Card + number*</span> +</label> + </div> + <div> + <div> +<label for="creditCard"> +<input id="creditCard" pattern="[0-9]*" inputmode="numeric" name="creditCard" autocomplete="section-payment cc-number" maxlength="16"> +</label> +</div> + </div> + <div> + <div> + <div> + <label> +<span>Expiration date*</span> +</label> + <div> + <span> + <label for="month-chooser"> + <svg width="11" height="6"> + <polygon fill="#027DC3" points="5.5,6 0,0 11,0"> +</polygon> + </svg> + <select id="month-chooser" name="month-chooser" autocomplete="section-payment cc-exp-month"> + <option selected="selected" value="" disabled="disabled"> + MM + </option> + <option value="01">01</option> + <option value="02">02</option> + <option value="03">03</option> + <option value="04">04</option> + <option value="05">05</option> + <option value="06">06</option> + <option value="07">07</option> + <option value="08">08</option> + <option value="09">09</option> + <option value="10">10</option> + <option value="11">11</option> + <option value="12">12</option> + </select> + </label> + </span> + </div> + <span> / </span> + <div> + <span> + <label for="year-chooser"> + <svg width="11" height="6"> + <polygon fill="#027DC3" points="5.5,6 0,0 11,0"> +</polygon> + </svg> + <select id="year-chooser" name="year-chooser" autocomplete="section-payment cc-exp-year"> + <option selected="selected" value="" disabled="disabled"> + YY + </option> + <option value="2017">17</option> + <option value="2018">18</option> + <option value="2019">19</option> + <option value="2020">20</option> + <option value="2021">21</option> + <option value="2022">22</option> + <option value="2023">23</option> + <option value="2024">24</option> + <option value="2025">25</option> + <option value="2026">26</option> + <option value="2027">27</option> + </select> + </label> + </span> + </div> + </div> + </div> + <div> + <input +title=" " name="brwsrAutofillText" type="text"> + <input +title=" " name="brwsrAutofillPassword" type="password"> + <div> + <div> + <label for="cvv"> +<span> +<span> +<span>Security code*</span> + </span> +</span> +</label> + <div> +<label for="cvv"> +<button type="button"> +<label for="cvv"> +<span name="help" size="1" alt="Icon for help"> +</span> +</label> +</button> +</label> +</div> + </div> + <div> + <div> +<input id="cvv" name="cvv" +title="cvv" value="" autocomplete="section-payment cc-csc" maxlength="3" pattern="[0-9]*" inputmode="numeric" type="password"> +</div> + </div> + </div> + </div> + </div> + <div> +<label for="phone"> +<span>Phone number*</span> +<span>Ex: (415) + 444 - 5555</span> +</label> + </div> + <div> + <div> +<label for="phone"> +<input id="phone" value="" name="phone" +title="Phone" autocomplete="section-payment tel" maxlength="14" type="tel"> +</label> +</div> + </div> + </div> + </div> + <div> + <div> + <div> + <label> +<input checked="checked" type="checkbox"> +</label> + <div> +<label> +<span>Same as shipping</span> +</label> +</div> + <div> + <p> +<span>22F., No.55, Haiiu 1st Rd., Bafu Dist.,</span> +<br> + <span> +<span>San Bruno</span> +<span>,</span> +</span> + <span> +<span>CA</span> +</span> +<span>94066</span> + </p> + </div> + </div> + </div> + </div> + </div> + <div> + <div> +<button type="button"> +<span>Review Your + Order</span> +</button> + </div> + </div> + </form> + </body> +</html> diff --git a/browser/extensions/formautofill/test/fixtures/third_party/Walmart/Shipping.html b/browser/extensions/formautofill/test/fixtures/third_party/Walmart/Shipping.html new file mode 100644 index 0000000000..5f07a05bab --- /dev/null +++ b/browser/extensions/formautofill/test/fixtures/third_party/Walmart/Shipping.html @@ -0,0 +1,234 @@ +<!DOCTYPE html> +<html lang="en"> + <head> + <meta name="generator" content="HTML Tidy for HTML5 for Mac OS X version 5.4.0"> + <meta http-equiv="content-type" content="text/html; charset=utf-8"> + <meta charset="utf-8"> + <meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1"> + <meta name="viewport" content="width=device-width, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no"> + <title>Checkout</title> + <meta property="og:type" content="Website"> + <meta property="og:image" content="http://sphotos-b.xx.fbcdn.net/hphotos-ash4/229244_10150189115584236_162217_n.jpg"> + <meta property="og:site_name" content="Walmart.com"> + <meta property="fb:app_id" content="105223049547814"> + <meta property="twitter:card" content="summary"> + <meta property="twitter:image" content="https://pbs.twimg.com/profile_images/616833885/walmart_logo_youtube_bigger.jpg"> + <meta property="twitter:site" content="@walmart"> + <meta property="og:title" content="Checkout"> + <meta property="twitter:title" content="Checkout"> + </head> + <body> + <form> + <p>Enter new zip code:</p> + <label> +</label> + <div> + <p> +<label>ZIP Code (required)</label> +</p> + <label> +<input name="zip-code" value="" placeholder="" type="text"> +</label> + </div> + <div> + <div> +<button type="submit">Calculate</button> +</div> + <div> +<button type="button">Cancel</button> +</div> + </div> + </form> + <form> + <label> +<span> +<span>Promo code + (optional)</span> +</span> +</label> + <div> +<input name="promoCode"> +</div> + <button type="submit"> +<span>Apply</span> +</button> + </form> + <form> + <div> + <div> + <div> + <div> + <div>*required field</div> + <label> +<span> +<span> +<span>First name*</span> +</span> +</span> +</label> + <div> +<input +title="First name" name="firstName" type="text"> +</div> + <label> +<span> +<span> +<span>Last name*</span> +</span> +</span> +</label> + <div> +<input +title="Last name" name="lastName" type="text"> +</div> + <label> +<span> +<span> +<span>Phone number*</span> +</span> +</span> +</label> + <div> +<input minlength="10" maxlength="14" +title="Phone number" name="phone" type="text"> +</div> + </div> + <div> + <label> +<span> +<span> +<span>Street address*</span> +</span> +</span> +</label> + <div> +<input +title="Street address" name="addressLineOne" type="text"> +</div> + <label> +<span> +<span> +<span>Apt, suite, etc (optional)</span> +</span> +</span> +</label> + <div> +<input +title="Apt, suite, bldg, c/o (optional)" name="addressLineTwo" type="text"> +</div> + <label> +<span> +<span> +<span>City*</span> +</span> +</span> +</label> + <div> +<input +title="City" name="city" value="" type="text"> +</div> + <div> + <div> + <div> + <label for="5"> +<span>State*</span> +</label> + <div> + <div> + <select id="5" +title="State" name="state"> + <option value=""> +</option> + <option value="AL">Alabama</option> + <option value="AK">Alaska</option> + <option value="AZ">Arizona</option> + <option value="AR">Arkansas</option> + <option value="CA">California</option> + <option value="CO">Colorado</option> + <option value="CT">Connecticut</option> + <option value="DC">District of Columbia</option> + <option value="DE">Delaware</option> + <option value="FL">Florida</option> + <option value="GA">Georgia</option> + <option value="HI">Hawaii</option> + <option value="ID">Idaho</option> + <option value="IL">Illinois</option> + <option value="IN">Indiana</option> + <option value="IA">Iowa</option> + <option value="KS">Kansas</option> + <option value="KY">Kentucky</option> + <option value="LA">Louisiana</option> + <option value="ME">Maine</option> + <option value="MD">Maryland</option> + <option value="MA">Massachusetts</option> + <option value="MI">Michigan</option> + <option value="MN">Minnesota</option> + <option value="MS">Mississippi</option> + <option value="MO">Missouri</option> + <option value="MT">Montana</option> + <option value="NE">Nebraska</option> + <option value="NV">Nevada</option> + <option value="NH">New Hampshire</option> + <option value="NJ">New Jersey</option> + <option value="NM">New Mexico</option> + <option value="NY">New York</option> + <option value="NC">North Carolina</option> + <option value="ND">North Dakota</option> + <option value="OH">Ohio</option> + <option value="OK">Oklahoma</option> + <option value="OR">Oregon</option> + <option value="PA">Pennsylvania</option> + <option value="RI">Rhode Island</option> + <option value="SC">South Carolina</option> + <option value="SD">South Dakota</option> + <option value="TN">Tennessee</option> + <option value="TX">Texas</option> + <option value="UT">Utah</option> + <option value="VT">Vermont</option> + <option value="VA">Virginia</option> + <option value="WA">Washington</option> + <option value="WV">West Virginia</option> + <option value="WI">Wisconsin</option> + <option value="WY">Wyoming</option> + <option value="AA">Armed Forces Americas</option> + <option value="AP">Armed Forces Pacific</option> + <option value="AE">Armed Forces other</option> + <option value="AS">American Samoa</option> + <option value="GU">Guam</option> + <option value="MP">N. Mariana Islands</option> + <option value="PW">Palau</option> + <option value="PR">Puerto Rico</option> + <option value="VI">Virgin Islands</option> + </select> + </div> + </div> + </div> + </div> + <div> + <label> +<span> +<span> +<span>ZIP + Code*</span> +</span> +</span> +</label> + <div> +<input +title="Zip code" name="postalCode" value="" type="text"> +</div> + </div> + </div> + </div> + <div> +<input id="4" name="isDefault" type="checkbox"> +<label for="4"> +<span>Set as my preferred address</span> +</label> +</div> + </div> + </div> + </div> + </form> + </body> +</html> diff --git a/browser/extensions/formautofill/test/mochitest/creditCard/mochitest.ini b/browser/extensions/formautofill/test/mochitest/creditCard/mochitest.ini new file mode 100644 index 0000000000..858227c8eb --- /dev/null +++ b/browser/extensions/formautofill/test/mochitest/creditCard/mochitest.ini @@ -0,0 +1,20 @@ +[DEFAULT] +prefs = + extensions.formautofill.creditCards.available=true + extensions.formautofill.creditCards.enabled=true + extensions.formautofill.reauth.enabled=true +support-files = + !/toolkit/components/satchel/test/satchel_common.js + ../../../../../../toolkit/components/satchel/test/parent_utils.js + !/toolkit/components/satchel/test/parent_utils.js + !/browser/extensions/formautofill/test/mochitest/formautofill_common.js + !/browser/extensions/formautofill/test/mochitest/formautofill_parent_utils.js +skip-if = xorigin + +[test_basic_creditcard_autocomplete_form.html] +scheme=https +[test_clear_form.html] +scheme=https +[test_creditcard_autocomplete_off.html] +skip-if = verify +scheme=https diff --git a/browser/extensions/formautofill/test/mochitest/creditCard/test_basic_creditcard_autocomplete_form.html b/browser/extensions/formautofill/test/mochitest/creditCard/test_basic_creditcard_autocomplete_form.html new file mode 100644 index 0000000000..b362f2388a --- /dev/null +++ b/browser/extensions/formautofill/test/mochitest/creditCard/test_basic_creditcard_autocomplete_form.html @@ -0,0 +1,253 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Test basic autofill</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/EventUtils.js"></script> + <script type="text/javascript" src="../formautofill_common.js"></script> + <script type="text/javascript" src="../../../../../..//toolkit/components/satchel/test/satchel_common.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" /> +</head> +<body> +Form autofill test: simple form credit card autofill + +<script> +"use strict"; + +const MOCK_STORAGE = [{ + "cc-name": "John Doe", + "cc-number": "4929001587121045", + "cc-exp-month": 4, + "cc-exp-year": 2017, + "cc-type": "visa", +}, { + "cc-name": "Timothy Berners-Lee", + "cc-number": "5103059495477870", + "cc-exp-month": 12, + "cc-exp-year": 2022, + "cc-type": "mastercard", +}]; + +const reducedMockRecord = { + "cc-name": "John Doe", + "cc-number": "4929001587121045", +}; + +async function setupCreditCardStorage() { + await addCreditCard(MOCK_STORAGE[0]); + await addCreditCard(MOCK_STORAGE[1]); +} + +async function setupFormHistory() { + await updateFormHistory([ + {op: "add", fieldname: "cc-name", value: "John Smith"}, + {op: "add", fieldname: "cc-exp-year", value: 2023}, + ]); +} + +initPopupListener(); + +// Form with history only. +add_task(async function history_only_menu_checking() { + // TODO: eliminate the timeout when we're able to indicate the right + // timing to start. + // + // After test process was re-spawning to https scheme. Wait 2 secs + // to ensure the environment is ready to do storage setup. + await sleep(2000); + await setupFormHistory(); + + await setInput("#cc-exp-year", ""); + synthesizeKey("KEY_ArrowDown"); + await expectPopup(); + checkMenuEntries(["2023"], false); +}); + +// Display credit card result even if the number of fillable fields is less than the threshold. +add_task(async function all_saved_fields_less_than_threshold() { + await addCreditCard(reducedMockRecord); + + await setInput("#cc-name", ""); + await expectPopup(); + synthesizeKey("KEY_ArrowDown"); + checkMenuEntries([reducedMockRecord].map(patchRecordCCNumber).map(cc => JSON.stringify({ + primary: cc["cc-name"], + secondary: cc.ccNumberFmt.affix + cc.ccNumberFmt.label, + ariaLabel: `Visa ${cc["cc-name"]} ${cc.ccNumberFmt.affix}${cc.ccNumberFmt.label}`, + }))); + + await cleanUpCreditCards(); +}); + +// Form with both history and credit card storage. +add_task(async function check_menu_when_both_existed() { + await setupCreditCardStorage(); + + await setInput("#cc-number", ""); + await expectPopup(); + synthesizeKey("KEY_ArrowDown"); + checkMenuEntries(MOCK_STORAGE.map(patchRecordCCNumber).map(cc => JSON.stringify({ + primaryAffix: cc.ccNumberFmt.affix, + primary: cc.ccNumberFmt.label, + secondary: cc["cc-name"], + ariaLabel: `${getCCTypeName(cc)} ${cc.ccNumberFmt.affix} ${cc.ccNumberFmt.label} ${cc["cc-name"]}`, + }))); + + await setInput("#cc-name", ""); + await expectPopup(); + synthesizeKey("KEY_ArrowDown"); + checkMenuEntries(MOCK_STORAGE.map(patchRecordCCNumber).map(cc => JSON.stringify({ + primary: cc["cc-name"], + secondary: cc.ccNumberFmt.affix + cc.ccNumberFmt.label, + ariaLabel: `${getCCTypeName(cc)} ${cc["cc-name"]} ${cc.ccNumberFmt.affix}${cc.ccNumberFmt.label}`, + }))); + + await setInput("#cc-exp-year", ""); + await expectPopup(); + synthesizeKey("KEY_ArrowDown"); + checkMenuEntries(MOCK_STORAGE.map(patchRecordCCNumber).map(cc => JSON.stringify({ + primary: cc["cc-exp-year"], + secondary: cc.ccNumberFmt.affix + cc.ccNumberFmt.label, + ariaLabel: `${getCCTypeName(cc)} ${cc["cc-exp-year"]} ${cc.ccNumberFmt.affix}${cc.ccNumberFmt.label}`, + }))); + + await setInput("#cc-exp-month", ""); + await expectPopup(); + synthesizeKey("KEY_ArrowDown"); + checkMenuEntries(MOCK_STORAGE.map(patchRecordCCNumber).map(cc => JSON.stringify({ + primary: cc["cc-exp-month"], + secondary: cc.ccNumberFmt.affix + cc.ccNumberFmt.label, + ariaLabel: `${getCCTypeName(cc)} ${cc["cc-exp-month"]} ${cc.ccNumberFmt.affix}${cc.ccNumberFmt.label}`, + }))); + + await cleanUpCreditCards(); +}); + +// Display history search result if no matched data in credit card. +add_task(async function check_fallback_for_mismatched_field() { + await addCreditCard(reducedMockRecord); + + await setInput("#cc-exp-year", ""); + synthesizeKey("KEY_ArrowDown"); + await expectPopup(); + checkMenuEntries(["2023"], false); + + await cleanUpCreditCards(); +}); + +// Display history search result if credit card autofill is disabled. +add_task(async function check_search_result_for_pref_off() { + await setupCreditCardStorage(); + + await SpecialPowers.pushPrefEnv({ + set: [["extensions.formautofill.creditCards.enabled", false]], + }); + + await setInput("#cc-name", ""); + synthesizeKey("KEY_ArrowDown"); + await expectPopup(); + checkMenuEntries(["John Smith"], false); + + await SpecialPowers.popPrefEnv(); +}); + +let canTest; + +// Autofill the credit card from dropdown menu. +add_task(async function check_fields_after_form_autofill() { + canTest = await canTestOSKeyStoreLogin(); + if (!canTest) { + todo(canTest, "Cannot test OS key store login on official builds."); + return; + } + + await setInput("#cc-exp-year", 202); + + synthesizeKey("KEY_ArrowDown"); + // The popup doesn't auto-show on focus because the field isn't empty + await expectPopup(); + checkMenuEntries(MOCK_STORAGE.slice(1).map(patchRecordCCNumber).map(cc => JSON.stringify({ + primary: cc["cc-exp-year"], + secondary: cc.ccNumberFmt.affix + cc.ccNumberFmt.label, + ariaLabel: `${getCCTypeName(cc)} ${cc["cc-exp-year"]} ${cc.ccNumberFmt.affix}${cc.ccNumberFmt.label}`, + }))); + + synthesizeKey("KEY_ArrowDown"); + let osKeyStoreLoginShown = waitForOSKeyStoreLogin(true); + await new Promise(resolve => SimpleTest.executeSoon(resolve)); + await triggerAutofillAndCheckProfile(MOCK_STORAGE[1]); + await osKeyStoreLoginShown; +}); + +// Fallback to history search after autofill values (for non-empty fields). +add_task(async function check_fallback_after_form_autofill() { + if (!canTest) { + return; + } + + await setInput("#cc-name", "J", true); + synthesizeKey("KEY_ArrowDown"); + await expectPopup(); + checkMenuEntries(["John Smith"], false); +}); + +// Present credit card popup immediately when user blanks a field +add_task(async function check_cc_popup_on_field_blank() { + if (!canTest) { + return; + } + + await setInput("#cc-name", "", true); + await expectPopup(); + checkMenuEntries(MOCK_STORAGE.map(patchRecordCCNumber).map(cc => JSON.stringify({ + primary: cc["cc-name"], + secondary: cc.ccNumberFmt.affix + cc.ccNumberFmt.label, + ariaLabel: `${getCCTypeName(cc)} ${cc["cc-name"]} ${cc.ccNumberFmt.affix}${cc.ccNumberFmt.label}`, + }))); +}); + +// Resume form autofill once all the autofilled fileds are changed. +add_task(async function check_form_autofill_resume() { + if (!canTest) { + return; + } + + document.querySelector("#cc-name").blur(); + document.querySelector("#form1").reset(); + + await setInput("#cc-name", ""); + synthesizeKey("KEY_ArrowDown"); + await expectPopup(); + checkMenuEntries(MOCK_STORAGE.map(patchRecordCCNumber).map(cc => JSON.stringify({ + primary: cc["cc-name"], + secondary: cc.ccNumberFmt.affix + cc.ccNumberFmt.label, + ariaLabel: `${getCCTypeName(cc)} ${cc["cc-name"]} ${cc.ccNumberFmt.affix}${cc.ccNumberFmt.label}`, + }))); +}); + +</script> + +<p id="display"></p> + +<div id="content"> + + <form id="form1"> + <p>This is a basic form.</p> + <p><label>Name: <input id="cc-name" autocomplete="cc-name"></label></p> + <p><label>Card Number: <input id="cc-number" autocomplete="cc-number"></label></p> + <p><label>Expiration month: <input id="cc-exp-month" autocomplete="cc-exp-month"></label></p> + <p><label>Expiration year: <input id="cc-exp-year" autocomplete="cc-exp-year"></label></p> + <p><label>Card Type: <select id="cc-type" autocomplete="cc-type"> + <option value="discover">Discover</option> + <option value="jcb">JCB</option> + <option value="visa">Visa</option> + <option value="mastercard">MasterCard</option> + </select></label></p> + <p><label>CSC: <input id="cc-csc" autocomplete="cc-csc"></label></p> + </form> +</div> + +<pre id="test"></pre> +</body> +</html> diff --git a/browser/extensions/formautofill/test/mochitest/creditCard/test_clear_form.html b/browser/extensions/formautofill/test/mochitest/creditCard/test_clear_form.html new file mode 100644 index 0000000000..76aea56a87 --- /dev/null +++ b/browser/extensions/formautofill/test/mochitest/creditCard/test_clear_form.html @@ -0,0 +1,196 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Test form autofill - clear form button</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/EventUtils.js"></script> + <script type="text/javascript" src="../formautofill_common.js"></script> + <script type="text/javascript" src="../../../../../../toolkit/components/satchel/test/satchel_common.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" /> +</head> +<body> +Form autofill test: clear form button + +<script> +"use strict"; + +const MOCK_ADDR_STORAGE = [{ + organization: "Sesame Street", + "street-address": "2 Harrison St\nline2\nline3", + tel: "+13453453456", +}, { + organization: "Mozilla", + "street-address": "331 E. Evelyn Avenue", +}, { + organization: "Tel org", + tel: "+12223334444", +}]; +const MOCK_CC_STORAGE = [{ + "cc-name": "John Doe", + "cc-number": "4929001587121045", + "cc-exp-month": 4, + "cc-exp-year": 2017, +}, { + "cc-name": "Timothy Berners-Lee", + "cc-number": "5103059495477870", + "cc-exp-month": 12, + "cc-exp-year": 2022, +}]; + +initPopupListener(); + +add_task(async function setup_storage() { + await addAddress(MOCK_ADDR_STORAGE[0]); + await addAddress(MOCK_ADDR_STORAGE[1]); + await addAddress(MOCK_ADDR_STORAGE[2]); + + await addCreditCard(MOCK_CC_STORAGE[0]); + await addCreditCard(MOCK_CC_STORAGE[1]); +}); + + +async function checkIsFormCleared(patch = {}) { + const form = document.getElementById("form1"); + + for (const elem of form.elements) { + const expectedValue = patch[elem.id] || ""; + checkFieldValue(elem, expectedValue); + await checkFieldHighlighted(elem, false); + await checkFieldPreview(elem, ""); + } +} + +async function confirmClear(selector) { + await SpecialPowers.pushPrefEnv({ + set: [["dom.input_events.beforeinput.enabled", true]], + }); + info("Await for clearing input"); + let promise = new Promise(resolve => { + let beforeInputFired = false; + let element = document.querySelector(selector); + element.addEventListener("beforeinput", (event) => { + beforeInputFired = true; + ok(event instanceof InputEvent, + '"beforeinput" event should be dispatched with InputEvent interface'); + is(event.cancelable, SpecialPowers.getBoolPref("dom.input_event.allow_to_cancel_set_user_input"), + `"beforeinput" event should be cancelable unless it's disabled by the pref`); + is(event.bubbles, true, + '"beforeinput" event should always bubble'); + is(event.inputType, "insertReplacementText", + 'inputType value of "beforeinput" should be "insertReplacementText"'); + is(event.data, "", + 'data value of "beforeinput" should be empty string'); + is(event.dataTransfer, null, + 'dataTransfer value of "beforeinput" should be null'); + is(event.getTargetRanges().length, 0, + 'getTargetRanges() of "beforeinput" event should return empty array'); + }, {once: true}); + element.addEventListener("input", (event) => { + ok(beforeInputFired, `"beforeinput" event should've been fired before "input" on <${element.tagName} type="${element.type}">`); + ok(event instanceof InputEvent, + '"input" event should be dispatched with InputEvent interface'); + is(event.cancelable, false, + '"input" event should be never cancelable'); + is(event.bubbles, true, + '"input" event should always bubble'); + is(event.inputType, "insertReplacementText", + 'inputType value of "input" should be "insertReplacementText"'); + is(event.data, "", + 'data value of "input" should be empty string'); + is(event.dataTransfer, null, + 'dataTransfer value of "input" should be null'); + is(event.getTargetRanges().length, 0, + 'getTargetRanges() of "input" should return empty array'); + resolve(); + }, {once: true}) + }); + synthesizeKey("KEY_Enter"); + await promise; +} + +add_task(async function simple_clear() { + await triggerPopupAndHoverItem("#organization", 0); + await triggerAutofillAndCheckProfile(MOCK_ADDR_STORAGE[0]); + + await triggerPopupAndHoverItem("#tel", 0); + await confirmClear("#tel"); + await checkIsFormCleared(); +}); + +add_task(async function clear_adapted_record() { + await triggerPopupAndHoverItem("#street-address", 0); + await triggerAutofillAndCheckProfile(MOCK_ADDR_STORAGE[0]); + + await triggerPopupAndHoverItem("#street-address", 0); + await confirmClear("#street-address"); + await checkIsFormCleared(); +}); + +add_task(async function clear_modified_form() { + await triggerPopupAndHoverItem("#organization", 0); + await triggerAutofillAndCheckProfile(MOCK_ADDR_STORAGE[0]); + + await setInput("#tel", "+1111111111", true); + + await triggerPopupAndHoverItem("#street-address", 0); + await confirmClear("#street-address"); + await checkIsFormCleared({tel: "+1111111111"}); +}); + +add_task(async function clear_distinct_section() { + if (!(await canTestOSKeyStoreLogin())) { + todo(false, "Cannot test OS key store login on official builds."); + return; + } + + document.getElementById("form1").reset(); + await triggerPopupAndHoverItem("#cc-name", 0); + let osKeyStoreLoginShown = waitForOSKeyStoreLogin(true); + await triggerAutofillAndCheckProfile(MOCK_CC_STORAGE[0]); + await osKeyStoreLoginShown; + + await triggerPopupAndHoverItem("#organization", 0); + await triggerAutofillAndCheckProfile(MOCK_ADDR_STORAGE[0]); + await triggerPopupAndHoverItem("#street-address", 0); + await confirmClear("#street-address"); + + for (const [id, val] of Object.entries(MOCK_CC_STORAGE[0])) { + const element = document.getElementById(id); + if (!element) { + return; + } + checkFieldValue(element, val); + await checkFieldHighlighted(element, true); + } + + await triggerPopupAndHoverItem("#cc-name", 0); + await confirmClear("#cc-name"); + await checkIsFormCleared(); +}); + +</script> + +<p id="display"></p> + +<div id="content"> + + <form id="form1"> + <p>This is a basic form.</p> + <p><label>organization: <input id="organization" autocomplete="organization"></label></p> + <p><label>streetAddress: <input id="street-address" autocomplete="street-address"></label></p> + <p><label>tel: <input id="tel" autocomplete="tel"></label></p> + <p><label>country: <input id="country" autocomplete="country"></label></p> + + <p><label>Name: <input id="cc-name" autocomplete="cc-name"></label></p> + <p><label>Card Number: <input id="cc-number" autocomplete="cc-number"></label></p> + <p><label>Expiration month: <input id="cc-exp-month" autocomplete="cc-exp-month"></label></p> + <p><label>Expiration year: <input id="cc-exp-year" autocomplete="cc-exp-year"></label></p> + <p><label>CSC: <input id="cc-csc" autocomplete="cc-csc"></label></p> + </form> + +</div> + +<pre id="test"></pre> +</body> +</html> diff --git a/browser/extensions/formautofill/test/mochitest/creditCard/test_creditcard_autocomplete_off.html b/browser/extensions/formautofill/test/mochitest/creditCard/test_creditcard_autocomplete_off.html new file mode 100644 index 0000000000..225d828ccb --- /dev/null +++ b/browser/extensions/formautofill/test/mochitest/creditCard/test_creditcard_autocomplete_off.html @@ -0,0 +1,96 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Test basic autofill</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/EventUtils.js"></script> + <script type="text/javascript" src="../formautofill_common.js"></script> + <script type="text/javascript" src="../../../../../../toolkit/components/satchel/test/satchel_common.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" /> +</head> +<body> +Form autofill test: simple form credit card autofill + +<script> +"use strict"; + +const MOCK_STORAGE = [{ + "cc-name": "John Doe", + "cc-number": "4929001587121045", + "cc-exp-month": 4, + "cc-exp-year": 2017, +}, { + "cc-name": "Timothy Berners-Lee", + "cc-number": "5103059495477870", + "cc-exp-month": 12, + "cc-exp-year": 2022, +}]; + +async function setupCreditCardStorage() { + await addCreditCard(MOCK_STORAGE[0]); + await addCreditCard(MOCK_STORAGE[1]); +} + +async function setupFormHistory() { + await updateFormHistory([ + {op: "add", fieldname: "cc-name", value: "John Smith"}, + {op: "add", fieldname: "cc-number", value: "6011029476355493"}, + ]); +} + +initPopupListener(); + +// Show Form History popup for non-autocomplete="off" field only +add_task(async function history_only_menu_checking() { + await setupFormHistory(); + + await setInput("#cc-number", ""); + synthesizeKey("KEY_ArrowDown"); + await expectPopup(); + checkMenuEntries(["6011029476355493"], false); + + await setInput("#cc-name", ""); + synthesizeKey("KEY_ArrowDown"); + await notExpectPopup(); +}); + +// Show Form Autofill popup for the credit card fields. +add_task(async function check_menu_when_both_with_autocomplete_off() { + await setupCreditCardStorage(); + + await setInput("#cc-number", ""); + synthesizeKey("KEY_ArrowDown"); + await expectPopup(); + checkMenuEntries(MOCK_STORAGE.map(patchRecordCCNumber).map(cc => JSON.stringify({ + primaryAffix: cc.ccNumberFmt.affix, + primary: cc.ccNumberFmt.label, + secondary: cc["cc-name"], + ariaLabel: `${getCCTypeName(cc)} ${cc.ccNumberFmt.affix} ${cc.ccNumberFmt.label} ${cc["cc-name"]}`, + }))); + + await setInput("#cc-name", ""); + synthesizeKey("KEY_ArrowDown"); + await expectPopup(); + checkMenuEntries(MOCK_STORAGE.map(patchRecordCCNumber).map(cc => JSON.stringify({ + primary: cc["cc-name"], + secondary: cc.ccNumberFmt.affix + cc.ccNumberFmt.label, + ariaLabel: `${getCCTypeName(cc)} ${cc["cc-name"]} ${cc.ccNumberFmt.affix}${cc.ccNumberFmt.label}`, + }))); +}); + +</script> + +<p id="display"></p> + +<div id="content"> + <form id="form1"> + <p>This is a Credit Card form with autocomplete="off" cc-name field.</p> + <p><label>Name: <input id="cc-name" autocomplete="off"></label></p> + <p><label>Card Number: <input id="cc-number" autocomplete="cc-number"></label></p> + </form> +</div> + +<pre id="test"></pre> +</body> +</html> diff --git a/browser/extensions/formautofill/test/mochitest/formautofill_common.js b/browser/extensions/formautofill/test/mochitest/formautofill_common.js new file mode 100644 index 0000000000..dffe5451b4 --- /dev/null +++ b/browser/extensions/formautofill/test/mochitest/formautofill_common.js @@ -0,0 +1,452 @@ +/* import-globals-from ../../../../../testing/mochitest/tests/SimpleTest/SimpleTest.js */ +/* import-globals-from ../../../../../testing/mochitest/tests/SimpleTest/EventUtils.js */ +/* import-globals-from ../../../../../toolkit/components/satchel/test/satchel_common.js */ +/* eslint-disable no-unused-vars */ + +"use strict"; + +let formFillChromeScript; +let defaultTextColor; +let expectingPopup = null; + +const { FormAutofillUtils } = SpecialPowers.Cu.import( + "resource://formautofill/FormAutofillUtils.jsm" +); + +async function sleep(ms = 500, reason = "Intentionally wait for UI ready") { + SimpleTest.requestFlakyTimeout(reason); + await new Promise(resolve => setTimeout(resolve, ms)); +} + +async function focusAndWaitForFieldsIdentified( + input, + mustBeIdentified = false +) { + info("expecting the target input being focused and indentified"); + if (typeof input === "string") { + input = document.querySelector(input); + } + const rootElement = input.form || input.ownerDocument.documentElement; + const previouslyFocused = input != document.activeElement; + + input.focus(); + + if (mustBeIdentified) { + rootElement.removeAttribute("test-formautofill-identified"); + } + if (rootElement.hasAttribute("test-formautofill-identified")) { + return; + } + if (!previouslyFocused) { + await new Promise(resolve => { + formFillChromeScript.addMessageListener( + "FormAutofillTest:FieldsIdentified", + function onIdentified() { + formFillChromeScript.removeMessageListener( + "FormAutofillTest:FieldsIdentified", + onIdentified + ); + resolve(); + } + ); + }); + } + // In order to ensure that "markAsAutofillField" is fully executed, a short period + // of timeout is still required. + await sleep(300, "Guarantee asynchronous identifyAutofillFields is invoked"); + rootElement.setAttribute("test-formautofill-identified", "true"); +} + +async function setInput(selector, value, userInput = false) { + const input = document.querySelector("input" + selector); + if (userInput) { + SpecialPowers.wrap(input).setUserInput(value); + } else { + input.value = value; + } + await focusAndWaitForFieldsIdentified(input); + + return input; +} + +function clickOnElement(selector) { + let element = document.querySelector(selector); + + if (!element) { + throw new Error("Can not find the element"); + } + + SimpleTest.executeSoon(() => element.click()); +} + +// The equivalent helper function to getAdaptedProfiles in FormAutofillHandler.jsm that +// transforms the given profile to expected filled profile. +function _getAdaptedProfile(profile) { + const adaptedProfile = Object.assign({}, profile); + + if (profile["street-address"]) { + adaptedProfile["street-address"] = FormAutofillUtils.toOneLineAddress( + profile["street-address"] + ); + } + + return adaptedProfile; +} + +// We could not get ManuallyManagedState of element now, so directly check if +// filter and text color style are applied. +async function checkFieldHighlighted(elem, expectedValue) { + let isHighlightApplied; + await SimpleTest.promiseWaitForCondition(function checkHighlight() { + const computedStyle = window.getComputedStyle(elem); + isHighlightApplied = computedStyle.getPropertyValue("filter") !== "none"; + return isHighlightApplied === expectedValue; + }, `Checking #${elem.id} highlight style`); + + is(isHighlightApplied, expectedValue, `Checking #${elem.id} highlight style`); +} + +async function checkFieldPreview(elem, expectedValue) { + is( + SpecialPowers.wrap(elem).previewValue, + expectedValue, + `Checking #${elem.id} previewValue` + ); + let isTextColorApplied; + await SimpleTest.promiseWaitForCondition(function checkPreview() { + const computedStyle = window.getComputedStyle(elem); + isTextColorApplied = + computedStyle.getPropertyValue("color") !== defaultTextColor; + return isTextColorApplied === !!expectedValue; + }, `Checking #${elem.id} preview style`); + + is(isTextColorApplied, !!expectedValue, `Checking #${elem.id} preview style`); +} + +function checkFieldValue(elem, expectedValue) { + if (typeof elem === "string") { + elem = document.querySelector(elem); + } + is(elem.value, String(expectedValue), "Checking " + elem.id + " field"); +} + +async function triggerAutofillAndCheckProfile(profile) { + const adaptedProfile = _getAdaptedProfile(profile); + const promises = []; + + await SpecialPowers.pushPrefEnv({ + set: [["dom.input_events.beforeinput.enabled", true]], + }); + + for (const [fieldName, value] of Object.entries(adaptedProfile)) { + info(`triggerAutofillAndCheckProfile: ${fieldName}`); + const element = document.getElementById(fieldName); + const expectingEvent = + document.activeElement == element ? "input" : "change"; + const checkFieldAutofilled = Promise.all([ + new Promise(resolve => { + let beforeInputFired = false; + let hadEditor = SpecialPowers.wrap(element).hasEditor; + element.addEventListener( + "beforeinput", + event => { + beforeInputFired = true; + is( + event.inputType, + "insertReplacementText", + 'inputType value should be "insertReplacementText"' + ); + is( + event.data, + String(value), + `data value of "beforeinput" should be "${value}"` + ); + is( + event.dataTransfer, + null, + 'dataTransfer of "beforeinput" should be null' + ); + is( + event.getTargetRanges().length, + 0, + 'getTargetRanges() of "beforeinput" should return empty array' + ); + is( + event.cancelable, + SpecialPowers.getBoolPref( + "dom.input_event.allow_to_cancel_set_user_input" + ), + `"beforeinput" event should be cancelable on ${element.tagName} unless it's suppressed by the pref` + ); + is( + event.bubbles, + true, + `"beforeinput" event should always bubble on ${element.tagName}` + ); + resolve(); + }, + { once: true } + ); + element.addEventListener( + "input", + event => { + if (element.tagName == "INPUT" && element.type == "text") { + if (hadEditor) { + ok( + beforeInputFired, + `"beforeinput" event should've been fired before "input" event on ${element.tagName}` + ); + } else { + ok( + beforeInputFired, + `"beforeinput" event should've been fired before "input" event on ${element.tagName}` + ); + } + ok( + event instanceof InputEvent, + `"input" event should be dispatched with InputEvent interface on ${element.tagName}` + ); + is( + event.inputType, + "insertReplacementText", + 'inputType value should be "insertReplacementText"' + ); + is(event.data, String(value), `data value should be "${value}"`); + is(event.dataTransfer, null, "dataTransfer should be null"); + is( + event.getTargetRanges().length, + 0, + "getTargetRanges() should return empty array" + ); + } else { + ok( + !beforeInputFired, + `"beforeinput" event shouldn't be fired on ${element.tagName}` + ); + ok( + event instanceof Event && !(event instanceof UIEvent), + `"input" event should be dispatched with Event interface on ${element.tagName}` + ); + } + is( + event.cancelable, + false, + `"input" event should be never cancelable on ${element.tagName}` + ); + is( + event.bubbles, + true, + `"input" event should always bubble on ${element.tagName}` + ); + resolve(); + }, + { once: true } + ); + }), + new Promise(resolve => + element.addEventListener(expectingEvent, resolve, { once: true }) + ), + ]).then(() => checkFieldValue(element, value)); + + promises.push(checkFieldAutofilled); + } + // Press Enter key and trigger form autofill. + synthesizeKey("KEY_Enter"); + + return Promise.all(promises); +} + +async function onStorageChanged(type) { + info(`expecting the storage changed: ${type}`); + return new Promise(resolve => { + formFillChromeScript.addMessageListener( + "formautofill-storage-changed", + function onChanged(data) { + formFillChromeScript.removeMessageListener( + "formautofill-storage-changed", + onChanged + ); + is(data.data, type, `Receive ${type} storage changed event`); + resolve(); + } + ); + }); +} + +function checkMenuEntries(expectedValues, isFormAutofillResult = true) { + let actualValues = getMenuEntries(); + // Expect one more item would appear at the bottom as the footer if the result is from form autofill. + let expectedLength = isFormAutofillResult + ? expectedValues.length + 1 + : expectedValues.length; + + is(actualValues.length, expectedLength, " Checking length of expected menu"); + for (let i = 0; i < expectedValues.length; i++) { + is(actualValues[i], expectedValues[i], " Checking menu entry #" + i); + } +} + +function invokeAsyncChromeTask(message, payload = {}) { + info(`expecting the chrome task finished: ${message}`); + return formFillChromeScript.sendQuery(message, payload); +} + +async function addAddress(address) { + await invokeAsyncChromeTask("FormAutofillTest:AddAddress", { address }); + await sleep(); +} + +async function removeAddress(guid) { + return invokeAsyncChromeTask("FormAutofillTest:RemoveAddress", { guid }); +} + +async function updateAddress(guid, address) { + return invokeAsyncChromeTask("FormAutofillTest:UpdateAddress", { + address, + guid, + }); +} + +async function checkAddresses(expectedAddresses) { + return invokeAsyncChromeTask("FormAutofillTest:CheckAddresses", { + expectedAddresses, + }); +} + +async function cleanUpAddresses() { + return invokeAsyncChromeTask("FormAutofillTest:CleanUpAddresses"); +} + +async function addCreditCard(creditcard) { + await invokeAsyncChromeTask("FormAutofillTest:AddCreditCard", { creditcard }); + await sleep(); +} + +async function removeCreditCard(guid) { + return invokeAsyncChromeTask("FormAutofillTest:RemoveCreditCard", { guid }); +} + +async function checkCreditCards(expectedCreditCards) { + return invokeAsyncChromeTask("FormAutofillTest:CheckCreditCards", { + expectedCreditCards, + }); +} + +async function cleanUpCreditCards() { + return invokeAsyncChromeTask("FormAutofillTest:CleanUpCreditCards"); +} + +async function cleanUpStorage() { + await cleanUpAddresses(); + await cleanUpCreditCards(); +} + +async function canTestOSKeyStoreLogin() { + let { canTest } = await invokeAsyncChromeTask( + "FormAutofillTest:CanTestOSKeyStoreLogin" + ); + return canTest; +} + +async function waitForOSKeyStoreLogin(login = false) { + await invokeAsyncChromeTask("FormAutofillTest:OSKeyStoreLogin", { login }); +} + +function patchRecordCCNumber(record) { + const number = record["cc-number"]; + const ccNumberFmt = { + affix: "****", + label: number.substr(-4), + }; + + return Object.assign({}, record, { ccNumberFmt }); +} + +// Utils for registerPopupShownListener(in satchel_common.js) that handles dropdown popup +// Please call "initPopupListener()" in your test and "await expectPopup()" +// if you want to wait for dropdown menu displayed. +function expectPopup() { + info("expecting a popup"); + return new Promise(resolve => { + expectingPopup = resolve; + }); +} + +function notExpectPopup(ms = 500) { + info("not expecting a popup"); + return new Promise((resolve, reject) => { + expectingPopup = reject.bind(this, "Unexpected Popup"); + // TODO: We don't have an event to notify no popup showing, so wait for 500 + // ms (in default) to predict any unexpected popup showing. + setTimeout(resolve, ms); + }); +} + +function popupShownListener() { + info("popup shown for test "); + if (expectingPopup) { + expectingPopup(); + expectingPopup = null; + } +} + +function initPopupListener() { + registerPopupShownListener(popupShownListener); +} + +async function triggerPopupAndHoverItem(fieldSelector, selectIndex) { + await focusAndWaitForFieldsIdentified(fieldSelector); + synthesizeKey("KEY_ArrowDown"); + await expectPopup(); + for (let i = 0; i <= selectIndex; i++) { + synthesizeKey("KEY_ArrowDown"); + } + await notifySelectedIndex(selectIndex); +} + +function formAutoFillCommonSetup() { + // Remove the /creditCard path segement when referenced from the 'creditCard' subdirectory. + let chromeURL = SimpleTest.getTestFileURL( + "formautofill_parent_utils.js" + ).replace(/\/creditCard/, ""); + formFillChromeScript = SpecialPowers.loadChromeScript(chromeURL); + formFillChromeScript.addMessageListener("onpopupshown", ({ results }) => { + gLastAutoCompleteResults = results; + if (gPopupShownListener) { + gPopupShownListener({ results }); + } + }); + + add_task(async function setup() { + info(`expecting the storage setup`); + await formFillChromeScript.sendQuery("setup"); + }); + + SimpleTest.registerCleanupFunction(async () => { + info(`expecting the storage cleanup`); + await formFillChromeScript.sendQuery("cleanup"); + + formFillChromeScript.destroy(); + expectingPopup = null; + }); + + document.addEventListener( + "DOMContentLoaded", + function() { + defaultTextColor = window + .getComputedStyle(document.querySelector("input")) + .getPropertyValue("color"); + }, + { once: true } + ); +} + +/* + * Extremely over-simplified detection of card type from card number just for + * our tests. This is needed to test the aria-label of credit card menu entries. + */ +function getCCTypeName(creditCard) { + return creditCard["cc-number"][0] == "4" ? "Visa" : "MasterCard"; +} + +formAutoFillCommonSetup(); diff --git a/browser/extensions/formautofill/test/mochitest/formautofill_parent_utils.js b/browser/extensions/formautofill/test/mochitest/formautofill_parent_utils.js new file mode 100644 index 0000000000..3323738085 --- /dev/null +++ b/browser/extensions/formautofill/test/mochitest/formautofill_parent_utils.js @@ -0,0 +1,309 @@ +// assert is available to chrome scripts loaded via SpecialPowers.loadChromeScript. +/* global assert */ +/* eslint-env mozilla/frame-script */ + +"use strict"; + +const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); +const { FormAutofill } = ChromeUtils.import( + "resource://formautofill/FormAutofill.jsm" +); +const { FormAutofillUtils } = ChromeUtils.import( + "resource://formautofill/FormAutofillUtils.jsm" +); +const { OSKeyStoreTestUtils } = ChromeUtils.import( + "resource://testing-common/OSKeyStoreTestUtils.jsm" +); + +let { formAutofillStorage } = ChromeUtils.import( + "resource://formautofill/FormAutofillStorage.jsm" +); + +const { + ADDRESSES_COLLECTION_NAME, + CREDITCARDS_COLLECTION_NAME, +} = FormAutofillUtils; + +let destroyed = false; + +var ParentUtils = { + getFormAutofillActor() { + let win = Services.wm.getMostRecentWindow("navigator:browser"); + let selectedBrowser = win.gBrowser.selectedBrowser; + return selectedBrowser.browsingContext.currentWindowGlobal.getActor( + "FormAutofill" + ); + }, + + _getRecords(collectionName) { + return this.getFormAutofillActor().receiveMessage({ + name: "FormAutofill:GetRecords", + data: { + searchString: "", + collectionName, + }, + }); + }, + + async _storageChangeObserved({ + topic = "formautofill-storage-changed", + type, + times = 1, + }) { + let count = times; + + return new Promise(resolve => { + Services.obs.addObserver(function observer(subject, obsTopic, data) { + if ((type && data != type) || !!--count) { + return; + } + + // every notification type should have the collection name. + // We're not allowed to trigger assertions during mochitest + // cleanup functions. + if (!destroyed) { + let allowedNames = [ + ADDRESSES_COLLECTION_NAME, + CREDITCARDS_COLLECTION_NAME, + ]; + assert.ok( + allowedNames.includes(subject.wrappedJSObject.collectionName), + "should include the collection name" + ); + // every notification except removeAll should have a guid. + if (data != "removeAll") { + assert.ok(subject.wrappedJSObject.guid, "should have a guid"); + } + } + Services.obs.removeObserver(observer, obsTopic); + resolve(); + }, topic); + }); + }, + + async _operateRecord(collectionName, type, msgData) { + let msgName, times, topic; + + if (collectionName == ADDRESSES_COLLECTION_NAME) { + switch (type) { + case "add": { + msgName = "FormAutofill:SaveAddress"; + break; + } + case "update": { + msgName = "FormAutofill:SaveAddress"; + break; + } + case "remove": { + msgName = "FormAutofill:RemoveAddresses"; + times = msgData.guids.length; + break; + } + default: + return; + } + } else { + switch (type) { + case "add": { + msgData = Object.assign({}, msgData); + msgName = "FormAutofill:SaveCreditCard"; + break; + } + case "remove": { + msgName = "FormAutofill:RemoveCreditCards"; + times = msgData.guids.length; + break; + } + default: + return; + } + } + + let storageChangePromise = this._storageChangeObserved({ + type, + times, + topic, + }); + this.getFormAutofillActor().receiveMessage({ + name: msgName, + data: msgData, + }); + await storageChangePromise; + }, + + async operateAddress(type, msgData) { + await this._operateRecord(ADDRESSES_COLLECTION_NAME, ...arguments); + }, + + async operateCreditCard(type, msgData) { + await this._operateRecord(CREDITCARDS_COLLECTION_NAME, ...arguments); + }, + + async cleanUpAddresses() { + const guids = (await this._getRecords(ADDRESSES_COLLECTION_NAME)).map( + record => record.guid + ); + + if (!guids.length) { + return; + } + + await this.operateAddress( + "remove", + { guids }, + "FormAutofillTest:AddressesCleanedUp" + ); + }, + + async cleanUpCreditCards() { + if (!FormAutofill.isAutofillCreditCardsAvailable) { + return; + } + const guids = (await this._getRecords(CREDITCARDS_COLLECTION_NAME)).map( + record => record.guid + ); + + if (!guids.length) { + return; + } + + await this.operateCreditCard( + "remove", + { guids }, + "FormAutofillTest:CreditCardsCleanedUp" + ); + }, + + setup() { + OSKeyStoreTestUtils.setup(); + }, + + async cleanup() { + await this.cleanUpAddresses(); + await this.cleanUpCreditCards(); + await OSKeyStoreTestUtils.cleanup(); + + Services.obs.removeObserver(this, "formautofill-storage-changed"); + }, + + _areRecordsMatching(recordA, recordB, collectionName) { + for (let field of formAutofillStorage[collectionName].VALID_FIELDS) { + if (recordA[field] !== recordB[field]) { + return false; + } + } + // Check the internal field if both addresses have valid value. + for (let field of formAutofillStorage.INTERNAL_FIELDS) { + if ( + field in recordA && + field in recordB && + recordA[field] !== recordB[field] + ) { + return false; + } + } + return true; + }, + + async _checkRecords(collectionName, expectedRecords) { + const records = await this._getRecords(collectionName); + + if (records.length !== expectedRecords.length) { + return false; + } + + for (let record of records) { + let matching = expectedRecords.some(expectedRecord => { + return ParentUtils._areRecordsMatching( + record, + expectedRecord, + collectionName + ); + }); + + if (!matching) { + return false; + } + } + + return true; + }, + + async checkAddresses({ expectedAddresses }) { + return this._checkRecords(ADDRESSES_COLLECTION_NAME, expectedAddresses); + }, + + async checkCreditCards({ expectedCreditCards }) { + return this._checkRecords(CREDITCARDS_COLLECTION_NAME, expectedCreditCards); + }, + + observe(subject, topic, data) { + if (!destroyed) { + assert.ok(topic === "formautofill-storage-changed"); + } + sendAsyncMessage("formautofill-storage-changed", { + subject: null, + topic, + data, + }); + }, +}; + +Services.obs.addObserver(ParentUtils, "formautofill-storage-changed"); + +Services.mm.addMessageListener("FormAutofill:FieldsIdentified", () => { + return null; +}); + +addMessageListener("FormAutofillTest:AddAddress", msg => { + return ParentUtils.operateAddress("add", msg); +}); + +addMessageListener("FormAutofillTest:RemoveAddress", msg => { + return ParentUtils.operateAddress("remove", msg); +}); + +addMessageListener("FormAutofillTest:UpdateAddress", msg => { + return ParentUtils.operateAddress("update", msg); +}); + +addMessageListener("FormAutofillTest:CheckAddresses", msg => { + return ParentUtils.checkAddresses(msg); +}); + +addMessageListener("FormAutofillTest:CleanUpAddresses", msg => { + return ParentUtils.cleanUpAddresses(); +}); + +addMessageListener("FormAutofillTest:AddCreditCard", msg => { + return ParentUtils.operateCreditCard("add", msg); +}); + +addMessageListener("FormAutofillTest:RemoveCreditCard", msg => { + return ParentUtils.operateCreditCard("remove", msg); +}); + +addMessageListener("FormAutofillTest:CheckCreditCards", msg => { + return ParentUtils.checkCreditCards(msg); +}); + +addMessageListener("FormAutofillTest:CleanUpCreditCards", msg => { + return ParentUtils.cleanUpCreditCards(); +}); + +addMessageListener("FormAutofillTest:CanTestOSKeyStoreLogin", msg => { + return { canTest: OSKeyStoreTestUtils.canTestOSKeyStoreLogin() }; +}); + +addMessageListener("FormAutofillTest:OSKeyStoreLogin", async msg => { + await OSKeyStoreTestUtils.waitForOSKeyStoreLogin(msg.login); +}); + +addMessageListener("setup", async () => { + ParentUtils.setup(); +}); + +addMessageListener("cleanup", async () => { + destroyed = true; + await ParentUtils.cleanup(); +}); diff --git a/browser/extensions/formautofill/test/mochitest/mochitest.ini b/browser/extensions/formautofill/test/mochitest/mochitest.ini new file mode 100644 index 0000000000..1dd9e51cef --- /dev/null +++ b/browser/extensions/formautofill/test/mochitest/mochitest.ini @@ -0,0 +1,19 @@ +[DEFAULT] +support-files = + ../../../../../toolkit/components/satchel/test/satchel_common.js + ../../../../../toolkit/components/satchel/test/parent_utils.js + formautofill_common.js + formautofill_parent_utils.js + +[test_address_level_1_submission.html] +[test_autofill_and_ordinal_forms.html] +[test_autofocus_form.html] +skip-if = verify +[test_basic_autocomplete_form.html] +skip-if = verify || (os == "win" && debug && bits == 32) || (os == "mac" && debug) #Bug 1454211 +[test_form_changes.html] +[test_formautofill_preview_highlight.html] +skip-if = verify || (!debug && os == "mac") # perma-fail see Bug 1600059 +[test_multi_locale_CA_address_form.html] +[test_multiple_forms.html] +[test_on_address_submission.html] diff --git a/browser/extensions/formautofill/test/mochitest/test_address_level_1_submission.html b/browser/extensions/formautofill/test/mochitest/test_address_level_1_submission.html new file mode 100644 index 0000000000..0c16b91d3a --- /dev/null +++ b/browser/extensions/formautofill/test/mochitest/test_address_level_1_submission.html @@ -0,0 +1,101 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Test autofill submission for a country without address-level1</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/EventUtils.js"></script> + <script type="text/javascript" src="formautofill_common.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 autofill test: Test autofill submission for a country without address-level1 + +<script> +/* import-globals-from ../../../../../toolkit/components/satchel/test/satchel_common.js */ + +"use strict"; + +const TEST_ADDRESSES = [{ + organization: "Mozilla", + "street-address": "123 Sesame Street", + "address-level1": "AL", + country: "DE", + timesUsed: 1, +}]; + +add_task(async function test_DE_is_valid_testcase() { + await SpecialPowers.pushPrefEnv({ + set: [ + ["extensions.formautofill.addresses.enabled", true], + ["extensions.formautofill.addresses.capture.enabled", true], + ["extensions.formautofill.supportedCountries", "US,CA,DE"], + ], + }); + let chromeScript = SpecialPowers.loadChromeScript(function test_country_data() { + const {AddressDataLoader} = ChromeUtils.import("resource://formautofill/FormAutofillUtils.jsm"); + let data = AddressDataLoader.getData("DE"); + /* global addMessageListener */ + addMessageListener("CheckSubKeys", () => { + return !data.defaultLocale.sub_keys; + }); + }); + + SimpleTest.registerCleanupFunction(() => { + chromeScript.destroy(); + }); + + let result = await chromeScript.sendQuery("CheckSubKeys"); + ok(result, "Check that there are no sub_keys for the test country"); +}); + +add_task(async function test_form_will_submit_without_sub_keys() { + await SpecialPowers.pushPrefEnv({ + set: [ + // This needs to match the country in the previous test and must have no sub_keys. + ["browser.search.region", "DE"], + // We already verified the first time use case in browser test + ["extensions.formautofill.firstTimeUse", false], + ], + }); + + // Click a field to get the form handler created + await focusAndWaitForFieldsIdentified("input[autocomplete='organization']"); + + let loadPromise = new Promise(resolve => { + /* eslint-disable-next-line mozilla/balanced-listeners */ + document.getElementById("submit_frame").addEventListener("load", resolve); + }); + + clickOnElement("input[type=submit]"); + + await onStorageChanged("add"); + // Check if timesUsed is set correctly + let matching = await checkAddresses(TEST_ADDRESSES); + ok(matching, "Address saved as expected"); + + await loadPromise; + isnot(window.submit_frame.location.href, "about:blank", "Check form submitted"); +}); + +</script> + +<div> + <!-- Submit to the frame so that the test doesn't get replaced. We don't return + -- false in onsubmit since we're testing the submission succeeds. --> + <iframe id="submit_frame" name="submit_frame"></iframe> + <form action="/" target="submit_frame" method="POST"> + <p><label>organization: <input autocomplete="organization" value="Mozilla"></label></p> + <p><label>streetAddress: <input autocomplete="street-address" value="123 Sesame Street"></label></p> + <p><label>address-level1: <select autocomplete="address-level1"> + <option selected>AL</option> + <option>AK</option> + </select></label></p> + <p><label>country: <input autocomplete="country" value="DE"></label></p> + <p><input type="submit"></p> + </form> + +</div> +</body> +</html> diff --git a/browser/extensions/formautofill/test/mochitest/test_autofill_and_ordinal_forms.html b/browser/extensions/formautofill/test/mochitest/test_autofill_and_ordinal_forms.html new file mode 100644 index 0000000000..5c143c3f4a --- /dev/null +++ b/browser/extensions/formautofill/test/mochitest/test_autofill_and_ordinal_forms.html @@ -0,0 +1,116 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Test autofill submit</title> + <script src="/tests/SimpleTest/EventUtils.js"></script> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script type="text/javascript" src="formautofill_common.js"></script> + <script type="text/javascript" src="satchel_common.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" /> +</head> +<body> + +<script> +/* import-globals-from ../../../../../toolkit/components/satchel/test/satchel_common.js */ + +"use strict"; + +SpecialPowers.pushPrefEnv({"set": [["security.allow_eval_with_system_principal", true]]}); + +let MOCK_STORAGE = [{ + "given-name": "John", + "additional-name": "R", + "family-name": "Smith", + "organization": "Sesame Street", + "street-address": "123 Sesame Street.", + "tel": "+13453453456", + "country": "US", + "address-level1": "NY", +}]; + +initPopupListener(); + +add_task(async function setupStorage() { + await addAddress(MOCK_STORAGE[0]); + + await updateFormHistory([ + {op: "add", fieldname: "username", value: "petya"}, + {op: "add", fieldname: "current-password", value: "abrh#25_,K"}, + ]); +}); + +add_task(async function check_switch_autofill_form_popup() { + await setInput("#tel", ""); + synthesizeKey("KEY_ArrowDown"); + await expectPopup(); + checkMenuEntries( + [ + `{"primary":"+13453453456","secondary":"123 Sesame Street."}`, + `{"primary":"","secondary":"","categories":["name","organization","address","tel"],"focusedCategory":"tel"}`, + ], + false + ); + + await testMenuEntry(0, "!(el instanceof MozElements.MozAutocompleteRichlistitem)"); +}); + +add_task(async function check_switch_oridnal_form_popup() { + // We need an intentional wait here before switching form. + await sleep(); + await setInput("#username", ""); + synthesizeKey("KEY_ArrowDown"); + await expectPopup(); + checkMenuEntries(["petya"], false); + + await testMenuEntry(0, "el instanceof MozElements.MozAutocompleteRichlistitem"); +}); + +add_task(async function check_switch_autofill_form_popup_back() { + // We need an intentional wait here before switching form. + await sleep(); + await setInput("#tel", ""); + synthesizeKey("KEY_ArrowDown"); + await expectPopup(); + checkMenuEntries( + [ + `{"primary":"+13453453456","secondary":"123 Sesame Street."}`, + `{"primary":"","secondary":"","categories":["name","organization","address","tel"],"focusedCategory":"tel"}`, + ], + false + ); + + await testMenuEntry(0, "!(el instanceof MozElements.MozAutocompleteRichlistitem)"); +}); + +</script> + +<div> + + <h2>Address form</h2> + <form class="alignedLabels"> + <label>given-name: <input autocomplete="given-name" autofocus></label> + <label>additional-name: <input id="additional-name" autocomplete="additional-name"></label> + <label>family-name: <input autocomplete="family-name"></label> + <label>organization: <input autocomplete="organization"></label> + <label>street-address: <input autocomplete="street-address"></label> + <label>address-level1: <input autocomplete="address-level1"></label> + <label>postal-code: <input autocomplete="postal-code"></label> + <label>country: <input autocomplete="country"></label> + <label>country-name: <input autocomplete="country-name"></label> + <label>tel: <input id="tel" autocomplete="tel"></label> + <p> + <input type="submit" value="Submit"> + <button type="reset">Reset</button> + </p> + </form> + + <h2>Ordinal form</h2> + <form class="alignedLabels"> + <label>username: <input id="username" autocomplete="username"></label> + <p><input type="submit" value="Username"></p> + </form> + +</div> +</body> +</html> diff --git a/browser/extensions/formautofill/test/mochitest/test_autofocus_form.html b/browser/extensions/formautofill/test/mochitest/test_autofocus_form.html new file mode 100644 index 0000000000..e2240474c8 --- /dev/null +++ b/browser/extensions/formautofill/test/mochitest/test_autofocus_form.html @@ -0,0 +1,69 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Test basic autofill</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/EventUtils.js"></script> + <script type="text/javascript" src="formautofill_common.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 autofill test: autocomplete on an autofocus form + +<script> +/* import-globals-from ../../../../../toolkit/components/satchel/test/satchel_common.js */ + +"use strict"; + +let MOCK_STORAGE = [{ + organization: "Sesame Street", + "street-address": "123 Sesame Street.", + tel: "1-345-345-3456", +}, { + organization: "Mozilla", + "street-address": "331 E. Evelyn Avenue", + tel: "1-650-903-0800", +}]; + +initPopupListener(); + +async function setupAddressStorage() { + await addAddress(MOCK_STORAGE[0]); + await addAddress(MOCK_STORAGE[1]); +} + +add_task(async function check_autocomplete_on_autofocus_field() { + await setupAddressStorage(); + synthesizeKey("KEY_ArrowDown"); + await expectPopup(); + checkMenuEntries(MOCK_STORAGE.map(address => + JSON.stringify({primary: address.organization, secondary: address["street-address"]}) + )); +}); + +</script> + +<p id="display"></p> + +<div id="content"> + + <form id="form1"> + <p>This is a basic form.</p> + <p><label>organization: <input id="organization" name="organization" autocomplete="organization" type="text"></label></p> + <script> + "use strict"; + // Focuses the input before DOMContentLoaded + document.getElementById("organization").focus(); + </script> + <p><label>streetAddress: <input id="street-address" name="street-address" autocomplete="street-address" type="text"></label></p> + <p><label>tel: <input id="tel" name="tel" autocomplete="tel" type="text"></label></p> + <p><label>country: <input id="country" name="country" autocomplete="country" type="text"></label></p> + </form> + +</div> + +<pre id="test"></pre> +</body> +</html> diff --git a/browser/extensions/formautofill/test/mochitest/test_basic_autocomplete_form.html b/browser/extensions/formautofill/test/mochitest/test_basic_autocomplete_form.html new file mode 100644 index 0000000000..a642b2abca --- /dev/null +++ b/browser/extensions/formautofill/test/mochitest/test_basic_autocomplete_form.html @@ -0,0 +1,220 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Test basic autofill</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/EventUtils.js"></script> + <script type="text/javascript" src="formautofill_common.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 autofill test: simple form address autofill + +<script> +/* import-globals-from ../../../../../toolkit/components/satchel/test/satchel_common.js */ + +"use strict"; + +let MOCK_STORAGE = [{ + organization: "Sesame Street", + "street-address": "123 Sesame Street.\n2-line\n3-line", + tel: "+13453453456", + country: "US", + "address-level1": "NY", +}, { + organization: "Mozilla", + "street-address": "331 E. Evelyn Avenue\n2-line\n3-line", + tel: "+16509030800", + country: "US", + "address-level1": "CA", +}]; + +async function setupAddressStorage() { + await addAddress(MOCK_STORAGE[0]); + await addAddress(MOCK_STORAGE[1]); +} + +async function setupFormHistory() { + await updateFormHistory([ + {op: "add", fieldname: "tel", value: "+1234567890"}, + {op: "add", fieldname: "email", value: "foo@mozilla.com"}, + ]); +} + +initPopupListener(); + +// Form with history only. +add_task(async function history_only_menu_checking() { + await setupFormHistory(); + + await setInput("#tel", ""); + await notExpectPopup(); + synthesizeKey("KEY_ArrowDown"); + await expectPopup(); + checkMenuEntries(["+1234567890"], false); +}); + +// Display history search result if less than 3 inputs are covered by all saved +// fields in the storage. +add_task(async function all_saved_fields_less_than_threshold() { + await addAddress({ + email: "test@test.com", + }); + + await setInput("#email", ""); + await notExpectPopup(); + synthesizeKey("KEY_ArrowDown"); + await expectPopup(); + checkMenuEntries(["foo@mozilla.com"], false); + + await cleanUpAddresses(); +}); + +// Form with both history and address storage. +add_task(async function check_menu_when_both_existed() { + await setupAddressStorage(); + + await setInput("#organization", ""); + await notExpectPopup(); + synthesizeKey("KEY_ArrowDown"); + await expectPopup(); + checkMenuEntries(MOCK_STORAGE.map(address => + JSON.stringify({ + primary: address.organization, + secondary: FormAutofillUtils.toOneLineAddress(address["street-address"]), + }) + )); + + await setInput("#street-address", ""); + await notExpectPopup(); + synthesizeKey("KEY_ArrowDown"); + await expectPopup(); + checkMenuEntries(MOCK_STORAGE.map(address => + JSON.stringify({ + primary: FormAutofillUtils.toOneLineAddress(address["street-address"]), + secondary: address.organization, + }) + )); + + await setInput("#tel", ""); + await notExpectPopup(); + synthesizeKey("KEY_ArrowDown"); + await expectPopup(); + checkMenuEntries(MOCK_STORAGE.map(address => + JSON.stringify({ + primary: address.tel, + secondary: FormAutofillUtils.toOneLineAddress(address["street-address"]), + }) + )); + + await setInput("#address-line1", ""); + await notExpectPopup(); + synthesizeKey("KEY_ArrowDown"); + await expectPopup(); + checkMenuEntries(MOCK_STORAGE.map(address => + JSON.stringify({ + primary: FormAutofillUtils.toOneLineAddress(address["street-address"]), + secondary: address.organization, + }) + )); +}); + +// Display history search result if no matched data in addresses. +add_task(async function check_fallback_for_mismatched_field() { + await setInput("#email", ""); + await notExpectPopup(); + synthesizeKey("KEY_ArrowDown"); + await expectPopup(); + checkMenuEntries(["foo@mozilla.com"], false); +}); + +// Display history search result if address autofill is disabled. +add_task(async function check_search_result_for_pref_off() { + await SpecialPowers.pushPrefEnv({ + set: [["extensions.formautofill.addresses.enabled", false]], + }); + + await setInput("#tel", ""); + await notExpectPopup(); + synthesizeKey("KEY_ArrowDown"); + await expectPopup(); + checkMenuEntries(["+1234567890"], false); + + await SpecialPowers.popPrefEnv(); +}); + +// Autofill the address from dropdown menu. +add_task(async function check_fields_after_form_autofill() { + const focusedInput = await setInput("#organization", "Moz"); + await notExpectPopup(); + synthesizeKey("KEY_ArrowDown"); + await expectPopup(); + checkMenuEntries(MOCK_STORAGE.map(address => + JSON.stringify({ + primary: address.organization, + secondary: FormAutofillUtils.toOneLineAddress(address["street-address"]), + }) + ).slice(1)); + synthesizeKey("KEY_ArrowDown"); + await triggerAutofillAndCheckProfile(MOCK_STORAGE[1]); + synthesizeKey("KEY_Escape"); + is(focusedInput.value, "Mozilla", "Filled field shouldn't be reverted by ESC key"); +}); + +// Fallback to history search after autofill address. +add_task(async function check_fallback_after_form_autofill() { + await setInput("#tel", "", true); + await triggerPopupAndHoverItem("#tel", 0); + checkMenuEntries(["+1234567890"], false); + await triggerAutofillAndCheckProfile({ + tel: "+1234567890", + }); +}); + +// Resume form autofill once all the autofilled fileds are changed. +add_task(async function check_form_autofill_resume() { + document.querySelector("#tel").blur(); + document.querySelector("#form1").reset(); + await setInput("#tel", ""); + await triggerPopupAndHoverItem("#tel", 0); + checkMenuEntries(MOCK_STORAGE.map(address => + JSON.stringify({ + primary: address.tel, + secondary: FormAutofillUtils.toOneLineAddress(address["street-address"]), + }) + )); + await triggerAutofillAndCheckProfile(MOCK_STORAGE[0]); +}); + +</script> + +<p id="display"></p> + +<div id="content"> + + <form id="form1"> + <p>This is a basic form.</p> + <p><label>organization: <input id="organization" name="organization" autocomplete="organization" type="text"></label></p> + <p><label>streetAddress: <input id="street-address" name="street-address" autocomplete="street-address" type="text"></label></p> + <p><label>address-line1: <input id="address-line1" name="address-line1" autocomplete="address-line1" type="text"></label></p> + <p><label>tel: <input id="tel" name="tel" autocomplete="tel" type="text"></label></p> + <p><label>email: <input id="email" name="email" autocomplete="email" type="text"></label></p> + <p><label>country: <select id="country" name="country" autocomplete="country"> + <option/> + <option value="US">United States</option> + </select></label></p> + <p><label>states: <select id="address-level1" name="address-level1" autocomplete="address-level1"> + <option/> + <option value="CA">California</option> + <option value="NY">New York</option> + <option value="WA">Washington</option> + </select></label></p> + </form> + +</div> + +<pre id="test"></pre> +</body> +</html> diff --git a/browser/extensions/formautofill/test/mochitest/test_form_changes.html b/browser/extensions/formautofill/test/mochitest/test_form_changes.html new file mode 100644 index 0000000000..f15a3a1f9e --- /dev/null +++ b/browser/extensions/formautofill/test/mochitest/test_form_changes.html @@ -0,0 +1,106 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Test basic autofill</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/EventUtils.js"></script> + <script type="text/javascript" src="formautofill_common.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 autofill test: autocomplete on an autofocus form + +<script> +/* import-globals-from ../../../../../toolkit/components/satchel/test/satchel_common.js */ + +"use strict"; + +let MOCK_STORAGE = [{ + name: "John Doe", + organization: "Sesame Street", + "address-level2": "Austin", + tel: "+13453453456", +}, { + name: "Foo Bar", + organization: "Mozilla", + "address-level2": "San Francisco", + tel: "+16509030800", +}]; + +initPopupListener(); + +async function setupAddressStorage() { + await addAddress(MOCK_STORAGE[0]); + await addAddress(MOCK_STORAGE[1]); +} + +function addInputField(form, className) { + let newElem = document.createElement("input"); + newElem.name = className; + newElem.autocomplete = className; + newElem.type = "text"; + form.appendChild(newElem); +} + +async function checkFormChangeHappened(formId) { + info("expecting form changed"); + await focusAndWaitForFieldsIdentified(`#${formId} input[name=tel]`); + synthesizeKey("KEY_ArrowDown"); + await expectPopup(); + checkMenuEntries(MOCK_STORAGE.map(address => + JSON.stringify({primary: address.tel, secondary: address.name}) + )); + + // This is for checking the changes of element count. + addInputField(document.querySelector(`#${formId}`), "address-level2"); + + await focusAndWaitForFieldsIdentified(`#${formId} input[name=name]`); + synthesizeKey("KEY_ArrowDown"); + await expectPopup(); + checkMenuEntries(MOCK_STORAGE.map(address => + JSON.stringify({primary: address.name, secondary: address["address-level2"]}) + )); + + // This is for checking the changes of element removed and added then. + document.querySelector(`#${formId} input[name=address-level2]`).remove(); + addInputField(document.querySelector(`#${formId}`), "address-level2"); + + await focusAndWaitForFieldsIdentified(`#${formId} input[name=address-level2]`, true); + synthesizeKey("KEY_ArrowDown"); + await expectPopup(); + checkMenuEntries(MOCK_STORAGE.map(address => + JSON.stringify({primary: address["address-level2"], secondary: address.name}) + )); +} + +add_task(async function init_storage() { + await setupAddressStorage(); +}); + +add_task(async function check_change_happened_in_form() { + await checkFormChangeHappened("form1"); +}); + +add_task(async function check_change_happened_in_body() { + await checkFormChangeHappened("form2"); +}); +</script> + +<p id="display"></p> +<div id="content"> + <form id="form1"> + <p><label>organization: <input name="organization" autocomplete="organization" type="text"></label></p> + <p><label>tel: <input name="tel" autocomplete="tel" type="text"></label></p> + <p><label>name: <input name="name" autocomplete="name" type="text"></label></p> + </form> + <div id="form2"> + <p><label>organization: <input name="organization" autocomplete="organization" type="text"></label></p> + <p><label>tel: <input name="tel" autocomplete="tel" type="text"></label></p> + <p><label>name: <input name="name" autocomplete="name" type="text"></label></p> + </div> +</div> +<pre id="test"></pre> +</body> +</html> diff --git a/browser/extensions/formautofill/test/mochitest/test_formautofill_preview_highlight.html b/browser/extensions/formautofill/test/mochitest/test_formautofill_preview_highlight.html new file mode 100644 index 0000000000..22b75209eb --- /dev/null +++ b/browser/extensions/formautofill/test/mochitest/test_formautofill_preview_highlight.html @@ -0,0 +1,102 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Test form autofill - preview and highlight</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/EventUtils.js"></script> + <script type="text/javascript" src="formautofill_common.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 autofill test: preview and highlight + +<script> +/* import-globals-from ../../../../../toolkit/components/satchel/test/satchel_common.js */ + +"use strict"; + +const MOCK_STORAGE = [{ + organization: "Sesame Street", + "street-address": "123 Sesame Street.", + tel: "+13453453456", +}, { + organization: "Mozilla", + "street-address": "331 E. Evelyn Avenue", +}, { + organization: "Tel org", + tel: "+12223334444", +}]; + +async function checkFormFieldsStyle(profile, isPreviewing = true) { + const elems = document.querySelectorAll("input, select"); + + for (const elem of elems) { + const fillableValue = profile && profile[elem.id]; + const previewValue = isPreviewing && fillableValue || ""; + + await checkFieldHighlighted(elem, !!fillableValue); + await checkFieldPreview(elem, previewValue); + } +} + +initPopupListener(); + +add_task(async function setup_storage() { + await addAddress(MOCK_STORAGE[0]); + await addAddress(MOCK_STORAGE[1]); + await addAddress(MOCK_STORAGE[2]); +}); + +add_task(async function check_preview() { + const focusedInput = await setInput("#organization", ""); + + synthesizeKey("KEY_ArrowDown"); + await expectPopup(); + await checkFormFieldsStyle(null); + + for (let i = 0; i < MOCK_STORAGE.length; i++) { + synthesizeKey("KEY_ArrowDown"); + await notifySelectedIndex(i); + await checkFormFieldsStyle(MOCK_STORAGE[i]); + } + + // Navigate to the footer + synthesizeKey("KEY_ArrowDown"); + await notifySelectedIndex(MOCK_STORAGE.length); + await checkFormFieldsStyle(null); + + synthesizeKey("KEY_ArrowDown"); + await notifySelectedIndex(-1); + await checkFormFieldsStyle(null); + + focusedInput.blur(); +}); + +add_task(async function check_filled_highlight() { + await triggerPopupAndHoverItem("#organization", 0); + // filled 1st address + await triggerAutofillAndCheckProfile(MOCK_STORAGE[0]); + await checkFormFieldsStyle(MOCK_STORAGE[0], false); +}); + +</script> + +<p id="display"></p> + +<div id="content"> + + <form id="form1"> + <p>This is a basic form.</p> + <p><label>organization: <input id="organization" autocomplete="organization"></label></p> + <p><label>streetAddress: <input id="street-address" autocomplete="street-address"></label></p> + <p><label>tel: <input id="tel" autocomplete="tel"></label></p> + <p><label>country: <input id="country" autocomplete="country"></label></p> + </form> + +</div> + +<pre id="test"></pre> +</body> +</html> diff --git a/browser/extensions/formautofill/test/mochitest/test_multi_locale_CA_address_form.html b/browser/extensions/formautofill/test/mochitest/test_multi_locale_CA_address_form.html new file mode 100644 index 0000000000..166f2faa94 --- /dev/null +++ b/browser/extensions/formautofill/test/mochitest/test_multi_locale_CA_address_form.html @@ -0,0 +1,274 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Test basic autofill</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/EventUtils.js"></script> + <script type="text/javascript" src="formautofill_common.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 autofill test: simple form address autofill + +<script> +/* import-globals-from ../../../../../toolkit/components/satchel/test/satchel_common.js */ + +"use strict"; + +let MOCK_STORAGE = [{ + organization: "Mozilla Vancouver", + "street-address": "163 W Hastings St.\n#209\n3-line", + tel: "+17787851540", + country: "CA", + "address-level1": "BC", +}, { + organization: "Mozilla Toronto", + "street-address": "366 Adelaide St.\nW Suite 500\n3-line", + tel: "+14168483114", + country: "CA", + "address-level1": "ON", +}, { + organization: "Prince of Wales Northern Heritage", + "street-address": "4750 48 St.\nYellowknife\n3-line", + tel: "+18677679347", + country: "CA", + "address-level1": "Northwest Territories", +}, { + organization: "ExpoCité", + "street-address": "250 Boulevard Wilfrid-Hamel\nVille de Québec\n3-line", + tel: "+14186917110", + country: "CA", + "address-level1": "Québec", +}]; + +function checkElementFilled(element, expectedvalue) { + let focusFired = false; + let inputFired = false; + let changeFired = false; + return [ + new Promise(resolve => { + element.addEventListener("focus", function onChange() { + ok(true, "Checking " + element.name + " field fires focus event"); + focusFired = true; + resolve(); + }, {once: true}); + }), + new Promise(resolve => { + let beforeInputFired = false; + let oldValue = element.value; + element.addEventListener("beforeinput", function onBeforeInput(event) { + ok(true, "Checking " + element.name + " field fires beforeinput event"); + ok(focusFired, "Focus fired before `beforeinput` event"); + beforeInputFired = true; + ok(event instanceof InputEvent, + `"beforeinput" event should be dispatched with InputEvent interface on ${element.name}`); + is(event.inputType, "insertReplacementText", + 'inputType value of "beforeinput" event should be "insertReplacementText"'); + is(event.data, expectedvalue, + 'data value of "beforeinput" event should be same as expected value'); + is(event.dataTransfer, null, + 'dataTransfer value of "beforeinput" event should be null'); + is(event.getTargetRanges().length, 0, + 'getTargetRanges() of "beforeinput" event should return empty array'); + is(event.cancelable, SpecialPowers.getBoolPref("dom.input_event.allow_to_cancel_set_user_input"), + `"beforeinput" event should be cancelable on ${element.name} unless it's suppressed by the pref`); + is(event.bubbles, true, + `"input" event should always bubble on ${element.name}`); + is(element.value, oldValue, + 'value of the element should not be modified at "beforeinput" event yet'); + }, {once: true}); + element.addEventListener("input", function onInput(event) { + ok(true, "Checking " + element.name + " field fires input event"); + if (element.tagName == "INPUT" && element.type == "text") { + ok(beforeInputFired, `"beforeinput" event shoud've been fired on ${element.name} before "input" event`); + ok(event instanceof InputEvent, + `"input" event should be dispatched with InputEvent interface on ${element.name}`); + is(event.inputType, "insertReplacementText", + "inputType value should be \"insertReplacementText\""); + is(event.data, expectedvalue, + "data value should be same as expected value"); + is(event.dataTransfer, null, + "dataTransfer value should be null"); + is(event.getTargetRanges().length, 0, + 'getTargetRanges() should return empty array'); + is(element.value, expectedvalue, + 'value of the element should be modified at "input" event'); + } else { + ok(!beforeInputFired, `"beforeinput" event shoudn't be fired on ${element.name} before "input" event`); + ok(event instanceof Event && !(event instanceof UIEvent), + `"input" event should be dispatched with Event interface on ${element.name}`); + } + is(event.cancelable, false, + `"input" event should be never cancelable on ${element.name}`); + is(event.bubbles, true, + `"input" event should always bubble on ${element.name}`); + inputFired = true; + resolve(); + }, {once: true}); + }), + new Promise(resolve => { + element.addEventListener("change", function onChange() { + ok(true, "Checking " + element.name + " field fires change event"); + is(element.value, expectedvalue, "Checking " + element.name + " field"); + ok(focusFired, "Focus fired before `change` event"); + changeFired = true; + resolve(); + }, {once: true}); + }), + new Promise(resolve => { + element.addEventListener("blur", function onChange() { + ok(true, "Checking " + element.name + " field fires blur event"); + ok(changeFired, "Change fired before `blur` event"); + ok(inputFired, "Input fired before `blur` event"); + is(element.value, expectedvalue, "Checking " + element.name + " field"); + resolve(); + }, {once: true}); + }), + ]; +} + +function checkAutoCompleteInputFilled(element, expectedvalue) { + return new Promise(resolve => { + element.addEventListener("input", function onInput() { + is(element.value, expectedvalue, "Checking " + element.name + " field"); + resolve(); + }, {once: true}); + }); +} + +function checkFormFilled(selector, address) { + info("expecting form filled"); + let promises = []; + let form = document.querySelector(selector); + for (let prop in address) { + let element = form.querySelector(`[name=${prop}]`); + if (document.activeElement == element) { + promises.push(checkAutoCompleteInputFilled(element, address[prop])); + } else { + let converted = address[prop]; + if (prop == "street-address") { + converted = FormAutofillUtils.toOneLineAddress(converted); + } + promises.push(...checkElementFilled(element, converted)); + } + } + synthesizeKey("KEY_Enter"); + return Promise.all(promises); +} + +async function setupAddressStorage() { + for (let address of MOCK_STORAGE) { + await addAddress(address); + } +} + +initPopupListener(); + +add_task(async function setup() { + // This test relies on being able to fill a Canadian address which isn't possible + // without `supportedCountries` allowing Canada + await SpecialPowers.pushPrefEnv({"set": [["extensions.formautofill.supportedCountries", "US,CA"], + ["dom.input_events.beforeinput.enabled", true]]}); + + await setupAddressStorage(); +}); + +// Autofill the address with address level 1 code. +add_task(async function autofill_with_level1_code() { + await setInput("#organization-en", "Mozilla Toront"); + synthesizeKey("KEY_ArrowDown"); + await expectPopup(); + + synthesizeKey("KEY_ArrowDown"); + // Replace address level 1 code with full name in English for test result + let result = Object.assign({}, MOCK_STORAGE[1], {"address-level1": "Ontario"}); + await checkFormFilled("#form-en", result); + + await setInput("#organization-fr", "Mozilla Vancouve"); + synthesizeKey("KEY_ArrowDown"); + await expectPopup(); + + synthesizeKey("KEY_ArrowDown"); + // Replace address level 1 code with full name in French for test result + result = Object.assign({}, MOCK_STORAGE[0], {"address-level1": "Colombie-Britannique"}); + await checkFormFilled("#form-fr", result); + document.querySelector("#form-en").reset(); + document.querySelector("#form-fr").reset(); +}); + +// Autofill the address with address level 1 full name. +add_task(async function autofill_with_level1_full_name() { + await setInput("#organization-en", "ExpoCit"); + synthesizeKey("KEY_ArrowDown"); + await expectPopup(); + + synthesizeKey("KEY_ArrowDown"); + // Replace address level 1 code with full name in French for test result + let result = Object.assign({}, MOCK_STORAGE[3], {"address-level1": "Quebec"}); + await checkFormFilled("#form-en", result); + + await setInput("#organization-fr", "Prince of Wales"); + synthesizeKey("KEY_ArrowDown"); + await expectPopup(); + + synthesizeKey("KEY_ArrowDown"); + // Replace address level 1 code with full name in English for test result + result = Object.assign({}, MOCK_STORAGE[2], {"address-level1": "Territoires du Nord-Ouest"}); + await checkFormFilled("#form-fr", result); +}); + +</script> + +<p id="display"></p> + +<div id="content"> + + <form id="form-en"> + <p>This is a basic CA form with en address level 1 select.</p> + <p><label>organization: <input id="organization-en" name="organization" autocomplete="organization" type="text"></label></p> + <p><label>streetAddress: <input id="street-address-en" name="street-address" autocomplete="street-address" type="text"></label></p> + <p><label>address-line1: <input id="address-line1-en" name="address-line1" autocomplete="address-line1" type="text"></label></p> + <p><label>tel: <input id="tel-en" name="tel" autocomplete="tel" type="text"></label></p> + <p><label>email: <input id="email-en" name="email" autocomplete="email" type="text"></label></p> + <p><label>country: <select id="country-en" name="country" autocomplete="country"> + <option/> + <option value="US">United States</option> + <option value="CA">Canada</option> + </select></label></p> + <p><label>states: <select id="address-level1-en" name="address-level1" autocomplete="address-level1"> + <option/> + <option value="British Columbia">British Columbia</option> + <option value="Ontario">Ontario</option> + <option value="Northwest Territories">Northwest Territories</option> + <option value="Quebec">Quebec</option> + </select></label></p> + </form> + + <form id="form-fr"> + <p>This is a basic CA form with fr address level 1 select.</p> + <p><label>organization: <input id="organization-fr" name="organization" autocomplete="organization" type="text"></label></p> + <p><label>streetAddress: <input id="street-address-fr" name="street-address" autocomplete="street-address" type="text"></label></p> + <p><label>address-line1: <input id="address-line1-fr" name="address-line1" autocomplete="address-line1" type="text"></label></p> + <p><label>tel: <input id="tel-fr" name="tel" autocomplete="tel" type="text"></label></p> + <p><label>email: <input id="email-fr" name="email" autocomplete="email" type="text"></label></p> + <p><label>country: <select id="country-fr" name="country" autocomplete="country"> + <option/> + <option value="US">United States</option> + <option value="CA">Canada</option> + </select></label></p> + <p><label>states: <select id="address-level1-fr" name="address-level1" autocomplete="address-level1"> + <option/> + <option value="Colombie-Britannique">Colombie-Britannique</option> + <option value="Ontario">Ontario</option> + <option value="Territoires du Nord-Ouest">Territoires du Nord-Ouest</option> + <option value="Québec">Québec</option> + </select></label></p> + </form> + +</div> + +<pre id="test"></pre> +</body> +</html> diff --git a/browser/extensions/formautofill/test/mochitest/test_multiple_forms.html b/browser/extensions/formautofill/test/mochitest/test_multiple_forms.html new file mode 100644 index 0000000000..feea55aae6 --- /dev/null +++ b/browser/extensions/formautofill/test/mochitest/test_multiple_forms.html @@ -0,0 +1,67 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Test autofill submit</title> + <script src="/tests/SimpleTest/EventUtils.js"></script> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script type="text/javascript" src="formautofill_common.js"></script> + <script type="text/javascript" src="satchel_common.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" /> +</head> +<body> + +<script> +/* import-globals-from ../../../../../toolkit/components/satchel/test/satchel_common.js */ + +"use strict"; + +let MOCK_STORAGE = [{ + "given-name": "John", + "additional-name": "R", + "family-name": "Smith", +}]; + +initPopupListener(); + +add_task(async function setupStorage() { + await addAddress(MOCK_STORAGE[0]); +}); + +add_task(async function check_switch_form_popup() { + await setInput("#additional-name", ""); + synthesizeKey("KEY_ArrowDown"); + await expectPopup(); + + // We need an intentional wait here before switching form. + await sleep(); + await setInput("#organization", ""); + synthesizeKey("KEY_ArrowDown"); + const {open: popupOpen} = await getPopupState(); + is(popupOpen, false); + + await sleep(); + await setInput("#given-name", ""); + synthesizeKey("KEY_ArrowDown"); + await expectPopup(); +}); + +</script> + +<div> + + <form> + <label>Name:<input id="name" autocomplete="name"></label> + <label>Organization:<input id="organization" autocomplete="organization"></label> + <label>City:<input autocomplete="address-level2"></label> + </form> + + <form> + <label>Given-Name: <input id="given-name" autocomplete="given-name"></label> + <label>Additional-Name/Middle: <input id="additional-name" autocomplete="additional-name"></label> + <label>FamilyName-LastName: <input id="family-name" autocomplete="family-name"></label> + </form> + +</div> +</body> +</html> diff --git a/browser/extensions/formautofill/test/mochitest/test_on_address_submission.html b/browser/extensions/formautofill/test/mochitest/test_on_address_submission.html new file mode 100644 index 0000000000..9c7f8a9f95 --- /dev/null +++ b/browser/extensions/formautofill/test/mochitest/test_on_address_submission.html @@ -0,0 +1,164 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Test autofill submit</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/EventUtils.js"></script> + <script type="text/javascript" src="formautofill_common.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 autofill test: check if address is saved/updated correctly + +<script> +/* import-globals-from ../../../../../toolkit/components/satchel/test/satchel_common.js */ + +"use strict"; + +let TEST_ADDRESSES = [{ + organization: "Sesame Street", + "street-address": "123 Sesame Street.", + tel: "+13453453456", +}, { + organization: "Mozilla", + "street-address": "331 E. Evelyn Avenue", + tel: "+16509030800", +}]; + +add_task(async function setup_prefs() { + await SpecialPowers.pushPrefEnv({ + set: [ + ["extensions.formautofill.addresses.enabled", true], + ["extensions.formautofill.addresses.capture.enabled", true], + ], + }); +}); + +initPopupListener(); + +// Submit first address for saving. +add_task(async function check_storage_after_form_submitted() { + // We already verified the first time use case in browser test + await SpecialPowers.pushPrefEnv({ + "set": [["extensions.formautofill.firstTimeUse", false]], + }); + + for (let key in TEST_ADDRESSES[0]) { + await setInput("#" + key, TEST_ADDRESSES[0][key]); + } + + clickOnElement("input[type=submit]"); + + let expectedAddresses = TEST_ADDRESSES.slice(0, 1); + await onStorageChanged("add"); + // Check if timesUsed is set correctly + expectedAddresses[0].timesUsed = 1; + let matching = await checkAddresses(expectedAddresses); + ok(matching, "Address saved as expected"); + delete expectedAddresses[0].timesUsed; +}); + +// Submit another new address. +add_task(async function check_storage_after_another_address_submitted() { + await SpecialPowers.pushPrefEnv({"set": [["privacy.reduceTimerPrecision", false]]}); + + document.querySelector("form").reset(); + for (let key in TEST_ADDRESSES[1]) { + await setInput("#" + key, TEST_ADDRESSES[1][key]); + } + + clickOnElement("input[type=submit]"); + + // The 2nd test address should be on the top since it's the last used one. + let addressesInMenu = TEST_ADDRESSES.slice(1); + addressesInMenu.push(TEST_ADDRESSES[0]); + + // let expectedAddresses = TEST_ADDRESSES.slice(0); + await onStorageChanged("add"); + let matching = await checkAddresses(TEST_ADDRESSES); + ok(matching, "New address saved as expected"); + + await setInput("#organization", ""); + synthesizeKey("KEY_ArrowDown"); + await expectPopup(); + checkMenuEntries(addressesInMenu.map(address => + JSON.stringify({primary: address.organization, secondary: address["street-address"]}) + )); +}); + +// Submit another new address that is mergeable. +add_task(async function new_address_submitted_and_merged() { + document.querySelector("form").reset(); + for (let key in TEST_ADDRESSES[0]) { + await setInput("#" + key, TEST_ADDRESSES[0][key]); + } + // Add country to first address in storage + await setInput("#country", "US"); + TEST_ADDRESSES[0].country = "US"; + clickOnElement("input[type=submit]"); + + let expectedAddresses = TEST_ADDRESSES.slice(0); + // Check if timesUsed is set correctly + expectedAddresses[0].timesUsed = 2; + await onStorageChanged("update"); + let matching = await checkAddresses(expectedAddresses); + ok(matching, "Address merged as expected"); + delete expectedAddresses[0].timesUsed; +}); + +// Submit an updated autofill address and merge. +add_task(async function check_storage_after_form_submitted() { + document.querySelector("form").reset(); + // Add country to second address in storage + await setInput("#country", "US"); + TEST_ADDRESSES[1].country = "US"; + + await setInput("#organization", "Moz"); + synthesizeKey("KEY_ArrowDown"); + await expectPopup(); + synthesizeKey("KEY_ArrowDown"); + synthesizeKey("KEY_Enter"); + clickOnElement("input[type=submit]"); + + let expectedAddresses = TEST_ADDRESSES.slice(0); + await onStorageChanged("update"); + let matching = await checkAddresses(expectedAddresses); + ok(matching, "Updated address merged as expected"); +}); + +// Submit a subset address manually. +add_task(async function submit_subset_manually() { + document.querySelector("form").reset(); + for (let key in TEST_ADDRESSES[0]) { + await setInput("#" + key, TEST_ADDRESSES[0][key]); + } + + // Set organization field to empty + await setInput("#organization", ""); + clickOnElement("input[type=submit]"); + + let expectedAddresses = TEST_ADDRESSES.slice(0); + + await sleep(1000); + let matching = await checkAddresses(expectedAddresses); + ok(matching, "The storage is still the same after submitting a subset"); +}); + +</script> + +<div> + + <form onsubmit="return false"> + <p>This is a basic form for submitting test.</p> + <p><label>organization: <input id="organization" name="organization" autocomplete="organization" type="text"></label></p> + <p><label>streetAddress: <input id="street-address" name="street-address" autocomplete="street-address" type="text"></label></p> + <p><label>tel: <input id="tel" name="tel" autocomplete="tel" type="text"></label></p> + <p><label>country: <input id="country" name="country" autocomplete="country" type="text"></label></p> + <p><input type="submit"></p> + </form> + +</div> +</body> +</html> diff --git a/browser/extensions/formautofill/test/unit/head.js b/browser/extensions/formautofill/test/unit/head.js new file mode 100644 index 0000000000..7cd5080f05 --- /dev/null +++ b/browser/extensions/formautofill/test/unit/head.js @@ -0,0 +1,345 @@ +/** + * Provides infrastructure for automated formautofill components tests. + */ + +"use strict"; + +var { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); +var { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); +var { ObjectUtils } = ChromeUtils.import( + "resource://gre/modules/ObjectUtils.jsm" +); +var { FormLikeFactory } = ChromeUtils.import( + "resource://gre/modules/FormLikeFactory.jsm" +); +var { AddonTestUtils, MockAsyncShutdown } = ChromeUtils.import( + "resource://testing-common/AddonTestUtils.jsm" +); +var { ExtensionTestUtils } = ChromeUtils.import( + "resource://testing-common/ExtensionXPCShellUtils.jsm" +); +var { FileTestUtils } = ChromeUtils.import( + "resource://testing-common/FileTestUtils.jsm" +); +var { MockDocument } = ChromeUtils.import( + "resource://testing-common/MockDocument.jsm" +); +var { sinon } = ChromeUtils.import("resource://testing-common/Sinon.jsm"); +var { TestUtils } = ChromeUtils.import( + "resource://testing-common/TestUtils.jsm" +); + +ChromeUtils.defineModuleGetter( + this, + "AddonManager", + "resource://gre/modules/AddonManager.jsm" +); +ChromeUtils.defineModuleGetter( + this, + "AddonManagerPrivate", + "resource://gre/modules/AddonManager.jsm" +); +ChromeUtils.defineModuleGetter( + this, + "DownloadPaths", + "resource://gre/modules/DownloadPaths.jsm" +); +ChromeUtils.defineModuleGetter( + this, + "FileUtils", + "resource://gre/modules/FileUtils.jsm" +); + +ChromeUtils.defineModuleGetter( + this, + "ExtensionParent", + "resource://gre/modules/ExtensionParent.jsm" +); + +{ + // We're going to register a mock file source + // with region names based on en-US. This is + // necessary for tests that expect to match + // on region code display names. + const { L10nRegistry, FileSource } = ChromeUtils.import( + "resource://gre/modules/L10nRegistry.jsm" + ); + + const fs = { + "toolkit/intl/regionNames.ftl": ` +region-name-us = United States +region-name-nz = New Zeland +region-name-au = Australia +region-name-ca = Canada +region-name-tw = Taiwan + `, + }; + + L10nRegistry.loadSync = function(url) { + if (!fs.hasOwnProperty(url)) { + return false; + } + return fs[url]; + }; + + let locales = Services.locale.packagedLocales; + const mockSource = new FileSource("mock", locales, ""); + L10nRegistry.registerSources([mockSource]); +} + +do_get_profile(); + +const EXTENSION_ID = "formautofill@mozilla.org"; + +AddonTestUtils.init(this); +AddonTestUtils.overrideCertDB(); + +async function loadExtension() { + AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "1", + "1.9.2" + ); + await AddonTestUtils.promiseStartupManager(); + + let extensionPath = Services.dirsvc.get("GreD", Ci.nsIFile); + extensionPath.append("browser"); + extensionPath.append("features"); + extensionPath.append(EXTENSION_ID); + + if (!extensionPath.exists()) { + extensionPath.leafName = `${EXTENSION_ID}.xpi`; + } + + let startupPromise = new Promise(resolve => { + const { apiManager } = ExtensionParent; + function onReady(event, extension) { + if (extension.id == EXTENSION_ID) { + apiManager.off("ready", onReady); + resolve(); + } + } + + apiManager.on("ready", onReady); + }); + + await AddonManager.installTemporaryAddon(extensionPath); + await startupPromise; +} + +// Returns a reference to a temporary file that is guaranteed not to exist and +// is cleaned up later. See FileTestUtils.getTempFile for details. +function getTempFile(leafName) { + return FileTestUtils.getTempFile(leafName); +} + +async function initProfileStorage( + fileName, + records, + collectionName = "addresses" +) { + let { FormAutofillStorage } = ChromeUtils.import( + "resource://formautofill/FormAutofillStorage.jsm", + null + ); + let path = getTempFile(fileName).path; + let profileStorage = new FormAutofillStorage(path); + await profileStorage.initialize(); + + // AddonTestUtils inserts its own directory provider that manages TmpD. + // It removes that directory at shutdown, which races with shutdown + // handing in JSONFile/DeferredTask (which is used by FormAutofillStorage). + // Avoid the race by explicitly finalizing any formautofill JSONFile + // instances created manually by individual tests when the test finishes. + registerCleanupFunction(function finalizeAutofillStorage() { + return profileStorage._finalize(); + }); + + if (!records || !Array.isArray(records)) { + return profileStorage; + } + + let onChanged = TestUtils.topicObserved( + "formautofill-storage-changed", + (subject, data) => + data == "add" && subject.wrappedJSObject.collectionName == collectionName + ); + for (let record of records) { + Assert.ok(await profileStorage[collectionName].add(record)); + await onChanged; + } + await profileStorage._saveImmediately(); + return profileStorage; +} + +function verifySectionFieldDetails(sections, expectedResults) { + Assert.equal( + sections.length, + expectedResults.length, + "Expected section count." + ); + sections.forEach((sectionInfo, sectionIndex) => { + let expectedSectionInfo = expectedResults[sectionIndex]; + info("FieldName Prediction Results: " + sectionInfo.map(i => i.fieldName)); + info( + "FieldName Expected Results: " + + expectedSectionInfo.map(i => i.fieldName) + ); + Assert.equal( + sectionInfo.length, + expectedSectionInfo.length, + "Expected field count." + ); + + sectionInfo.forEach((field, fieldIndex) => { + let expectedField = expectedSectionInfo[fieldIndex]; + delete field._reason; + delete field.elementWeakRef; + Assert.deepEqual(field, expectedField); + }); + }); +} + +var FormAutofillHeuristics, LabelUtils; +var AddressDataLoader, FormAutofillUtils; + +async function runHeuristicsTest(patterns, fixturePathPrefix) { + add_task(async function setup() { + ({ FormAutofillHeuristics, LabelUtils } = ChromeUtils.import( + "resource://formautofill/FormAutofillHeuristics.jsm" + )); + ({ AddressDataLoader, FormAutofillUtils } = ChromeUtils.import( + "resource://formautofill/FormAutofillUtils.jsm" + )); + }); + + patterns.forEach(testPattern => { + add_task(async function() { + info("Starting test fixture: " + testPattern.fixturePath); + let file = do_get_file(fixturePathPrefix + testPattern.fixturePath); + let doc = MockDocument.createTestDocumentFromFile( + "http://localhost:8080/test/", + file + ); + + let forms = []; + + for (let field of FormAutofillUtils.autofillFieldSelector(doc)) { + let formLike = FormLikeFactory.createFromField(field); + if (!forms.some(form => form.rootElement === formLike.rootElement)) { + forms.push(formLike); + } + } + + Assert.equal( + forms.length, + testPattern.expectedResult.length, + "Expected form count." + ); + + forms.forEach((form, formIndex) => { + let sections = FormAutofillHeuristics.getFormInfo(form); + verifySectionFieldDetails( + sections.map(section => section.fieldDetails), + testPattern.expectedResult[formIndex] + ); + }); + }); + }); +} + +/** + * Returns the Sync change counter for a profile storage record. Synced records + * store additional metadata for tracking changes and resolving merge conflicts. + * Deleting a synced record replaces the record with a tombstone. + * + * @param {AutofillRecords} records + * The `AutofillRecords` instance to query. + * @param {string} guid + * The GUID of the record or tombstone. + * @returns {number} + * The change counter, or -1 if the record doesn't exist or hasn't + * been synced yet. + */ +function getSyncChangeCounter(records, guid) { + let record = records._findByGUID(guid, { includeDeleted: true }); + if (!record) { + return -1; + } + let sync = records._getSyncMetaData(record); + if (!sync) { + return -1; + } + return sync.changeCounter; +} + +/** + * Performs a partial deep equality check to determine if an object contains + * the given fields. + * + * @param {Object} object + * The object to check. Unlike `ObjectUtils.deepEqual`, properties in + * `object` that are not in `fields` will be ignored. + * @param {Object} fields + * The fields to match. + * @returns {boolean} + * Does `object` contain `fields` with matching values? + */ +function objectMatches(object, fields) { + let actual = {}; + for (let key in fields) { + if (!object.hasOwnProperty(key)) { + return false; + } + actual[key] = object[key]; + } + return ObjectUtils.deepEqual(actual, fields); +} + +add_task(async function head_initialize() { + Services.prefs.setStringPref("extensions.formautofill.available", "on"); + Services.prefs.setBoolPref("extensions.experiments.enabled", true); + Services.prefs.setBoolPref( + "extensions.formautofill.creditCards.available", + true + ); + Services.prefs.setBoolPref( + "extensions.formautofill.creditCards.enabled", + true + ); + Services.prefs.setBoolPref( + "extensions.formautofill.heuristics.enabled", + true + ); + Services.prefs.setBoolPref("extensions.formautofill.section.enabled", true); + Services.prefs.setBoolPref("dom.forms.autocomplete.formautofill", true); + + // Clean up after every test. + registerCleanupFunction(function head_cleanup() { + Services.prefs.clearUserPref("extensions.formautofill.available"); + Services.prefs.clearUserPref("extensions.experiments.enabled"); + Services.prefs.clearUserPref( + "extensions.formautofill.creditCards.available" + ); + Services.prefs.clearUserPref("extensions.formautofill.creditCards.enabled"); + Services.prefs.clearUserPref("extensions.formautofill.heuristics.enabled"); + Services.prefs.clearUserPref("extensions.formautofill.section.enabled"); + Services.prefs.clearUserPref("dom.forms.autocomplete.formautofill"); + }); + + await loadExtension(); +}); + +let OSKeyStoreTestUtils; +add_task(async function os_key_store_setup() { + ({ OSKeyStoreTestUtils } = ChromeUtils.import( + "resource://testing-common/OSKeyStoreTestUtils.jsm" + )); + OSKeyStoreTestUtils.setup(); + registerCleanupFunction(async function cleanup() { + await OSKeyStoreTestUtils.cleanup(); + }); +}); diff --git a/browser/extensions/formautofill/test/unit/heuristics/test_basic.js b/browser/extensions/formautofill/test/unit/heuristics/test_basic.js new file mode 100644 index 0000000000..be148adb64 --- /dev/null +++ b/browser/extensions/formautofill/test/unit/heuristics/test_basic.js @@ -0,0 +1,180 @@ +/* global runHeuristicsTest */ + +"use strict"; + +runHeuristicsTest( + [ + { + fixturePath: "autocomplete_basic.html", + expectedResult: [ + [ + [ + { + section: "", + addressType: "", + contactType: "", + fieldName: "organization", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "street-address", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "address-level2", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "address-level1", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "postal-code", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "country", + }, + { section: "", addressType: "", contactType: "", fieldName: "tel" }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "email", + }, + ], + ], + [ + [ + { + section: "", + addressType: "", + contactType: "", + fieldName: "organization", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "address-line1", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "address-line2", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "address-line3", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "address-level2", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "address-level1", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "postal-code", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "country", + }, + { section: "", addressType: "", contactType: "", fieldName: "tel" }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "email", + }, + ], + [ + { + section: "", + addressType: "", + contactType: "", + fieldName: "cc-number", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "cc-name", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "cc-exp-month", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "cc-exp-year", + }, + ], + ], + [ + [ + { + section: "", + addressType: "", + contactType: "", + fieldName: "address-line1", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "address-level2", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "address-line2", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "organization", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "address-line3", + }, + ], + ], + ], + }, + ], + "../../fixtures/" +); diff --git a/browser/extensions/formautofill/test/unit/heuristics/test_cc_exp.js b/browser/extensions/formautofill/test/unit/heuristics/test_cc_exp.js new file mode 100644 index 0000000000..50a1766966 --- /dev/null +++ b/browser/extensions/formautofill/test/unit/heuristics/test_cc_exp.js @@ -0,0 +1,112 @@ +/* global runHeuristicsTest */ + +"use strict"; + +runHeuristicsTest( + [ + { + fixturePath: "heuristics_cc_exp.html", + expectedResult: [ + [ + [ + { + section: "", + addressType: "", + contactType: "", + fieldName: "cc-name", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "cc-number", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "cc-exp-month", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "cc-exp-year", + }, + ], + ], + [ + [ + { + section: "", + addressType: "", + contactType: "", + fieldName: "cc-number", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "cc-exp", + }, + ], + ], + [ + [ + { + section: "", + addressType: "", + contactType: "", + fieldName: "cc-number", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "cc-exp", + }, + ], + ], + [ + [ + { + section: "", + addressType: "", + contactType: "", + fieldName: "cc-number", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "cc-exp-month", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "cc-exp-year", + }, + ], + ], + [ + [ + { + section: "", + addressType: "", + contactType: "", + fieldName: "cc-exp-month", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "cc-exp-year", + }, + ], + ], + ], + }, + ], + "../../fixtures/" +); diff --git a/browser/extensions/formautofill/test/unit/heuristics/test_de_fields.js b/browser/extensions/formautofill/test/unit/heuristics/test_de_fields.js new file mode 100644 index 0000000000..b3f99c3feb --- /dev/null +++ b/browser/extensions/formautofill/test/unit/heuristics/test_de_fields.js @@ -0,0 +1,48 @@ +/* global runHeuristicsTest */ + +"use strict"; + +runHeuristicsTest( + [ + { + fixturePath: "heuristics_de_fields.html", + expectedResult: [ + [ + [ + { + section: "", + addressType: "", + contactType: "", + fieldName: "cc-name", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "cc-type", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "cc-number", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "cc-exp-month", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "cc-exp-year", + }, + ], + ], + ], + }, + ], + "../../fixtures/" +); diff --git a/browser/extensions/formautofill/test/unit/heuristics/test_known_strings.js b/browser/extensions/formautofill/test/unit/heuristics/test_known_strings.js new file mode 100644 index 0000000000..60e02ff5cb --- /dev/null +++ b/browser/extensions/formautofill/test/unit/heuristics/test_known_strings.js @@ -0,0 +1,145 @@ +"use strict"; +/* global FormAutofillHeuristics: true */ + +const KNOWN_NAMES = { + "cc-name": ["cc-name", "card-name", "cardholder-name", "cardholder"], + "cc-number": [ + "cc-number", + "cc-num", + "card-number", + "card-num", + "number", + "cc", + "cc-no", + "card-no", + "credit-card", + "numero-carte", + "carte", + "carte-credit", + "num-carte", + "cb-num", + ], + "cc-exp": [ + "cc-exp", + "card-exp", + "cc-expiration", + "card-expiration", + "cc-ex", + "card-ex", + "card-expire", + "card-expiry", + "validite", + "expiration", + "expiry", + "mm-yy", + "mm-yyyy", + "yy-mm", + "yyyy-mm", + "expiration-date", + "payment-card-expiration", + "payment-cc-date", + ], + "cc-exp-month": [ + "exp-month", + "cc-exp-month", + "cc-month", + "card-month", + "cc-mo", + "card-mo", + "exp-mo", + "card-exp-mo", + "cc-exp-mo", + "card-expiration-month", + "expiration-month", + "cc-mm", + "cc-m", + "card-mm", + "card-m", + "card-exp-mm", + "cc-exp-mm", + "exp-mm", + "exp-m", + "expire-month", + "expire-mo", + "expiry-month", + "expiry-mo", + "card-expire-month", + "card-expire-mo", + "card-expiry-month", + "card-expiry-mo", + "mois-validite", + "mois-expiration", + "m-validite", + "m-expiration", + "expiry-date-field-month", + "expiration-date-month", + "expiration-date-mm", + "exp-mon", + "validity-mo", + "exp-date-mo", + "cb-date-mois", + "date-m", + ], + "cc-exp-year": [ + "exp-year", + "cc-exp-year", + "cc-year", + "card-year", + "cc-yr", + "card-yr", + "exp-yr", + "card-exp-yr", + "cc-exp-yr", + "card-expiration-year", + "expiration-year", + "cc-yy", + "cc-y", + "card-yy", + "card-y", + "card-exp-yy", + "cc-exp-yy", + "exp-yy", + "exp-y", + "cc-yyyy", + "card-yyyy", + "card-exp-yyyy", + "cc-exp-yyyy", + "expire-year", + "expire-yr", + "expiry-year", + "expiry-yr", + "card-expire-year", + "card-expire-yr", + "card-expiry-year", + "card-expiry-yr", + "an-validite", + "an-expiration", + "annee-validite", + "annee-expiration", + "expiry-date-field-year", + "expiration-date-year", + "cb-date-ann", + "expiration-date-yy", + "expiration-date-yyyy", + "validity-year", + "exp-date-year", + "date-y", + ], +}; + +add_task(async function setup() { + ({ FormAutofillHeuristics } = ChromeUtils.import( + "resource://formautofill/FormAutofillHeuristics.jsm" + )); +}); + +for (let field in KNOWN_NAMES) { + KNOWN_NAMES[field].forEach(name => { + add_task(async () => { + ok( + FormAutofillHeuristics.RULES[field].test(name), + `RegExp for ${field} matches string '${name}'` + ); + }); + }); +} diff --git a/browser/extensions/formautofill/test/unit/heuristics/test_multiple_section.js b/browser/extensions/formautofill/test/unit/heuristics/test_multiple_section.js new file mode 100644 index 0000000000..752d13bbc4 --- /dev/null +++ b/browser/extensions/formautofill/test/unit/heuristics/test_multiple_section.js @@ -0,0 +1,280 @@ +/* global runHeuristicsTest */ + +"use strict"; + +runHeuristicsTest( + [ + { + fixturePath: "multiple_section.html", + expectedResult: [ + [ + [ + { + section: "", + addressType: "", + contactType: "", + fieldName: "name", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "organization", + }, + { + section: "", + addressType: "", + contactType: "work", + fieldName: "tel", + }, + { + section: "", + addressType: "", + contactType: "work", + fieldName: "email", + }, + + // Even the `contactType` of these two fields are different with the + // above two, we still consider they are identical until supporting + // multiple phone number and email in one profile. + // {"section": "", "addressType": "", "contactType": "home", "fieldName": "tel"}, + // {"section": "", "addressType": "", "contactType": "home", "fieldName": "email"}, + ], + [ + { + section: "", + addressType: "shipping", + contactType: "", + fieldName: "street-address", + }, + { + section: "", + addressType: "shipping", + contactType: "", + fieldName: "address-level2", + }, + { + section: "", + addressType: "shipping", + contactType: "", + fieldName: "address-level1", + }, + { + section: "", + addressType: "shipping", + contactType: "", + fieldName: "postal-code", + }, + { + section: "", + addressType: "shipping", + contactType: "", + fieldName: "country", + }, + ], + [ + { + section: "", + addressType: "billing", + contactType: "", + fieldName: "street-address", + }, + { + section: "", + addressType: "billing", + contactType: "", + fieldName: "address-level2", + }, + { + section: "", + addressType: "billing", + contactType: "", + fieldName: "address-level1", + }, + { + section: "", + addressType: "billing", + contactType: "", + fieldName: "postal-code", + }, + { + section: "", + addressType: "billing", + contactType: "", + fieldName: "country", + }, + ], + [ + { + section: "section-my", + addressType: "", + contactType: "", + fieldName: "street-address", + }, + { + section: "section-my", + addressType: "", + contactType: "", + fieldName: "address-level2", + }, + { + section: "section-my", + addressType: "", + contactType: "", + fieldName: "address-level1", + }, + { + section: "section-my", + addressType: "", + contactType: "", + fieldName: "postal-code", + }, + { + section: "section-my", + addressType: "", + contactType: "", + fieldName: "country", + }, + ], + ], + [ + [ + { + section: "", + addressType: "", + contactType: "", + fieldName: "name", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "organization", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "street-address", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "address-level2", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "address-level1", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "postal-code", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "country", + }, + ], + [ + { + section: "", + addressType: "", + contactType: "", + fieldName: "street-address", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "address-level2", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "address-level1", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "postal-code", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "country", + }, + ], + [ + { + section: "", + addressType: "", + contactType: "", + fieldName: "street-address", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "address-level2", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "address-level1", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "postal-code", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "country", + }, + { + section: "", + addressType: "", + contactType: "work", + fieldName: "tel", + }, + { + section: "", + addressType: "", + contactType: "work", + fieldName: "email", + }, + ], + [ + { + section: "", + addressType: "", + contactType: "home", + fieldName: "tel", + }, + { + section: "", + addressType: "", + contactType: "home", + fieldName: "email", + }, + ], + ], + ], + }, + ], + "../../fixtures/" +); diff --git a/browser/extensions/formautofill/test/unit/heuristics/third_party/test_BestBuy.js b/browser/extensions/formautofill/test/unit/heuristics/third_party/test_BestBuy.js new file mode 100644 index 0000000000..5aa741a729 --- /dev/null +++ b/browser/extensions/formautofill/test/unit/heuristics/third_party/test_BestBuy.js @@ -0,0 +1,163 @@ +/* global runHeuristicsTest */ + +"use strict"; + +runHeuristicsTest( + [ + { + fixturePath: "Checkout_ShippingAddress.html", + expectedResult: [ + [], // Search form + [ + [ + { + section: "", + addressType: "", + contactType: "", + fieldName: "given-name", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "family-name", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "street-address", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "address-level2", + }, // city + { + section: "", + addressType: "", + contactType: "", + fieldName: "address-level1", + }, // state + { + section: "", + addressType: "", + contactType: "", + fieldName: "postal-code", + }, + ], + ], + [ + [ + // Sign up + { + section: "", + addressType: "", + contactType: "", + fieldName: "email", + }, + ], + ], + [ + [ + // unknown + { + section: "", + addressType: "", + contactType: "", + fieldName: "email", + }, + { section: "", addressType: "", contactType: "", fieldName: "tel" }, + ], + ], + ], + }, + { + fixturePath: "Checkout_Payment.html", + expectedResult: [ + [], // Search form + [ + [ + // Sign up + { + section: "", + addressType: "", + contactType: "", + fieldName: "email", + }, + ], + ], + [ + [ + { + section: "", + addressType: "", + contactType: "", + fieldName: "given-name", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "family-name", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "street-address", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "address-level2", + }, // city + { + section: "", + addressType: "", + contactType: "", + fieldName: "address-level1", + }, // state + { + section: "", + addressType: "", + contactType: "", + fieldName: "postal-code", + }, + ], + ], + [ + [ + // unknown + { + section: "", + addressType: "", + contactType: "", + fieldName: "email", + }, + { section: "", addressType: "", contactType: "", fieldName: "tel" }, + ], + ], + ], + }, + { + fixturePath: "SignIn.html", + expectedResult: [ + [ + [ + // Sign in + { + section: "", + addressType: "", + contactType: "", + fieldName: "email", + }, + ], + ], + ], + }, + ], + "../../../fixtures/third_party/BestBuy/" +); diff --git a/browser/extensions/formautofill/test/unit/heuristics/third_party/test_CDW.js b/browser/extensions/formautofill/test/unit/heuristics/third_party/test_CDW.js new file mode 100644 index 0000000000..71a84660d7 --- /dev/null +++ b/browser/extensions/formautofill/test/unit/heuristics/third_party/test_CDW.js @@ -0,0 +1,169 @@ +/* global runHeuristicsTest */ + +"use strict"; + +runHeuristicsTest( + [ + { + fixturePath: "Checkout_ShippingInfo.html", + expectedResult: [ + [ + [ + { + section: "", + addressType: "", + contactType: "", + fieldName: "given-name", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "family-name", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "organization", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "address-line1", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "address-line2", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "address-level2", + }, // city + { + section: "", + addressType: "", + contactType: "", + fieldName: "address-level1", + }, // state + { + section: "", + addressType: "", + contactType: "", + fieldName: "postal-code", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "email", + }, + { section: "", addressType: "", contactType: "", fieldName: "tel" }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "tel-extension", + }, + ], + ], + [], + ], + }, + { + fixturePath: "Checkout_BillingPaymentInfo.html", + expectedResult: [ + [ + [ + { + section: "", + addressType: "", + contactType: "", + fieldName: "given-name", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "family-name", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "organization", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "address-line1", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "address-line2", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "address-level2", + }, // city + { + section: "", + addressType: "", + contactType: "", + fieldName: "address-level1", + }, // state + { + section: "", + addressType: "", + contactType: "", + fieldName: "postal-code", + }, + ], + [ + { + section: "", + addressType: "", + contactType: "", + fieldName: "cc-type", + }, // ac-off + { + section: "", + addressType: "", + contactType: "", + fieldName: "cc-number", + }, // ac-off + { + section: "", + addressType: "", + contactType: "", + fieldName: "cc-exp-month", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "cc-exp-year", + }, + // {"section": "", "addressType": "", "contactType": "", "fieldName": "cc-csc"}, + ], + ], + [], + ], + }, + { + fixturePath: "Checkout_Logon.html", + expectedResult: [[], [], []], + }, + ], + "../../../fixtures/third_party/CDW/" +); diff --git a/browser/extensions/formautofill/test/unit/heuristics/third_party/test_CostCo.js b/browser/extensions/formautofill/test/unit/heuristics/third_party/test_CostCo.js new file mode 100644 index 0000000000..049bccc9a9 --- /dev/null +++ b/browser/extensions/formautofill/test/unit/heuristics/third_party/test_CostCo.js @@ -0,0 +1,448 @@ +/* global runHeuristicsTest */ + +"use strict"; + +runHeuristicsTest( + [ + { + fixturePath: "ShippingAddress.html", + expectedResult: [ + [], + [], + [ + [ + { + section: "", + addressType: "", + contactType: "", + fieldName: "given-name", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "additional-name", + }, // middle-name initial + { + section: "", + addressType: "", + contactType: "", + fieldName: "family-name", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "organization", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "country", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "address-line1", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "address-line2", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "address-level2", + }, // city + { + section: "", + addressType: "", + contactType: "", + fieldName: "address-level1", + }, // state + { + section: "", + addressType: "", + contactType: "", + fieldName: "postal-code", + }, + { section: "", addressType: "", contactType: "", fieldName: "tel" }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "email", + }, + ], + ], + [ + [ + { + section: "", + addressType: "", + contactType: "", + fieldName: "given-name", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "additional-name", + }, // middle-name initial + { + section: "", + addressType: "", + contactType: "", + fieldName: "family-name", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "organization", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "country", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "address-line1", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "address-line2", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "address-level2", + }, // city + { + section: "", + addressType: "", + contactType: "", + fieldName: "address-level1", + }, // state + { + section: "", + addressType: "", + contactType: "", + fieldName: "postal-code", + }, + { section: "", addressType: "", contactType: "", fieldName: "tel" }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "email", + }, + ], + ], + [], + [ + [ + { + section: "", + addressType: "", + contactType: "", + fieldName: "email", + }, + ], + ], + [], + ], + }, + { + fixturePath: "Payment.html", + expectedResult: [ + [ + [ + { + section: "", + addressType: "", + contactType: "", + fieldName: "cc-type", + }, // ac-off + { + section: "", + addressType: "", + contactType: "", + fieldName: "cc-number", + }, // ac-off + { + section: "", + addressType: "", + contactType: "", + fieldName: "cc-exp-month", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "cc-exp-year", + }, + // {"section": "", "addressType": "", "contactType": "", "fieldName": "cc-csc"}, // ac-off + { + section: "", + addressType: "", + contactType: "", + fieldName: "cc-name", + }, // ac-off + ], + ], + [ + [ + { + section: "", + addressType: "", + contactType: "", + fieldName: "cc-number", + }, // ac-off + ], + ], + [], + [], + [ + [ + { + section: "", + addressType: "", + contactType: "", + fieldName: "given-name", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "additional-name", + }, // middle-name initial + { + section: "", + addressType: "", + contactType: "", + fieldName: "family-name", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "organization", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "country", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "address-line1", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "address-line2", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "address-level2", + }, // city + { + section: "", + addressType: "", + contactType: "", + fieldName: "address-level1", + }, // state + { + section: "", + addressType: "", + contactType: "", + fieldName: "postal-code", + }, + { section: "", addressType: "", contactType: "", fieldName: "tel" }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "email", + }, + ], + ], + [ + [ + { + section: "", + addressType: "", + contactType: "", + fieldName: "given-name", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "additional-name", + }, // middle-name initial + { + section: "", + addressType: "", + contactType: "", + fieldName: "family-name", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "organization", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "country", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "address-line1", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "address-line2", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "address-level2", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "address-level1", + }, // state + { + section: "", + addressType: "", + contactType: "", + fieldName: "postal-code", + }, + { section: "", addressType: "", contactType: "", fieldName: "tel" }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "email", + }, + ], + ], + [], + [ + [ + { + section: "", + addressType: "", + contactType: "", + fieldName: "email", + }, + ], + ], + [], + ], + }, + { + fixturePath: "SignIn.html", + expectedResult: [ + [], + [], + [ + [ + { + section: "", + addressType: "", + contactType: "", + fieldName: "email", + }, + ], + ], + [], + [ + [ + // Forgot password + { + section: "", + addressType: "", + contactType: "", + fieldName: "email", + }, + ], + ], + [ + [ + { + section: "", + addressType: "", + contactType: "", + fieldName: "email", + }, + ], + ], + [ + [ + { + section: "", + addressType: "", + contactType: "", + fieldName: "email", + }, + // {"section": "", "addressType": "", "contactType": "", "fieldName": "password"}, + ], + ], + [ + [ + // Sign up + { + section: "", + addressType: "", + contactType: "", + fieldName: "email", + }, + ], + ], + [ + [ + { + section: "", + addressType: "", + contactType: "", + fieldName: "email", + }, + ], + ], + [], + ], + }, + ], + "../../../fixtures/third_party/CostCo/" +); diff --git a/browser/extensions/formautofill/test/unit/heuristics/third_party/test_HomeDepot.js b/browser/extensions/formautofill/test/unit/heuristics/third_party/test_HomeDepot.js new file mode 100644 index 0000000000..1100c29842 --- /dev/null +++ b/browser/extensions/formautofill/test/unit/heuristics/third_party/test_HomeDepot.js @@ -0,0 +1,110 @@ +/* global runHeuristicsTest */ + +"use strict"; + +runHeuristicsTest( + [ + { + fixturePath: "Checkout_ShippingPayment.html", + expectedResult: [ + [ + [ + { + section: "", + addressType: "", + contactType: "", + fieldName: "given-name", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "family-name", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "email", + }, + { section: "", addressType: "", contactType: "", fieldName: "tel" }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "street-address", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "postal-code", + }, + ], + [ + // FIXME: bug 1392944 - the uncommented cc-exp-month and cc-exp-year are + // both invisible <input> elements, and the following two <select> + // elements are the correct ones. BTW, they are both applied + // autocomplete attr. + { + section: "", + addressType: "", + contactType: "", + fieldName: "cc-exp-month", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "cc-exp-year", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "cc-number", + }, + // {"section": "", "addressType": "", "contactType": "", "fieldName": "cc-exp-month"}, + // {"section": "", "addressType": "", "contactType": "", "fieldName": "cc-exp-year"}, + + // {"section": "", "addressType": "", "contactType": "", "fieldName": "cc-csc"}, + ], + [ + { + section: "", + addressType: "billing", + contactType: "", + fieldName: "street-address", + }, // <select> + ], + ], + ], + }, + { + fixturePath: "SignIn.html", + expectedResult: [ + [ + [ + { + section: "", + addressType: "", + contactType: "", + fieldName: "email", + }, + ], + ], + [ + [ + { + section: "", + addressType: "", + contactType: "", + fieldName: "email", + }, + ], + ], + ], + }, + ], + "../../../fixtures/third_party/HomeDepot/" +); diff --git a/browser/extensions/formautofill/test/unit/heuristics/third_party/test_Lush.js b/browser/extensions/formautofill/test/unit/heuristics/third_party/test_Lush.js new file mode 100644 index 0000000000..ffbe40099b --- /dev/null +++ b/browser/extensions/formautofill/test/unit/heuristics/third_party/test_Lush.js @@ -0,0 +1,56 @@ +/* global runHeuristicsTest */ + +"use strict"; + +runHeuristicsTest( + [ + { + fixturePath: "index.html", + expectedResult: [ + [ + [ + { + section: "", + addressType: "", + contactType: "", + fieldName: "cc-name", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "cc-number", + }, + ], + [ + { + section: "", + addressType: "", + contactType: "", + fieldName: "cc-number", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "cc-name", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "cc-exp-month", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "cc-exp-year", + }, + ], + ], + ], + }, + ], + "../../../fixtures/third_party/Lush/" +); diff --git a/browser/extensions/formautofill/test/unit/heuristics/third_party/test_Macys.js b/browser/extensions/formautofill/test/unit/heuristics/third_party/test_Macys.js new file mode 100644 index 0000000000..b496801cd0 --- /dev/null +++ b/browser/extensions/formautofill/test/unit/heuristics/third_party/test_Macys.js @@ -0,0 +1,190 @@ +/* global runHeuristicsTest */ + +"use strict"; + +runHeuristicsTest( + [ + { + fixturePath: "Checkout_ShippingAddress.html", + expectedResult: [ + [ + [ + { + section: "", + addressType: "", + contactType: "", + fieldName: "given-name", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "family-name", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "address-line1", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "address-line2", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "address-level2", + }, // city + { + section: "", + addressType: "", + contactType: "", + fieldName: "address-level1", + }, // state + { + section: "", + addressType: "", + contactType: "", + fieldName: "postal-code", + }, + { section: "", addressType: "", contactType: "", fieldName: "tel" }, + ], + ], + [], + ], + }, + { + fixturePath: "Checkout_Payment.html", + expectedResult: [ + [ + [ + { + section: "", + addressType: "", + contactType: "", + fieldName: "given-name", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "family-name", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "address-line1", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "address-line2", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "address-level2", + }, // city + { + section: "", + addressType: "", + contactType: "", + fieldName: "address-level1", + }, // state + { + section: "", + addressType: "", + contactType: "", + fieldName: "postal-code", + }, + { section: "", addressType: "", contactType: "", fieldName: "tel" }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "email", + }, + ], + [ + { + section: "", + addressType: "", + contactType: "", + fieldName: "cc-type", + }, // ac-off + { + section: "", + addressType: "", + contactType: "", + fieldName: "cc-number", + }, // ac-off + { + section: "", + addressType: "", + contactType: "", + fieldName: "cc-exp-month", + }, // ac-off + { + section: "", + addressType: "", + contactType: "", + fieldName: "cc-exp-year", + }, // ac-off + // {"section": "", "addressType": "", "contactType": "", "fieldName": "cc-csc"}, // ac-off + // {"section": "", "addressType": "", "contactType": "", "fieldName": "cc-csc"}, // ac-off + ], + ], + [], + ], + }, + { + fixturePath: "SignIn.html", + expectedResult: [ + [ + [ + // Sign in + { + section: "", + addressType: "", + contactType: "", + fieldName: "email", + }, + // {"section": "", "addressType": "", "contactType": "", "fieldName": "password"}, + ], + ], + [ + [ + // Forgot password + { + section: "", + addressType: "", + contactType: "", + fieldName: "email", + }, + ], + ], + [ + [ + { + section: "", + addressType: "", + contactType: "", + fieldName: "email", + }, + ], + ], + [], + [], + [], + ], + }, + ], + "../../../fixtures/third_party/Macys/" +); diff --git a/browser/extensions/formautofill/test/unit/heuristics/third_party/test_NewEgg.js b/browser/extensions/formautofill/test/unit/heuristics/third_party/test_NewEgg.js new file mode 100644 index 0000000000..46ebcf81e5 --- /dev/null +++ b/browser/extensions/formautofill/test/unit/heuristics/third_party/test_NewEgg.js @@ -0,0 +1,232 @@ +/* global runHeuristicsTest */ + +"use strict"; + +runHeuristicsTest( + [ + { + fixturePath: "ShippingInfo.html", + expectedResult: [ + [ + [ + { + section: "", + addressType: "", + contactType: "", + fieldName: "given-name", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "family-name", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "country", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "address-line1", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "address-line2", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "address-level2", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "address-level1", + }, // state + { + section: "", + addressType: "", + contactType: "", + fieldName: "postal-code", + }, + { section: "", addressType: "", contactType: "", fieldName: "tel" }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "email", + }, + ], + ], + [], + ], + }, + { + fixturePath: "BillingInfo.html", + expectedResult: [ + [ + [ + { + section: "", + addressType: "", + contactType: "", + fieldName: "cc-name", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "cc-number", + }, // ac-off + ], + [ + { + section: "", + addressType: "", + contactType: "", + fieldName: "country", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "address-line1", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "address-line2", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "address-level2", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "address-level1", + }, // state + { + section: "", + addressType: "", + contactType: "", + fieldName: "postal-code", + }, + { section: "", addressType: "", contactType: "", fieldName: "tel" }, + ], + [ + { + section: "", + addressType: "", + contactType: "", + fieldName: "cc-name", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "cc-number", + }, // ac-off + { + section: "", + addressType: "", + contactType: "", + fieldName: "cc-exp-month", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "cc-exp-year", + }, + // {"section": "", "addressType": "", "contactType": "", "fieldName": "cc-csc"}, + ], + ], + [ + [ + { + section: "", + addressType: "", + contactType: "", + fieldName: "cc-name", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "cc-number", + }, // ac-off + { + section: "", + addressType: "", + contactType: "", + fieldName: "cc-exp-month", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "cc-exp-year", + }, + ], + ], + [], + [ + [ + { + section: "", + addressType: "", + contactType: "", + fieldName: "cc-name", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "cc-number", + }, // ac-off + ], + ], + ], + }, + { + fixturePath: "Login.html", + expectedResult: [ + [ + [ + { + section: "", + addressType: "", + contactType: "", + fieldName: "email", + }, + ], + ], + [], + [ + [ + { + section: "", + addressType: "", + contactType: "", + fieldName: "email", + }, + ], + ], + ], + }, + ], + "../../../fixtures/third_party/NewEgg/" +); diff --git a/browser/extensions/formautofill/test/unit/heuristics/third_party/test_OfficeDepot.js b/browser/extensions/formautofill/test/unit/heuristics/third_party/test_OfficeDepot.js new file mode 100644 index 0000000000..57ad77d3be --- /dev/null +++ b/browser/extensions/formautofill/test/unit/heuristics/third_party/test_OfficeDepot.js @@ -0,0 +1,219 @@ +/* global runHeuristicsTest */ + +"use strict"; + +runHeuristicsTest( + [ + { + fixturePath: "ShippingAddress.html", + expectedResult: [ + [ + [ + { + section: "", + addressType: "", + contactType: "", + fieldName: "given-name", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "family-name", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "organization", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "address-line1", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "address-line2", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "postal-code", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "address-level2", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "address-level1", + }, // state + + { + section: "", + addressType: "", + contactType: "", + fieldName: "tel-area-code", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "tel-local-prefix", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "tel-local-suffix", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "tel-extension", + }, + + { + section: "", + addressType: "", + contactType: "", + fieldName: "email", + }, + ], + ], + [], + ], + }, + { + fixturePath: "Payment.html", + expectedResult: [ + [ + [ + { + section: "", + addressType: "", + contactType: "", + fieldName: "given-name", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "family-name", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "organization", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "address-line1", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "address-line2", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "postal-code", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "address-level2", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "address-level1", + }, // state + + { + section: "", + addressType: "", + contactType: "", + fieldName: "tel-area-code", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "tel-local-prefix", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "tel-local-suffix", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "tel-extension", + }, + + { + section: "", + addressType: "", + contactType: "", + fieldName: "email", + }, + ], + [ + { + section: "", + addressType: "", + contactType: "", + fieldName: "cc-exp-month", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "cc-exp-year", + }, + // FIXME: bug 1392950 - the membership number should not be detected + // as cc-number. + { + section: "", + addressType: "", + contactType: "", + fieldName: "cc-number", + }, + ], + ], + ], + }, + { + fixturePath: "SignIn.html", + expectedResult: [ + [ + // ac-off + // {"section": "", "addressType": "", "contactType": "", "fieldName": "email"}, + ], + ], + }, + ], + "../../../fixtures/third_party/OfficeDepot/" +); diff --git a/browser/extensions/formautofill/test/unit/heuristics/third_party/test_QVC.js b/browser/extensions/formautofill/test/unit/heuristics/third_party/test_QVC.js new file mode 100644 index 0000000000..de359cba20 --- /dev/null +++ b/browser/extensions/formautofill/test/unit/heuristics/third_party/test_QVC.js @@ -0,0 +1,143 @@ +/* global runHeuristicsTest */ + +"use strict"; + +runHeuristicsTest( + [ + { + fixturePath: "YourInformation.html", + expectedResult: [ + [ + [ + // {"section": "", "addressType": "", "contactType": "", "fieldName": "tel"}, // ac-off + { + section: "", + addressType: "", + contactType: "", + fieldName: "email", + }, + // {"section": "", "addressType": "", "contactType": "", "fieldName": "bday-month"}, // select + // {"section": "", "addressType": "", "contactType": "", "fieldName": "bday-day"}, // select + // {"section": "", "addressType": "", "contactType": "", "fieldName": "bday-year"}, + ], + [ + { + section: "", + addressType: "", + contactType: "", + fieldName: "cc-type", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "cc-number", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "cc-exp", + }, + // {"section": "", "addressType": "", "contactType": "", "fieldName": "cc-csc"}, + ], + [ + { + section: "", + addressType: "", + contactType: "", + fieldName: "cc-number", + }, + ], + ], + [ + [ + { + section: "", + addressType: "", + contactType: "", + fieldName: "email", + }, + ], + ], + ], + }, + { + fixturePath: "PaymentMethod.html", + expectedResult: [ + [ + [ + // {"section": "", "addressType": "", "contactType": "", "fieldName": "tel"}, // ac-off + { + section: "", + addressType: "", + contactType: "", + fieldName: "email", + }, + // {"section": "", "addressType": "", "contactType": "", "fieldName": "bday-month"}, // select + // {"section": "", "addressType": "", "contactType": "", "fieldName": "bday-day"}, // select + // {"section": "", "addressType": "", "contactType": "", "fieldName": "bday-year"}, // select + ], + [ + { + section: "", + addressType: "", + contactType: "", + fieldName: "cc-type", + }, // ac-off + { + section: "", + addressType: "", + contactType: "", + fieldName: "cc-number", + }, // ac-off + { + section: "", + addressType: "", + contactType: "", + fieldName: "cc-exp", + }, + // {"section": "", "addressType": "", "contactType": "", "fieldName": "cc-csc"}, + ], + [ + { + section: "", + addressType: "", + contactType: "", + fieldName: "cc-number", + }, // ac-off + ], + ], + [ + [ + { + section: "", + addressType: "", + contactType: "", + fieldName: "email", + }, + ], + ], + ], + }, + { + fixturePath: "SignIn.html", + expectedResult: [ + [], + [ + [ + // Sign in + { + section: "", + addressType: "", + contactType: "", + fieldName: "email", + }, + ], + ], + [], + ], + }, + ], + "../../../fixtures/third_party/QVC/" +); diff --git a/browser/extensions/formautofill/test/unit/heuristics/third_party/test_Sears.js b/browser/extensions/formautofill/test/unit/heuristics/third_party/test_Sears.js new file mode 100644 index 0000000000..493db2487e --- /dev/null +++ b/browser/extensions/formautofill/test/unit/heuristics/third_party/test_Sears.js @@ -0,0 +1,196 @@ +/* global runHeuristicsTest */ + +"use strict"; + +runHeuristicsTest( + [ + { + fixturePath: "ShippingAddress.html", + expectedResult: [ + [], + [], // search form, ac-off + [ + // ac-off + // {"section": "", "addressType": "", "contactType": "", "fieldName": "email"}, + ], + [ + // check-out, ac-off + /* + {"section": "", "addressType": "", "contactType": "", "fieldName": "given-name"}, + {"section": "", "addressType": "", "contactType": "", "fieldName": "family-name"}, + {"section": "", "addressType": "", "contactType": "", "fieldName": "address-line1"}, + {"section": "", "addressType": "", "contactType": "", "fieldName": "address-line2"}, + {"section": "", "addressType": "", "contactType": "", "fieldName": "address-level2"}, + {"section": "", "addressType": "", "contactType": "", "fieldName": "address-level1"}, + {"section": "", "addressType": "", "contactType": "", "fieldName": "postal-code"}, + {"section": "", "addressType": "", "contactType": "", "fieldName": "tel"}, +// {"section": "", "addressType": "", "contactType": "", "fieldName": "tel-extension"}, + {"section": "", "addressType": "", "contactType": "", "fieldName": "email"}, +*/ + ], + [ + // ac-off + /* + {"section": "", "addressType": "", "contactType": "", "fieldName": "email"}, + {"section": "", "addressType": "", "contactType": "", "fieldName": "given-name"}, + {"section": "", "addressType": "", "contactType": "", "fieldName": "family-name"}, + {"section": "", "addressType": "", "contactType": "", "fieldName": "address-line1"}, + {"section": "", "addressType": "", "contactType": "", "fieldName": "address-line2"}, + {"section": "", "addressType": "", "contactType": "", "fieldName": "address-level2"}, + {"section": "", "addressType": "", "contactType": "", "fieldName": "address-level1"}, + {"section": "", "addressType": "", "contactType": "", "fieldName": "postal-code"}, + {"section": "", "addressType": "", "contactType": "", "fieldName": "tel"}, + {"section": "", "addressType": "", "contactType": "", "fieldName": "tel-extension"}, +// {"section": "", "addressType": "", "contactType": "", "fieldName": "new-password"}, +*/ + ], + [ + // ac-off + // {"section": "", "addressType": "", "contactType": "", "fieldName": "email"}, + ], + [ + // ac-off + // {"section": "", "addressType": "", "contactType": "", "fieldName": "email"}, + ], + ], + }, + { + fixturePath: "PaymentOptions.html", + expectedResult: [ + [], + [], // search + [ + [ + // credit card + { + section: "", + addressType: "", + contactType: "", + fieldName: "cc-number", + }, + // FIXME: bug 1392958 - Cardholder name field should be detected as cc-name + // {"section": "", "addressType": "", "contactType": "", "fieldName": "cc-name"}, + // {"section": "", "addressType": "", "contactType": "", "fieldName": "cc-csc"}, + { + section: "", + addressType: "", + contactType: "", + fieldName: "cc-exp-month", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "cc-exp-year", + }, + ], + ], + [ + [ + // Another billing address + { + section: "", + addressType: "", + contactType: "", + fieldName: "given-name", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "family-name", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "address-line1", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "address-line2", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "address-level2", + }, // city + { + section: "", + addressType: "", + contactType: "", + fieldName: "address-level1", + }, // state + { + section: "", + addressType: "", + contactType: "", + fieldName: "postal-code", + }, + { section: "", addressType: "", contactType: "", fieldName: "tel" }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "tel-extension", + }, + ], + ], + [ + [ + // check out + { + section: "", + addressType: "", + contactType: "", + fieldName: "given-name", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "family-name", + }, + + // FIXME: bug 1392934 - this should be detected as address-level1 since + // it's for Driver's license or state identification. + { + section: "", + addressType: "", + contactType: "", + fieldName: "address-level1", + }, + + // {"section": "", "addressType": "", "contactType": "", "fieldName": "bday-month"}, + // {"section": "", "addressType": "", "contactType": "", "fieldName": "bday-day"}, + // {"section": "", "addressType": "", "contactType": "", "fieldName": "bday-year"}, + ], + [ + // FIXME: bug 1392950 - the bank routing number should not be detected + // as cc-number. + { + section: "", + addressType: "", + contactType: "", + fieldName: "cc-number", + }, + ], + ], + [ + [ + { + section: "", + addressType: "", + contactType: "", + fieldName: "email", + }, + ], + ], + ], + }, + ], + "../../../fixtures/third_party/Sears/" +); diff --git a/browser/extensions/formautofill/test/unit/heuristics/third_party/test_Staples.js b/browser/extensions/formautofill/test/unit/heuristics/third_party/test_Staples.js new file mode 100644 index 0000000000..62684ba21b --- /dev/null +++ b/browser/extensions/formautofill/test/unit/heuristics/third_party/test_Staples.js @@ -0,0 +1,109 @@ +/* global runHeuristicsTest */ + +"use strict"; + +runHeuristicsTest( + [ + { + fixturePath: "Basic.html", + expectedResult: [ + [ + // ac-off + // {"section": "", "addressType": "", "contactType": "", "fieldName": "given-name"}, + // {"section": "", "addressType": "", "contactType": "", "fieldName": "family-name"}, + // {"section": "", "addressType": "", "contactType": "", "fieldName": "address-line1"}, + // {"section": "", "addressType": "", "contactType": "", "fieldName": "email"}, + // {"section": "", "addressType": "", "contactType": "", "fieldName": "tel"}, + // {"section": "", "addressType": "", "contactType": "", "fieldName": "tel-extension"}, + // {"section": "", "addressType": "", "contactType": "", "fieldName": "organization"}, + ], + ], + }, + { + fixturePath: "Basic_ac_on.html", + expectedResult: [ + [ + [ + { + section: "", + addressType: "", + contactType: "", + fieldName: "given-name", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "family-name", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "address-line1", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "email", + }, + { section: "", addressType: "", contactType: "", fieldName: "tel" }, + // {"section": "", "addressType": "", "contactType": "", "fieldName": "tel-extension"}, + { + section: "", + addressType: "", + contactType: "", + fieldName: "organization", + }, + ], + ], + ], + }, + { + fixturePath: "PaymentBilling.html", + expectedResult: [ + [ + [ + { + section: "", + addressType: "", + contactType: "", + fieldName: "cc-number", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "cc-exp", + }, + // {"section": "", "addressType": "", "contactType": "", "fieldName": "cc-csc"}, + ], + ], + ], + }, + { + fixturePath: "PaymentBilling_ac_on.html", + expectedResult: [ + [ + [ + { + section: "", + addressType: "", + contactType: "", + fieldName: "cc-number", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "cc-exp", + }, + // {"section": "", "addressType": "", "contactType": "", "fieldName": "cc-csc"}, + ], + ], + ], + }, + ], + "../../../fixtures/third_party/Staples/" +); diff --git a/browser/extensions/formautofill/test/unit/heuristics/third_party/test_Walmart.js b/browser/extensions/formautofill/test/unit/heuristics/third_party/test_Walmart.js new file mode 100644 index 0000000000..82705b9d91 --- /dev/null +++ b/browser/extensions/formautofill/test/unit/heuristics/third_party/test_Walmart.js @@ -0,0 +1,167 @@ +/* global runHeuristicsTest */ + +"use strict"; + +runHeuristicsTest( + [ + { + fixturePath: "Checkout.html", + expectedResult: [ + [ + [ + { + section: "", + addressType: "", + contactType: "", + fieldName: "postal-code", + }, + ], + ], + [], + [ + [ + { + section: "", + addressType: "", + contactType: "", + fieldName: "email", + }, + // {"section": "", "addressType": "", "contactType": "", "fieldName": "password"}, // ac-off + ], + ], + [ + [ + { + section: "", + addressType: "", + contactType: "", + fieldName: "given-name", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "family-name", + }, + // {"section": "", "addressType": "", "contactType": "", "fieldName": "email"}, // ac-off + // {"section": "", "addressType": "", "contactType": "", "fieldName": "password"}, + // {"section": "", "addressType": "", "contactType": "", "fieldName": "password"}, // ac-off + ], + ], + ], + }, + { + fixturePath: "Payment.html", + expectedResult: [ + [], + [ + [ + { + section: "section-payment", + addressType: "", + contactType: "", + fieldName: "given-name", + }, + { + section: "section-payment", + addressType: "", + contactType: "", + fieldName: "family-name", + }, + { + section: "section-payment", + addressType: "", + contactType: "", + fieldName: "tel", + }, + ], + [ + { + section: "section-payment", + addressType: "", + contactType: "", + fieldName: "cc-number", + }, + { + section: "section-payment", + addressType: "", + contactType: "", + fieldName: "cc-exp-month", + }, + { + section: "section-payment", + addressType: "", + contactType: "", + fieldName: "cc-exp-year", + }, + // {"section": "section-payment", "addressType": "", "contactType": "", "fieldName": "cc-csc"}, + ], + ], + ], + }, + { + fixturePath: "Shipping.html", + expectedResult: [ + [ + [ + { + section: "", + addressType: "", + contactType: "", + fieldName: "postal-code", + }, + ], + ], + [], + [ + [ + { + section: "", + addressType: "", + contactType: "", + fieldName: "given-name", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "family-name", + }, + { section: "", addressType: "", contactType: "", fieldName: "tel" }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "address-line1", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "address-line2", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "address-level2", + }, // city + { + section: "", + addressType: "", + contactType: "", + fieldName: "address-level1", + }, // state + { + section: "", + addressType: "", + contactType: "", + fieldName: "postal-code", + }, + ], + ], + ], + }, + ], + "../../../fixtures/third_party/Walmart/" +); diff --git a/browser/extensions/formautofill/test/unit/test_activeStatus.js b/browser/extensions/formautofill/test/unit/test_activeStatus.js new file mode 100644 index 0000000000..1b7bee8f97 --- /dev/null +++ b/browser/extensions/formautofill/test/unit/test_activeStatus.js @@ -0,0 +1,176 @@ +/* + * Test for status handling in Form Autofill Parent. + */ + +"use strict"; + +let FormAutofillStatus; + +add_task(async function setup() { + ({ FormAutofillStatus } = ChromeUtils.import( + "resource://formautofill/FormAutofillParent.jsm" + )); +}); + +add_task(async function test_activeStatus_init() { + sinon.spy(FormAutofillStatus, "updateStatus"); + + // Default status is null before initialization + Assert.equal(FormAutofillStatus._active, null); + Assert.equal(Services.ppmm.sharedData.get("FormAutofill:enabled"), undefined); + + FormAutofillStatus.init(); + // init shouldn't call updateStatus since that requires storage which will + // lead to startup time regressions. + Assert.equal(FormAutofillStatus.updateStatus.called, false); + Assert.equal(Services.ppmm.sharedData.get("FormAutofill:enabled"), undefined); + + // Initialize profile storage + await FormAutofillStatus.formAutofillStorage.initialize(); + // Upon first initializing profile storage, status should be computed. + Assert.equal(FormAutofillStatus.updateStatus.called, true); + Assert.equal(Services.ppmm.sharedData.get("FormAutofill:enabled"), false); + + FormAutofillStatus.uninit(); +}); + +add_task(async function test_activeStatus_observe() { + FormAutofillStatus.init(); + sinon.stub(FormAutofillStatus, "computeStatus"); + sinon.spy(FormAutofillStatus, "onStatusChanged"); + + // _active = _computeStatus() => No need to trigger _onStatusChanged + FormAutofillStatus._active = true; + FormAutofillStatus.computeStatus.returns(true); + FormAutofillStatus.observe( + null, + "nsPref:changed", + "extensions.formautofill.addresses.enabled" + ); + FormAutofillStatus.observe( + null, + "nsPref:changed", + "extensions.formautofill.creditCards.enabled" + ); + Assert.equal(FormAutofillStatus.onStatusChanged.called, false); + + // _active != computeStatus() => Need to trigger onStatusChanged + FormAutofillStatus.computeStatus.returns(false); + FormAutofillStatus.onStatusChanged.resetHistory(); + FormAutofillStatus.observe( + null, + "nsPref:changed", + "extensions.formautofill.addresses.enabled" + ); + FormAutofillStatus.observe( + null, + "nsPref:changed", + "extensions.formautofill.creditCards.enabled" + ); + Assert.equal(FormAutofillStatus.onStatusChanged.called, true); + + // profile changed => Need to trigger _onStatusChanged + await Promise.all( + ["add", "update", "remove", "reconcile"].map(async event => { + FormAutofillStatus.computeStatus.returns(!FormAutofillStatus._active); + FormAutofillStatus.onStatusChanged.resetHistory(); + await FormAutofillStatus.observe( + null, + "formautofill-storage-changed", + event + ); + Assert.equal(FormAutofillStatus.onStatusChanged.called, true); + }) + ); + + // profile metadata updated => No need to trigger onStatusChanged + FormAutofillStatus.computeStatus.returns(!FormAutofillStatus._active); + FormAutofillStatus.onStatusChanged.resetHistory(); + await FormAutofillStatus.observe( + null, + "formautofill-storage-changed", + "notifyUsed" + ); + Assert.equal(FormAutofillStatus.onStatusChanged.called, false); + + FormAutofillStatus.computeStatus.restore(); +}); + +add_task(async function test_activeStatus_computeStatus() { + registerCleanupFunction(function cleanup() { + Services.prefs.clearUserPref("extensions.formautofill.addresses.enabled"); + Services.prefs.clearUserPref("extensions.formautofill.creditCards.enabled"); + }); + + sinon.stub( + FormAutofillStatus.formAutofillStorage.addresses, + "getSavedFieldNames" + ); + FormAutofillStatus.formAutofillStorage.addresses.getSavedFieldNames.returns( + new Set() + ); + sinon.stub( + FormAutofillStatus.formAutofillStorage.creditCards, + "getSavedFieldNames" + ); + FormAutofillStatus.formAutofillStorage.creditCards.getSavedFieldNames.returns( + new Set() + ); + + // pref is enabled and profile is empty. + Services.prefs.setBoolPref("extensions.formautofill.addresses.enabled", true); + Services.prefs.setBoolPref( + "extensions.formautofill.creditCards.enabled", + true + ); + Assert.equal(FormAutofillStatus.computeStatus(), false); + + // pref is disabled and profile is empty. + Services.prefs.setBoolPref( + "extensions.formautofill.addresses.enabled", + false + ); + Services.prefs.setBoolPref( + "extensions.formautofill.creditCards.enabled", + false + ); + Assert.equal(FormAutofillStatus.computeStatus(), false); + + FormAutofillStatus.formAutofillStorage.addresses.getSavedFieldNames.returns( + new Set(["given-name"]) + ); + FormAutofillStatus.observe(null, "formautofill-storage-changed", "add"); + + // pref is enabled and profile is not empty. + Services.prefs.setBoolPref("extensions.formautofill.addresses.enabled", true); + Services.prefs.setBoolPref("extensions.formautofill.addresses.enabled", true); + Assert.equal(FormAutofillStatus.computeStatus(), true); + + // pref is partial enabled and profile is not empty. + Services.prefs.setBoolPref("extensions.formautofill.addresses.enabled", true); + Services.prefs.setBoolPref( + "extensions.formautofill.creditCards.enabled", + false + ); + Assert.equal(FormAutofillStatus.computeStatus(), true); + Services.prefs.setBoolPref( + "extensions.formautofill.addresses.enabled", + false + ); + Services.prefs.setBoolPref( + "extensions.formautofill.creditCards.enabled", + true + ); + Assert.equal(FormAutofillStatus.computeStatus(), true); + + // pref is disabled and profile is not empty. + Services.prefs.setBoolPref( + "extensions.formautofill.addresses.enabled", + false + ); + Services.prefs.setBoolPref( + "extensions.formautofill.creditCards.enabled", + false + ); + Assert.equal(FormAutofillStatus.computeStatus(), false); +}); diff --git a/browser/extensions/formautofill/test/unit/test_addressDataLoader.js b/browser/extensions/formautofill/test/unit/test_addressDataLoader.js new file mode 100644 index 0000000000..8085580477 --- /dev/null +++ b/browser/extensions/formautofill/test/unit/test_addressDataLoader.js @@ -0,0 +1,102 @@ +"use strict"; + +const SUPPORT_COUNTRIES_TESTCASES = [ + { + country: "US", + properties: ["languages", "alternative_names", "sub_keys", "sub_names"], + }, + { + country: "CA", + properties: ["languages", "name", "sub_keys", "sub_names"], + }, + { + country: "DE", + properties: ["name"], + }, +]; + +var AddressDataLoader, FormAutofillUtils; +add_task(async function setup() { + ({ AddressDataLoader, FormAutofillUtils } = ChromeUtils.import( + "resource://formautofill/FormAutofillUtils.jsm" + )); +}); + +add_task(async function test_initalState() { + // addressData should not exist + Assert.equal(AddressDataLoader._addressData, undefined); + // Verify _dataLoaded state + Assert.equal(AddressDataLoader._dataLoaded.country, false); + Assert.equal(AddressDataLoader._dataLoaded.level1.size, 0); +}); + +add_task(async function test_loadDataState() { + sinon.spy(AddressDataLoader, "_loadScripts"); + let metadata = FormAutofillUtils.getCountryAddressData("US"); + Assert.ok(AddressDataLoader._addressData, "addressData exists"); + // Verify _dataLoaded state + Assert.equal(AddressDataLoader._dataLoaded.country, true); + Assert.equal(AddressDataLoader._dataLoaded.level1.size, 0); + // _loadScripts should be called + sinon.assert.called(AddressDataLoader._loadScripts); + // Verify metadata + Assert.equal(metadata.id, "data/US"); + Assert.ok( + metadata.alternative_names, + "US alternative names should be loaded from extension" + ); + AddressDataLoader._loadScripts.resetHistory(); + + // Load data without country + let newMetadata = FormAutofillUtils.getCountryAddressData(); + // _loadScripts should not be called + sinon.assert.notCalled(AddressDataLoader._loadScripts); + Assert.deepEqual( + metadata, + newMetadata, + "metadata should be US if country is not specified" + ); + AddressDataLoader._loadScripts.resetHistory(); + + // Load level 1 data that does not exist + let undefinedMetadata = FormAutofillUtils.getCountryAddressData("US", "CA"); + // _loadScripts should be called + sinon.assert.called(AddressDataLoader._loadScripts); + Assert.equal(undefinedMetadata, undefined, "metadata should be undefined"); + Assert.ok( + AddressDataLoader._dataLoaded.level1.has("US"), + "level 1 state array should be set even there's no valid metadata" + ); + AddressDataLoader._loadScripts.resetHistory(); + + // Load level 1 data again + undefinedMetadata = FormAutofillUtils.getCountryAddressData("US", "AS"); + Assert.equal(undefinedMetadata, undefined, "metadata should be undefined"); + // _loadScripts should not be called + sinon.assert.notCalled(AddressDataLoader._loadScripts); +}); + +SUPPORT_COUNTRIES_TESTCASES.forEach(testcase => { + add_task(async function test_support_country() { + info("Starting testcase: Check " + testcase.country + " metadata"); + let metadata = FormAutofillUtils.getCountryAddressData(testcase.country); + Assert.ok( + testcase.properties.every(key => metadata[key]), + "These properties should exist: " + testcase.properties + ); + // Verify the multi-locale country + if (metadata.languages && metadata.languages.length > 1) { + let locales = FormAutofillUtils.getCountryAddressDataWithLocales( + testcase.country + ); + Assert.equal( + metadata.languages.length, + locales.length, + "Total supported locales should be matched" + ); + metadata.languages.forEach((lang, index) => { + Assert.equal(lang, locales[index].lang, `Should support ${lang}`); + }); + } + }); +}); diff --git a/browser/extensions/formautofill/test/unit/test_addressRecords.js b/browser/extensions/formautofill/test/unit/test_addressRecords.js new file mode 100644 index 0000000000..c479ff5b3a --- /dev/null +++ b/browser/extensions/formautofill/test/unit/test_addressRecords.js @@ -0,0 +1,848 @@ +/** + * Tests FormAutofillStorage object with addresses records. + */ + +"use strict"; + +const TEST_STORE_FILE_NAME = "test-profile.json"; +const COLLECTION_NAME = "addresses"; + +const TEST_ADDRESS_1 = { + "given-name": "Timothy", + "additional-name": "John", + "family-name": "Berners-Lee", + organization: "World Wide Web Consortium", + "street-address": "32 Vassar Street\nMIT Room 32-G524", + "address-level2": "Cambridge", + "address-level1": "MA", + "postal-code": "02139", + country: "US", + tel: "+16172535702", + email: "timbl@w3.org", +}; + +const TEST_ADDRESS_2 = { + "street-address": "Some Address", + country: "US", +}; + +const TEST_ADDRESS_3 = { + "given-name": "Timothy", + "family-name": "Berners-Lee", + "street-address": "Other Address", + "postal-code": "12345", +}; + +const TEST_ADDRESS_4 = { + "given-name": "Timothy", + "additional-name": "John", + "family-name": "Berners-Lee", + organization: "World Wide Web Consortium", +}; + +const TEST_ADDRESS_WITH_EMPTY_FIELD = { + name: "Tim Berners", + "street-address": "", +}; + +const TEST_ADDRESS_WITH_EMPTY_COMPUTED_FIELD = { + name: "", + "address-line1": "", + "address-line2": "", + "address-line3": "", + "country-name": "", + "tel-country-code": "", + "tel-national": "", + "tel-area-code": "", + "tel-local": "", + "tel-local-prefix": "", + "tel-local-suffix": "", + email: "timbl@w3.org", +}; + +const TEST_ADDRESS_WITH_INVALID_FIELD = { + "street-address": "Another Address", + invalidField: "INVALID", +}; + +const TEST_ADDRESS_EMPTY_AFTER_NORMALIZE = { + country: "XXXXXX", +}; + +const TEST_ADDRESS_EMPTY_AFTER_UPDATE_ADDRESS_2 = { + "street-address": "", + country: "XXXXXX", +}; + +const MERGE_TESTCASES = [ + { + description: "Merge a superset", + addressInStorage: { + "given-name": "Timothy", + "street-address": "331 E. Evelyn Avenue", + tel: "+16509030800", + }, + addressToMerge: { + "given-name": "Timothy", + "street-address": "331 E. Evelyn Avenue", + tel: "+16509030800", + country: "US", + }, + expectedAddress: { + "given-name": "Timothy", + "street-address": "331 E. Evelyn Avenue", + tel: "+16509030800", + country: "US", + }, + }, + { + description: "Loose merge a subset", + addressInStorage: { + "given-name": "Timothy", + "street-address": "331 E. Evelyn Avenue", + tel: "+16509030800", + country: "US", + }, + addressToMerge: { + "given-name": "Timothy", + "street-address": "331 E. Evelyn Avenue", + tel: "+16509030800", + }, + expectedAddress: { + "given-name": "Timothy", + "street-address": "331 E. Evelyn Avenue", + tel: "+16509030800", + country: "US", + }, + noNeedToUpdate: true, + }, + { + description: "Strict merge a subset without empty string", + addressInStorage: { + "given-name": "Timothy", + "street-address": "331 E. Evelyn Avenue", + tel: "+16509030800", + country: "US", + }, + addressToMerge: { + "given-name": "Timothy", + "street-address": "331 E. Evelyn Avenue", + tel: "+16509030800", + }, + expectedAddress: { + "given-name": "Timothy", + "street-address": "331 E. Evelyn Avenue", + tel: "+16509030800", + country: "US", + }, + strict: true, + noNeedToUpdate: true, + }, + { + description: "Merge an address with partial overlaps", + addressInStorage: { + "given-name": "Timothy", + "street-address": "331 E. Evelyn Avenue", + tel: "+16509030800", + }, + addressToMerge: { + "street-address": "331 E. Evelyn Avenue", + tel: "+16509030800", + country: "US", + }, + expectedAddress: { + "given-name": "Timothy", + "street-address": "331 E. Evelyn Avenue", + tel: "+16509030800", + country: "US", + }, + }, + { + description: + "Merge an address with multi-line street-address in storage and single-line incoming one", + addressInStorage: { + "given-name": "Timothy", + "street-address": "331 E. Evelyn Avenue\nLine2", + tel: "+16509030800", + }, + addressToMerge: { + "street-address": "331 E. Evelyn Avenue Line2", + tel: "+16509030800", + country: "US", + }, + expectedAddress: { + "given-name": "Timothy", + "street-address": "331 E. Evelyn Avenue\nLine2", + tel: "+16509030800", + country: "US", + }, + }, + { + description: + "Merge an address with 3-line street-address in storage and 2-line incoming one", + addressInStorage: { + "given-name": "Timothy", + "street-address": "331 E. Evelyn Avenue\nLine2\nLine3", + tel: "+16509030800", + }, + addressToMerge: { + "street-address": "331 E. Evelyn Avenue\nLine2 Line3", + tel: "+16509030800", + country: "US", + }, + expectedAddress: { + "given-name": "Timothy", + "street-address": "331 E. Evelyn Avenue\nLine2\nLine3", + tel: "+16509030800", + country: "US", + }, + }, + { + description: + "Merge an address with single-line street-address in storage and multi-line incoming one", + addressInStorage: { + "given-name": "Timothy", + "street-address": "331 E. Evelyn Avenue Line2", + tel: "+16509030800", + }, + addressToMerge: { + "street-address": "331 E. Evelyn Avenue\nLine2", + tel: "+16509030800", + country: "US", + }, + expectedAddress: { + "given-name": "Timothy", + "street-address": "331 E. Evelyn Avenue\nLine2", + tel: "+16509030800", + country: "US", + }, + }, + { + description: + "Merge an address with 2-line street-address in storage and 3-line incoming one", + addressInStorage: { + "given-name": "Timothy", + "street-address": "331 E. Evelyn Avenue\nLine2 Line3", + tel: "+16509030800", + }, + addressToMerge: { + "street-address": "331 E. Evelyn Avenue\nLine2\nLine3", + tel: "+16509030800", + country: "US", + }, + expectedAddress: { + "given-name": "Timothy", + "street-address": "331 E. Evelyn Avenue\nLine2\nLine3", + tel: "+16509030800", + country: "US", + }, + }, + { + description: "Merge an address with the same amount of lines", + addressInStorage: { + "given-name": "Timothy", + "street-address": "331 E. Evelyn Avenue\nLine2\nLine3", + tel: "+16509030800", + }, + addressToMerge: { + "street-address": "331 E. Evelyn\nAvenue Line2\nLine3", + tel: "+16509030800", + country: "US", + }, + expectedAddress: { + "given-name": "Timothy", + "street-address": "331 E. Evelyn Avenue\nLine2\nLine3", + tel: "+16509030800", + country: "US", + }, + }, + { + description: + "Merge an address with superfluous external and internal whitespace in the street-address", + addressInStorage: { + "given-name": "Timothy", + "street-address": "331 E. Evelyn Avenue\nLine2\nLine3", + tel: "+16509030800", + }, + addressToMerge: { + "street-address": " 331 E. Evelyn\n Avenue Line2\n Line3 ", + country: "US", + }, + expectedAddress: { + "given-name": "Timothy", + "street-address": "331 E. Evelyn Avenue\nLine2\nLine3", + tel: "+16509030800", + country: "US", + }, + }, + { + description: "Merge an address with collapsed whitespace", + addressInStorage: { + "given-name": "Timothy", + "street-address": "331 E. Evelyn Avenue", + tel: "+16509030800", + }, + addressToMerge: { + "street-address": "331 E.Evelyn Avenue", + country: "US", + }, + expectedAddress: { + "given-name": "Timothy", + "street-address": "331 E. Evelyn Avenue", + tel: "+16509030800", + country: "US", + }, + }, + { + description: "Merge an address with punctuation and mIxEd-cAsE", + addressInStorage: { + "given-name": "Timothy", + "street-address": "331 E. Evelyn Avenue", + tel: "+16509030800", + }, + addressToMerge: { + "street-address": "331.e.EVELYN AVENUE", + country: "US", + }, + expectedAddress: { + "given-name": "Timothy", + "street-address": "331 E. Evelyn Avenue", + tel: "+16509030800", + country: "US", + }, + }, + { + description: "Merge an address with accent characters", + addressInStorage: { + "given-name": "Timothy", + "street-address": "331 E. Evelyn Straße", + tel: "+16509030800", + }, + addressToMerge: { + "street-address": "331.e.EVELYN Strasse", + country: "US", + }, + expectedAddress: { + "given-name": "Timothy", + "street-address": "331 E. Evelyn Straße", + tel: "+16509030800", + country: "US", + }, + }, + { + description: "Merge an address with a mIxEd-cAsE name", + addressInStorage: { + "given-name": "Timothy", + tel: "+16509030800", + }, + addressToMerge: { + "given-name": "TIMOTHY", + tel: "+16509030800", + country: "US", + }, + expectedAddress: { + "given-name": "Timothy", + tel: "+16509030800", + country: "US", + }, + }, +]; + +ChromeUtils.defineModuleGetter( + this, + "Preferences", + "resource://gre/modules/Preferences.jsm" +); + +let do_check_record_matches = (recordWithMeta, record) => { + for (let key in record) { + Assert.equal(recordWithMeta[key], record[key]); + } +}; + +add_task(async function test_initialize() { + let profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME); + + Assert.equal(profileStorage._store.data.version, 1); + Assert.equal(profileStorage._store.data.addresses.length, 0); + + let data = profileStorage._store.data; + Assert.deepEqual(data.addresses, []); + + await profileStorage._saveImmediately(); + + profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME); + + Assert.deepEqual(profileStorage._store.data, data); + for (let { _sync } of profileStorage._store.data.addresses) { + Assert.ok(_sync); + Assert.equal(_sync.changeCounter, 1); + } +}); + +add_task(async function test_getAll() { + let profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME, [ + TEST_ADDRESS_1, + TEST_ADDRESS_2, + ]); + + let addresses = await profileStorage.addresses.getAll(); + + Assert.equal(addresses.length, 2); + do_check_record_matches(addresses[0], TEST_ADDRESS_1); + do_check_record_matches(addresses[1], TEST_ADDRESS_2); + + // Check computed fields. + Assert.equal(addresses[0].name, "Timothy John Berners-Lee"); + Assert.equal(addresses[0]["address-line1"], "32 Vassar Street"); + Assert.equal(addresses[0]["address-line2"], "MIT Room 32-G524"); + + // Test with rawData set. + addresses = await profileStorage.addresses.getAll({ rawData: true }); + Assert.equal(addresses[0].name, undefined); + Assert.equal(addresses[0]["address-line1"], undefined); + Assert.equal(addresses[0]["address-line2"], undefined); + + // Modifying output shouldn't affect the storage. + addresses[0].organization = "test"; + do_check_record_matches( + (await profileStorage.addresses.getAll())[0], + TEST_ADDRESS_1 + ); +}); + +add_task(async function test_get() { + let profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME, [ + TEST_ADDRESS_1, + TEST_ADDRESS_2, + ]); + + let addresses = await profileStorage.addresses.getAll(); + let guid = addresses[0].guid; + + let address = await profileStorage.addresses.get(guid); + do_check_record_matches(address, TEST_ADDRESS_1); + + // Test with rawData set. + address = await profileStorage.addresses.get(guid, { rawData: true }); + Assert.equal(address.name, undefined); + Assert.equal(address["address-line1"], undefined); + Assert.equal(address["address-line2"], undefined); + + // Modifying output shouldn't affect the storage. + address.organization = "test"; + do_check_record_matches( + await profileStorage.addresses.get(guid), + TEST_ADDRESS_1 + ); + + Assert.equal(await profileStorage.addresses.get("INVALID_GUID"), null); +}); + +add_task(async function test_add() { + let profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME, [ + TEST_ADDRESS_1, + TEST_ADDRESS_2, + ]); + + let addresses = await profileStorage.addresses.getAll(); + + Assert.equal(addresses.length, 2); + + do_check_record_matches(addresses[0], TEST_ADDRESS_1); + do_check_record_matches(addresses[1], TEST_ADDRESS_2); + + Assert.notEqual(addresses[0].guid, undefined); + Assert.equal(addresses[0].version, 1); + Assert.notEqual(addresses[0].timeCreated, undefined); + Assert.equal(addresses[0].timeLastModified, addresses[0].timeCreated); + Assert.equal(addresses[0].timeLastUsed, 0); + Assert.equal(addresses[0].timesUsed, 0); + + // Empty string should be deleted before saving. + await profileStorage.addresses.add(TEST_ADDRESS_WITH_EMPTY_FIELD); + let address = profileStorage.addresses._data[2]; + Assert.equal(address.name, TEST_ADDRESS_WITH_EMPTY_FIELD.name); + Assert.equal(address["street-address"], undefined); + + // Empty computed fields shouldn't cause any problem. + await profileStorage.addresses.add(TEST_ADDRESS_WITH_EMPTY_COMPUTED_FIELD); + address = profileStorage.addresses._data[3]; + Assert.equal(address.email, TEST_ADDRESS_WITH_EMPTY_COMPUTED_FIELD.email); + + await Assert.rejects( + profileStorage.addresses.add(TEST_ADDRESS_WITH_INVALID_FIELD), + /"invalidField" is not a valid field\./ + ); + + await Assert.rejects( + profileStorage.addresses.add({}), + /Record contains no valid field\./ + ); + + await Assert.rejects( + profileStorage.addresses.add(TEST_ADDRESS_EMPTY_AFTER_NORMALIZE), + /Record contains no valid field\./ + ); +}); + +add_task(async function test_update() { + // Test assumes that when an entry is saved a second time, it's last modified date will + // be different from the first. With high values of precision reduction, we execute too + // fast for that to be true. + let timerPrecision = Preferences.get("privacy.reduceTimerPrecision"); + Preferences.set("privacy.reduceTimerPrecision", false); + + registerCleanupFunction(function() { + Preferences.set("privacy.reduceTimerPrecision", timerPrecision); + }); + + let profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME, [ + TEST_ADDRESS_1, + TEST_ADDRESS_2, + ]); + + let addresses = await profileStorage.addresses.getAll(); + let guid = addresses[1].guid; + let timeLastModified = addresses[1].timeLastModified; + + let onChanged = TestUtils.topicObserved( + "formautofill-storage-changed", + (subject, data) => + data == "update" && + subject.wrappedJSObject.guid == guid && + subject.wrappedJSObject.collectionName == COLLECTION_NAME + ); + + Assert.notEqual(addresses[1].country, undefined); + + await profileStorage.addresses.update(guid, TEST_ADDRESS_3); + await onChanged; + await profileStorage._saveImmediately(); + + profileStorage.addresses.pullSyncChanges(); // force sync metadata, which we check below. + + let address = await profileStorage.addresses.get(guid, { rawData: true }); + + Assert.equal(address.country, undefined); + Assert.notEqual(address.timeLastModified, timeLastModified); + do_check_record_matches(address, TEST_ADDRESS_3); + Assert.equal(getSyncChangeCounter(profileStorage.addresses, guid), 1); + + // Test preserveOldProperties parameter and field with empty string. + await profileStorage.addresses.update( + guid, + TEST_ADDRESS_WITH_EMPTY_FIELD, + true + ); + await onChanged; + await profileStorage._saveImmediately(); + + profileStorage.addresses.pullSyncChanges(); // force sync metadata, which we check below. + + address = await profileStorage.addresses.get(guid, { rawData: true }); + + Assert.equal(address["given-name"], "Tim"); + Assert.equal(address["family-name"], "Berners"); + Assert.equal(address["street-address"], undefined); + Assert.equal(address["postal-code"], "12345"); + Assert.notEqual(address.timeLastModified, timeLastModified); + Assert.equal(getSyncChangeCounter(profileStorage.addresses, guid), 2); + + // Empty string should be deleted while updating. + await profileStorage.addresses.update( + profileStorage.addresses._data[0].guid, + TEST_ADDRESS_WITH_EMPTY_FIELD + ); + address = profileStorage.addresses._data[0]; + Assert.equal(address.name, TEST_ADDRESS_WITH_EMPTY_FIELD.name); + Assert.equal(address["street-address"], undefined); + + // Empty computed fields shouldn't cause any problem. + await profileStorage.addresses.update( + profileStorage.addresses._data[0].guid, + TEST_ADDRESS_WITH_EMPTY_COMPUTED_FIELD, + false + ); + address = profileStorage.addresses._data[0]; + Assert.equal(address.email, TEST_ADDRESS_WITH_EMPTY_COMPUTED_FIELD.email); + await profileStorage.addresses.update( + profileStorage.addresses._data[1].guid, + TEST_ADDRESS_WITH_EMPTY_COMPUTED_FIELD, + true + ); + address = profileStorage.addresses._data[1]; + Assert.equal(address.email, TEST_ADDRESS_WITH_EMPTY_COMPUTED_FIELD.email); + + await Assert.rejects( + profileStorage.addresses.update("INVALID_GUID", TEST_ADDRESS_3), + /No matching record\./ + ); + + await Assert.rejects( + profileStorage.addresses.update(guid, TEST_ADDRESS_WITH_INVALID_FIELD), + /"invalidField" is not a valid field\./ + ); + + await Assert.rejects( + profileStorage.addresses.update(guid, {}), + /Record contains no valid field\./ + ); + + await Assert.rejects( + profileStorage.addresses.update(guid, TEST_ADDRESS_EMPTY_AFTER_NORMALIZE), + /Record contains no valid field\./ + ); + + profileStorage.addresses.update(guid, TEST_ADDRESS_2); + await Assert.rejects( + profileStorage.addresses.update( + guid, + TEST_ADDRESS_EMPTY_AFTER_UPDATE_ADDRESS_2 + ), + /Record contains no valid field\./ + ); +}); + +add_task(async function test_notifyUsed() { + let profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME, [ + TEST_ADDRESS_1, + TEST_ADDRESS_2, + ]); + + let addresses = await profileStorage.addresses.getAll(); + let guid = addresses[1].guid; + let timeLastUsed = addresses[1].timeLastUsed; + let timesUsed = addresses[1].timesUsed; + + profileStorage.addresses.pullSyncChanges(); // force sync metadata, which we check below. + let changeCounter = getSyncChangeCounter(profileStorage.addresses, guid); + + let onChanged = TestUtils.topicObserved( + "formautofill-storage-changed", + (subject, data) => + data == "notifyUsed" && + subject.wrappedJSObject.guid == guid && + subject.wrappedJSObject.collectionName == COLLECTION_NAME + ); + + profileStorage.addresses.notifyUsed(guid); + await onChanged; + + let address = await profileStorage.addresses.get(guid); + + Assert.equal(address.timesUsed, timesUsed + 1); + Assert.notEqual(address.timeLastUsed, timeLastUsed); + + // Using a record should not bump its change counter. + Assert.equal( + getSyncChangeCounter(profileStorage.addresses, guid), + changeCounter + ); + + Assert.throws( + () => profileStorage.addresses.notifyUsed("INVALID_GUID"), + /No matching record\./ + ); +}); + +add_task(async function test_remove() { + let profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME, [ + TEST_ADDRESS_1, + TEST_ADDRESS_2, + ]); + + let addresses = await profileStorage.addresses.getAll(); + let guid = addresses[1].guid; + + let onChanged = TestUtils.topicObserved( + "formautofill-storage-changed", + (subject, data) => + data == "remove" && + subject.wrappedJSObject.guid == guid && + subject.wrappedJSObject.collectionName == COLLECTION_NAME + ); + + Assert.equal(addresses.length, 2); + + profileStorage.addresses.remove(guid); + await onChanged; + + addresses = await profileStorage.addresses.getAll(); + + Assert.equal(addresses.length, 1); + + Assert.equal(await profileStorage.addresses.get(guid), null); +}); + +MERGE_TESTCASES.forEach(testcase => { + add_task(async function test_merge() { + info("Starting testcase: " + testcase.description); + let profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME, [ + testcase.addressInStorage, + ]); + let addresses = await profileStorage.addresses.getAll(); + let guid = addresses[0].guid; + let timeLastModified = addresses[0].timeLastModified; + + // Merge address and verify the guid in notifyObservers subject + let onMerged = TestUtils.topicObserved( + "formautofill-storage-changed", + (subject, data) => + data == "update" && + subject.wrappedJSObject.guid == guid && + subject.wrappedJSObject.collectionName == COLLECTION_NAME + ); + + // Force to create sync metadata. + profileStorage.addresses.pullSyncChanges(); + Assert.equal(getSyncChangeCounter(profileStorage.addresses, guid), 1); + + Assert.ok( + profileStorage.addresses.mergeIfPossible( + guid, + testcase.addressToMerge, + testcase.strict + ) + ); + if (!testcase.noNeedToUpdate) { + await onMerged; + } + + addresses = await profileStorage.addresses.getAll(); + Assert.equal(addresses.length, 1); + do_check_record_matches(addresses[0], testcase.expectedAddress); + if (testcase.noNeedToUpdate) { + Assert.equal(addresses[0].timeLastModified, timeLastModified); + + // No need to bump the change counter if the data is unchanged. + Assert.equal(getSyncChangeCounter(profileStorage.addresses, guid), 1); + } else { + Assert.notEqual(addresses[0].timeLastModified, timeLastModified); + + // Record merging should bump the change counter. + Assert.equal(getSyncChangeCounter(profileStorage.addresses, guid), 2); + } + }); +}); + +add_task(async function test_merge_same_address() { + let profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME, [ + TEST_ADDRESS_1, + ]); + let addresses = await profileStorage.addresses.getAll(); + let guid = addresses[0].guid; + let timeLastModified = addresses[0].timeLastModified; + + // Force to create sync metadata. + profileStorage.addresses.pullSyncChanges(); + Assert.equal(getSyncChangeCounter(profileStorage.addresses, guid), 1); + + // Merge same address will still return true but it won't update timeLastModified. + Assert.ok(profileStorage.addresses.mergeIfPossible(guid, TEST_ADDRESS_1)); + Assert.equal(addresses[0].timeLastModified, timeLastModified); + + // ... and won't bump the change counter, either. + Assert.equal(getSyncChangeCounter(profileStorage.addresses, guid), 1); +}); + +add_task(async function test_merge_unable_merge() { + let profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME, [ + TEST_ADDRESS_1, + TEST_ADDRESS_2, + ]); + + let addresses = await profileStorage.addresses.getAll(); + let guid = addresses[1].guid; + + // Force to create sync metadata. + profileStorage.addresses.pullSyncChanges(); + Assert.equal(getSyncChangeCounter(profileStorage.addresses, guid), 1); + + // Unable to merge because of conflict + Assert.equal( + await profileStorage.addresses.mergeIfPossible(guid, TEST_ADDRESS_3), + false + ); + + // Unable to merge because no overlap + Assert.equal( + await profileStorage.addresses.mergeIfPossible(guid, TEST_ADDRESS_4), + false + ); + + // Unable to strict merge because subset with empty string + let subset = Object.assign({}, TEST_ADDRESS_1); + subset.organization = ""; + Assert.equal( + await profileStorage.addresses.mergeIfPossible(guid, subset, true), + false + ); + + // Shouldn't bump the change counter + Assert.equal(getSyncChangeCounter(profileStorage.addresses, guid), 1); +}); + +add_task(async function test_mergeToStorage() { + let profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME, [ + TEST_ADDRESS_1, + TEST_ADDRESS_2, + ]); + // Merge an address to storage + let anotherAddress = profileStorage.addresses._clone(TEST_ADDRESS_2); + await profileStorage.addresses.add(anotherAddress); + anotherAddress.email = "timbl@w3.org"; + Assert.equal( + (await profileStorage.addresses.mergeToStorage(anotherAddress)).length, + 2 + ); + + Assert.equal( + (await profileStorage.addresses.getAll())[1].email, + anotherAddress.email + ); + Assert.equal( + (await profileStorage.addresses.getAll())[2].email, + anotherAddress.email + ); + + // Empty computed fields shouldn't cause any problem. + Assert.equal( + ( + await profileStorage.addresses.mergeToStorage( + TEST_ADDRESS_WITH_EMPTY_COMPUTED_FIELD + ) + ).length, + 3 + ); +}); + +add_task(async function test_mergeToStorage_strict() { + let profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME, [ + TEST_ADDRESS_1, + TEST_ADDRESS_2, + ]); + // Try to merge a subset with empty string + let anotherAddress = profileStorage.addresses._clone(TEST_ADDRESS_1); + anotherAddress.email = ""; + Assert.equal( + (await profileStorage.addresses.mergeToStorage(anotherAddress, true)) + .length, + 0 + ); + Assert.equal( + (await profileStorage.addresses.getAll())[0].email, + TEST_ADDRESS_1.email + ); + + // Empty computed fields shouldn't cause any problem. + Assert.equal( + ( + await profileStorage.addresses.mergeToStorage( + TEST_ADDRESS_WITH_EMPTY_COMPUTED_FIELD, + true + ) + ).length, + 1 + ); +}); diff --git a/browser/extensions/formautofill/test/unit/test_autofillFormFields.js b/browser/extensions/formautofill/test/unit/test_autofillFormFields.js new file mode 100644 index 0000000000..d7a9d7803c --- /dev/null +++ b/browser/extensions/formautofill/test/unit/test_autofillFormFields.js @@ -0,0 +1,625 @@ +/* + * Test for form auto fill content helper fill all inputs function. + */ +/* eslint-disable mozilla/no-arbitrary-setTimeout */ + +"use strict"; + +const { setTimeout, clearTimeout } = ChromeUtils.import( + "resource://gre/modules/Timer.jsm", + {} +); + +var FormAutofillHandler, OSKeyStore; +add_task(async function setup() { + ({ FormAutofillHandler } = ChromeUtils.import( + "resource://formautofill/FormAutofillHandler.jsm" + )); + ({ OSKeyStore } = ChromeUtils.import( + "resource://gre/modules/OSKeyStore.jsm" + )); +}); + +const TESTCASES = [ + { + description: "Form without autocomplete property", + document: `<form><input id="given-name"><input id="family-name"> + <input id="street-addr"><input id="city"><select id="country"></select> + <input id='email'><input id="tel"></form>`, + focusedInputId: "given-name", + profileData: {}, + expectedResult: { + "street-addr": "", + city: "", + country: "", + email: "", + tel: "", + }, + }, + { + description: "Form with autocomplete properties and 1 token", + document: `<form><input id="given-name" autocomplete="given-name"> + <input id="family-name" autocomplete="family-name"> + <input id="street-addr" autocomplete="street-address"> + <input id="city" autocomplete="address-level2"> + <select id="country" autocomplete="country"> + <option/> + <option value="US">United States</option> + </select> + <input id="email" autocomplete="email"> + <input id="tel" autocomplete="tel"></form>`, + focusedInputId: "given-name", + profileData: { + guid: "123", + "street-address": "2 Harrison St line2", + "-moz-street-address-one-line": "2 Harrison St line2", + "address-level2": "San Francisco", + country: "US", + email: "foo@mozilla.com", + tel: "1234567", + }, + expectedResult: { + "street-addr": "2 Harrison St line2", + city: "San Francisco", + country: "US", + email: "foo@mozilla.com", + tel: "1234567", + }, + }, + { + description: "Form with autocomplete properties and 2 tokens", + document: `<form><input id="given-name" autocomplete="shipping given-name"> + <input id="family-name" autocomplete="shipping family-name"> + <input id="street-addr" autocomplete="shipping street-address"> + <input id="city" autocomplete="shipping address-level2"> + <select id="country" autocomplete="shipping country"> + <option/> + <option value="US">United States</option> + </select> + <input id='email' autocomplete="shipping email"> + <input id="tel" autocomplete="shipping tel"></form>`, + focusedInputId: "given-name", + profileData: { + guid: "123", + "street-address": "2 Harrison St", + "address-level2": "San Francisco", + country: "US", + email: "foo@mozilla.com", + tel: "1234567", + }, + expectedResult: { + "street-addr": "2 Harrison St", + city: "San Francisco", + country: "US", + email: "foo@mozilla.com", + tel: "1234567", + }, + }, + { + description: + "Form with autocomplete properties and profile is partly matched", + document: `<form><input id="given-name" autocomplete="shipping given-name"> + <input id="family-name" autocomplete="shipping family-name"> + <input id="street-addr" autocomplete="shipping street-address"> + <input id="city" autocomplete="shipping address-level2"> + <input id="country" autocomplete="shipping country"> + <input id='email' autocomplete="shipping email"> + <input id="tel" autocomplete="shipping tel"></form>`, + focusedInputId: "given-name", + profileData: { + guid: "123", + "street-address": "2 Harrison St", + "address-level2": "San Francisco", + country: "US", + email: "", + tel: "", + }, + expectedResult: { + "street-addr": "2 Harrison St", + city: "San Francisco", + country: "US", + email: "", + tel: "", + }, + }, + { + description: "Form with autocomplete properties but mismatched", + document: `<form><input id="given-name" autocomplete="shipping given-name"> + <input id="family-name" autocomplete="shipping family-name"> + <input id="street-addr" autocomplete="billing street-address"> + <input id="city" autocomplete="billing address-level2"> + <input id="country" autocomplete="billing country"> + <input id='email' autocomplete="shipping email"> + <input id="tel" autocomplete="shipping tel"></form>`, + focusedInputId: "given-name", + profileData: { + guid: "123", + "street-address": "", + "address-level2": "", + country: "", + email: "foo@mozilla.com", + tel: "1234567", + }, + expectedResult: { + "street-addr": "", + city: "", + country: "", + email: "foo@mozilla.com", + tel: "1234567", + }, + }, + { + description: + "Form with autocomplete select elements and matching option values", + document: `<form> + <input id="given-name" autocomplete="shipping given-name"> + <select id="country" autocomplete="shipping country"> + <option value=""></option> + <option value="US">United States</option> + </select> + <select id="state" autocomplete="shipping address-level1"> + <option value=""></option> + <option value="CA">California</option> + <option value="WA">Washington</option> + </select> + </form>`, + focusedInputId: "given-name", + profileData: { + guid: "123", + country: "US", + "address-level1": "CA", + }, + expectedResult: { + country: "US", + state: "CA", + }, + }, + { + description: + "Form with autocomplete select elements and matching option texts", + document: `<form> + <input id="given-name" autocomplete="shipping given-name"> + <select id="country" autocomplete="shipping country"> + <option value=""></option> + <option value="US">United States</option> + </select> + <select id="state" autocomplete="shipping address-level1"> + <option value=""></option> + <option value="CA">California</option> + <option value="WA">Washington</option> + </select> + </form>`, + focusedInputId: "given-name", + profileData: { + guid: "123", + country: "United States", + "address-level1": "California", + }, + expectedResult: { + country: "US", + state: "CA", + }, + }, + { + description: "Fill address fields in a form with addr and CC fields.", + document: `<form> + <input id="given-name" autocomplete="given-name"> + <input id="family-name" autocomplete="family-name"> + <input id="street-addr" autocomplete="street-address"> + <input id="city" autocomplete="address-level2"> + <select id="country" autocomplete="country"> + <option/> + <option value="US">United States</option> + </select> + <input id="email" autocomplete="email"> + <input id="tel" autocomplete="tel"> + <input id="cc-number" autocomplete="cc-number"> + <input id="cc-name" autocomplete="cc-name"> + <input id="cc-exp-month" autocomplete="cc-exp-month"> + <input id="cc-exp-year" autocomplete="cc-exp-year"> + </form>`, + focusedInputId: "given-name", + profileData: { + guid: "123", + "street-address": "2 Harrison St line2", + "-moz-street-address-one-line": "2 Harrison St line2", + "address-level2": "San Francisco", + country: "US", + email: "foo@mozilla.com", + tel: "1234567", + }, + expectedResult: { + "street-addr": "2 Harrison St line2", + city: "San Francisco", + country: "US", + email: "foo@mozilla.com", + tel: "1234567", + "cc-number": "", + "cc-name": "", + "cc-exp-month": "", + "cc-exp-year": "", + }, + }, + { + description: "Fill credit card fields in a form with addr and CC fields.", + document: `<form> + <input id="given-name" autocomplete="given-name"> + <input id="family-name" autocomplete="family-name"> + <input id="street-addr" autocomplete="street-address"> + <input id="city" autocomplete="address-level2"> + <select id="country" autocomplete="country"> + <option/> + <option value="US">United States</option> + </select> + <input id="email" autocomplete="email"> + <input id="tel" autocomplete="tel"> + <input id="cc-number" autocomplete="cc-number"> + <input id="cc-name" autocomplete="cc-name"> + <input id="cc-exp-month" autocomplete="cc-exp-month"> + <input id="cc-exp-year" autocomplete="cc-exp-year"> + </form>`, + focusedInputId: "cc-number", + profileData: { + guid: "123", + "cc-number": "4111111111111111", + "cc-name": "test name", + "cc-exp-month": "06", + "cc-exp-year": "25", + }, + expectedResult: { + "street-addr": "", + city: "", + country: "", + email: "", + tel: "", + "cc-number": "4111111111111111", + "cc-name": "test name", + "cc-exp-month": "06", + "cc-exp-year": "25", + }, + }, +]; + +const TESTCASES_INPUT_UNCHANGED = [ + { + description: + "Form with autocomplete select elements; with default and no matching options", + document: `<form> + <input id="given-name" autocomplete="shipping given-name"> + <select id="country" autocomplete="shipping country"> + <option value="US">United States</option> + </select> + <select id="state" autocomplete="shipping address-level1"> + <option value=""></option> + <option value="CA">California</option> + <option value="WA">Washington</option> + </select> + </form>`, + focusedInputId: "given-name", + profileData: { + guid: "123", + country: "US", + "address-level1": "unknown state", + }, + expectedResult: { + country: "US", + state: "", + }, + }, +]; + +const TESTCASES_FILL_SELECT = [ + // US States + { + description: "Form with US states select elements", + document: `<form> + <input id="given-name" autocomplete="shipping given-name"> + <input id="family-name" autocomplete="shipping family-name"> + <select id="state" autocomplete="shipping address-level1"> + <option value=""></option> + <option value="CA">California</option> + </select></form>`, + focusedInputId: "given-name", + profileData: { + guid: "123", + country: "US", + "address-level1": "CA", + }, + expectedResult: { + state: "CA", + }, + }, + { + description: + "Form with US states select elements; with lower case state key", + document: `<form> + <input id="given-name" autocomplete="shipping given-name"> + <input id="family-name" autocomplete="shipping family-name"> + <select id="state" autocomplete="shipping address-level1"> + <option value=""></option> + <option value="ca">ca</option> + </select></form>`, + focusedInputId: "given-name", + profileData: { + guid: "123", + country: "US", + "address-level1": "CA", + }, + expectedResult: { + state: "ca", + }, + }, + { + description: + "Form with US states select elements; with state name and extra spaces", + document: `<form> + <input id="given-name" autocomplete="shipping given-name"> + <input id="family-name" autocomplete="shipping family-name"> + <select id="state" autocomplete="shipping address-level1"> + <option value=""></option> + <option value="CA">CA</option> + </select></form>`, + focusedInputId: "given-name", + profileData: { + guid: "123", + country: "US", + "address-level1": " California ", + }, + expectedResult: { + state: "CA", + }, + }, + { + description: + "Form with US states select elements; with partial state key match", + document: `<form> + <input id="given-name" autocomplete="shipping given-name"> + <input id="family-name" autocomplete="shipping family-name"> + <select id="state" autocomplete="shipping address-level1"> + <option value=""></option> + <option value="US-WA">WA-Washington</option> + </select></form>`, + focusedInputId: "given-name", + profileData: { + guid: "123", + country: "US", + "address-level1": "WA", + }, + expectedResult: { + state: "US-WA", + }, + }, + + // Country + { + description: "Form with country select elements", + document: `<form> + <input id="given-name" autocomplete="given-name"> + <input id="family-name" autocomplete="family-name"> + <select id="country" autocomplete="country"> + <option value=""></option> + <option value="US">United States</option> + </select></form>`, + focusedInputId: "given-name", + profileData: { + guid: "123", + country: "US", + }, + expectedResult: { + country: "US", + }, + }, + { + description: "Form with country select elements; with lower case key", + document: `<form> + <input id="given-name" autocomplete="given-name"> + <input id="family-name" autocomplete="family-name"> + <select id="country" autocomplete="country"> + <option value=""></option> + <option value="us">us</option> + </select></form>`, + focusedInputId: "given-name", + profileData: { + guid: "123", + country: "US", + }, + expectedResult: { + country: "us", + }, + }, + { + description: "Form with country select elements; with alternative name 1", + document: `<form> + <input id="given-name" autocomplete="given-name"> + <input id="family-name" autocomplete="family-name"> + <select id="country" autocomplete="country"> + <option value=""></option> + <option value="XX">United States</option> + </select></form>`, + focusedInputId: "given-name", + profileData: { + guid: "123", + country: "US", + }, + expectedResult: { + country: "XX", + }, + }, + { + description: "Form with country select elements; with alternative name 2", + document: `<form> + <input id="given-name" autocomplete="given-name"> + <input id="family-name" autocomplete="family-name"> + <select id="country" autocomplete="country"> + <option value=""></option> + <option value="XX">America</option> + </select></form>`, + focusedInputId: "given-name", + profileData: { + guid: "123", + country: "US", + }, + expectedResult: { + country: "XX", + }, + }, + { + description: + "Form with country select elements; with partial matching value", + document: `<form> + <input id="given-name" autocomplete="given-name"> + <input id="family-name" autocomplete="family-name"> + <select id="country" autocomplete="country"> + <option value=""></option> + <option value="XX">Ship to America</option> + </select></form>`, + focusedInputId: "given-name", + profileData: { + guid: "123", + country: "US", + }, + expectedResult: { + country: "XX", + }, + }, +]; + +function do_test(testcases, testFn) { + for (let tc of testcases) { + (function() { + let testcase = tc; + add_task(async function() { + info("Starting testcase: " + testcase.description); + let ccNumber = testcase.profileData["cc-number"]; + if (ccNumber) { + testcase.profileData[ + "cc-number-encrypted" + ] = await OSKeyStore.encrypt(ccNumber); + delete testcase.profileData["cc-number"]; + } + + let doc = MockDocument.createTestDocument( + "http://localhost:8080/test/", + testcase.document + ); + let form = doc.querySelector("form"); + let formLike = FormLikeFactory.createFromForm(form); + let handler = new FormAutofillHandler(formLike); + let promises = []; + // Replace the internal decrypt method with OSKeyStore API, + // but don't pass the reauth parameter to avoid triggering + // reauth login dialog in these tests. + let decryptHelper = async (cipherText, reauth) => { + return OSKeyStore.decrypt(cipherText, false); + }; + + handler.collectFormFields(); + + let focusedInput = doc.getElementById(testcase.focusedInputId); + handler.focusedInput = focusedInput; + + for (let section of handler.sections) { + section._decrypt = decryptHelper; + } + + handler.activeSection.fieldDetails.forEach(field => { + let element = field.elementWeakRef.get(); + if (!testcase.profileData[field.fieldName]) { + // Avoid waiting for `change` event of a input with a blank value to + // be filled. + return; + } + promises.push(...testFn(testcase, element)); + }); + + let [adaptedProfile] = handler.activeSection.getAdaptedProfiles([ + testcase.profileData, + ]); + await handler.autofillFormFields(adaptedProfile, focusedInput); + Assert.equal( + handler.activeSection.filledRecordGUID, + testcase.profileData.guid, + "Check if filledRecordGUID is set correctly" + ); + await Promise.all(promises); + }); + })(); + } +} + +do_test(TESTCASES, (testcase, element) => { + let id = element.id; + return [ + new Promise(resolve => { + element.addEventListener( + "input", + () => { + Assert.ok(true, "Checking " + id + " field fires input event"); + resolve(); + }, + { once: true } + ); + }), + new Promise(resolve => { + element.addEventListener( + "change", + () => { + Assert.ok(true, "Checking " + id + " field fires change event"); + Assert.equal( + element.value, + testcase.expectedResult[id], + "Check the " + id + " field was filled with correct data" + ); + resolve(); + }, + { once: true } + ); + }), + ]; +}); + +do_test(TESTCASES_INPUT_UNCHANGED, (testcase, element) => { + return [ + new Promise((resolve, reject) => { + // Make sure no change or input event is fired when no change occurs. + let cleaner; + let timer = setTimeout(() => { + let id = element.id; + element.removeEventListener("change", cleaner); + element.removeEventListener("input", cleaner); + Assert.equal( + element.value, + testcase.expectedResult[id], + "Check no value is changed on the " + id + " field" + ); + resolve(); + }, 1000); + cleaner = event => { + clearTimeout(timer); + reject(`${event.type} event should not fire`); + }; + element.addEventListener("change", cleaner); + element.addEventListener("input", cleaner); + }), + ]; +}); + +do_test(TESTCASES_FILL_SELECT, (testcase, element) => { + let id = element.id; + return [ + new Promise(resolve => { + element.addEventListener( + "input", + () => { + Assert.equal( + element.value, + testcase.expectedResult[id], + "Check the " + id + " field was filled with correct data" + ); + resolve(); + }, + { once: true } + ); + }), + ]; +}); diff --git a/browser/extensions/formautofill/test/unit/test_collectFormFields.js b/browser/extensions/formautofill/test/unit/test_collectFormFields.js new file mode 100644 index 0000000000..f5739287f0 --- /dev/null +++ b/browser/extensions/formautofill/test/unit/test_collectFormFields.js @@ -0,0 +1,1129 @@ +/* + * Test for form auto fill content helper collectFormFields functions. + */ + +"use strict"; + +var FormAutofillHandler; +add_task(async function setup() { + ({ FormAutofillHandler } = ChromeUtils.import( + "resource://formautofill/FormAutofillHandler.jsm" + )); +}); + +const TESTCASES = [ + { + description: "Form without autocomplete property", + document: `<form><input id="given-name"><input id="family-name"> + <input id="street-addr"><input id="city"><select id="country"></select> + <input id='email'><input id="phone"></form>`, + sections: [ + [ + { + section: "", + addressType: "", + contactType: "", + fieldName: "given-name", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "family-name", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "address-line1", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "address-level2", + }, + { section: "", addressType: "", contactType: "", fieldName: "country" }, + { section: "", addressType: "", contactType: "", fieldName: "email" }, + { section: "", addressType: "", contactType: "", fieldName: "tel" }, + ], + ], + validFieldDetails: [ + { + section: "", + addressType: "", + contactType: "", + fieldName: "given-name", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "family-name", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "address-line1", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "address-level2", + }, + { section: "", addressType: "", contactType: "", fieldName: "country" }, + { section: "", addressType: "", contactType: "", fieldName: "email" }, + { section: "", addressType: "", contactType: "", fieldName: "tel" }, + ], + ids: [ + "given-name", + "family-name", + "street-addr", + "city", + "country", + "email", + "phone", + ], + }, + { + description: + "An address and credit card form with autocomplete properties and 1 token", + document: `<form> + <input id="given-name" autocomplete="given-name"> + <input id="family-name" autocomplete="family-name"> + <input id="street-addr" autocomplete="street-address"> + <input id="city" autocomplete="address-level2"> + <select id="country" autocomplete="country"></select> + <input id="email" autocomplete="email"> + <input id="tel" autocomplete="tel"> + <input id="cc-number" autocomplete="cc-number"> + <input id="cc-name" autocomplete="cc-name"> + <input id="cc-exp-month" autocomplete="cc-exp-month"> + <input id="cc-exp-year" autocomplete="cc-exp-year"> + </form>`, + sections: [ + [ + { + section: "", + addressType: "", + contactType: "", + fieldName: "given-name", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "family-name", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "street-address", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "address-level2", + }, + { section: "", addressType: "", contactType: "", fieldName: "country" }, + { section: "", addressType: "", contactType: "", fieldName: "email" }, + { section: "", addressType: "", contactType: "", fieldName: "tel" }, + ], + [ + { + section: "", + addressType: "", + contactType: "", + fieldName: "cc-number", + }, + { section: "", addressType: "", contactType: "", fieldName: "cc-name" }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "cc-exp-month", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "cc-exp-year", + }, + ], + ], + validFieldDetails: [ + { + section: "", + addressType: "", + contactType: "", + fieldName: "given-name", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "family-name", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "street-address", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "address-level2", + }, + { section: "", addressType: "", contactType: "", fieldName: "country" }, + { section: "", addressType: "", contactType: "", fieldName: "email" }, + { section: "", addressType: "", contactType: "", fieldName: "tel" }, + { section: "", addressType: "", contactType: "", fieldName: "cc-number" }, + { section: "", addressType: "", contactType: "", fieldName: "cc-name" }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "cc-exp-month", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "cc-exp-year", + }, + ], + }, + { + description: "An address form with autocomplete properties and 2 tokens", + document: `<form><input id="given-name" autocomplete="shipping given-name"> + <input id="family-name" autocomplete="shipping family-name"> + <input id="street-addr" autocomplete="shipping street-address"> + <input id="city" autocomplete="shipping address-level2"> + <input id="country" autocomplete="shipping country"> + <input id='email' autocomplete="shipping email"> + <input id="tel" autocomplete="shipping tel"></form>`, + sections: [ + [ + { + section: "", + addressType: "shipping", + contactType: "", + fieldName: "given-name", + }, + { + section: "", + addressType: "shipping", + contactType: "", + fieldName: "family-name", + }, + { + section: "", + addressType: "shipping", + contactType: "", + fieldName: "street-address", + }, + { + section: "", + addressType: "shipping", + contactType: "", + fieldName: "address-level2", + }, + { + section: "", + addressType: "shipping", + contactType: "", + fieldName: "country", + }, + { + section: "", + addressType: "shipping", + contactType: "", + fieldName: "email", + }, + { + section: "", + addressType: "shipping", + contactType: "", + fieldName: "tel", + }, + ], + ], + validFieldDetails: [ + { + section: "", + addressType: "shipping", + contactType: "", + fieldName: "given-name", + }, + { + section: "", + addressType: "shipping", + contactType: "", + fieldName: "family-name", + }, + { + section: "", + addressType: "shipping", + contactType: "", + fieldName: "street-address", + }, + { + section: "", + addressType: "shipping", + contactType: "", + fieldName: "address-level2", + }, + { + section: "", + addressType: "shipping", + contactType: "", + fieldName: "country", + }, + { + section: "", + addressType: "shipping", + contactType: "", + fieldName: "email", + }, + { + section: "", + addressType: "shipping", + contactType: "", + fieldName: "tel", + }, + ], + }, + { + description: + "Form with autocomplete properties and profile is partly matched", + document: `<form><input id="given-name" autocomplete="shipping given-name"> + <input id="family-name" autocomplete="shipping family-name"> + <input id="street-addr" autocomplete="shipping street-address"> + <input autocomplete="shipping address-level2"> + <select autocomplete="shipping country"></select> + <input id='email' autocomplete="shipping email"> + <input id="tel" autocomplete="shipping tel"></form>`, + sections: [ + [ + { + section: "", + addressType: "shipping", + contactType: "", + fieldName: "given-name", + }, + { + section: "", + addressType: "shipping", + contactType: "", + fieldName: "family-name", + }, + { + section: "", + addressType: "shipping", + contactType: "", + fieldName: "street-address", + }, + { + section: "", + addressType: "shipping", + contactType: "", + fieldName: "address-level2", + }, + { + section: "", + addressType: "shipping", + contactType: "", + fieldName: "country", + }, + { + section: "", + addressType: "shipping", + contactType: "", + fieldName: "email", + }, + { + section: "", + addressType: "shipping", + contactType: "", + fieldName: "tel", + }, + ], + ], + validFieldDetails: [ + { + section: "", + addressType: "shipping", + contactType: "", + fieldName: "given-name", + }, + { + section: "", + addressType: "shipping", + contactType: "", + fieldName: "family-name", + }, + { + section: "", + addressType: "shipping", + contactType: "", + fieldName: "street-address", + }, + { + section: "", + addressType: "shipping", + contactType: "", + fieldName: "address-level2", + }, + { + section: "", + addressType: "shipping", + contactType: "", + fieldName: "country", + }, + { + section: "", + addressType: "shipping", + contactType: "", + fieldName: "email", + }, + { + section: "", + addressType: "shipping", + contactType: "", + fieldName: "tel", + }, + ], + }, + { + description: "It's a valid address and credit card form.", + document: `<form> + <input id="given-name" autocomplete="shipping given-name"> + <input id="family-name" autocomplete="shipping family-name"> + <input id="street-addr" autocomplete="shipping street-address"> + <input id="cc-number" autocomplete="shipping cc-number"> + </form>`, + sections: [ + [ + { + section: "", + addressType: "shipping", + contactType: "", + fieldName: "given-name", + }, + { + section: "", + addressType: "shipping", + contactType: "", + fieldName: "family-name", + }, + { + section: "", + addressType: "shipping", + contactType: "", + fieldName: "street-address", + }, + ], + [ + { + section: "", + addressType: "shipping", + contactType: "", + fieldName: "cc-number", + }, + ], + ], + validFieldDetails: [ + { + section: "", + addressType: "shipping", + contactType: "", + fieldName: "given-name", + }, + { + section: "", + addressType: "shipping", + contactType: "", + fieldName: "family-name", + }, + { + section: "", + addressType: "shipping", + contactType: "", + fieldName: "street-address", + }, + { + section: "", + addressType: "shipping", + contactType: "", + fieldName: "cc-number", + }, + ], + }, + { + description: "An invalid address form due to less than 3 fields.", + document: `<form> + <input id="given-name" autocomplete="shipping given-name"> + <input autocomplete="shipping address-level2"> + </form>`, + sections: [[]], + validFieldDetails: [], + }, + { + description: + "An invalid credit card form due to non-autocomplete-attr cc-number only", + document: `<form> + <input id="cc-number" name="cc-number"> + </form>`, + sections: [[]], + validFieldDetails: [], + }, + { + description: "An invalid credit card form due to omitted cc-number.", + document: `<form> + <input id="cc-name" autocomplete="cc-name"> + <input id="cc-exp-month" autocomplete="cc-exp-month"> + <input id="cc-exp-year" autocomplete="cc-exp-year"> + </form>`, + sections: [[]], + validFieldDetails: [], + }, + { + description: + "A valid credit card form with non-autocomplete-attr cc-number and cc-name.", + document: `<form> + <input id="cc-name" autocomplete="cc-name"> + <input id="cc-number" name="card-number"> + </form>`, + sections: [ + [ + { section: "", addressType: "", contactType: "", fieldName: "cc-name" }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "cc-number", + }, + ], + ], + validFieldDetails: [ + { section: "", addressType: "", contactType: "", fieldName: "cc-name" }, + { section: "", addressType: "", contactType: "", fieldName: "cc-number" }, + ], + ids: ["cc-name", "cc-number"], + }, + { + description: + "A valid credit card form with autocomplete-attr cc-number only.", + document: `<form> + <input id="cc-number" autocomplete="cc-number"> + </form>`, + sections: [ + [ + { + section: "", + addressType: "", + contactType: "", + fieldName: "cc-number", + }, + ], + ], + validFieldDetails: [ + { section: "", addressType: "", contactType: "", fieldName: "cc-number" }, + ], + }, + { + description: + "A valid credit card form with non-autocomplete-attr cc-number and cc-exp.", + document: `<form> + <input id="cc-number" name="card-number"> + <input id="cc-exp" autocomplete="cc-exp"> + </form>`, + sections: [ + [ + { + section: "", + addressType: "", + contactType: "", + fieldName: "cc-number", + }, + { section: "", addressType: "", contactType: "", fieldName: "cc-exp" }, + ], + ], + validFieldDetails: [ + { section: "", addressType: "", contactType: "", fieldName: "cc-number" }, + { section: "", addressType: "", contactType: "", fieldName: "cc-exp" }, + ], + ids: ["cc-number", "cc-exp"], + }, + { + description: + "A valid credit card form with non-autocomplete-attr cc-number and cc-exp-month/cc-exp-year.", + document: `<form> + <input id="cc-number" name="card-number"> + <input id="cc-exp-month" autocomplete="cc-exp-month"> + <input id="cc-exp-year" autocomplete="cc-exp-year"> + </form>`, + sections: [ + [ + { + section: "", + addressType: "", + contactType: "", + fieldName: "cc-number", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "cc-exp-month", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "cc-exp-year", + }, + ], + ], + validFieldDetails: [ + { section: "", addressType: "", contactType: "", fieldName: "cc-number" }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "cc-exp-month", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "cc-exp-year", + }, + ], + ids: ["cc-number", "cc-exp-month", "cc-exp-year"], + }, + { + description: "Three sets of adjacent phone number fields", + document: `<form> + <input id="shippingAC" name="phone" maxlength="3"> + <input id="shippingPrefix" name="phone" maxlength="3"> + <input id="shippingSuffix" name="phone" maxlength="4"> + <input id="shippingTelExt" name="extension"> + + <input id="billingAC" name="phone" maxlength="3"> + <input id="billingPrefix" name="phone" maxlength="3"> + <input id="billingSuffix" name="phone" maxlength="4"> + + <input id="otherCC" name="phone" maxlength="3"> + <input id="otherAC" name="phone" maxlength="3"> + <input id="otherPrefix" name="phone" maxlength="3"> + <input id="otherSuffix" name="phone" maxlength="4"> + </form>`, + allowDuplicates: true, + sections: [ + [ + { + section: "", + addressType: "", + contactType: "", + fieldName: "tel-area-code", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "tel-local-prefix", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "tel-local-suffix", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "tel-extension", + }, + ], + [ + { + section: "", + addressType: "", + contactType: "", + fieldName: "tel-area-code", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "tel-local-prefix", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "tel-local-suffix", + }, + + // TODO Bug 1421181 - "tel-country-code" field should belong to the next + // section. There should be a way to group the related fields during the + // parsing stage. + { + section: "", + addressType: "", + contactType: "", + fieldName: "tel-country-code", + }, + ], + [ + { + section: "", + addressType: "", + contactType: "", + fieldName: "tel-area-code", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "tel-local-prefix", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "tel-local-suffix", + }, + ], + ], + validFieldDetails: [ + { + section: "", + addressType: "", + contactType: "", + fieldName: "tel-area-code", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "tel-local-prefix", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "tel-local-suffix", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "tel-extension", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "tel-area-code", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "tel-local-prefix", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "tel-local-suffix", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "tel-country-code", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "tel-area-code", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "tel-local-prefix", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "tel-local-suffix", + }, + ], + ids: [ + "shippingAC", + "shippingPrefix", + "shippingSuffix", + "shippingTelExt", + "billingAC", + "billingPrefix", + "billingSuffix", + "otherCC", + "otherAC", + "otherPrefix", + "otherSuffix", + ], + }, + { + description: + "Dedup the same field names of the different telephone fields.", + document: `<form> + <input id="i1" autocomplete="given-name"> + <input id="i2" autocomplete="family-name"> + <input id="i3" autocomplete="street-address"> + <input id="i4" autocomplete="email"> + + <input id="homePhone" maxlength="10"> + <input id="mobilePhone" maxlength="10"> + <input id="officePhone" maxlength="10"> + </form>`, + sections: [ + [ + { + section: "", + addressType: "", + contactType: "", + fieldName: "given-name", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "family-name", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "street-address", + }, + { section: "", addressType: "", contactType: "", fieldName: "email" }, + { section: "", addressType: "", contactType: "", fieldName: "tel" }, + ], + ], + validFieldDetails: [ + { + section: "", + addressType: "", + contactType: "", + fieldName: "given-name", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "family-name", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "street-address", + }, + { section: "", addressType: "", contactType: "", fieldName: "email" }, + { section: "", addressType: "", contactType: "", fieldName: "tel" }, + ], + ids: ["i1", "i2", "i3", "i4", "homePhone"], + }, + { + description: + "The duplicated phones of a single one and a set with ac, prefix, suffix.", + document: `<form> + <input id="i1" autocomplete="shipping given-name"> + <input id="i2" autocomplete="shipping family-name"> + <input id="i3" autocomplete="shipping street-address"> + <input id="i4" autocomplete="shipping email"> + <input id="singlePhone" autocomplete="shipping tel"> + <input id="shippingAreaCode" autocomplete="shipping tel-area-code"> + <input id="shippingPrefix" autocomplete="shipping tel-local-prefix"> + <input id="shippingSuffix" autocomplete="shipping tel-local-suffix"> + </form>`, + sections: [ + [ + { + section: "", + addressType: "shipping", + contactType: "", + fieldName: "given-name", + }, + { + section: "", + addressType: "shipping", + contactType: "", + fieldName: "family-name", + }, + { + section: "", + addressType: "shipping", + contactType: "", + fieldName: "street-address", + }, + { + section: "", + addressType: "shipping", + contactType: "", + fieldName: "email", + }, + + // NOTES: Ideally, there is only one full telephone field(s) in a form for + // this case. We can see if there is any better solution later. + { + section: "", + addressType: "shipping", + contactType: "", + fieldName: "tel", + }, + + { + section: "", + addressType: "shipping", + contactType: "", + fieldName: "tel-area-code", + }, + { + section: "", + addressType: "shipping", + contactType: "", + fieldName: "tel-local-prefix", + }, + { + section: "", + addressType: "shipping", + contactType: "", + fieldName: "tel-local-suffix", + }, + ], + ], + validFieldDetails: [ + { + section: "", + addressType: "shipping", + contactType: "", + fieldName: "given-name", + }, + { + section: "", + addressType: "shipping", + contactType: "", + fieldName: "family-name", + }, + { + section: "", + addressType: "shipping", + contactType: "", + fieldName: "street-address", + }, + { + section: "", + addressType: "shipping", + contactType: "", + fieldName: "email", + }, + { + section: "", + addressType: "shipping", + contactType: "", + fieldName: "tel", + }, + { + section: "", + addressType: "shipping", + contactType: "", + fieldName: "tel-area-code", + }, + { + section: "", + addressType: "shipping", + contactType: "", + fieldName: "tel-local-prefix", + }, + { + section: "", + addressType: "shipping", + contactType: "", + fieldName: "tel-local-suffix", + }, + ], + ids: [ + "i1", + "i2", + "i3", + "i4", + "singlePhone", + "shippingAreaCode", + "shippingPrefix", + "shippingSuffix", + ], + }, + { + description: "Always adopt the info from autocomplete attribute.", + document: `<form> + <input id="given-name" autocomplete="shipping given-name"> + <input id="family-name" autocomplete="shipping family-name"> + <input id="dummyAreaCode" autocomplete="shipping tel" maxlength="3"> + <input id="dummyPrefix" autocomplete="shipping tel" maxlength="3"> + <input id="dummySuffix" autocomplete="shipping tel" maxlength="4"> + </form>`, + sections: [ + [ + { + section: "", + addressType: "shipping", + contactType: "", + fieldName: "given-name", + }, + { + section: "", + addressType: "shipping", + contactType: "", + fieldName: "family-name", + }, + { + section: "", + addressType: "shipping", + contactType: "", + fieldName: "tel", + }, + ], + ], + validFieldDetails: [ + { + section: "", + addressType: "shipping", + contactType: "", + fieldName: "given-name", + }, + { + section: "", + addressType: "shipping", + contactType: "", + fieldName: "family-name", + }, + { + section: "", + addressType: "shipping", + contactType: "", + fieldName: "tel", + }, + ], + ids: ["given-name", "family-name", "dummyAreaCode"], + }, +]; + +for (let tc of TESTCASES) { + (function() { + let testcase = tc; + add_task(async function() { + info("Starting testcase: " + testcase.description); + + let doc = MockDocument.createTestDocument( + "http://localhost:8080/test/", + testcase.document + ); + let form = doc.querySelector("form"); + let formLike = FormLikeFactory.createFromForm(form); + + function setElementWeakRef(details) { + if (!details) { + return; + } + + details.forEach((detail, index) => { + let elementRef; + if (testcase.ids && testcase.ids[index]) { + elementRef = doc.getElementById(testcase.ids[index]); + } else { + elementRef = doc.querySelector( + "*[autocomplete*='" + detail.fieldName + "']" + ); + } + detail.elementWeakRef = Cu.getWeakReference(elementRef); + }); + } + + function verifyDetails(handlerDetails, testCaseDetails) { + if (handlerDetails === null) { + Assert.equal(handlerDetails, testCaseDetails); + return; + } + Assert.equal( + handlerDetails.length, + testCaseDetails.length, + "field count" + ); + handlerDetails.forEach((detail, index) => { + Assert.equal( + detail.fieldName, + testCaseDetails[index].fieldName, + "fieldName" + ); + Assert.equal( + detail.section, + testCaseDetails[index].section, + "section" + ); + Assert.equal( + detail.addressType, + testCaseDetails[index].addressType, + "addressType" + ); + Assert.equal( + detail.contactType, + testCaseDetails[index].contactType, + "contactType" + ); + Assert.equal( + detail.elementWeakRef.get(), + testCaseDetails[index].elementWeakRef.get(), + "DOM reference" + ); + }); + } + setElementWeakRef( + testcase.sections.reduce((fieldDetails, section) => { + fieldDetails.push(...section); + return fieldDetails; + }, []) + ); + setElementWeakRef(testcase.validFieldDetails); + + let handler = new FormAutofillHandler(formLike); + let validFieldDetails = handler.collectFormFields( + testcase.allowDuplicates + ); + + Assert.equal( + handler.sections.length, + testcase.sections.length, + "section count" + ); + for (let i = 0; i < handler.sections.length; i++) { + let section = handler.sections[i]; + verifyDetails(section.fieldDetails, testcase.sections[i]); + } + verifyDetails(validFieldDetails, testcase.validFieldDetails); + }); + })(); +} diff --git a/browser/extensions/formautofill/test/unit/test_createRecords.js b/browser/extensions/formautofill/test/unit/test_createRecords.js new file mode 100644 index 0000000000..e67f8d8717 --- /dev/null +++ b/browser/extensions/formautofill/test/unit/test_createRecords.js @@ -0,0 +1,477 @@ +/* + * Test for the normalization of records created by FormAutofillHandler. + */ + +"use strict"; + +var FormAutofillHandler; +add_task(async function seutp() { + ({ FormAutofillHandler } = ChromeUtils.import( + "resource://formautofill/FormAutofillHandler.jsm" + )); +}); + +const TESTCASES = [ + { + description: + "Don't contain a field whose length of value is greater than 200", + document: `<form> + <input id="given-name" autocomplete="given-name"> + <input id="organization" autocomplete="organization"> + <input id="address-level1" autocomplete="address-level1"> + <input id="country" autocomplete="country"> + <input id="cc-number" autocomplete="cc-number"> + <input id="cc-name" autocomplete="cc-name"> + </form>`, + formValue: { + "given-name": "John", + organization: "*".repeat(200), + "address-level1": "*".repeat(201), + country: "US", + "cc-number": "1111222233334444", + "cc-name": "*".repeat(201), + }, + expectedRecord: { + address: [ + { + "given-name": "John", + organization: "*".repeat(200), + "address-level1": "", + country: "US", + }, + ], + creditCard: [ + { + "cc-number": "1111222233334444", + "cc-name": "", + }, + ], + }, + }, + { + description: "Don't create address record if filled data is less than 3", + document: `<form> + <input id="given-name" autocomplete="given-name"> + <input id="organization" autocomplete="organization"> + <input id="country" autocomplete="country"> + </form>`, + formValue: { + "given-name": "John", + organization: "Mozilla", + }, + expectedRecord: { + address: [], + creditCard: [], + }, + }, + { + description: + '"country" using @autocomplete shouldn\'t be identified aggressively', + document: `<form> + <input id="given-name" autocomplete="given-name"> + <input id="organization" autocomplete="organization"> + <input id="country" autocomplete="country"> + </form>`, + formValue: { + "given-name": "John", + organization: "Mozilla", + country: "United States", + }, + expectedRecord: { + // "United States" is not a valid country, only country-name. See isRecordCreatable. + address: [], + creditCard: [], + }, + }, + { + description: '"country" using heuristics should be identified aggressively', + document: `<form> + <input id="given-name" autocomplete="given-name"> + <input id="organization" autocomplete="organization"> + <input id="country" name="country"> + </form>`, + formValue: { + "given-name": "John", + organization: "Mozilla", + country: "United States", + }, + expectedRecord: { + address: [ + { + "given-name": "John", + organization: "Mozilla", + country: "US", + }, + ], + creditCard: [], + }, + }, + { + description: '"tel" related fields should be concatenated', + document: `<form> + <input id="given-name" autocomplete="given-name"> + <input id="organization" autocomplete="organization"> + <input id="tel-country-code" autocomplete="tel-country-code"> + <input id="tel-national" autocomplete="tel-national"> + </form>`, + formValue: { + "given-name": "John", + organization: "Mozilla", + "tel-country-code": "+1", + "tel-national": "1234567890", + }, + expectedRecord: { + address: [ + { + "given-name": "John", + organization: "Mozilla", + tel: "+11234567890", + }, + ], + creditCard: [], + }, + }, + { + description: '"tel" should be removed if it\'s too short', + document: `<form> + <input id="given-name" autocomplete="given-name"> + <input id="organization" autocomplete="organization"> + <input id="country" autocomplete="country"> + <input id="tel" autocomplete="tel-national"> + </form>`, + formValue: { + "given-name": "John", + organization: "Mozilla", + country: "US", + tel: "1234", + }, + expectedRecord: { + address: [ + { + "given-name": "John", + organization: "Mozilla", + country: "US", + tel: "", + }, + ], + creditCard: [], + }, + }, + { + description: '"tel" should be removed if it\'s too long', + document: `<form> + <input id="given-name" autocomplete="given-name"> + <input id="organization" autocomplete="organization"> + <input id="country" autocomplete="country"> + <input id="tel" autocomplete="tel-national"> + </form>`, + formValue: { + "given-name": "John", + organization: "Mozilla", + country: "US", + tel: "1234567890123456", + }, + expectedRecord: { + address: [ + { + "given-name": "John", + organization: "Mozilla", + country: "US", + tel: "", + }, + ], + creditCard: [], + }, + }, + { + description: '"tel" should be removed if it contains invalid characters', + document: `<form> + <input id="given-name" autocomplete="given-name"> + <input id="organization" autocomplete="organization"> + <input id="country" autocomplete="country"> + <input id="tel" autocomplete="tel-national"> + </form>`, + formValue: { + "given-name": "John", + organization: "Mozilla", + country: "US", + tel: "12345###!!!", + }, + expectedRecord: { + address: [ + { + "given-name": "John", + organization: "Mozilla", + country: "US", + tel: "", + }, + ], + creditCard: [], + }, + }, + { + description: "All name related fields should be counted as 1 field only.", + document: `<form> + <input id="given-name" autocomplete="given-name"> + <input id="family-name" autocomplete="family-name"> + <input id="organization" autocomplete="organization"> + </form>`, + formValue: { + "given-name": "John", + "family-name": "Doe", + organization: "Mozilla", + }, + expectedRecord: { + address: [], + creditCard: [], + }, + }, + { + description: + "All telephone related fields should be counted as 1 field only.", + document: `<form> + <input id="tel-country-code" autocomplete="tel-country-code"> + <input id="tel-area-code" autocomplete="tel-area-code"> + <input id="tel-local" autocomplete="tel-local"> + <input id="organization" autocomplete="organization"> + </form>`, + formValue: { + "tel-country-code": "+1", + "tel-area-code": "123", + "tel-local": "4567890", + organization: "Mozilla", + }, + expectedRecord: { + address: [], + creditCard: [], + }, + }, + { + description: + "A credit card form with the value of cc-number, cc-exp, cc-name and cc-type.", + document: `<form> + <input id="cc-number" autocomplete="cc-number"> + <input id="cc-name" autocomplete="cc-name"> + <input id="cc-exp" autocomplete="cc-exp"> + <input id="cc-type" autocomplete="cc-type"> + </form>`, + formValue: { + "cc-number": "5105105105105100", + "cc-name": "Foo Bar", + "cc-exp": "2022-06", + "cc-type": "Visa", + }, + expectedRecord: { + address: [], + creditCard: [ + { + "cc-number": "5105105105105100", + "cc-name": "Foo Bar", + "cc-exp": "2022-06", + "cc-type": "Visa", + }, + ], + }, + }, + { + description: "A credit card form with cc-number value only.", + document: `<form> + <input id="cc-number" autocomplete="cc-number"> + </form>`, + formValue: { + "cc-number": "4111111111111111", + }, + expectedRecord: { + address: [], + creditCard: [ + { + "cc-number": "4111111111111111", + }, + ], + }, + }, + { + description: "A credit card form must have cc-number value.", + document: `<form> + <input id="cc-number" autocomplete="cc-number"> + <input id="cc-name" autocomplete="cc-name"> + <input id="cc-exp" autocomplete="cc-exp"> + </form>`, + formValue: { + "cc-number": "", + "cc-name": "Foo Bar", + "cc-exp": "2022-06", + }, + expectedRecord: { + address: [], + creditCard: [], + }, + }, + { + description: "A credit card form must have cc-number field.", + document: `<form> + <input id="cc-name" autocomplete="cc-name"> + <input id="cc-exp" autocomplete="cc-exp"> + </form>`, + formValue: { + "cc-name": "Foo Bar", + "cc-exp": "2022-06", + }, + expectedRecord: { + address: [], + creditCard: [], + }, + }, + { + description: "A form with multiple sections", + document: `<form> + <input id="given-name" autocomplete="given-name"> + <input id="organization" autocomplete="organization"> + <input id="country" autocomplete="country"> + + <input id="given-name-shipping" autocomplete="shipping given-name"> + <input id="family-name-shipping" autocomplete="shipping family-name"> + <input id="organization-shipping" autocomplete="shipping organization"> + <input id="country-shipping" autocomplete="shipping country"> + + <input id="given-name-billing" autocomplete="billing given-name"> + <input id="organization-billing" autocomplete="billing organization"> + <input id="country-billing" autocomplete="billing country"> + + <input id="cc-number-section-one" autocomplete="section-one cc-number"> + <input id="cc-name-section-one" autocomplete="section-one cc-name"> + + <input id="cc-number-section-two" autocomplete="section-two cc-number"> + <input id="cc-name-section-two" autocomplete="section-two cc-name"> + <input id="cc-exp-section-two" autocomplete="section-two cc-exp"> + </form>`, + formValue: { + "given-name": "Bar", + organization: "Foo", + country: "US", + + "given-name-shipping": "John", + "family-name-shipping": "Doe", + "organization-shipping": "Mozilla", + "country-shipping": "US", + + "given-name-billing": "Foo", + "organization-billing": "Bar", + "country-billing": "US", + + "cc-number-section-one": "4111111111111111", + "cc-name-section-one": "John", + + "cc-number-section-two": "5105105105105100", + "cc-name-section-two": "Foo Bar", + "cc-exp-section-two": "2026-26", + }, + expectedRecord: { + address: [ + { + "given-name": "Bar", + organization: "Foo", + country: "US", + }, + { + "given-name": "John", + "family-name": "Doe", + organization: "Mozilla", + country: "US", + }, + { + "given-name": "Foo", + organization: "Bar", + country: "US", + }, + ], + creditCard: [ + { + "cc-number": "4111111111111111", + "cc-name": "John", + }, + { + "cc-number": "5105105105105100", + "cc-name": "Foo Bar", + "cc-exp": "2026-26", + }, + ], + }, + }, + { + description: "A credit card form with a cc-type select.", + document: `<form> + <input id="cc-number" autocomplete="cc-number"> + <label for="field1">Card Type:</label> + <select id="field1"> + <option value="visa" selected>Visa</option> + </select> + </form>`, + formValue: { + "cc-number": "5105105105105100", + }, + expectedRecord: { + address: [], + creditCard: [ + { + "cc-number": "5105105105105100", + "cc-type": "visa", + }, + ], + }, + }, + { + description: "A credit card form with a cc-type select from label.", + document: `<form> + <input id="cc-number" autocomplete="cc-number"> + <label for="cc-type">Card Type:</label> + <select id="cc-type"> + <option value="V" selected>Visa</option> + <option value="A">American Express</option> + </select> + </form>`, + formValue: { + "cc-number": "5105105105105100", + "cc-type": "A", + }, + expectedRecord: { + address: [], + creditCard: [ + { + "cc-number": "5105105105105100", + "cc-type": "amex", + }, + ], + }, + }, +]; + +for (let testcase of TESTCASES) { + add_task(async function() { + info("Starting testcase: " + testcase.description); + + let doc = MockDocument.createTestDocument( + "http://localhost:8080/test/", + testcase.document + ); + let form = doc.querySelector("form"); + let formLike = FormLikeFactory.createFromForm(form); + let handler = new FormAutofillHandler(formLike); + + handler.collectFormFields(); + + for (let id in testcase.formValue) { + doc.getElementById(id).value = testcase.formValue[id]; + } + + let record = handler.createRecords(); + + let expectedRecord = testcase.expectedRecord; + for (let type in record) { + Assert.deepEqual( + record[type].map(secRecord => secRecord.record), + expectedRecord[type] + ); + } + }); +} diff --git a/browser/extensions/formautofill/test/unit/test_creditCardRecords.js b/browser/extensions/formautofill/test/unit/test_creditCardRecords.js new file mode 100644 index 0000000000..cf8674940d --- /dev/null +++ b/browser/extensions/formautofill/test/unit/test_creditCardRecords.js @@ -0,0 +1,892 @@ +/** + * Tests FormAutofillStorage object with creditCards records. + */ + +"use strict"; + +ChromeUtils.defineModuleGetter( + this, + "Preferences", + "resource://gre/modules/Preferences.jsm" +); +const { CreditCard } = ChromeUtils.import( + "resource://gre/modules/CreditCard.jsm" +); + +let FormAutofillStorage; +add_task(async function setup() { + ({ FormAutofillStorage } = ChromeUtils.import( + "resource://formautofill/FormAutofillStorage.jsm", + null + )); +}); + +const TEST_STORE_FILE_NAME = "test-credit-card.json"; +const COLLECTION_NAME = "creditCards"; + +const TEST_CREDIT_CARD_1 = { + "cc-name": "John Doe", + "cc-number": "4929001587121045", + "cc-exp-month": 4, + "cc-exp-year": 2017, + "cc-type": "visa", +}; + +const TEST_CREDIT_CARD_2 = { + "cc-name": "Timothy Berners-Lee", + "cc-number": "5103059495477870", + "cc-exp-month": 12, + "cc-exp-year": 2022, + "cc-type": "mastercard", +}; + +const TEST_CREDIT_CARD_3 = { + "cc-number": "3589993783099582", + "cc-exp-month": 1, + "cc-exp-year": 2000, + "cc-type": "amex", +}; + +const TEST_CREDIT_CARD_4 = { + "cc-name": "Foo Bar", + "cc-number": "3589993783099582", + "cc-type": "amex", +}; + +const TEST_CREDIT_CARD_WITH_BILLING_ADDRESS = { + "cc-name": "J. Smith", + "cc-number": "4111111111111111", + billingAddressGUID: "9m6hf4gfr6ge", +}; + +const TEST_CREDIT_CARD_WITH_EMPTY_FIELD = { + billingAddressGUID: "", + "cc-name": "", + "cc-number": "344060747836806", + "cc-exp-month": 1, + "cc-type": "", +}; + +const TEST_CREDIT_CARD_WITH_EMPTY_COMPUTED_FIELD = { + "cc-given-name": "", + "cc-additional-name": "", + "cc-family-name": "", + "cc-exp": "", + "cc-number": "5415425865751454", +}; + +const TEST_CREDIT_CARD_WITH_2_DIGITS_YEAR = { + "cc-number": "344060747836806", + "cc-exp-month": 1, + "cc-exp-year": 12, +}; + +const TEST_CREDIT_CARD_WITH_INVALID_FIELD = { + "cc-name": "John Doe", + "cc-number": "344060747836806", + invalidField: "INVALID", +}; + +const TEST_CREDIT_CARD_WITH_INVALID_EXPIRY_DATE = { + "cc-name": "John Doe", + "cc-number": "5103059495477870", + "cc-exp-month": 13, + "cc-exp-year": -3, +}; + +const TEST_CREDIT_CARD_WITH_SPACES_BETWEEN_DIGITS = { + "cc-name": "John Doe", + "cc-number": "5103 0594 9547 7870", +}; + +const TEST_CREDIT_CARD_WITH_INVALID_NETWORK = { + "cc-name": "John Doe", + "cc-number": "4929001587121045", + "cc-exp-month": 4, + "cc-exp-year": 2017, + "cc-type": "asiv", +}; + +const TEST_CREDIT_CARD_EMPTY_AFTER_NORMALIZE = { + "cc-exp-month": 13, +}; + +const TEST_CREDIT_CARD_EMPTY_AFTER_UPDATE_CREDIT_CARD_1 = { + "cc-name": "", + "cc-number": "", + "cc-exp-month": 13, + "cc-exp-year": "", +}; + +const MERGE_TESTCASES = [ + { + description: "Merge a superset", + creditCardInStorage: { + "cc-number": "4929001587121045", + "cc-exp-month": 4, + "cc-exp-year": 2017, + }, + creditCardToMerge: { + "cc-name": "John Doe", + "cc-number": "4929001587121045", + "cc-exp-month": 4, + "cc-exp-year": 2017, + }, + expectedCreditCard: { + "cc-name": "John Doe", + "cc-number": "4929001587121045", + "cc-exp-month": 4, + "cc-exp-year": 2017, + }, + }, + { + description: "Merge a superset with billingAddressGUID", + creditCardInStorage: { + "cc-number": "4929001587121045", + }, + creditCardToMerge: { + "cc-number": "4929001587121045", + billingAddressGUID: "ijsnbhfr", + }, + expectedCreditCard: { + "cc-number": "4929001587121045", + billingAddressGUID: "ijsnbhfr", + }, + }, + { + description: "Merge a subset", + creditCardInStorage: { + "cc-name": "John Doe", + "cc-number": "4929001587121045", + "cc-exp-month": 4, + "cc-exp-year": 2017, + }, + creditCardToMerge: { + "cc-number": "4929001587121045", + "cc-exp-month": 4, + "cc-exp-year": 2017, + }, + expectedCreditCard: { + "cc-name": "John Doe", + "cc-number": "4929001587121045", + "cc-exp-month": 4, + "cc-exp-year": 2017, + }, + noNeedToUpdate: true, + }, + { + description: "Merge a subset with billingAddressGUID", + creditCardInStorage: { + "cc-number": "4929001587121045", + billingAddressGUID: "8fhdb3ug6", + }, + creditCardToMerge: { + "cc-number": "4929001587121045", + }, + expectedCreditCard: { + billingAddressGUID: "8fhdb3ug6", + "cc-number": "4929001587121045", + }, + noNeedToUpdate: true, + }, + { + description: "Merge an creditCard with partial overlaps", + creditCardInStorage: { + "cc-name": "John Doe", + "cc-number": "4929001587121045", + }, + creditCardToMerge: { + "cc-number": "4929001587121045", + "cc-exp-month": 4, + "cc-exp-year": 2017, + }, + expectedCreditCard: { + "cc-name": "John Doe", + "cc-number": "4929001587121045", + "cc-exp-month": 4, + "cc-exp-year": 2017, + }, + }, +]; + +let prepareTestCreditCards = async function(path) { + let profileStorage = new FormAutofillStorage(path); + await profileStorage.initialize(); + + let onChanged = TestUtils.topicObserved( + "formautofill-storage-changed", + (subject, data) => + data == "add" && + subject.wrappedJSObject.guid && + subject.wrappedJSObject.collectionName == COLLECTION_NAME + ); + Assert.ok(await profileStorage.creditCards.add(TEST_CREDIT_CARD_1)); + await onChanged; + Assert.ok(await profileStorage.creditCards.add(TEST_CREDIT_CARD_2)); + await onChanged; + await profileStorage._saveImmediately(); +}; + +let reCCNumber = /^(\*+)(.{4})$/; + +let do_check_credit_card_matches = (creditCardWithMeta, creditCard) => { + for (let key in creditCard) { + if (key == "cc-number") { + let matches = reCCNumber.exec(creditCardWithMeta["cc-number"]); + Assert.notEqual(matches, null); + Assert.equal( + creditCardWithMeta["cc-number"].length, + creditCard["cc-number"].length + ); + Assert.equal(creditCard["cc-number"].endsWith(matches[2]), true); + Assert.notEqual(creditCard["cc-number-encrypted"], ""); + } else { + Assert.equal(creditCardWithMeta[key], creditCard[key], "Testing " + key); + } + } +}; + +add_task(async function test_initialize() { + let path = getTempFile(TEST_STORE_FILE_NAME).path; + let profileStorage = new FormAutofillStorage(path); + await profileStorage.initialize(); + + Assert.equal(profileStorage._store.data.version, 1); + Assert.equal(profileStorage._store.data.creditCards.length, 0); + + let data = profileStorage._store.data; + Assert.deepEqual(data.creditCards, []); + + await profileStorage._saveImmediately(); + + profileStorage = new FormAutofillStorage(path); + await profileStorage.initialize(); + + Assert.deepEqual(profileStorage._store.data, data); +}); + +add_task(async function test_getAll() { + let path = getTempFile(TEST_STORE_FILE_NAME).path; + await prepareTestCreditCards(path); + + let profileStorage = new FormAutofillStorage(path); + await profileStorage.initialize(); + + let creditCards = await profileStorage.creditCards.getAll(); + + Assert.equal(creditCards.length, 2); + do_check_credit_card_matches(creditCards[0], TEST_CREDIT_CARD_1); + do_check_credit_card_matches(creditCards[1], TEST_CREDIT_CARD_2); + + // Check computed fields. + Assert.equal(creditCards[0]["cc-given-name"], "John"); + Assert.equal(creditCards[0]["cc-family-name"], "Doe"); + Assert.equal(creditCards[0]["cc-exp"], "2017-04"); + + // Test with rawData set. + creditCards = await profileStorage.creditCards.getAll({ rawData: true }); + Assert.equal(creditCards[0]["cc-given-name"], undefined); + Assert.equal(creditCards[0]["cc-family-name"], undefined); + Assert.equal(creditCards[0]["cc-exp"], undefined); + + // Modifying output shouldn't affect the storage. + creditCards[0]["cc-name"] = "test"; + do_check_credit_card_matches( + (await profileStorage.creditCards.getAll())[0], + TEST_CREDIT_CARD_1 + ); +}); + +add_task(async function test_get() { + let path = getTempFile(TEST_STORE_FILE_NAME).path; + await prepareTestCreditCards(path); + + let profileStorage = new FormAutofillStorage(path); + await profileStorage.initialize(); + + let creditCards = await profileStorage.creditCards.getAll(); + let guid = creditCards[0].guid; + + let creditCard = await profileStorage.creditCards.get(guid); + do_check_credit_card_matches(creditCard, TEST_CREDIT_CARD_1); + + // Modifying output shouldn't affect the storage. + creditCards[0]["cc-name"] = "test"; + do_check_credit_card_matches( + await profileStorage.creditCards.get(guid), + TEST_CREDIT_CARD_1 + ); + + Assert.equal(await profileStorage.creditCards.get("INVALID_GUID"), null); +}); + +add_task(async function test_add() { + let path = getTempFile(TEST_STORE_FILE_NAME).path; + await prepareTestCreditCards(path); + + let profileStorage = new FormAutofillStorage(path); + await profileStorage.initialize(); + + let creditCards = await profileStorage.creditCards.getAll(); + + Assert.equal(creditCards.length, 2); + + do_check_credit_card_matches(creditCards[0], TEST_CREDIT_CARD_1); + do_check_credit_card_matches(creditCards[1], TEST_CREDIT_CARD_2); + + Assert.notEqual(creditCards[0].guid, undefined); + Assert.equal(creditCards[0].version, 3); + Assert.notEqual(creditCards[0].timeCreated, undefined); + Assert.equal(creditCards[0].timeLastModified, creditCards[0].timeCreated); + Assert.equal(creditCards[0].timeLastUsed, 0); + Assert.equal(creditCards[0].timesUsed, 0); + + // Empty string should be deleted before saving. + await profileStorage.creditCards.add(TEST_CREDIT_CARD_WITH_EMPTY_FIELD); + let creditCard = profileStorage.creditCards._data[2]; + Assert.equal( + creditCard["cc-exp-month"], + TEST_CREDIT_CARD_WITH_EMPTY_FIELD["cc-exp-month"] + ); + Assert.equal(creditCard["cc-name"], undefined); + Assert.equal(creditCard.billingAddressGUID, undefined); + + // Empty computed fields shouldn't cause any problem. + await profileStorage.creditCards.add( + TEST_CREDIT_CARD_WITH_EMPTY_COMPUTED_FIELD + ); + creditCard = profileStorage.creditCards._data[3]; + Assert.equal( + creditCard["cc-number"], + CreditCard.getLongMaskedNumber( + TEST_CREDIT_CARD_WITH_EMPTY_COMPUTED_FIELD["cc-number"] + ) + ); + + await Assert.rejects( + profileStorage.creditCards.add(TEST_CREDIT_CARD_WITH_INVALID_FIELD), + /"invalidField" is not a valid field\./ + ); + + await Assert.rejects( + profileStorage.creditCards.add({}), + /Record contains no valid field\./ + ); + + await Assert.rejects( + profileStorage.creditCards.add(TEST_CREDIT_CARD_EMPTY_AFTER_NORMALIZE), + /Record contains no valid field\./ + ); +}); + +add_task(async function test_addWithBillingAddress() { + let path = getTempFile(TEST_STORE_FILE_NAME).path; + let profileStorage = new FormAutofillStorage(path); + await profileStorage.initialize(); + + let creditCards = await profileStorage.creditCards.getAll(); + + Assert.equal(creditCards.length, 0); + + await profileStorage.creditCards.add(TEST_CREDIT_CARD_WITH_BILLING_ADDRESS); + + creditCards = await profileStorage.creditCards.getAll(); + Assert.equal(creditCards.length, 1); + do_check_credit_card_matches( + creditCards[0], + TEST_CREDIT_CARD_WITH_BILLING_ADDRESS + ); +}); + +add_task(async function test_update() { + // Test assumes that when an entry is saved a second time, it's last modified date will + // be different from the first. With high values of precision reduction, we execute too + // fast for that to be true. + let timerPrecision = Preferences.get("privacy.reduceTimerPrecision"); + Preferences.set("privacy.reduceTimerPrecision", false); + + registerCleanupFunction(function() { + Preferences.set("privacy.reduceTimerPrecision", timerPrecision); + }); + + let path = getTempFile(TEST_STORE_FILE_NAME).path; + await prepareTestCreditCards(path); + + let profileStorage = new FormAutofillStorage(path); + await profileStorage.initialize(); + + let creditCards = await profileStorage.creditCards.getAll(); + let guid = creditCards[1].guid; + let timeLastModified = creditCards[1].timeLastModified; + + let onChanged = TestUtils.topicObserved( + "formautofill-storage-changed", + (subject, data) => + data == "update" && + subject.wrappedJSObject.guid == guid && + subject.wrappedJSObject.collectionName == COLLECTION_NAME + ); + + Assert.notEqual(creditCards[1]["cc-name"], undefined); + await profileStorage.creditCards.update(guid, TEST_CREDIT_CARD_3); + await onChanged; + await profileStorage._saveImmediately(); + + profileStorage = new FormAutofillStorage(path); + await profileStorage.initialize(); + + let creditCard = await profileStorage.creditCards.get(guid); + + Assert.equal(creditCard["cc-name"], undefined); + Assert.notEqual(creditCard.timeLastModified, timeLastModified); + do_check_credit_card_matches(creditCard, TEST_CREDIT_CARD_3); + + // Empty string should be deleted while updating. + await profileStorage.creditCards.update( + profileStorage.creditCards._data[0].guid, + TEST_CREDIT_CARD_WITH_EMPTY_FIELD + ); + creditCard = profileStorage.creditCards._data[0]; + Assert.equal( + creditCard["cc-exp-month"], + TEST_CREDIT_CARD_WITH_EMPTY_FIELD["cc-exp-month"] + ); + Assert.equal(creditCard["cc-name"], undefined); + Assert.equal(creditCard["cc-type"], "amex"); + Assert.equal(creditCard.billingAddressGUID, undefined); + + // Empty computed fields shouldn't cause any problem. + await profileStorage.creditCards.update( + profileStorage.creditCards._data[0].guid, + TEST_CREDIT_CARD_WITH_EMPTY_COMPUTED_FIELD, + false + ); + creditCard = profileStorage.creditCards._data[0]; + Assert.equal( + creditCard["cc-number"], + CreditCard.getLongMaskedNumber( + TEST_CREDIT_CARD_WITH_EMPTY_COMPUTED_FIELD["cc-number"] + ) + ); + await profileStorage.creditCards.update( + profileStorage.creditCards._data[1].guid, + TEST_CREDIT_CARD_WITH_EMPTY_COMPUTED_FIELD, + true + ); + creditCard = profileStorage.creditCards._data[1]; + Assert.equal( + creditCard["cc-number"], + CreditCard.getLongMaskedNumber( + TEST_CREDIT_CARD_WITH_EMPTY_COMPUTED_FIELD["cc-number"] + ) + ); + + // Decryption failure of existing record should not prevent it from being updated. + creditCard = profileStorage.creditCards._data[0]; + creditCard["cc-number-encrypted"] = "INVALID"; + await profileStorage.creditCards.update( + profileStorage.creditCards._data[0].guid, + TEST_CREDIT_CARD_WITH_EMPTY_COMPUTED_FIELD, + false + ); + creditCard = profileStorage.creditCards._data[0]; + Assert.equal( + creditCard["cc-number"], + CreditCard.getLongMaskedNumber( + TEST_CREDIT_CARD_WITH_EMPTY_COMPUTED_FIELD["cc-number"] + ) + ); + + await Assert.rejects( + profileStorage.creditCards.update("INVALID_GUID", TEST_CREDIT_CARD_3), + /No matching record\./ + ); + + await Assert.rejects( + profileStorage.creditCards.update( + guid, + TEST_CREDIT_CARD_WITH_INVALID_FIELD + ), + /"invalidField" is not a valid field\./ + ); + + await Assert.rejects( + profileStorage.creditCards.update(guid, {}), + /Record contains no valid field\./ + ); + + await Assert.rejects( + profileStorage.creditCards.update( + guid, + TEST_CREDIT_CARD_EMPTY_AFTER_NORMALIZE + ), + /Record contains no valid field\./ + ); + + await profileStorage.creditCards.update(guid, TEST_CREDIT_CARD_1); + await Assert.rejects( + profileStorage.creditCards.update( + guid, + TEST_CREDIT_CARD_EMPTY_AFTER_UPDATE_CREDIT_CARD_1 + ), + /Record contains no valid field\./ + ); +}); + +add_task(async function test_validate() { + let path = getTempFile(TEST_STORE_FILE_NAME).path; + + let profileStorage = new FormAutofillStorage(path); + await profileStorage.initialize(); + + await profileStorage.creditCards.add( + TEST_CREDIT_CARD_WITH_INVALID_EXPIRY_DATE + ); + await profileStorage.creditCards.add(TEST_CREDIT_CARD_WITH_2_DIGITS_YEAR); + await profileStorage.creditCards.add( + TEST_CREDIT_CARD_WITH_SPACES_BETWEEN_DIGITS + ); + await profileStorage.creditCards.add(TEST_CREDIT_CARD_WITH_INVALID_NETWORK); + + let creditCards = await profileStorage.creditCards.getAll(); + + Assert.equal(creditCards[0]["cc-exp-month"], undefined); + Assert.equal(creditCards[0]["cc-exp-year"], undefined); + Assert.equal(creditCards[0]["cc-exp"], undefined); + + let month = TEST_CREDIT_CARD_WITH_2_DIGITS_YEAR["cc-exp-month"]; + let year = + parseInt(TEST_CREDIT_CARD_WITH_2_DIGITS_YEAR["cc-exp-year"], 10) + 2000; + Assert.equal(creditCards[1]["cc-exp-month"], month); + Assert.equal(creditCards[1]["cc-exp-year"], year); + Assert.equal( + creditCards[1]["cc-exp"], + year + "-" + month.toString().padStart(2, "0") + ); + + Assert.equal(creditCards[2]["cc-number"].length, 16); + + // dont enforce validity on the card network when storing a record, + // to avoid data loss when syncing records between different clients with different rules + Assert.equal(creditCards[3]["cc-type"], "asiv"); +}); + +add_task(async function test_notifyUsed() { + let path = getTempFile(TEST_STORE_FILE_NAME).path; + await prepareTestCreditCards(path); + + let profileStorage = new FormAutofillStorage(path); + await profileStorage.initialize(); + + let creditCards = await profileStorage.creditCards.getAll(); + let guid = creditCards[1].guid; + let timeLastUsed = creditCards[1].timeLastUsed; + let timesUsed = creditCards[1].timesUsed; + + let onChanged = TestUtils.topicObserved( + "formautofill-storage-changed", + (subject, data) => + data == "notifyUsed" && + subject.wrappedJSObject.collectionName == COLLECTION_NAME && + subject.wrappedJSObject.guid == guid + ); + + profileStorage.creditCards.notifyUsed(guid); + await onChanged; + await profileStorage._saveImmediately(); + + profileStorage = new FormAutofillStorage(path); + await profileStorage.initialize(); + + let creditCard = await profileStorage.creditCards.get(guid); + + Assert.equal(creditCard.timesUsed, timesUsed + 1); + Assert.notEqual(creditCard.timeLastUsed, timeLastUsed); + + Assert.throws( + () => profileStorage.creditCards.notifyUsed("INVALID_GUID"), + /No matching record\./ + ); +}); + +add_task(async function test_remove() { + let path = getTempFile(TEST_STORE_FILE_NAME).path; + await prepareTestCreditCards(path); + + let profileStorage = new FormAutofillStorage(path); + await profileStorage.initialize(); + + let creditCards = await profileStorage.creditCards.getAll(); + let guid = creditCards[1].guid; + + let onChanged = TestUtils.topicObserved( + "formautofill-storage-changed", + (subject, data) => + data == "remove" && + subject.wrappedJSObject.guid == guid && + subject.wrappedJSObject.collectionName == COLLECTION_NAME + ); + + Assert.equal(creditCards.length, 2); + + profileStorage.creditCards.remove(guid); + await onChanged; + await profileStorage._saveImmediately(); + + profileStorage = new FormAutofillStorage(path); + await profileStorage.initialize(); + + creditCards = await profileStorage.creditCards.getAll(); + + Assert.equal(creditCards.length, 1); + + Assert.equal(await profileStorage.creditCards.get(guid), null); +}); + +MERGE_TESTCASES.forEach(testcase => { + add_task(async function test_merge() { + info("Starting testcase: " + testcase.description); + let profileStorage = await initProfileStorage( + TEST_STORE_FILE_NAME, + [testcase.creditCardInStorage], + "creditCards" + ); + let creditCards = await profileStorage.creditCards.getAll(); + let guid = creditCards[0].guid; + let timeLastModified = creditCards[0].timeLastModified; + // Merge creditCard and verify the guid in notifyObservers subject + let onMerged = TestUtils.topicObserved( + "formautofill-storage-changed", + (subject, data) => + data == "update" && + subject.wrappedJSObject.guid == guid && + subject.wrappedJSObject.collectionName == COLLECTION_NAME + ); + // Force to create sync metadata. + profileStorage.creditCards.pullSyncChanges(); + Assert.equal(getSyncChangeCounter(profileStorage.creditCards, guid), 1); + Assert.ok( + await profileStorage.creditCards.mergeIfPossible( + guid, + testcase.creditCardToMerge + ) + ); + if (!testcase.noNeedToUpdate) { + await onMerged; + } + creditCards = await profileStorage.creditCards.getAll(); + Assert.equal(creditCards.length, 1); + do_check_credit_card_matches(creditCards[0], testcase.expectedCreditCard); + if (!testcase.noNeedToUpdate) { + // Record merging should update timeLastModified and bump the change counter. + Assert.notEqual(creditCards[0].timeLastModified, timeLastModified); + Assert.equal(getSyncChangeCounter(profileStorage.creditCards, guid), 2); + } else { + // Subset record merging should not update timeLastModified and the change + // counter is still the same. + Assert.equal(creditCards[0].timeLastModified, timeLastModified); + Assert.equal(getSyncChangeCounter(profileStorage.creditCards, guid), 1); + } + }); +}); + +add_task(async function test_merge_unable_merge() { + let profileStorage = await initProfileStorage( + TEST_STORE_FILE_NAME, + [TEST_CREDIT_CARD_1], + "creditCards" + ); + + let creditCards = await profileStorage.creditCards.getAll(); + let guid = creditCards[0].guid; + // Force to create sync metadata. + profileStorage.creditCards.pullSyncChanges(); + Assert.equal(getSyncChangeCounter(profileStorage.creditCards, guid), 1); + + // Unable to merge because of conflict + let anotherCreditCard = profileStorage.creditCards._clone(TEST_CREDIT_CARD_1); + anotherCreditCard["cc-name"] = "Foo Bar"; + Assert.equal( + await profileStorage.creditCards.mergeIfPossible(guid, anotherCreditCard), + false + ); + // The change counter is unchanged. + Assert.equal(getSyncChangeCounter(profileStorage.creditCards, guid), 1); + + // Unable to merge because no credit card number + anotherCreditCard = profileStorage.creditCards._clone(TEST_CREDIT_CARD_1); + anotherCreditCard["cc-number"] = ""; + Assert.equal( + await profileStorage.creditCards.mergeIfPossible(guid, anotherCreditCard), + false + ); + // The change counter is still unchanged. + Assert.equal(getSyncChangeCounter(profileStorage.creditCards, guid), 1); +}); + +add_task(async function test_mergeToStorage() { + let profileStorage = await initProfileStorage( + TEST_STORE_FILE_NAME, + [TEST_CREDIT_CARD_3, TEST_CREDIT_CARD_4], + "creditCards" + ); + // Merge a creditCard to storage + let anotherCreditCard = profileStorage.creditCards._clone(TEST_CREDIT_CARD_3); + anotherCreditCard["cc-name"] = "Foo Bar"; + Assert.equal( + (await profileStorage.creditCards.mergeToStorage(anotherCreditCard)).length, + 2 + ); + Assert.equal( + (await profileStorage.creditCards.getAll())[0]["cc-name"], + "Foo Bar" + ); + Assert.equal( + (await profileStorage.creditCards.getAll())[0]["cc-exp"], + "2000-01" + ); + Assert.equal( + (await profileStorage.creditCards.getAll())[1]["cc-name"], + "Foo Bar" + ); + Assert.equal( + (await profileStorage.creditCards.getAll())[1]["cc-exp"], + "2000-01" + ); + + // Empty computed fields shouldn't cause any problem. + Assert.equal( + ( + await profileStorage.creditCards.mergeToStorage( + TEST_CREDIT_CARD_WITH_EMPTY_COMPUTED_FIELD + ) + ).length, + 0 + ); +}); + +add_task(async function test_getDuplicateGuid() { + let profileStorage = await initProfileStorage( + TEST_STORE_FILE_NAME, + [TEST_CREDIT_CARD_3], + "creditCards" + ); + let guid = profileStorage.creditCards._data[0].guid; + + // Absolutely a duplicate. + Assert.equal( + await profileStorage.creditCards.getDuplicateGuid(TEST_CREDIT_CARD_3), + guid + ); + + // Absolutely not a duplicate. + Assert.equal( + await profileStorage.creditCards.getDuplicateGuid(TEST_CREDIT_CARD_1), + null + ); + + // Subset with the same number is a duplicate. + let record = Object.assign({}, TEST_CREDIT_CARD_3); + delete record["cc-exp-month"]; + Assert.equal(await profileStorage.creditCards.getDuplicateGuid(record), guid); + + // Superset with the same number is a duplicate. + record = Object.assign({}, TEST_CREDIT_CARD_3); + record["cc-name"] = "John Doe"; + Assert.equal(await profileStorage.creditCards.getDuplicateGuid(record), guid); + + // Numbers with the same last 4 digits shouldn't be treated as a duplicate. + record = Object.assign({}, TEST_CREDIT_CARD_3); + let last4Digits = record["cc-number"].substr(-4); + // This number differs from TEST_CREDIT_CARD_3 by swapping the order of the + // 09 and 90 adjacent digits, which is still a valid credit card number. + record["cc-number"] = "358999378390" + last4Digits; + + // We don't treat numbers with the same last 4 digits as a duplicate. + Assert.equal(await profileStorage.creditCards.getDuplicateGuid(record), null); +}); + +add_task(async function test_getDuplicateGuidMatch() { + let profileStorage = await initProfileStorage( + TEST_STORE_FILE_NAME, + [TEST_CREDIT_CARD_2], + "creditCards" + ); + let guid = profileStorage.creditCards._data[0].guid; + + // Absolutely a duplicate. + Assert.equal( + await profileStorage.creditCards.getDuplicateGuid(TEST_CREDIT_CARD_2), + guid + ); + + // Absolutely not a duplicate. + Assert.equal( + await profileStorage.creditCards.getDuplicateGuid(TEST_CREDIT_CARD_1), + null + ); + + // Numbers with the same last 4 digits shouldn't be treated as a duplicate. + record = Object.assign({}, TEST_CREDIT_CARD_2); + + // We change month from `1` to `2` + record["cc-exp-month"] = 2; + Assert.equal(await profileStorage.creditCards.getDuplicateGuid(record), guid); + + // We change year from `2000` to `2001` + record["cc-exp-year"] = 2001; + Assert.equal(await profileStorage.creditCards.getDuplicateGuid(record), guid); + + // New name, same card + record["cc-name"] = "John Doe"; + Assert.equal(await profileStorage.creditCards.getDuplicateGuid(record), guid); +}); + +add_task(async function test_creditCardFillDisabled() { + Services.prefs.setBoolPref( + "extensions.formautofill.creditCards.enabled", + false + ); + + let path = getTempFile(TEST_STORE_FILE_NAME).path; + let profileStorage = new FormAutofillStorage(path); + await profileStorage.initialize(); + + Assert.equal( + !!profileStorage.creditCards, + true, + "credit card records initialized and available." + ); + + Services.prefs.setBoolPref( + "extensions.formautofill.creditCards.enabled", + true + ); +}); + +add_task(async function test_creditCardFillUnavailable() { + Services.prefs.setBoolPref( + "extensions.formautofill.creditCards.available", + false + ); + + let path = getTempFile(TEST_STORE_FILE_NAME).path; + let profileStorage = new FormAutofillStorage(path); + await profileStorage.initialize(); + + try { + profileStorage.creditCards; // eslint-disable-line no-unused-expressions + throw new Error("Access credit card didn't throw."); + } catch (err) { + Assert.equal( + err.message, + "CreditCards is not initialized. " + + "Please restart if you flip the pref manually." + ); + } + + Services.prefs.setBoolPref( + "extensions.formautofill.creditCards.available", + true + ); +}); diff --git a/browser/extensions/formautofill/test/unit/test_extractLabelStrings.js b/browser/extensions/formautofill/test/unit/test_extractLabelStrings.js new file mode 100644 index 0000000000..6264b5081f --- /dev/null +++ b/browser/extensions/formautofill/test/unit/test_extractLabelStrings.js @@ -0,0 +1,80 @@ +"use strict"; + +var LabelUtils; +add_task(async function() { + ({ LabelUtils } = ChromeUtils.import( + "resource://formautofill/FormAutofillHeuristics.jsm" + )); +}); + +const TESTCASES = [ + { + description: "A label element contains one input element.", + document: `<label id="typeA"> label type A + <!-- This comment should not be extracted. --> + <input type="text"> + <script>FOO</script> + <noscript>FOO</noscript> + <option>FOO</option> + <style>FOO</style> + </label>`, + inputId: "typeA", + expectedStrings: ["label type A"], + }, + { + description: "A label element with inner div contains one input element.", + document: `<label id="typeB"> label type B + <!-- This comment should not be extracted. --> + <script>FOO</script> + <noscript>FOO</noscript> + <option>FOO</option> + <style>FOO</style> + <div> inner div + <input type="text"> + </div> + </label>`, + inputId: "typeB", + expectedStrings: ["label type B", "inner div"], + }, + { + description: + "A label element with inner prefix/postfix strings contains span elements.", + document: `<label id="typeC"> label type C + <!-- This comment should not be extracted. --> + <script>FOO</script> + <noscript>FOO</noscript> + <option>FOO</option> + <style>FOO</style> + <div> inner div prefix + <span>test C-1 </span> + <span> test C-2</span> + inner div postfix + </div> + </label>`, + inputId: "typeC", + expectedStrings: [ + "label type C", + "inner div prefix", + "test C-1", + "test C-2", + "inner div postfix", + ], + }, +]; + +TESTCASES.forEach(testcase => { + add_task(async function() { + info("Starting testcase: " + testcase.description); + LabelUtils._labelStrings = new WeakMap(); + + let doc = MockDocument.createTestDocument( + "http://localhost:8080/test/", + testcase.document + ); + + let element = doc.getElementById(testcase.inputId); + let strings = LabelUtils.extractLabelStrings(element); + + Assert.deepEqual(strings, testcase.expectedStrings); + }); +}); diff --git a/browser/extensions/formautofill/test/unit/test_findLabelElements.js b/browser/extensions/formautofill/test/unit/test_findLabelElements.js new file mode 100644 index 0000000000..97c305f160 --- /dev/null +++ b/browser/extensions/formautofill/test/unit/test_findLabelElements.js @@ -0,0 +1,103 @@ +"use strict"; + +var LabelUtils; +add_task(async function() { + ({ LabelUtils } = ChromeUtils.import( + "resource://formautofill/FormAutofillHeuristics.jsm" + )); +}); + +const TESTCASES = [ + { + description: "Input contains in a label element.", + document: `<form> + <label id="labelA"> label type A + <input id="typeA" type="text"> + </label> + </form>`, + inputId: "typeA", + expectedLabelIds: ["labelA"], + }, + { + description: "Input contains in a label element.", + document: `<label id="labelB"> label type B + <div> inner div + <input id="typeB" type="text"> + </div> + </label>`, + inputId: "typeB", + expectedLabelIds: ["labelB"], + }, + { + description: '"for" attribute used to indicate input by one label.', + document: `<label id="labelC" for="typeC">label type C</label> + <input id="typeC" type="text">`, + inputId: "typeC", + expectedLabelIds: ["labelC"], + }, + { + description: '"for" attribute used to indicate input by multiple labels.', + document: `<form> + <label id="labelD1" for="typeD">label type D1</label> + <label id="labelD2" for="typeD">label type D2</label> + <label id="labelD3" for="typeD">label type D3</label> + <input id="typeD" type="text"> + </form>`, + inputId: "typeD", + expectedLabelIds: ["labelD1", "labelD2", "labelD3"], + }, + { + description: + '"for" attribute used to indicate input by multiple labels with space prefix/postfix.', + document: `<label id="labelE1" for="typeE">label type E1</label> + <label id="labelE2" for="typeE ">label type E2</label> + <label id="labelE3" for=" TYPEe">label type E3</label> + <label id="labelE4" for=" typeE ">label type E4</label> + <input id=" typeE " type="text">`, + inputId: " typeE ", + expectedLabelIds: [], + }, + { + description: "Input contains in a label element.", + document: `<label id="labelF"> label type F + <label for="dummy"> inner label + <input id="typeF" type="text"> + <input id="dummy" type="text"> + </div> + </label>`, + inputId: "typeF", + expectedLabelIds: ["labelF"], + }, + { + description: + '"for" attribute used to indicate input by labels out of the form.', + document: `<label id="labelG1" for="typeG">label type G1</label> + <form> + <label id="labelG2" for="typeG">label type G2</label> + <input id="typeG" type="text"> + </form> + <label id="labelG3" for="typeG">label type G3</label>`, + inputId: "typeG", + expectedLabelIds: ["labelG1", "labelG2", "labelG3"], + }, +]; + +TESTCASES.forEach(testcase => { + add_task(async function() { + info("Starting testcase: " + testcase.description); + + let doc = MockDocument.createTestDocument( + "http://localhost:8080/test/", + testcase.document + ); + + let input = doc.getElementById(testcase.inputId); + let labels = LabelUtils.findLabelElements(input); + + Assert.deepEqual( + labels.map(l => l.id), + testcase.expectedLabelIds + ); + LabelUtils.clearLabelMap(); + }); +}); diff --git a/browser/extensions/formautofill/test/unit/test_getAdaptedProfiles.js b/browser/extensions/formautofill/test/unit/test_getAdaptedProfiles.js new file mode 100644 index 0000000000..fc9c22fbe1 --- /dev/null +++ b/browser/extensions/formautofill/test/unit/test_getAdaptedProfiles.js @@ -0,0 +1,1194 @@ +/* + * Test for form auto fill content helper fill all inputs function. + */ + +"use strict"; + +var FormAutofillHandler; +add_task(async function() { + ({ FormAutofillHandler } = ChromeUtils.import( + "resource://formautofill/FormAutofillHandler.jsm" + )); +}); + +const DEFAULT_ADDRESS_RECORD = { + guid: "123", + "street-address": "2 Harrison St\nline2\nline3", + "address-line1": "2 Harrison St", + "address-line2": "line2", + "address-line3": "line3", + "address-level1": "CA", + country: "US", + tel: "+19876543210", + "tel-national": "9876543210", +}; + +const ADDRESS_RECORD_2 = { + guid: "address2", + "given-name": "John", + "additional-name": "Middle", + "family-name": "Doe", + "postal-code": "940012345", +}; + +const DEFAULT_CREDITCARD_RECORD = { + guid: "123", + "cc-exp-month": 1, + "cc-exp-year": 2025, + "cc-exp": "2025-01", +}; + +const TESTCASES = [ + { + description: "Address form with street-address", + document: `<form> + <input autocomplete="given-name"> + <input autocomplete="family-name"> + <input id="street-addr" autocomplete="street-address"> + </form>`, + profileData: [Object.assign({}, DEFAULT_ADDRESS_RECORD)], + expectedResult: [ + { + guid: "123", + "street-address": "2 Harrison St line2 line3", + "-moz-street-address-one-line": "2 Harrison St line2 line3", + "address-line1": "2 Harrison St", + "address-line2": "line2", + "address-line3": "line3", + "address-level1": "CA", + country: "US", + tel: "+19876543210", + "tel-national": "9876543210", + }, + ], + }, + { + description: "Address form with street-address, address-line[1, 2, 3]", + document: `<form> + <input id="street-addr" autocomplete="street-address"> + <input id="line1" autocomplete="address-line1"> + <input id="line2" autocomplete="address-line2"> + <input id="line3" autocomplete="address-line3"> + </form>`, + profileData: [Object.assign({}, DEFAULT_ADDRESS_RECORD)], + expectedResult: [ + { + guid: "123", + "street-address": "2 Harrison St line2 line3", + "-moz-street-address-one-line": "2 Harrison St line2 line3", + "address-line1": "2 Harrison St", + "address-line2": "line2", + "address-line3": "line3", + "address-level1": "CA", + country: "US", + tel: "+19876543210", + "tel-national": "9876543210", + }, + ], + }, + { + description: "Address form with street-address, address-line1", + document: `<form> + <input autocomplete="given-name"> + <input id="street-addr" autocomplete="street-address"> + <input id="line1" autocomplete="address-line1"> + </form>`, + profileData: [Object.assign({}, DEFAULT_ADDRESS_RECORD)], + expectedResult: [ + { + guid: "123", + "street-address": "2 Harrison St line2 line3", + "-moz-street-address-one-line": "2 Harrison St line2 line3", + "address-line1": "2 Harrison St line2 line3", + "address-line2": "line2", + "address-line3": "line3", + "address-level1": "CA", + country: "US", + tel: "+19876543210", + "tel-national": "9876543210", + }, + ], + }, + { + description: "Address form with street-address, address-line[1, 2]", + document: `<form> + <input id="street-addr" autocomplete="street-address"> + <input id="line1" autocomplete="address-line1"> + <input id="line2" autocomplete="address-line2"> + </form>`, + profileData: [Object.assign({}, DEFAULT_ADDRESS_RECORD)], + expectedResult: [ + { + guid: "123", + "street-address": "2 Harrison St line2 line3", + "-moz-street-address-one-line": "2 Harrison St line2 line3", + "address-line1": "2 Harrison St", + "address-line2": "line2 line3", + "address-line3": "line3", + "address-level1": "CA", + country: "US", + tel: "+19876543210", + "tel-national": "9876543210", + }, + ], + }, + { + description: + "Address form with street-address, address-line[1, 3]" + + ", determined by autocomplete attr", + document: `<form> + <input id="street-addr" autocomplete="street-address"> + <input id="line1" autocomplete="address-line1"> + <input id="line3" autocomplete="address-line3"> + </form>`, + profileData: [Object.assign({}, DEFAULT_ADDRESS_RECORD)], + expectedResult: [ + { + guid: "123", + "street-address": "2 Harrison St line2 line3", + "-moz-street-address-one-line": "2 Harrison St line2 line3", + // Since the form is missing address-line2 field, the value of + // address-line1 should contain line2 value as well. + "address-line1": "2 Harrison St line2", + "address-line2": "line2", + "address-line3": "line3", + "address-level1": "CA", + country: "US", + tel: "+19876543210", + "tel-national": "9876543210", + }, + ], + }, + { + description: + "Address form with street-address, address-line[1, 3]" + + ", determined by heuristics", + document: `<form> + <input id="street-address"> + <input id="address-line1"> + <input id="address-line3"> + </form>`, + profileData: [Object.assign({}, DEFAULT_ADDRESS_RECORD)], + expectedResult: [ + { + guid: "123", + "street-address": "2 Harrison St line2 line3", + "-moz-street-address-one-line": "2 Harrison St line2 line3", + // Since the form is missing address-line2 field, the value of + // address-line1 should contain line2 value as well. + "address-line1": "2 Harrison St line2", + "address-line2": "line2", + "address-line3": "line3", + "address-level1": "CA", + country: "US", + tel: "+19876543210", + "tel-national": "9876543210", + }, + ], + }, + { + description: "Address form with exact matching options in select", + document: `<form> + <input autocomplete="given-name"> + <select autocomplete="address-level1"> + <option id="option-address-level1-XX" value="XX">Dummy</option> + <option id="option-address-level1-CA" value="CA">California</option> + </select> + <select autocomplete="country"> + <option id="option-country-XX" value="XX">Dummy</option> + <option id="option-country-US" value="US">United States</option> + </select> + </form>`, + profileData: [Object.assign({}, DEFAULT_ADDRESS_RECORD)], + expectedResult: [ + { + guid: "123", + "street-address": "2 Harrison St\nline2\nline3", + "-moz-street-address-one-line": "2 Harrison St line2 line3", + "address-line1": "2 Harrison St", + "address-line2": "line2", + "address-line3": "line3", + "address-level1": "CA", + country: "US", + tel: "+19876543210", + "tel-national": "9876543210", + }, + ], + expectedOptionElements: [ + { + "address-level1": "option-address-level1-CA", + country: "option-country-US", + }, + ], + }, + { + description: "Address form with inexact matching options in select", + document: `<form> + <input autocomplete="given-name"> + <select autocomplete="address-level1"> + <option id="option-address-level1-XX" value="XX">Dummy</option> + <option id="option-address-level1-OO" value="OO">California</option> + </select> + <select autocomplete="country"> + <option id="option-country-XX" value="XX">Dummy</option> + <option id="option-country-OO" value="OO">United States</option> + </select> + </form>`, + profileData: [Object.assign({}, DEFAULT_ADDRESS_RECORD)], + expectedResult: [ + { + guid: "123", + "street-address": "2 Harrison St\nline2\nline3", + "-moz-street-address-one-line": "2 Harrison St line2 line3", + "address-line1": "2 Harrison St", + "address-line2": "line2", + "address-line3": "line3", + "address-level1": "CA", + country: "US", + tel: "+19876543210", + "tel-national": "9876543210", + }, + ], + expectedOptionElements: [ + { + "address-level1": "option-address-level1-OO", + country: "option-country-OO", + }, + ], + }, + { + description: "Address form with value-omitted options in select", + document: `<form> + <input autocomplete="given-name"> + <select autocomplete="address-level1"> + <option id="option-address-level1-1" value="">Dummy</option> + <option id="option-address-level1-2" value="">California</option> + </select> + <select autocomplete="country"> + <option id="option-country-1" value="">Dummy</option> + <option id="option-country-2" value="">United States</option> + </select> + </form>`, + profileData: [Object.assign({}, DEFAULT_ADDRESS_RECORD)], + expectedResult: [ + { + guid: "123", + "street-address": "2 Harrison St\nline2\nline3", + "-moz-street-address-one-line": "2 Harrison St line2 line3", + "address-line1": "2 Harrison St", + "address-line2": "line2", + "address-line3": "line3", + "address-level1": "CA", + country: "US", + tel: "+19876543210", + "tel-national": "9876543210", + }, + ], + expectedOptionElements: [ + { + "address-level1": "option-address-level1-2", + country: "option-country-2", + }, + ], + }, + { + description: "Address form with options with the same value in select ", + document: `<form> + <input autocomplete="given-name"> + <select autocomplete="address-level1"> + <option id="option-address-level1-same1" value="same">Dummy</option> + <option id="option-address-level1-same2" value="same">California</option> + </select> + <select autocomplete="country"> + <option id="option-country-same1" value="sametoo">Dummy</option> + <option id="option-country-same2" value="sametoo">United States</option> + </select> + </form>`, + profileData: [Object.assign({}, DEFAULT_ADDRESS_RECORD)], + expectedResult: [ + { + guid: "123", + "street-address": "2 Harrison St\nline2\nline3", + "-moz-street-address-one-line": "2 Harrison St line2 line3", + "address-line1": "2 Harrison St", + "address-line2": "line2", + "address-line3": "line3", + "address-level1": "CA", + country: "US", + tel: "+19876543210", + "tel-national": "9876543210", + }, + ], + expectedOptionElements: [ + { + "address-level1": "option-address-level1-same2", + country: "option-country-same2", + }, + ], + }, + { + description: + "Address form without matching options in select for address-level1 and country", + document: `<form> + <input autocomplete="given-name"> + <select autocomplete="address-level1"> + <option id="option-address-level1-dummy1" value="">Dummy</option> + <option id="option-address-level1-dummy2" value="">Dummy 2</option> + </select> + <select autocomplete="country"> + <option id="option-country-dummy1" value="">Dummy</option> + <option id="option-country-dummy2" value="">Dummy 2</option> + </select> + </form>`, + profileData: [Object.assign({}, DEFAULT_ADDRESS_RECORD)], + expectedResult: [ + { + guid: "123", + "street-address": "2 Harrison St\nline2\nline3", + "-moz-street-address-one-line": "2 Harrison St line2 line3", + "address-line1": "2 Harrison St", + "address-line2": "line2", + "address-line3": "line3", + tel: "+19876543210", + "tel-national": "9876543210", + }, + ], + }, + { + description: + "Change the tel value of a profile to tel-national for a field without pattern and maxlength.", + document: `<form> + <input id="telephone"> + <input id="line1" autocomplete="address-line1"> + <input id="line2" autocomplete="address-line2"> + </form>`, + profileData: [Object.assign({}, DEFAULT_ADDRESS_RECORD)], + expectedResult: [ + { + guid: "123", + "street-address": "2 Harrison St\nline2\nline3", + "-moz-street-address-one-line": "2 Harrison St line2 line3", + "address-line1": "2 Harrison St", + "address-line2": "line2 line3", + "address-line3": "line3", + "address-level1": "CA", + country: "US", + tel: "9876543210", + "tel-national": "9876543210", + }, + ], + }, + { + description: + 'Do not change the profile for an autocomplete="tel" field without patern and maxlength.', + document: `<form> + <input id="tel" autocomplete="tel"> + <input id="line1" autocomplete="address-line1"> + <input id="line2" autocomplete="address-line2"> + </form>`, + profileData: [Object.assign({}, DEFAULT_ADDRESS_RECORD)], + expectedResult: [ + { + guid: "123", + "street-address": "2 Harrison St\nline2\nline3", + "-moz-street-address-one-line": "2 Harrison St line2 line3", + "address-line1": "2 Harrison St", + "address-line2": "line2 line3", + "address-line3": "line3", + "address-level1": "CA", + country: "US", + tel: "+19876543210", + "tel-national": "9876543210", + }, + ], + }, + { + description: + 'autocomplete="tel" field with `maxlength` can be filled with `tel` value.', + document: `<form> + <input id="telephone" autocomplete="tel" maxlength="12"> + <input id="line1" autocomplete="address-line1"> + <input id="line2" autocomplete="address-line2"> + </form>`, + profileData: [Object.assign({}, DEFAULT_ADDRESS_RECORD)], + expectedResult: [ + { + guid: "123", + "street-address": "2 Harrison St\nline2\nline3", + "-moz-street-address-one-line": "2 Harrison St line2 line3", + "address-line1": "2 Harrison St", + "address-line2": "line2 line3", + "address-line3": "line3", + "address-level1": "CA", + country: "US", + tel: "+19876543210", + "tel-national": "9876543210", + }, + ], + }, + { + description: + "Still fill `tel-national` in a `tel` field with `maxlength` can be filled with `tel` value.", + document: `<form> + <input id="telephone" maxlength="12"> + <input id="line1" autocomplete="address-line1"> + <input id="line2" autocomplete="address-line2"> + </form>`, + profileData: [Object.assign({}, DEFAULT_ADDRESS_RECORD)], + expectedResult: [ + { + guid: "123", + "street-address": "2 Harrison St\nline2\nline3", + "-moz-street-address-one-line": "2 Harrison St line2 line3", + "address-line1": "2 Harrison St", + "address-line2": "line2 line3", + "address-line3": "line3", + "address-level1": "CA", + country: "US", + tel: "9876543210", + "tel-national": "9876543210", + }, + ], + }, + { + description: + "`tel` field with `maxlength` can be filled with `tel-national` value.", + document: `<form> + <input id="telephone" maxlength="10"> + <input id="line1" autocomplete="address-line1"> + <input id="line2" autocomplete="address-line2"> + </form>`, + profileData: [Object.assign({}, DEFAULT_ADDRESS_RECORD)], + expectedResult: [ + { + guid: "123", + "street-address": "2 Harrison St\nline2\nline3", + "-moz-street-address-one-line": "2 Harrison St line2 line3", + "address-line1": "2 Harrison St", + "address-line2": "line2 line3", + "address-line3": "line3", + "address-level1": "CA", + country: "US", + tel: "9876543210", + "tel-national": "9876543210", + }, + ], + }, + { + description: + "`tel` field with `pattern` attr can be filled with `tel` value.", + document: `<form> + <input id="telephone" pattern="[+][0-9]+"> + <input id="line1" autocomplete="address-line1"> + <input id="line2" autocomplete="address-line2"> + </form>`, + profileData: [Object.assign({}, DEFAULT_ADDRESS_RECORD)], + expectedResult: [ + { + guid: "123", + "street-address": "2 Harrison St\nline2\nline3", + "-moz-street-address-one-line": "2 Harrison St line2 line3", + "address-line1": "2 Harrison St", + "address-line2": "line2 line3", + "address-line3": "line3", + "address-level1": "CA", + country: "US", + tel: "+19876543210", + "tel-national": "9876543210", + }, + ], + }, + { + description: + "Change the tel value of a profile to tel-national one when the pattern is matched.", + document: `<form> + <input id="telephone" pattern="\d*"> + <input id="line1" autocomplete="address-line1"> + <input id="line2" autocomplete="address-line2"> + </form>`, + profileData: [Object.assign({}, DEFAULT_ADDRESS_RECORD)], + expectedResult: [ + { + guid: "123", + "street-address": "2 Harrison St\nline2\nline3", + "-moz-street-address-one-line": "2 Harrison St line2 line3", + "address-line1": "2 Harrison St", + "address-line2": "line2 line3", + "address-line3": "line3", + "address-level1": "CA", + country: "US", + tel: "9876543210", + "tel-national": "9876543210", + }, + ], + }, + { + description: 'Matching pattern when a field is with autocomplete="tel".', + document: `<form> + <input id="tel" autocomplete="tel" pattern="[0-9]+"> + <input id="line1" autocomplete="address-line1"> + <input id="line2" autocomplete="address-line2"> + </form>`, + profileData: [Object.assign({}, DEFAULT_ADDRESS_RECORD)], + expectedResult: [ + { + guid: "123", + "street-address": "2 Harrison St\nline2\nline3", + "-moz-street-address-one-line": "2 Harrison St line2 line3", + "address-line1": "2 Harrison St", + "address-line2": "line2 line3", + "address-line3": "line3", + "address-level1": "CA", + country: "US", + tel: "9876543210", + "tel-national": "9876543210", + }, + ], + }, + { + description: + "Checking maxlength of tel field first when a field is with maxlength.", + document: `<form> + <input id="tel" autocomplete="tel" maxlength="10"> + <input id="line1" autocomplete="address-line1"> + <input id="line2" autocomplete="address-line2"> + </form>`, + profileData: [Object.assign({}, DEFAULT_ADDRESS_RECORD)], + expectedResult: [ + { + guid: "123", + "street-address": "2 Harrison St\nline2\nline3", + "-moz-street-address-one-line": "2 Harrison St line2 line3", + "address-line1": "2 Harrison St", + "address-line2": "line2 line3", + "address-line3": "line3", + "address-level1": "CA", + country: "US", + tel: "9876543210", + "tel-national": "9876543210", + }, + ], + }, + { + description: "Address form with maxlength restriction", + document: `<form> + <input autocomplete="given-name" maxlength="1"> + <input autocomplete="additional-name" maxlength="1"> + <input autocomplete="family-name" maxlength="1"> + <input autocomplete="postal-code" maxlength="5"> + </form>`, + profileData: [Object.assign({}, ADDRESS_RECORD_2)], + expectedResult: [ + { + guid: "address2", + "given-name": "J", + "additional-name": "M", + "family-name": "D", + "postal-code": "94001", + }, + ], + }, + { + description: + "Address form with the special cases of the maxlength restriction", + document: `<form> + <input autocomplete="given-name" maxlength="-1"> + <input autocomplete="additional-name" maxlength="0"> + <input autocomplete="family-name" maxlength="1"> + </form>`, + profileData: [Object.assign({}, ADDRESS_RECORD_2)], + expectedResult: [ + { + guid: "address2", + "given-name": "John", + "family-name": "D", + "postal-code": "940012345", + }, + ], + }, + { + description: + "Credit Card form with matching options of cc-exp-year and cc-exp-month", + document: `<form> + <input autocomplete="cc-number"> + <select autocomplete="cc-exp-month"> + <option id="option-cc-exp-month-01" value="1">01</option> + <option id="option-cc-exp-month-02" value="2">02</option> + <option id="option-cc-exp-month-03" value="3">03</option> + <option id="option-cc-exp-month-04" value="4">04</option> + <option id="option-cc-exp-month-05" value="5">05</option> + <option id="option-cc-exp-month-06" value="6">06</option> + <option id="option-cc-exp-month-07" value="7">07</option> + <option id="option-cc-exp-month-08" value="8">08</option> + <option id="option-cc-exp-month-09" value="9">09</option> + <option id="option-cc-exp-month-10" value="10">10</option> + <option id="option-cc-exp-month-11" value="11">11</option> + <option id="option-cc-exp-month-12" value="12">12</option> + </select> + <select autocomplete="cc-exp-year"> + <option id="option-cc-exp-year-17" value="2017">17</option> + <option id="option-cc-exp-year-18" value="2018">18</option> + <option id="option-cc-exp-year-19" value="2019">19</option> + <option id="option-cc-exp-year-20" value="2020">20</option> + <option id="option-cc-exp-year-21" value="2021">21</option> + <option id="option-cc-exp-year-22" value="2022">22</option> + <option id="option-cc-exp-year-23" value="2023">23</option> + <option id="option-cc-exp-year-24" value="2024">24</option> + <option id="option-cc-exp-year-25" value="2025">25</option> + <option id="option-cc-exp-year-26" value="2026">26</option> + <option id="option-cc-exp-year-27" value="2027">27</option> + <option id="option-cc-exp-year-28" value="2028">28</option> + </select> + </form>`, + profileData: [Object.assign({}, DEFAULT_CREDITCARD_RECORD)], + expectedResult: [DEFAULT_CREDITCARD_RECORD], + expectedOptionElements: [ + { + "cc-exp-month": "option-cc-exp-month-01", + "cc-exp-year": "option-cc-exp-year-25", + }, + ], + }, + { + description: "Credit Card form with matching options which contain labels", + document: `<form> + <input autocomplete="cc-number"> + <select autocomplete="cc-exp-month"> + <option value="" selected="selected">Month</option> + <option label="01 - January" id="option-cc-exp-month-01" value="object:17">dummy</option> + <option label="02 - February" id="option-cc-exp-month-02" value="object:18">dummy</option> + <option label="03 - March" id="option-cc-exp-month-03" value="object:19">dummy</option> + <option label="04 - April" id="option-cc-exp-month-04" value="object:20">dummy</option> + <option label="05 - May" id="option-cc-exp-month-05" value="object:21">dummy</option> + <option label="06 - June" id="option-cc-exp-month-06" value="object:22">dummy</option> + <option label="07 - July" id="option-cc-exp-month-07" value="object:23">dummy</option> + <option label="08 - August" id="option-cc-exp-month-08" value="object:24">dummy</option> + <option label="09 - September" id="option-cc-exp-month-09" value="object:25">dummy</option> + <option label="10 - October" id="option-cc-exp-month-10" value="object:26">dummy</option> + <option label="11 - November" id="option-cc-exp-month-11" value="object:27">dummy</option> + <option label="12 - December" id="option-cc-exp-month-12" value="object:28">dummy</option> + </select> + <select autocomplete="cc-exp-year"> + <option value="" selected="selected">Year</option> + <option label="2017" id="option-cc-exp-year-17" value="object:29">dummy</option> + <option label="2018" id="option-cc-exp-year-18" value="object:30">dummy</option> + <option label="2019" id="option-cc-exp-year-19" value="object:31">dummy</option> + <option label="2020" id="option-cc-exp-year-20" value="object:32">dummy</option> + <option label="2021" id="option-cc-exp-year-21" value="object:33">dummy</option> + <option label="2022" id="option-cc-exp-year-22" value="object:34">dummy</option> + <option label="2023" id="option-cc-exp-year-23" value="object:35">dummy</option> + <option label="2024" id="option-cc-exp-year-24" value="object:36">dummy</option> + <option label="2025" id="option-cc-exp-year-25" value="object:37">dummy</option> + <option label="2026" id="option-cc-exp-year-26" value="object:38">dummy</option> + <option label="2027" id="option-cc-exp-year-27" value="object:39">dummy</option> + <option label="2028" id="option-cc-exp-year-28" value="object:40">dummy</option> + <option label="2029" id="option-cc-exp-year-29" value="object:41">dummy</option> + <option label="2030" id="option-cc-exp-year-30" value="object:42">dummy</option> + <option label="2031" id="option-cc-exp-year-31" value="object:43">dummy</option> + <option label="2032" id="option-cc-exp-year-32" value="object:44">dummy</option> + <option label="2033" id="option-cc-exp-year-33" value="object:45">dummy</option> + <option label="2034" id="option-cc-exp-year-34" value="object:46">dummy</option> + <option label="2035" id="option-cc-exp-year-35" value="object:47">dummy</option> + </select> + </form>`, + profileData: [Object.assign({}, DEFAULT_CREDITCARD_RECORD)], + expectedResult: [DEFAULT_CREDITCARD_RECORD], + expectedOptionElements: [ + { + "cc-exp-month": "option-cc-exp-month-01", + "cc-exp-year": "option-cc-exp-year-25", + }, + ], + }, + { + description: "Compound cc-exp: {MON1}/{YEAR2}", + document: `<form> + <input autocomplete="cc-number"> + <select autocomplete="cc-exp"> + <option value="3/17">3/17</option> + <option value="1/25" id="selected-cc-exp">1/25</option> + </select></form>`, + profileData: [Object.assign({}, DEFAULT_CREDITCARD_RECORD)], + expectedResult: [DEFAULT_CREDITCARD_RECORD], + expectedOptionElements: [{ "cc-exp": "selected-cc-exp" }], + }, + { + description: "Compound cc-exp: {MON1}/{YEAR4}", + document: `<form> + <input autocomplete="cc-number"> + <select autocomplete="cc-exp"> + <option value="3/2017">3/2017</option> + <option value="1/2025" id="selected-cc-exp">1/2025</option> + </select></form>`, + profileData: [Object.assign({}, DEFAULT_CREDITCARD_RECORD)], + expectedResult: [DEFAULT_CREDITCARD_RECORD], + expectedOptionElements: [{ "cc-exp": "selected-cc-exp" }], + }, + { + description: "Compound cc-exp: {MON2}/{YEAR2}", + document: `<form> + <input autocomplete="cc-number"> + <select autocomplete="cc-exp"> + <option value="03/17">03/17</option> + <option value="01/25" id="selected-cc-exp">01/25</option> + </select></form>`, + profileData: [Object.assign({}, DEFAULT_CREDITCARD_RECORD)], + expectedResult: [DEFAULT_CREDITCARD_RECORD], + expectedOptionElements: [{ "cc-exp": "selected-cc-exp" }], + }, + { + description: "Compound cc-exp: {MON2}/{YEAR4}", + document: `<form> + <input autocomplete="cc-number"> + <select autocomplete="cc-exp"> + <option value="03/2017">03/2017</option> + <option value="01/2025" id="selected-cc-exp">01/2025</option> + </select></form>`, + profileData: [Object.assign({}, DEFAULT_CREDITCARD_RECORD)], + expectedResult: [DEFAULT_CREDITCARD_RECORD], + expectedOptionElements: [{ "cc-exp": "selected-cc-exp" }], + }, + { + description: "Compound cc-exp: {MON1}-{YEAR2}", + document: `<form> + <input autocomplete="cc-number"> + <select autocomplete="cc-exp"> + <option value="3-17">3-17</option> + <option value="1-25" id="selected-cc-exp">1-25</option> + </select></form>`, + profileData: [Object.assign({}, DEFAULT_CREDITCARD_RECORD)], + expectedResult: [DEFAULT_CREDITCARD_RECORD], + expectedOptionElements: [{ "cc-exp": "selected-cc-exp" }], + }, + { + description: "Compound cc-exp: {MON1}-{YEAR4}", + document: `<form> + <input autocomplete="cc-number"> + <select autocomplete="cc-exp"> + <option value="3-2017">3-2017</option> + <option value="1-2025" id="selected-cc-exp">1-2025</option> + </select></form>`, + profileData: [Object.assign({}, DEFAULT_CREDITCARD_RECORD)], + expectedResult: [DEFAULT_CREDITCARD_RECORD], + expectedOptionElements: [{ "cc-exp": "selected-cc-exp" }], + }, + { + description: "Compound cc-exp: {MON2}-{YEAR2}", + document: `<form> + <input autocomplete="cc-number"> + <select autocomplete="cc-exp"> + <option value="03-17">03-17</option> + <option value="01-25" id="selected-cc-exp">01-25</option> + </select></form>`, + profileData: [Object.assign({}, DEFAULT_CREDITCARD_RECORD)], + expectedResult: [DEFAULT_CREDITCARD_RECORD], + expectedOptionElements: [{ "cc-exp": "selected-cc-exp" }], + }, + { + description: "Compound cc-exp: {MON2}-{YEAR4}", + document: `<form> + <input autocomplete="cc-number"> + <select autocomplete="cc-exp"> + <option value="03-2017">03-2017</option> + <option value="01-2025" id="selected-cc-exp">01-2025</option> + </select></form>`, + profileData: [Object.assign({}, DEFAULT_CREDITCARD_RECORD)], + expectedResult: [DEFAULT_CREDITCARD_RECORD], + expectedOptionElements: [{ "cc-exp": "selected-cc-exp" }], + }, + { + description: "Compound cc-exp: {YEAR2}-{MON2}", + document: `<form> + <input autocomplete="cc-number"> + <select autocomplete="cc-exp"> + <option value="17-03">17-03</option> + <option value="25-01" id="selected-cc-exp">25-01</option> + </select></form>`, + profileData: [Object.assign({}, DEFAULT_CREDITCARD_RECORD)], + expectedResult: [DEFAULT_CREDITCARD_RECORD], + expectedOptionElements: [{ "cc-exp": "selected-cc-exp" }], + }, + { + description: "Compound cc-exp: {YEAR4}-{MON2}", + document: `<form> + <input autocomplete="cc-number"> + <select autocomplete="cc-exp"> + <option value="2017-03">2017-03</option> + <option value="2025-01" id="selected-cc-exp">2025-01</option> + </select></form>`, + profileData: [Object.assign({}, DEFAULT_CREDITCARD_RECORD)], + expectedResult: [DEFAULT_CREDITCARD_RECORD], + expectedOptionElements: [{ "cc-exp": "selected-cc-exp" }], + }, + { + description: "Compound cc-exp: {YEAR4}/{MON2}", + document: `<form> + <input autocomplete="cc-number"> + <select autocomplete="cc-exp"> + <option value="2017/3">2017/3</option> + <option value="2025/1" id="selected-cc-exp">2025/1</option> + </select></form>`, + profileData: [Object.assign({}, DEFAULT_CREDITCARD_RECORD)], + expectedResult: [DEFAULT_CREDITCARD_RECORD], + expectedOptionElements: [{ "cc-exp": "selected-cc-exp" }], + }, + { + description: "Compound cc-exp: {MON2}{YEAR2}", + document: `<form> + <input autocomplete="cc-number"> + <select autocomplete="cc-exp"> + <option value="0317">0317</option> + <option value="0125" id="selected-cc-exp">0125</option> + </select></form>`, + profileData: [Object.assign({}, DEFAULT_CREDITCARD_RECORD)], + expectedResult: [DEFAULT_CREDITCARD_RECORD], + expectedOptionElements: [{ "cc-exp": "selected-cc-exp" }], + }, + { + description: "Compound cc-exp: {YEAR2}{MON2}", + document: `<form> + <input autocomplete="cc-number"> + <select autocomplete="cc-exp"> + <option value="1703">1703</option> + <option value="2501" id="selected-cc-exp">2501</option> + </select></form>`, + profileData: [Object.assign({}, DEFAULT_CREDITCARD_RECORD)], + expectedResult: [DEFAULT_CREDITCARD_RECORD], + expectedOptionElements: [{ "cc-exp": "selected-cc-exp" }], + }, + { + description: "Fill a cc-exp without cc-exp-month value in the profile", + document: `<form> + <input autocomplete="cc-number"> + <select autocomplete="cc-exp"> + <option value="03/17">03/17</option> + <option value="01/25">01/25</option> + </select></form>`, + profileData: [ + Object.assign( + {}, + { + guid: "123", + "cc-exp-year": 2025, + } + ), + ], + expectedResult: [ + { + guid: "123", + "cc-exp-year": 2025, + }, + ], + expectedOptionElements: [], + }, + { + description: "Fill a cc-exp without cc-exp-year value in the profile", + document: `<form> + <input autocomplete="cc-number"> + <select autocomplete="cc-exp"> + <option value="03/17">03/17</option> + <option value="01/25">01/25</option> + </select></form>`, + profileData: [ + Object.assign( + {}, + { + guid: "123", + "cc-exp-month": 1, + } + ), + ], + expectedResult: [ + { + guid: "123", + "cc-exp-month": 1, + }, + ], + expectedOptionElements: [], + }, + { + description: "Fill a cc-exp* without cc-exp-month value in the profile", + document: `<form> + <input autocomplete="cc-number"> + <select autocomplete="cc-exp-month"> + <option value="03">03</option> + <option value="01">01</option> + </select> + <select autocomplete="cc-exp-year"> + <option value="17">2017</option> + <option value="25">2025</option> + </select> + </form>`, + profileData: [ + Object.assign( + {}, + { + guid: "123", + "cc-exp-year": 2025, + } + ), + ], + expectedResult: [ + { + guid: "123", + "cc-exp-year": 2025, + }, + ], + expectedOptionElements: [], + }, + { + description: "Fill a cc-exp* without cc-exp-year value in the profile", + document: `<form> + <input autocomplete="cc-number"> + <select autocomplete="cc-exp-month"> + <option value="03">03</option> + <option value="01">01</option> + </select> + <select autocomplete="cc-exp-year"> + <option value="17">2017</option> + <option value="25">2025</option> + </select> + </form>`, + profileData: [ + Object.assign( + {}, + { + guid: "123", + "cc-exp-month": 1, + } + ), + ], + expectedResult: [ + { + guid: "123", + "cc-exp-month": 1, + }, + ], + expectedOptionElements: [], + }, + { + description: "Use placeholder to adjust cc-exp format [mm/yy].", + document: `<form><input autocomplete="cc-number"> + <input placeholder="mm/yy" autocomplete="cc-exp"></form>`, + profileData: [Object.assign({}, DEFAULT_CREDITCARD_RECORD)], + expectedResult: [ + Object.assign({}, DEFAULT_CREDITCARD_RECORD, { + "cc-exp": "01/25", + }), + ], + }, + { + description: "Use placeholder to adjust cc-exp format [mm / yy].", + document: `<form><input autocomplete="cc-number"> + <input placeholder="mm / yy" autocomplete="cc-exp"></form>`, + profileData: [Object.assign({}, DEFAULT_CREDITCARD_RECORD)], + expectedResult: [ + Object.assign({}, DEFAULT_CREDITCARD_RECORD, { + "cc-exp": "01/25", + }), + ], + }, + { + description: "Use placeholder to adjust cc-exp format [MM / YY].", + document: `<form><input autocomplete="cc-number"> + <input placeholder="MM / YY" autocomplete="cc-exp"></form>`, + profileData: [Object.assign({}, DEFAULT_CREDITCARD_RECORD)], + expectedResult: [ + Object.assign({}, DEFAULT_CREDITCARD_RECORD, { + "cc-exp": "01/25", + }), + ], + }, + { + description: "Use placeholder to adjust cc-exp format [mm / yyyy].", + document: `<form><input autocomplete="cc-number"> + <input placeholder="mm / yyyy" autocomplete="cc-exp"></form>`, + profileData: [Object.assign({}, DEFAULT_CREDITCARD_RECORD)], + expectedResult: [ + Object.assign({}, DEFAULT_CREDITCARD_RECORD, { + "cc-exp": "01/2025", + }), + ], + }, + { + description: "Use placeholder to adjust cc-exp format [mm - yyyy].", + document: `<form><input autocomplete="cc-number"> + <input placeholder="mm - yyyy" autocomplete="cc-exp"></form>`, + profileData: [Object.assign({}, DEFAULT_CREDITCARD_RECORD)], + expectedResult: [ + Object.assign({}, DEFAULT_CREDITCARD_RECORD, { + "cc-exp": "01-2025", + }), + ], + }, + { + description: "Use placeholder to adjust cc-exp format [yyyy-mm].", + document: `<form><input autocomplete="cc-number"> + <input placeholder="yyyy-mm" autocomplete="cc-exp"></form>`, + profileData: [Object.assign({}, DEFAULT_CREDITCARD_RECORD)], + expectedResult: [ + Object.assign({}, DEFAULT_CREDITCARD_RECORD, { + "cc-exp": "2025-01", + }), + ], + }, + { + description: "Use placeholder to adjust cc-exp format [yyy-mm].", + document: `<form><input autocomplete="cc-number"> + <input placeholder="yyy-mm" autocomplete="cc-exp"></form>`, + profileData: [Object.assign({}, DEFAULT_CREDITCARD_RECORD)], + expectedResult: [ + Object.assign({}, DEFAULT_CREDITCARD_RECORD, { + "cc-exp": "025-01", + }), + ], + }, + { + description: "Use placeholder to adjust cc-exp format [mmm yyyy].", + document: `<form><input autocomplete="cc-number"> + <input placeholder="mmm yyyy" autocomplete="cc-exp"></form>`, + profileData: [Object.assign({}, DEFAULT_CREDITCARD_RECORD)], + expectedResult: [Object.assign({}, DEFAULT_CREDITCARD_RECORD)], + }, + { + description: "Use placeholder to adjust cc-exp format [mm foo yyyy].", + document: `<form><input autocomplete="cc-number"> + <input placeholder="mm foo yyyy" autocomplete="cc-exp"></form>`, + profileData: [Object.assign({}, DEFAULT_CREDITCARD_RECORD)], + expectedResult: [Object.assign({}, DEFAULT_CREDITCARD_RECORD)], + }, + { + description: "Use placeholder to adjust cc-exp format [mm - - yyyy].", + document: `<form><input autocomplete="cc-number"> + <input placeholder="mm - - yyyy" autocomplete="cc-exp"></form>`, + profileData: [Object.assign({}, DEFAULT_CREDITCARD_RECORD)], + expectedResult: [Object.assign({}, DEFAULT_CREDITCARD_RECORD)], + }, + { + description: "Test maxlength=2 on numeric fields.", + document: `<form> + <input autocomplete="cc-number"> + <input autocomplete="cc-exp-month" maxlength="2"> + <input autocomplete="cc-exp-year" maxlength="2"> + </form>`, + profileData: [Object.assign({}, DEFAULT_CREDITCARD_RECORD)], + expectedResult: [ + Object.assign({}, DEFAULT_CREDITCARD_RECORD, { + "cc-exp-year": 25, + }), + ], + }, + { + description: "Test maxlength=4 on numeric fields.", + document: `<form> + <input autocomplete="cc-number"> + <input autocomplete="cc-exp-month" maxlength="4"> + <input autocomplete="cc-exp-year" maxlength="4"> + </form>`, + profileData: [Object.assign({}, DEFAULT_CREDITCARD_RECORD)], + expectedResult: [Object.assign({}, DEFAULT_CREDITCARD_RECORD)], + }, + { + description: "Test maxlength=1 on numeric fields.", + document: `<form> + <input autocomplete="cc-number"> + <input autocomplete="cc-exp-month" maxlength="1"> + <input autocomplete="cc-exp-year" maxlength="1"> + </form>`, + profileData: [Object.assign({}, DEFAULT_CREDITCARD_RECORD)], + expectedResult: [ + Object.assign({}, DEFAULT_CREDITCARD_RECORD, { + "cc-exp-year": 5, + }), + ], + }, + { + description: "Test maxlength=0 on numeric fields.", + document: `<form> + <input autocomplete="cc-number"> + <input autocomplete="cc-exp-month" maxlength="0"> + <input autocomplete="cc-exp-year" maxlength="0"> + </form>`, + profileData: [Object.assign({}, DEFAULT_CREDITCARD_RECORD)], + expectedResult: [ + { + guid: DEFAULT_CREDITCARD_RECORD.guid, + "cc-exp": DEFAULT_CREDITCARD_RECORD["cc-exp"], + }, + ], + }, + { + // It appears that negative values do not get propagated. + description: "Test maxlength=-2 on numeric fields.", + document: `<form> + <input autocomplete="cc-number"> + <input autocomplete="cc-exp-month" maxlength="-2"> + <input autocomplete="cc-exp-year" maxlength="-2"> + </form>`, + profileData: [Object.assign({}, DEFAULT_CREDITCARD_RECORD)], + expectedResult: [Object.assign({}, DEFAULT_CREDITCARD_RECORD)], + }, + { + description: "Test maxlength=10 on numeric fields.", + document: `<form> + <input autocomplete="cc-number"> + <input autocomplete="cc-exp-month" maxlength="10"> + <input autocomplete="cc-exp-year" maxlength="10"> + </form>`, + profileData: [Object.assign({}, DEFAULT_CREDITCARD_RECORD)], + expectedResult: [Object.assign({}, DEFAULT_CREDITCARD_RECORD)], + }, + { + description: "Test (special case) maxlength=5 on cc-exp field.", + document: `<form> + <input autocomplete="cc-number"> + <input autocomplete="cc-exp" maxlength="5"> + </form>`, + profileData: [Object.assign({}, DEFAULT_CREDITCARD_RECORD)], + expectedResult: [ + Object.assign({}, DEFAULT_CREDITCARD_RECORD, { + "cc-exp": "01/25", + }), + ], + }, +]; + +for (let testcase of TESTCASES) { + add_task(async function() { + info("Starting testcase: " + testcase.description); + + let doc = MockDocument.createTestDocument( + "http://localhost:8080/test/", + testcase.document + ); + let form = doc.querySelector("form"); + let formLike = FormLikeFactory.createFromForm(form); + let handler = new FormAutofillHandler(formLike); + + handler.collectFormFields(); + handler.focusedInput = form.elements[0]; + let adaptedRecords = handler.activeSection.getAdaptedProfiles( + testcase.profileData + ); + Assert.deepEqual(adaptedRecords, testcase.expectedResult); + + if (testcase.expectedOptionElements) { + testcase.expectedOptionElements.forEach((expectedOptionElement, i) => { + for (let field in expectedOptionElement) { + let select = form.querySelector(`[autocomplete=${field}]`); + let expectedOption = doc.getElementById(expectedOptionElement[field]); + Assert.notEqual(expectedOption, null); + + let value = testcase.profileData[i][field]; + let cache = handler.activeSection._cacheValue.matchingSelectOption.get( + select + ); + let targetOption = cache[value] && cache[value].get(); + Assert.notEqual(targetOption, null); + + Assert.equal(targetOption, expectedOption); + } + }); + } + }); +} diff --git a/browser/extensions/formautofill/test/unit/test_getCategoriesFromFieldNames.js b/browser/extensions/formautofill/test/unit/test_getCategoriesFromFieldNames.js new file mode 100644 index 0000000000..2ce28dc8ad --- /dev/null +++ b/browser/extensions/formautofill/test/unit/test_getCategoriesFromFieldNames.js @@ -0,0 +1,95 @@ +"use strict"; + +var FormAutofillUtils; +add_task(async function() { + ({ FormAutofillUtils } = ChromeUtils.import( + "resource://formautofill/FormAutofillUtils.jsm" + )); +}); + +add_task(async function test_isAddressField_isCreditCardField() { + const TEST_CASES = { + "given-name": { + isAddressField: true, + isCreditCardField: false, + }, + organization: { + isAddressField: true, + isCreditCardField: false, + }, + "address-line2": { + isAddressField: true, + isCreditCardField: false, + }, + tel: { + isAddressField: true, + isCreditCardField: false, + }, + email: { + isAddressField: true, + isCreditCardField: false, + }, + "cc-number": { + isAddressField: false, + isCreditCardField: true, + }, + UNKNOWN: { + isAddressField: false, + isCreditCardField: false, + }, + "": { + isAddressField: false, + isCreditCardField: false, + }, + }; + + for (let fieldName of Object.keys(TEST_CASES)) { + info("Starting testcase: " + fieldName); + let field = TEST_CASES[fieldName]; + Assert.equal( + FormAutofillUtils.isAddressField(fieldName), + field.isAddressField, + "isAddressField" + ); + Assert.equal( + FormAutofillUtils.isCreditCardField(fieldName), + field.isCreditCardField, + "isCreditCardField" + ); + } +}); + +add_task(async function test_getCategoriesFromFieldNames() { + const TEST_CASES = [ + { + fieldNames: ["given-name", "family-name", "name", "tel", "organization"], + set: ["name", "tel", "organization"], + }, + { + fieldNames: [ + "address-line2", + "family-name", + "name", + "tel", + "organization", + "email", + ], + set: ["address", "name", "tel", "organization", "email"], + }, + { + fieldNames: ["address-line2", "family-name", "", "name", "tel", "UNKOWN"], + set: ["address", "name", "tel"], + }, + { + fieldNames: ["tel", "family-name", "", "name", "tel", "UNKOWN"], + set: ["tel", "name"], + }, + ]; + + for (let tc of TEST_CASES) { + let categories = FormAutofillUtils.getCategoriesFromFieldNames( + tc.fieldNames + ); + Assert.deepEqual(Array.from(categories), tc.set); + } +}); diff --git a/browser/extensions/formautofill/test/unit/test_getFormInputDetails.js b/browser/extensions/formautofill/test/unit/test_getFormInputDetails.js new file mode 100644 index 0000000000..250a91bced --- /dev/null +++ b/browser/extensions/formautofill/test/unit/test_getFormInputDetails.js @@ -0,0 +1,205 @@ +"use strict"; + +var FormAutofillContent; +add_task(async function() { + ({ FormAutofillContent } = ChromeUtils.import( + "resource://formautofill/FormAutofillContent.jsm" + )); +}); + +const TESTCASES = [ + { + description: "Form containing 5 fields with autocomplete attribute.", + document: `<form id="form1"> + <input id="street-addr" autocomplete="street-address"> + <input id="city" autocomplete="address-level2"> + <select id="country" autocomplete="country"></select> + <input id="email" autocomplete="email"> + <input id="tel" autocomplete="tel"> + </form>`, + targetInput: ["street-addr", "email"], + expectedResult: [ + { + input: { + section: "", + addressType: "", + contactType: "", + fieldName: "street-address", + }, + formId: "form1", + form: [ + { + section: "", + addressType: "", + contactType: "", + fieldName: "street-address", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "address-level2", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "country", + }, + { section: "", addressType: "", contactType: "", fieldName: "email" }, + { section: "", addressType: "", contactType: "", fieldName: "tel" }, + ], + }, + { + input: { + section: "", + addressType: "", + contactType: "", + fieldName: "email", + }, + formId: "form1", + form: [ + { + section: "", + addressType: "", + contactType: "", + fieldName: "street-address", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "address-level2", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "country", + }, + { section: "", addressType: "", contactType: "", fieldName: "email" }, + { section: "", addressType: "", contactType: "", fieldName: "tel" }, + ], + }, + ], + }, + { + description: "2 forms that are able to be auto filled", + document: `<form id="form2"> + <input id="home-addr" autocomplete="street-address"> + <input id="city" autocomplete="address-level2"> + <select id="country" autocomplete="country"></select> + </form> + <form id="form3"> + <input id="office-addr" autocomplete="street-address"> + <input id="email" autocomplete="email"> + <input id="tel" autocomplete="tel"> + </form>`, + targetInput: ["home-addr", "office-addr"], + expectedResult: [ + { + input: { + section: "", + addressType: "", + contactType: "", + fieldName: "street-address", + }, + formId: "form2", + form: [ + { + section: "", + addressType: "", + contactType: "", + fieldName: "street-address", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "address-level2", + }, + { + section: "", + addressType: "", + contactType: "", + fieldName: "country", + }, + ], + }, + { + input: { + section: "", + addressType: "", + contactType: "", + fieldName: "street-address", + }, + formId: "form3", + form: [ + { + section: "", + addressType: "", + contactType: "", + fieldName: "street-address", + }, + { section: "", addressType: "", contactType: "", fieldName: "email" }, + { section: "", addressType: "", contactType: "", fieldName: "tel" }, + ], + }, + ], + }, +]; + +function inputDetailAssertion(detail, expected) { + Assert.equal(detail.section, expected.section); + Assert.equal(detail.addressType, expected.addressType); + Assert.equal(detail.contactType, expected.contactType); + Assert.equal(detail.fieldName, expected.fieldName); + Assert.equal(detail.elementWeakRef.get(), expected.elementWeakRef.get()); +} + +TESTCASES.forEach(testcase => { + add_task(async function() { + info("Starting testcase: " + testcase.description); + + let doc = MockDocument.createTestDocument( + "http://localhost:8080/test/", + testcase.document + ); + + for (let i in testcase.targetInput) { + let input = doc.getElementById(testcase.targetInput[i]); + FormAutofillContent.identifyAutofillFields(input); + FormAutofillContent.updateActiveInput(input); + + // Put the input element reference to `element` to make sure the result of + // `activeFieldDetail` contains the same input element. + testcase.expectedResult[i].input.elementWeakRef = Cu.getWeakReference( + input + ); + + inputDetailAssertion( + FormAutofillContent.activeFieldDetail, + testcase.expectedResult[i].input + ); + + let formDetails = testcase.expectedResult[i].form; + for (let formDetail of formDetails) { + // Compose a query string to get the exact reference of <input>/<select> + // element, e.g. #form1 > *[autocomplete="street-address"] + let queryString = + "#" + + testcase.expectedResult[i].formId + + " > *[autocomplete=" + + formDetail.fieldName + + "]"; + formDetail.elementWeakRef = Cu.getWeakReference( + doc.querySelector(queryString) + ); + } + + FormAutofillContent.activeFormDetails.forEach((detail, index) => { + inputDetailAssertion(detail, formDetails[index]); + }); + } + }); +}); diff --git a/browser/extensions/formautofill/test/unit/test_getInfo.js b/browser/extensions/formautofill/test/unit/test_getInfo.js new file mode 100644 index 0000000000..2a033642b2 --- /dev/null +++ b/browser/extensions/formautofill/test/unit/test_getInfo.js @@ -0,0 +1,326 @@ +"use strict"; + +var FormAutofillHeuristics, LabelUtils; +add_task(async function() { + ({ FormAutofillHeuristics, LabelUtils } = ChromeUtils.import( + "resource://formautofill/FormAutofillHeuristics.jsm" + )); +}); + +const TESTCASES = [ + { + description: "Input element in a label element", + document: `<form> + <label> E-Mail + <input id="targetElement" type="text"> + </label> + </form>`, + elementId: "targetElement", + expectedReturnValue: { + fieldName: "email", + section: "", + addressType: "", + contactType: "", + }, + }, + { + description: + "A label element is out of the form contains the related input", + document: `<label for="targetElement"> E-Mail</label> + <form> + <input id="targetElement" type="text"> + </form>`, + elementId: "targetElement", + expectedReturnValue: { + fieldName: "email", + section: "", + addressType: "", + contactType: "", + }, + }, + { + description: "A label element contains span element", + document: `<label for="targetElement">FOO<span>E-Mail</span>BAR</label> + <form> + <input id="targetElement" type="text"> + </form>`, + elementId: "targetElement", + expectedReturnValue: { + fieldName: "email", + section: "", + addressType: "", + contactType: "", + }, + }, + { + description: "The signature in 'name' attr of an input", + document: `<input id="targetElement" name="email" type="text">`, + elementId: "targetElement", + expectedReturnValue: { + fieldName: "email", + section: "", + addressType: "", + contactType: "", + }, + }, + { + description: "The signature in 'id' attr of an input", + document: `<input id="targetElement_email" name="tel" type="text">`, + elementId: "targetElement_email", + expectedReturnValue: { + fieldName: "email", + section: "", + addressType: "", + contactType: "", + }, + }, + { + description: "Select element in a label element", + document: `<form> + <label> State + <select id="targetElement"></select> + </label> + </form>`, + elementId: "targetElement", + expectedReturnValue: { + fieldName: "address-level1", + section: "", + addressType: "", + contactType: "", + }, + }, + { + description: "A select element without a form wrapped", + document: `<label for="targetElement">State</label> + <select id="targetElement"></select>`, + elementId: "targetElement", + expectedReturnValue: { + fieldName: "address-level1", + section: "", + addressType: "", + contactType: "", + }, + }, + { + description: "address line input", + document: `<label for="targetElement">street</label> + <input id="targetElement" type="text">`, + elementId: "targetElement", + expectedReturnValue: { + fieldName: "street-address", + section: "", + addressType: "", + contactType: "", + }, + }, + { + description: "CJK character - Traditional Chinese", + document: `<label> 郵遞區號 + <input id="targetElement" /> + </label>`, + elementId: "targetElement", + expectedReturnValue: { + fieldName: "postal-code", + section: "", + addressType: "", + contactType: "", + }, + }, + { + description: "CJK character - Japanese", + document: `<label> 郵便番号 + <input id="targetElement" /> + </label>`, + elementId: "targetElement", + expectedReturnValue: { + fieldName: "postal-code", + section: "", + addressType: "", + contactType: "", + }, + }, + { + description: "CJK character - Korean", + document: `<label> 우편 번호 + <input id="targetElement" /> + </label>`, + elementId: "targetElement", + expectedReturnValue: { + fieldName: "postal-code", + section: "", + addressType: "", + contactType: "", + }, + }, + { + description: "", + document: `<input id="targetElement" name="fullname">`, + elementId: "targetElement", + expectedReturnValue: { + fieldName: "name", + section: "", + addressType: "", + contactType: "", + }, + }, + { + description: 'input element with "submit" type', + document: `<input id="targetElement" type="submit" />`, + elementId: "targetElement", + expectedReturnValue: null, + }, + { + description: "The signature in 'name' attr of an email input", + document: `<input id="targetElement" name="email" type="number">`, + elementId: "targetElement", + expectedReturnValue: { + fieldName: "email", + section: "", + addressType: "", + contactType: "", + }, + }, + { + description: 'input element with "email" type', + document: `<input id="targetElement" type="email" />`, + elementId: "targetElement", + expectedReturnValue: { + fieldName: "email", + section: "", + addressType: "", + contactType: "", + }, + }, + { + description: "Exclude United State string", + document: `<label>United State + <input id="targetElement" /> + </label>`, + elementId: "targetElement", + expectedReturnValue: null, + }, + { + description: '"County" field with "United State" string', + document: `<label>United State County + <input id="targetElement" /> + </label>`, + elementId: "targetElement", + expectedReturnValue: { + fieldName: "address-level1", + section: "", + addressType: "", + contactType: "", + }, + }, + { + description: '"city" field with double "United State" string', + document: `<label>United State united sTATE city + <input id="targetElement" /> + </label>`, + elementId: "targetElement", + expectedReturnValue: { + fieldName: "address-level2", + section: "", + addressType: "", + contactType: "", + }, + }, + { + description: "Verify credit card number", + document: `<form> + <label for="targetElement"> Card Number</label> + <input id="targetElement" type="text"> + </form>`, + elementId: "targetElement", + expectedReturnValue: { + fieldName: "cc-number", + section: "", + addressType: "", + contactType: "", + }, + }, + { + description: "Identify credit card type field", + document: `<form> + <label for="targetElement">Card Type</label> + <input id="targetElement" type="text"> + </form>`, + elementId: "targetElement", + expectedReturnValue: { + fieldName: "cc-type", + section: "", + addressType: "", + contactType: "", + }, + }, +]; + +TESTCASES.forEach(testcase => { + add_task(async function() { + info("Starting testcase: " + testcase.description); + + let doc = MockDocument.createTestDocument( + "http://localhost:8080/test/", + testcase.document + ); + + let element = doc.getElementById(testcase.elementId); + let value = FormAutofillHeuristics.getInfo(element); + + Assert.deepEqual(value, testcase.expectedReturnValue); + LabelUtils.clearLabelMap(); + }); +}); + +add_task(async function test_regexp_list() { + info("Verify the fieldName support for select element."); + let SUPPORT_LIST = { + email: null, // email + "tel-extension": null, // tel-extension + phone: null, // tel + organization: null, // organization + "street-address": null, // street-address + address1: null, // address-line1 + address2: null, // address-line2 + address3: null, // address-line3 + city: "address-level2", + region: "address-level1", + "postal-code": null, // postal-code + country: "country", + fullname: null, // name + fname: null, // given-name + mname: null, // additional-name + lname: null, // family-name + cardholder: null, // cc-name + "cc-number": null, // cc-number + addmonth: "cc-exp-month", + addyear: "cc-exp-year", + }; + for (let label of Object.keys(SUPPORT_LIST)) { + let testcase = { + description: `A select element supports ${label} or not`, + document: `<select id="${label}"></select>`, + elementId: label, + expectedReturnValue: SUPPORT_LIST[label] + ? { + fieldName: SUPPORT_LIST[label], + section: "", + addressType: "", + contactType: "", + } + : null, + }; + info(testcase.description); + info(testcase.document); + let doc = MockDocument.createTestDocument( + "http://localhost:8080/test/", + testcase.document + ); + + let element = doc.getElementById(testcase.elementId); + let value = FormAutofillHeuristics.getInfo(element); + + Assert.deepEqual(value, testcase.expectedReturnValue, label); + } + LabelUtils.clearLabelMap(); +}); diff --git a/browser/extensions/formautofill/test/unit/test_getRecords.js b/browser/extensions/formautofill/test/unit/test_getRecords.js new file mode 100644 index 0000000000..ce225a51e2 --- /dev/null +++ b/browser/extensions/formautofill/test/unit/test_getRecords.js @@ -0,0 +1,258 @@ +/* + * Test for make sure getRecords can retrieve right collection from storage. + */ + +"use strict"; + +const { CreditCard } = ChromeUtils.import( + "resource://gre/modules/CreditCard.jsm" +); + +let FormAutofillParent, FormAutofillStatus; +let OSKeyStore; +add_task(async function setup() { + ({ FormAutofillParent, FormAutofillStatus } = ChromeUtils.import( + "resource://formautofill/FormAutofillParent.jsm" + )); + ({ OSKeyStore } = ChromeUtils.import( + "resource://gre/modules/OSKeyStore.jsm" + )); +}); + +const TEST_ADDRESS_1 = { + "given-name": "Timothy", + "additional-name": "John", + "family-name": "Berners-Lee", + organization: "World Wide Web Consortium", + "street-address": "32 Vassar Street\nMIT Room 32-G524", + "address-level2": "Cambridge", + "address-level1": "MA", + "postal-code": "02139", + country: "US", + tel: "+16172535702", + email: "timbl@w3.org", +}; + +const TEST_ADDRESS_2 = { + "street-address": "Some Address", + country: "US", +}; + +let TEST_CREDIT_CARD_1 = { + "cc-name": "John Doe", + "cc-number": "4111111111111111", + "cc-exp-month": 4, + "cc-exp-year": 2017, + "cc-type": "visa", +}; + +let TEST_CREDIT_CARD_2 = { + "cc-name": "John Dai", + "cc-number": "4929001587121045", + "cc-exp-month": 2, + "cc-exp-year": 2017, + "cc-type": "visa", +}; + +add_task(async function test_getRecords() { + FormAutofillStatus.init(); + + await FormAutofillStatus.formAutofillStorage.initialize(); + let fakeResult = { + addresses: [ + { + "given-name": "Timothy", + "additional-name": "John", + "family-name": "Berners-Lee", + organization: "World Wide Web Consortium", + }, + ], + creditCards: [ + { + "cc-name": "John Doe", + "cc-number": "4111111111111111", + "cc-exp-month": 4, + "cc-exp-year": 2017, + }, + ], + }; + + for (let collectionName of ["addresses", "creditCards", "nonExisting"]) { + let collection = FormAutofillStatus.formAutofillStorage[collectionName]; + let expectedResult = fakeResult[collectionName] || []; + + if (collection) { + sinon.stub(collection, "getAll"); + collection.getAll.returns(Promise.resolve(expectedResult)); + } + await FormAutofillParent._getRecords({ collectionName }); + if (collection) { + Assert.equal(collection.getAll.called, true); + collection.getAll.restore(); + } + } +}); + +add_task(async function test_getRecords_addresses() { + await FormAutofillStatus.formAutofillStorage.initialize(); + let mockAddresses = [TEST_ADDRESS_1, TEST_ADDRESS_2]; + let collection = FormAutofillStatus.formAutofillStorage.addresses; + sinon.stub(collection, "getAll"); + collection.getAll.returns(Promise.resolve(mockAddresses)); + + let testCases = [ + { + description: "If the search string could match 1 address", + filter: { + collectionName: "addresses", + info: { fieldName: "street-address" }, + searchString: "Some", + }, + expectedResult: [TEST_ADDRESS_2], + }, + { + description: "If the search string could match multiple addresses", + filter: { + collectionName: "addresses", + info: { fieldName: "country" }, + searchString: "u", + }, + expectedResult: [TEST_ADDRESS_1, TEST_ADDRESS_2], + }, + { + description: "If the search string could not match any address", + filter: { + collectionName: "addresses", + info: { fieldName: "street-address" }, + searchString: "test", + }, + expectedResult: [], + }, + { + description: "If the search string is empty", + filter: { + collectionName: "addresses", + info: { fieldName: "street-address" }, + searchString: "", + }, + expectedResult: [TEST_ADDRESS_1, TEST_ADDRESS_2], + }, + { + description: + "Check if the filtering logic is free from searching special chars", + filter: { + collectionName: "addresses", + info: { fieldName: "street-address" }, + searchString: ".*", + }, + expectedResult: [], + }, + { + description: + "Prevent broken while searching the property that does not exist", + filter: { + collectionName: "addresses", + info: { fieldName: "tel" }, + searchString: "1", + }, + expectedResult: [], + }, + ]; + + for (let testCase of testCases) { + info("Starting testcase: " + testCase.description); + let result = await FormAutofillParent._getRecords(testCase.filter); + Assert.deepEqual(result, testCase.expectedResult); + } +}); + +add_task(async function test_getRecords_creditCards() { + await FormAutofillStatus.formAutofillStorage.initialize(); + let collection = FormAutofillStatus.formAutofillStorage.creditCards; + let encryptedCCRecords = await Promise.all( + [TEST_CREDIT_CARD_1, TEST_CREDIT_CARD_2].map(async record => { + let clonedRecord = Object.assign({}, record); + clonedRecord["cc-number"] = CreditCard.getLongMaskedNumber( + record["cc-number"] + ); + clonedRecord["cc-number-encrypted"] = await OSKeyStore.encrypt( + record["cc-number"] + ); + return clonedRecord; + }) + ); + sinon + .stub(collection, "getAll") + .callsFake(() => + Promise.resolve([ + Object.assign({}, encryptedCCRecords[0]), + Object.assign({}, encryptedCCRecords[1]), + ]) + ); + + let testCases = [ + { + description: "If the search string could match multiple creditCards", + filter: { + collectionName: "creditCards", + info: { fieldName: "cc-name" }, + searchString: "John", + }, + expectedResult: encryptedCCRecords, + }, + { + description: "If the search string could not match any creditCard", + filter: { + collectionName: "creditCards", + info: { fieldName: "cc-name" }, + searchString: "T", + }, + expectedResult: [], + }, + { + description: + "Return all creditCards if focused field is cc number; " + + "if the search string could match multiple creditCards", + filter: { + collectionName: "creditCards", + info: { fieldName: "cc-number" }, + searchString: "4", + }, + expectedResult: encryptedCCRecords, + }, + { + description: "If the search string could match 1 creditCard", + filter: { + collectionName: "creditCards", + info: { fieldName: "cc-name" }, + searchString: "John Doe", + }, + mpEnabled: true, + expectedResult: encryptedCCRecords.slice(0, 1), + }, + { + description: "Return all creditCards if focused field is cc number", + filter: { + collectionName: "creditCards", + info: { fieldName: "cc-number" }, + searchString: "411", + }, + mpEnabled: true, + expectedResult: encryptedCCRecords, + }, + ]; + + for (let testCase of testCases) { + info("Starting testcase: " + testCase.description); + if (testCase.mpEnabled) { + let tokendb = Cc["@mozilla.org/security/pk11tokendb;1"].createInstance( + Ci.nsIPK11TokenDB + ); + let token = tokendb.getInternalKeyToken(); + token.reset(); + token.initPassword("password"); + } + let result = await FormAutofillParent._getRecords(testCase.filter); + Assert.deepEqual(result, testCase.expectedResult); + } +}); diff --git a/browser/extensions/formautofill/test/unit/test_isAvailable.js b/browser/extensions/formautofill/test/unit/test_isAvailable.js new file mode 100644 index 0000000000..4633616c26 --- /dev/null +++ b/browser/extensions/formautofill/test/unit/test_isAvailable.js @@ -0,0 +1,37 @@ +/** + * Test enabling the feature in specific locales and regions. + */ + +"use strict"; + +const DOM_ENABLED_PREF = "dom.forms.autocomplete.formautofill"; + +add_task(async function test_defaultTestEnvironment() { + Assert.ok(Services.prefs.getBoolPref(DOM_ENABLED_PREF)); +}); + +add_task(async function test_unsupportedRegion() { + Services.prefs.setCharPref("extensions.formautofill.available", "detect"); + Services.prefs.setCharPref("browser.search.region", "ZZ"); + registerCleanupFunction(function cleanupRegion() { + Services.prefs.clearUserPref("browser.search.region"); + }); + + let addon = await AddonManager.getAddonByID(EXTENSION_ID); + await addon.reload(); + + Assert.ok(!Services.prefs.getBoolPref(DOM_ENABLED_PREF)); +}); + +add_task(async function test_supportedRegion() { + Services.prefs.setCharPref("extensions.formautofill.available", "detect"); + Services.prefs.setCharPref("browser.search.region", "US"); + registerCleanupFunction(function cleanupRegion() { + Services.prefs.clearUserPref("browser.search.region"); + }); + + let addon = await AddonManager.getAddonByID(EXTENSION_ID); + await addon.reload(); + + Assert.ok(Services.prefs.getBoolPref(DOM_ENABLED_PREF)); +}); diff --git a/browser/extensions/formautofill/test/unit/test_isCJKName.js b/browser/extensions/formautofill/test/unit/test_isCJKName.js new file mode 100644 index 0000000000..7fd47e154e --- /dev/null +++ b/browser/extensions/formautofill/test/unit/test_isCJKName.js @@ -0,0 +1,80 @@ +/** + * Tests the "isCJKName" function of FormAutofillNameUtils object. + */ + +"use strict"; + +var FormAutofillNameUtils; +add_task(async function setup() { + ({ FormAutofillNameUtils } = ChromeUtils.import( + "resource://formautofill/FormAutofillNameUtils.jsm" + )); +}); + +// Test cases is initially copied from +// https://cs.chromium.org/chromium/src/components/autofill/core/browser/autofill_data_util_unittest.cc +const TESTCASES = [ + { + // Non-CJK language with only ASCII characters. + fullName: "Homer Jay Simpson", + expectedResult: false, + }, + { + // Non-CJK language with some ASCII characters. + fullName: "Éloïse Paré", + expectedResult: false, + }, + { + // Non-CJK language with no ASCII characters. + fullName: "Σωκράτης", + expectedResult: false, + }, + { + // (Simplified) Chinese name, Unihan. + fullName: "刘翔", + expectedResult: true, + }, + { + // (Simplified) Chinese name, Unihan, with an ASCII space. + fullName: "成 龙", + expectedResult: true, + }, + { + // Korean name, Hangul. + fullName: "송지효", + expectedResult: true, + }, + { + // Korean name, Hangul, with an 'IDEOGRAPHIC SPACE' (U+3000). + fullName: "김 종국", + expectedResult: true, + }, + { + // Japanese name, Unihan. + fullName: "山田貴洋", + expectedResult: true, + }, + { + // Japanese name, Katakana, with a 'KATAKANA MIDDLE DOT' (U+30FB). + fullName: "ビル・ゲイツ", + expectedResult: true, + }, + { + // Japanese name, Katakana, with a 'MIDDLE DOT' (U+00B7) (likely a typo). + fullName: "ビル·ゲイツ", + expectedResult: true, + }, + { + // CJK names don't have a middle name, so a 3-part name is bogus to us. + fullName: "반 기 문", + expectedResult: false, + }, +]; + +add_task(async function test_isCJKName() { + TESTCASES.forEach(testcase => { + info("Starting testcase: " + testcase.fullName); + let result = FormAutofillNameUtils._isCJKName(testcase.fullName); + Assert.equal(result, testcase.expectedResult); + }); +}); diff --git a/browser/extensions/formautofill/test/unit/test_isFieldEligibleForAutofill.js b/browser/extensions/formautofill/test/unit/test_isFieldEligibleForAutofill.js new file mode 100644 index 0000000000..4446a9d4a2 --- /dev/null +++ b/browser/extensions/formautofill/test/unit/test_isFieldEligibleForAutofill.js @@ -0,0 +1,88 @@ +"use strict"; + +var FormAutofillUtils; +add_task(async function seutp() { + ({ FormAutofillUtils } = ChromeUtils.import( + "resource://formautofill/FormAutofillUtils.jsm" + )); +}); + +const TESTCASES = [ + { + document: `<input id="targetElement" type="text">`, + fieldId: "targetElement", + expectedResult: true, + }, + { + document: `<input id="targetElement" type="email">`, + fieldId: "targetElement", + expectedResult: true, + }, + { + document: `<input id="targetElement" type="number">`, + fieldId: "targetElement", + expectedResult: true, + }, + { + document: `<input id="targetElement" type="tel">`, + fieldId: "targetElement", + expectedResult: true, + }, + { + document: `<input id="targetElement" type="radio">`, + fieldId: "targetElement", + expectedResult: false, + }, + { + document: `<input id="targetElement" type="text" autocomplete="off">`, + fieldId: "targetElement", + expectedResult: true, + }, + { + document: `<input id="targetElement">`, + fieldId: "targetElement", + expectedResult: true, + }, + { + document: `<input id="targetElement" type="unknown">`, + fieldId: "targetElement", + expectedResult: true, + }, + { + document: `<select id="targetElement" autocomplete="off"></select>`, + fieldId: "targetElement", + expectedResult: true, + }, + { + document: `<select id="targetElement"></select>`, + fieldId: "targetElement", + expectedResult: true, + }, + { + document: `<select id="targetElement" multiple></select>`, + fieldId: "targetElement", + expectedResult: true, + }, + { + document: `<div id="targetElement"></div>`, + fieldId: "targetElement", + expectedResult: false, + }, +]; + +TESTCASES.forEach(testcase => { + add_task(async function() { + info("Starting testcase: " + testcase.document); + + let doc = MockDocument.createTestDocument( + "http://localhost:8080/test/", + testcase.document + ); + + let field = doc.getElementById(testcase.fieldId); + Assert.equal( + FormAutofillUtils.isFieldEligibleForAutofill(field), + testcase.expectedResult + ); + }); +}); diff --git a/browser/extensions/formautofill/test/unit/test_markAsAutofillField.js b/browser/extensions/formautofill/test/unit/test_markAsAutofillField.js new file mode 100644 index 0000000000..925eec5d8d --- /dev/null +++ b/browser/extensions/formautofill/test/unit/test_markAsAutofillField.js @@ -0,0 +1,87 @@ +"use strict"; + +const TESTCASES = [ + { + description: "Form containing 8 fields with autocomplete attribute.", + document: `<form> + <input id="given-name" autocomplete="given-name"> + <input id="additional-name" autocomplete="additional-name"> + <input id="family-name" autocomplete="family-name"> + <input id="street-addr" autocomplete="street-address"> + <input id="city" autocomplete="address-level2"> + <input id="country" autocomplete="country"> + <input id="email" autocomplete="email"> + <input id="tel" autocomplete="tel"> + <input id="without-autocomplete-1"> + <input id="without-autocomplete-2"> + </form>`, + targetElementId: "given-name", + expectedResult: [ + "given-name", + "additional-name", + "family-name", + "street-addr", + "city", + "country", + "email", + "tel", + ], + }, + { + description: "Form containing only 2 fields with autocomplete attribute.", + document: `<form> + <input id="street-addr" autocomplete="street-address"> + <input id="city" autocomplete="address-level2"> + <input id="without-autocomplete-1"> + <input id="without-autocomplete-2"> + </form>`, + targetElementId: "street-addr", + expectedResult: [], + }, + { + description: "Fields without form element.", + document: `<input id="street-addr" autocomplete="street-address"> + <input id="city" autocomplete="address-level2"> + <input id="country" autocomplete="country"> + <input id="email" autocomplete="email"> + <input id="tel" autocomplete="tel"> + <input id="without-autocomplete-1"> + <input id="without-autocomplete-2">`, + targetElementId: "street-addr", + expectedResult: ["street-addr", "city", "country", "email", "tel"], + }, +]; + +let markedFieldId = []; + +var FormAutofillContent; +add_task(async function setup() { + ({ FormAutofillContent } = ChromeUtils.import( + "resource://formautofill/FormAutofillContent.jsm" + )); + + FormAutofillContent._markAsAutofillField = function(field) { + markedFieldId.push(field.id); + }; +}); + +TESTCASES.forEach(testcase => { + add_task(async function() { + info("Starting testcase: " + testcase.description); + + markedFieldId = []; + + let doc = MockDocument.createTestDocument( + "http://localhost:8080/test/", + testcase.document + ); + let element = doc.getElementById(testcase.targetElementId); + FormAutofillContent.identifyAutofillFields(element); + + Assert.deepEqual( + markedFieldId, + testcase.expectedResult, + "Check the fields were marked correctly." + ); + }); +}); diff --git a/browser/extensions/formautofill/test/unit/test_migrateRecords.js b/browser/extensions/formautofill/test/unit/test_migrateRecords.js new file mode 100644 index 0000000000..fa0a46b588 --- /dev/null +++ b/browser/extensions/formautofill/test/unit/test_migrateRecords.js @@ -0,0 +1,321 @@ +/** + * Tests the migration algorithm in profileStorage. + */ + +"use strict"; + +let FormAutofillStorage; +add_task(async function setup() { + ({ FormAutofillStorage } = ChromeUtils.import( + "resource://formautofill/FormAutofillStorage.jsm", + null + )); +}); + +const TEST_STORE_FILE_NAME = "test-profile.json"; + +const ADDRESS_SCHEMA_VERSION = 1; +const CREDIT_CARD_SCHEMA_VERSION = 3; + +const ADDRESS_TESTCASES = [ + { + description: + "The record version is equal to the current version. The migration shouldn't be invoked.", + record: { + guid: "test-guid", + version: ADDRESS_SCHEMA_VERSION, + "given-name": "Timothy", + name: "John", // The cached name field doesn't align "given-name" but it + // won't be recomputed because the migration isn't invoked. + }, + expectedResult: { + guid: "test-guid", + version: ADDRESS_SCHEMA_VERSION, + "given-name": "Timothy", + name: "John", + }, + }, + { + description: + "The record version is greater than the current version. The migration shouldn't be invoked.", + record: { + guid: "test-guid", + version: 99, + "given-name": "Timothy", + name: "John", + }, + expectedResult: { + guid: "test-guid", + version: 99, + "given-name": "Timothy", + name: "John", + }, + }, + { + description: + "The record version is less than the current version. The migration should be invoked.", + record: { + guid: "test-guid", + version: 0, + "given-name": "Timothy", + name: "John", + }, + expectedResult: { + guid: "test-guid", + version: ADDRESS_SCHEMA_VERSION, + "given-name": "Timothy", + name: "Timothy", + }, + }, + { + description: + "The record version is omitted. The migration should be invoked.", + record: { + guid: "test-guid", + "given-name": "Timothy", + name: "John", + }, + expectedResult: { + guid: "test-guid", + version: ADDRESS_SCHEMA_VERSION, + "given-name": "Timothy", + name: "Timothy", + }, + }, + { + description: + "The record version is an invalid value. The migration should be invoked.", + record: { + guid: "test-guid", + version: "ABCDE", + "given-name": "Timothy", + name: "John", + }, + expectedResult: { + guid: "test-guid", + version: ADDRESS_SCHEMA_VERSION, + "given-name": "Timothy", + name: "Timothy", + }, + }, + { + description: + "The omitted computed fields should be always recomputed even the record version is up-to-date.", + record: { + guid: "test-guid", + version: ADDRESS_SCHEMA_VERSION, + "given-name": "Timothy", + }, + expectedResult: { + guid: "test-guid", + version: ADDRESS_SCHEMA_VERSION, + "given-name": "Timothy", + name: "Timothy", + }, + }, + { + description: "The migration shouldn't be invoked on tombstones.", + record: { + guid: "test-guid", + timeLastModified: 12345, + deleted: true, + }, + expectedResult: { + guid: "test-guid", + timeLastModified: 12345, + deleted: true, + + // Make sure no new fields are appended. + version: undefined, + name: undefined, + }, + }, +]; + +const CREDIT_CARD_TESTCASES = [ + { + description: + "The record version is equal to the current version. The migration shouldn't be invoked.", + record: { + guid: "test-guid", + version: CREDIT_CARD_SCHEMA_VERSION, + "cc-name": "Timothy", + "cc-given-name": "John", // The cached "cc-given-name" field doesn't align + // "cc-name" but it won't be recomputed because + // the migration isn't invoked. + }, + expectedResult: { + guid: "test-guid", + version: CREDIT_CARD_SCHEMA_VERSION, + "cc-name": "Timothy", + "cc-given-name": "John", + }, + }, + { + description: + "The record version is greater than the current version. The migration shouldn't be invoked.", + record: { + guid: "test-guid", + version: 99, + "cc-name": "Timothy", + "cc-given-name": "John", + }, + expectedResult: { + guid: "test-guid", + version: 99, + "cc-name": "Timothy", + "cc-given-name": "John", + }, + }, + { + description: + "The record version is less than the current version. The migration should be invoked.", + record: { + guid: "test-guid", + version: 0, + "cc-name": "Timothy", + "cc-given-name": "John", + }, + expectedResult: { + guid: "test-guid", + version: CREDIT_CARD_SCHEMA_VERSION, + "cc-name": "Timothy", + "cc-given-name": "Timothy", + }, + }, + { + description: + "The record version is omitted. The migration should be invoked.", + record: { + guid: "test-guid", + "cc-name": "Timothy", + "cc-given-name": "John", + }, + expectedResult: { + guid: "test-guid", + version: CREDIT_CARD_SCHEMA_VERSION, + "cc-name": "Timothy", + "cc-given-name": "Timothy", + }, + }, + { + description: + "The record version is an invalid value. The migration should be invoked.", + record: { + guid: "test-guid", + version: "ABCDE", + "cc-name": "Timothy", + "cc-given-name": "John", + }, + expectedResult: { + guid: "test-guid", + version: CREDIT_CARD_SCHEMA_VERSION, + "cc-name": "Timothy", + "cc-given-name": "Timothy", + }, + }, + { + description: + "The omitted computed fields should be always recomputed even the record version is up-to-date.", + record: { + guid: "test-guid", + version: CREDIT_CARD_SCHEMA_VERSION, + "cc-name": "Timothy", + }, + expectedResult: { + guid: "test-guid", + version: CREDIT_CARD_SCHEMA_VERSION, + "cc-name": "Timothy", + "cc-given-name": "Timothy", + }, + }, + { + description: "The migration shouldn't be invoked on tombstones.", + record: { + guid: "test-guid", + timeLastModified: 12345, + deleted: true, + }, + expectedResult: { + guid: "test-guid", + timeLastModified: 12345, + deleted: true, + + // Make sure no new fields are appended. + version: undefined, + "cc-given-name": undefined, + }, + }, +]; + +let do_check_record_matches = (expectedRecord, record) => { + for (let key in expectedRecord) { + Assert.equal(expectedRecord[key], record[key]); + } +}; + +add_task(async function test_migrateAddressRecords() { + let path = getTempFile(TEST_STORE_FILE_NAME).path; + + let profileStorage = new FormAutofillStorage(path); + await profileStorage.initialize(); + + for (let testcase of ADDRESS_TESTCASES) { + info(testcase.description); + profileStorage._store.data.addresses = [testcase.record]; + await profileStorage.addresses._migrateRecord(testcase.record, 0); + do_check_record_matches( + testcase.expectedResult, + profileStorage.addresses._data[0] + ); + } +}); + +add_task(async function test_migrateCreditCardRecords() { + let path = getTempFile(TEST_STORE_FILE_NAME).path; + + let profileStorage = new FormAutofillStorage(path); + await profileStorage.initialize(); + + for (let testcase of CREDIT_CARD_TESTCASES) { + info(testcase.description); + profileStorage._store.data.creditCards = [testcase.record]; + await profileStorage.creditCards._migrateRecord(testcase.record, 0); + do_check_record_matches( + testcase.expectedResult, + profileStorage.creditCards._data[0] + ); + } +}); + +add_task(async function test_migrateEncryptedCreditCardNumber() { + let path = getTempFile(TEST_STORE_FILE_NAME).path; + + let profileStorage = new FormAutofillStorage(path); + await profileStorage.initialize(); + + info("v1 and v2 schema cards should be abandoned."); + + let v1record = { + guid: "test-guid1", + version: 1, + "cc-name": "Timothy", + "cc-number-encrypted": "aaaa", + }; + + let v2record = { + guid: "test-guid2", + version: 2, + "cc-name": "Bob", + "cc-number-encrypted": "bbbb", + }; + + profileStorage._store.data.creditCards = [v1record, v2record]; + await profileStorage.creditCards._migrateRecord(v1record, 0); + await profileStorage.creditCards._migrateRecord(v2record, 1); + v1record = profileStorage.creditCards._data[0]; + v2record = profileStorage.creditCards._data[1]; + + Assert.ok(v1record.deleted); + Assert.ok(v2record.deleted); +}); diff --git a/browser/extensions/formautofill/test/unit/test_nameUtils.js b/browser/extensions/formautofill/test/unit/test_nameUtils.js new file mode 100644 index 0000000000..de3ca697cc --- /dev/null +++ b/browser/extensions/formautofill/test/unit/test_nameUtils.js @@ -0,0 +1,289 @@ +/** + * Tests FormAutofillNameUtils object. + */ + +"use strict"; + +var FormAutofillNameUtils; +add_task(async function() { + ({ FormAutofillNameUtils } = ChromeUtils.import( + "resource://formautofill/FormAutofillNameUtils.jsm" + )); +}); + +// Test cases initially copied from +// https://cs.chromium.org/chromium/src/components/autofill/core/browser/autofill_data_util_unittest.cc +const TESTCASES = [ + { + description: "Full name including given, middle and family names", + fullName: "Homer Jay Simpson", + nameParts: { + given: "Homer", + middle: "Jay", + family: "Simpson", + }, + }, + { + description: "No middle name", + fullName: "Moe Szyslak", + nameParts: { + given: "Moe", + middle: "", + family: "Szyslak", + }, + }, + { + description: "Common name prefixes removed", + fullName: "Reverend Timothy Lovejoy", + nameParts: { + given: "Timothy", + middle: "", + family: "Lovejoy", + }, + expectedFullName: "Timothy Lovejoy", + }, + { + description: "Common name suffixes removed", + fullName: "John Frink Phd", + nameParts: { + given: "John", + middle: "", + family: "Frink", + }, + expectedFullName: "John Frink", + }, + { + description: "Exception to the name suffix removal", + fullName: "John Ma", + nameParts: { + given: "John", + middle: "", + family: "Ma", + }, + }, + { + description: "Common family name prefixes not considered a middle name", + fullName: "Milhouse Van Houten", + nameParts: { + given: "Milhouse", + middle: "", + family: "Van Houten", + }, + }, + + // CJK names have reverse order (surname goes first, given name goes second). + { + description: "Chinese name, Unihan", + fullName: "孫 德明", + nameParts: { + given: "德明", + middle: "", + family: "孫", + }, + expectedFullName: "孫德明", + }, + { + description: 'Chinese name, Unihan, "IDEOGRAPHIC SPACE"', + fullName: "孫 德明", + nameParts: { + given: "德明", + middle: "", + family: "孫", + }, + expectedFullName: "孫德明", + }, + { + description: "Korean name, Hangul", + fullName: "홍 길동", + nameParts: { + given: "길동", + middle: "", + family: "홍", + }, + expectedFullName: "홍길동", + }, + { + description: "Japanese name, Unihan", + fullName: "山田 貴洋", + nameParts: { + given: "貴洋", + middle: "", + family: "山田", + }, + expectedFullName: "山田貴洋", + }, + + // In Japanese, foreign names use 'KATAKANA MIDDLE DOT' (U+30FB) as a + // separator. There is no consensus for the ordering. For now, we use the same + // ordering as regular Japanese names ("last・first"). + { + description: "Foreign name in Japanese, Katakana", + fullName: "ゲイツ・ビル", + nameParts: { + given: "ビル", + middle: "", + family: "ゲイツ", + }, + expectedFullName: "ゲイツビル", + }, + + // 'KATAKANA MIDDLE DOT' is occasionally typoed as 'MIDDLE DOT' (U+00B7). + { + description: "Foreign name in Japanese, Katakana", + fullName: "ゲイツ·ビル", + nameParts: { + given: "ビル", + middle: "", + family: "ゲイツ", + }, + expectedFullName: "ゲイツビル", + }, + + // CJK names don't usually have a space in the middle, but most of the time, + // the surname is only one character (in Chinese & Korean). + { + description: "Korean name, Hangul", + fullName: "최성훈", + nameParts: { + given: "성훈", + middle: "", + family: "최", + }, + }, + { + description: "(Simplified) Chinese name, Unihan", + fullName: "刘翔", + nameParts: { + given: "翔", + middle: "", + family: "刘", + }, + }, + { + description: "(Traditional) Chinese name, Unihan", + fullName: "劉翔", + nameParts: { + given: "翔", + middle: "", + family: "劉", + }, + }, + + // There are a few exceptions. Occasionally, the surname has two characters. + { + description: "Korean name, Hangul", + fullName: "남궁도", + nameParts: { + given: "도", + middle: "", + family: "남궁", + }, + }, + { + description: "Korean name, Hangul", + fullName: "황보혜정", + nameParts: { + given: "혜정", + middle: "", + family: "황보", + }, + }, + { + description: "(Traditional) Chinese name, Unihan", + fullName: "歐陽靖", + nameParts: { + given: "靖", + middle: "", + family: "歐陽", + }, + }, + + // In Korean, some 2-character surnames are rare/ambiguous, like "강전": "강" + // is a common surname, and "전" can be part of a given name. In those cases, + // we assume it's 1/2 for 3-character names, or 2/2 for 4-character names. + { + description: "Korean name, Hangul", + fullName: "강전희", + nameParts: { + given: "전희", + middle: "", + family: "강", + }, + }, + { + description: "Korean name, Hangul", + fullName: "황목치승", + nameParts: { + given: "치승", + middle: "", + family: "황목", + }, + }, + + // It occasionally happens that a full name is 2 characters, 1/1. + { + description: "Korean name, Hangul", + fullName: "이도", + nameParts: { + given: "도", + middle: "", + family: "이", + }, + }, + { + description: "Korean name, Hangul", + fullName: "孫文", + nameParts: { + given: "文", + middle: "", + family: "孫", + }, + }, + + // These are no CJK names for us, they're just bogus. + { + description: "Bogus", + fullName: "Homer シンプソン", + nameParts: { + given: "Homer", + middle: "", + family: "シンプソン", + }, + }, + { + description: "Bogus", + fullName: "ホーマー Simpson", + nameParts: { + given: "ホーマー", + middle: "", + family: "Simpson", + }, + }, + { + description: "CJK has a middle-name, too unusual", + fullName: "반 기 문", + nameParts: { + given: "반", + middle: "기", + family: "문", + }, + }, +]; + +add_task(async function test_splitName() { + TESTCASES.forEach(testcase => { + if (testcase.fullName) { + info("Starting testcase: " + testcase.description); + let nameParts = FormAutofillNameUtils.splitName(testcase.fullName); + Assert.deepEqual(nameParts, testcase.nameParts); + } + }); +}); + +add_task(async function test_joinName() { + TESTCASES.forEach(testcase => { + info("Starting testcase: " + testcase.description); + let name = FormAutofillNameUtils.joinNameParts(testcase.nameParts); + Assert.equal(name, testcase.expectedFullName || testcase.fullName); + }); +}); diff --git a/browser/extensions/formautofill/test/unit/test_onFormSubmitted.js b/browser/extensions/formautofill/test/unit/test_onFormSubmitted.js new file mode 100644 index 0000000000..8f745ad76a --- /dev/null +++ b/browser/extensions/formautofill/test/unit/test_onFormSubmitted.js @@ -0,0 +1,686 @@ +"use strict"; + +var FormAutofillContent; +add_task(async function setup() { + ({ FormAutofillContent } = ChromeUtils.import( + "resource://formautofill/FormAutofillContent.jsm" + )); +}); + +const MOCK_DOC = MockDocument.createTestDocument( + "http://localhost:8080/test/", + `<form id="form1"> + <input id="street-addr" autocomplete="street-address"> + <select id="address-level1" autocomplete="address-level1"> + <option value=""></option> + <option value="AL">Alabama</option> + <option value="AK">Alaska</option> + <option value="AP">Armed Forces Pacific</option> + + <option value="ca">california</option> + <option value="AR">US-Arkansas</option> + <option value="US-CA">California</option> + <option value="CA">California</option> + <option value="US-AZ">US_Arizona</option> + <option value="Ariz">Arizonac</option> + </select> + <input id="city" autocomplete="address-level2"> + <input id="country" autocomplete="country"> + <input id="email" autocomplete="email"> + <input id="tel" autocomplete="tel"> + <input id="cc-name" autocomplete="cc-name"> + <input id="cc-number" autocomplete="cc-number"> + <input id="cc-exp-month" autocomplete="cc-exp-month"> + <input id="cc-exp-year" autocomplete="cc-exp-year"> + <select id="cc-type"> + <option value="">Select</option> + <option value="visa">Visa</option> + <option value="mastercard">Master Card</option> + <option value="amex">American Express</option> + </select> + <input id="submit" type="submit"> + </form>` +); +const TARGET_ELEMENT_ID = "street-addr"; + +const TESTCASES = [ + { + description: + "Should not trigger address saving if the number of fields is less than 3", + formValue: { + "street-addr": "331 E. Evelyn Avenue", + tel: "1-650-903-0800", + }, + expectedResult: { + formSubmission: false, + }, + }, + { + description: "Should not trigger credit card saving if number is empty", + formValue: { + "cc-name": "John Doe", + "cc-exp-month": 12, + "cc-exp-year": 2000, + }, + expectedResult: { + formSubmission: false, + }, + }, + { + description: "Trigger address saving", + formValue: { + "street-addr": "331 E. Evelyn Avenue", + country: "US", + tel: "1-650-903-0800", + }, + expectedResult: { + formSubmission: true, + records: { + address: [ + { + guid: null, + record: { + "street-address": "331 E. Evelyn Avenue", + "address-level1": "", + "address-level2": "", + country: "US", + email: "", + tel: "1-650-903-0800", + }, + untouchedFields: [], + }, + ], + creditCard: [], + }, + }, + }, + { + description: "Trigger credit card saving", + formValue: { + "cc-name": "John Doe", + "cc-number": "5105105105105100", + "cc-exp-month": 12, + "cc-exp-year": 2000, + "cc-type": "amex", + }, + expectedResult: { + formSubmission: true, + records: { + address: [], + creditCard: [ + { + guid: null, + record: { + "cc-name": "John Doe", + "cc-number": "5105105105105100", + "cc-exp-month": 12, + "cc-exp-year": 2000, + "cc-type": "amex", + }, + untouchedFields: [], + }, + ], + }, + }, + }, + { + description: "Trigger address and credit card saving", + formValue: { + "street-addr": "331 E. Evelyn Avenue", + country: "US", + tel: "1-650-903-0800", + "cc-name": "John Doe", + "cc-number": "5105105105105100", + "cc-exp-month": 12, + "cc-exp-year": 2000, + "cc-type": "visa", + }, + expectedResult: { + formSubmission: true, + records: { + address: [ + { + guid: null, + record: { + "street-address": "331 E. Evelyn Avenue", + "address-level1": "", + "address-level2": "", + country: "US", + email: "", + tel: "1-650-903-0800", + }, + untouchedFields: [], + }, + ], + creditCard: [ + { + guid: null, + record: { + "cc-name": "John Doe", + "cc-number": "5105105105105100", + "cc-exp-month": 12, + "cc-exp-year": 2000, + "cc-type": "visa", + }, + untouchedFields: [], + }, + ], + }, + }, + }, + { + description: "Profile saved with trimmed string", + formValue: { + "street-addr": "331 E. Evelyn Avenue ", + country: "US", + tel: " 1-650-903-0800", + }, + expectedResult: { + formSubmission: true, + records: { + address: [ + { + guid: null, + record: { + "street-address": "331 E. Evelyn Avenue", + "address-level1": "", + "address-level2": "", + country: "US", + email: "", + tel: "1-650-903-0800", + }, + untouchedFields: [], + }, + ], + creditCard: [], + }, + }, + }, + { + description: "Eliminate the field that is empty after trimmed", + formValue: { + "street-addr": "331 E. Evelyn Avenue", + country: "US", + email: " ", + tel: "1-650-903-0800", + }, + expectedResult: { + formSubmission: true, + records: { + address: [ + { + guid: null, + record: { + "street-address": "331 E. Evelyn Avenue", + "address-level1": "", + "address-level2": "", + country: "US", + email: "", + tel: "1-650-903-0800", + }, + untouchedFields: [], + }, + ], + creditCard: [], + }, + }, + }, + { + description: "Save state with regular select option", + formValue: { + "address-level1": "CA", + "street-addr": "331 E. Evelyn Avenue", + country: "US", + }, + expectedResult: { + formSubmission: true, + records: { + address: [ + { + guid: null, + record: { + "address-level1": "CA", + "address-level2": "", + "street-address": "331 E. Evelyn Avenue", + country: "US", + email: "", + tel: "", + }, + untouchedFields: [], + }, + ], + creditCard: [], + }, + }, + }, + { + description: "Save state with lowercase value", + formValue: { + "address-level1": "ca", + "street-addr": "331 E. Evelyn Avenue", + country: "US", + }, + expectedResult: { + formSubmission: true, + records: { + address: [ + { + guid: null, + record: { + "address-level1": "CA", + "address-level2": "", + "street-address": "331 E. Evelyn Avenue", + country: "US", + email: "", + tel: "", + }, + untouchedFields: [], + }, + ], + creditCard: [], + }, + }, + }, + { + description: "Save state with a country code prefixed to the label", + formValue: { + "address-level1": "AR", + "street-addr": "331 E. Evelyn Avenue", + country: "US", + }, + expectedResult: { + formSubmission: true, + records: { + address: [ + { + guid: null, + record: { + "address-level1": "AR", + "address-level2": "", + "street-address": "331 E. Evelyn Avenue", + country: "US", + email: "", + tel: "", + }, + untouchedFields: [], + }, + ], + creditCard: [], + }, + }, + }, + { + description: "Save state with a country code prefixed to the value", + formValue: { + "address-level1": "US-CA", + "street-addr": "331 E. Evelyn Avenue", + country: "US", + }, + expectedResult: { + formSubmission: true, + records: { + address: [ + { + guid: null, + record: { + "address-level1": "CA", + "address-level2": "", + "street-address": "331 E. Evelyn Avenue", + country: "US", + email: "", + tel: "", + }, + untouchedFields: [], + }, + ], + creditCard: [], + }, + }, + }, + { + description: + "Save state with a country code prefixed to the value and label", + formValue: { + "address-level1": "US-AZ", + "street-addr": "331 E. Evelyn Avenue", + country: "US", + }, + expectedResult: { + formSubmission: true, + records: { + address: [ + { + guid: null, + record: { + "address-level1": "AZ", + "address-level2": "", + "street-address": "331 E. Evelyn Avenue", + country: "US", + email: "", + tel: "", + }, + untouchedFields: [], + }, + ], + creditCard: [], + }, + }, + }, + { + description: + "Should save select label instead when failed to abbreviate the value", + formValue: { + "address-level1": "Ariz", + "street-addr": "331 E. Evelyn Avenue", + country: "US", + }, + expectedResult: { + formSubmission: true, + records: { + address: [ + { + guid: null, + record: { + "address-level1": "Arizonac", + "address-level2": "", + "street-address": "331 E. Evelyn Avenue", + country: "US", + email: "", + tel: "", + }, + untouchedFields: [], + }, + ], + creditCard: [], + }, + }, + }, + { + description: "Shouldn't save select with multiple selections", + formValue: { + "address-level1": ["AL", "AK", "AP"], + "street-addr": "331 E. Evelyn Avenue", + country: "US", + tel: "1-650-903-0800", + }, + expectedResult: { + formSubmission: true, + records: { + address: [ + { + guid: null, + record: { + "street-address": "331 E. Evelyn Avenue", + "address-level1": "", + "address-level2": "", + country: "US", + tel: "1-650-903-0800", + email: "", + }, + untouchedFields: [], + }, + ], + creditCard: [], + }, + }, + }, + { + description: "Shouldn't save select with empty value", + formValue: { + "address-level1": "", + "street-addr": "331 E. Evelyn Avenue", + country: "US", + tel: "1-650-903-0800", + }, + expectedResult: { + formSubmission: true, + records: { + address: [ + { + guid: null, + record: { + "street-address": "331 E. Evelyn Avenue", + "address-level1": "", + "address-level2": "", + country: "US", + tel: "1-650-903-0800", + email: "", + }, + untouchedFields: [], + }, + ], + creditCard: [], + }, + }, + }, + { + description: "Shouldn't save tel whose length is too short", + formValue: { + "street-addr": "331 E. Evelyn Avenue", + "address-level1": "CA", + country: "US", + tel: "1234", + }, + expectedResult: { + formSubmission: true, + records: { + address: [ + { + guid: null, + record: { + "street-address": "331 E. Evelyn Avenue", + "address-level1": "CA", + "address-level2": "", + country: "US", + tel: "", + email: "", + }, + untouchedFields: [], + }, + ], + creditCard: [], + }, + }, + }, + { + description: "Shouldn't save tel whose length is too long", + formValue: { + "street-addr": "331 E. Evelyn Avenue", + "address-level1": "CA", + country: "US", + tel: "1234567890123456", + }, + expectedResult: { + formSubmission: true, + records: { + address: [ + { + guid: null, + record: { + "street-address": "331 E. Evelyn Avenue", + "address-level1": "CA", + "address-level2": "", + country: "US", + tel: "", + email: "", + }, + untouchedFields: [], + }, + ], + creditCard: [], + }, + }, + }, + { + description: "Shouldn't save tel which contains invalid characters", + formValue: { + "street-addr": "331 E. Evelyn Avenue", + "address-level1": "CA", + country: "US", + tel: "12345###!!", + }, + expectedResult: { + formSubmission: true, + records: { + address: [ + { + guid: null, + record: { + "street-address": "331 E. Evelyn Avenue", + "address-level1": "CA", + "address-level2": "", + country: "US", + tel: "", + email: "", + }, + untouchedFields: [], + }, + ], + creditCard: [], + }, + }, + }, +]; + +add_task(async function handle_invalid_form() { + info("Starting testcase: Test an invalid form element"); + let fakeForm = MOCK_DOC.createElement("form"); + sinon.spy(FormAutofillContent, "_onFormSubmit"); + + FormAutofillContent.formSubmitted(fakeForm, null); + Assert.equal(FormAutofillContent._onFormSubmit.called, false); + FormAutofillContent._onFormSubmit.restore(); +}); + +add_task(async function autofill_disabled() { + let form = MOCK_DOC.getElementById("form1"); + form.reset(); + + let testcase = { + "street-addr": "331 E. Evelyn Avenue", + country: "US", + tel: "+16509030800", + "cc-number": "1111222233334444", + }; + for (let key in testcase) { + let input = MOCK_DOC.getElementById(key); + input.value = testcase[key]; + } + + let element = MOCK_DOC.getElementById(TARGET_ELEMENT_ID); + FormAutofillContent.identifyAutofillFields(element); + + sinon.stub(FormAutofillContent, "_onFormSubmit"); + + // "_onFormSubmit" shouldn't be called if both "addresses" and "creditCards" + // are disabled. + Services.prefs.setBoolPref( + "extensions.formautofill.addresses.enabled", + false + ); + Services.prefs.setBoolPref( + "extensions.formautofill.creditCards.enabled", + false + ); + FormAutofillContent.formSubmitted(form, null); + Assert.equal(FormAutofillContent._onFormSubmit.called, false); + FormAutofillContent._onFormSubmit.resetHistory(); + + // "_onFormSubmit" should be called as usual. + Services.prefs.clearUserPref("extensions.formautofill.addresses.enabled"); + Services.prefs.clearUserPref("extensions.formautofill.creditCards.enabled"); + + Services.prefs.setBoolPref( + "extensions.formautofill.creditCards.enabled", + true + ); + + FormAutofillContent.formSubmitted(form, null); + Assert.equal(FormAutofillContent._onFormSubmit.called, true); + Assert.notDeepEqual(FormAutofillContent._onFormSubmit.args[0][0].address, []); + Assert.notDeepEqual( + FormAutofillContent._onFormSubmit.args[0][0].creditCard, + [] + ); + FormAutofillContent._onFormSubmit.resetHistory(); + + // "address" should be empty if "addresses" pref is disabled. + Services.prefs.setBoolPref( + "extensions.formautofill.addresses.enabled", + false + ); + FormAutofillContent.formSubmitted(form, null); + Assert.equal(FormAutofillContent._onFormSubmit.called, true); + Assert.deepEqual(FormAutofillContent._onFormSubmit.args[0][0].address, []); + Assert.notDeepEqual( + FormAutofillContent._onFormSubmit.args[0][0].creditCard, + [] + ); + FormAutofillContent._onFormSubmit.resetHistory(); + Services.prefs.clearUserPref("extensions.formautofill.addresses.enabled"); + + // "creditCard" should be empty if "creditCards" pref is disabled. + Services.prefs.setBoolPref( + "extensions.formautofill.creditCards.enabled", + false + ); + FormAutofillContent.formSubmitted(form, null); + Assert.deepEqual(FormAutofillContent._onFormSubmit.called, true); + Assert.notDeepEqual(FormAutofillContent._onFormSubmit.args[0][0].address, []); + Assert.deepEqual(FormAutofillContent._onFormSubmit.args[0][0].creditCard, []); + FormAutofillContent._onFormSubmit.resetHistory(); + Services.prefs.clearUserPref("extensions.formautofill.creditCards.enabled"); + + FormAutofillContent._onFormSubmit.restore(); +}); + +TESTCASES.forEach(testcase => { + add_task(async function check_records_saving_is_called_correctly() { + info("Starting testcase: " + testcase.description); + + Services.prefs.setBoolPref( + "extensions.formautofill.creditCards.enabled", + true + ); + + let form = MOCK_DOC.getElementById("form1"); + form.reset(); + for (let key in testcase.formValue) { + let input = MOCK_DOC.getElementById(key); + let value = testcase.formValue[key]; + + if (ChromeUtils.getClassName(input) === "HTMLSelectElement" && value) { + input.multiple = Array.isArray(value); + [...input.options].forEach(option => { + option.selected = value.includes(option.value); + }); + } else { + input.value = testcase.formValue[key]; + } + } + sinon.stub(FormAutofillContent, "_onFormSubmit"); + + let element = MOCK_DOC.getElementById(TARGET_ELEMENT_ID); + FormAutofillContent.identifyAutofillFields(element); + FormAutofillContent.formSubmitted(form, null); + + Assert.equal( + FormAutofillContent._onFormSubmit.called, + testcase.expectedResult.formSubmission, + "Check expected onFormSubmit.called" + ); + if (FormAutofillContent._onFormSubmit.called) { + for (let ccRecord of FormAutofillContent._onFormSubmit.args[0][0] + .creditCard) { + delete ccRecord.flowId; + } + + Assert.deepEqual( + FormAutofillContent._onFormSubmit.args[0][0], + testcase.expectedResult.records + ); + } + FormAutofillContent._onFormSubmit.restore(); + Services.prefs.clearUserPref("extensions.formautofill.creditCards.enabled"); + }); +}); diff --git a/browser/extensions/formautofill/test/unit/test_parseAddressFormat.js b/browser/extensions/formautofill/test/unit/test_parseAddressFormat.js new file mode 100644 index 0000000000..6c629155fa --- /dev/null +++ b/browser/extensions/formautofill/test/unit/test_parseAddressFormat.js @@ -0,0 +1,66 @@ +"use strict"; + +var FormAutofillUtils; +add_task(async function setup() { + ({ FormAutofillUtils } = ChromeUtils.import( + "resource://formautofill/FormAutofillUtils.jsm" + )); +}); + +add_task(async function test_parseAddressFormat() { + const TEST_CASES = [ + { + fmt: "%N%n%O%n%A%n%C, %S %Z", // US + parsed: [ + { fieldId: "name", newLine: true }, + { fieldId: "organization", newLine: true }, + { fieldId: "street-address", newLine: true }, + { fieldId: "address-level2" }, + { fieldId: "address-level1" }, + { fieldId: "postal-code" }, + ], + }, + { + fmt: "%N%n%O%n%A%n%C %S %Z", // CA + parsed: [ + { fieldId: "name", newLine: true }, + { fieldId: "organization", newLine: true }, + { fieldId: "street-address", newLine: true }, + { fieldId: "address-level2" }, + { fieldId: "address-level1" }, + { fieldId: "postal-code" }, + ], + }, + { + fmt: "%N%n%O%n%A%n%Z %C", // DE + parsed: [ + { fieldId: "name", newLine: true }, + { fieldId: "organization", newLine: true }, + { fieldId: "street-address", newLine: true }, + { fieldId: "postal-code" }, + { fieldId: "address-level2" }, + ], + }, + { + fmt: "%N%n%O%n%A%n%D%n%C%n%S %Z", // IE + parsed: [ + { fieldId: "name", newLine: true }, + { fieldId: "organization", newLine: true }, + { fieldId: "street-address", newLine: true }, + { fieldId: "address-level3", newLine: true }, + { fieldId: "address-level2", newLine: true }, + { fieldId: "address-level1" }, + { fieldId: "postal-code" }, + ], + }, + ]; + + Assert.throws( + () => FormAutofillUtils.parseAddressFormat(), + /fmt string is missing./, + "Should throw if fmt is empty" + ); + for (let tc of TEST_CASES) { + Assert.deepEqual(FormAutofillUtils.parseAddressFormat(tc.fmt), tc.parsed); + } +}); diff --git a/browser/extensions/formautofill/test/unit/test_phoneNumber.js b/browser/extensions/formautofill/test/unit/test_phoneNumber.js new file mode 100644 index 0000000000..67fe4a42eb --- /dev/null +++ b/browser/extensions/formautofill/test/unit/test_phoneNumber.js @@ -0,0 +1,399 @@ +/** + * Tests PhoneNumber.jsm and PhoneNumberNormalizer.jsm. + */ + +"use strict"; + +var PhoneNumber, PhoneNumberNormalizer; +add_task(async function setup() { + ({ PhoneNumber } = ChromeUtils.import( + "resource://formautofill/phonenumberutils/PhoneNumber.jsm" + )); + ({ PhoneNumberNormalizer } = ChromeUtils.import( + "resource://formautofill/phonenumberutils/PhoneNumberNormalizer.jsm" + )); +}); + +function IsPlain(dial, expected) { + let result = PhoneNumber.IsPlain(dial); + Assert.equal(result, expected); +} + +function Normalize(dial, expected) { + let result = PhoneNumberNormalizer.Normalize(dial); + Assert.equal(result, expected); +} + +function CantParse(dial, currentRegion) { + let result = PhoneNumber.Parse(dial, currentRegion); + Assert.equal(null, result); +} + +function Parse(dial, currentRegion) { + let result = PhoneNumber.Parse(dial, currentRegion); + Assert.notEqual(result, null); + return result; +} + +function Test(dial, currentRegion, nationalNumber, region) { + let result = Parse(dial, currentRegion); + Assert.equal(result.nationalNumber, nationalNumber); + Assert.equal(result.region, region); + return result; +} + +function TestProperties(dial, currentRegion) { + let result = Parse(dial, currentRegion); + Assert.ok(result.internationalFormat); + Assert.ok(result.internationalNumber); + Assert.ok(result.nationalFormat); + Assert.ok(result.nationalNumber); + Assert.ok(result.countryName); + Assert.ok(result.countryCode); +} + +function Format( + dial, + currentRegion, + nationalNumber, + region, + nationalFormat, + internationalFormat +) { + let result = Test(dial, currentRegion, nationalNumber, region); + Assert.equal(result.nationalFormat, nationalFormat); + Assert.equal(result.internationalFormat, internationalFormat); + return result; +} + +function AllEqual(list, currentRegion) { + let parsedList = list.map(item => Parse(item, currentRegion)); + let firstItem = parsedList.shift(); + for (let item of parsedList) { + Assert.deepEqual(item, firstItem); + } +} + +add_task(async function test_phoneNumber() { + // Test whether could a string be a phone number. + IsPlain(null, false); + IsPlain("", false); + IsPlain("1", true); + IsPlain("*2", true); // Real number used in Venezuela + IsPlain("*8", true); // Real number used in Venezuela + IsPlain("12", true); + IsPlain("123", true); + IsPlain("1a2", false); + IsPlain("12a", false); + IsPlain("1234", true); + IsPlain("123a", false); + IsPlain("+", true); + IsPlain("+1", true); + IsPlain("+12", true); + IsPlain("+123", true); + IsPlain("()123", false); + IsPlain("(1)23", false); + IsPlain("(12)3", false); + IsPlain("(123)", false); + IsPlain("(123)4", false); + IsPlain("(123)4", false); + IsPlain("123;ext=", false); + IsPlain("123;ext=1", false); + IsPlain("123;ext=1234567", false); + IsPlain("123;ext=12345678", false); + IsPlain("123 ext:1", false); + IsPlain("123 ext:1#", false); + IsPlain("123-1#", false); + IsPlain("123 1#", false); + IsPlain("123 12345#", false); + IsPlain("123 +123456#", false); + + // Getting international number back from intl number. + TestProperties("+19497262896"); + + // Test parsing national numbers. + Parse("033316005", "NZ"); + Parse("03-331 6005", "NZ"); + Parse("03 331 6005", "NZ"); + // Testing international prefixes. + // Should strip country code. + Parse("0064 3 331 6005", "NZ"); + + // Test CA before US because CA has to import meta-information for US. + Parse("4031234567", "CA"); + Parse("(416) 585-4319", "CA"); + Parse("647-967-4357", "CA"); + Parse("416-716-8768", "CA"); + Parse("18002684646", "CA"); + Parse("416-445-9119", "CA"); + Parse("1-800-668-6866", "CA"); + Parse("(416) 453-6486", "CA"); + Parse("(647) 268-4778", "CA"); + Parse("647-218-1313", "CA"); + Parse("+1 647-209-4642", "CA"); + Parse("416-559-0133", "CA"); + Parse("+1 647-639-4118", "CA"); + Parse("+12898803664", "CA"); + Parse("780-901-4687", "CA"); + Parse("+14167070550", "CA"); + Parse("+1-647-522-6487", "CA"); + Parse("(416) 877-0880", "CA"); + + // Try again, but this time we have an international number with region rode US. It should + // recognize the country code and parse accordingly. + Parse("01164 3 331 6005", "US"); + Parse("+64 3 331 6005", "US"); + Parse("64(0)64123456", "NZ"); + // Check that using a "/" is fine in a phone number. + Parse("123/45678", "DE"); + Parse("123-456-7890", "US"); + + // Test parsing international numbers. + Parse("+1 (650) 333-6000", "NZ"); + Parse("1-650-333-6000", "US"); + // Calling the US number from Singapore by using different service providers + // 1st test: calling using SingTel IDD service (IDD is 001) + Parse("0011-650-333-6000", "SG"); + // 2nd test: calling using StarHub IDD service (IDD is 008) + Parse("0081-650-333-6000", "SG"); + // 3rd test: calling using SingTel V019 service (IDD is 019) + Parse("0191-650-333-6000", "SG"); + // Calling the US number from Poland + Parse("0~01-650-333-6000", "PL"); + // Using "++" at the start. + Parse("++1 (650) 333-6000", "PL"); + // Using a full-width plus sign. + Parse("\uFF0B1 (650) 333-6000", "SG"); + // The whole number, including punctuation, is here represented in full-width form. + Parse( + "\uFF0B\uFF11\u3000\uFF08\uFF16\uFF15\uFF10\uFF09" + + "\u3000\uFF13\uFF13\uFF13\uFF0D\uFF16\uFF10\uFF10\uFF10", + "SG" + ); + + // Test parsing with leading zeros. + Parse("+39 02-36618 300", "NZ"); + Parse("02-36618 300", "IT"); + Parse("312 345 678", "IT"); + + // Test parsing numbers in Argentina. + Parse("+54 9 343 555 1212", "AR"); + Parse("0343 15 555 1212", "AR"); + Parse("+54 9 3715 65 4320", "AR"); + Parse("03715 15 65 4320", "AR"); + Parse("+54 11 3797 0000", "AR"); + Parse("011 3797 0000", "AR"); + Parse("+54 3715 65 4321", "AR"); + Parse("03715 65 4321", "AR"); + Parse("+54 23 1234 0000", "AR"); + Parse("023 1234 0000", "AR"); + + // Test numbers in Mexico + Parse("+52 (449)978-0001", "MX"); + Parse("01 (449)978-0001", "MX"); + Parse("(449)978-0001", "MX"); + Parse("+52 1 33 1234-5678", "MX"); + Parse("044 (33) 1234-5678", "MX"); + Parse("045 33 1234-5678", "MX"); + + // Test that lots of spaces are ok. + Parse("0 3 3 3 1 6 0 0 5", "NZ"); + + // Test omitting the current region. This is only valid when the number starts + // with a '+'. + Parse("+64 3 331 6005"); + Parse("+64 3 331 6005", null); + + // US numbers + Format( + "19497261234", + "US", + "9497261234", + "US", + "(949) 726-1234", + "+1 949-726-1234" + ); + + // Try a couple german numbers from the US with various access codes. + Format( + "49451491934", + "US", + "0451491934", + "DE", + "0451 491934", + "+49 451 491934" + ); + Format( + "+49451491934", + "US", + "0451491934", + "DE", + "0451 491934", + "+49 451 491934" + ); + Format( + "01149451491934", + "US", + "0451491934", + "DE", + "0451 491934", + "+49 451 491934" + ); + + // Now try dialing the same number from within the German region. + Format( + "451491934", + "DE", + "0451491934", + "DE", + "0451 491934", + "+49 451 491934" + ); + Format( + "0451491934", + "DE", + "0451491934", + "DE", + "0451 491934", + "+49 451 491934" + ); + + // Numbers in italy keep the leading 0 in the city code when dialing internationally. + Format( + "0577-555-555", + "IT", + "0577555555", + "IT", + "05 7755 5555", + "+39 05 7755 5555" + ); + + // Colombian international number without the leading "+" + Format("5712234567", "CO", "12234567", "CO", "(1) 2234567", "+57 1 2234567"); + + // Telefonica tests + Format( + "612123123", + "ES", + "612123123", + "ES", + "612 12 31 23", + "+34 612 12 31 23" + ); + + // Chile mobile number from a landline + Format( + "0997654321", + "CL", + "997654321", + "CL", + "(99) 765 4321", + "+56 99 765 4321" + ); + + // Chile mobile number from another mobile number + Format( + "997654321", + "CL", + "997654321", + "CL", + "(99) 765 4321", + "+56 99 765 4321" + ); + + // Dialing 911 in the US. This is not a national number. + CantParse("911", "US"); + + // China mobile number with a 0 in it + Format( + "15955042864", + "CN", + "015955042864", + "CN", + "0159 5504 2864", + "+86 159 5504 2864" + ); + + // Testing international region numbers. + CantParse("883510000000091", "001"); + Format( + "+883510000000092", + "001", + "510000000092", + "001", + "510 000 000 092", + "+883 510 000 000 092" + ); + Format( + "883510000000093", + "FR", + "510000000093", + "001", + "510 000 000 093", + "+883 510 000 000 093" + ); + Format( + "+883510000000094", + "FR", + "510000000094", + "001", + "510 000 000 094", + "+883 510 000 000 094" + ); + Format( + "883510000000095", + "US", + "510000000095", + "001", + "510 000 000 095", + "+883 510 000 000 095" + ); + Format( + "+883510000000096", + "US", + "510000000096", + "001", + "510 000 000 096", + "+883 510 000 000 096" + ); + CantParse("979510000012", "001"); + Format( + "+979510000012", + "001", + "510000012", + "001", + "5 1000 0012", + "+979 5 1000 0012" + ); + + // Test normalizing numbers. Only 0-9,#* are valid in a phone number. + Normalize("+ABC # * , 9 _ 1 _0", "+222#*,910"); + Normalize("ABCDEFGHIJKLMNOPQRSTUVWXYZ", "22233344455566677778889999"); + Normalize("abcdefghijklmnopqrstuvwxyz", "22233344455566677778889999"); + + // 8 and 9 digit numbers with area code in Brazil with collect call prefix (90) + AllEqual( + [ + "01187654321", + "0411187654321", + "551187654321", + "90411187654321", + "+551187654321", + ], + "BR" + ); + AllEqual( + [ + "011987654321", + "04111987654321", + "5511987654321", + "904111987654321", + "+5511987654321", + ], + "BR" + ); + + Assert.equal(PhoneNumberNormalizer.Normalize("123abc", true), "123"); + Assert.equal(PhoneNumberNormalizer.Normalize("12345", true), "12345"); + Assert.equal(PhoneNumberNormalizer.Normalize("1abcd", false), "12223"); +}); diff --git a/browser/extensions/formautofill/test/unit/test_profileAutocompleteResult.js b/browser/extensions/formautofill/test/unit/test_profileAutocompleteResult.js new file mode 100644 index 0000000000..ba2c31ff9d --- /dev/null +++ b/browser/extensions/formautofill/test/unit/test_profileAutocompleteResult.js @@ -0,0 +1,450 @@ +"use strict"; + +var AddressResult, CreditCardResult; +add_task(async function setup() { + ({ AddressResult, CreditCardResult } = ChromeUtils.import( + "resource://formautofill/ProfileAutoCompleteResult.jsm" + )); +}); + +let matchingProfiles = [ + { + guid: "test-guid-1", + "given-name": "Timothy", + "family-name": "Berners-Lee", + name: "Timothy Berners-Lee", + organization: "Sesame Street", + "street-address": "123 Sesame Street.", + "address-line1": "123 Sesame Street.", + tel: "1-345-345-3456.", + }, + { + guid: "test-guid-2", + "given-name": "John", + "family-name": "Doe", + name: "John Doe", + organization: "Mozilla", + "street-address": "331 E. Evelyn Avenue", + "address-line1": "331 E. Evelyn Avenue", + tel: "1-650-903-0800", + }, + { + guid: "test-guid-3", + organization: "", + "street-address": "321, No Name St. 2nd line 3rd line", + "-moz-street-address-one-line": "321, No Name St. 2nd line 3rd line", + "address-line1": "321, No Name St.", + "address-line2": "2nd line", + "address-line3": "3rd line", + tel: "1-000-000-0000", + }, +]; + +let allFieldNames = [ + "given-name", + "family-name", + "street-address", + "address-line1", + "address-line2", + "address-line3", + "organization", + "tel", +]; + +let addressTestCases = [ + { + description: "Focus on an `organization` field", + options: {}, + matchingProfiles, + allFieldNames, + searchString: "", + fieldName: "organization", + expected: { + searchResult: Ci.nsIAutoCompleteResult.RESULT_SUCCESS, + defaultIndex: 0, + items: [ + { + value: "", + style: "autofill-profile", + comment: JSON.stringify(matchingProfiles[0]), + label: JSON.stringify({ + primary: "Sesame Street", + secondary: "123 Sesame Street.", + }), + image: "", + }, + { + value: "", + style: "autofill-profile", + comment: JSON.stringify(matchingProfiles[1]), + label: JSON.stringify({ + primary: "Mozilla", + secondary: "331 E. Evelyn Avenue", + }), + image: "", + }, + ], + }, + }, + { + description: "Focus on an `tel` field", + options: {}, + matchingProfiles, + allFieldNames, + searchString: "", + fieldName: "tel", + expected: { + searchResult: Ci.nsIAutoCompleteResult.RESULT_SUCCESS, + defaultIndex: 0, + items: [ + { + value: "", + style: "autofill-profile", + comment: JSON.stringify(matchingProfiles[0]), + label: JSON.stringify({ + primary: "1-345-345-3456.", + secondary: "123 Sesame Street.", + }), + image: "", + }, + { + value: "", + style: "autofill-profile", + comment: JSON.stringify(matchingProfiles[1]), + label: JSON.stringify({ + primary: "1-650-903-0800", + secondary: "331 E. Evelyn Avenue", + }), + image: "", + }, + { + value: "", + style: "autofill-profile", + comment: JSON.stringify(matchingProfiles[2]), + label: JSON.stringify({ + primary: "1-000-000-0000", + secondary: "321, No Name St. 2nd line 3rd line", + }), + image: "", + }, + ], + }, + }, + { + description: "Focus on an `street-address` field", + options: {}, + matchingProfiles, + allFieldNames, + searchString: "", + fieldName: "street-address", + expected: { + searchResult: Ci.nsIAutoCompleteResult.RESULT_SUCCESS, + defaultIndex: 0, + items: [ + { + value: "", + style: "autofill-profile", + comment: JSON.stringify(matchingProfiles[0]), + label: JSON.stringify({ + primary: "123 Sesame Street.", + secondary: "Timothy Berners-Lee", + }), + image: "", + }, + { + value: "", + style: "autofill-profile", + comment: JSON.stringify(matchingProfiles[1]), + label: JSON.stringify({ + primary: "331 E. Evelyn Avenue", + secondary: "John Doe", + }), + image: "", + }, + { + value: "", + style: "autofill-profile", + comment: JSON.stringify(matchingProfiles[2]), + label: JSON.stringify({ + primary: "321, No Name St. 2nd line 3rd line", + secondary: "1-000-000-0000", + }), + image: "", + }, + ], + }, + }, + { + description: "Focus on an `address-line1` field", + options: {}, + matchingProfiles, + allFieldNames, + searchString: "", + fieldName: "address-line1", + expected: { + searchResult: Ci.nsIAutoCompleteResult.RESULT_SUCCESS, + defaultIndex: 0, + items: [ + { + value: "", + style: "autofill-profile", + comment: JSON.stringify(matchingProfiles[0]), + label: JSON.stringify({ + primary: "123 Sesame Street.", + secondary: "Timothy Berners-Lee", + }), + image: "", + }, + { + value: "", + style: "autofill-profile", + comment: JSON.stringify(matchingProfiles[1]), + label: JSON.stringify({ + primary: "331 E. Evelyn Avenue", + secondary: "John Doe", + }), + image: "", + }, + { + value: "", + style: "autofill-profile", + comment: JSON.stringify(matchingProfiles[2]), + label: JSON.stringify({ + primary: "321, No Name St.", + secondary: "1-000-000-0000", + }), + image: "", + }, + ], + }, + }, + { + description: "No matching profiles", + options: {}, + matchingProfiles: [], + allFieldNames, + searchString: "", + fieldName: "", + expected: { + searchResult: Ci.nsIAutoCompleteResult.RESULT_NOMATCH, + defaultIndex: 0, + items: [], + }, + }, + { + description: "Search with failure", + options: { resultCode: Ci.nsIAutoCompleteResult.RESULT_FAILURE }, + matchingProfiles: [], + allFieldNames, + searchString: "", + fieldName: "", + expected: { + searchResult: Ci.nsIAutoCompleteResult.RESULT_FAILURE, + defaultIndex: 0, + items: [], + }, + }, +]; + +matchingProfiles = [ + { + guid: "test-guid-1", + "cc-name": "Timothy Berners-Lee", + "cc-number": "************6785", + "cc-exp-month": 12, + "cc-exp-year": 2014, + "cc-type": "visa", + }, + { + guid: "test-guid-2", + "cc-name": "John Doe", + "cc-number": "************1234", + "cc-exp-month": 4, + "cc-exp-year": 2014, + "cc-type": "amex", + }, + { + guid: "test-guid-3", + "cc-number": "************5678", + "cc-exp-month": 8, + "cc-exp-year": 2018, + }, +]; + +allFieldNames = ["cc-name", "cc-number", "cc-exp-month", "cc-exp-year"]; + +let creditCardTestCases = [ + { + description: "Focus on a `cc-name` field", + options: {}, + matchingProfiles, + allFieldNames, + searchString: "", + fieldName: "cc-name", + expected: { + searchResult: Ci.nsIAutoCompleteResult.RESULT_SUCCESS, + defaultIndex: 0, + items: [ + { + value: "", + style: "autofill-profile", + comment: JSON.stringify(matchingProfiles[0]), + label: JSON.stringify({ + primary: "Timothy Berners-Lee", + secondary: "****6785", + ariaLabel: "Visa Timothy Berners-Lee ****6785", + }), + image: "chrome://formautofill/content/third-party/cc-logo-visa.svg", + }, + { + value: "", + style: "autofill-profile", + comment: JSON.stringify(matchingProfiles[1]), + label: JSON.stringify({ + primary: "John Doe", + secondary: "****1234", + ariaLabel: "American Express John Doe ****1234", + }), + image: "chrome://formautofill/content/third-party/cc-logo-amex.png", + }, + ], + }, + }, + { + description: "Focus on a `cc-number` field", + options: {}, + matchingProfiles, + allFieldNames, + searchString: "", + fieldName: "cc-number", + expected: { + searchResult: Ci.nsIAutoCompleteResult.RESULT_SUCCESS, + defaultIndex: 0, + items: [ + { + value: "", + style: "autofill-profile", + comment: JSON.stringify(matchingProfiles[0]), + label: JSON.stringify({ + primaryAffix: "****", + primary: "6785", + secondary: "Timothy Berners-Lee", + ariaLabel: "Visa **** 6785 Timothy Berners-Lee", + }), + image: "chrome://formautofill/content/third-party/cc-logo-visa.svg", + }, + { + value: "", + style: "autofill-profile", + comment: JSON.stringify(matchingProfiles[1]), + label: JSON.stringify({ + primaryAffix: "****", + primary: "1234", + secondary: "John Doe", + ariaLabel: "American Express **** 1234 John Doe", + }), + image: "chrome://formautofill/content/third-party/cc-logo-amex.png", + }, + { + value: "", + style: "autofill-profile", + comment: JSON.stringify(matchingProfiles[2]), + label: JSON.stringify({ + primaryAffix: "****", + primary: "5678", + secondary: "", + ariaLabel: "**** 5678", + }), + image: "chrome://formautofill/content/icon-credit-card-generic.svg", + }, + ], + }, + }, + { + description: "No matching profiles", + options: {}, + matchingProfiles: [], + allFieldNames, + searchString: "", + fieldName: "", + expected: { + searchResult: Ci.nsIAutoCompleteResult.RESULT_NOMATCH, + defaultIndex: 0, + items: [], + }, + }, + { + description: "Search with failure", + options: { resultCode: Ci.nsIAutoCompleteResult.RESULT_FAILURE }, + matchingProfiles: [], + allFieldNames, + searchString: "", + fieldName: "", + expected: { + searchResult: Ci.nsIAutoCompleteResult.RESULT_FAILURE, + defaultIndex: 0, + items: [], + }, + }, +]; + +add_task(async function test_all_patterns() { + let testSets = [ + { + collectionConstructor: AddressResult, + testCases: addressTestCases, + }, + { + collectionConstructor: CreditCardResult, + testCases: creditCardTestCases, + }, + ]; + + testSets.forEach(({ collectionConstructor, testCases }) => { + testCases.forEach(testCase => { + info("Starting testcase: " + testCase.description); + let actual = new collectionConstructor( + testCase.searchString, + testCase.fieldName, + testCase.allFieldNames, + testCase.matchingProfiles, + testCase.options + ); + let expectedValue = testCase.expected; + let expectedItemLength = expectedValue.items.length; + // If the last item shows up as a footer, we expect one more item + // than expected. + if (actual.getStyleAt(actual.matchCount - 1) == "autofill-footer") { + expectedItemLength++; + } + + equal(actual.searchResult, expectedValue.searchResult); + equal(actual.defaultIndex, expectedValue.defaultIndex); + equal(actual.matchCount, expectedItemLength); + expectedValue.items.forEach((item, index) => { + equal(actual.getValueAt(index), item.value); + equal(actual.getCommentAt(index), item.comment); + equal(actual.getLabelAt(index), item.label); + equal(actual.getStyleAt(index), item.style); + equal(actual.getImageAt(index), item.image); + }); + + if (expectedValue.items.length) { + Assert.throws( + () => actual.getValueAt(expectedItemLength), + /Index out of range\./ + ); + + Assert.throws( + () => actual.getLabelAt(expectedItemLength), + /Index out of range\./ + ); + + Assert.throws( + () => actual.getCommentAt(expectedItemLength), + /Index out of range\./ + ); + } + }); + }); +}); diff --git a/browser/extensions/formautofill/test/unit/test_reconcile.js b/browser/extensions/formautofill/test/unit/test_reconcile.js new file mode 100644 index 0000000000..319a03d3cc --- /dev/null +++ b/browser/extensions/formautofill/test/unit/test_reconcile.js @@ -0,0 +1,1136 @@ +"use strict"; + +const TEST_STORE_FILE_NAME = "test-profile.json"; +const CURRENT_CC_VERSION = 3; + +// NOTE: a guide to reading these test-cases: +// parent: What the local record looked like the last time we wrote the +// record to the Sync server. +// local: What the local record looks like now. IOW, the differences between +// 'parent' and 'local' are changes recently made which we wish to sync. +// remote: An incoming record we need to apply (ie, a record that was possibly +// changed on a remote device) +// +// To further help understanding this, a few of the testcases are annotated. +const ADDRESS_RECONCILE_TESTCASES = [ + { + description: "Local change", + parent: { + // So when we last wrote the record to the server, it had these values. + guid: "2bbd2d8fbc6b", + version: 1, + "given-name": "Mark", + "family-name": "Hammond", + }, + local: [ + { + // The current local record - by comparing against parent we can see that + // only the given-name has changed locally. + "given-name": "Skip", + "family-name": "Hammond", + }, + ], + remote: { + // This is the incoming record. It has the same values as "parent", so + // we can deduce the record hasn't actually been changed remotely so we + // can safely ignore the incoming record and write our local changes. + guid: "2bbd2d8fbc6b", + version: 1, + "given-name": "Mark", + "family-name": "Hammond", + }, + reconciled: { + guid: "2bbd2d8fbc6b", + "given-name": "Skip", + "family-name": "Hammond", + }, + }, + { + description: "Remote change", + parent: { + guid: "e3680e9f890d", + version: 1, + "given-name": "Mark", + "family-name": "Hammond", + }, + local: [ + { + "given-name": "Mark", + "family-name": "Hammond", + }, + ], + remote: { + guid: "e3680e9f890d", + version: 1, + "given-name": "Skip", + "family-name": "Hammond", + }, + reconciled: { + guid: "e3680e9f890d", + "given-name": "Skip", + "family-name": "Hammond", + }, + }, + { + description: "New local field", + parent: { + guid: "0cba738b1be0", + version: 1, + "given-name": "Mark", + "family-name": "Hammond", + }, + local: [ + { + "given-name": "Mark", + "family-name": "Hammond", + tel: "123456", + }, + ], + remote: { + guid: "0cba738b1be0", + version: 1, + "given-name": "Mark", + "family-name": "Hammond", + }, + reconciled: { + guid: "0cba738b1be0", + "given-name": "Mark", + "family-name": "Hammond", + tel: "123456", + }, + }, + { + description: "New remote field", + parent: { + guid: "be3ef97f8285", + version: 1, + "given-name": "Mark", + "family-name": "Hammond", + }, + local: [ + { + "given-name": "Mark", + "family-name": "Hammond", + }, + ], + remote: { + guid: "be3ef97f8285", + version: 1, + "given-name": "Mark", + "family-name": "Hammond", + tel: "123456", + }, + reconciled: { + guid: "be3ef97f8285", + "given-name": "Mark", + "family-name": "Hammond", + tel: "123456", + }, + }, + { + description: "Deleted field locally", + parent: { + guid: "9627322248ec", + version: 1, + "given-name": "Mark", + "family-name": "Hammond", + tel: "123456", + }, + local: [ + { + "given-name": "Mark", + "family-name": "Hammond", + }, + ], + remote: { + guid: "9627322248ec", + version: 1, + "given-name": "Mark", + "family-name": "Hammond", + tel: "123456", + }, + reconciled: { + guid: "9627322248ec", + "given-name": "Mark", + "family-name": "Hammond", + }, + }, + { + description: "Deleted field remotely", + parent: { + guid: "7d7509f3eeb2", + version: 1, + "given-name": "Mark", + "family-name": "Hammond", + tel: "123456", + }, + local: [ + { + "given-name": "Mark", + "family-name": "Hammond", + tel: "123456", + }, + ], + remote: { + guid: "7d7509f3eeb2", + version: 1, + "given-name": "Mark", + "family-name": "Hammond", + }, + reconciled: { + guid: "7d7509f3eeb2", + "given-name": "Mark", + "family-name": "Hammond", + }, + }, + { + description: "Local and remote changes to unrelated fields", + parent: { + // The last time we wrote this to the server, country was NZ. + guid: "e087a06dfc57", + version: 1, + "given-name": "Mark", + "family-name": "Hammond", + country: "NZ", + }, + local: [ + { + // The current local record - so locally we've changed given-name to Skip. + "given-name": "Skip", + "family-name": "Hammond", + country: "NZ", + }, + ], + remote: { + // Remotely, we've changed the country to AU. + guid: "e087a06dfc57", + version: 1, + "given-name": "Mark", + "family-name": "Hammond", + country: "AU", + }, + reconciled: { + guid: "e087a06dfc57", + "given-name": "Skip", + "family-name": "Hammond", + country: "AU", + }, + }, + { + description: "Multiple local changes", + parent: { + guid: "340a078c596f", + version: 1, + "given-name": "Mark", + "family-name": "Hammond", + tel: "123456", + }, + local: [ + { + "given-name": "Skip", + "family-name": "Hammond", + }, + { + "given-name": "Skip", + "family-name": "Hammond", + organization: "Mozilla", + }, + ], + remote: { + guid: "340a078c596f", + version: 1, + "given-name": "Mark", + "family-name": "Hammond", + tel: "123456", + country: "AU", + }, + reconciled: { + guid: "340a078c596f", + "given-name": "Skip", + "family-name": "Hammond", + organization: "Mozilla", + country: "AU", + }, + }, + { + // Local and remote diverged from the shared parent, but the values are the + // same, so we shouldn't fork. + description: "Same change to local and remote", + parent: { + guid: "0b3a72a1bea2", + version: 1, + "given-name": "Mark", + "family-name": "Hammond", + }, + local: [ + { + "given-name": "Skip", + "family-name": "Hammond", + }, + ], + remote: { + guid: "0b3a72a1bea2", + version: 1, + "given-name": "Skip", + "family-name": "Hammond", + }, + reconciled: { + guid: "0b3a72a1bea2", + "given-name": "Skip", + "family-name": "Hammond", + }, + }, + { + description: "Conflicting changes to single field", + parent: { + // This is what we last wrote to the sync server. + guid: "62068784d089", + version: 1, + "given-name": "Mark", + "family-name": "Hammond", + }, + local: [ + { + // The current version of the local record - the given-name has changed locally. + "given-name": "Skip", + "family-name": "Hammond", + }, + ], + remote: { + // An incoming record has a different given-name than any of the above! + guid: "62068784d089", + version: 1, + "given-name": "Kip", + "family-name": "Hammond", + }, + forked: { + // So we've forked the local record to a new GUID (and the next sync is + // going to write this as a new record) + "given-name": "Skip", + "family-name": "Hammond", + }, + reconciled: { + // And we've updated the local version of the record to be the remote version. + guid: "62068784d089", + "given-name": "Kip", + "family-name": "Hammond", + }, + }, + { + description: "Conflicting changes to multiple fields", + parent: { + guid: "244dbb692e94", + version: 1, + "given-name": "Mark", + "family-name": "Hammond", + country: "NZ", + }, + local: [ + { + "given-name": "Skip", + "family-name": "Hammond", + country: "AU", + }, + ], + remote: { + guid: "244dbb692e94", + version: 1, + "given-name": "Kip", + "family-name": "Hammond", + country: "CA", + }, + forked: { + "given-name": "Skip", + "family-name": "Hammond", + country: "AU", + }, + reconciled: { + guid: "244dbb692e94", + "given-name": "Kip", + "family-name": "Hammond", + country: "CA", + }, + }, + { + description: "Field deleted locally, changed remotely", + parent: { + guid: "6fc45e03d19a", + version: 1, + "given-name": "Mark", + "family-name": "Hammond", + country: "AU", + }, + local: [ + { + "given-name": "Mark", + "family-name": "Hammond", + }, + ], + remote: { + guid: "6fc45e03d19a", + version: 1, + "given-name": "Mark", + "family-name": "Hammond", + country: "NZ", + }, + forked: { + "given-name": "Mark", + "family-name": "Hammond", + }, + reconciled: { + guid: "6fc45e03d19a", + "given-name": "Mark", + "family-name": "Hammond", + country: "NZ", + }, + }, + { + description: "Field changed locally, deleted remotely", + parent: { + guid: "fff9fa27fa18", + version: 1, + "given-name": "Mark", + "family-name": "Hammond", + country: "AU", + }, + local: [ + { + "given-name": "Mark", + "family-name": "Hammond", + country: "NZ", + }, + ], + remote: { + guid: "fff9fa27fa18", + version: 1, + "given-name": "Mark", + "family-name": "Hammond", + }, + forked: { + "given-name": "Mark", + "family-name": "Hammond", + country: "NZ", + }, + reconciled: { + guid: "fff9fa27fa18", + "given-name": "Mark", + "family-name": "Hammond", + }, + }, + { + // Created, last modified should be synced; last used and times used should + // be local. Remote created time older than local, remote modified time + // newer than local. + description: + "Created, last modified time reconciliation without local changes", + parent: { + guid: "5113f329c42f", + version: 1, + "given-name": "Mark", + "family-name": "Hammond", + timeCreated: 1234, + timeLastModified: 5678, + timeLastUsed: 5678, + timesUsed: 6, + }, + local: [], + remote: { + guid: "5113f329c42f", + version: 1, + "given-name": "Mark", + "family-name": "Hammond", + timeCreated: 1200, + timeLastModified: 5700, + timeLastUsed: 5700, + timesUsed: 3, + }, + reconciled: { + guid: "5113f329c42f", + "given-name": "Mark", + "family-name": "Hammond", + timeCreated: 1200, + timeLastModified: 5700, + timeLastUsed: 5678, + timesUsed: 6, + }, + }, + { + // Local changes, remote created time newer than local, remote modified time + // older than local. + description: + "Created, last modified time reconciliation with local changes", + parent: { + guid: "791e5608b80a", + version: 1, + "given-name": "Mark", + "family-name": "Hammond", + timeCreated: 1234, + timeLastModified: 5678, + timeLastUsed: 5678, + timesUsed: 6, + }, + local: [ + { + "given-name": "Skip", + "family-name": "Hammond", + }, + ], + remote: { + guid: "791e5608b80a", + version: 1, + "given-name": "Mark", + "family-name": "Hammond", + timeCreated: 1300, + timeLastModified: 5000, + timeLastUsed: 5000, + timesUsed: 3, + }, + reconciled: { + guid: "791e5608b80a", + "given-name": "Skip", + "family-name": "Hammond", + timeCreated: 1234, + timeLastUsed: 5678, + timesUsed: 6, + }, + }, +]; + +const CREDIT_CARD_RECONCILE_TESTCASES = [ + { + description: "Local change", + parent: { + // So when we last wrote the record to the server, it had these values. + guid: "2bbd2d8fbc6b", + version: CURRENT_CC_VERSION, + "cc-name": "John Doe", + "cc-number": "4111111111111111", + }, + local: [ + { + // The current local record - by comparing against parent we can see that + // only the cc-number has changed locally. + "cc-name": "John Doe", + "cc-number": "4929001587121045", + }, + ], + remote: { + // This is the incoming record. It has the same values as "parent", so + // we can deduce the record hasn't actually been changed remotely so we + // can safely ignore the incoming record and write our local changes. + guid: "2bbd2d8fbc6b", + version: CURRENT_CC_VERSION, + "cc-name": "John Doe", + "cc-number": "4111111111111111", + }, + reconciled: { + guid: "2bbd2d8fbc6b", + "cc-name": "John Doe", + "cc-number": "4929001587121045", + }, + }, + { + description: "Remote change", + parent: { + guid: "e3680e9f890d", + version: CURRENT_CC_VERSION, + "cc-name": "John Doe", + "cc-number": "4111111111111111", + }, + local: [ + { + "cc-name": "John Doe", + "cc-number": "4111111111111111", + }, + ], + remote: { + guid: "e3680e9f890d", + version: CURRENT_CC_VERSION, + "cc-name": "John Doe", + "cc-number": "4929001587121045", + }, + reconciled: { + guid: "e3680e9f890d", + "cc-name": "John Doe", + "cc-number": "4929001587121045", + }, + }, + + { + description: "New local field", + parent: { + guid: "0cba738b1be0", + version: CURRENT_CC_VERSION, + "cc-name": "John Doe", + "cc-number": "4111111111111111", + }, + local: [ + { + "cc-name": "John Doe", + "cc-number": "4111111111111111", + "cc-exp-month": 12, + }, + ], + remote: { + guid: "0cba738b1be0", + version: CURRENT_CC_VERSION, + "cc-name": "John Doe", + "cc-number": "4111111111111111", + }, + reconciled: { + guid: "0cba738b1be0", + "cc-name": "John Doe", + "cc-number": "4111111111111111", + "cc-exp-month": 12, + }, + }, + { + description: "New remote field", + parent: { + guid: "be3ef97f8285", + version: CURRENT_CC_VERSION, + "cc-name": "John Doe", + "cc-number": "4111111111111111", + }, + local: [ + { + "cc-name": "John Doe", + "cc-number": "4111111111111111", + }, + ], + remote: { + guid: "be3ef97f8285", + version: CURRENT_CC_VERSION, + "cc-name": "John Doe", + "cc-number": "4111111111111111", + "cc-exp-month": 12, + }, + reconciled: { + guid: "be3ef97f8285", + "cc-name": "John Doe", + "cc-number": "4111111111111111", + "cc-exp-month": 12, + }, + }, + { + description: "Deleted field locally", + parent: { + guid: "9627322248ec", + version: CURRENT_CC_VERSION, + "cc-name": "John Doe", + "cc-number": "4111111111111111", + "cc-exp-month": 12, + }, + local: [ + { + "cc-name": "John Doe", + "cc-number": "4111111111111111", + }, + ], + remote: { + guid: "9627322248ec", + version: CURRENT_CC_VERSION, + "cc-name": "John Doe", + "cc-number": "4111111111111111", + "cc-exp-month": 12, + }, + reconciled: { + guid: "9627322248ec", + "cc-name": "John Doe", + "cc-number": "4111111111111111", + }, + }, + { + description: "Deleted field remotely", + parent: { + guid: "7d7509f3eeb2", + version: CURRENT_CC_VERSION, + "cc-name": "John Doe", + "cc-number": "4111111111111111", + "cc-exp-month": 12, + }, + local: [ + { + "cc-name": "John Doe", + "cc-number": "4111111111111111", + "cc-exp-month": 12, + }, + ], + remote: { + guid: "7d7509f3eeb2", + version: CURRENT_CC_VERSION, + "cc-name": "John Doe", + "cc-number": "4111111111111111", + }, + reconciled: { + guid: "7d7509f3eeb2", + "cc-name": "John Doe", + "cc-number": "4111111111111111", + }, + }, + { + description: "Local and remote changes to unrelated fields", + parent: { + // The last time we wrote this to the server, "cc-exp-month" was 12. + guid: "e087a06dfc57", + version: CURRENT_CC_VERSION, + "cc-name": "John Doe", + "cc-number": "4111111111111111", + "cc-exp-month": 12, + }, + local: [ + { + // The current local record - so locally we've changed "cc-number". + "cc-name": "John Doe", + "cc-number": "4929001587121045", + "cc-exp-month": 12, + }, + ], + remote: { + // Remotely, we've changed "cc-exp-month" to 1. + guid: "e087a06dfc57", + version: CURRENT_CC_VERSION, + "cc-name": "John Doe", + "cc-number": "4111111111111111", + "cc-exp-month": 1, + }, + reconciled: { + guid: "e087a06dfc57", + "cc-name": "John Doe", + "cc-number": "4929001587121045", + "cc-exp-month": 1, + }, + }, + { + description: "Multiple local changes", + parent: { + guid: "340a078c596f", + version: CURRENT_CC_VERSION, + "cc-name": "John Doe", + "cc-number": "4111111111111111", + }, + local: [ + { + "cc-name": "Skip", + "cc-number": "4111111111111111", + }, + { + "cc-name": "Skip", + "cc-number": "4111111111111111", + "cc-exp-month": 12, + }, + ], + remote: { + guid: "340a078c596f", + version: CURRENT_CC_VERSION, + "cc-name": "John Doe", + "cc-number": "4111111111111111", + "cc-exp-year": 2000, + }, + reconciled: { + guid: "340a078c596f", + "cc-name": "Skip", + "cc-number": "4111111111111111", + "cc-exp-month": 12, + "cc-exp-year": 2000, + }, + }, + { + // Local and remote diverged from the shared parent, but the values are the + // same, so we shouldn't fork. + description: "Same change to local and remote", + parent: { + guid: "0b3a72a1bea2", + version: CURRENT_CC_VERSION, + "cc-name": "John Doe", + "cc-number": "4111111111111111", + }, + local: [ + { + "cc-name": "John Doe", + "cc-number": "4929001587121045", + }, + ], + remote: { + guid: "0b3a72a1bea2", + version: CURRENT_CC_VERSION, + "cc-name": "John Doe", + "cc-number": "4929001587121045", + }, + reconciled: { + guid: "0b3a72a1bea2", + "cc-name": "John Doe", + "cc-number": "4929001587121045", + }, + }, + { + description: "Conflicting changes to single field", + parent: { + // This is what we last wrote to the sync server. + guid: "62068784d089", + version: CURRENT_CC_VERSION, + "cc-name": "John Doe", + "cc-number": "4111111111111111", + }, + local: [ + { + // The current version of the local record - the cc-number has changed locally. + "cc-name": "John Doe", + "cc-number": "5103059495477870", + }, + ], + remote: { + // An incoming record has a different cc-number than any of the above! + guid: "62068784d089", + version: CURRENT_CC_VERSION, + "cc-name": "John Doe", + "cc-number": "4929001587121045", + }, + forked: { + // So we've forked the local record to a new GUID (and the next sync is + // going to write this as a new record) + "cc-name": "John Doe", + "cc-number": "5103059495477870", + }, + reconciled: { + // And we've updated the local version of the record to be the remote version. + guid: "62068784d089", + "cc-name": "John Doe", + "cc-number": "4929001587121045", + }, + }, + { + description: "Conflicting changes to multiple fields", + parent: { + guid: "244dbb692e94", + version: CURRENT_CC_VERSION, + "cc-name": "John Doe", + "cc-number": "4111111111111111", + "cc-exp-month": 12, + }, + local: [ + { + "cc-name": "John Doe", + "cc-number": "5103059495477870", + "cc-exp-month": 1, + }, + ], + remote: { + guid: "244dbb692e94", + version: CURRENT_CC_VERSION, + "cc-name": "John Doe", + "cc-number": "4929001587121045", + "cc-exp-month": 3, + }, + forked: { + "cc-name": "John Doe", + "cc-number": "5103059495477870", + "cc-exp-month": 1, + }, + reconciled: { + guid: "244dbb692e94", + "cc-name": "John Doe", + "cc-number": "4929001587121045", + "cc-exp-month": 3, + }, + }, + { + description: "Field deleted locally, changed remotely", + parent: { + guid: "6fc45e03d19a", + version: CURRENT_CC_VERSION, + "cc-name": "John Doe", + "cc-number": "4111111111111111", + "cc-exp-month": 12, + }, + local: [ + { + "cc-name": "John Doe", + "cc-number": "4111111111111111", + }, + ], + remote: { + guid: "6fc45e03d19a", + version: CURRENT_CC_VERSION, + "cc-name": "John Doe", + "cc-number": "4111111111111111", + "cc-exp-month": 3, + }, + forked: { + "cc-name": "John Doe", + "cc-number": "4111111111111111", + }, + reconciled: { + guid: "6fc45e03d19a", + "cc-name": "John Doe", + "cc-number": "4111111111111111", + "cc-exp-month": 3, + }, + }, + { + description: "Field changed locally, deleted remotely", + parent: { + guid: "fff9fa27fa18", + version: CURRENT_CC_VERSION, + "cc-name": "John Doe", + "cc-number": "4111111111111111", + "cc-exp-month": 12, + }, + local: [ + { + "cc-name": "John Doe", + "cc-number": "4111111111111111", + "cc-exp-month": 3, + }, + ], + remote: { + guid: "fff9fa27fa18", + version: CURRENT_CC_VERSION, + "cc-name": "John Doe", + "cc-number": "4111111111111111", + }, + forked: { + "cc-name": "John Doe", + "cc-number": "4111111111111111", + "cc-exp-month": 3, + }, + reconciled: { + guid: "fff9fa27fa18", + "cc-name": "John Doe", + "cc-number": "4111111111111111", + }, + }, + { + // Created, last modified should be synced; last used and times used should + // be local. Remote created time older than local, remote modified time + // newer than local. + description: + "Created, last modified time reconciliation without local changes", + parent: { + guid: "5113f329c42f", + version: CURRENT_CC_VERSION, + "cc-name": "John Doe", + "cc-number": "4111111111111111", + timeCreated: 1234, + timeLastModified: 5678, + timeLastUsed: 5678, + timesUsed: 6, + }, + local: [], + remote: { + guid: "5113f329c42f", + version: CURRENT_CC_VERSION, + "cc-name": "John Doe", + "cc-number": "4111111111111111", + timeCreated: 1200, + timeLastModified: 5700, + timeLastUsed: 5700, + timesUsed: 3, + }, + reconciled: { + guid: "5113f329c42f", + "cc-name": "John Doe", + "cc-number": "4111111111111111", + timeCreated: 1200, + timeLastModified: 5700, + timeLastUsed: 5678, + timesUsed: 6, + }, + }, + { + // Local changes, remote created time newer than local, remote modified time + // older than local. + description: + "Created, last modified time reconciliation with local changes", + parent: { + guid: "791e5608b80a", + version: CURRENT_CC_VERSION, + "cc-name": "John Doe", + "cc-number": "4111111111111111", + timeCreated: 1234, + timeLastModified: 5678, + timeLastUsed: 5678, + timesUsed: 6, + }, + local: [ + { + "cc-name": "John Doe", + "cc-number": "4929001587121045", + }, + ], + remote: { + guid: "791e5608b80a", + version: CURRENT_CC_VERSION, + "cc-name": "John Doe", + "cc-number": "4111111111111111", + timeCreated: 1300, + timeLastModified: 5000, + timeLastUsed: 5000, + timesUsed: 3, + }, + reconciled: { + guid: "791e5608b80a", + "cc-name": "John Doe", + "cc-number": "4929001587121045", + timeCreated: 1234, + timeLastUsed: 5678, + timesUsed: 6, + }, + }, +]; + +add_task(async function test_reconcile_unknown_version() { + let profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME); + + // Cross-version reconciliation isn't supported yet. See bug 1377204. + await Assert.rejects( + profileStorage.addresses.reconcile({ + guid: "31d83d2725ec", + version: 3, + "given-name": "Mark", + "family-name": "Hammond", + }), + /Got unknown record version/ + ); +}); + +add_task(async function test_reconcile_idempotent() { + let profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME); + + let guid = "de1ba7b094fe"; + await profileStorage.addresses.add( + { + guid, + version: 1, + "given-name": "Mark", + "family-name": "Hammond", + }, + { sourceSync: true } + ); + await profileStorage.addresses.update(guid, { + "given-name": "Skip", + "family-name": "Hammond", + organization: "Mozilla", + }); + + let remote = { + guid, + version: 1, + "given-name": "Mark", + "family-name": "Hammond", + tel: "123456", + }; + + { + let { forkedGUID } = await profileStorage.addresses.reconcile(remote); + let updatedRecord = await profileStorage.addresses.get(guid, { + rawData: true, + }); + + ok(!forkedGUID, "First merge should not fork record"); + ok( + objectMatches(updatedRecord, { + guid: "de1ba7b094fe", + "given-name": "Skip", + "family-name": "Hammond", + organization: "Mozilla", + tel: "123456", + }), + "First merge should merge local and remote changes" + ); + } + + { + let { forkedGUID } = await profileStorage.addresses.reconcile(remote); + let updatedRecord = await profileStorage.addresses.get(guid, { + rawData: true, + }); + + ok(!forkedGUID, "Second merge should not fork record"); + ok( + objectMatches(updatedRecord, { + guid: "de1ba7b094fe", + "given-name": "Skip", + "family-name": "Hammond", + organization: "Mozilla", + tel: "123456", + }), + "Second merge should not change record" + ); + } +}); + +add_task(async function test_reconcile_three_way_merge() { + let TESTCASES = { + addresses: ADDRESS_RECONCILE_TESTCASES, + creditCards: CREDIT_CARD_RECONCILE_TESTCASES, + }; + + for (let collectionName in TESTCASES) { + info(`Start to test reconcile on ${collectionName}`); + + let profileStorage = await initProfileStorage( + TEST_STORE_FILE_NAME, + null, + collectionName + ); + + for (let test of TESTCASES[collectionName]) { + info(test.description); + + await profileStorage[collectionName].add(test.parent, { + sourceSync: true, + }); + + for (let updatedRecord of test.local) { + await profileStorage[collectionName].update( + test.parent.guid, + updatedRecord + ); + } + + let localRecord = await profileStorage[collectionName].get( + test.parent.guid, + { + rawData: true, + } + ); + + let onReconciled = TestUtils.topicObserved( + "formautofill-storage-changed", + (subject, data) => + data == "reconcile" && + subject.wrappedJSObject.collectionName == collectionName + ); + let { forkedGUID } = await profileStorage[collectionName].reconcile( + test.remote + ); + await onReconciled; + let reconciledRecord = await profileStorage[collectionName].get( + test.parent.guid, + { + rawData: true, + } + ); + if (forkedGUID) { + let forkedRecord = await profileStorage[collectionName].get( + forkedGUID, + { + rawData: true, + } + ); + + notEqual(forkedRecord.guid, reconciledRecord.guid); + equal(forkedRecord.timeLastModified, localRecord.timeLastModified); + ok( + objectMatches(forkedRecord, test.forked), + `${test.description} should fork record` + ); + } else { + ok(!test.forked, `${test.description} should not fork record`); + } + + ok(objectMatches(reconciledRecord, test.reconciled)); + } + } +}); diff --git a/browser/extensions/formautofill/test/unit/test_savedFieldNames.js b/browser/extensions/formautofill/test/unit/test_savedFieldNames.js new file mode 100644 index 0000000000..ca9ea4376b --- /dev/null +++ b/browser/extensions/formautofill/test/unit/test_savedFieldNames.js @@ -0,0 +1,106 @@ +/* + * Test for keeping the valid fields information in sharedData. + */ + +"use strict"; + +let FormAutofillStatus; + +add_task(async function setup() { + ({ FormAutofillStatus } = ChromeUtils.import( + "resource://formautofill/FormAutofillParent.jsm" + )); +}); + +add_task(async function test_profileSavedFieldNames_init() { + FormAutofillStatus.init(); + sinon.stub(FormAutofillStatus, "updateSavedFieldNames"); + + await FormAutofillStatus.formAutofillStorage.initialize(); + Assert.equal(FormAutofillStatus.updateSavedFieldNames.called, true); + + FormAutofillStatus.uninit(); +}); + +add_task(async function test_profileSavedFieldNames_observe() { + FormAutofillStatus.init(); + + // profile changed => Need to trigger updateValidFields + ["add", "update", "remove", "reconcile", "removeAll"].forEach(event => { + FormAutofillStatus.observe(null, "formautofill-storage-changed", event); + Assert.equal(FormAutofillStatus.updateSavedFieldNames.called, true); + }); + + // profile metadata updated => no need to trigger updateValidFields + FormAutofillStatus.updateSavedFieldNames.resetHistory(); + FormAutofillStatus.observe( + null, + "formautofill-storage-changed", + "notifyUsed" + ); + Assert.equal(FormAutofillStatus.updateSavedFieldNames.called, false); + FormAutofillStatus.updateSavedFieldNames.restore(); +}); + +add_task(async function test_profileSavedFieldNames_update() { + registerCleanupFunction(function cleanup() { + Services.prefs.clearUserPref("extensions.formautofill.addresses.enabled"); + }); + + Object.defineProperty( + FormAutofillStatus.formAutofillStorage.addresses, + "_data", + { writable: true } + ); + + FormAutofillStatus.formAutofillStorage.addresses._data = []; + + // The set is empty if there's no profile in the store. + FormAutofillStatus.updateSavedFieldNames(); + Assert.equal( + Services.ppmm.sharedData.get("FormAutofill:savedFieldNames").size, + 0 + ); + + // 2 profiles with 4 valid fields. + FormAutofillStatus.formAutofillStorage.addresses._data = [ + { + guid: "test-guid-1", + organization: "Sesame Street", + "street-address": "123 Sesame Street.", + tel: "1-345-345-3456", + email: "", + timeCreated: 0, + timeLastUsed: 0, + timeLastModified: 0, + timesUsed: 0, + }, + { + guid: "test-guid-2", + organization: "Mozilla", + "street-address": "331 E. Evelyn Avenue", + tel: "1-650-903-0800", + country: "US", + timeCreated: 0, + timeLastUsed: 0, + timeLastModified: 0, + timesUsed: 0, + }, + ]; + + FormAutofillStatus.updateSavedFieldNames(); + + let autofillSavedFieldNames = Services.ppmm.sharedData.get( + "FormAutofill:savedFieldNames" + ); + Assert.equal(autofillSavedFieldNames.size, 4); + Assert.equal(autofillSavedFieldNames.has("organization"), true); + Assert.equal(autofillSavedFieldNames.has("street-address"), true); + Assert.equal(autofillSavedFieldNames.has("tel"), true); + Assert.equal(autofillSavedFieldNames.has("email"), false); + Assert.equal(autofillSavedFieldNames.has("guid"), false); + Assert.equal(autofillSavedFieldNames.has("timeCreated"), false); + Assert.equal(autofillSavedFieldNames.has("timeLastUsed"), false); + Assert.equal(autofillSavedFieldNames.has("timeLastModified"), false); + Assert.equal(autofillSavedFieldNames.has("timesUsed"), false); +}); diff --git a/browser/extensions/formautofill/test/unit/test_storage_remove.js b/browser/extensions/formautofill/test/unit/test_storage_remove.js new file mode 100644 index 0000000000..93d8576899 --- /dev/null +++ b/browser/extensions/formautofill/test/unit/test_storage_remove.js @@ -0,0 +1,89 @@ +/** + * Tests removing all address/creditcard records. + */ + +"use strict"; + +let FormAutofillStorage; +add_task(async function setup() { + ({ FormAutofillStorage } = ChromeUtils.import( + "resource://formautofill/FormAutofillStorage.jsm", + null + )); +}); + +const TEST_STORE_FILE_NAME = "test-tombstones.json"; + +const TEST_ADDRESS_1 = { + "given-name": "Timothy", + "additional-name": "John", + "family-name": "Berners-Lee", + organization: "World Wide Web Consortium", + "street-address": "32 Vassar Street\nMIT Room 32-G524", + "address-level2": "Cambridge", + "address-level1": "MA", + "postal-code": "02139", + country: "US", + tel: "+1 617 253 5702", + email: "timbl@w3.org", +}; + +const TEST_ADDRESS_2 = { + "street-address": "Some Address", + country: "US", +}; + +const TEST_CREDIT_CARD_1 = { + "cc-name": "John Doe", + "cc-number": "4111111111111111", + "cc-exp-month": 4, + "cc-exp-year": 2017, +}; + +const TEST_CREDIT_CARD_2 = { + "cc-name": "Timothy Berners-Lee", + "cc-number": "4929001587121045", + "cc-exp-month": 12, + "cc-exp-year": 2022, +}; + +// Like add_task, but actually adds 2 - one for addresses and one for cards. +function add_storage_task(test_function) { + add_task(async function() { + let path = getTempFile(TEST_STORE_FILE_NAME).path; + let profileStorage = new FormAutofillStorage(path); + await profileStorage.initialize(); + let address_records = [TEST_ADDRESS_1, TEST_ADDRESS_2]; + let cc_records = [TEST_CREDIT_CARD_1, TEST_CREDIT_CARD_2]; + + for (let [storage, record] of [ + [profileStorage.addresses, address_records], + [profileStorage.creditCards, cc_records], + ]) { + await test_function(storage, record); + } + }); +} + +add_storage_task(async function test_remove_everything(storage, records) { + info("check simple tombstone semantics"); + + let guid = await storage.add(records[0]); + Assert.equal((await storage.getAll()).length, 1); + + storage.pullSyncChanges(); // force sync metadata, which triggers tombstone behaviour. + + storage.remove(guid); + + await storage.add(records[1]); + // getAll() is still 1 as we deleted the first. + Assert.equal((await storage.getAll()).length, 1); + + // check we have the tombstone. + Assert.equal((await storage.getAll({ includeDeleted: true })).length, 2); + + storage.removeAll(); + + // should have deleted both the existing and deleted records. + Assert.equal((await storage.getAll({ includeDeleted: true })).length, 0); +}); diff --git a/browser/extensions/formautofill/test/unit/test_storage_syncfields.js b/browser/extensions/formautofill/test/unit/test_storage_syncfields.js new file mode 100644 index 0000000000..30fc05ae4d --- /dev/null +++ b/browser/extensions/formautofill/test/unit/test_storage_syncfields.js @@ -0,0 +1,497 @@ +/** + * Tests FormAutofillStorage objects support for sync related fields. + */ + +"use strict"; + +// The duplication of some of these fixtures between tests is unfortunate. +const TEST_STORE_FILE_NAME = "test-profile.json"; + +const TEST_ADDRESS_1 = { + "given-name": "Timothy", + "additional-name": "John", + "family-name": "Berners-Lee", + organization: "World Wide Web Consortium", + "street-address": "32 Vassar Street\nMIT Room 32-G524", + "address-level2": "Cambridge", + "address-level1": "MA", + "postal-code": "02139", + country: "US", + tel: "+1 617 253 5702", + email: "timbl@w3.org", +}; + +const TEST_ADDRESS_2 = { + "street-address": "Some Address", + country: "US", +}; + +const TEST_ADDRESS_3 = { + "street-address": "Other Address", + "postal-code": "12345", +}; + +// storage.get() doesn't support getting deleted items. However, this test +// wants to do that, so rather than making .get() support that just for this +// test, we use this helper. +async function findGUID(storage, guid, options) { + let all = await storage.getAll(options); + let records = all.filter(r => r.guid == guid); + equal(records.length, 1); + return records[0]; +} + +add_task(async function test_changeCounter() { + let profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME, [ + TEST_ADDRESS_1, + ]); + + let [address] = await profileStorage.addresses.getAll(); + // new records don't get the sync metadata. + equal(getSyncChangeCounter(profileStorage.addresses, address.guid), -1); + // But we can force one. + profileStorage.addresses.pullSyncChanges(); + equal(getSyncChangeCounter(profileStorage.addresses, address.guid), 1); +}); + +add_task(async function test_pushChanges() { + let profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME, [ + TEST_ADDRESS_1, + TEST_ADDRESS_2, + ]); + + profileStorage.addresses.pullSyncChanges(); // force sync metadata for all items + + let [, address] = await profileStorage.addresses.getAll(); + let guid = address.guid; + let changeCounter = getSyncChangeCounter(profileStorage.addresses, guid); + + // Pretend we're doing a sync now, and an update occured mid-sync. + let changes = { + [guid]: { + profile: address, + counter: changeCounter, + modified: address.timeLastModified, + synced: true, + }, + }; + + let onChanged = TestUtils.topicObserved( + "formautofill-storage-changed", + (subject, data) => data == "update" + ); + await profileStorage.addresses.update(guid, TEST_ADDRESS_3); + await onChanged; + + changeCounter = getSyncChangeCounter(profileStorage.addresses, guid); + Assert.equal(changeCounter, 2); + + profileStorage.addresses.pushSyncChanges(changes); + address = await profileStorage.addresses.get(guid); + changeCounter = getSyncChangeCounter(profileStorage.addresses, guid); + + // Counter should still be 1, since our sync didn't record the mid-sync change + Assert.equal( + changeCounter, + 1, + "Counter shouldn't be zero because it didn't record update" + ); + + // now, push a new set of changes, which should make the changeCounter 0 + profileStorage.addresses.pushSyncChanges({ + [guid]: { + profile: address, + counter: changeCounter, + modified: address.timeLastModified, + synced: true, + }, + }); + + changeCounter = getSyncChangeCounter(profileStorage.addresses, guid); + Assert.equal(changeCounter, 0); +}); + +async function checkingSyncChange(action, callback) { + let onChanged = TestUtils.topicObserved( + "formautofill-storage-changed", + (subject, data) => data == action + ); + await callback(); + let [subject] = await onChanged; + ok( + subject.wrappedJSObject.sourceSync, + "change notification should have source sync" + ); +} + +add_task(async function test_add_sourceSync() { + let profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME, []); + + // Hardcode a guid so that we don't need to generate a dynamic regex + let guid = "aaaaaaaaaaaa"; + let testAddr = Object.assign({ guid, version: 1 }, TEST_ADDRESS_1); + + await checkingSyncChange("add", async () => + profileStorage.addresses.add(testAddr, { sourceSync: true }) + ); + + let changeCounter = getSyncChangeCounter(profileStorage.addresses, guid); + equal(changeCounter, 0); + + await Assert.rejects( + profileStorage.addresses.add({ guid, deleted: true }, { sourceSync: true }), + /Record aaaaaaaaaaaa already exists/ + ); +}); + +add_task(async function test_add_tombstone_sourceSync() { + let profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME, []); + + let guid = profileStorage.addresses._generateGUID(); + let testAddr = { guid, deleted: true }; + await checkingSyncChange("add", async () => + profileStorage.addresses.add(testAddr, { sourceSync: true }) + ); + + let added = await findGUID(profileStorage.addresses, guid, { + includeDeleted: true, + }); + ok(added); + equal(getSyncChangeCounter(profileStorage.addresses, guid), 0); + ok(added.deleted); + + // Adding same record again shouldn't throw (or change anything) + await checkingSyncChange("add", async () => + profileStorage.addresses.add(testAddr, { sourceSync: true }) + ); + + added = await findGUID(profileStorage.addresses, guid, { + includeDeleted: true, + }); + equal(getSyncChangeCounter(profileStorage.addresses, guid), 0); + ok(added.deleted); +}); + +add_task(async function test_add_resurrects_tombstones() { + let profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME, []); + + let guid = profileStorage.addresses._generateGUID(); + + // Add a tombstone. + await profileStorage.addresses.add({ guid, deleted: true }); + + // You can't re-add an item with an explicit GUID. + let resurrected = Object.assign({}, TEST_ADDRESS_1, { guid, version: 1 }); + await Assert.rejects( + profileStorage.addresses.add(resurrected), + /"(guid|version)" is not a valid field/ + ); + + // But Sync can! + let guid3 = await profileStorage.addresses.add(resurrected, { + sourceSync: true, + }); + equal(guid, guid3); + + let got = await profileStorage.addresses.get(guid); + equal(got["given-name"], TEST_ADDRESS_1["given-name"]); +}); + +add_task(async function test_remove_sourceSync_localChanges() { + let profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME, [ + TEST_ADDRESS_1, + ]); + profileStorage.addresses.pullSyncChanges(); // force sync metadata + + let [{ guid }] = await profileStorage.addresses.getAll(); + + equal(getSyncChangeCounter(profileStorage.addresses, guid), 1); + // try and remove a record stored locally with local changes + await checkingSyncChange("remove", async () => + profileStorage.addresses.remove(guid, { sourceSync: true }) + ); + + let record = await profileStorage.addresses.get(guid); + ok(record); + equal(getSyncChangeCounter(profileStorage.addresses, guid), 1); +}); + +add_task(async function test_remove_sourceSync_unknown() { + // remove a record not stored locally + let profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME, []); + + let guid = profileStorage.addresses._generateGUID(); + await checkingSyncChange("remove", async () => + profileStorage.addresses.remove(guid, { sourceSync: true }) + ); + + let tombstone = await findGUID(profileStorage.addresses, guid, { + includeDeleted: true, + }); + ok(tombstone.deleted); + equal(getSyncChangeCounter(profileStorage.addresses, guid), 0); +}); + +add_task(async function test_remove_sourceSync_unchanged() { + // Remove a local record without a change counter. + let profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME, []); + + let guid = profileStorage.addresses._generateGUID(); + let addr = Object.assign({ guid, version: 1 }, TEST_ADDRESS_1); + // add a record with sourceSync to guarantee changeCounter == 0 + await checkingSyncChange("add", async () => + profileStorage.addresses.add(addr, { sourceSync: true }) + ); + + equal(getSyncChangeCounter(profileStorage.addresses, guid), 0); + + await checkingSyncChange("remove", async () => + profileStorage.addresses.remove(guid, { sourceSync: true }) + ); + + let tombstone = await findGUID(profileStorage.addresses, guid, { + includeDeleted: true, + }); + ok(tombstone.deleted); + equal(getSyncChangeCounter(profileStorage.addresses, guid), 0); +}); + +add_task(async function test_pullSyncChanges() { + let profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME, [ + TEST_ADDRESS_1, + TEST_ADDRESS_2, + ]); + + let startAddresses = await profileStorage.addresses.getAll(); + equal(startAddresses.length, 2); + // All should start without sync metadata + for (let { guid } of profileStorage.addresses._store.data.addresses) { + let changeCounter = getSyncChangeCounter(profileStorage.addresses, guid); + equal(changeCounter, -1); + } + profileStorage.addresses.pullSyncChanges(); // force sync metadata + + let addedDirectGUID = profileStorage.addresses._generateGUID(); + let testAddr = Object.assign( + { guid: addedDirectGUID, version: 1 }, + TEST_ADDRESS_1, + TEST_ADDRESS_3 + ); + + await checkingSyncChange("add", async () => + profileStorage.addresses.add(testAddr, { sourceSync: true }) + ); + + let tombstoneGUID = profileStorage.addresses._generateGUID(); + await checkingSyncChange("add", async () => + profileStorage.addresses.add( + { guid: tombstoneGUID, deleted: true }, + { sourceSync: true } + ) + ); + + let onChanged = TestUtils.topicObserved( + "formautofill-storage-changed", + (subject, data) => data == "remove" + ); + + profileStorage.addresses.remove(startAddresses[0].guid); + await onChanged; + + let addresses = await profileStorage.addresses.getAll({ + includeDeleted: true, + }); + + // Should contain changes with a change counter + let changes = profileStorage.addresses.pullSyncChanges(); + equal(Object.keys(changes).length, 2); + + ok(changes[startAddresses[0].guid].profile.deleted); + equal(changes[startAddresses[0].guid].counter, 2); + + ok(!changes[startAddresses[1].guid].profile.deleted); + equal(changes[startAddresses[1].guid].counter, 1); + + ok( + !changes[tombstoneGUID], + "Missing because it's a tombstone from sourceSync" + ); + ok(!changes[addedDirectGUID], "Missing because it was added with sourceSync"); + + for (let address of addresses) { + let change = changes[address.guid]; + if (!change) { + continue; + } + equal(change.profile.guid, address.guid); + let changeCounter = getSyncChangeCounter( + profileStorage.addresses, + change.profile.guid + ); + equal(change.counter, changeCounter); + ok(!change.synced); + } +}); + +add_task(async function test_pullPushChanges() { + // round-trip changes between pull and push + let profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME, []); + let psa = profileStorage.addresses; + + let guid1 = await psa.add(TEST_ADDRESS_1); + let guid2 = await psa.add(TEST_ADDRESS_2); + let guid3 = await psa.add(TEST_ADDRESS_3); + + let changes = psa.pullSyncChanges(); + + equal(getSyncChangeCounter(psa, guid1), 1); + equal(getSyncChangeCounter(psa, guid2), 1); + equal(getSyncChangeCounter(psa, guid3), 1); + + // between the pull and the push we change the second. + await psa.update(guid2, Object.assign({}, TEST_ADDRESS_2, { country: "AU" })); + equal(getSyncChangeCounter(psa, guid2), 2); + // and update the changeset to indicated we did update the first 2, but failed + // to update the 3rd for some reason. + changes[guid1].synced = true; + changes[guid2].synced = true; + + psa.pushSyncChanges(changes); + + // first was synced correctly. + equal(getSyncChangeCounter(psa, guid1), 0); + // second was synced correctly, but it had a change while syncing. + equal(getSyncChangeCounter(psa, guid2), 1); + // 3rd wasn't marked as having synced. + equal(getSyncChangeCounter(psa, guid3), 1); +}); + +add_task(async function test_changeGUID() { + let profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME, []); + + let newguid = () => profileStorage.addresses._generateGUID(); + + let guid_synced = await profileStorage.addresses.add(TEST_ADDRESS_1); + + // pullSyncChanges so guid_synced is flagged as syncing. + profileStorage.addresses.pullSyncChanges(); + + // and 2 items that haven't been synced. + let guid_u1 = await profileStorage.addresses.add(TEST_ADDRESS_2); + let guid_u2 = await profileStorage.addresses.add(TEST_ADDRESS_3); + + // Change a non-existing guid + Assert.throws( + () => profileStorage.addresses.changeGUID(newguid(), newguid()), + /changeGUID: no source record/ + ); + // Change to a guid that already exists. + Assert.throws( + () => profileStorage.addresses.changeGUID(guid_u1, guid_u2), + /changeGUID: record with destination id exists already/ + ); + // Try and change a guid that's already been synced. + Assert.throws( + () => profileStorage.addresses.changeGUID(guid_synced, newguid()), + /changeGUID: existing record has already been synced/ + ); + + // Change an item to itself makes no sense. + Assert.throws( + () => profileStorage.addresses.changeGUID(guid_u1, guid_u1), + /changeGUID: old and new IDs are the same/ + ); + + // and one that works. + equal( + (await profileStorage.addresses.getAll({ includeDeleted: true })).length, + 3 + ); + let targetguid = newguid(); + profileStorage.addresses.changeGUID(guid_u1, targetguid); + equal( + (await profileStorage.addresses.getAll({ includeDeleted: true })).length, + 3 + ); + + ok( + await profileStorage.addresses.get(guid_synced), + "synced item still exists." + ); + ok( + await profileStorage.addresses.get(guid_u2), + "guid we didn't touch still exists." + ); + ok(await profileStorage.addresses.get(targetguid), "target guid exists."); + ok( + !(await profileStorage.addresses.get(guid_u1)), + "old guid no longer exists." + ); +}); + +add_task(async function test_findDuplicateGUID() { + let profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME, [ + TEST_ADDRESS_1, + ]); + + let [record] = await profileStorage.addresses.getAll({ rawData: true }); + await Assert.rejects( + profileStorage.addresses.findDuplicateGUID(record), + /Record \w+ already exists/, + "Should throw if the GUID already exists" + ); + + // Add a malformed record, passing `sourceSync` to work around the record + // normalization logic that would prevent this. + let timeLastModified = Date.now(); + let timeCreated = timeLastModified - 60 * 1000; + + await profileStorage.addresses.add( + { + guid: profileStorage.addresses._generateGUID(), + version: 1, + timeCreated, + timeLastModified, + }, + { sourceSync: true } + ); + + strictEqual( + await profileStorage.addresses.findDuplicateGUID({ + guid: profileStorage.addresses._generateGUID(), + version: 1, + timeCreated, + timeLastModified, + }), + null, + "Should ignore internal fields and malformed records" + ); +}); + +add_task(async function test_reset() { + let profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME, [ + TEST_ADDRESS_1, + TEST_ADDRESS_2, + ]); + + let addresses = await profileStorage.addresses.getAll(); + // All should start without sync metadata + for (let { guid } of addresses) { + let changeCounter = getSyncChangeCounter(profileStorage.addresses, guid); + equal(changeCounter, -1); + } + // pullSyncChanges should create the metadata. + profileStorage.addresses.pullSyncChanges(); + addresses = await profileStorage.addresses.getAll(); + for (let { guid } of addresses) { + let changeCounter = getSyncChangeCounter(profileStorage.addresses, guid); + equal(changeCounter, 1); + } + // and resetSync should wipe it. + profileStorage.addresses.resetSync(); + addresses = await profileStorage.addresses.getAll(); + for (let { guid } of addresses) { + let changeCounter = getSyncChangeCounter(profileStorage.addresses, guid); + equal(changeCounter, -1); + } +}); diff --git a/browser/extensions/formautofill/test/unit/test_storage_tombstones.js b/browser/extensions/formautofill/test/unit/test_storage_tombstones.js new file mode 100644 index 0000000000..b613c4f638 --- /dev/null +++ b/browser/extensions/formautofill/test/unit/test_storage_tombstones.js @@ -0,0 +1,191 @@ +/** + * Tests tombstones in address/creditcard records. + */ + +"use strict"; + +let FormAutofillStorage; +add_task(async function setup() { + ({ FormAutofillStorage } = ChromeUtils.import( + "resource://formautofill/FormAutofillStorage.jsm", + null + )); +}); + +const TEST_STORE_FILE_NAME = "test-tombstones.json"; + +const TEST_ADDRESS_1 = { + "given-name": "Timothy", + "additional-name": "John", + "family-name": "Berners-Lee", + organization: "World Wide Web Consortium", + "street-address": "32 Vassar Street\nMIT Room 32-G524", + "address-level2": "Cambridge", + "address-level1": "MA", + "postal-code": "02139", + country: "US", + tel: "+1 617 253 5702", + email: "timbl@w3.org", +}; + +const TEST_CC_1 = { + "cc-name": "John Doe", + "cc-number": "4111111111111111", + "cc-exp-month": 4, + "cc-exp-year": 2017, +}; + +let do_check_tombstone_record = profile => { + Assert.ok(profile.deleted); + Assert.deepEqual( + Object.keys(profile).sort(), + ["guid", "timeLastModified", "deleted"].sort() + ); +}; + +// Like add_task, but actually adds 2 - one for addresses and one for cards. +function add_storage_task(test_function) { + add_task(async function() { + let path = getTempFile(TEST_STORE_FILE_NAME).path; + let profileStorage = new FormAutofillStorage(path); + let testCC1 = Object.assign({}, TEST_CC_1); + await profileStorage.initialize(); + + for (let [storage, record] of [ + [profileStorage.addresses, TEST_ADDRESS_1], + [profileStorage.creditCards, testCC1], + ]) { + await test_function(storage, record); + } + }); +} + +add_storage_task(async function test_simple_tombstone(storage, record) { + info("check simple tombstone semantics"); + + let guid = await storage.add(record); + Assert.equal((await storage.getAll()).length, 1); + + storage.remove(guid); + + // should be unable to get it normally. + Assert.equal(await storage.get(guid), null); + // and getAll should also not return it. + Assert.equal((await storage.getAll()).length, 0); + + // but getAll allows us to access deleted items - but we didn't create + // a tombstone here, so even that will not get it. + let all = await storage.getAll({ includeDeleted: true }); + Assert.equal(all.length, 0); +}); + +add_storage_task(async function test_simple_synctombstone(storage, record) { + info("check simple tombstone semantics for synced records"); + + let guid = await storage.add(record); + Assert.equal((await storage.getAll()).length, 1); + + storage.pullSyncChanges(); // force sync metadata, which triggers tombstone behaviour. + + storage.remove(guid); + + // should be unable to get it normally. + Assert.equal(await storage.get(guid), null); + // and getAll should also not return it. + Assert.equal((await storage.getAll()).length, 0); + + // but getAll allows us to access deleted items. + let all = await storage.getAll({ includeDeleted: true }); + Assert.equal(all.length, 1); + + do_check_tombstone_record(all[0]); + + // a tombstone got from API should look exactly the same as it got from the + // disk (besides "_sync"). + let tombstoneInDisk = Object.assign( + {}, + storage._store.data[storage._collectionName][0] + ); + delete tombstoneInDisk._sync; + do_check_tombstone_record(tombstoneInDisk); +}); + +add_storage_task(async function test_add_tombstone(storage, record) { + info("Should be able to add a new tombstone"); + let guid = await storage.add({ guid: "test-guid-1", deleted: true }); + + // should be unable to get it normally. + Assert.equal(await storage.get(guid), null); + // and getAll should also not return it. + Assert.equal((await storage.getAll()).length, 0); + + // but getAll allows us to access deleted items. + let all = await storage.getAll({ rawData: true, includeDeleted: true }); + Assert.equal(all.length, 1); + + do_check_tombstone_record(all[0]); + + // a tombstone got from API should look exactly the same as it got from the + // disk (besides "_sync"). + let tombstoneInDisk = Object.assign( + {}, + storage._store.data[storage._collectionName][0] + ); + delete tombstoneInDisk._sync; + do_check_tombstone_record(tombstoneInDisk); +}); + +add_storage_task(async function test_add_tombstone_without_guid( + storage, + record +) { + info("Should not be able to add a new tombstone without specifying the guid"); + await Assert.rejects(storage.add({ deleted: true }), /Record missing GUID/); + Assert.equal((await storage.getAll({ includeDeleted: true })).length, 0); +}); + +add_storage_task(async function test_add_tombstone_existing_guid( + storage, + record +) { + info( + "Should not be able to add a new tombstone when a record with that ID exists" + ); + let guid = await storage.add(record); + await Assert.rejects( + storage.add({ guid, deleted: true }), + /a record with this GUID already exists/ + ); + + // same if the existing item is already a tombstone. + await storage.add({ guid: "test-guid-1", deleted: true }); + await Assert.rejects( + storage.add({ guid: "test-guid-1", deleted: true }), + /a record with this GUID already exists/ + ); +}); + +add_storage_task(async function test_update_tombstone(storage, record) { + info("Updating a tombstone should fail"); + let guid = await storage.add({ guid: "test-guid-1", deleted: true }); + await Assert.rejects(storage.update(guid, {}), /No matching record./); +}); + +add_storage_task(async function test_remove_existing_tombstone( + storage, + record +) { + info("Removing a record that's already a tombstone should be a no-op"); + let guid = await storage.add({ + guid: "test-guid-1", + deleted: true, + timeLastModified: 1234, + }); + + storage.remove(guid); + let all = await storage.getAll({ rawData: true, includeDeleted: true }); + Assert.equal(all.length, 1); + + do_check_tombstone_record(all[0]); + equal(all[0].timeLastModified, 1234); // should not be updated to now(). +}); diff --git a/browser/extensions/formautofill/test/unit/test_sync.js b/browser/extensions/formautofill/test/unit/test_sync.js new file mode 100644 index 0000000000..537809ea1e --- /dev/null +++ b/browser/extensions/formautofill/test/unit/test_sync.js @@ -0,0 +1,924 @@ +/** + * Tests sync functionality. + */ + +/* import-globals-from ../../../../../services/sync/tests/unit/head_appinfo.js */ +/* import-globals-from ../../../../../services/common/tests/unit/head_helpers.js */ +/* import-globals-from ../../../../../services/sync/tests/unit/head_helpers.js */ +/* import-globals-from ../../../../../services/sync/tests/unit/head_http_server.js */ + +"use strict"; + +const { Service } = ChromeUtils.import("resource://services-sync/service.js"); +const { SCORE_INCREMENT_XLARGE } = ChromeUtils.import( + "resource://services-sync/constants.js" +); + +let sanitizeStorageObject, AutofillRecord, AddressesEngine; +add_task(async function() { + ({ + sanitizeStorageObject, + AutofillRecord, + AddressesEngine, + } = ChromeUtils.import("resource://formautofill/FormAutofillSync.jsm", null)); +}); + +Services.prefs.setCharPref("extensions.formautofill.loglevel", "Trace"); +initTestLogging("Trace"); + +const TEST_STORE_FILE_NAME = "test-profile.json"; + +const TEST_PROFILE_1 = { + "given-name": "Timothy", + "additional-name": "John", + "family-name": "Berners-Lee", + organization: "World Wide Web Consortium", + "street-address": "32 Vassar Street\nMIT Room 32-G524", + "address-level2": "Cambridge", + "address-level1": "MA", + "postal-code": "02139", + country: "US", + tel: "+16172535702", + email: "timbl@w3.org", +}; + +const TEST_PROFILE_2 = { + "street-address": "Some Address", + country: "US", +}; + +async function expectLocalProfiles(profileStorage, expected) { + let profiles = await profileStorage.addresses.getAll({ + rawData: true, + includeDeleted: true, + }); + expected.sort((a, b) => a.guid.localeCompare(b.guid)); + profiles.sort((a, b) => a.guid.localeCompare(b.guid)); + try { + deepEqual( + profiles.map(p => p.guid), + expected.map(p => p.guid) + ); + for (let i = 0; i < expected.length; i++) { + let thisExpected = expected[i]; + let thisGot = profiles[i]; + // always check "deleted". + equal(thisExpected.deleted, thisGot.deleted); + ok(objectMatches(thisGot, thisExpected)); + } + } catch (ex) { + info("Comparing expected profiles:"); + info(JSON.stringify(expected, undefined, 2)); + info("against actual profiles:"); + info(JSON.stringify(profiles, undefined, 2)); + throw ex; + } +} + +async function setup() { + let profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME); + // should always start with no profiles. + Assert.equal( + (await profileStorage.addresses.getAll({ includeDeleted: true })).length, + 0 + ); + + Services.prefs.setCharPref( + "services.sync.log.logger.engine.addresses", + "Trace" + ); + let engine = new AddressesEngine(Service); + await engine.initialize(); + // Avoid accidental automatic sync due to our own changes + Service.scheduler.syncThreshold = 10000000; + let syncID = await engine.resetLocalSyncID(); + let server = serverForUsers( + { foo: "password" }, + { + meta: { + global: { engines: { addresses: { version: engine.version, syncID } } }, + }, + addresses: {}, + } + ); + + Service.engineManager._engines.addresses = engine; + engine.enabled = true; + engine._store._storage = profileStorage.addresses; + + generateNewKeys(Service.collectionKeys); + + await SyncTestingInfrastructure(server); + + let collection = server.user("foo").collection("addresses"); + + return { profileStorage, server, collection, engine }; +} + +async function cleanup(server) { + let promiseStartOver = promiseOneObserver("weave:service:start-over:finish"); + await Service.startOver(); + await promiseStartOver; + await promiseStopServer(server); +} + +add_task(async function test_log_sanitization() { + let sanitized = sanitizeStorageObject(TEST_PROFILE_1); + // all strings have been mangled. + for (let key of Object.keys(TEST_PROFILE_1)) { + let val = TEST_PROFILE_1[key]; + if (typeof val == "string") { + notEqual(sanitized[key], val); + } + } + // And check that stringifying a sync record is sanitized. + let record = new AutofillRecord("collection", "some-id"); + record.entry = TEST_PROFILE_1; + let serialized = record.toString(); + // None of the string values should appear in the output. + for (let key of Object.keys(TEST_PROFILE_1)) { + let val = TEST_PROFILE_1[key]; + if (typeof val == "string") { + ok(!serialized.includes(val), `"${val}" shouldn't be in: ${serialized}`); + } + } +}); + +add_task(async function test_outgoing() { + let { profileStorage, server, collection, engine } = await setup(); + try { + equal(engine._tracker.score, 0); + let existingGUID = await profileStorage.addresses.add(TEST_PROFILE_1); + // And a deleted item. + let deletedGUID = profileStorage.addresses._generateGUID(); + await profileStorage.addresses.add({ guid: deletedGUID, deleted: true }); + + await expectLocalProfiles(profileStorage, [ + { + guid: existingGUID, + }, + { + guid: deletedGUID, + deleted: true, + }, + ]); + + await engine._tracker.asyncObserver.promiseObserversComplete(); + // The tracker should have a score recorded for the 2 additions we had. + equal(engine._tracker.score, SCORE_INCREMENT_XLARGE * 2); + + await engine.setLastSync(0); + await engine.sync(); + + Assert.equal(collection.count(), 2); + Assert.ok(collection.wbo(existingGUID)); + Assert.ok(collection.wbo(deletedGUID)); + + await expectLocalProfiles(profileStorage, [ + { + guid: existingGUID, + }, + { + guid: deletedGUID, + deleted: true, + }, + ]); + + strictEqual( + getSyncChangeCounter(profileStorage.addresses, existingGUID), + 0 + ); + strictEqual(getSyncChangeCounter(profileStorage.addresses, deletedGUID), 0); + } finally { + await cleanup(server); + } +}); + +add_task(async function test_incoming_new() { + let { profileStorage, server, engine } = await setup(); + try { + let profileID = Utils.makeGUID(); + let deletedID = Utils.makeGUID(); + + server.insertWBO( + "foo", + "addresses", + new ServerWBO( + profileID, + encryptPayload({ + id: profileID, + entry: Object.assign( + { + version: 1, + }, + TEST_PROFILE_1 + ), + }), + Date.now() / 1000 + ) + ); + server.insertWBO( + "foo", + "addresses", + new ServerWBO( + deletedID, + encryptPayload({ + id: deletedID, + deleted: true, + }), + Date.now() / 1000 + ) + ); + + // The tracker should start with no score. + equal(engine._tracker.score, 0); + + await engine.setLastSync(0); + await engine.sync(); + + await expectLocalProfiles(profileStorage, [ + { + guid: profileID, + }, + { + guid: deletedID, + deleted: true, + }, + ]); + + strictEqual(getSyncChangeCounter(profileStorage.addresses, profileID), 0); + strictEqual(getSyncChangeCounter(profileStorage.addresses, deletedID), 0); + + // The sync applied new records - ensure our tracker knew it came from + // sync and didn't bump the score. + equal(engine._tracker.score, 0); + } finally { + await cleanup(server); + } +}); + +add_task(async function test_incoming_existing() { + let { profileStorage, server, engine } = await setup(); + try { + let guid1 = await profileStorage.addresses.add(TEST_PROFILE_1); + let guid2 = await profileStorage.addresses.add(TEST_PROFILE_2); + + // an initial sync so we don't think they are locally modified. + await engine.setLastSync(0); + await engine.sync(); + + // now server records that modify the existing items. + let modifiedEntry1 = Object.assign({}, TEST_PROFILE_1, { + version: 1, + "given-name": "NewName", + }); + + let lastSync = await engine.getLastSync(); + server.insertWBO( + "foo", + "addresses", + new ServerWBO( + guid1, + encryptPayload({ + id: guid1, + entry: modifiedEntry1, + }), + lastSync + 10 + ) + ); + server.insertWBO( + "foo", + "addresses", + new ServerWBO( + guid2, + encryptPayload({ + id: guid2, + deleted: true, + }), + lastSync + 10 + ) + ); + + await engine.sync(); + + await expectLocalProfiles(profileStorage, [ + Object.assign({}, modifiedEntry1, { guid: guid1 }), + { guid: guid2, deleted: true }, + ]); + } finally { + await cleanup(server); + } +}); + +add_task(async function test_tombstones() { + let { profileStorage, server, collection, engine } = await setup(); + try { + let existingGUID = await profileStorage.addresses.add(TEST_PROFILE_1); + + await engine.setLastSync(0); + await engine.sync(); + + Assert.equal(collection.count(), 1); + let payload = collection.payloads()[0]; + equal(payload.id, existingGUID); + equal(payload.deleted, undefined); + + profileStorage.addresses.remove(existingGUID); + await engine.sync(); + + // should still exist, but now be a tombstone. + Assert.equal(collection.count(), 1); + payload = collection.payloads()[0]; + equal(payload.id, existingGUID); + equal(payload.deleted, true); + } finally { + await cleanup(server); + } +}); + +add_task(async function test_applyIncoming_both_deleted() { + let { profileStorage, server, engine } = await setup(); + try { + let guid = await profileStorage.addresses.add(TEST_PROFILE_1); + + await engine.setLastSync(0); + await engine.sync(); + + // Delete synced record locally. + profileStorage.addresses.remove(guid); + + // Delete same record remotely. + let lastSync = await engine.getLastSync(); + let collection = server.user("foo").collection("addresses"); + collection.insert( + guid, + encryptPayload({ + id: guid, + deleted: true, + }), + lastSync + 10 + ); + + await engine.sync(); + + ok( + !(await await profileStorage.addresses.get(guid)), + "Should not return record for locally deleted item" + ); + + let localRecords = await profileStorage.addresses.getAll({ + includeDeleted: true, + }); + equal(localRecords.length, 1, "Only tombstone should exist locally"); + + equal(collection.count(), 1, "Only tombstone should exist on server"); + } finally { + await cleanup(server); + } +}); + +add_task(async function test_applyIncoming_nonexistent_tombstone() { + let { profileStorage, server, engine } = await setup(); + try { + let guid = profileStorage.addresses._generateGUID(); + let collection = server.user("foo").collection("addresses"); + collection.insert( + guid, + encryptPayload({ + id: guid, + deleted: true, + }), + Date.now() / 1000 + ); + + await engine.setLastSync(0); + await engine.sync(); + + ok( + !(await profileStorage.addresses.get(guid)), + "Should not return record for uknown deleted item" + ); + let localTombstone = ( + await profileStorage.addresses.getAll({ + includeDeleted: true, + }) + ).find(record => record.guid == guid); + ok(localTombstone, "Should store tombstone for unknown item"); + } finally { + await cleanup(server); + } +}); + +add_task(async function test_applyIncoming_incoming_deleted() { + let { profileStorage, server, engine } = await setup(); + try { + let guid = await profileStorage.addresses.add(TEST_PROFILE_1); + + await engine.setLastSync(0); + await engine.sync(); + + // Delete the record remotely. + let lastSync = await engine.getLastSync(); + let collection = server.user("foo").collection("addresses"); + collection.insert( + guid, + encryptPayload({ + id: guid, + deleted: true, + }), + lastSync + 10 + ); + + await engine.sync(); + + ok( + !(await profileStorage.addresses.get(guid)), + "Should delete unmodified item locally" + ); + + let localTombstone = ( + await profileStorage.addresses.getAll({ + includeDeleted: true, + }) + ).find(record => record.guid == guid); + ok(localTombstone, "Should keep local tombstone for remotely deleted item"); + strictEqual( + getSyncChangeCounter(profileStorage.addresses, guid), + 0, + "Local tombstone should be marked as syncing" + ); + } finally { + await cleanup(server); + } +}); + +add_task(async function test_applyIncoming_incoming_restored() { + let { profileStorage, server, engine } = await setup(); + try { + let guid = await profileStorage.addresses.add(TEST_PROFILE_1); + + // Upload the record to the server. + await engine.setLastSync(0); + await engine.sync(); + + // Removing a synced record should write a tombstone. + profileStorage.addresses.remove(guid); + + // Modify the deleted record remotely. + let collection = server.user("foo").collection("addresses"); + let serverPayload = JSON.parse( + JSON.parse(collection.payload(guid)).ciphertext + ); + serverPayload.entry["street-address"] = "I moved!"; + let lastSync = await engine.getLastSync(); + collection.insert(guid, encryptPayload(serverPayload), lastSync + 10); + + // Sync again. + await engine.sync(); + + // We should replace our tombstone with the server's version. + let localRecord = await profileStorage.addresses.get(guid); + ok( + objectMatches(localRecord, { + "given-name": "Timothy", + "family-name": "Berners-Lee", + "street-address": "I moved!", + }) + ); + + let maybeNewServerPayload = JSON.parse( + JSON.parse(collection.payload(guid)).ciphertext + ); + deepEqual( + maybeNewServerPayload, + serverPayload, + "Should not change record on server" + ); + } finally { + await cleanup(server); + } +}); + +add_task(async function test_applyIncoming_outgoing_restored() { + let { profileStorage, server, engine } = await setup(); + try { + let guid = await profileStorage.addresses.add(TEST_PROFILE_1); + + // Upload the record to the server. + await engine.setLastSync(0); + await engine.sync(); + + // Modify the local record. + let localCopy = Object.assign({}, TEST_PROFILE_1); + localCopy["street-address"] = "I moved!"; + await profileStorage.addresses.update(guid, localCopy); + + // Replace the record with a tombstone on the server. + let lastSync = await engine.getLastSync(); + let collection = server.user("foo").collection("addresses"); + collection.insert( + guid, + encryptPayload({ + id: guid, + deleted: true, + }), + lastSync + 10 + ); + + // Sync again. + await engine.sync(); + + // We should resurrect the record on the server. + let serverPayload = JSON.parse( + JSON.parse(collection.payload(guid)).ciphertext + ); + ok(!serverPayload.deleted, "Should resurrect record on server"); + ok( + objectMatches(serverPayload.entry, { + "given-name": "Timothy", + "family-name": "Berners-Lee", + "street-address": "I moved!", + }) + ); + + let localRecord = await profileStorage.addresses.get(guid); + ok(localRecord, "Modified record should not be deleted locally"); + } finally { + await cleanup(server); + } +}); + +// Unlike most sync engines, we want "both modified" to inspect the records, +// and if materially different, create a duplicate. +add_task(async function test_reconcile_both_modified_identical() { + let { profileStorage, server, engine } = await setup(); + try { + // create a record locally. + let guid = await profileStorage.addresses.add(TEST_PROFILE_1); + + // and an identical record on the server. + server.insertWBO( + "foo", + "addresses", + new ServerWBO( + guid, + encryptPayload({ + id: guid, + entry: TEST_PROFILE_1, + }), + Date.now() / 1000 + ) + ); + + await engine.setLastSync(0); + await engine.sync(); + + await expectLocalProfiles(profileStorage, [{ guid }]); + } finally { + await cleanup(server); + } +}); + +add_task(async function test_incoming_dupes() { + let { profileStorage, server, engine } = await setup(); + try { + // Create a profile locally, then sync to upload the new profile to the + // server. + let guid1 = await profileStorage.addresses.add(TEST_PROFILE_1); + + await engine.setLastSync(0); + await engine.sync(); + + // Create another profile locally, but don't sync it yet. + await profileStorage.addresses.add(TEST_PROFILE_2); + + // Now create two records on the server with the same contents as our local + // profiles, but different GUIDs. + let lastSync = await engine.getLastSync(); + let guid1_dupe = Utils.makeGUID(); + server.insertWBO( + "foo", + "addresses", + new ServerWBO( + guid1_dupe, + encryptPayload({ + id: guid1_dupe, + entry: Object.assign( + { + version: 1, + }, + TEST_PROFILE_1 + ), + }), + lastSync + 10 + ) + ); + let guid2_dupe = Utils.makeGUID(); + server.insertWBO( + "foo", + "addresses", + new ServerWBO( + guid2_dupe, + encryptPayload({ + id: guid2_dupe, + entry: Object.assign( + { + version: 1, + }, + TEST_PROFILE_2 + ), + }), + lastSync + 10 + ) + ); + + // Sync again. We should download `guid1_dupe` and `guid2_dupe`, then + // reconcile changes. + await engine.sync(); + + await expectLocalProfiles(profileStorage, [ + // We uploaded `guid1` during the first sync. Even though its contents + // are the same as `guid1_dupe`, we keep both. + Object.assign({}, TEST_PROFILE_1, { guid: guid1 }), + Object.assign({}, TEST_PROFILE_1, { guid: guid1_dupe }), + // However, we didn't upload `guid2` before downloading `guid2_dupe`, so + // we *should* dedupe `guid2` to `guid2_dupe`. + Object.assign({}, TEST_PROFILE_2, { guid: guid2_dupe }), + ]); + } finally { + await cleanup(server); + } +}); + +add_task(async function test_dedupe_identical_unsynced() { + let { profileStorage, server, engine } = await setup(); + try { + // create a record locally. + let localGuid = await profileStorage.addresses.add(TEST_PROFILE_1); + + // and an identical record on the server but different GUID. + let remoteGuid = Utils.makeGUID(); + notEqual(localGuid, remoteGuid); + server.insertWBO( + "foo", + "addresses", + new ServerWBO( + remoteGuid, + encryptPayload({ + id: remoteGuid, + entry: Object.assign( + { + version: 1, + }, + TEST_PROFILE_1 + ), + }), + Date.now() / 1000 + ) + ); + + await engine.setLastSync(0); + await engine.sync(); + + // Should have 1 item locally with GUID changed to the remote one. + // There's no tombstone as the original was unsynced. + await expectLocalProfiles(profileStorage, [ + { + guid: remoteGuid, + }, + ]); + } finally { + await cleanup(server); + } +}); + +add_task(async function test_dedupe_identical_synced() { + let { profileStorage, server, engine } = await setup(); + try { + // create a record locally. + let localGuid = await profileStorage.addresses.add(TEST_PROFILE_1); + + // sync it - it will no longer be a candidate for de-duping. + await engine.setLastSync(0); + await engine.sync(); + + // and an identical record on the server but different GUID. + let lastSync = await engine.getLastSync(); + let remoteGuid = Utils.makeGUID(); + server.insertWBO( + "foo", + "addresses", + new ServerWBO( + remoteGuid, + encryptPayload({ + id: remoteGuid, + entry: Object.assign( + { + version: 1, + }, + TEST_PROFILE_1 + ), + }), + lastSync + 10 + ) + ); + + await engine.sync(); + + // Should have 2 items locally, since the first was synced. + await expectLocalProfiles(profileStorage, [ + { guid: localGuid }, + { guid: remoteGuid }, + ]); + } finally { + await cleanup(server); + } +}); + +add_task(async function test_dedupe_multiple_candidates() { + let { profileStorage, server, engine } = await setup(); + try { + // It's possible to have duplicate local profiles, with the same fields but + // different GUIDs. After a node reassignment, or after disconnecting and + // reconnecting to Sync, we might dedupe a local record A to a remote record + // B, if we see B before we download and apply A. Since A and B are dupes, + // that's OK. We'll write a tombstone for A when we dedupe A to B, and + // overwrite that tombstone when we see A. + + let localRecord = { + "given-name": "Mark", + "family-name": "Hammond", + organization: "Mozilla", + country: "AU", + tel: "+12345678910", + }; + let serverRecord = Object.assign( + { + version: 1, + }, + localRecord + ); + + // We don't pass `sourceSync` so that the records are marked as NEW. + let aGuid = await profileStorage.addresses.add(localRecord); + let bGuid = await profileStorage.addresses.add(localRecord); + + // Insert B before A. + server.insertWBO( + "foo", + "addresses", + new ServerWBO( + bGuid, + encryptPayload({ + id: bGuid, + entry: serverRecord, + }), + Date.now() / 1000 + ) + ); + server.insertWBO( + "foo", + "addresses", + new ServerWBO( + aGuid, + encryptPayload({ + id: aGuid, + entry: serverRecord, + }), + Date.now() / 1000 + ) + ); + + await engine.setLastSync(0); + await engine.sync(); + + await expectLocalProfiles(profileStorage, [ + { + guid: aGuid, + "given-name": "Mark", + "family-name": "Hammond", + organization: "Mozilla", + country: "AU", + tel: "+12345678910", + }, + { + guid: bGuid, + "given-name": "Mark", + "family-name": "Hammond", + organization: "Mozilla", + country: "AU", + tel: "+12345678910", + }, + ]); + // Make sure these are both syncing. + strictEqual( + getSyncChangeCounter(profileStorage.addresses, aGuid), + 0, + "A should be marked as syncing" + ); + strictEqual( + getSyncChangeCounter(profileStorage.addresses, bGuid), + 0, + "B should be marked as syncing" + ); + } finally { + await cleanup(server); + } +}); + +// Unlike most sync engines, we want "both modified" to inspect the records, +// and if materially different, create a duplicate. +add_task(async function test_reconcile_both_modified_conflict() { + let { profileStorage, server, engine } = await setup(); + try { + // create a record locally. + let guid = await profileStorage.addresses.add(TEST_PROFILE_1); + + // Upload the record to the server. + await engine.setLastSync(0); + await engine.sync(); + + strictEqual( + getSyncChangeCounter(profileStorage.addresses, guid), + 0, + "Original record should be marked as syncing" + ); + + // Change the same field locally and on the server. + let localCopy = Object.assign({}, TEST_PROFILE_1); + localCopy["street-address"] = "I moved!"; + await profileStorage.addresses.update(guid, localCopy); + + let lastSync = await engine.getLastSync(); + let collection = server.user("foo").collection("addresses"); + let serverPayload = JSON.parse( + JSON.parse(collection.payload(guid)).ciphertext + ); + serverPayload.entry["street-address"] = "I moved, too!"; + collection.insert(guid, encryptPayload(serverPayload), lastSync + 10); + + // Sync again. + await engine.sync(); + + // Since we wait to pull changes until we're ready to upload, both records + // should now exist on the server; we don't need a follow-up sync. + let serverPayloads = collection.payloads(); + equal(serverPayloads.length, 2, "Both records should exist on server"); + + let forkedPayload = serverPayloads.find(payload => payload.id != guid); + ok(forkedPayload, "Forked record should exist on server"); + + await expectLocalProfiles(profileStorage, [ + { + guid, + "given-name": "Timothy", + "family-name": "Berners-Lee", + "street-address": "I moved, too!", + }, + { + guid: forkedPayload.id, + "given-name": "Timothy", + "family-name": "Berners-Lee", + "street-address": "I moved!", + }, + ]); + + let changeCounter = getSyncChangeCounter( + profileStorage.addresses, + forkedPayload.id + ); + strictEqual(changeCounter, 0, "Forked record should be marked as syncing"); + } finally { + await cleanup(server); + } +}); + +add_task(async function test_wipe() { + let { profileStorage, server, engine } = await setup(); + try { + let guid = await profileStorage.addresses.add(TEST_PROFILE_1); + + await expectLocalProfiles(profileStorage, [{ guid }]); + + let promiseObserved = promiseOneObserver("formautofill-storage-changed"); + + await engine._wipeClient(); + + let { subject, data } = await promiseObserved; + Assert.equal( + subject.wrappedJSObject.sourceSync, + true, + "it should be noted this came from sync" + ); + Assert.equal( + subject.wrappedJSObject.collectionName, + "addresses", + "got the correct collection" + ); + Assert.equal(data, "removeAll", "a removeAll should be noted"); + + await expectLocalProfiles(profileStorage, []); + } finally { + await cleanup(server); + } +}); diff --git a/browser/extensions/formautofill/test/unit/test_toOneLineAddress.js b/browser/extensions/formautofill/test/unit/test_toOneLineAddress.js new file mode 100644 index 0000000000..e6190bb8ef --- /dev/null +++ b/browser/extensions/formautofill/test/unit/test_toOneLineAddress.js @@ -0,0 +1,64 @@ +"use strict"; + +var FormAutofillUtils; +add_task(async function setup() { + ({ FormAutofillUtils } = ChromeUtils.import( + "resource://formautofill/FormAutofillUtils.jsm" + )); +}); + +add_task(async function test_getCategoriesFromFieldNames() { + const TEST_CASES = [ + { + strings: ["A", "B", "C", "D"], + expectedValue: "A B C D", + }, + { + strings: ["A", "B", "", "D"], + expectedValue: "A B D", + }, + { + strings: ["", "B", "", "D"], + expectedValue: "B D", + }, + { + strings: [null, "B", " ", "D"], + expectedValue: "B D", + }, + { + strings: "A B C", + expectedValue: "A B C", + }, + { + strings: "A\nB\n\n\nC", + expectedValue: "A B C", + }, + { + strings: "A B \nC", + expectedValue: "A B C", + }, + { + strings: "A-B-C", + expectedValue: "A B C", + delimiter: "-", + }, + { + strings: "A B\n \nC", + expectedValue: "A B C", + }, + { + strings: null, + expectedValue: "", + }, + ]; + + for (let tc of TEST_CASES) { + let result; + if (tc.delimiter) { + result = FormAutofillUtils.toOneLineAddress(tc.strings, tc.delimiter); + } else { + result = FormAutofillUtils.toOneLineAddress(tc.strings); + } + Assert.equal(result, tc.expectedValue); + } +}); diff --git a/browser/extensions/formautofill/test/unit/test_transformFields.js b/browser/extensions/formautofill/test/unit/test_transformFields.js new file mode 100644 index 0000000000..45cd605d47 --- /dev/null +++ b/browser/extensions/formautofill/test/unit/test_transformFields.js @@ -0,0 +1,973 @@ +/** + * Tests the transform algorithm in profileStorage. + */ + +"use strict"; + +let FormAutofillStorage; +add_task(async function setup() { + ({ FormAutofillStorage } = ChromeUtils.import( + "resource://formautofill/FormAutofillStorage.jsm", + null + )); +}); + +const TEST_STORE_FILE_NAME = "test-profile.json"; + +const ADDRESS_COMPUTE_TESTCASES = [ + // Name + { + description: "Has split names", + address: { + "given-name": "Timothy", + "additional-name": "John", + "family-name": "Berners-Lee", + }, + expectedResult: { + "given-name": "Timothy", + "additional-name": "John", + "family-name": "Berners-Lee", + name: "Timothy John Berners-Lee", + }, + }, + { + description: "Has split CJK names", + address: { + "given-name": "德明", + "family-name": "孫", + }, + expectedResult: { + "given-name": "德明", + "family-name": "孫", + name: "孫德明", + }, + }, + + // Address + { + description: '"street-address" with single line', + address: { + "street-address": "single line", + }, + expectedResult: { + "street-address": "single line", + "address-line1": "single line", + }, + }, + { + description: '"street-address" with multiple lines', + address: { + "street-address": "line1\nline2\nline3", + }, + expectedResult: { + "street-address": "line1\nline2\nline3", + "address-line1": "line1", + "address-line2": "line2", + "address-line3": "line3", + }, + }, + { + description: '"street-address" with multiple lines but line2 is omitted', + address: { + "street-address": "line1\n\nline3", + }, + expectedResult: { + "street-address": "line1\n\nline3", + "address-line1": "line1", + "address-line2": undefined, + "address-line3": "line3", + }, + }, + { + description: '"street-address" with 4 lines', + address: { + "street-address": "line1\nline2\nline3\nline4", + }, + expectedResult: { + "street-address": "line1\nline2\nline3\nline4", + "address-line1": "line1", + "address-line2": "line2", + "address-line3": "line3 line4", + }, + }, + { + description: '"street-address" with blank lines', + address: { + "street-address": "line1\n \nline3\n \nline5", + }, + expectedResult: { + "street-address": "line1\n \nline3\n \nline5", + "address-line1": "line1", + "address-line2": undefined, + "address-line3": "line3 line5", + }, + }, + + // Country + { + description: 'Has "country"', + address: { + country: "US", + }, + expectedResult: { + country: "US", + "country-name": "United States", + }, + }, + + // Tel + { + description: '"tel" with US country code', + address: { + tel: "+16172535702", + }, + expectedResult: { + tel: "+16172535702", + "tel-country-code": "+1", + "tel-national": "6172535702", + "tel-area-code": "617", + "tel-local": "2535702", + "tel-local-prefix": "253", + "tel-local-suffix": "5702", + }, + }, + { + description: '"tel" with TW country code (the components won\'t be parsed)', + address: { + tel: "+886212345678", + }, + expectedResult: { + tel: "+886212345678", + "tel-country-code": "+886", + "tel-national": "0212345678", + "tel-area-code": undefined, + "tel-local": undefined, + "tel-local-prefix": undefined, + "tel-local-suffix": undefined, + }, + }, + { + description: '"tel" without country code so use "US" as default resion', + address: { + tel: "6172535702", + }, + expectedResult: { + tel: "+16172535702", + "tel-country-code": "+1", + "tel-national": "6172535702", + "tel-area-code": "617", + "tel-local": "2535702", + "tel-local-prefix": "253", + "tel-local-suffix": "5702", + }, + }, + { + description: '"tel" without country code but "country" is "TW"', + address: { + tel: "0212345678", + country: "TW", + }, + expectedResult: { + tel: "+886212345678", + "tel-country-code": "+886", + "tel-national": "0212345678", + "tel-area-code": undefined, + "tel-local": undefined, + "tel-local-prefix": undefined, + "tel-local-suffix": undefined, + }, + }, + { + description: '"tel" can\'t be parsed so leave it as-is', + address: { + tel: "12345", + }, + expectedResult: { + tel: "12345", + "tel-country-code": undefined, + "tel-national": "12345", + "tel-area-code": undefined, + "tel-local": undefined, + "tel-local-prefix": undefined, + "tel-local-suffix": undefined, + }, + }, +]; + +const ADDRESS_NORMALIZE_TESTCASES = [ + // Name + { + description: 'Has "name", and the split names are omitted', + address: { + name: "Timothy John Berners-Lee", + }, + expectedResult: { + "given-name": "Timothy", + "additional-name": "John", + "family-name": "Berners-Lee", + }, + }, + { + description: 'Has both "name" and split names', + address: { + name: "John Doe", + "given-name": "Timothy", + "additional-name": "John", + "family-name": "Berners-Lee", + }, + expectedResult: { + "given-name": "Timothy", + "additional-name": "John", + "family-name": "Berners-Lee", + }, + }, + { + description: 'Has "name", and some of split names are omitted', + address: { + name: "John Doe", + "given-name": "Timothy", + }, + expectedResult: { + "given-name": "Timothy", + "family-name": "Doe", + }, + }, + + // Address + { + description: 'Has "address-line1~3" and "street-address" is omitted', + address: { + "address-line1": "line1", + "address-line2": "line2", + "address-line3": "line3", + }, + expectedResult: { + "street-address": "line1\nline2\nline3", + }, + }, + { + description: 'Has both "address-line1~3" and "street-address"', + address: { + "street-address": "street address", + "address-line1": "line1", + "address-line2": "line2", + "address-line3": "line3", + }, + expectedResult: { + "street-address": "street address", + }, + }, + { + description: 'Has "address-line2~3" and single-line "street-address"', + address: { + "street-address": "street address", + "address-line2": "line2", + "address-line3": "line3", + }, + expectedResult: { + "street-address": "street address\nline2\nline3", + }, + }, + { + description: 'Has "address-line2~3" and multiple-line "street-address"', + address: { + "street-address": "street address\nstreet address line 2", + "address-line2": "line2", + "address-line3": "line3", + }, + expectedResult: { + "street-address": "street address\nstreet address line 2", + }, + }, + { + description: 'Has only "address-line1~2"', + address: { + "address-line1": "line1", + "address-line2": "line2", + }, + expectedResult: { + "street-address": "line1\nline2", + }, + }, + { + description: 'Has only "address-line1"', + address: { + "address-line1": "line1", + }, + expectedResult: { + "street-address": "line1", + }, + }, + { + description: 'Has only "address-line2~3"', + address: { + "address-line2": "line2", + "address-line3": "line3", + }, + expectedResult: { + "street-address": "\nline2\nline3", + }, + }, + { + description: 'Has only "address-line2"', + address: { + "address-line2": "line2", + }, + expectedResult: { + "street-address": "\nline2", + }, + }, + + // Country + { + description: 'Has "country" in lowercase', + address: { + country: "us", + }, + expectedResult: { + country: "US", + }, + }, + { + description: 'Has unknown "country"', + address: { + "given-name": "John", // Make sure it won't be an empty record. + country: "AA", + }, + expectedResult: { + country: undefined, + }, + }, + { + description: 'Has "country-name"', + address: { + "country-name": "united states", + }, + expectedResult: { + country: "US", + "country-name": "United States", + }, + }, + { + description: 'Has alternative "country-name"', + address: { + "country-name": "america", + }, + expectedResult: { + country: "US", + "country-name": "United States", + }, + }, + { + description: 'Has "country-name" as a substring', + address: { + "country-name": "test america test", + }, + expectedResult: { + country: "US", + "country-name": "United States", + }, + }, + { + description: 'Has "country-name" as part of a word', + address: { + "given-name": "John", // Make sure it won't be an empty record. + "country-name": "TRUST", + }, + expectedResult: { + country: undefined, + "country-name": undefined, + }, + }, + { + description: 'Has unknown "country-name"', + address: { + "given-name": "John", // Make sure it won't be an empty record. + "country-name": "unknown country name", + }, + expectedResult: { + country: undefined, + "country-name": undefined, + }, + }, + { + description: 'Has "country" and unknown "country-name"', + address: { + country: "us", + "country-name": "unknown country name", + }, + expectedResult: { + country: "US", + "country-name": "United States", + }, + }, + { + description: 'Has "country-name" and unknown "country"', + address: { + "given-name": "John", // Make sure it won't be an empty record. + country: "AA", + "country-name": "united states", + }, + expectedResult: { + country: undefined, + "country-name": undefined, + }, + }, + { + description: 'Has unsupported "country"', + address: { + "given-name": "John", // Make sure it won't be an empty record. + country: "XX", + }, + expectedResult: { + country: undefined, + "country-name": undefined, + }, + }, + + // Tel + { + description: 'Has "tel" with country code', + address: { + tel: "+16172535702", + }, + expectedResult: { + tel: "+16172535702", + }, + }, + { + description: 'Has "tel" without country code but "country" is set', + address: { + tel: "0212345678", + country: "TW", + }, + expectedResult: { + tel: "+886212345678", + }, + }, + { + description: + 'Has "tel" without country code and "country" so use "US" as default region', + address: { + tel: "6172535702", + }, + expectedResult: { + tel: "+16172535702", + }, + }, + { + description: '"tel" can\'t be parsed so leave it as-is', + address: { + tel: "12345", + }, + expectedResult: { + tel: "12345", + }, + }, + { + description: 'Has a valid tel-local format "tel"', + address: { + tel: "1234567", + }, + expectedResult: { + tel: "1234567", + }, + }, + { + description: 'Has "tel-national" and "tel-country-code"', + address: { + "tel-national": "0212345678", + "tel-country-code": "+886", + }, + expectedResult: { + tel: "+886212345678", + }, + }, + { + description: 'Has "tel-national" and "country"', + address: { + "tel-national": "0212345678", + country: "TW", + }, + expectedResult: { + tel: "+886212345678", + }, + }, + { + description: 'Has "tel-national", "tel-country-code" and "country"', + address: { + "tel-national": "0212345678", + "tel-country-code": "+886", + country: "US", + }, + expectedResult: { + tel: "+886212345678", + }, + }, + { + description: 'Has "tel-area-code" and "tel-local"', + address: { + "tel-area-code": "617", + "tel-local": "2535702", + }, + expectedResult: { + tel: "+16172535702", + }, + }, + { + description: + 'Has "tel-area-code", "tel-local-prefix" and "tel-local-suffix"', + address: { + "tel-area-code": "617", + "tel-local-prefix": "253", + "tel-local-suffix": "5702", + }, + expectedResult: { + tel: "+16172535702", + }, + }, +]; + +const CREDIT_CARD_COMPUTE_TESTCASES = [ + // Name + { + description: 'Has "cc-name"', + creditCard: { + "cc-name": "Timothy John Berners-Lee", + "cc-number": "4929001587121045", + }, + expectedResult: { + "cc-name": "Timothy John Berners-Lee", + "cc-number": "************1045", + "cc-given-name": "Timothy", + "cc-additional-name": "John", + "cc-family-name": "Berners-Lee", + }, + }, + + // Card Number + { + description: "Number should be encrypted and masked", + creditCard: { + "cc-number": "4929001587121045", + }, + expectedResult: { + "cc-number": "************1045", + }, + }, + + // Expiration Date + { + description: 'Has "cc-exp-year" and "cc-exp-month"', + creditCard: { + "cc-exp-month": 12, + "cc-exp-year": 2022, + "cc-number": "4929001587121045", + }, + expectedResult: { + "cc-exp-month": 12, + "cc-exp-year": 2022, + "cc-exp": "2022-12", + "cc-number": "************1045", + }, + }, + { + description: 'Has only "cc-exp-month"', + creditCard: { + "cc-exp-month": 12, + "cc-number": "4929001587121045", + }, + expectedResult: { + "cc-exp-month": 12, + "cc-exp": undefined, + "cc-number": "************1045", + }, + }, + { + description: 'Has only "cc-exp-year"', + creditCard: { + "cc-exp-year": 2022, + "cc-number": "4929001587121045", + }, + expectedResult: { + "cc-exp-year": 2022, + "cc-exp": undefined, + "cc-number": "************1045", + }, + }, +]; + +const CREDIT_CARD_NORMALIZE_TESTCASES = [ + // Name + { + description: 'Has both "cc-name" and the split name fields', + creditCard: { + "cc-name": "Timothy John Berners-Lee", + "cc-given-name": "John", + "cc-family-name": "Doe", + "cc-number": "4929001587121045", + }, + expectedResult: { + "cc-name": "Timothy John Berners-Lee", + "cc-number": "4929001587121045", + }, + }, + { + description: "Has only the split name fields", + creditCard: { + "cc-given-name": "John", + "cc-family-name": "Doe", + "cc-number": "4929001587121045", + }, + expectedResult: { + "cc-name": "John Doe", + "cc-number": "4929001587121045", + }, + }, + + // Card Number + { + description: "Regular number", + creditCard: { + "cc-number": "4929001587121045", + }, + expectedResult: { + "cc-number": "4929001587121045", + }, + }, + { + description: "Number with spaces", + creditCard: { + "cc-number": "4111 1111 1111 1111", + }, + expectedResult: { + "cc-number": "4111111111111111", + }, + }, + { + description: "Number with hyphens", + creditCard: { + "cc-number": "4111-1111-1111-1111", + }, + expectedResult: { + "cc-number": "4111111111111111", + }, + }, + + // Expiration Date + { + description: 'Has "cc-exp" formatted "yyyy-mm"', + creditCard: { + "cc-number": "4929001587121045", + "cc-exp": "2022-12", + }, + expectedResult: { + "cc-exp-month": 12, + "cc-exp-year": 2022, + "cc-number": "4929001587121045", + }, + }, + { + description: 'Has "cc-exp" formatted "yyyy/mm"', + creditCard: { + "cc-number": "4929001587121045", + "cc-exp": "2022/12", + }, + expectedResult: { + "cc-exp-month": 12, + "cc-exp-year": 2022, + "cc-number": "4929001587121045", + }, + }, + { + description: 'Has "cc-exp" formatted "yyyy-m"', + creditCard: { + "cc-number": "4929001587121045", + "cc-exp": "2022-3", + }, + expectedResult: { + "cc-exp-month": 3, + "cc-exp-year": 2022, + "cc-number": "4929001587121045", + }, + }, + { + description: 'Has "cc-exp" formatted "yyyy/m"', + creditCard: { + "cc-number": "4929001587121045", + "cc-exp": "2022/3", + }, + expectedResult: { + "cc-exp-month": 3, + "cc-exp-year": 2022, + "cc-number": "4929001587121045", + }, + }, + { + description: 'Has "cc-exp" formatted "mm-yyyy"', + creditCard: { + "cc-number": "4929001587121045", + "cc-exp": "12-2022", + }, + expectedResult: { + "cc-exp-month": 12, + "cc-exp-year": 2022, + "cc-number": "4929001587121045", + }, + }, + { + description: 'Has "cc-exp" formatted "mm/yyyy"', + creditCard: { + "cc-number": "4929001587121045", + "cc-exp": "12/2022", + }, + expectedResult: { + "cc-exp-month": 12, + "cc-exp-year": 2022, + "cc-number": "4929001587121045", + }, + }, + { + description: 'Has "cc-exp" formatted "m-yyyy"', + creditCard: { + "cc-number": "4929001587121045", + "cc-exp": "3-2022", + }, + expectedResult: { + "cc-exp-month": 3, + "cc-exp-year": 2022, + "cc-number": "4929001587121045", + }, + }, + { + description: 'Has "cc-exp" formatted "m/yyyy"', + creditCard: { + "cc-number": "4929001587121045", + "cc-exp": "3/2022", + }, + expectedResult: { + "cc-exp-month": 3, + "cc-exp-year": 2022, + "cc-number": "4929001587121045", + }, + }, + { + description: 'Has "cc-exp" formatted "mm-yy"', + creditCard: { + "cc-number": "4929001587121045", + "cc-exp": "12-22", + }, + expectedResult: { + "cc-exp-month": 12, + "cc-exp-year": 2022, + "cc-number": "4929001587121045", + }, + }, + { + description: 'Has "cc-exp" formatted "mm/yy"', + creditCard: { + "cc-number": "4929001587121045", + "cc-exp": "12/22", + }, + expectedResult: { + "cc-exp-month": 12, + "cc-exp-year": 2022, + "cc-number": "4929001587121045", + }, + }, + { + description: 'Has "cc-exp" formatted "yy-mm"', + creditCard: { + "cc-number": "4929001587121045", + "cc-exp": "22-12", + }, + expectedResult: { + "cc-exp-month": 12, + "cc-exp-year": 2022, + "cc-number": "4929001587121045", + }, + }, + { + description: 'Has "cc-exp" formatted "yy/mm"', + creditCard: { + "cc-exp": "22/12", + "cc-number": "4929001587121045", + }, + expectedResult: { + "cc-exp-month": 12, + "cc-exp-year": 2022, + "cc-number": "4929001587121045", + }, + }, + { + description: 'Has "cc-exp" formatted "mmyy"', + creditCard: { + "cc-exp": "1222", + "cc-number": "4929001587121045", + }, + expectedResult: { + "cc-exp-month": 12, + "cc-exp-year": 2022, + "cc-number": "4929001587121045", + }, + }, + { + description: 'Has "cc-exp" formatted "yymm"', + creditCard: { + "cc-exp": "2212", + "cc-number": "4929001587121045", + }, + expectedResult: { + "cc-exp-month": 12, + "cc-exp-year": 2022, + "cc-number": "4929001587121045", + }, + }, + { + description: 'Has "cc-exp" with spaces', + creditCard: { + "cc-exp": " 2033-11 ", + "cc-number": "4929001587121045", + }, + expectedResult: { + "cc-exp-month": 11, + "cc-exp-year": 2033, + "cc-number": "4929001587121045", + }, + }, + { + description: 'Has invalid "cc-exp"', + creditCard: { + "cc-number": "4111111111111111", // Make sure it won't be an empty record. + "cc-exp": "99-9999", + }, + expectedResult: { + "cc-exp-month": undefined, + "cc-exp-year": undefined, + }, + }, + { + description: 'Has both "cc-exp-*" and "cc-exp"', + creditCard: { + "cc-exp": "2022-12", + "cc-exp-month": 3, + "cc-exp-year": 2030, + "cc-number": "4929001587121045", + }, + expectedResult: { + "cc-exp-month": 3, + "cc-exp-year": 2030, + "cc-number": "4929001587121045", + }, + }, + { + description: 'Has only "cc-exp-year" and "cc-exp"', + creditCard: { + "cc-exp": "2022-12", + "cc-exp-year": 2030, + "cc-number": "4929001587121045", + }, + expectedResult: { + "cc-exp-month": 12, + "cc-exp-year": 2022, + "cc-number": "4929001587121045", + }, + }, + { + description: 'Has only "cc-exp-month" and "cc-exp"', + creditCard: { + "cc-exp": "2022-12", + "cc-exp-month": 3, + "cc-number": "4929001587121045", + }, + expectedResult: { + "cc-exp-month": 12, + "cc-exp-year": 2022, + "cc-number": "4929001587121045", + }, + }, +]; + +let do_check_record_matches = (expectedRecord, record) => { + for (let key in expectedRecord) { + Assert.equal(expectedRecord[key], record[key]); + } +}; + +add_task(async function test_computeAddressFields() { + let path = getTempFile(TEST_STORE_FILE_NAME).path; + + let profileStorage = new FormAutofillStorage(path); + await profileStorage.initialize(); + + for (let testcase of ADDRESS_COMPUTE_TESTCASES) { + info("Verify testcase: " + testcase.description); + + let guid = await profileStorage.addresses.add(testcase.address); + let address = await profileStorage.addresses.get(guid); + do_check_record_matches(testcase.expectedResult, address); + + profileStorage.addresses.remove(guid); + } + + await profileStorage._finalize(); +}); + +add_task(async function test_normalizeAddressFields() { + let path = getTempFile(TEST_STORE_FILE_NAME).path; + + let profileStorage = new FormAutofillStorage(path); + await profileStorage.initialize(); + + for (let testcase of ADDRESS_NORMALIZE_TESTCASES) { + info("Verify testcase: " + testcase.description); + + let guid = await profileStorage.addresses.add(testcase.address); + let address = await profileStorage.addresses.get(guid); + do_check_record_matches(testcase.expectedResult, address); + + profileStorage.addresses.remove(guid); + } + + await profileStorage._finalize(); +}); + +add_task(async function test_computeCreditCardFields() { + let path = getTempFile(TEST_STORE_FILE_NAME).path; + + let profileStorage = new FormAutofillStorage(path); + await profileStorage.initialize(); + + for (let testcase of CREDIT_CARD_COMPUTE_TESTCASES) { + info("Verify testcase: " + testcase.description); + + let guid = await profileStorage.creditCards.add(testcase.creditCard); + let creditCard = await profileStorage.creditCards.get(guid); + do_check_record_matches(testcase.expectedResult, creditCard); + + profileStorage.creditCards.remove(guid); + } + + await profileStorage._finalize(); +}); + +add_task(async function test_normalizeCreditCardFields() { + let path = getTempFile(TEST_STORE_FILE_NAME).path; + + let profileStorage = new FormAutofillStorage(path); + await profileStorage.initialize(); + + for (let testcase of CREDIT_CARD_NORMALIZE_TESTCASES) { + info("Verify testcase: " + testcase.description); + + let guid = await profileStorage.creditCards.add(testcase.creditCard); + let creditCard = await profileStorage.creditCards.get(guid, { + rawData: true, + }); + do_check_record_matches(testcase.expectedResult, creditCard); + + profileStorage.creditCards.remove(guid); + } + + await profileStorage._finalize(); +}); diff --git a/browser/extensions/formautofill/test/unit/xpcshell.ini b/browser/extensions/formautofill/test/unit/xpcshell.ini new file mode 100644 index 0000000000..6e031d6c2d --- /dev/null +++ b/browser/extensions/formautofill/test/unit/xpcshell.ini @@ -0,0 +1,83 @@ +[DEFAULT] +firefox-appdir = browser +head = head.js +support-files = + ../fixtures/** + +[heuristics/test_basic.js] +skip-if = (os == "linux") && ccov # bug 1614100 +[heuristics/test_cc_exp.js] +skip-if = (os == "linux") && ccov # bug 1614100 +[heuristics/test_de_fields.js] +skip-if = (os == "linux") && ccov # bug 1614100 +[heuristics/test_known_strings.js] +skip-if = (os == "linux") && ccov # bug 1614100 +[heuristics/test_multiple_section.js] +skip-if = (os == "linux") && ccov # bug 1614100 +[heuristics/third_party/test_BestBuy.js] +skip-if = (os == "linux") && ccov # bug 1614100 +[heuristics/third_party/test_CDW.js] +skip-if = (os == "linux") && ccov # bug 1614100 +[heuristics/third_party/test_CostCo.js] +skip-if = (os == "linux") && ccov # bug 1614100 +[heuristics/third_party/test_HomeDepot.js] +skip-if = (os == "linux") && ccov # bug 1614100 +[heuristics/third_party/test_Macys.js] +skip-if = (os == "linux") && ccov # bug 1614100 +[heuristics/third_party/test_NewEgg.js] +skip-if = (os == "linux") && ccov # bug 1614100 +[heuristics/third_party/test_OfficeDepot.js] +skip-if = (os == "linux") && ccov # bug 1614100 +[heuristics/third_party/test_QVC.js] +skip-if = (os == "linux") && ccov # bug 1614100 +[heuristics/third_party/test_Sears.js] +skip-if = (os == "linux") && ccov # bug 1614100 +[heuristics/third_party/test_Staples.js] +skip-if = (os == "linux") && ccov # bug 1614100 +[heuristics/third_party/test_Walmart.js] +skip-if = (os == "linux") && ccov # bug 1614100 +[heuristics/third_party/test_Lush.js] +skip-if = (os == "linux") && ccov # bug 1614100 +[test_activeStatus.js] +[test_addressDataLoader.js] +[test_addressRecords.js] +[test_autofillFormFields.js] +skip-if = tsan # Times out, bug 1612707 +[test_collectFormFields.js] +[test_createRecords.js] +[test_creditCardRecords.js] +skip-if = tsan # Times out, bug 1612707 +[test_extractLabelStrings.js] +[test_findLabelElements.js] +[test_getAdaptedProfiles.js] +[test_getCategoriesFromFieldNames.js] +[test_getFormInputDetails.js] +[test_getInfo.js] +[test_getRecords.js] +skip-if = tsan # Times out, bug 1612707 +[test_isAvailable.js] +[test_isCJKName.js] +[test_isFieldEligibleForAutofill.js] +[test_markAsAutofillField.js] +[test_migrateRecords.js] +skip-if = tsan # Times out, bug 1612707 +[test_nameUtils.js] +[test_onFormSubmitted.js] +skip-if = tsan # Times out, bug 1612707 +[test_parseAddressFormat.js] +[test_profileAutocompleteResult.js] +[test_phoneNumber.js] +[test_reconcile.js] +skip-if = tsan # Times out, bug 1612707 +[test_savedFieldNames.js] +[test_toOneLineAddress.js] +[test_storage_tombstones.js] +skip-if = tsan # Times out, bug 1612707 +[test_storage_remove.js] +skip-if = tsan # Times out, bug 1612707 +[test_storage_syncfields.js] +[test_transformFields.js] +skip-if = tsan # Times out, bug 1612707 +[test_sync.js] +head = head.js ../../../../../services/sync/tests/unit/head_appinfo.js ../../../../../services/common/tests/unit/head_helpers.js ../../../../../services/sync/tests/unit/head_helpers.js ../../../../../services/sync/tests/unit/head_http_server.js +skip-if = tsan # Times out, bug 1612707 diff --git a/browser/extensions/moz.build b/browser/extensions/moz.build new file mode 100644 index 0000000000..0eb3c53e76 --- /dev/null +++ b/browser/extensions/moz.build @@ -0,0 +1,7 @@ +# -*- 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/. + +DIRS += ["doh-rollout", "formautofill", "screenshots", "webcompat", "report-site-issue"] diff --git a/browser/extensions/report-site-issue/.eslintrc.js b/browser/extensions/report-site-issue/.eslintrc.js new file mode 100644 index 0000000000..e26bd6da3f --- /dev/null +++ b/browser/extensions/report-site-issue/.eslintrc.js @@ -0,0 +1,76 @@ +/* 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"; + +module.exports = { + rules: { + // Rules from the mozilla plugin + "mozilla/balanced-listeners": "error", + "mozilla/no-aArgs": "error", + "mozilla/var-only-at-top-level": "error", + + "valid-jsdoc": [ + "error", + { + prefer: { + return: "returns", + }, + preferType: { + Boolean: "boolean", + Number: "number", + String: "string", + bool: "boolean", + }, + requireParamDescription: false, + requireReturn: false, + requireReturnDescription: false, + }, + ], + + // No expressions where a statement is expected + "no-unused-expressions": "error", + + // No declaring variables that are never used + "no-unused-vars": "error", + + // Disallow using variables outside the blocks they are defined (especially + // since only let and const are used, see "no-var"). + "block-scoped-var": "error", + + // Warn about cyclomatic complexity in functions. + complexity: ["error", { max: 26 }], + + // Maximum depth callbacks can be nested. + "max-nested-callbacks": ["error", 4], + + // Allow the console API aside from console.log. + "no-console": ["error", { allow: ["error", "info", "trace", "warn"] }], + + // Disallow fallthrough of case statements, except if there is a comment. + "no-fallthrough": "error", + + // Disallow use of multiline strings (use template strings instead). + "no-multi-str": "error", + + // Disallow usage of __proto__ property. + "no-proto": "error", + + // Disallow use of assignment in return statement. It is preferable for a + // single line of code to have only one easily predictable effect. + "no-return-assign": "error", + + // Require use of the second argument for parseInt(). + radix: "error", + + // Require "use strict" to be defined globally in the script. + strict: ["error", "global"], + + // Disallow Yoda conditions (where literal value comes first). + yoda: "error", + + // Disallow function or variable declarations in nested blocks + "no-inner-declarations": "error", + }, +}; diff --git a/browser/extensions/report-site-issue/background.js b/browser/extensions/report-site-issue/background.js new file mode 100644 index 0000000000..d5c4edcbd7 --- /dev/null +++ b/browser/extensions/report-site-issue/background.js @@ -0,0 +1,215 @@ +/* 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"; + +/* globals browser */ + +const Config = { + newIssueEndpoint: "https://webcompat.com/issues/new", + newIssueEndpointPref: "newIssueEndpoint", + screenshotFormat: { + format: "jpeg", + quality: 75, + }, +}; + +const FRAMEWORK_KEYS = ["hasFastClick", "hasMobify", "hasMarfeel"]; + +browser.pageActionExtras.setLabelForHistogram("webcompat"); + +browser.pageAction.onClicked.addListener(tab => { + return getWebCompatInfoForTab(tab).then( + info => { + return openWebCompatTab(info); + }, + err => { + console.error("WebCompat Reporter: unexpected error", err); + } + ); +}); + +browser.aboutConfigPrefs.onEndpointPrefChange.addListener(checkEndpointPref); + +checkEndpointPref(); + +async function checkEndpointPref() { + const value = await browser.aboutConfigPrefs.getEndpointPref(); + if (value === undefined) { + browser.aboutConfigPrefs.setEndpointPref(Config.newIssueEndpoint); + } else { + Config.newIssueEndpoint = value; + } +} + +function hasFastClickPageScript() { + const win = window.wrappedJSObject; + + if (win.FastClick) { + return true; + } + + for (const property in win) { + try { + const proto = win[property].prototype; + if (proto && proto.needsClick) { + return true; + } + } catch (_) {} + } + + return false; +} + +function hasMobifyPageScript() { + const win = window.wrappedJSObject; + return !!(win.Mobify && win.Mobify.Tag); +} + +function hasMarfeelPageScript() { + const win = window.wrappedJSObject; + return !!win.marfeel; +} + +function checkForFrameworks(tabId) { + return browser.tabs + .executeScript(tabId, { + code: ` + (function() { + ${hasFastClickPageScript}; + ${hasMobifyPageScript}; + ${hasMarfeelPageScript}; + + const result = { + hasFastClick: hasFastClickPageScript(), + hasMobify: hasMobifyPageScript(), + hasMarfeel: hasMarfeelPageScript(), + } + + return result; + })(); + `, + }) + .then(([results]) => results) + .catch(() => false); +} + +function getWebCompatInfoForTab(tab) { + const { id, url } = tab; + return Promise.all([ + browser.browserInfo.getBlockList(), + browser.browserInfo.getBuildID(), + browser.browserInfo.getGraphicsPrefs(), + browser.browserInfo.getUpdateChannel(), + browser.browserInfo.hasTouchScreen(), + browser.tabExtras.getWebcompatInfo(id), + checkForFrameworks(id), + browser.tabs.captureTab(id, Config.screenshotFormat).catch(e => { + console.error("WebCompat Reporter: getting a screenshot failed", e); + return Promise.resolve(undefined); + }), + ]).then( + ([ + blockList, + buildID, + graphicsPrefs, + channel, + hasTouchScreen, + frameInfo, + frameworks, + screenshot, + ]) => { + if (channel !== "linux") { + delete graphicsPrefs["layers.acceleration.force-enabled"]; + } + + const consoleLog = frameInfo.log; + delete frameInfo.log; + + return Object.assign(frameInfo, { + tabId: id, + blockList, + details: Object.assign(graphicsPrefs, { + buildID, + channel, + consoleLog, + frameworks, + hasTouchScreen, + "mixed active content blocked": + frameInfo.hasMixedActiveContentBlocked, + "mixed passive content blocked": + frameInfo.hasMixedDisplayContentBlocked, + "tracking content blocked": frameInfo.hasTrackingContentBlocked + ? `true (${blockList})` + : "false", + }), + screenshot, + url, + }); + } + ); +} + +function stripNonASCIIChars(str) { + // eslint-disable-next-line no-control-regex + return str.replace(/[^\x00-\x7F]/g, ""); +} + +browser.l10n + .getMessage("wc-reporter.label2") + .then(browser.pageActionExtras.setDefaultTitle, () => {}); + +browser.l10n + .getMessage("wc-reporter.tooltip") + .then(browser.pageActionExtras.setTooltipText, () => {}); + +async function openWebCompatTab(compatInfo) { + const url = new URL(Config.newIssueEndpoint); + const { details } = compatInfo; + const params = { + url: `${compatInfo.url}`, + utm_source: "desktop-reporter", + utm_campaign: "report-site-issue-button", + src: "desktop-reporter", + details, + extra_labels: [], + }; + + for (let framework of FRAMEWORK_KEYS) { + if (details.frameworks[framework]) { + params.details[framework] = true; + params.extra_labels.push( + framework.replace(/^has/, "type-").toLowerCase() + ); + } + } + delete details.frameworks; + + if (details["gfx.webrender.all"] || details["gfx.webrender.enabled"]) { + params.extra_labels.push("type-webrender-enabled"); + } + if (compatInfo.hasTrackingContentBlocked) { + params.extra_labels.push( + `type-tracking-protection-${compatInfo.blockList}` + ); + } + + const json = stripNonASCIIChars(JSON.stringify(params)); + const tab = await browser.tabs.create({ url: url.href }); + await browser.tabs.executeScript(tab.id, { + runAt: "document_end", + code: `(function() { + async function postMessageData(dataURI, metadata) { + const res = await fetch(dataURI); + const blob = await res.blob(); + const data = { + screenshot: blob, + message: metadata + }; + postMessage(data, "${url.origin}"); + } + postMessageData("${compatInfo.screenshot}", ${json}); + })()`, + }); +} diff --git a/browser/extensions/report-site-issue/experimentalAPIs/aboutConfigPrefs.js b/browser/extensions/report-site-issue/experimentalAPIs/aboutConfigPrefs.js new file mode 100644 index 0000000000..97206ba754 --- /dev/null +++ b/browser/extensions/report-site-issue/experimentalAPIs/aboutConfigPrefs.js @@ -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/. */ + +"use strict"; + +/* global ExtensionAPI, ExtensionCommon */ + +var { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); + +this.aboutConfigPrefs = class extends ExtensionAPI { + getAPI(context) { + const EventManager = ExtensionCommon.EventManager; + const extensionIDBase = context.extension.id.split("@")[0]; + const endpointPrefName = `extensions.${extensionIDBase}.newIssueEndpoint`; + + return { + aboutConfigPrefs: { + onEndpointPrefChange: new EventManager({ + context, + name: "aboutConfigPrefs.onEndpointPrefChange", + register: fire => { + const callback = () => { + fire.async().catch(() => {}); // ignore Message Manager disconnects + }; + Services.prefs.addObserver(endpointPrefName, callback); + return () => { + Services.prefs.removeObserver(endpointPrefName, callback); + }; + }, + }).api(), + async getEndpointPref() { + return Services.prefs.getStringPref(endpointPrefName, undefined); + }, + async setEndpointPref(value) { + Services.prefs.setStringPref(endpointPrefName, value); + }, + }, + }; + } +}; diff --git a/browser/extensions/report-site-issue/experimentalAPIs/aboutConfigPrefs.json b/browser/extensions/report-site-issue/experimentalAPIs/aboutConfigPrefs.json new file mode 100644 index 0000000000..1fd313e392 --- /dev/null +++ b/browser/extensions/report-site-issue/experimentalAPIs/aboutConfigPrefs.json @@ -0,0 +1,35 @@ +[ + { + "namespace": "aboutConfigPrefs", + "description": "experimental API extension to allow access to about:config preferences", + "events": [ + { + "name": "onEndpointPrefChange", + "type": "function", + "parameters": [] + } + ], + "functions": [ + { + "name": "getEndpointPref", + "type": "function", + "description": "Get the endpoint preference's value", + "parameters": [], + "async": true + }, + { + "name": "setEndpointPref", + "type": "function", + "description": "Set the endpoint preference's value", + "parameters": [ + { + "name": "value", + "type": "string", + "description": "The new value" + } + ], + "async": true + } + ] + } +] diff --git a/browser/extensions/report-site-issue/experimentalAPIs/actors/tabExtrasActor.jsm b/browser/extensions/report-site-issue/experimentalAPIs/actors/tabExtrasActor.jsm new file mode 100644 index 0000000000..869856dbea --- /dev/null +++ b/browser/extensions/report-site-issue/experimentalAPIs/actors/tabExtrasActor.jsm @@ -0,0 +1,156 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 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/. */ + +"use strict"; + +const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); + +var EXPORTED_SYMBOLS = ["ReportSiteIssueHelperChild"]; + +const PREVIEW_MAX_ITEMS = 10; +const LOG_LEVELS = ["debug", "info", "warn", "error"]; + +function getPreview(value) { + switch (typeof value) { + case "function": + return "function ()"; + + case "object": + if (value === null) { + return null; + } + + if (Array.isArray(value)) { + return `(${value.length})[...]`; + } + + return "{...}"; + + case "undefined": + return "undefined"; + + default: + return value; + } +} + +function getArrayPreview(arr) { + const preview = []; + let count = 0; + for (const value of arr) { + if (++count > PREVIEW_MAX_ITEMS) { + break; + } + preview.push(getPreview(value)); + } + + return preview; +} + +function getObjectPreview(obj) { + const preview = {}; + let count = 0; + for (const key of Object.keys(obj)) { + if (++count > PREVIEW_MAX_ITEMS) { + break; + } + preview[key] = getPreview(obj[key]); + } + + return preview; +} + +function getArgs(value) { + if (typeof value === "object" && value !== null) { + if (Array.isArray(value)) { + return getArrayPreview(value); + } + + return getObjectPreview(value); + } + + return getPreview(value); +} + +class ReportSiteIssueHelperChild extends JSWindowActorChild { + _getConsoleMessages(windowId) { + const ConsoleAPIStorage = Cc[ + "@mozilla.org/consoleAPI-storage;1" + ].getService(Ci.nsIConsoleAPIStorage); + let messages = ConsoleAPIStorage.getEvents(windowId); + return messages.map(evt => { + const { columnNumber, filename, level, lineNumber, timeStamp } = evt; + const args = evt.arguments.map(getArgs); + + const message = { + level, + log: args, + uri: filename, + pos: `${lineNumber}:${columnNumber}`, + }; + + return { timeStamp, message }; + }); + } + + _getScriptErrors(windowId, includePrivate) { + const messages = Services.console.getMessageArray(); + return messages + .filter(message => { + if (message instanceof Ci.nsIScriptError) { + if (!includePrivate && message.isFromPrivateWindow) { + return false; + } + + if (windowId && windowId !== message.innerWindowID) { + return false; + } + + return true; + } + + // If this is not an nsIScriptError and we need to do window-based + // filtering we skip this message. + return false; + }) + .map(error => { + const { + timeStamp, + errorMessage, + sourceName, + lineNumber, + columnNumber, + logLevel, + } = error; + const message = { + level: LOG_LEVELS[logLevel], + log: [errorMessage], + uri: sourceName, + pos: `${lineNumber}:${columnNumber}`, + }; + return { timeStamp, message }; + }); + } + + _getLoggedMessages(includePrivate = false) { + const windowId = this.contentWindow.windowGlobalChild.innerWindowId; + return this._getConsoleMessages(windowId).concat( + this._getScriptErrors(windowId, includePrivate) + ); + } + + receiveMessage(msg) { + switch (msg.name) { + case "GetLog": + return this._getLoggedMessages(); + case "GetBlockingStatus": + const { docShell } = this; + return { + hasTrackingContentBlocked: docShell.hasTrackingContentBlocked, + }; + } + return null; + } +} diff --git a/browser/extensions/report-site-issue/experimentalAPIs/browserInfo.js b/browser/extensions/report-site-issue/experimentalAPIs/browserInfo.js new file mode 100644 index 0000000000..23d224dd98 --- /dev/null +++ b/browser/extensions/report-site-issue/experimentalAPIs/browserInfo.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/. */ + +"use strict"; + +/* global ExtensionAPI */ + +var { AppConstants } = ChromeUtils.import( + "resource://gre/modules/AppConstants.jsm" +); +var { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); + +function isTelemetryEnabled() { + return Services.prefs.getBoolPref( + "datareporting.healthreport.uploadEnabled", + false + ); +} + +function isWebRenderEnabled() { + return ( + Services.prefs.getBoolPref("gfx.webrender.all", false) || + Services.prefs.getBoolPref("gfx.webrender.enabled", false) + ); +} + +this.browserInfo = class extends ExtensionAPI { + getAPI(context) { + return { + browserInfo: { + async getGraphicsPrefs() { + const prefs = {}; + for (const [name, dflt] of Object.entries({ + "layers.acceleration.force-enabled": false, + "gfx.webrender.all": false, + "gfx.webrender.blob-images": true, + "gfx.webrender.enabled": false, + "image.mem.shared": true, + })) { + prefs[name] = Services.prefs.getBoolPref(name, dflt); + } + return prefs; + }, + async getAppVersion() { + return AppConstants.MOZ_APP_VERSION; + }, + async getBlockList() { + const trackingTable = Services.prefs.getCharPref( + "urlclassifier.trackingTable" + ); + // If content-track-digest256 is in the tracking table, + // the user has enabled the strict list. + return trackingTable.includes("content") ? "strict" : "basic"; + }, + async getBuildID() { + return Services.appinfo.appBuildID; + }, + async getUpdateChannel() { + return AppConstants.MOZ_UPDATE_CHANNEL; + }, + async getPlatform() { + return AppConstants.platform; + }, + async hasTouchScreen() { + const gfxInfo = Cc["@mozilla.org/gfx/info;1"].getService( + Ci.nsIGfxInfo + ); + return gfxInfo.getInfo().ApzTouchInput == 1; + }, + }, + }; + } +}; diff --git a/browser/extensions/report-site-issue/experimentalAPIs/browserInfo.json b/browser/extensions/report-site-issue/experimentalAPIs/browserInfo.json new file mode 100644 index 0000000000..dc437c9bc9 --- /dev/null +++ b/browser/extensions/report-site-issue/experimentalAPIs/browserInfo.json @@ -0,0 +1,57 @@ +[ + { + "namespace": "browserInfo", + "description": "experimental API extensions to get browser info not exposed via web APIs", + "functions": [ + { + "name": "getAppVersion", + "type": "function", + "description": "Gets the app version", + "parameters": [], + "async": true + }, + { + "name": "getBlockList", + "type": "function", + "description": "Gets the current blocklist", + "parameters": [], + "async": true + }, + { + "name": "getBuildID", + "type": "function", + "description": "Gets the build ID", + "parameters": [], + "async": true + }, + { + "name": "getGraphicsPrefs", + "type": "function", + "description": "Gets interesting about:config prefs for graphics", + "parameters": [], + "async": true + }, + { + "name": "getPlatform", + "type": "function", + "description": "Gets the platform", + "parameters": [], + "async": true + }, + { + "name": "getUpdateChannel", + "type": "function", + "description": "Gets the update channel", + "parameters": [], + "async": true + }, + { + "name": "hasTouchScreen", + "type": "function", + "description": "Gets whether a touchscreen is present", + "parameters": [], + "async": true + } + ] + } +] diff --git a/browser/extensions/report-site-issue/experimentalAPIs/l10n.js b/browser/extensions/report-site-issue/experimentalAPIs/l10n.js new file mode 100644 index 0000000000..e87569562c --- /dev/null +++ b/browser/extensions/report-site-issue/experimentalAPIs/l10n.js @@ -0,0 +1,57 @@ +/* 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"; + +/* global ExtensionAPI, XPCOMUtils */ + +var { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); + +XPCOMUtils.defineLazyGetter(this, "l10nStrings", function() { + return Services.strings.createBundle( + "chrome://report-site-issue/locale/webcompat.properties" + ); +}); + +let l10nManifest; + +this.l10n = class extends ExtensionAPI { + onShutdown(isAppShutdown) { + if (!isAppShutdown && l10nManifest) { + Components.manager.removeBootstrappedManifestLocation(l10nManifest); + } + } + getAPI(context) { + // Until we move to Fluent (bug 1446164), we're stuck with + // chrome.manifest for handling localization since its what the + // build system can handle for localized repacks. + if (context.extension.rootURI instanceof Ci.nsIJARURI) { + l10nManifest = context.extension.rootURI.JARFile.QueryInterface( + Ci.nsIFileURL + ).file; + } else if (context.extension.rootURI instanceof Ci.nsIFileURL) { + l10nManifest = context.extension.rootURI.file; + } + + if (l10nManifest) { + Components.manager.addBootstrappedManifestLocation(l10nManifest); + } else { + Cu.reportError( + "Cannot find webcompat reporter chrome.manifest for registring translated strings" + ); + } + + return { + l10n: { + getMessage(name) { + try { + return Promise.resolve(l10nStrings.GetStringFromName(name)); + } catch (e) { + return Promise.reject(e); + } + }, + }, + }; + } +}; diff --git a/browser/extensions/report-site-issue/experimentalAPIs/l10n.json b/browser/extensions/report-site-issue/experimentalAPIs/l10n.json new file mode 100644 index 0000000000..fd10cf68e7 --- /dev/null +++ b/browser/extensions/report-site-issue/experimentalAPIs/l10n.json @@ -0,0 +1,19 @@ +[ + { + "namespace": "l10n", + "description": "A stop-gap L10N API only meant to be used until a Fluent-based API is added in bug 1425104", + "functions": [ + { + "name": "getMessage", + "type": "function", + "description": "Gets the message with the given name", + "parameters": [{ + "name": "name", + "type": "string", + "description": "The name of the message" + }], + "async": true + } + ] + } +] diff --git a/browser/extensions/report-site-issue/experimentalAPIs/pageActionExtras.js b/browser/extensions/report-site-issue/experimentalAPIs/pageActionExtras.js new file mode 100644 index 0000000000..19ee738a60 --- /dev/null +++ b/browser/extensions/report-site-issue/experimentalAPIs/pageActionExtras.js @@ -0,0 +1,43 @@ +/* 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"; + +/* global ExtensionAPI */ + +this.pageActionExtras = class extends ExtensionAPI { + getAPI(context) { + const extension = context.extension; + const pageActionAPI = extension.apiManager.getAPI( + "pageAction", + extension, + context.envType + ); + const { + Management: { + global: { windowTracker }, + }, + } = ChromeUtils.import("resource://gre/modules/Extension.jsm", null); + return { + pageActionExtras: { + async setDefaultTitle(title) { + pageActionAPI.action.getContextData(null).title = title; + // Make sure the new default title is considered right away + for (const window of windowTracker.browserWindows()) { + const tab = window.gBrowser.selectedTab; + if (pageActionAPI.action.isShownForTab(tab)) { + pageActionAPI.updateButton(window); + } + } + }, + async setLabelForHistogram(label) { + pageActionAPI.browserPageAction._labelForHistogram = label; + }, + async setTooltipText(text) { + pageActionAPI.browserPageAction.setTooltip(text); + }, + }, + }; + } +}; diff --git a/browser/extensions/report-site-issue/experimentalAPIs/pageActionExtras.json b/browser/extensions/report-site-issue/experimentalAPIs/pageActionExtras.json new file mode 100644 index 0000000000..df848e439a --- /dev/null +++ b/browser/extensions/report-site-issue/experimentalAPIs/pageActionExtras.json @@ -0,0 +1,41 @@ +[ + { + "namespace": "pageActionExtras", + "description": "experimental pageAction API extensions", + "functions": [ + { + "name": "setDefaultTitle", + "type": "function", + "async": true, + "description": "Set the page action's title for all tabs", + "parameters": [{ + "name": "title", + "type": "string", + "description": "title" + }] + }, + { + "name": "setLabelForHistogram", + "type": "function", + "async": true, + "description": "Set the page action's label for telemetry histograms", + "parameters": [{ + "name": "label", + "type": "string", + "description": "label for the histogram" + }] + }, + { + "name": "setTooltipText", + "type": "function", + "async": true, + "description": "Set the page action's tooltip text", + "parameters": [{ + "name": "text", + "type": "string", + "description": "text" + }] + } + ] + } +] diff --git a/browser/extensions/report-site-issue/experimentalAPIs/tabExtras.js b/browser/extensions/report-site-issue/experimentalAPIs/tabExtras.js new file mode 100644 index 0000000000..7da789f905 --- /dev/null +++ b/browser/extensions/report-site-issue/experimentalAPIs/tabExtras.js @@ -0,0 +1,97 @@ +/* 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"; + +/* global ExtensionAPI, XPCOMUtils */ + +var { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); + +XPCOMUtils.defineLazyServiceGetter( + this, + "resProto", + "@mozilla.org/network/protocol;1?name=resource", + "nsISubstitutingProtocolHandler" +); + +this.tabExtras = class extends ExtensionAPI { + constructor(extension) { + super(extension); + this._registerActorModule(); + } + + getAPI(context) { + const { tabManager } = context.extension; + return { + tabExtras: { + async getWebcompatInfo(tabId) { + const { browsingContext } = tabManager.get(tabId).browser; + const actors = gatherActors("ReportSiteIssueHelper", browsingContext); + const promises = actors.map(actor => actor.sendQuery("GetLog")); + const logs = await Promise.all(promises); + const info = await actors[0].sendQuery("GetBlockingStatus"); + info.hasMixedActiveContentBlocked = !!( + browsingContext.secureBrowserUI.state & + Ci.nsIWebProgressListener.STATE_BLOCKED_MIXED_ACTIVE_CONTENT + ); + info.hasMixedDisplayContentBlocked = !!( + browsingContext.secureBrowserUI.state & + Ci.nsIWebProgressListener.STATE_BLOCKED_MIXED_DISPLAY_CONTENT + ); + info.log = logs + .flat() + .sort((a, b) => a.timeStamp - b.timeStamp) + .map(m => m.message); + return info; + }, + }, + }; + } + + onShutdown(isAppShutdown) { + this._unregisterActorModule(); + } + + _registerActorModule() { + resProto.setSubstitution( + "report-site-issue", + Services.io.newURI( + "experimentalAPIs/actors/", + null, + this.extension.rootURI + ) + ); + ChromeUtils.registerWindowActor("ReportSiteIssueHelper", { + child: { + moduleURI: "resource://report-site-issue/tabExtrasActor.jsm", + }, + allFrames: true, + }); + } + + _unregisterActorModule() { + ChromeUtils.unregisterWindowActor("ReportSiteIssueHelper"); + resProto.setSubstitution("report-site-issue", null); + } +}; + +function getActorForBrowsingContext(name, browsingContext) { + const windowGlobal = browsingContext.currentWindowGlobal; + return windowGlobal ? windowGlobal.getActor(name) : null; +} + +function gatherActors(name, browsingContext) { + const list = []; + + const actor = getActorForBrowsingContext(name, browsingContext); + if (actor) { + list.push(actor); + } + + for (const child of browsingContext.children) { + list.push(...gatherActors(name, child)); + } + + return list; +} diff --git a/browser/extensions/report-site-issue/experimentalAPIs/tabExtras.json b/browser/extensions/report-site-issue/experimentalAPIs/tabExtras.json new file mode 100644 index 0000000000..d59230b2f6 --- /dev/null +++ b/browser/extensions/report-site-issue/experimentalAPIs/tabExtras.json @@ -0,0 +1,19 @@ +[ + { + "namespace": "tabExtras", + "description": "experimental tab API extensions", + "functions": [ + { + "name": "getWebcompatInfo", + "type": "function", + "description": "Gets the content blocking status and script log for a given tab", + "parameters": [{ + "type": "integer", + "name": "tabId", + "minimum": 0 + }], + "async": true + } + ] + } +] diff --git a/browser/extensions/report-site-issue/icons/lightbulb.svg b/browser/extensions/report-site-issue/icons/lightbulb.svg new file mode 100644 index 0000000000..1f1e34da4f --- /dev/null +++ b/browser/extensions/report-site-issue/icons/lightbulb.svg @@ -0,0 +1,6 @@ +<!-- 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/. --> +<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="context-fill" fill-opacity="context-fill-opacity"> + <path d="M8 0C4.3 0 2 2.107 2 5.5c0 2.372 2.065 4.268 3 5V14c0 1.476 1.616 2 3 2s3-.524 3-2v-3.5c.935-.736 3-2.632 3-5C14 2.107 11.7 0 8 0zm1 12H7v-1h2zm-1 2a3.086 3.086 0 0 1-1-.172V13h2v.828A3.047 3.047 0 0 1 8 14zm1.445-4.832A1 1 0 0 0 9 10H7a1 1 0 0 0-.444-.831C5.845 8.691 4 7.1 4 5.5 4 2.607 6.175 2 8 2s4 .607 4 3.5c0 1.6-1.845 3.191-2.555 3.668z"/> +</svg> diff --git a/browser/extensions/report-site-issue/locales/en-US/webcompat.properties b/browser/extensions/report-site-issue/locales/en-US/webcompat.properties new file mode 100644 index 0000000000..ee8cab2cf0 --- /dev/null +++ b/browser/extensions/report-site-issue/locales/en-US/webcompat.properties @@ -0,0 +1,10 @@ +# 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/. + +# LOCALIZATION NOTE(wc-reporter.label2): This string will be used in the +# Firefox page actions menu. Localized length should be considered. +wc-reporter.label2=Report Site Issue… +# LOCALIZATION NOTE(wc-reporter.tooltip): A site compatibility issue is +# a website bug that exists in one browser (Firefox), but not another. +wc-reporter.tooltip=Report a site compatibility issue diff --git a/browser/extensions/report-site-issue/locales/jar.mn b/browser/extensions/report-site-issue/locales/jar.mn new file mode 100644 index 0000000000..3422f6248f --- /dev/null +++ b/browser/extensions/report-site-issue/locales/jar.mn @@ -0,0 +1,8 @@ +#filter substitution +# 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/. + +[features/webcompat-reporter@mozilla.org] @AB_CD@.jar: +% locale report-site-issue @AB_CD@ %locale/@AB_CD@/ + locale/@AB_CD@/webcompat.properties (%webcompat.properties) diff --git a/browser/extensions/report-site-issue/locales/moz.build b/browser/extensions/report-site-issue/locales/moz.build new file mode 100644 index 0000000000..d988c0ff9b --- /dev/null +++ b/browser/extensions/report-site-issue/locales/moz.build @@ -0,0 +1,7 @@ +# -*- 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/. + +JAR_MANIFESTS += ["jar.mn"] diff --git a/browser/extensions/report-site-issue/manifest.json b/browser/extensions/report-site-issue/manifest.json new file mode 100644 index 0000000000..d0afa9e795 --- /dev/null +++ b/browser/extensions/report-site-issue/manifest.json @@ -0,0 +1,78 @@ +{ + "manifest_version": 2, + "name": "WebCompat Reporter", + "description": "Report site compatibility issues on webcompat.com", + "author": "Thomas Wisniewski <twisniewski@mozilla.com>", + "version": "1.4.0", + "homepage_url": "https://github.com/mozilla/webcompat-reporter", + "applications": { + "gecko": { + "id": "webcompat-reporter@mozilla.org" + } + }, + "experiment_apis": { + "aboutConfigPrefs": { + "schema": "experimentalAPIs/aboutConfigPrefs.json", + "parent": { + "scopes": ["addon_parent"], + "script": "experimentalAPIs/aboutConfigPrefs.js", + "paths": [["aboutConfigPrefs"]] + } + }, + "browserInfo": { + "schema": "experimentalAPIs/browserInfo.json", + "parent": { + "scopes": ["addon_parent"], + "script": "experimentalAPIs/browserInfo.js", + "paths": [["browserInfo"]] + } + }, + "l10n": { + "schema": "experimentalAPIs/l10n.json", + "parent": { + "scopes": ["addon_parent"], + "script": "experimentalAPIs/l10n.js", + "paths": [["l10n"]] + } + }, + "pageActionExtras": { + "schema": "experimentalAPIs/pageActionExtras.json", + "parent": { + "scopes": ["addon_parent"], + "script": "experimentalAPIs/pageActionExtras.js", + "paths": [["pageActionExtras"]] + } + }, + "tabExtras": { + "schema": "experimentalAPIs/tabExtras.json", + "parent": { + "scopes": ["addon_parent"], + "script": "experimentalAPIs/tabExtras.js", + "paths": [["tabExtras"]] + } + } + }, + "icons": { + "16": "icons/lightbulb.svg", + "32": "icons/lightbulb.svg", + "48": "icons/lightbulb.svg", + "96": "icons/lightbulb.svg", + "128": "icons/lightbulb.svg" + }, + "permissions": [ + "tabs", + "<all_urls>" + ], + "background": { + "scripts": [ + "background.js" + ] + }, + "page_action": { + "browser_style": true, + "default_icon": "icons/lightbulb.svg", + "default_title": "Report Site Issue…", + "pinned": false, + "show_matches": ["http://*/*", "https://*/*"] + } +} diff --git a/browser/extensions/report-site-issue/moz.build b/browser/extensions/report-site-issue/moz.build new file mode 100644 index 0000000000..e3e92a3319 --- /dev/null +++ b/browser/extensions/report-site-issue/moz.build @@ -0,0 +1,41 @@ +# -*- 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/. + +DEFINES["MOZ_APP_VERSION"] = CONFIG["MOZ_APP_VERSION"] +DEFINES["MOZ_APP_MAXVERSION"] = CONFIG["MOZ_APP_MAXVERSION"] + +DIRS += ["locales"] + +FINAL_TARGET_FILES.features["webcompat-reporter@mozilla.org"] += [ + "background.js", + "manifest.json", +] + +FINAL_TARGET_FILES.features["webcompat-reporter@mozilla.org"].experimentalAPIs += [ + "experimentalAPIs/aboutConfigPrefs.js", + "experimentalAPIs/aboutConfigPrefs.json", + "experimentalAPIs/browserInfo.js", + "experimentalAPIs/browserInfo.json", + "experimentalAPIs/l10n.js", + "experimentalAPIs/l10n.json", + "experimentalAPIs/pageActionExtras.js", + "experimentalAPIs/pageActionExtras.json", + "experimentalAPIs/tabExtras.js", + "experimentalAPIs/tabExtras.json", +] + +FINAL_TARGET_FILES.features[ + "webcompat-reporter@mozilla.org" +].experimentalAPIs.actors += ["experimentalAPIs/actors/tabExtrasActor.jsm"] + +FINAL_TARGET_FILES.features["webcompat-reporter@mozilla.org"].icons += [ + "icons/lightbulb.svg" +] + +BROWSER_CHROME_MANIFESTS += ["test/browser/browser.ini"] + +with Files("**"): + BUG_COMPONENT = ("Web Compatibility", "Tooling & Investigations") diff --git a/browser/extensions/report-site-issue/test/browser/.eslintrc.js b/browser/extensions/report-site-issue/test/browser/.eslintrc.js new file mode 100644 index 0000000000..1779fd7f1c --- /dev/null +++ b/browser/extensions/report-site-issue/test/browser/.eslintrc.js @@ -0,0 +1,5 @@ +"use strict"; + +module.exports = { + extends: ["plugin:mozilla/browser-test"], +}; diff --git a/browser/extensions/report-site-issue/test/browser/browser.ini b/browser/extensions/report-site-issue/test/browser/browser.ini new file mode 100644 index 0000000000..39861d8cc6 --- /dev/null +++ b/browser/extensions/report-site-issue/test/browser/browser.ini @@ -0,0 +1,11 @@ +[DEFAULT] +support-files = + frameworks.html + fastclick.html + head.js + test.html + webcompat.html + +[browser_disabled_cleanup.js] +[browser_button_state.js] +[browser_report_site_issue.js] diff --git a/browser/extensions/report-site-issue/test/browser/browser_button_state.js b/browser/extensions/report-site-issue/test/browser/browser_button_state.js new file mode 100644 index 0000000000..d97a8460d8 --- /dev/null +++ b/browser/extensions/report-site-issue/test/browser/browser_button_state.js @@ -0,0 +1,93 @@ +"use strict"; + +const REPORTABLE_PAGE = "http://example.com/"; +const REPORTABLE_PAGE2 = "https://example.com/"; +const NONREPORTABLE_PAGE = "about:mozilla"; + +/* Test that the Report Site Issue panel item is enabled for http and https tabs, + on page load, or TabSelect, and disabled for everything else. */ +add_task(async function test_button_state_disabled() { + await SpecialPowers.pushPrefEnv({ set: [[PREF_WC_REPORTER_ENABLED, true]] }); + + let tab1 = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + REPORTABLE_PAGE + ); + await openPageActions(); + is( + await isPanelItemEnabled(), + true, + "Check that panel item is enabled for reportable schemes on tab load" + ); + + let tab2 = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + NONREPORTABLE_PAGE + ); + await openPageActions(); + is( + await isPanelItemDisabled(), + true, + "Check that panel item is disabled for non-reportable schemes on tab load" + ); + + let tab3 = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + REPORTABLE_PAGE2 + ); + await openPageActions(); + is( + await isPanelItemEnabled(), + true, + "Check that panel item is enabled for reportable schemes on tab load" + ); + + await BrowserTestUtils.removeTab(tab1); + await BrowserTestUtils.removeTab(tab2); + await BrowserTestUtils.removeTab(tab3); +}); + +/* Test that the button is enabled or disabled when we expected it to be, when + pinned to the URL bar. */ +add_task(async function test_button_state_in_urlbar() { + await SpecialPowers.pushPrefEnv({ set: [[PREF_WC_REPORTER_ENABLED, true]] }); + + pinToURLBar(); + let tab1 = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + REPORTABLE_PAGE + ); + await openPageActions(); + is( + await isURLButtonPresent(), + true, + "Check that urlbar icon is enabled for reportable schemes on tab load" + ); + + let tab2 = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + NONREPORTABLE_PAGE + ); + await openPageActions(); + is( + await isURLButtonPresent(), + false, + "Check that urlbar icon is hidden for non-reportable schemes on tab load" + ); + + let tab3 = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + REPORTABLE_PAGE2 + ); + await openPageActions(); + is( + await isURLButtonPresent(), + true, + "Check that urlbar icon is enabled for reportable schemes on tab load" + ); + + unpinFromURLBar(); + await BrowserTestUtils.removeTab(tab1); + await BrowserTestUtils.removeTab(tab2); + await BrowserTestUtils.removeTab(tab3); +}); diff --git a/browser/extensions/report-site-issue/test/browser/browser_disabled_cleanup.js b/browser/extensions/report-site-issue/test/browser/browser_disabled_cleanup.js new file mode 100644 index 0000000000..fac7a5022e --- /dev/null +++ b/browser/extensions/report-site-issue/test/browser/browser_disabled_cleanup.js @@ -0,0 +1,53 @@ +"use strict"; + +// Test the addon is cleaning up after itself when disabled. +add_task(async function test_disabled() { + await promiseAddonEnabled(); + + pinToURLBar(); + + SpecialPowers.Services.prefs.setBoolPref(PREF_WC_REPORTER_ENABLED, false); + await promisePageActionRemoved(); + + await BrowserTestUtils.withNewTab( + { gBrowser, url: "http://example.com" }, + async function() { + await openPageActions(); + is( + await isPanelItemPresent(), + false, + "Report Site Issue button is not shown on the popup panel." + ); + is( + await isURLButtonPresent(), + false, + "Report Site Issue is not shown on the url bar." + ); + } + ); + + await promiseAddonEnabled(); + + pinToURLBar(); + + await BrowserTestUtils.withNewTab( + { gBrowser, url: "http://example.com" }, + async function() { + await openPageActions(); + is( + await isPanelItemEnabled(), + true, + "Report Site Issue button is shown on the popup panel." + ); + is( + await isURLButtonPresent(), + true, + "Report Site Issue is shown on the url bar." + ); + } + ); + + // Shut down the addon at the end,or the new instance started when we re-enabled it will "leak". + SpecialPowers.Services.prefs.setBoolPref(PREF_WC_REPORTER_ENABLED, false); + await promisePageActionRemoved(); +}); diff --git a/browser/extensions/report-site-issue/test/browser/browser_report_site_issue.js b/browser/extensions/report-site-issue/test/browser/browser_report_site_issue.js new file mode 100644 index 0000000000..f3d34990ba --- /dev/null +++ b/browser/extensions/report-site-issue/test/browser/browser_report_site_issue.js @@ -0,0 +1,291 @@ +"use strict"; + +async function clickToReportAndAwaitReportTabLoad() { + await openPageActions(); + await isPanelItemEnabled(); + + // click on "report site issue" and wait for the new tab to open + const tab = await new Promise(resolve => { + gBrowser.tabContainer.addEventListener( + "TabOpen", + event => { + resolve(event.target); + }, + { once: true } + ); + document.getElementById(WC_PAGE_ACTION_PANEL_ID).click(); + }); + + // wait for the new tab to acknowledge that it received a screenshot + await BrowserTestUtils.waitForContentEvent( + gBrowser.selectedBrowser, + "ScreenshotReceived", + false, + null, + true + ); + + return tab; +} + +add_task(async function start_issue_server() { + requestLongerTimeout(2); + + const serverLanding = await startIssueServer(); + + // ./head.js sets the value for PREF_WC_REPORTER_ENDPOINT + await SpecialPowers.pushPrefEnv({ + set: [ + ["datareporting.healthreport.uploadEnabled", true], + [PREF_WC_REPORTER_ENABLED, true], + [PREF_WC_REPORTER_ENDPOINT, serverLanding], + ], + }); +}); + +/* Test that clicking on the Report Site Issue button opens a new tab + and sends a postMessaged blob to it. */ +add_task(async function test_opened_page() { + let tab1 = await BrowserTestUtils.openNewForegroundTab(gBrowser, TEST_PAGE); + let tab2 = await clickToReportAndAwaitReportTabLoad(); + + await SpecialPowers.spawn(tab2.linkedBrowser, [{ TEST_PAGE }], async function( + args + ) { + async function isGreen(dataUrl) { + const getPixel = await new Promise(resolve => { + const myCanvas = content.document.createElement("canvas"); + const ctx = myCanvas.getContext("2d"); + const img = new content.Image(); + img.onload = () => { + ctx.drawImage(img, 0, 0); + resolve((x, y) => { + return ctx.getImageData(x, y, 1, 1).data; + }); + }; + img.src = dataUrl; + }); + function isPixelGreenFuzzy(p) { + // jpeg, so it will be off slightly + const fuzz = 4; + return p[0] < fuzz && Math.abs(p[1] - 128) < fuzz && p[2] < fuzz; + } + ok(isPixelGreenFuzzy(getPixel(0, 0)), "The pixels were green"); + } + + let doc = content.document; + let urlParam = doc.getElementById("url").innerText; + let preview = doc.getElementById("screenshot-preview"); + const URL = + "http://example.com/browser/browser/extensions/report-site-issue/test/browser/test.html"; + is( + urlParam, + args.TEST_PAGE, + "Reported page is correctly added to the url param" + ); + + let docShell = content.docShell; + is( + typeof docShell.getHasTrackingContentBlocked, + "function", + "docShell.hasTrackingContentBlocked is available" + ); + + let detailsParam = doc.getElementById("details").innerText; + const details = JSON.parse(detailsParam); + ok( + typeof details == "object", + "Details param is a stringified JSON object." + ); + ok(Array.isArray(details.consoleLog), "Details has a consoleLog array."); + + const log1 = details.consoleLog[0]; + ok(log1.log[0] === null, "Can handle degenerate console logs"); + ok(log1.level === "log", "Reports correct log level"); + ok(log1.uri === URL, "Reports correct url"); + ok(log1.pos === "7:13", "Reports correct line and column"); + + const log2 = details.consoleLog[1]; + ok(log2.log[0] === "colored message", "Can handle fancy console logs"); + ok(log2.level === "error", "Reports correct log level"); + ok(log2.uri === URL, "Reports correct url"); + ok(log2.pos === "8:13", "Reports correct line and column"); + + const log3 = details.consoleLog[2]; + const loggedObject = log3.log[0]; + is(loggedObject.testobj, "{...}", "Reports object inside object"); + is(loggedObject.testnumber, 1, "Reports number inside object"); + is(loggedObject.testArray, "(4)[...]", "Reports array inside object"); + is(loggedObject.testUndf, "undefined", "Reports undefined inside object"); + is(loggedObject.testNull, null, "Reports null inside object"); + is( + loggedObject.testFunc, + undefined, + "Reports function inside object as undefined due to security reasons" + ); + is(loggedObject.testString, "string", "Reports string inside object"); + is(loggedObject.c, "{...}", "Reports circular reference inside object"); + is( + Object.keys(loggedObject).length, + 10, + "Preview has 10 keys inside object" + ); + ok(log3.level === "log", "Reports correct log level"); + ok(log3.uri === URL, "Reports correct url"); + ok(log3.pos === "23:13", "Reports correct line and column"); + + const log4 = details.consoleLog[3]; + const loggedArray = log4.log[0]; + is(loggedArray[0], "string", "Reports string inside array"); + is(loggedArray[1], "{...}", "Reports object inside array"); + is(loggedArray[2], null, "Reports null inside array"); + is(loggedArray[3], 90, "Reports number inside array"); + is(loggedArray[4], "undefined", "Reports undefined inside array"); + is( + loggedArray[5], + "undefined", + "Reports function inside array as undefined due to security reasons" + ); + is(loggedArray[6], "(4)[...]", "Reports array inside array"); + is(loggedArray[7], "(8)[...]", "Reports circular array inside array"); + + const log5 = details.consoleLog[4]; + ok( + log5.log[0].match(/TypeError: .*document\.access is undefined/), + "Script errors are logged" + ); + ok(log5.level === "error", "Reports correct log level"); + ok(log5.uri === URL, "Reports correct url"); + ok(log5.pos === "35:5", "Reports correct line and column"); + + ok(typeof details.buildID == "string", "Details has a buildID string."); + ok(typeof details.channel == "string", "Details has a channel string."); + ok( + typeof details.hasTouchScreen == "boolean", + "Details has a hasTouchScreen flag." + ); + ok( + typeof details.hasFastClick == "undefined", + "Details does not have FastClick if not found." + ); + ok( + typeof details.hasMobify == "undefined", + "Details does not have Mobify if not found." + ); + ok( + typeof details.hasMarfeel == "undefined", + "Details does not have Marfeel if not found." + ); + ok( + typeof details["mixed active content blocked"] == "boolean", + "Details has a mixed active content blocked flag." + ); + ok( + typeof details["mixed passive content blocked"] == "boolean", + "Details has a mixed passive content blocked flag." + ); + ok( + typeof details["tracking content blocked"] == "string", + "Details has a tracking content blocked string." + ); + ok( + typeof details["gfx.webrender.all"] == "boolean", + "Details has gfx.webrender.all." + ); + ok( + typeof details["gfx.webrender.blob-images"] == "boolean", + "Details has gfx.webrender.blob-images." + ); + ok( + typeof details["gfx.webrender.enabled"] == "boolean", + "Details has gfx.webrender.enabled." + ); + ok( + typeof details["image.mem.shared"] == "boolean", + "Details has image.mem.shared." + ); + + is( + preview.innerText, + "Pass", + "A Blob object was successfully transferred to the test page." + ); + + const bgUrl = preview.style.backgroundImage.match(/url\(\"(.*)\"\)/)[1]; + ok( + bgUrl.startsWith("data:image/jpeg;base64,"), + "A jpeg screenshot was successfully postMessaged" + ); + await isGreen(bgUrl); + }); + + BrowserTestUtils.removeTab(tab2); + BrowserTestUtils.removeTab(tab1); +}); + +add_task(async function test_framework_detection() { + let tab1 = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + FRAMEWORKS_TEST_PAGE + ); + let tab2 = await clickToReportAndAwaitReportTabLoad(); + + await SpecialPowers.spawn(tab2.linkedBrowser, [], async function(args) { + let doc = content.document; + let detailsParam = doc.getElementById("details").innerText; + const details = JSON.parse(detailsParam); + ok( + typeof details == "object", + "Details param is a stringified JSON object." + ); + is(details.hasFastClick, true, "FastClick was found."); + is(details.hasMobify, true, "Mobify was found."); + is(details.hasMarfeel, true, "Marfeel was found."); + }); + + BrowserTestUtils.removeTab(tab2); + BrowserTestUtils.removeTab(tab1); +}); + +add_task(async function test_fastclick_detection() { + let tab1 = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + FASTCLICK_TEST_PAGE + ); + let tab2 = await clickToReportAndAwaitReportTabLoad(); + + await SpecialPowers.spawn(tab2.linkedBrowser, [], async function(args) { + let doc = content.document; + let detailsParam = doc.getElementById("details").innerText; + const details = JSON.parse(detailsParam); + ok( + typeof details == "object", + "Details param is a stringified JSON object." + ); + is(details.hasFastClick, true, "FastClick was found."); + }); + + BrowserTestUtils.removeTab(tab2); + BrowserTestUtils.removeTab(tab1); +}); + +add_task(async function test_framework_label() { + let tab1 = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + FRAMEWORKS_TEST_PAGE + ); + let tab2 = await clickToReportAndAwaitReportTabLoad(); + + await SpecialPowers.spawn(tab2.linkedBrowser, [], async function(args) { + let doc = content.document; + let labelParam = doc.getElementById("label").innerText; + const label = JSON.parse(labelParam); + ok(typeof label == "object", "Label param is a stringified JSON object."); + is(label.includes("type-fastclick"), true, "FastClick was found."); + is(label.includes("type-mobify"), true, "Mobify was found."); + is(label.includes("type-marfeel"), true, "Marfeel was found."); + }); + + BrowserTestUtils.removeTab(tab2); + BrowserTestUtils.removeTab(tab1); +}); diff --git a/browser/extensions/report-site-issue/test/browser/fastclick.html b/browser/extensions/report-site-issue/test/browser/fastclick.html new file mode 100644 index 0000000000..e13329dfd7 --- /dev/null +++ b/browser/extensions/report-site-issue/test/browser/fastclick.html @@ -0,0 +1,11 @@ +<!DOCTYPE html> +<meta charset="utf-8"> +<script> + "use strict"; + function ObscuredFastClick() { + } + ObscuredFastClick.prototype = { + needsClick: () => {}, + }; + window.someRandomVar = new ObscuredFastClick(); +</script> diff --git a/browser/extensions/report-site-issue/test/browser/frameworks.html b/browser/extensions/report-site-issue/test/browser/frameworks.html new file mode 100644 index 0000000000..14df387ec9 --- /dev/null +++ b/browser/extensions/report-site-issue/test/browser/frameworks.html @@ -0,0 +1,8 @@ +<!DOCTYPE html> +<meta charset="utf-8"> +<script> + "use strict"; + function FastClick() {} + function marfeel() {} + var Mobify = {Tag: "something"}; +</script> diff --git a/browser/extensions/report-site-issue/test/browser/head.js b/browser/extensions/report-site-issue/test/browser/head.js new file mode 100644 index 0000000000..2779e7dfbb --- /dev/null +++ b/browser/extensions/report-site-issue/test/browser/head.js @@ -0,0 +1,204 @@ +"use strict"; + +ChromeUtils.defineModuleGetter( + this, + "AddonManager", + "resource://gre/modules/AddonManager.jsm" +); + +const { Management } = ChromeUtils.import( + "resource://gre/modules/Extension.jsm", + null +); + +const PREF_WC_REPORTER_ENABLED = "extensions.webcompat-reporter.enabled"; +const PREF_WC_REPORTER_ENDPOINT = + "extensions.webcompat-reporter.newIssueEndpoint"; + +const TEST_ROOT = getRootDirectory(gTestPath).replace( + "chrome://mochitests/content", + "http://example.com" +); +const TEST_PAGE = TEST_ROOT + "test.html"; +const FRAMEWORKS_TEST_PAGE = TEST_ROOT + "frameworks.html"; +const FASTCLICK_TEST_PAGE = TEST_ROOT + "fastclick.html"; +const NEW_ISSUE_PAGE = TEST_ROOT + "webcompat.html"; + +const WC_ADDON_ID = "webcompat-reporter@mozilla.org"; + +const WC_PAGE_ACTION_ID = "webcompat-reporter_mozilla_org"; +const WC_PAGE_ACTION_PANEL_ID = + "pageAction-panel-webcompat-reporter_mozilla_org"; +const WC_PAGE_ACTION_URLBAR_ID = + "pageAction-urlbar-webcompat-reporter_mozilla_org"; + +const oldPAadd = PageActions.addAction; +const placedSignal = "webext-page-action-placed"; +PageActions.addAction = function(action) { + oldPAadd.call(this, action); + if (action.id === WC_PAGE_ACTION_ID) { + Services.obs.notifyObservers(null, placedSignal); + } + return action; +}; +const oldPAremoved = PageActions.onActionRemoved; +const removedSignal = "webext-page-action-removed"; +PageActions.onActionRemoved = function(action) { + oldPAremoved.call(this, action); + if (action.id === WC_PAGE_ACTION_ID) { + Services.obs.notifyObservers(null, removedSignal); + } + return action; +}; + +function promisePageActionSignal(signal) { + return new Promise(done => { + const obs = function() { + Services.obs.removeObserver(obs, signal); + done(); + }; + Services.obs.addObserver(obs, signal); + }); +} + +function promisePageActionPlaced() { + return promisePageActionSignal(placedSignal); +} + +function promisePageActionRemoved() { + return promisePageActionSignal(removedSignal); +} + +async function promiseAddonEnabled() { + const addon = await AddonManager.getAddonByID(WC_ADDON_ID); + if (addon.isActive) { + return; + } + const pref = SpecialPowers.Services.prefs.getBoolPref( + PREF_WC_REPORTER_ENABLED, + false + ); + if (!pref) { + SpecialPowers.Services.prefs.setBoolPref(PREF_WC_REPORTER_ENABLED, true); + } + await promisePageActionPlaced(); +} + +async function isPanelItemEnabled() { + const icon = document.getElementById(WC_PAGE_ACTION_PANEL_ID); + return icon && !icon.disabled; +} + +async function isPanelItemDisabled() { + const icon = document.getElementById(WC_PAGE_ACTION_PANEL_ID); + return icon && icon.disabled; +} + +async function isPanelItemPresent() { + return document.getElementById(WC_PAGE_ACTION_PANEL_ID) !== null; +} + +async function isURLButtonPresent() { + return document.getElementById(WC_PAGE_ACTION_URLBAR_ID) !== null; +} + +function openPageActions() { + let dwu = window.windowUtils; + return BrowserTestUtils.waitForCondition(() => { + // Wait for the main page action button to become visible. It's hidden for + // some URIs, so depending on when this is called, it may not yet be quite + // visible. It's up to the caller to make sure it will be visible. + info("Waiting for main page action button to have non-0 size"); + let bounds = dwu.getBoundsWithoutFlushing( + BrowserPageActions.mainButtonNode + ); + return bounds.width > 0 && bounds.height > 0; + }) + .then(() => { + // Wait for the panel to become open, by clicking the button if necessary. + info("Waiting for main page action panel to be open"); + if (BrowserPageActions.panelNode.state == "open") { + return Promise.resolve(); + } + let shownPromise = promisePageActionPanelShown(); + EventUtils.synthesizeMouseAtCenter(BrowserPageActions.mainButtonNode, {}); + return shownPromise; + }) + .then(() => { + // Wait for items in the panel to become visible. + return promisePageActionViewChildrenVisible( + BrowserPageActions.mainViewNode + ); + }); +} + +function promisePageActionPanelShown() { + return new Promise(resolve => { + if (BrowserPageActions.panelNode.state == "open") { + executeSoon(resolve); + return; + } + BrowserPageActions.panelNode.addEventListener( + "popupshown", + () => { + executeSoon(resolve); + }, + { once: true } + ); + }); +} + +function promisePageActionViewChildrenVisible(panelViewNode) { + info( + "promisePageActionViewChildrenVisible waiting for a child node to be visible" + ); + let dwu = window.windowUtils; + return BrowserTestUtils.waitForCondition(() => { + let bodyNode = panelViewNode.firstElementChild; + for (let childNode of bodyNode.children) { + let bounds = dwu.getBoundsWithoutFlushing(childNode); + if (bounds.width > 0 && bounds.height > 0) { + return true; + } + } + return false; + }); +} + +function pinToURLBar() { + PageActions.actionForID(WC_PAGE_ACTION_ID).pinnedToUrlbar = true; +} + +function unpinFromURLBar() { + PageActions.actionForID(WC_PAGE_ACTION_ID).pinnedToUrlbar = false; +} + +async function startIssueServer() { + const landingTemplate = await new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + xhr.open("GET", NEW_ISSUE_PAGE); + xhr.onload = () => { + resolve(xhr.responseText); + }; + xhr.onerror = reject; + xhr.send(); + }); + + const { HttpServer } = ChromeUtils.import( + "resource://testing-common/httpd.js" + ); + const server = new HttpServer(); + + registerCleanupFunction(async function cleanup() { + await new Promise(resolve => server.stop(resolve)); + }); + + server.registerPathHandler("/new", function(request, response) { + response.setHeader("Content-Type", "text/html", false); + response.setStatusLine(request.httpVersion, 200, "OK"); + response.write(landingTemplate); + }); + + server.start(-1); + return `http://localhost:${server.identity.primaryPort}/new`; +} diff --git a/browser/extensions/report-site-issue/test/browser/test.html b/browser/extensions/report-site-issue/test/browser/test.html new file mode 100644 index 0000000000..e2bae1d80a --- /dev/null +++ b/browser/extensions/report-site-issue/test/browser/test.html @@ -0,0 +1,39 @@ +<!DOCTYPE html> +<meta charset="utf-8"> +<script> + /* eslint-disable no-console */ + /* eslint-disable no-unused-expressions */ + "use strict"; + console.log(null); + console.error("%ccolored message", "background:green; color:white"); + const obj = { + testobj: {}, + testnumber: 1, + testArray: [1, {}, 2, 555], + testUndf: undefined, + testNull: null, + testFunc() {}, + testString: 'string', + prop1: 'prop1', + prop2: 'prop2' + }; + obj.c = obj; + obj.prop3 = 'prop3'; + obj.prop4 = 'prop4'; + console.log(obj); + const arr = [ + 'string', + {test: 'obj'}, + null, + 90, + undefined, + function() {}, + [1, {}, 2, 555] + ]; + arr.push(arr); + console.log(arr); + document.access.non.existent.property.to.trigger.error; +</script> +<style> + body {background: rgb(0, 128, 0);} +</style> diff --git a/browser/extensions/report-site-issue/test/browser/webcompat.html b/browser/extensions/report-site-issue/test/browser/webcompat.html new file mode 100644 index 0000000000..872c8917b7 --- /dev/null +++ b/browser/extensions/report-site-issue/test/browser/webcompat.html @@ -0,0 +1,85 @@ +<!DOCTYPE html> +<meta charset="utf-8"> +<style> + #screenshot-preview {width: 200px; height: 200px;} +</style> +<div id="url">$$URL$$</div> +<div id="details">$$DETAILS$$</div> +<div id="label">$$LABEL$$</div> +<div id="screenshot-preview">Fail</div> +<script> +"use strict"; +let preview = document.getElementById("screenshot-preview"); +const CONFIG = { + url: { + element: document.getElementById("url") + }, + details: { + element: document.getElementById("details"), + toStringify: true + }, + extra_labels: { + element: document.getElementById("label"), + toStringify: true + }, +}; + +function getBlobAsDataURL(blob) { + return new Promise((resolve, reject) => { + let reader = new FileReader(); + + // eslint-disable-next-line mozilla/balanced-listeners + reader.addEventListener("error", (e) => { + reject(`There was an error reading the blob: ${e.type}`); + }); + + // eslint-disable-next-line mozilla/balanced-listeners + reader.addEventListener("load", (e) => { + resolve(e.target.result); + }); + + reader.readAsDataURL(blob); + }); +} + +function setPreviewBG(backgroundData) { + return new Promise((resolve) => { + preview.style.background = `url(${backgroundData})`; + resolve(); + }); +} + +function sendReceivedEvent() { + window.dispatchEvent(new CustomEvent("ScreenshotReceived", {bubbles: true})); +} + +function prepareContent(toStringify, content) { + if (toStringify) { + return JSON.stringify(content) + } + + return content; +} + +function appendMessage(message) { + for (const key in CONFIG) { + if (key in message) { + const field = CONFIG[key]; + field.element.innerText = prepareContent(field.toStringify, message[key]); + } + } +} + +// eslint-disable-next-line mozilla/balanced-listeners +window.addEventListener("message", function(event) { + if (event.data.screenshot instanceof Blob) { + preview.innerText = "Pass"; + } + + if (event.data.message) { + appendMessage(event.data.message); + } + + getBlobAsDataURL(event.data.screenshot).then(setPreviewBG).then(sendReceivedEvent); +}); +</script> diff --git a/browser/extensions/screenshots/assertIsBlankDocument.js b/browser/extensions/screenshots/assertIsBlankDocument.js new file mode 100644 index 0000000000..2f39374215 --- /dev/null +++ b/browser/extensions/screenshots/assertIsBlankDocument.js @@ -0,0 +1,16 @@ +/* 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/. */ + +/** For use inside an iframe onload function, throws an Error if iframe src is not blank.html + + Should be applied *inside* catcher.watchFunction +*/ +this.assertIsBlankDocument = function assertIsBlankDocument(doc) { + if (doc.documentURI !== browser.extension.getURL("blank.html")) { + const exc = new Error("iframe URL does not match expected blank.html"); + exc.foundURL = doc.documentURI; + throw exc; + } +}; +null; diff --git a/browser/extensions/screenshots/assertIsTrusted.js b/browser/extensions/screenshots/assertIsTrusted.js new file mode 100644 index 0000000000..9065d8603b --- /dev/null +++ b/browser/extensions/screenshots/assertIsTrusted.js @@ -0,0 +1,24 @@ +/* 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/. */ + +/** For use with addEventListener, assures that any events have event.isTrusted set to true + https://developer.mozilla.org/en-US/docs/Web/API/Event/isTrusted + Should be applied *inside* catcher.watchFunction +*/ +this.assertIsTrusted = function assertIsTrusted(handlerFunction) { + return function(event) { + if (!event) { + const exc = new Error("assertIsTrusted did not get an event"); + exc.noPopup = true; + throw exc; + } + if (!event.isTrusted) { + const exc = new Error(`Received untrusted event (type: ${event.type})`); + exc.noPopup = true; + throw exc; + } + return handlerFunction.call(this, event); + }; +}; +null; diff --git a/browser/extensions/screenshots/background/analytics.js b/browser/extensions/screenshots/background/analytics.js new file mode 100644 index 0000000000..37049e3b77 --- /dev/null +++ b/browser/extensions/screenshots/background/analytics.js @@ -0,0 +1,367 @@ +/* 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/. */ + +/* globals main, auth, browser, catcher, deviceInfo, communication, log */ + +"use strict"; + +this.analytics = (function() { + const exports = {}; + + const GA_PORTION = 0.1; // 10% of users will send to the server/GA + // This is set from storage, or randomly; if it is less that GA_PORTION then we send analytics: + let myGaSegment = 1; + let telemetryPrefKnown = false; + let telemetryEnabled; + // If we ever get a 410 Gone response (or 404) from the server, we'll stop trying to send events for the rest + // of the session + let hasReturnedGone = false; + // If there's this many entirely failed responses (e.g., server can't be contacted), then stop sending events + // for the rest of the session: + let serverFailedResponses = 3; + + const EVENT_BATCH_DURATION = 1000; // ms for setTimeout + let pendingEvents = []; + let pendingTimings = []; + let eventsTimeoutHandle, timingsTimeoutHandle; + const fetchOptions = { + method: "POST", + mode: "cors", + headers: { "content-type": "application/json" }, + credentials: "include", + }; + + function shouldSendEvents() { + return !hasReturnedGone && serverFailedResponses > 0 && myGaSegment < GA_PORTION; + } + + function flushEvents() { + if (pendingEvents.length === 0) { + return; + } + + const eventsUrl = `${main.getBackend()}/event`; + const deviceId = auth.getDeviceId(); + const sendTime = Date.now(); + + pendingEvents.forEach(event => { + event.queueTime = sendTime - event.eventTime; + log.info(`sendEvent ${event.event}/${event.action}/${event.label || "none"} ${JSON.stringify(event.options)}`); + }); + + const body = JSON.stringify({deviceId, events: pendingEvents}); + const fetchRequest = fetch(eventsUrl, Object.assign({body}, fetchOptions)); + fetchWatcher(fetchRequest); + pendingEvents = []; + } + + function flushTimings() { + if (pendingTimings.length === 0) { + return; + } + + const timingsUrl = `${main.getBackend()}/timing`; + const deviceId = auth.getDeviceId(); + const body = JSON.stringify({deviceId, timings: pendingTimings}); + const fetchRequest = fetch(timingsUrl, Object.assign({body}, fetchOptions)); + fetchWatcher(fetchRequest); + pendingTimings.forEach(t => { + log.info(`sendTiming ${t.timingCategory}/${t.timingLabel}/${t.timingVar}: ${t.timingValue}`); + }); + pendingTimings = []; + } + + function sendTiming(timingLabel, timingVar, timingValue) { + // sendTiming is only called in response to sendEvent, so no need to check + // the telemetry pref again here. + if (!shouldSendEvents()) { + return; + } + const timingCategory = "addon"; + pendingTimings.push({ + timingCategory, + timingLabel, + timingVar, + timingValue, + }); + if (!timingsTimeoutHandle) { + timingsTimeoutHandle = setTimeout(() => { + timingsTimeoutHandle = null; + flushTimings(); + }, EVENT_BATCH_DURATION); + } + } + + exports.sendEvent = function(action, label, options) { + const eventCategory = "addon"; + if (!telemetryPrefKnown) { + log.warn("sendEvent called before we were able to refresh"); + return Promise.resolve(); + } + if (!telemetryEnabled) { + log.info(`Cancelled sendEvent ${eventCategory}/${action}/${label || "none"} ${JSON.stringify(options)}`); + return Promise.resolve(); + } + measureTiming(action, label); + // Internal-only events are used for measuring time between events, + // but aren't submitted to GA. + if (action === "internal") { + return Promise.resolve(); + } + if (typeof label === "object" && (!options)) { + options = label; + label = undefined; + } + options = options || {}; + + // Don't send events if in private browsing. + if (options.incognito) { + return Promise.resolve(); + } + + // Don't include in event data. + delete options.incognito; + + const di = deviceInfo(); + options.applicationName = di.appName; + options.applicationVersion = di.addonVersion; + const abTests = auth.getAbTests(); + for (const [gaField, value] of Object.entries(abTests)) { + options[gaField] = value; + } + if (!shouldSendEvents()) { + // We don't want to save or send the events anymore + return Promise.resolve(); + } + pendingEvents.push({ + eventTime: Date.now(), + event: eventCategory, + action, + label, + options, + }); + if (!eventsTimeoutHandle) { + eventsTimeoutHandle = setTimeout(() => { + eventsTimeoutHandle = null; + flushEvents(); + }, EVENT_BATCH_DURATION); + } + // This function used to return a Promise that was not used at any of the + // call sites; doing this simply maintains that interface. + return Promise.resolve(); + }; + + exports.incrementCount = function(scalar) { + const allowedScalars = ["download", "upload", "copy"]; + if (!allowedScalars.includes(scalar)) { + const err = `incrementCount passed an unrecognized scalar ${scalar}`; + log.warn(err); + return Promise.resolve(); + } + return browser.telemetry.scalarAdd(`screenshots.${scalar}`, 1).catch(err => { + log.warn(`incrementCount failed with error: ${err}`); + }); + }; + + exports.refreshTelemetryPref = function() { + return browser.telemetry.canUpload().then((result) => { + telemetryPrefKnown = true; + telemetryEnabled = result; + }, (error) => { + // If there's an error reading the pref, we should assume that we shouldn't send data + telemetryPrefKnown = true; + telemetryEnabled = false; + throw error; + }); + }; + + exports.isTelemetryEnabled = function() { + catcher.watchPromise(exports.refreshTelemetryPref()); + return telemetryEnabled; + }; + + const timingData = new Map(); + + // Configuration for filtering the sendEvent stream on start/end events. + // When start or end events occur, the time is recorded. + // When end events occur, the elapsed time is calculated and submitted + // via `sendEvent`, where action = "perf-response-time", label = name of rule, + // and cd1 value is the elapsed time in milliseconds. + // If a cancel event happens between the start and end events, the start time + // is deleted. + const rules = [{ + name: "page-action", + start: { action: "start-shot", label: "toolbar-button" }, + end: { action: "internal", label: "unhide-preselection-frame" }, + cancel: [ + { action: "cancel-shot" }, + { action: "internal", label: "document-hidden" }, + { action: "internal", label: "unhide-onboarding-frame" }, + ], + }, { + name: "context-menu", + start: { action: "start-shot", label: "context-menu" }, + end: { action: "internal", label: "unhide-preselection-frame" }, + cancel: [ + { action: "cancel-shot" }, + { action: "internal", label: "document-hidden" }, + { action: "internal", label: "unhide-onboarding-frame" }, + ], + }, { + name: "page-action-onboarding", + start: { action: "start-shot", label: "toolbar-button" }, + end: { action: "internal", label: "unhide-onboarding-frame" }, + cancel: [ + { action: "cancel-shot" }, + { action: "internal", label: "document-hidden" }, + { action: "internal", label: "unhide-preselection-frame" }, + ], + }, { + name: "context-menu-onboarding", + start: { action: "start-shot", label: "context-menu" }, + end: { action: "internal", label: "unhide-onboarding-frame" }, + cancel: [ + { action: "cancel-shot" }, + { action: "internal", label: "document-hidden" }, + { action: "internal", label: "unhide-preselection-frame" }, + ], + }, { + name: "capture-full-page", + start: { action: "capture-full-page" }, + end: { action: "internal", label: "unhide-preview-frame" }, + cancel: [ + { action: "cancel-shot" }, + { action: "internal", label: "document-hidden" }, + ], + }, { + name: "capture-visible", + start: { action: "capture-visible" }, + end: { action: "internal", label: "unhide-preview-frame" }, + cancel: [ + { action: "cancel-shot" }, + { action: "internal", label: "document-hidden" }, + ], + }, { + name: "make-selection", + start: { action: "make-selection" }, + end: { action: "internal", label: "unhide-selection-frame" }, + cancel: [ + { action: "cancel-shot" }, + { action: "internal", label: "document-hidden" }, + ], + }, { + name: "save-shot", + start: { action: "save-shot" }, + end: { action: "internal", label: "open-shot-tab" }, + cancel: [{ action: "cancel-shot" }, { action: "upload-failed" }], + }, { + name: "save-visible", + start: { action: "save-visible" }, + end: { action: "internal", label: "open-shot-tab" }, + cancel: [{ action: "cancel-shot" }, { action: "upload-failed" }], + }, { + name: "save-full-page", + start: { action: "save-full-page" }, + end: { action: "internal", label: "open-shot-tab" }, + cancel: [{ action: "cancel-shot" }, { action: "upload-failed" }], + }, { + name: "save-full-page-truncated", + start: { action: "save-full-page-truncated" }, + end: { action: "internal", label: "open-shot-tab" }, + cancel: [{ action: "cancel-shot" }, { action: "upload-failed" }], + }, { + name: "download-shot", + start: { action: "download-shot" }, + end: { action: "internal", label: "deactivate" }, + cancel: [ + { action: "cancel-shot" }, + { action: "internal", label: "document-hidden" }, + ], + }, { + name: "download-full-page", + start: { action: "download-full-page" }, + end: { action: "internal", label: "deactivate" }, + cancel: [ + { action: "cancel-shot" }, + { action: "internal", label: "document-hidden" }, + ], + }, { + name: "download-full-page-truncated", + start: { action: "download-full-page-truncated" }, + end: { action: "internal", label: "deactivate" }, + cancel: [ + { action: "cancel-shot" }, + { action: "internal", label: "document-hidden" }, + ], + }, { + name: "download-visible", + start: { action: "download-visible" }, + end: { action: "internal", label: "deactivate" }, + cancel: [ + { action: "cancel-shot" }, + { action: "internal", label: "document-hidden" }, + ], + }]; + + // Match a filter (action and optional label) against an action and label. + function match(filter, action, label) { + return filter.label ? + filter.action === action && filter.label === label : + filter.action === action; + } + + function anyMatches(filters, action, label) { + return filters.some(filter => match(filter, action, label)); + } + + function measureTiming(action, label) { + rules.forEach(r => { + if (anyMatches(r.cancel, action, label)) { + delete timingData[r.name]; + } else if (match(r.start, action, label)) { + timingData[r.name] = Math.round(performance.now()); + } else if (timingData[r.name] && match(r.end, action, label)) { + const endTime = Math.round(performance.now()); + const elapsed = endTime - timingData[r.name]; + sendTiming("perf-response-time", r.name, elapsed); + delete timingData[r.name]; + } + }); + } + + function fetchWatcher(request) { + request.then(response => { + if (response.status === 410 || response.status === 404) { // Gone + hasReturnedGone = true; + pendingEvents = []; + pendingTimings = []; + } + if (!response.ok) { + log.debug(`Error code in event response: ${response.status} ${response.statusText}`); + } + }).catch(error => { + serverFailedResponses--; + if (serverFailedResponses <= 0) { + log.info(`Server is not responding, no more events will be sent`); + pendingEvents = []; + pendingTimings = []; + } + log.debug(`Error event in response: ${error}`); + }); + } + + async function init() { + const result = await browser.storage.local.get(["myGaSegment"]); + if (!result.myGaSegment) { + myGaSegment = Math.random(); + await browser.storage.local.set({myGaSegment}); + } else { + myGaSegment = result.myGaSegment; + } + } + + init(); + + return exports; +})(); diff --git a/browser/extensions/screenshots/background/auth.js b/browser/extensions/screenshots/background/auth.js new file mode 100644 index 0000000000..f6cfd0f9aa --- /dev/null +++ b/browser/extensions/screenshots/background/auth.js @@ -0,0 +1,224 @@ +/* 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/. */ + +/* globals log */ +/* globals main, makeUuid, deviceInfo, analytics, catcher, buildSettings, communication */ + +"use strict"; + +this.auth = (function() { + const exports = {}; + + let registrationInfo; + let initialized = false; + let authHeader = null; + let sentryPublicDSN = null; + let abTests = {}; + let accountId = null; + + const fetchStoredInfo = catcher.watchPromise( + browser.storage.local.get(["registrationInfo", "abTests"]).then((result) => { + if (result.abTests) { + abTests = result.abTests; + } + if (result.registrationInfo) { + registrationInfo = result.registrationInfo; + } + })); + + function getRegistrationInfo() { + if (!registrationInfo) { + registrationInfo = generateRegistrationInfo(); + log.info("Generating new device authentication ID", registrationInfo); + browser.storage.local.set({registrationInfo}); + } + return registrationInfo; + } + + exports.getDeviceId = function() { + return registrationInfo && registrationInfo.deviceId; + }; + + function generateRegistrationInfo() { + const info = { + deviceId: `anon${makeUuid()}`, + secret: makeUuid(), + registered: false, + }; + return info; + } + + function register() { + return new Promise((resolve, reject) => { + const registerUrl = main.getBackend() + "/api/register"; + // TODO: replace xhr with Fetch #2261 + const req = new XMLHttpRequest(); + req.open("POST", registerUrl); + req.setRequestHeader("content-type", "application/json"); + req.onload = catcher.watchFunction(() => { + if (req.status === 200) { + log.info("Registered login"); + initialized = true; + saveAuthInfo(JSON.parse(req.responseText)); + resolve(true); + analytics.sendEvent("registered"); + } else { + analytics.sendEvent("register-failed", `bad-response-${req.status}`); + log.warn("Error in response:", req.responseText); + const exc = new Error("Bad response: " + req.status); + exc.popupMessage = "LOGIN_ERROR"; + reject(exc); + } + }); + req.onerror = catcher.watchFunction(() => { + analytics.sendEvent("register-failed", "connection-error"); + const exc = new Error("Error contacting server"); + exc.popupMessage = "LOGIN_CONNECTION_ERROR"; + reject(exc); + }); + req.send(JSON.stringify({ + deviceId: registrationInfo.deviceId, + secret: registrationInfo.secret, + deviceInfo: JSON.stringify(deviceInfo()), + })); + }); + } + + function login(options) { + const { ownershipCheck, noRegister } = options || {}; + return new Promise((resolve, reject) => { + return fetchStoredInfo.then(() => { + const registrationInfo = getRegistrationInfo(); + const loginUrl = main.getBackend() + "/api/login"; + // TODO: replace xhr with Fetch #2261 + const req = new XMLHttpRequest(); + req.open("POST", loginUrl); + req.onload = catcher.watchFunction(() => { + if (req.status === 404) { + if (noRegister) { + resolve(false); + } else { + resolve(register()); + } + } else if (req.status >= 300) { + log.warn("Error in response:", req.responseText); + const exc = new Error("Could not log in: " + req.status); + exc.popupMessage = "LOGIN_ERROR"; + analytics.sendEvent("login-failed", `bad-response-${req.status}`); + reject(exc); + } else if (req.status === 0) { + const error = new Error("Could not log in, server unavailable"); + error.popupMessage = "LOGIN_CONNECTION_ERROR"; + analytics.sendEvent("login-failed", "connection-error"); + reject(error); + } else { + initialized = true; + const jsonResponse = JSON.parse(req.responseText); + log.info("Screenshots logged in"); + analytics.sendEvent("login"); + saveAuthInfo(jsonResponse); + if (ownershipCheck) { + resolve({isOwner: jsonResponse.isOwner}); + } else { + resolve(true); + } + } + }); + req.onerror = catcher.watchFunction(() => { + analytics.sendEvent("login-failed", "connection-error"); + const exc = new Error("Connection failed"); + exc.url = loginUrl; + exc.popupMessage = "CONNECTION_ERROR"; + reject(exc); + }); + req.setRequestHeader("content-type", "application/json"); + req.send(JSON.stringify({ + deviceId: registrationInfo.deviceId, + secret: registrationInfo.secret, + deviceInfo: JSON.stringify(deviceInfo()), + ownershipCheck, + })); + }); + }); + } + + function saveAuthInfo(responseJson) { + accountId = responseJson.accountId; + if (responseJson.sentryPublicDSN) { + sentryPublicDSN = responseJson.sentryPublicDSN; + } + if (responseJson.authHeader) { + authHeader = responseJson.authHeader; + if (!registrationInfo.registered) { + registrationInfo.registered = true; + catcher.watchPromise(browser.storage.local.set({registrationInfo})); + } + } + if (responseJson.abTests) { + abTests = responseJson.abTests; + catcher.watchPromise(browser.storage.local.set({abTests})); + } + } + + exports.maybeLogin = function() { + if (!registrationInfo) { + return Promise.resolve(); + } + + return exports.authHeaders(); + }; + + exports.authHeaders = function() { + let initPromise = Promise.resolve(); + if (!initialized) { + initPromise = login(); + } + return initPromise.then(() => { + if (authHeader) { + return {"x-screenshots-auth": authHeader}; + } + log.warn("No auth header available"); + return {}; + }); + }; + + exports.getSentryPublicDSN = function() { + return sentryPublicDSN || buildSettings.defaultSentryDsn; + }; + + exports.getAbTests = function() { + return abTests; + }; + + exports.isRegistered = function() { + return registrationInfo && registrationInfo.registered; + }; + + communication.register("getAuthInfo", (sender, ownershipCheck) => { + return fetchStoredInfo.then(() => { + // If a device id was never generated, report back accordingly. + if (!registrationInfo) { + return null; + } + + return exports.authHeaders().then((authHeaders) => { + let info = registrationInfo; + if (info.registered) { + return login({ownershipCheck}).then((result) => { + return { + isOwner: result && result.isOwner, + deviceId: registrationInfo.deviceId, + accountId, + authHeaders, + }; + }); + } + info = Object.assign({authHeaders}, info); + return info; + }); + }); +}); + + return exports; +})(); diff --git a/browser/extensions/screenshots/background/communication.js b/browser/extensions/screenshots/background/communication.js new file mode 100644 index 0000000000..7db2b188f2 --- /dev/null +++ b/browser/extensions/screenshots/background/communication.js @@ -0,0 +1,53 @@ +/* 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/. */ + +/* globals catcher, log */ + +"use strict"; + +this.communication = (function() { + const exports = {}; + + const registeredFunctions = {}; + + exports.onMessage = catcher.watchFunction((req, sender, sendResponse) => { + if (!(req.funcName in registeredFunctions)) { + log.error(`Received unknown internal message type ${req.funcName}`); + sendResponse({type: "error", name: "Unknown message type"}); + return; + } + if (!Array.isArray(req.args)) { + log.error("Received message with no .args list"); + sendResponse({type: "error", name: "No .args"}); + return; + } + const func = registeredFunctions[req.funcName]; + let result; + try { + req.args.unshift(sender); + result = func.apply(null, req.args); + } catch (e) { + log.error(`Error in ${req.funcName}:`, e, e.stack); + // FIXME: should consider using makeError from catcher here: + sendResponse({type: "error", message: e + "", errorCode: e.errorCode, popupMessage: e.popupMessage}); + return; + } + if (result && result.then) { + result.then((concreteResult) => { + sendResponse({type: "success", value: concreteResult}); + }).catch((errorResult) => { + log.error(`Promise error in ${req.funcName}:`, errorResult, errorResult && errorResult.stack); + sendResponse({type: "error", message: errorResult + "", errorCode: errorResult.errorCode, popupMessage: errorResult.popupMessage}); + }); + return; + } + sendResponse({type: "success", value: result}); + }); + + exports.register = function(name, func) { + registeredFunctions[name] = func; + }; + + return exports; +})(); diff --git a/browser/extensions/screenshots/background/deviceInfo.js b/browser/extensions/screenshots/background/deviceInfo.js new file mode 100644 index 0000000000..a16580a8e4 --- /dev/null +++ b/browser/extensions/screenshots/background/deviceInfo.js @@ -0,0 +1,38 @@ +/* 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/. */ + +/* globals catcher */ + +"use strict"; + +this.deviceInfo = (function() { + const manifest = browser.runtime.getManifest(); + + let platformInfo = {}; + catcher.watchPromise(browser.runtime.getPlatformInfo().then((info) => { + platformInfo = info; + })); + + return function deviceInfo() { + let match = navigator.userAgent.match(/Chrom(?:e|ium)\/([0-9.]{1,1000})/); + const chromeVersion = match ? match[1] : null; + match = navigator.userAgent.match(/Firefox\/([0-9.]{1,1000})/); + const firefoxVersion = match ? match[1] : null; + const appName = chromeVersion ? "chrome" : "firefox"; + + return { + addonVersion: manifest.version, + platform: platformInfo.os, + architecture: platformInfo.arch, + version: firefoxVersion || chromeVersion, + // These don't seem to apply to Chrome: + // build: system.build, + // platformVersion: system.platformVersion, + userAgent: navigator.userAgent, + appVendor: appName, + appName, + }; + }; + +})(); diff --git a/browser/extensions/screenshots/background/main.js b/browser/extensions/screenshots/background/main.js new file mode 100644 index 0000000000..25f1cd6979 --- /dev/null +++ b/browser/extensions/screenshots/background/main.js @@ -0,0 +1,291 @@ +/* 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/. */ + +/* globals selectorLoader, analytics, communication, catcher, log, makeUuid, auth, senderror, startBackground, blobConverters buildSettings */ + +"use strict"; + +this.main = (function() { + const exports = {}; + + const pasteSymbol = (window.navigator.platform.match(/Mac/i)) ? "\u2318" : "Ctrl"; + const { sendEvent, incrementCount } = analytics; + + const manifest = browser.runtime.getManifest(); + let backend; + + exports.hasAnyShots = function() { + return false; + }; + + exports.setBackend = function(newBackend) { + backend = newBackend; + backend = backend.replace(/\/*$/, ""); + }; + + exports.getBackend = function() { + return backend; + }; + + communication.register("getBackend", () => { + return backend; + }); + + for (const permission of manifest.permissions) { + if (/^https?:\/\//.test(permission)) { + exports.setBackend(permission); + break; + } + } + + function setIconActive(active, tabId) { + const path = active ? "icons/icon-highlight-32-v2.svg" : "icons/icon-v2.svg"; + browser.pageAction.setIcon({tabId, path}); + } + + function toggleSelector(tab) { + return analytics.refreshTelemetryPref() + .then(() => selectorLoader.toggle(tab.id)) + .then(active => { + setIconActive(active, tab.id); + return active; + }) + .catch((error) => { + if (error.message && /Missing host permission for the tab/.test(error.message)) { + error.noReport = true; + } + error.popupMessage = "UNSHOOTABLE_PAGE"; + throw error; + }); + } + + function shouldOpenMyShots(url) { + return /^about:(?:newtab|blank|home)/i.test(url) || /^resource:\/\/activity-streams\//i.test(url); + } + + // This is called by startBackground.js, where is registered as a click + // handler for the webextension page action. + exports.onClicked = catcher.watchFunction((tab) => { + _startShotFlow(tab, "toolbar-button"); + }); + + exports.onClickedContextMenu = catcher.watchFunction((info, tab) => { + _startShotFlow(tab, "context-menu"); + }); + + exports.onCommand = catcher.watchFunction((tab) => { + _startShotFlow(tab, "keyboard-shortcut"); + }); + + const _openMyShots = (tab, inputType) => { + catcher.watchPromise(analytics.refreshTelemetryPref().then(() => { + sendEvent("goto-myshots", inputType, {incognito: tab.incognito}); + })); + catcher.watchPromise( + auth.maybeLogin() + .then(() => browser.tabs.update({url: backend + "/shots"}))); + }; + + const _startShotFlow = (tab, inputType) => { + if (!tab) { + // Not in a page/tab context, ignore + return; + } + if (!urlEnabled(tab.url)) { + senderror.showError({ + popupMessage: "UNSHOOTABLE_PAGE", + }); + return; + } else if (shouldOpenMyShots(tab.url)) { + _openMyShots(tab, inputType); + return; + } + + catcher.watchPromise(toggleSelector(tab) + .then(active => { + let event = "start-shot"; + if (inputType !== "context-menu") { + event = active ? "start-shot" : "cancel-shot"; + } + sendEvent(event, inputType, {incognito: tab.incognito}); + }).catch((error) => { + throw error; + })); + }; + + function urlEnabled(url) { + if (shouldOpenMyShots(url)) { + return true; + } + // Allow screenshots on urls related to web pages in reader mode. + if (url && url.startsWith("about:reader?url=")) { + return true; + } + if (isShotOrMyShotPage(url) || /^(?:about|data|moz-extension):/i.test(url) || isBlacklistedUrl(url)) { + return false; + } + return true; + } + + function isShotOrMyShotPage(url) { + // It's okay to take a shot of any pages except shot pages and My Shots + if (!url.startsWith(backend)) { + return false; + } + const path = url.substr(backend.length).replace(/^\/*/, "").replace(/[?#].*/, ""); + if (path === "shots") { + return true; + } + if (/^[^/]{1,4000}\/[^/]{1,4000}$/.test(path)) { + // Blocks {:id}/{:domain}, but not /, /privacy, etc + return true; + } + return false; + } + + function isBlacklistedUrl(url) { + // These specific domains are not allowed for general WebExtension permission reasons + // Discussion: https://bugzilla.mozilla.org/show_bug.cgi?id=1310082 + // List of domains copied from: https://searchfox.org/mozilla-central/source/browser/app/permissions#18-19 + // Note we disable it here to be informative, the security check is done in WebExtension code + const badDomains = ["testpilot.firefox.com"]; + let domain = url.replace(/^https?:\/\//i, ""); + domain = domain.replace(/\/.*/, "").replace(/:.*/, ""); + domain = domain.toLowerCase(); + return badDomains.includes(domain); + } + + communication.register("getStrings", (sender, ids) => { + return getStrings(ids.map(id => ({id}))); + }); + + communication.register("sendEvent", (sender, ...args) => { + catcher.watchPromise(sendEvent(...args)); + // We don't wait for it to complete: + return null; + }); + + communication.register("openMyShots", (sender) => { + return catcher.watchPromise( + auth.maybeLogin() + .then(() => browser.tabs.create({url: backend + "/shots"}))); + }); + + communication.register("openShot", async (sender, {url, copied}) => { + if (copied) { + const id = makeUuid(); + const [ title, message ] = await getStrings([ + { id: "screenshots-notification-link-copied-title" }, + { id: "screenshots-notification-link-copied-details" }, + ]); + return browser.notifications.create(id, { + type: "basic", + iconUrl: "../icons/copied-notification.svg", + title, + message, + }); + } + return null; + }); + + // This is used for truncated full page downloads and copy to clipboards. + // Those longer operations need to display an animated spinner/loader, so + // it's preferable to perform toDataURL() in the background. + communication.register("canvasToDataURL", (sender, imageData) => { + const canvas = document.createElement("canvas"); + canvas.width = imageData.width; + canvas.height = imageData.height; + canvas.getContext("2d").putImageData(imageData, 0, 0); + let dataUrl = canvas.toDataURL(); + if (buildSettings.pngToJpegCutoff && dataUrl.length > buildSettings.pngToJpegCutoff) { + const jpegDataUrl = canvas.toDataURL("image/jpeg"); + if (jpegDataUrl.length < dataUrl.length) { + // Only use the JPEG if it is actually smaller + dataUrl = jpegDataUrl; + } + } + return dataUrl; + }); + + communication.register("copyShotToClipboard", async (sender, blob) => { + let buffer = await blobConverters.blobToArray(blob); + await browser.clipboard.setImageData(buffer, blob.type.split("/", 2)[1]); + + const [title, message] = await getStrings([ + { id: "screenshots-notification-image-copied-title" }, + { id: "screenshots-notification-image-copied-details" }, + ]); + + catcher.watchPromise(incrementCount("copy")); + return browser.notifications.create({ + type: "basic", + iconUrl: "../icons/copied-notification.svg", + title, + message, + }); + }); + + communication.register("downloadShot", (sender, info) => { + // 'data:' urls don't work directly, let's use a Blob + // see http://stackoverflow.com/questions/40269862/save-data-uri-as-file-using-downloads-download-api + const blob = blobConverters.dataUrlToBlob(info.url); + const url = URL.createObjectURL(blob); + let downloadId; + const onChangedCallback = catcher.watchFunction(function(change) { + if (!downloadId || downloadId !== change.id) { + return; + } + if (change.state && change.state.current !== "in_progress") { + URL.revokeObjectURL(url); + browser.downloads.onChanged.removeListener(onChangedCallback); + } + }); + browser.downloads.onChanged.addListener(onChangedCallback); + catcher.watchPromise(incrementCount("download")); + return browser.windows.getLastFocused().then(windowInfo => { + return browser.downloads.download({ + url, + incognito: windowInfo.incognito, + filename: info.filename, + }).catch((error) => { + // We are not logging error message when user cancels download + if (error && error.message && !error.message.includes("canceled")) { + log.error(error.message); + } + }).then((id) => { + downloadId = id; + }); + }); + }); + + communication.register("closeSelector", (sender) => { + setIconActive(false, sender.tab.id); + }); + + communication.register("abortStartShot", () => { + // Note, we only show the error but don't report it, as we know that we can't + // take shots of these pages: + senderror.showError({ + popupMessage: "UNSHOOTABLE_PAGE", + }); + }); + + // A Screenshots page wants us to start/force onboarding + communication.register("requestOnboarding", (sender) => { + return startSelectionWithOnboarding(sender.tab); + }); + + communication.register("getPlatformOs", () => { + return catcher.watchPromise(browser.runtime.getPlatformInfo().then(platformInfo => { + return platformInfo.os; + })); + }); + + // This allows the web site show notifications through sitehelper.js + communication.register("showNotification", (sender, notification) => { + return browser.notifications.create(notification); + }); + + return exports; +})(); diff --git a/browser/extensions/screenshots/background/selectorLoader.js b/browser/extensions/screenshots/background/selectorLoader.js new file mode 100644 index 0000000000..a6dbd69b26 --- /dev/null +++ b/browser/extensions/screenshots/background/selectorLoader.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/. */ + +/* globals catcher, communication, log, main */ + +"use strict"; + +// eslint-disable-next-line no-var +var global = this; + +this.selectorLoader = (function() { + const exports = {}; + + // These modules are loaded in order, first standardScripts and then selectorScripts + // The order is important due to dependencies + const standardScripts = [ + "build/buildSettings.js", + "log.js", + "catcher.js", + "assertIsTrusted.js", + "assertIsBlankDocument.js", + "blobConverters.js", + "background/selectorLoader.js", + "selector/callBackground.js", + "selector/util.js", + ]; + + const selectorScripts = [ + "clipboard.js", + "makeUuid.js", + "build/selection.js", + "build/shot.js", + "randomString.js", + "domainFromUrl.js", + "build/inlineSelectionCss.js", + "selector/documentMetadata.js", + "selector/ui.js", + "selector/shooter.js", + "selector/uicontrol.js", + ]; + + exports.unloadIfLoaded = function(tabId) { + return browser.tabs.executeScript(tabId, { + code: "this.selectorLoader && this.selectorLoader.unloadModules()", + runAt: "document_start", + }).then(result => { + return result && result[0]; + }); + }; + + exports.testIfLoaded = function(tabId) { + if (loadingTabs.has(tabId)) { + return true; + } + return browser.tabs.executeScript(tabId, { + code: "!!this.selectorLoader", + runAt: "document_start", + }).then(result => { + return result && result[0]; + }); + }; + + const loadingTabs = new Set(); + + exports.loadModules = function(tabId) { + loadingTabs.add(tabId); + catcher.watchPromise(browser.tabs.executeScript(tabId, { + code: `window.hasAnyShots = ${!!main.hasAnyShots()};`, + runAt: "document_start", + }).then(() => { + return executeModules(tabId, standardScripts.concat(selectorScripts)); + }).finally(() => { + loadingTabs.delete(tabId); + })); + }; + + function executeModules(tabId, scripts) { + let lastPromise = Promise.resolve(null); + scripts.forEach((file) => { + lastPromise = lastPromise.then(() => { + return browser.tabs.executeScript(tabId, { + file, + runAt: "document_start", + }).catch((error) => { + log.error("error in script:", file, error); + error.scriptName = file; + throw error; + }); + }); + }); + return lastPromise.then(() => { + log.debug("finished loading scripts:", scripts.join(" ")); + }, + (error) => { + exports.unloadIfLoaded(tabId); + catcher.unhandled(error); + throw error; + }); + } + + exports.unloadModules = function() { + const watchFunction = catcher.watchFunction; + const allScripts = standardScripts.concat(selectorScripts); + const moduleNames = allScripts.map((filename) => + filename.replace(/^.*\//, "").replace(/\.js$/, "")); + moduleNames.reverse(); + for (const moduleName of moduleNames) { + const moduleObj = global[moduleName]; + if (moduleObj && moduleObj.unload) { + try { + watchFunction(moduleObj.unload)(); + } catch (e) { + // ignore (watchFunction handles it) + } + } + delete global[moduleName]; + } + return true; + }; + + exports.toggle = function(tabId) { + return exports.unloadIfLoaded(tabId) + .then(wasLoaded => { + if (!wasLoaded) { + exports.loadModules(tabId); + } + return !wasLoaded; + }); + }; + + return exports; +})(); +null; diff --git a/browser/extensions/screenshots/background/senderror.js b/browser/extensions/screenshots/background/senderror.js new file mode 100644 index 0000000000..f136e265be --- /dev/null +++ b/browser/extensions/screenshots/background/senderror.js @@ -0,0 +1,154 @@ +/* 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/. */ + +/* globals startBackground, analytics, communication, makeUuid, Raven, catcher, auth, log */ + +"use strict"; + +this.senderror = (function() { + const exports = {}; + + const manifest = browser.runtime.getManifest(); + + // Do not show an error more than every ERROR_TIME_LIMIT milliseconds: + const ERROR_TIME_LIMIT = 3000; + + const messages = { + REQUEST_ERROR: { + titleKey: "screenshots-request-error-title", + infoKey: "screenshots-request-error-details", + }, + CONNECTION_ERROR: { + titleKey: "screenshots-connection-error-title", + infoKey: "screenshots-connection-error-details", + }, + LOGIN_ERROR: { + titleKey: "screenshots-request-error-title", + infoKey: "screenshots-login-error-details", + }, + LOGIN_CONNECTION_ERROR: { + titleKey: "screenshots-connection-error-title", + infoKey: "screenshots-connection-error-details", + }, + UNSHOOTABLE_PAGE: { + titleKey: "screenshots-unshootable-page-error-title", + infoKey: "screenshots-unshootable-page-error-details", + }, + SHOT_PAGE: { + titleKey: "screenshots-self-screenshot-error-title", + }, + MY_SHOTS: { + titleKey: "screenshots-self-screenshot-error-title", + }, + EMPTY_SELECTION: { + titleKey: "screenshots-empty-selection-error-title", + }, + PRIVATE_WINDOW: { + titleKey: "screenshots-private-window-error-title", + infoKey: "screenshots-private-window-error-details", + }, + generic: { + titleKey: "screenshots-generic-error-title", + infoKey: "screenshots-generic-error-details", + showMessage: true, + }, + }; + + communication.register("reportError", (sender, error) => { + catcher.unhandled(error); + }); + + let lastErrorTime; + + exports.showError = async function(error) { + if (lastErrorTime && (Date.now() - lastErrorTime) < ERROR_TIME_LIMIT) { + return; + } + lastErrorTime = Date.now(); + const id = makeUuid(); + let popupMessage = error.popupMessage || "generic"; + if (!messages[popupMessage]) { + popupMessage = "generic"; + } + + let item = messages[popupMessage]; + if (!("title" in item)) { + let keys = [{id: item.titleKey}]; + if ("infoKey" in item) { + keys.push({id: item.infoKey}); + } + + [item.title, item.info] = await getStrings(keys); + } + + let title = item.title; + let message = item.info || ""; + const showMessage = item.showMessage; + if (error.message && showMessage) { + if (message) { + message += "\n" + error.message; + } else { + message = error.message; + } + } + if (Date.now() - startBackground.startTime > 5 * 1000) { + browser.notifications.create(id, { + type: "basic", + // FIXME: need iconUrl for an image, see #2239 + title, + message, + }); + } + }; + + exports.reportError = function(e) { + if (!analytics.isTelemetryEnabled()) { + log.error("Telemetry disabled. Not sending critical error:", e); + return; + } + const dsn = auth.getSentryPublicDSN(); + if (!dsn) { + return; + } + if (!Raven.isSetup()) { + Raven.config(dsn, {allowSecretKey: true}).install(); + } + const exception = new Error(e.message); + exception.stack = e.multilineStack || e.stack || undefined; + + // To improve Sentry reporting & grouping, replace the + // moz-extension://$uuid base URL with a generic resource:// URL. + if (exception.stack) { + exception.stack = exception.stack.replace( + /moz-extension:\/\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/g, + "resource://screenshots-addon" + ); + } + const rest = {}; + for (const attr in e) { + if (!["name", "message", "stack", "multilineStack", "popupMessage", "version", "sentryPublicDSN", "help", "fromMakeError"].includes(attr)) { + rest[attr] = e[attr]; + } + } + rest.stack = exception.stack; + Raven.captureException(exception, { + logger: "addon", + tags: {category: e.popupMessage}, + release: manifest.version, + message: exception.message, + extra: rest, + }); + }; + + catcher.registerHandler((errorObj) => { + if (!errorObj.noPopup) { + exports.showError(errorObj); + } + if (!errorObj.noReport) { + exports.reportError(errorObj); + } + }); + + return exports; +})(); diff --git a/browser/extensions/screenshots/background/startBackground.js b/browser/extensions/screenshots/background/startBackground.js new file mode 100644 index 0000000000..b32dc3d6f7 --- /dev/null +++ b/browser/extensions/screenshots/background/startBackground.js @@ -0,0 +1,135 @@ +/* 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/. */ + +/* globals browser, main, communication, manifest */ +/* This file handles: + clicks on the WebExtension page action + browser.contextMenus.onClicked + browser.runtime.onMessage + and loads the rest of the background page in response to those events, forwarding + the events to main.onClicked, main.onClickedContextMenu, or communication.onMessage +*/ +const startTime = Date.now(); + +// Set up to be able to use fluent: +(function() { + let link = document.createElement("link"); + link.setAttribute("rel", "localization"); + link.setAttribute("href", "browser/screenshots.ftl"); + document.head.appendChild(link); + + link = document.createElement("link"); + link.setAttribute("rel", "localization"); + link.setAttribute("href", "browser/branding/brandings.ftl"); + document.head.appendChild(link); +})(); + +this.getStrings = async function(ids) { + if (document.readyState != "complete") { + await new Promise(resolve => window.addEventListener("load", resolve, {once: true})); + } + await document.l10n.ready; + return document.l10n.formatValues(ids); +} + +this.startBackground = (function() { + const exports = {startTime}; + + const backgroundScripts = [ + "log.js", + "makeUuid.js", + "catcher.js", + "blobConverters.js", + "background/selectorLoader.js", + "background/communication.js", + "background/auth.js", + "background/senderror.js", + "build/raven.js", + "build/shot.js", + "build/thumbnailGenerator.js", + "background/analytics.js", + "background/deviceInfo.js", + "background/takeshot.js", + "background/main.js", + ]; + + browser.pageAction.onClicked.addListener(tab => { + loadIfNecessary().then(() => { + main.onClicked(tab); + }).catch(error => { + console.error("Error loading Screenshots:", error); + }); + }); + + this.getStrings([{id: "screenshots-context-menu"}]).then(msgs => { + browser.contextMenus.create({ + id: "create-screenshot", + title: msgs[0], + contexts: ["page", "selection"], + documentUrlPatterns: ["<all_urls>", "about:reader*"], + }); + }); + + browser.contextMenus.onClicked.addListener((info, tab) => { + loadIfNecessary().then(() => { + main.onClickedContextMenu(info, tab); + }).catch((error) => { + console.error("Error loading Screenshots:", error); + }); + }); + + browser.commands.onCommand.addListener((cmd) => { + if (cmd !== "take-screenshot") { + return; + } + loadIfNecessary().then(() => { + browser.tabs.query({currentWindow: true, active: true}).then((tabs) => { + const activeTab = tabs[0]; + main.onCommand(activeTab); + }).catch((error) => { + throw error; + }); + }).catch((error) => { + console.error("Error toggling Screenshots via keyboard shortcut: ", error); + }); + }); + + browser.runtime.onMessage.addListener((req, sender, sendResponse) => { + loadIfNecessary().then(() => { + return communication.onMessage(req, sender, sendResponse); + }).catch((error) => { + console.error("Error loading Screenshots:", error); + }); + return true; + }); + + let loadedPromise; + + function loadIfNecessary() { + if (loadedPromise) { + return loadedPromise; + } + loadedPromise = Promise.resolve(); + backgroundScripts.forEach((script) => { + loadedPromise = loadedPromise.then(() => { + return new Promise((resolve, reject) => { + const tag = document.createElement("script"); + tag.src = browser.extension.getURL(script); + tag.onload = () => { + resolve(); + }; + tag.onerror = (error) => { + const exc = new Error(`Error loading script: ${error.message}`); + exc.scriptName = script; + reject(exc); + }; + document.head.appendChild(tag); + }); + }); + }); + return loadedPromise; + } + + return exports; +})(); diff --git a/browser/extensions/screenshots/background/takeshot.js b/browser/extensions/screenshots/background/takeshot.js new file mode 100644 index 0000000000..b25e984fc8 --- /dev/null +++ b/browser/extensions/screenshots/background/takeshot.js @@ -0,0 +1,179 @@ +/* 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/. */ + +/* globals communication, shot, main, auth, catcher, analytics, buildSettings, blobConverters, thumbnailGenerator */ + +"use strict"; + +this.takeshot = (function() { + const exports = {}; + const Shot = shot.AbstractShot; + const { sendEvent, incrementCount } = analytics; + const MAX_CANVAS_DIMENSION = 32767; + + communication.register("screenshotPage", (sender, selectedPos, isFullPage, devicePixelRatio) => { + return screenshotPage(selectedPos, isFullPage, devicePixelRatio); + }); + + function screenshotPage(pos, isFullPage, devicePixelRatio) { + pos.width = Math.min(pos.right - pos.left, MAX_CANVAS_DIMENSION); + pos.height = Math.min(pos.bottom - pos.top, MAX_CANVAS_DIMENSION); + + // If we are printing the full page or a truncated full page, + // we must pass in this rectangle to preview the entire image + let options = {format: "png"}; + if (isFullPage) { + let rectangle = { + x: 0, + y: 0, + width: pos.width, + height: pos.height, + } + options.rect = rectangle; + + // To avoid creating extremely large images (which causes + // performance problems), we set the scale to 1. + devicePixelRatio = options.scale = 1; + } else { + let rectangle = { + x: pos.left, + y: pos.top, + width: pos.width, + height: pos.height, + } + options.rect = rectangle + } + + return catcher.watchPromise(browser.tabs.captureTab( + null, + options, + ).then((dataUrl) => { + const image = new Image(); + image.src = dataUrl; + return new Promise((resolve, reject) => { + image.onload = catcher.watchFunction(() => { + const xScale = devicePixelRatio; + const yScale = devicePixelRatio; + const canvas = document.createElement("canvas"); + canvas.height = pos.height * yScale; + canvas.width = pos.width * xScale; + const context = canvas.getContext("2d"); + context.drawImage( + image, + 0, 0, + pos.width * xScale, pos.height * yScale, + 0, 0, + pos.width * xScale, pos.height * yScale + ); + const result = canvas.toDataURL(); + resolve(result); + }); + }); + })); + } + + /** Combines two buffers or Uint8Array's */ + function concatBuffers(buffer1, buffer2) { + const tmp = new Uint8Array(buffer1.byteLength + buffer2.byteLength); + tmp.set(new Uint8Array(buffer1), 0); + tmp.set(new Uint8Array(buffer2), buffer1.byteLength); + return tmp.buffer; + } + + /** Creates a multipart TypedArray, given {name: value} fields + and a files array in the format of + [{fieldName: "NAME", filename: "NAME.png", blob: fileBlob}, {...}, ...] + + Returns {body, "content-type"} + */ + function createMultipart(fields, files) { + const boundary = "---------------------------ScreenshotBoundary" + Date.now(); + let body = []; + for (const name in fields) { + body.push("--" + boundary); + body.push(`Content-Disposition: form-data; name="${name}"`); + body.push(""); + body.push(fields[name]); + } + body.push(""); + body = body.join("\r\n"); + const enc = new TextEncoder("utf-8"); + body = enc.encode(body).buffer; + + const blobToArrayPromises = files.map(f => { + return blobConverters.blobToArray(f.blob); + }); + + return Promise.all(blobToArrayPromises).then(buffers => { + for (let i = 0; i < buffers.length; i++) { + let filePart = []; + filePart.push("--" + boundary); + filePart.push(`Content-Disposition: form-data; name="${files[i].fieldName}"; filename="${files[i].filename}"`); + filePart.push(`Content-Type: ${files[i].blob.type}`); + filePart.push(""); + filePart.push(""); + filePart = filePart.join("\r\n"); + filePart = concatBuffers(enc.encode(filePart).buffer, buffers[i]); + body = concatBuffers(body, filePart); + body = concatBuffers(body, enc.encode("\r\n").buffer); + } + + let tail = `\r\n--${boundary}--`; + tail = enc.encode(tail); + body = concatBuffers(body, tail.buffer); + return { + "content-type": `multipart/form-data; boundary=${boundary}`, + body, + }; + }); + } + + function uploadShot(shot, blob, thumbnail) { + let headers; + return auth.authHeaders().then((_headers) => { + headers = _headers; + if (blob) { + const files = [ {fieldName: "blob", filename: "screenshot.png", blob} ]; + if (thumbnail) { + files.push({fieldName: "thumbnail", filename: "thumbnail.png", blob: thumbnail}); + } + return createMultipart( + {shot: JSON.stringify(shot)}, + + files + ); + } + return { + "content-type": "application/json", + body: JSON.stringify(shot), + }; + + }).then((submission) => { + headers["content-type"] = submission["content-type"]; + sendEvent("upload", "started", {eventValue: Math.floor(submission.body.length / 1000)}); + return fetch(shot.jsonUrl, { + method: "PUT", + mode: "cors", + headers, + body: submission.body, + }); + }).then((resp) => { + if (!resp.ok) { + sendEvent("upload-failed", `status-${resp.status}`); + const exc = new Error(`Response failed with status ${resp.status}`); + exc.popupMessage = "REQUEST_ERROR"; + throw exc; + } else { + sendEvent("upload", "success"); + } + }, (error) => { + // FIXME: I'm not sure what exceptions we can expect + sendEvent("upload-failed", "connection"); + error.popupMessage = "CONNECTION_ERROR"; + throw error; + }); + } + + return exports; +})(); diff --git a/browser/extensions/screenshots/blank.html b/browser/extensions/screenshots/blank.html new file mode 100644 index 0000000000..dacff8e72b --- /dev/null +++ b/browser/extensions/screenshots/blank.html @@ -0,0 +1,5 @@ +<!-- 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/. --> + +<html></html> diff --git a/browser/extensions/screenshots/blobConverters.js b/browser/extensions/screenshots/blobConverters.js new file mode 100644 index 0000000000..f2312251d7 --- /dev/null +++ b/browser/extensions/screenshots/blobConverters.js @@ -0,0 +1,48 @@ +/* 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.blobConverters = (function() { + const exports = {}; + + exports.dataUrlToBlob = function(url) { + const binary = atob(url.split(",", 2)[1]); + let contentType = exports.getTypeFromDataUrl(url); + if (contentType !== "image/png" && contentType !== "image/jpeg") { + contentType = "image/png"; + } + const data = Uint8Array.from(binary, char => char.charCodeAt(0)); + const blob = new Blob([data], {type: contentType}); + return blob; + }; + + exports.getTypeFromDataUrl = function(url) { + let contentType = url.split(",", 1)[0]; + contentType = contentType.split(";", 1)[0]; + contentType = contentType.split(":", 2)[1]; + return contentType; + }; + + exports.blobToArray = function(blob) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.addEventListener("loadend", function() { + resolve(reader.result); + }); + reader.readAsArrayBuffer(blob); + }); + }; + + exports.blobToDataUrl = function(blob) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.addEventListener("loadend", function() { + resolve(reader.result); + }); + reader.readAsDataURL(blob); + }); + }; + + return exports; +})(); +null; diff --git a/browser/extensions/screenshots/build/buildSettings.js b/browser/extensions/screenshots/build/buildSettings.js new file mode 100644 index 0000000000..cc79976c8d --- /dev/null +++ b/browser/extensions/screenshots/build/buildSettings.js @@ -0,0 +1,15 @@ +/* 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/. */ + +window.buildSettings = { + defaultSentryDsn: "", + logLevel: "" || "warn", + captureText: ("" === "true"), + uploadBinary: ("" === "true"), + pngToJpegCutoff: parseInt("" || 2500000, 10), + maxImageHeight: parseInt("" || 10000, 10), + maxImageWidth: parseInt("" || 10000, 10) +}; +null; + diff --git a/browser/extensions/screenshots/build/inlineSelectionCss.js b/browser/extensions/screenshots/build/inlineSelectionCss.js new file mode 100644 index 0000000000..c59b8fa89a --- /dev/null +++ b/browser/extensions/screenshots/build/inlineSelectionCss.js @@ -0,0 +1,666 @@ +/* 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/. */ + +/* Created from build/server/static/css/inline-selection.css */ +window.inlineSelectionCss = ` +.button, .highlight-button-cancel, .highlight-button-download, .highlight-button-copy { + display: flex; + align-items: center; + justify-content: center; + border: 0; + border-radius: 3px; + cursor: pointer; + font-size: 16px; + font-weight: 400; + height: 40px; + min-width: 40px; + outline: none; + padding: 0 10px; + position: relative; + text-align: center; + text-decoration: none; + transition: background 150ms cubic-bezier(0.07, 0.95, 0, 1), border 150ms cubic-bezier(0.07, 0.95, 0, 1); + user-select: none; + white-space: nowrap; } + .button.hidden, .hidden.highlight-button-cancel, .hidden.highlight-button-download, .hidden.highlight-button-copy { + display: none; } + .button.small, .small.highlight-button-cancel, .small.highlight-button-download, .small.highlight-button-copy { + height: 32px; + line-height: 32px; + padding: 0 8px; } + .button.active, .active.highlight-button-cancel, .active.highlight-button-download, .active.highlight-button-copy { + background-color: #dedede; } + .button.tiny, .tiny.highlight-button-cancel, .tiny.highlight-button-download, .tiny.highlight-button-copy { + font-size: 14px; + height: 26px; + border: 1px solid #c7c7c7; } + .button.tiny:hover, .tiny.highlight-button-cancel:hover, .tiny.highlight-button-download:hover, .tiny.highlight-button-copy:hover, .button.tiny:focus, .tiny.highlight-button-cancel:focus, .tiny.highlight-button-download:focus, .tiny.highlight-button-copy:focus { + background: #ededf0; + border-color: #989898; } + .button.tiny:active, .tiny.highlight-button-cancel:active, .tiny.highlight-button-download:active, .tiny.highlight-button-copy:active { + background: #dedede; + border-color: #989898; } + .button.block-button, .block-button.highlight-button-cancel, .block-button.highlight-button-download, .block-button.highlight-button-copy { + display: flex; + align-items: center; + justify-content: center; + box-sizing: border-box; + border: 0; + border-inline-end: 1px solid #c7c7c7; + box-shadow: 0; + border-radius: 0; + flex-shrink: 0; + font-size: 20px; + height: 100px; + line-height: 100%; + overflow: hidden; } + @media (max-width: 719px) { + .button.block-button, .block-button.highlight-button-cancel, .block-button.highlight-button-download, .block-button.highlight-button-copy { + justify-content: flex-start; + font-size: 16px; + height: 72px; + margin-inline-end: 10px; + padding: 0 5px; } } + .button.block-button:hover, .block-button.highlight-button-cancel:hover, .block-button.highlight-button-download:hover, .block-button.highlight-button-copy:hover { + background: #ededf0; } + .button.block-button:active, .block-button.highlight-button-cancel:active, .block-button.highlight-button-download:active, .block-button.highlight-button-copy:active { + background: #dedede; } + .button.download, .download.highlight-button-cancel, .download.highlight-button-download, .download.highlight-button-copy, .button.edit, .edit.highlight-button-cancel, .edit.highlight-button-download, .edit.highlight-button-copy, .button.trash, .trash.highlight-button-cancel, .trash.highlight-button-download, .trash.highlight-button-copy, .button.share, .share.highlight-button-cancel, .share.highlight-button-download, .share.highlight-button-copy, .button.flag, .flag.highlight-button-cancel, .flag.highlight-button-download, .flag.highlight-button-copy { + background-repeat: no-repeat; + background-size: 50%; + background-position: center; + margin-inline-end: 10px; + transition: background-color 150ms cubic-bezier(0.07, 0.95, 0, 1); } + .button.download, .download.highlight-button-cancel, .download.highlight-button-download, .download.highlight-button-copy { + background-image: url("../img/icon-download.svg"); } + .button.download:hover, .download.highlight-button-cancel:hover, .download.highlight-button-download:hover, .download.highlight-button-copy:hover { + background-color: #ededf0; } + .button.download:active, .download.highlight-button-cancel:active, .download.highlight-button-download:active, .download.highlight-button-copy:active { + background-color: #dedede; } + .button.share, .share.highlight-button-cancel, .share.highlight-button-download, .share.highlight-button-copy { + background-image: url("../img/icon-share.svg"); } + .button.share:hover, .share.highlight-button-cancel:hover, .share.highlight-button-download:hover, .share.highlight-button-copy:hover { + background-color: #ededf0; } + .button.share.active, .share.active.highlight-button-cancel, .share.active.highlight-button-download, .share.active.highlight-button-copy, .button.share:active, .share.highlight-button-cancel:active, .share.highlight-button-download:active, .share.highlight-button-copy:active { + background-color: #dedede; } + .button.share.newicon, .share.newicon.highlight-button-cancel, .share.newicon.highlight-button-download, .share.newicon.highlight-button-copy { + background-image: url("../img/icon-share-alternate.svg"); } + .button.trash, .trash.highlight-button-cancel, .trash.highlight-button-download, .trash.highlight-button-copy { + background-image: url("../img/icon-trash.svg"); } + .button.trash:hover, .trash.highlight-button-cancel:hover, .trash.highlight-button-download:hover, .trash.highlight-button-copy:hover { + background-color: #ededf0; } + .button.trash:active, .trash.highlight-button-cancel:active, .trash.highlight-button-download:active, .trash.highlight-button-copy:active { + background-color: #dedede; } + .button.edit, .edit.highlight-button-cancel, .edit.highlight-button-download, .edit.highlight-button-copy { + background-image: url("../img/icon-edit.svg"); } + .button.edit:hover, .edit.highlight-button-cancel:hover, .edit.highlight-button-download:hover, .edit.highlight-button-copy:hover { + background-color: #ededf0; } + .button.edit:active, .edit.highlight-button-cancel:active, .edit.highlight-button-download:active, .edit.highlight-button-copy:active { + background-color: #dedede; } + +.app-body { + background: #f9f9fa; + color: #38383d; } + .app-body a { + color: #0a84ff; } + +.highlight-color-scheme { + background: #0a84ff; + color: #fff; } + .highlight-color-scheme a { + color: #fff; + text-decoration: underline; } + +.alt-color-scheme { + background: #38383d; + color: #f9f9fa; } + .alt-color-scheme h1 { + color: #6f7fb6; } + .alt-color-scheme a { + color: #e1e1e6; + text-decoration: underline; } + +.button.primary, .primary.highlight-button-cancel, .highlight-button-download, .primary.highlight-button-copy { + background-color: #0a84ff; + color: #fff; } + .button.primary:hover, .primary.highlight-button-cancel:hover, .highlight-button-download:hover, .primary.highlight-button-copy:hover, .button.primary:focus, .primary.highlight-button-cancel:focus, .highlight-button-download:focus, .primary.highlight-button-copy:focus { + background-color: #0072e5; } + .button.primary:active, .primary.highlight-button-cancel:active, .highlight-button-download:active, .primary.highlight-button-copy:active { + background-color: #0065cc; } + +.button.secondary, .highlight-button-cancel, .secondary.highlight-button-download, .highlight-button-copy { + background-color: #f9f9fa; + color: #38383d; } + .button.secondary:hover, .highlight-button-cancel:hover, .secondary.highlight-button-download:hover, .highlight-button-copy:hover { + background-color: #ededf0; } + .button.secondary:active, .highlight-button-cancel:active, .secondary.highlight-button-download:active, .highlight-button-copy:active { + background-color: #dedede; } + +.button.transparent, .transparent.highlight-button-cancel, .transparent.highlight-button-download, .transparent.highlight-button-copy { + background-color: transparent; + color: #38383d; } + .button.transparent:hover, .transparent.highlight-button-cancel:hover, .transparent.highlight-button-download:hover, .transparent.highlight-button-copy:hover { + background-color: #ededf0; } + .button.transparent:focus, .transparent.highlight-button-cancel:focus, .transparent.highlight-button-download:focus, .transparent.highlight-button-copy:focus, .button.transparent:active, .transparent.highlight-button-cancel:active, .transparent.highlight-button-download:active, .transparent.highlight-button-copy:active { + background-color: #dedede; } + +.button.warning, .warning.highlight-button-cancel, .warning.highlight-button-download, .warning.highlight-button-copy { + color: #fff; + background: #d92215; } + .button.warning:hover, .warning.highlight-button-cancel:hover, .warning.highlight-button-download:hover, .warning.highlight-button-copy:hover, .button.warning:focus, .warning.highlight-button-cancel:focus, .warning.highlight-button-download:focus, .warning.highlight-button-copy:focus { + background: #b81d12; } + .button.warning:active, .warning.highlight-button-cancel:active, .warning.highlight-button-download:active, .warning.highlight-button-copy:active { + background: #a11910; } + +.subtitle-link { + color: #0a84ff; } + +.loader { + background: rgba(12, 12, 13, 0.2); + border-radius: 2px; + height: 4px; + overflow: hidden; + position: relative; + width: 200px; } + +.loader-inner { + animation: bounce infinite alternate 1250ms cubic-bezier(0.7, 0, 0.3, 1); + background: #45a1ff; + border-radius: 2px; + height: 4px; + transform: translateX(-40px); + width: 50px; } + +@keyframes bounce { + 0% { + transform: translateX(-40px); } + 100% { + transform: translate(190px); } } + +@keyframes fade-in { + 0% { + opacity: 0; } + 100% { + opacity: 1; } } + +@keyframes pop { + 0% { + transform: scale(1); } + 97% { + transform: scale(1.04); } + 100% { + transform: scale(1); } } + +@keyframes pulse { + 0% { + opacity: 0.3; + transform: scale(1); } + 70% { + opacity: 0.25; + transform: scale(1.04); } + 100% { + opacity: 0.3; + transform: scale(1); } } + +@keyframes slide-left { + 0% { + opacity: 0; + transform: translate3d(160px, 0, 0); } + 100% { + opacity: 1; + transform: translate3d(0, 0, 0); } } + +@keyframes bounce-in { + 0% { + opacity: 0; + transform: scale(1); } + 60% { + opacity: 1; + transform: scale(1.02); } + 100% { + transform: scale(1); } } + +.mover-target { + display: flex; + align-items: center; + justify-content: center; + pointer-events: auto; + position: absolute; + z-index: 5; } + +.highlight, +.mover-target { + background-color: transparent; + background-image: none; } + +.mover-target, +.bghighlight { + border: 0; } + +.hover-highlight { + animation: fade-in 125ms forwards cubic-bezier(0.07, 0.95, 0, 1); + background: rgba(255, 255, 255, 0.2); + border-radius: 1px; + pointer-events: none; + position: absolute; + z-index: 10000000000; } + .hover-highlight::before { + border: 2px dashed rgba(255, 255, 255, 0.4); + bottom: 0; + content: ""; + inset-inline-start: 0; + position: absolute; + inset-inline-end: 0; + top: 0; } + body.hcm .hover-highlight { + background-color: white; + opacity: 0.2; } + +.mover-target.direction-topLeft { + cursor: nwse-resize; + height: 60px; + left: -30px; + top: -30px; + width: 60px; } + +.mover-target.direction-top { + cursor: ns-resize; + height: 60px; + inset-inline-start: 0; + top: -30px; + width: 100%; + z-index: 4; } + +.mover-target.direction-topRight { + cursor: nesw-resize; + height: 60px; + right: -30px; + top: -30px; + width: 60px; } + +.mover-target.direction-left { + cursor: ew-resize; + height: 100%; + left: -30px; + top: 0; + width: 60px; + z-index: 4; } + +.mover-target.direction-right { + cursor: ew-resize; + height: 100%; + right: -30px; + top: 0; + width: 60px; + z-index: 4; } + +.mover-target.direction-bottomLeft { + bottom: -30px; + cursor: nesw-resize; + height: 60px; + left: -30px; + width: 60px; } + +.mover-target.direction-bottom { + bottom: -30px; + cursor: ns-resize; + height: 60px; + inset-inline-start: 0; + width: 100%; + z-index: 4; } + +.mover-target.direction-bottomRight { + bottom: -30px; + cursor: nwse-resize; + height: 60px; + right: -30px; + width: 60px; } + +.mover-target:hover .mover { + transform: scale(1.05); } + +.mover { + background-color: #fff; + border-radius: 50%; + box-shadow: 0 0 4px rgba(0, 0, 0, 0.5); + height: 16px; + opacity: 1; + position: relative; + transition: transform 125ms cubic-bezier(0.07, 0.95, 0, 1); + width: 16px; } + .small-selection .mover { + height: 10px; + width: 10px; } + +.direction-topLeft .mover, +.direction-left .mover, +.direction-bottomLeft .mover { + left: -1px; } + +.direction-topLeft .mover, +.direction-top .mover, +.direction-topRight .mover { + top: -1px; } + +.direction-topRight .mover, +.direction-right .mover, +.direction-bottomRight .mover { + right: -1px; } + +.direction-bottomRight .mover, +.direction-bottom .mover, +.direction-bottomLeft .mover { + bottom: -1px; } + +.bghighlight { + background-color: rgba(0, 0, 0, 0.7); + position: absolute; + z-index: 9999999999; } + body.hcm .bghighlight { + background-color: black; + opacity: 0.7; } + +.preview-overlay { + align-items: center; + background-color: rgba(0, 0, 0, 0.7); + display: flex; + height: 100%; + justify-content: center; + inset-inline-start: 0; + margin: 0; + padding: 0; + position: fixed; + top: 0; + width: 100%; + z-index: 9999999999; } + body.hcm .preview-overlay { + background-color: black; + opacity: 0.7; } + +.precision-cursor { + cursor: crosshair; } + +.highlight { + border-radius: 1px; + border: 2px dashed rgba(255, 255, 255, 0.8); + box-sizing: border-box; + cursor: move; + position: absolute; + z-index: 9999999999; } + body.hcm .highlight { + border: 2px dashed white; + opacity: 1.0; } + +.highlight-buttons { + display: flex; + align-items: center; + justify-content: center; + bottom: -58px; + position: absolute; + inset-inline-end: 5px; + z-index: 6; } + .bottom-selection .highlight-buttons { + bottom: 5px; } + .left-selection .highlight-buttons { + inset-inline-end: auto; + inset-inline-start: 5px; } + .highlight-buttons > button { + box-shadow: 0 0 0 1px rgba(12, 12, 13, 0.1), 0 2px 8px rgba(12, 12, 13, 0.1); } + +.highlight-button-cancel { + margin: 5px; + width: 40px; } + +.highlight-button-download { + margin: 5px; + width: auto; + font-size: 18px; } + +.highlight-button-download img { + height: 16px; + width: 16px; +} + +.highlight-button-download:-moz-locale-dir(rtl) { + flex-direction: reverse; +} + +.highlight-button-download img:-moz-locale-dir(ltr) { + padding-inline-end: 8px; +} + +.highlight-button-download img:-moz-locale-dir(rtl) { + padding-inline-start: 8px; +} + +.highlight-button-copy { + margin: 5px; + width: auto; } + +.highlight-button-copy img { + height: 16px; + width: 16px; +} + +.highlight-button-copy:-moz-locale-dir(rtl) { + flex-direction: reverse; +} + +.highlight-button-copy img:-moz-locale-dir(ltr) { + padding-inline-end: 8px; +} + +.highlight-button-copy img:-moz-locale-dir(rtl) { + padding-inline-start: 8px; +} + +.pixel-dimensions { + position: absolute; + pointer-events: none; + font-weight: bold; + font-family: -apple-system, BlinkMacSystemFont, "segoe ui", "helvetica neue", helvetica, ubuntu, roboto, noto, arial, sans-serif; + font-size: 70%; + color: #000; + text-shadow: -1px -1px 0 #fff, 1px -1px 0 #fff, -1px 1px 0 #fff, 1px 1px 0 #fff; } + +.preview-buttons { + display: flex; + align-items: center; + justify-content: flex-end; + padding-inline-end: 4px; + inset-inline-end: 0; + width: 100%; + position: absolute; + height: 60px; + border-radius: 4px 4px 0 0; + background: rgba(249, 249, 250, 0.8); + top: 0; + border: 1px solid rgba(249, 249, 250, 0.2); + border-bottom: 0; + box-sizing: border-box; } + +.preview-image { + display: flex; + align-items: center; + flex-direction: column; + justify-content: center; + margin: 24px auto; + position: relative; + max-width: 80%; + max-height: 95%; + text-align: center; + animation-delay: 50ms; + display: flex; } + +.preview-image-wrapper { + background: rgba(249, 249, 250, 0.8); + border-radius: 0 0 4px 4px; + display: block; + height: auto; + max-width: 100%; + min-width: 320px; + overflow-y: scroll; + padding: 0 60px; + margin-top: 60px; + border: 1px solid rgba(249, 249, 250, 0.2); + border-top: 0; } + +.preview-image-wrapper > img { + box-shadow: 0 0 0 1px rgba(12, 12, 13, 0.1), 0 2px 8px rgba(12, 12, 13, 0.1); + height: auto; + margin-bottom: 60px; + max-width: 100%; + width: 100%; } + +.fixed-container { + align-items: center; + display: flex; + flex-direction: column; + height: 100vh; + justify-content: center; + inset-inline-start: 0; + margin: 0; + padding: 0; + pointer-events: none; + position: fixed; + top: 0; + width: 100%; } + +.face-container { + position: relative; + width: 64px; + height: 64px; } + +.face { + width: 62.4px; + height: 62.4px; + display: block; + background-image: url("MOZ_EXTENSION/icons/icon-welcome-face-without-eyes.svg"); } + +.eye { + background-color: #fff; + width: 10.8px; + height: 14.6px; + position: absolute; + border-radius: 100%; + overflow: hidden; + inset-inline-start: 16.4px; + top: 19.8px; } + +.eyeball { + position: absolute; + width: 6px; + height: 6px; + background-color: #000; + border-radius: 50%; + inset-inline-start: 2.4px; + top: 4.3px; + z-index: 10; } + +.left { + margin-inline-start: 0; } + +.right { + margin-inline-start: 20px; } + +.preview-instructions { + display: flex; + align-items: center; + justify-content: center; + animation: pulse 125mm cubic-bezier(0.07, 0.95, 0, 1); + color: #fff; + font-family: -apple-system, BlinkMacSystemFont, "segoe ui", "helvetica neue", helvetica, ubuntu, roboto, noto, arial, sans-serif; + font-size: 24px; + line-height: 32px; + text-align: center; + padding-top: 20px; + width: 400px; + user-select: none; } + +.cancel-shot { + background-color: transparent; + cursor: pointer; + outline: none; + border-radius: 3px; + border: 1px #9b9b9b solid; + color: #fff; + cursor: pointer; + font-family: -apple-system, BlinkMacSystemFont, "segoe ui", "helvetica neue", helvetica, ubuntu, roboto, noto, arial, sans-serif; + font-size: 16px; + margin-top: 40px; + padding: 10px 25px; + pointer-events: all; } + +.myshots-all-buttons-container { + display: flex; + flex-direction: row-reverse; + background: #f5f5f5; + border-radius: 2px; + box-sizing: border-box; + height: 80px; + padding: 8px; + position: absolute; + inset-inline-end: 8px; + top: 8px; + box-shadow: 0 0 0 1px rgba(12, 12, 13, 0.1), 0 2px 8px rgba(12, 12, 13, 0.1); } + .myshots-all-buttons-container .spacer { + background-color: #c9c9c9; + flex: 0 0 1px; + height: 80px; + margin: 0 10px; + position: relative; + top: -8px; } + .myshots-all-buttons-container button { + display: flex; + align-items: center; + flex-direction: column; + justify-content: flex-end; + color: #3e3d40; + background-color: #f5f5f5; + background-position: center top; + background-repeat: no-repeat; + background-size: 46px 46px; + border: 1px solid transparent; + cursor: pointer; + height: 100%; + min-width: 90px; + padding: 46px 5px 5px; + pointer-events: all; + transition: border 150ms cubic-bezier(0.07, 0.95, 0, 1), background-color 150ms cubic-bezier(0.07, 0.95, 0, 1); + white-space: nowrap; } + .myshots-all-buttons-container button:hover { + background-color: #ebebeb; + border: 1px solid #c7c7c7; } + .myshots-all-buttons-container button:active { + background-color: #dedede; + border: 1px solid #989898; } + .myshots-all-buttons-container .myshots-button { + background-image: url("MOZ_EXTENSION/icons/menu-myshot.svg"); } + .myshots-all-buttons-container .full-page { + background-image: url("MOZ_EXTENSION/icons/menu-fullpage.svg"); } + .myshots-all-buttons-container .visible { + background-image: url("MOZ_EXTENSION/icons/menu-visible.svg"); } + +.myshots-button-container { + display: flex; + align-items: center; + justify-content: center; } + +@keyframes pulse { + 0% { + transform: scale(1); } + 50% { + transform: scale(1.06); } + 100% { + transform: scale(1); } } + +@keyframes fade-in { + 0% { + opacity: 0; } + 100% { + opacity: 1; } } + +`; +null; + diff --git a/browser/extensions/screenshots/build/raven.js b/browser/extensions/screenshots/build/raven.js new file mode 100644 index 0000000000..86eaa50368 --- /dev/null +++ b/browser/extensions/screenshots/build/raven.js @@ -0,0 +1,4115 @@ +/*! Raven.js 3.27.0 (200cffcc) | github.com/getsentry/raven-js */ + +/* + * Includes TraceKit + * https://github.com/getsentry/TraceKit + * + * Copyright (c) 2018 Sentry (https://sentry.io) and individual contributors. + * All rights reserved. + * https://github.com/getsentry/sentry-javascript/blob/master/packages/raven-js/LICENSE + * + */ + +(function(f){if(typeof exports==="object"&&typeof module!=="undefined"){module.exports=f()}else if(typeof define==="function"&&define.amd){define([],f)}else{var g;if(typeof window!=="undefined"){g=window}else if(typeof global!=="undefined"){g=global}else if(typeof self!=="undefined"){g=self}else{g=this}g.Raven = f()}})(function(){var define,module,exports;return (function e(t,n,r){function s(o,u){if(!n[o]){if(!t[o]){var a=typeof require=="function"&&require;if(!u&&a)return a(o,!0);if(i)return i(o,!0);var f=new Error("Cannot find module '"+o+"'");throw f.code="MODULE_NOT_FOUND",f}var l=n[o]={exports:{}};t[o][0].call(l.exports,function(e){var n=t[o][1][e];return s(n?n:e)},l,l.exports,e,t,n,r)}return n[o].exports}var i=typeof require=="function"&&require;for(var o=0;o<r.length;o++)s(r[o]);return s})({1:[function(_dereq_,module,exports){ +function RavenConfigError(message) { + this.name = 'RavenConfigError'; + this.message = message; +} +RavenConfigError.prototype = new Error(); +RavenConfigError.prototype.constructor = RavenConfigError; + +module.exports = RavenConfigError; + +},{}],2:[function(_dereq_,module,exports){ +var utils = _dereq_(5); + +var wrapMethod = function(console, level, callback) { + var originalConsoleLevel = console[level]; + var originalConsole = console; + + if (!(level in console)) { + return; + } + + var sentryLevel = level === 'warn' ? 'warning' : level; + + console[level] = function() { + var args = [].slice.call(arguments); + + var msg = utils.safeJoin(args, ' '); + var data = {level: sentryLevel, logger: 'console', extra: {arguments: args}}; + + if (level === 'assert') { + if (args[0] === false) { + // Default browsers message + msg = + 'Assertion failed: ' + (utils.safeJoin(args.slice(1), ' ') || 'console.assert'); + data.extra.arguments = args.slice(1); + callback && callback(msg, data); + } + } else { + callback && callback(msg, data); + } + + // this fails for some browsers. :( + if (originalConsoleLevel) { + // IE9 doesn't allow calling apply on console functions directly + // See: https://stackoverflow.com/questions/5472938/does-ie9-support-console-log-and-is-it-a-real-function#answer-5473193 + Function.prototype.apply.call(originalConsoleLevel, originalConsole, args); + } + }; +}; + +module.exports = { + wrapMethod: wrapMethod +}; + +},{"5":5}],3:[function(_dereq_,module,exports){ +(function (global){ +/*global XDomainRequest:false */ + +var TraceKit = _dereq_(6); +var stringify = _dereq_(7); +var md5 = _dereq_(8); +var RavenConfigError = _dereq_(1); + +var utils = _dereq_(5); +var isErrorEvent = utils.isErrorEvent; +var isDOMError = utils.isDOMError; +var isDOMException = utils.isDOMException; +var isError = utils.isError; +var isObject = utils.isObject; +var isPlainObject = utils.isPlainObject; +var isUndefined = utils.isUndefined; +var isFunction = utils.isFunction; +var isString = utils.isString; +var isArray = utils.isArray; +var isEmptyObject = utils.isEmptyObject; +var each = utils.each; +var objectMerge = utils.objectMerge; +var truncate = utils.truncate; +var objectFrozen = utils.objectFrozen; +var hasKey = utils.hasKey; +var joinRegExp = utils.joinRegExp; +var urlencode = utils.urlencode; +var uuid4 = utils.uuid4; +var htmlTreeAsString = utils.htmlTreeAsString; +var isSameException = utils.isSameException; +var isSameStacktrace = utils.isSameStacktrace; +var parseUrl = utils.parseUrl; +var fill = utils.fill; +var supportsFetch = utils.supportsFetch; +var supportsReferrerPolicy = utils.supportsReferrerPolicy; +var serializeKeysForMessage = utils.serializeKeysForMessage; +var serializeException = utils.serializeException; +var sanitize = utils.sanitize; + +var wrapConsoleMethod = _dereq_(2).wrapMethod; + +var dsnKeys = 'source protocol user pass host port path'.split(' '), + dsnPattern = /^(?:(\w+):)?\/\/(?:(\w+)(:\w+)?@)?([\w\.-]+)(?::(\d+))?(\/.*)/; + +function now() { + return +new Date(); +} + +// This is to be defensive in environments where window does not exist (see https://github.com/getsentry/raven-js/pull/785) +var _window = + typeof window !== 'undefined' + ? window + : typeof global !== 'undefined' ? global : typeof self !== 'undefined' ? self : {}; +var _document = _window.document; +var _navigator = _window.navigator; + +function keepOriginalCallback(original, callback) { + return isFunction(callback) + ? function(data) { + return callback(data, original); + } + : callback; +} + +// First, check for JSON support +// If there is no JSON, we no-op the core features of Raven +// since JSON is required to encode the payload +function Raven() { + this._hasJSON = !!(typeof JSON === 'object' && JSON.stringify); + // Raven can run in contexts where there's no document (react-native) + this._hasDocument = !isUndefined(_document); + this._hasNavigator = !isUndefined(_navigator); + this._lastCapturedException = null; + this._lastData = null; + this._lastEventId = null; + this._globalServer = null; + this._globalKey = null; + this._globalProject = null; + this._globalContext = {}; + this._globalOptions = { + // SENTRY_RELEASE can be injected by https://github.com/getsentry/sentry-webpack-plugin + release: _window.SENTRY_RELEASE && _window.SENTRY_RELEASE.id, + logger: 'javascript', + ignoreErrors: [], + ignoreUrls: [], + whitelistUrls: [], + includePaths: [], + headers: null, + collectWindowErrors: true, + captureUnhandledRejections: true, + maxMessageLength: 0, + // By default, truncates URL values to 250 chars + maxUrlLength: 250, + stackTraceLimit: 50, + autoBreadcrumbs: true, + instrument: true, + sampleRate: 1, + sanitizeKeys: [] + }; + this._fetchDefaults = { + method: 'POST', + // Despite all stars in the sky saying that Edge supports old draft syntax, aka 'never', 'always', 'origin' and 'default + // https://caniuse.com/#feat=referrer-policy + // It doesn't. And it throw exception instead of ignoring this parameter... + // REF: https://github.com/getsentry/raven-js/issues/1233 + referrerPolicy: supportsReferrerPolicy() ? 'origin' : '' + }; + this._ignoreOnError = 0; + this._isRavenInstalled = false; + this._originalErrorStackTraceLimit = Error.stackTraceLimit; + // capture references to window.console *and* all its methods first + // before the console plugin has a chance to monkey patch + this._originalConsole = _window.console || {}; + this._originalConsoleMethods = {}; + this._plugins = []; + this._startTime = now(); + this._wrappedBuiltIns = []; + this._breadcrumbs = []; + this._lastCapturedEvent = null; + this._keypressTimeout; + this._location = _window.location; + this._lastHref = this._location && this._location.href; + this._resetBackoff(); + + // eslint-disable-next-line guard-for-in + for (var method in this._originalConsole) { + this._originalConsoleMethods[method] = this._originalConsole[method]; + } +} + +/* + * The core Raven singleton + * + * @this {Raven} + */ + +Raven.prototype = { + // Hardcode version string so that raven source can be loaded directly via + // webpack (using a build step causes webpack #1617). Grunt verifies that + // this value matches package.json during build. + // See: https://github.com/getsentry/raven-js/issues/465 + VERSION: '3.27.0', + + debug: false, + + TraceKit: TraceKit, // alias to TraceKit + + /* + * Configure Raven with a DSN and extra options + * + * @param {string} dsn The public Sentry DSN + * @param {object} options Set of global options [optional] + * @return {Raven} + */ + config: function(dsn, options) { + var self = this; + + if (self._globalServer) { + this._logDebug('error', 'Error: Raven has already been configured'); + return self; + } + if (!dsn) return self; + + var globalOptions = self._globalOptions; + + // merge in options + if (options) { + each(options, function(key, value) { + // tags and extra are special and need to be put into context + if (key === 'tags' || key === 'extra' || key === 'user') { + self._globalContext[key] = value; + } else { + globalOptions[key] = value; + } + }); + } + + self.setDSN(dsn); + + // "Script error." is hard coded into browsers for errors that it can't read. + // this is the result of a script being pulled in from an external domain and CORS. + globalOptions.ignoreErrors.push(/^Script error\.?$/); + globalOptions.ignoreErrors.push(/^Javascript error: Script error\.? on line 0$/); + + // join regexp rules into one big rule + globalOptions.ignoreErrors = joinRegExp(globalOptions.ignoreErrors); + globalOptions.ignoreUrls = globalOptions.ignoreUrls.length + ? joinRegExp(globalOptions.ignoreUrls) + : false; + globalOptions.whitelistUrls = globalOptions.whitelistUrls.length + ? joinRegExp(globalOptions.whitelistUrls) + : false; + globalOptions.includePaths = joinRegExp(globalOptions.includePaths); + globalOptions.maxBreadcrumbs = Math.max( + 0, + Math.min(globalOptions.maxBreadcrumbs || 100, 100) + ); // default and hard limit is 100 + + var autoBreadcrumbDefaults = { + xhr: true, + console: true, + dom: true, + location: true, + sentry: true + }; + + var autoBreadcrumbs = globalOptions.autoBreadcrumbs; + if ({}.toString.call(autoBreadcrumbs) === '[object Object]') { + autoBreadcrumbs = objectMerge(autoBreadcrumbDefaults, autoBreadcrumbs); + } else if (autoBreadcrumbs !== false) { + autoBreadcrumbs = autoBreadcrumbDefaults; + } + globalOptions.autoBreadcrumbs = autoBreadcrumbs; + + var instrumentDefaults = { + tryCatch: true + }; + + var instrument = globalOptions.instrument; + if ({}.toString.call(instrument) === '[object Object]') { + instrument = objectMerge(instrumentDefaults, instrument); + } else if (instrument !== false) { + instrument = instrumentDefaults; + } + globalOptions.instrument = instrument; + + TraceKit.collectWindowErrors = !!globalOptions.collectWindowErrors; + + // return for chaining + return self; + }, + + /* + * Installs a global window.onerror error handler + * to capture and report uncaught exceptions. + * At this point, install() is required to be called due + * to the way TraceKit is set up. + * + * @return {Raven} + */ + install: function() { + var self = this; + if (self.isSetup() && !self._isRavenInstalled) { + TraceKit.report.subscribe(function() { + self._handleOnErrorStackInfo.apply(self, arguments); + }); + + if (self._globalOptions.captureUnhandledRejections) { + self._attachPromiseRejectionHandler(); + } + + self._patchFunctionToString(); + + if (self._globalOptions.instrument && self._globalOptions.instrument.tryCatch) { + self._instrumentTryCatch(); + } + + if (self._globalOptions.autoBreadcrumbs) self._instrumentBreadcrumbs(); + + // Install all of the plugins + self._drainPlugins(); + + self._isRavenInstalled = true; + } + + Error.stackTraceLimit = self._globalOptions.stackTraceLimit; + return this; + }, + + /* + * Set the DSN (can be called multiple time unlike config) + * + * @param {string} dsn The public Sentry DSN + */ + setDSN: function(dsn) { + var self = this, + uri = self._parseDSN(dsn), + lastSlash = uri.path.lastIndexOf('/'), + path = uri.path.substr(1, lastSlash); + + self._dsn = dsn; + self._globalKey = uri.user; + self._globalSecret = uri.pass && uri.pass.substr(1); + self._globalProject = uri.path.substr(lastSlash + 1); + + self._globalServer = self._getGlobalServer(uri); + + self._globalEndpoint = + self._globalServer + '/' + path + 'api/' + self._globalProject + '/store/'; + + // Reset backoff state since we may be pointing at a + // new project/server + this._resetBackoff(); + }, + + /* + * Wrap code within a context so Raven can capture errors + * reliably across domains that is executed immediately. + * + * @param {object} options A specific set of options for this context [optional] + * @param {function} func The callback to be immediately executed within the context + * @param {array} args An array of arguments to be called with the callback [optional] + */ + context: function(options, func, args) { + if (isFunction(options)) { + args = func || []; + func = options; + options = {}; + } + + return this.wrap(options, func).apply(this, args); + }, + + /* + * Wrap code within a context and returns back a new function to be executed + * + * @param {object} options A specific set of options for this context [optional] + * @param {function} func The function to be wrapped in a new context + * @param {function} _before A function to call before the try/catch wrapper [optional, private] + * @return {function} The newly wrapped functions with a context + */ + wrap: function(options, func, _before) { + var self = this; + // 1 argument has been passed, and it's not a function + // so just return it + if (isUndefined(func) && !isFunction(options)) { + return options; + } + + // options is optional + if (isFunction(options)) { + func = options; + options = undefined; + } + + // At this point, we've passed along 2 arguments, and the second one + // is not a function either, so we'll just return the second argument. + if (!isFunction(func)) { + return func; + } + + // We don't wanna wrap it twice! + try { + if (func.__raven__) { + return func; + } + + // If this has already been wrapped in the past, return that + if (func.__raven_wrapper__) { + return func.__raven_wrapper__; + } + } catch (e) { + // Just accessing custom props in some Selenium environments + // can cause a "Permission denied" exception (see raven-js#495). + // Bail on wrapping and return the function as-is (defers to window.onerror). + return func; + } + + function wrapped() { + var args = [], + i = arguments.length, + deep = !options || (options && options.deep !== false); + + if (_before && isFunction(_before)) { + _before.apply(this, arguments); + } + + // Recursively wrap all of a function's arguments that are + // functions themselves. + while (i--) args[i] = deep ? self.wrap(options, arguments[i]) : arguments[i]; + + try { + // Attempt to invoke user-land function + // NOTE: If you are a Sentry user, and you are seeing this stack frame, it + // means Raven caught an error invoking your application code. This is + // expected behavior and NOT indicative of a bug with Raven.js. + return func.apply(this, args); + } catch (e) { + self._ignoreNextOnError(); + self.captureException(e, options); + throw e; + } + } + + // copy over properties of the old function + for (var property in func) { + if (hasKey(func, property)) { + wrapped[property] = func[property]; + } + } + wrapped.prototype = func.prototype; + + func.__raven_wrapper__ = wrapped; + // Signal that this function has been wrapped/filled already + // for both debugging and to prevent it to being wrapped/filled twice + wrapped.__raven__ = true; + wrapped.__orig__ = func; + + return wrapped; + }, + + /** + * Uninstalls the global error handler. + * + * @return {Raven} + */ + uninstall: function() { + TraceKit.report.uninstall(); + + this._detachPromiseRejectionHandler(); + this._unpatchFunctionToString(); + this._restoreBuiltIns(); + this._restoreConsole(); + + Error.stackTraceLimit = this._originalErrorStackTraceLimit; + this._isRavenInstalled = false; + + return this; + }, + + /** + * Callback used for `unhandledrejection` event + * + * @param {PromiseRejectionEvent} event An object containing + * promise: the Promise that was rejected + * reason: the value with which the Promise was rejected + * @return void + */ + _promiseRejectionHandler: function(event) { + this._logDebug('debug', 'Raven caught unhandled promise rejection:', event); + this.captureException(event.reason, { + mechanism: { + type: 'onunhandledrejection', + handled: false + } + }); + }, + + /** + * Installs the global promise rejection handler. + * + * @return {raven} + */ + _attachPromiseRejectionHandler: function() { + this._promiseRejectionHandler = this._promiseRejectionHandler.bind(this); + _window.addEventListener && + _window.addEventListener('unhandledrejection', this._promiseRejectionHandler); + return this; + }, + + /** + * Uninstalls the global promise rejection handler. + * + * @return {raven} + */ + _detachPromiseRejectionHandler: function() { + _window.removeEventListener && + _window.removeEventListener('unhandledrejection', this._promiseRejectionHandler); + return this; + }, + + /** + * Manually capture an exception and send it over to Sentry + * + * @param {error} ex An exception to be logged + * @param {object} options A specific set of options for this error [optional] + * @return {Raven} + */ + captureException: function(ex, options) { + options = objectMerge({trimHeadFrames: 0}, options ? options : {}); + + if (isErrorEvent(ex) && ex.error) { + // If it is an ErrorEvent with `error` property, extract it to get actual Error + ex = ex.error; + } else if (isDOMError(ex) || isDOMException(ex)) { + // If it is a DOMError or DOMException (which are legacy APIs, but still supported in some browsers) + // then we just extract the name and message, as they don't provide anything else + // https://developer.mozilla.org/en-US/docs/Web/API/DOMError + // https://developer.mozilla.org/en-US/docs/Web/API/DOMException + var name = ex.name || (isDOMError(ex) ? 'DOMError' : 'DOMException'); + var message = ex.message ? name + ': ' + ex.message : name; + + return this.captureMessage( + message, + objectMerge(options, { + // neither DOMError or DOMException provide stack trace and we most likely wont get it this way as well + // but it's barely any overhead so we may at least try + stacktrace: true, + trimHeadFrames: options.trimHeadFrames + 1 + }) + ); + } else if (isError(ex)) { + // we have a real Error object + ex = ex; + } else if (isPlainObject(ex)) { + // If it is plain Object, serialize it manually and extract options + // This will allow us to group events based on top-level keys + // which is much better than creating new group when any key/value change + options = this._getCaptureExceptionOptionsFromPlainObject(options, ex); + ex = new Error(options.message); + } else { + // If none of previous checks were valid, then it means that + // it's not a DOMError/DOMException + // it's not a plain Object + // it's not a valid ErrorEvent (one with an error property) + // it's not an Error + // So bail out and capture it as a simple message: + return this.captureMessage( + ex, + objectMerge(options, { + stacktrace: true, // if we fall back to captureMessage, default to attempting a new trace + trimHeadFrames: options.trimHeadFrames + 1 + }) + ); + } + + // Store the raw exception object for potential debugging and introspection + this._lastCapturedException = ex; + + // TraceKit.report will re-raise any exception passed to it, + // which means you have to wrap it in try/catch. Instead, we + // can wrap it here and only re-raise if TraceKit.report + // raises an exception different from the one we asked to + // report on. + try { + var stack = TraceKit.computeStackTrace(ex); + this._handleStackInfo(stack, options); + } catch (ex1) { + if (ex !== ex1) { + throw ex1; + } + } + + return this; + }, + + _getCaptureExceptionOptionsFromPlainObject: function(currentOptions, ex) { + var exKeys = Object.keys(ex).sort(); + var options = objectMerge(currentOptions, { + message: + 'Non-Error exception captured with keys: ' + serializeKeysForMessage(exKeys), + fingerprint: [md5(exKeys)], + extra: currentOptions.extra || {} + }); + options.extra.__serialized__ = serializeException(ex); + + return options; + }, + + /* + * Manually send a message to Sentry + * + * @param {string} msg A plain message to be captured in Sentry + * @param {object} options A specific set of options for this message [optional] + * @return {Raven} + */ + captureMessage: function(msg, options) { + // config() automagically converts ignoreErrors from a list to a RegExp so we need to test for an + // early call; we'll error on the side of logging anything called before configuration since it's + // probably something you should see: + if ( + !!this._globalOptions.ignoreErrors.test && + this._globalOptions.ignoreErrors.test(msg) + ) { + return; + } + + options = options || {}; + msg = msg + ''; // Make sure it's actually a string + + var data = objectMerge( + { + message: msg + }, + options + ); + + var ex; + // Generate a "synthetic" stack trace from this point. + // NOTE: If you are a Sentry user, and you are seeing this stack frame, it is NOT indicative + // of a bug with Raven.js. Sentry generates synthetic traces either by configuration, + // or if it catches a thrown object without a "stack" property. + try { + throw new Error(msg); + } catch (ex1) { + ex = ex1; + } + + // null exception name so `Error` isn't prefixed to msg + ex.name = null; + var stack = TraceKit.computeStackTrace(ex); + + // stack[0] is `throw new Error(msg)` call itself, we are interested in the frame that was just before that, stack[1] + var initialCall = isArray(stack.stack) && stack.stack[1]; + + // if stack[1] is `Raven.captureException`, it means that someone passed a string to it and we redirected that call + // to be handled by `captureMessage`, thus `initialCall` is the 3rd one, not 2nd + // initialCall => captureException(string) => captureMessage(string) + if (initialCall && initialCall.func === 'Raven.captureException') { + initialCall = stack.stack[2]; + } + + var fileurl = (initialCall && initialCall.url) || ''; + + if ( + !!this._globalOptions.ignoreUrls.test && + this._globalOptions.ignoreUrls.test(fileurl) + ) { + return; + } + + if ( + !!this._globalOptions.whitelistUrls.test && + !this._globalOptions.whitelistUrls.test(fileurl) + ) { + return; + } + + // Always attempt to get stacktrace if message is empty. + // It's the only way to provide any helpful information to the user. + if (this._globalOptions.stacktrace || options.stacktrace || data.message === '') { + // fingerprint on msg, not stack trace (legacy behavior, could be revisited) + data.fingerprint = data.fingerprint == null ? msg : data.fingerprint; + + options = objectMerge( + { + trimHeadFrames: 0 + }, + options + ); + // Since we know this is a synthetic trace, the top frame (this function call) + // MUST be from Raven.js, so mark it for trimming + // We add to the trim counter so that callers can choose to trim extra frames, such + // as utility functions. + options.trimHeadFrames += 1; + + var frames = this._prepareFrames(stack, options); + data.stacktrace = { + // Sentry expects frames oldest to newest + frames: frames.reverse() + }; + } + + // Make sure that fingerprint is always wrapped in an array + if (data.fingerprint) { + data.fingerprint = isArray(data.fingerprint) + ? data.fingerprint + : [data.fingerprint]; + } + + // Fire away! + this._send(data); + + return this; + }, + + captureBreadcrumb: function(obj) { + var crumb = objectMerge( + { + timestamp: now() / 1000 + }, + obj + ); + + if (isFunction(this._globalOptions.breadcrumbCallback)) { + var result = this._globalOptions.breadcrumbCallback(crumb); + + if (isObject(result) && !isEmptyObject(result)) { + crumb = result; + } else if (result === false) { + return this; + } + } + + this._breadcrumbs.push(crumb); + if (this._breadcrumbs.length > this._globalOptions.maxBreadcrumbs) { + this._breadcrumbs.shift(); + } + return this; + }, + + addPlugin: function(plugin /*arg1, arg2, ... argN*/) { + var pluginArgs = [].slice.call(arguments, 1); + + this._plugins.push([plugin, pluginArgs]); + if (this._isRavenInstalled) { + this._drainPlugins(); + } + + return this; + }, + + /* + * Set/clear a user to be sent along with the payload. + * + * @param {object} user An object representing user data [optional] + * @return {Raven} + */ + setUserContext: function(user) { + // Intentionally do not merge here since that's an unexpected behavior. + this._globalContext.user = user; + + return this; + }, + + /* + * Merge extra attributes to be sent along with the payload. + * + * @param {object} extra An object representing extra data [optional] + * @return {Raven} + */ + setExtraContext: function(extra) { + this._mergeContext('extra', extra); + + return this; + }, + + /* + * Merge tags to be sent along with the payload. + * + * @param {object} tags An object representing tags [optional] + * @return {Raven} + */ + setTagsContext: function(tags) { + this._mergeContext('tags', tags); + + return this; + }, + + /* + * Clear all of the context. + * + * @return {Raven} + */ + clearContext: function() { + this._globalContext = {}; + + return this; + }, + + /* + * Get a copy of the current context. This cannot be mutated. + * + * @return {object} copy of context + */ + getContext: function() { + // lol javascript + return JSON.parse(stringify(this._globalContext)); + }, + + /* + * Set environment of application + * + * @param {string} environment Typically something like 'production'. + * @return {Raven} + */ + setEnvironment: function(environment) { + this._globalOptions.environment = environment; + + return this; + }, + + /* + * Set release version of application + * + * @param {string} release Typically something like a git SHA to identify version + * @return {Raven} + */ + setRelease: function(release) { + this._globalOptions.release = release; + + return this; + }, + + /* + * Set the dataCallback option + * + * @param {function} callback The callback to run which allows the + * data blob to be mutated before sending + * @return {Raven} + */ + setDataCallback: function(callback) { + var original = this._globalOptions.dataCallback; + this._globalOptions.dataCallback = keepOriginalCallback(original, callback); + return this; + }, + + /* + * Set the breadcrumbCallback option + * + * @param {function} callback The callback to run which allows filtering + * or mutating breadcrumbs + * @return {Raven} + */ + setBreadcrumbCallback: function(callback) { + var original = this._globalOptions.breadcrumbCallback; + this._globalOptions.breadcrumbCallback = keepOriginalCallback(original, callback); + return this; + }, + + /* + * Set the shouldSendCallback option + * + * @param {function} callback The callback to run which allows + * introspecting the blob before sending + * @return {Raven} + */ + setShouldSendCallback: function(callback) { + var original = this._globalOptions.shouldSendCallback; + this._globalOptions.shouldSendCallback = keepOriginalCallback(original, callback); + return this; + }, + + /** + * Override the default HTTP transport mechanism that transmits data + * to the Sentry server. + * + * @param {function} transport Function invoked instead of the default + * `makeRequest` handler. + * + * @return {Raven} + */ + setTransport: function(transport) { + this._globalOptions.transport = transport; + + return this; + }, + + /* + * Get the latest raw exception that was captured by Raven. + * + * @return {error} + */ + lastException: function() { + return this._lastCapturedException; + }, + + /* + * Get the last event id + * + * @return {string} + */ + lastEventId: function() { + return this._lastEventId; + }, + + /* + * Determine if Raven is setup and ready to go. + * + * @return {boolean} + */ + isSetup: function() { + if (!this._hasJSON) return false; // needs JSON support + if (!this._globalServer) { + if (!this.ravenNotConfiguredError) { + this.ravenNotConfiguredError = true; + this._logDebug('error', 'Error: Raven has not been configured.'); + } + return false; + } + return true; + }, + + afterLoad: function() { + // TODO: remove window dependence? + + // Attempt to initialize Raven on load + var RavenConfig = _window.RavenConfig; + if (RavenConfig) { + this.config(RavenConfig.dsn, RavenConfig.config).install(); + } + }, + + showReportDialog: function(options) { + if ( + !_document // doesn't work without a document (React native) + ) + return; + + options = objectMerge( + { + eventId: this.lastEventId(), + dsn: this._dsn, + user: this._globalContext.user || {} + }, + options + ); + + if (!options.eventId) { + throw new RavenConfigError('Missing eventId'); + } + + if (!options.dsn) { + throw new RavenConfigError('Missing DSN'); + } + + var encode = encodeURIComponent; + var encodedOptions = []; + + for (var key in options) { + if (key === 'user') { + var user = options.user; + if (user.name) encodedOptions.push('name=' + encode(user.name)); + if (user.email) encodedOptions.push('email=' + encode(user.email)); + } else { + encodedOptions.push(encode(key) + '=' + encode(options[key])); + } + } + var globalServer = this._getGlobalServer(this._parseDSN(options.dsn)); + + var script = _document.createElement('script'); + script.async = true; + script.src = globalServer + '/api/embed/error-page/?' + encodedOptions.join('&'); + (_document.head || _document.body).appendChild(script); + }, + + /**** Private functions ****/ + _ignoreNextOnError: function() { + var self = this; + this._ignoreOnError += 1; + setTimeout(function() { + // onerror should trigger before setTimeout + self._ignoreOnError -= 1; + }); + }, + + _triggerEvent: function(eventType, options) { + // NOTE: `event` is a native browser thing, so let's avoid conflicting wiht it + var evt, key; + + if (!this._hasDocument) return; + + options = options || {}; + + eventType = 'raven' + eventType.substr(0, 1).toUpperCase() + eventType.substr(1); + + if (_document.createEvent) { + evt = _document.createEvent('HTMLEvents'); + evt.initEvent(eventType, true, true); + } else { + evt = _document.createEventObject(); + evt.eventType = eventType; + } + + for (key in options) + if (hasKey(options, key)) { + evt[key] = options[key]; + } + + if (_document.createEvent) { + // IE9 if standards + _document.dispatchEvent(evt); + } else { + // IE8 regardless of Quirks or Standards + // IE9 if quirks + try { + _document.fireEvent('on' + evt.eventType.toLowerCase(), evt); + } catch (e) { + // Do nothing + } + } + }, + + /** + * Wraps addEventListener to capture UI breadcrumbs + * @param evtName the event name (e.g. "click") + * @returns {Function} + * @private + */ + _breadcrumbEventHandler: function(evtName) { + var self = this; + return function(evt) { + // reset keypress timeout; e.g. triggering a 'click' after + // a 'keypress' will reset the keypress debounce so that a new + // set of keypresses can be recorded + self._keypressTimeout = null; + + // It's possible this handler might trigger multiple times for the same + // event (e.g. event propagation through node ancestors). Ignore if we've + // already captured the event. + if (self._lastCapturedEvent === evt) return; + + self._lastCapturedEvent = evt; + + // try/catch both: + // - accessing evt.target (see getsentry/raven-js#838, #768) + // - `htmlTreeAsString` because it's complex, and just accessing the DOM incorrectly + // can throw an exception in some circumstances. + var target; + try { + target = htmlTreeAsString(evt.target); + } catch (e) { + target = '<unknown>'; + } + + self.captureBreadcrumb({ + category: 'ui.' + evtName, // e.g. ui.click, ui.input + message: target + }); + }; + }, + + /** + * Wraps addEventListener to capture keypress UI events + * @returns {Function} + * @private + */ + _keypressEventHandler: function() { + var self = this, + debounceDuration = 1000; // milliseconds + + // TODO: if somehow user switches keypress target before + // debounce timeout is triggered, we will only capture + // a single breadcrumb from the FIRST target (acceptable?) + return function(evt) { + var target; + try { + target = evt.target; + } catch (e) { + // just accessing event properties can throw an exception in some rare circumstances + // see: https://github.com/getsentry/raven-js/issues/838 + return; + } + var tagName = target && target.tagName; + + // only consider keypress events on actual input elements + // this will disregard keypresses targeting body (e.g. tabbing + // through elements, hotkeys, etc) + if ( + !tagName || + (tagName !== 'INPUT' && tagName !== 'TEXTAREA' && !target.isContentEditable) + ) + return; + + // record first keypress in a series, but ignore subsequent + // keypresses until debounce clears + var timeout = self._keypressTimeout; + if (!timeout) { + self._breadcrumbEventHandler('input')(evt); + } + clearTimeout(timeout); + self._keypressTimeout = setTimeout(function() { + self._keypressTimeout = null; + }, debounceDuration); + }; + }, + + /** + * Captures a breadcrumb of type "navigation", normalizing input URLs + * @param to the originating URL + * @param from the target URL + * @private + */ + _captureUrlChange: function(from, to) { + var parsedLoc = parseUrl(this._location.href); + var parsedTo = parseUrl(to); + var parsedFrom = parseUrl(from); + + // because onpopstate only tells you the "new" (to) value of location.href, and + // not the previous (from) value, we need to track the value of the current URL + // state ourselves + this._lastHref = to; + + // Use only the path component of the URL if the URL matches the current + // document (almost all the time when using pushState) + if (parsedLoc.protocol === parsedTo.protocol && parsedLoc.host === parsedTo.host) + to = parsedTo.relative; + if (parsedLoc.protocol === parsedFrom.protocol && parsedLoc.host === parsedFrom.host) + from = parsedFrom.relative; + + this.captureBreadcrumb({ + category: 'navigation', + data: { + to: to, + from: from + } + }); + }, + + _patchFunctionToString: function() { + var self = this; + self._originalFunctionToString = Function.prototype.toString; + // eslint-disable-next-line no-extend-native + Function.prototype.toString = function() { + if (typeof this === 'function' && this.__raven__) { + return self._originalFunctionToString.apply(this.__orig__, arguments); + } + return self._originalFunctionToString.apply(this, arguments); + }; + }, + + _unpatchFunctionToString: function() { + if (this._originalFunctionToString) { + // eslint-disable-next-line no-extend-native + Function.prototype.toString = this._originalFunctionToString; + } + }, + + /** + * Wrap timer functions and event targets to catch errors and provide + * better metadata. + */ + _instrumentTryCatch: function() { + var self = this; + + var wrappedBuiltIns = self._wrappedBuiltIns; + + function wrapTimeFn(orig) { + return function(fn, t) { + // preserve arity + // Make a copy of the arguments to prevent deoptimization + // https://github.com/petkaantonov/bluebird/wiki/Optimization-killers#32-leaking-arguments + var args = new Array(arguments.length); + for (var i = 0; i < args.length; ++i) { + args[i] = arguments[i]; + } + var originalCallback = args[0]; + if (isFunction(originalCallback)) { + args[0] = self.wrap( + { + mechanism: { + type: 'instrument', + data: {function: orig.name || '<anonymous>'} + } + }, + originalCallback + ); + } + + // IE < 9 doesn't support .call/.apply on setInterval/setTimeout, but it + // also supports only two arguments and doesn't care what this is, so we + // can just call the original function directly. + if (orig.apply) { + return orig.apply(this, args); + } else { + return orig(args[0], args[1]); + } + }; + } + + var autoBreadcrumbs = this._globalOptions.autoBreadcrumbs; + + function wrapEventTarget(global) { + var proto = _window[global] && _window[global].prototype; + if (proto && proto.hasOwnProperty && proto.hasOwnProperty('addEventListener')) { + fill( + proto, + 'addEventListener', + function(orig) { + return function(evtName, fn, capture, secure) { + // preserve arity + try { + if (fn && fn.handleEvent) { + fn.handleEvent = self.wrap( + { + mechanism: { + type: 'instrument', + data: { + target: global, + function: 'handleEvent', + handler: (fn && fn.name) || '<anonymous>' + } + } + }, + fn.handleEvent + ); + } + } catch (err) { + // can sometimes get 'Permission denied to access property "handle Event' + } + + // More breadcrumb DOM capture ... done here and not in `_instrumentBreadcrumbs` + // so that we don't have more than one wrapper function + var before, clickHandler, keypressHandler; + + if ( + autoBreadcrumbs && + autoBreadcrumbs.dom && + (global === 'EventTarget' || global === 'Node') + ) { + // NOTE: generating multiple handlers per addEventListener invocation, should + // revisit and verify we can just use one (almost certainly) + clickHandler = self._breadcrumbEventHandler('click'); + keypressHandler = self._keypressEventHandler(); + before = function(evt) { + // need to intercept every DOM event in `before` argument, in case that + // same wrapped method is re-used for different events (e.g. mousemove THEN click) + // see #724 + if (!evt) return; + + var eventType; + try { + eventType = evt.type; + } catch (e) { + // just accessing event properties can throw an exception in some rare circumstances + // see: https://github.com/getsentry/raven-js/issues/838 + return; + } + if (eventType === 'click') return clickHandler(evt); + else if (eventType === 'keypress') return keypressHandler(evt); + }; + } + return orig.call( + this, + evtName, + self.wrap( + { + mechanism: { + type: 'instrument', + data: { + target: global, + function: 'addEventListener', + handler: (fn && fn.name) || '<anonymous>' + } + } + }, + fn, + before + ), + capture, + secure + ); + }; + }, + wrappedBuiltIns + ); + fill( + proto, + 'removeEventListener', + function(orig) { + return function(evt, fn, capture, secure) { + try { + fn = fn && (fn.__raven_wrapper__ ? fn.__raven_wrapper__ : fn); + } catch (e) { + // ignore, accessing __raven_wrapper__ will throw in some Selenium environments + } + return orig.call(this, evt, fn, capture, secure); + }; + }, + wrappedBuiltIns + ); + } + } + + fill(_window, 'setTimeout', wrapTimeFn, wrappedBuiltIns); + fill(_window, 'setInterval', wrapTimeFn, wrappedBuiltIns); + if (_window.requestAnimationFrame) { + fill( + _window, + 'requestAnimationFrame', + function(orig) { + return function(cb) { + return orig( + self.wrap( + { + mechanism: { + type: 'instrument', + data: { + function: 'requestAnimationFrame', + handler: (orig && orig.name) || '<anonymous>' + } + } + }, + cb + ) + ); + }; + }, + wrappedBuiltIns + ); + } + + // event targets borrowed from bugsnag-js: + // https://github.com/bugsnag/bugsnag-js/blob/master/src/bugsnag.js#L666 + var eventTargets = [ + 'EventTarget', + 'Window', + 'Node', + 'ApplicationCache', + 'AudioTrackList', + 'ChannelMergerNode', + 'CryptoOperation', + 'EventSource', + 'FileReader', + 'HTMLUnknownElement', + 'IDBDatabase', + 'IDBRequest', + 'IDBTransaction', + 'KeyOperation', + 'MediaController', + 'MessagePort', + 'ModalWindow', + 'Notification', + 'SVGElementInstance', + 'Screen', + 'TextTrack', + 'TextTrackCue', + 'TextTrackList', + 'WebSocket', + 'WebSocketWorker', + 'Worker', + 'XMLHttpRequest', + 'XMLHttpRequestEventTarget', + 'XMLHttpRequestUpload' + ]; + for (var i = 0; i < eventTargets.length; i++) { + wrapEventTarget(eventTargets[i]); + } + }, + + /** + * Instrument browser built-ins w/ breadcrumb capturing + * - XMLHttpRequests + * - DOM interactions (click/typing) + * - window.location changes + * - console + * + * Can be disabled or individually configured via the `autoBreadcrumbs` config option + */ + _instrumentBreadcrumbs: function() { + var self = this; + var autoBreadcrumbs = this._globalOptions.autoBreadcrumbs; + + var wrappedBuiltIns = self._wrappedBuiltIns; + + function wrapProp(prop, xhr) { + if (prop in xhr && isFunction(xhr[prop])) { + fill(xhr, prop, function(orig) { + return self.wrap( + { + mechanism: { + type: 'instrument', + data: {function: prop, handler: (orig && orig.name) || '<anonymous>'} + } + }, + orig + ); + }); // intentionally don't track filled methods on XHR instances + } + } + + if (autoBreadcrumbs.xhr && 'XMLHttpRequest' in _window) { + var xhrproto = _window.XMLHttpRequest && _window.XMLHttpRequest.prototype; + fill( + xhrproto, + 'open', + function(origOpen) { + return function(method, url) { + // preserve arity + + // if Sentry key appears in URL, don't capture + if (isString(url) && url.indexOf(self._globalKey) === -1) { + this.__raven_xhr = { + method: method, + url: url, + status_code: null + }; + } + + return origOpen.apply(this, arguments); + }; + }, + wrappedBuiltIns + ); + + fill( + xhrproto, + 'send', + function(origSend) { + return function() { + // preserve arity + var xhr = this; + + function onreadystatechangeHandler() { + if (xhr.__raven_xhr && xhr.readyState === 4) { + try { + // touching statusCode in some platforms throws + // an exception + xhr.__raven_xhr.status_code = xhr.status; + } catch (e) { + /* do nothing */ + } + + self.captureBreadcrumb({ + type: 'http', + category: 'xhr', + data: xhr.__raven_xhr + }); + } + } + + var props = ['onload', 'onerror', 'onprogress']; + for (var j = 0; j < props.length; j++) { + wrapProp(props[j], xhr); + } + + if ('onreadystatechange' in xhr && isFunction(xhr.onreadystatechange)) { + fill( + xhr, + 'onreadystatechange', + function(orig) { + return self.wrap( + { + mechanism: { + type: 'instrument', + data: { + function: 'onreadystatechange', + handler: (orig && orig.name) || '<anonymous>' + } + } + }, + orig, + onreadystatechangeHandler + ); + } /* intentionally don't track this instrumentation */ + ); + } else { + // if onreadystatechange wasn't actually set by the page on this xhr, we + // are free to set our own and capture the breadcrumb + xhr.onreadystatechange = onreadystatechangeHandler; + } + + return origSend.apply(this, arguments); + }; + }, + wrappedBuiltIns + ); + } + + if (autoBreadcrumbs.xhr && supportsFetch()) { + fill( + _window, + 'fetch', + function(origFetch) { + return function() { + // preserve arity + // Make a copy of the arguments to prevent deoptimization + // https://github.com/petkaantonov/bluebird/wiki/Optimization-killers#32-leaking-arguments + var args = new Array(arguments.length); + for (var i = 0; i < args.length; ++i) { + args[i] = arguments[i]; + } + + var fetchInput = args[0]; + var method = 'GET'; + var url; + + if (typeof fetchInput === 'string') { + url = fetchInput; + } else if ('Request' in _window && fetchInput instanceof _window.Request) { + url = fetchInput.url; + if (fetchInput.method) { + method = fetchInput.method; + } + } else { + url = '' + fetchInput; + } + + // if Sentry key appears in URL, don't capture, as it's our own request + if (url.indexOf(self._globalKey) !== -1) { + return origFetch.apply(this, args); + } + + if (args[1] && args[1].method) { + method = args[1].method; + } + + var fetchData = { + method: method, + url: url, + status_code: null + }; + + return origFetch + .apply(this, args) + .then(function(response) { + fetchData.status_code = response.status; + + self.captureBreadcrumb({ + type: 'http', + category: 'fetch', + data: fetchData + }); + + return response; + }) + ['catch'](function(err) { + // if there is an error performing the request + self.captureBreadcrumb({ + type: 'http', + category: 'fetch', + data: fetchData, + level: 'error' + }); + + throw err; + }); + }; + }, + wrappedBuiltIns + ); + } + + // Capture breadcrumbs from any click that is unhandled / bubbled up all the way + // to the document. Do this before we instrument addEventListener. + if (autoBreadcrumbs.dom && this._hasDocument) { + if (_document.addEventListener) { + _document.addEventListener('click', self._breadcrumbEventHandler('click'), false); + _document.addEventListener('keypress', self._keypressEventHandler(), false); + } else if (_document.attachEvent) { + // IE8 Compatibility + _document.attachEvent('onclick', self._breadcrumbEventHandler('click')); + _document.attachEvent('onkeypress', self._keypressEventHandler()); + } + } + + // record navigation (URL) changes + // NOTE: in Chrome App environment, touching history.pushState, *even inside + // a try/catch block*, will cause Chrome to output an error to console.error + // borrowed from: https://github.com/angular/angular.js/pull/13945/files + var chrome = _window.chrome; + var isChromePackagedApp = chrome && chrome.app && chrome.app.runtime; + var hasPushAndReplaceState = + !isChromePackagedApp && + _window.history && + _window.history.pushState && + _window.history.replaceState; + if (autoBreadcrumbs.location && hasPushAndReplaceState) { + // TODO: remove onpopstate handler on uninstall() + var oldOnPopState = _window.onpopstate; + _window.onpopstate = function() { + var currentHref = self._location.href; + self._captureUrlChange(self._lastHref, currentHref); + + if (oldOnPopState) { + return oldOnPopState.apply(this, arguments); + } + }; + + var historyReplacementFunction = function(origHistFunction) { + // note history.pushState.length is 0; intentionally not declaring + // params to preserve 0 arity + return function(/* state, title, url */) { + var url = arguments.length > 2 ? arguments[2] : undefined; + + // url argument is optional + if (url) { + // coerce to string (this is what pushState does) + self._captureUrlChange(self._lastHref, url + ''); + } + + return origHistFunction.apply(this, arguments); + }; + }; + + fill(_window.history, 'pushState', historyReplacementFunction, wrappedBuiltIns); + fill(_window.history, 'replaceState', historyReplacementFunction, wrappedBuiltIns); + } + + if (autoBreadcrumbs.console && 'console' in _window && console.log) { + // console + var consoleMethodCallback = function(msg, data) { + self.captureBreadcrumb({ + message: msg, + level: data.level, + category: 'console' + }); + }; + + each(['debug', 'info', 'warn', 'error', 'log'], function(_, level) { + wrapConsoleMethod(console, level, consoleMethodCallback); + }); + } + }, + + _restoreBuiltIns: function() { + // restore any wrapped builtins + var builtin; + while (this._wrappedBuiltIns.length) { + builtin = this._wrappedBuiltIns.shift(); + + var obj = builtin[0], + name = builtin[1], + orig = builtin[2]; + + obj[name] = orig; + } + }, + + _restoreConsole: function() { + // eslint-disable-next-line guard-for-in + for (var method in this._originalConsoleMethods) { + this._originalConsole[method] = this._originalConsoleMethods[method]; + } + }, + + _drainPlugins: function() { + var self = this; + + // FIX ME TODO + each(this._plugins, function(_, plugin) { + var installer = plugin[0]; + var args = plugin[1]; + installer.apply(self, [self].concat(args)); + }); + }, + + _parseDSN: function(str) { + var m = dsnPattern.exec(str), + dsn = {}, + i = 7; + + try { + while (i--) dsn[dsnKeys[i]] = m[i] || ''; + } catch (e) { + throw new RavenConfigError('Invalid DSN: ' + str); + } + + if (dsn.pass && !this._globalOptions.allowSecretKey) { + throw new RavenConfigError( + 'Do not specify your secret key in the DSN. See: http://bit.ly/raven-secret-key' + ); + } + + return dsn; + }, + + _getGlobalServer: function(uri) { + // assemble the endpoint from the uri pieces + var globalServer = '//' + uri.host + (uri.port ? ':' + uri.port : ''); + + if (uri.protocol) { + globalServer = uri.protocol + ':' + globalServer; + } + return globalServer; + }, + + _handleOnErrorStackInfo: function(stackInfo, options) { + options = options || {}; + options.mechanism = options.mechanism || { + type: 'onerror', + handled: false + }; + + // if we are intentionally ignoring errors via onerror, bail out + if (!this._ignoreOnError) { + this._handleStackInfo(stackInfo, options); + } + }, + + _handleStackInfo: function(stackInfo, options) { + var frames = this._prepareFrames(stackInfo, options); + + this._triggerEvent('handle', { + stackInfo: stackInfo, + options: options + }); + + this._processException( + stackInfo.name, + stackInfo.message, + stackInfo.url, + stackInfo.lineno, + frames, + options + ); + }, + + _prepareFrames: function(stackInfo, options) { + var self = this; + var frames = []; + if (stackInfo.stack && stackInfo.stack.length) { + each(stackInfo.stack, function(i, stack) { + var frame = self._normalizeFrame(stack, stackInfo.url); + if (frame) { + frames.push(frame); + } + }); + + // e.g. frames captured via captureMessage throw + if (options && options.trimHeadFrames) { + for (var j = 0; j < options.trimHeadFrames && j < frames.length; j++) { + frames[j].in_app = false; + } + } + } + frames = frames.slice(0, this._globalOptions.stackTraceLimit); + return frames; + }, + + _normalizeFrame: function(frame, stackInfoUrl) { + // normalize the frames data + var normalized = { + filename: frame.url, + lineno: frame.line, + colno: frame.column, + function: frame.func || '?' + }; + + // Case when we don't have any information about the error + // E.g. throwing a string or raw object, instead of an `Error` in Firefox + // Generating synthetic error doesn't add any value here + // + // We should probably somehow let a user know that they should fix their code + if (!frame.url) { + normalized.filename = stackInfoUrl; // fallback to whole stacks url from onerror handler + } + + normalized.in_app = !// determine if an exception came from outside of our app + // first we check the global includePaths list. + ( + (!!this._globalOptions.includePaths.test && + !this._globalOptions.includePaths.test(normalized.filename)) || + // Now we check for fun, if the function name is Raven or TraceKit + /(Raven|TraceKit)\./.test(normalized['function']) || + // finally, we do a last ditch effort and check for raven.min.js + /raven\.(min\.)?js$/.test(normalized.filename) + ); + + return normalized; + }, + + _processException: function(type, message, fileurl, lineno, frames, options) { + var prefixedMessage = (type ? type + ': ' : '') + (message || ''); + if ( + !!this._globalOptions.ignoreErrors.test && + (this._globalOptions.ignoreErrors.test(message) || + this._globalOptions.ignoreErrors.test(prefixedMessage)) + ) { + return; + } + + var stacktrace; + + if (frames && frames.length) { + fileurl = frames[0].filename || fileurl; + // Sentry expects frames oldest to newest + // and JS sends them as newest to oldest + frames.reverse(); + stacktrace = {frames: frames}; + } else if (fileurl) { + stacktrace = { + frames: [ + { + filename: fileurl, + lineno: lineno, + in_app: true + } + ] + }; + } + + if ( + !!this._globalOptions.ignoreUrls.test && + this._globalOptions.ignoreUrls.test(fileurl) + ) { + return; + } + + if ( + !!this._globalOptions.whitelistUrls.test && + !this._globalOptions.whitelistUrls.test(fileurl) + ) { + return; + } + + var data = objectMerge( + { + // sentry.interfaces.Exception + exception: { + values: [ + { + type: type, + value: message, + stacktrace: stacktrace + } + ] + }, + transaction: fileurl + }, + options + ); + + var ex = data.exception.values[0]; + if (ex.type == null && ex.value === '') { + ex.value = 'Unrecoverable error caught'; + } + + // Move mechanism from options to exception interface + // We do this, as requiring user to pass `{exception:{mechanism:{ ... }}}` would be + // too much + if (!data.exception.mechanism && data.mechanism) { + data.exception.mechanism = data.mechanism; + delete data.mechanism; + } + + data.exception.mechanism = objectMerge( + { + type: 'generic', + handled: true + }, + data.exception.mechanism || {} + ); + + // Fire away! + this._send(data); + }, + + _trimPacket: function(data) { + // For now, we only want to truncate the two different messages + // but this could/should be expanded to just trim everything + var max = this._globalOptions.maxMessageLength; + if (data.message) { + data.message = truncate(data.message, max); + } + if (data.exception) { + var exception = data.exception.values[0]; + exception.value = truncate(exception.value, max); + } + + var request = data.request; + if (request) { + if (request.url) { + request.url = truncate(request.url, this._globalOptions.maxUrlLength); + } + if (request.Referer) { + request.Referer = truncate(request.Referer, this._globalOptions.maxUrlLength); + } + } + + if (data.breadcrumbs && data.breadcrumbs.values) + this._trimBreadcrumbs(data.breadcrumbs); + + return data; + }, + + /** + * Truncate breadcrumb values (right now just URLs) + */ + _trimBreadcrumbs: function(breadcrumbs) { + // known breadcrumb properties with urls + // TODO: also consider arbitrary prop values that start with (https?)?:// + var urlProps = ['to', 'from', 'url'], + urlProp, + crumb, + data; + + for (var i = 0; i < breadcrumbs.values.length; ++i) { + crumb = breadcrumbs.values[i]; + if ( + !crumb.hasOwnProperty('data') || + !isObject(crumb.data) || + objectFrozen(crumb.data) + ) + continue; + + data = objectMerge({}, crumb.data); + for (var j = 0; j < urlProps.length; ++j) { + urlProp = urlProps[j]; + if (data.hasOwnProperty(urlProp) && data[urlProp]) { + data[urlProp] = truncate(data[urlProp], this._globalOptions.maxUrlLength); + } + } + breadcrumbs.values[i].data = data; + } + }, + + _getHttpData: function() { + if (!this._hasNavigator && !this._hasDocument) return; + var httpData = {}; + + if (this._hasNavigator && _navigator.userAgent) { + httpData.headers = { + 'User-Agent': _navigator.userAgent + }; + } + + // Check in `window` instead of `document`, as we may be in ServiceWorker environment + if (_window.location && _window.location.href) { + httpData.url = _window.location.href; + } + + if (this._hasDocument && _document.referrer) { + if (!httpData.headers) httpData.headers = {}; + httpData.headers.Referer = _document.referrer; + } + + return httpData; + }, + + _resetBackoff: function() { + this._backoffDuration = 0; + this._backoffStart = null; + }, + + _shouldBackoff: function() { + return this._backoffDuration && now() - this._backoffStart < this._backoffDuration; + }, + + /** + * Returns true if the in-process data payload matches the signature + * of the previously-sent data + * + * NOTE: This has to be done at this level because TraceKit can generate + * data from window.onerror WITHOUT an exception object (IE8, IE9, + * other old browsers). This can take the form of an "exception" + * data object with a single frame (derived from the onerror args). + */ + _isRepeatData: function(current) { + var last = this._lastData; + + if ( + !last || + current.message !== last.message || // defined for captureMessage + current.transaction !== last.transaction // defined for captureException/onerror + ) + return false; + + // Stacktrace interface (i.e. from captureMessage) + if (current.stacktrace || last.stacktrace) { + return isSameStacktrace(current.stacktrace, last.stacktrace); + } else if (current.exception || last.exception) { + // Exception interface (i.e. from captureException/onerror) + return isSameException(current.exception, last.exception); + } + + return true; + }, + + _setBackoffState: function(request) { + // If we are already in a backoff state, don't change anything + if (this._shouldBackoff()) { + return; + } + + var status = request.status; + + // 400 - project_id doesn't exist or some other fatal + // 401 - invalid/revoked dsn + // 429 - too many requests + if (!(status === 400 || status === 401 || status === 429)) return; + + var retry; + try { + // If Retry-After is not in Access-Control-Expose-Headers, most + // browsers will throw an exception trying to access it + if (supportsFetch()) { + retry = request.headers.get('Retry-After'); + } else { + retry = request.getResponseHeader('Retry-After'); + } + + // Retry-After is returned in seconds + retry = parseInt(retry, 10) * 1000; + } catch (e) { + /* eslint no-empty:0 */ + } + + this._backoffDuration = retry + ? // If Sentry server returned a Retry-After value, use it + retry + : // Otherwise, double the last backoff duration (starts at 1 sec) + this._backoffDuration * 2 || 1000; + + this._backoffStart = now(); + }, + + _send: function(data) { + var globalOptions = this._globalOptions; + + var baseData = { + project: this._globalProject, + logger: globalOptions.logger, + platform: 'javascript' + }, + httpData = this._getHttpData(); + + if (httpData) { + baseData.request = httpData; + } + + // HACK: delete `trimHeadFrames` to prevent from appearing in outbound payload + if (data.trimHeadFrames) delete data.trimHeadFrames; + + data = objectMerge(baseData, data); + + // Merge in the tags and extra separately since objectMerge doesn't handle a deep merge + data.tags = objectMerge(objectMerge({}, this._globalContext.tags), data.tags); + data.extra = objectMerge(objectMerge({}, this._globalContext.extra), data.extra); + + // Send along our own collected metadata with extra + data.extra['session:duration'] = now() - this._startTime; + + if (this._breadcrumbs && this._breadcrumbs.length > 0) { + // intentionally make shallow copy so that additions + // to breadcrumbs aren't accidentally sent in this request + data.breadcrumbs = { + values: [].slice.call(this._breadcrumbs, 0) + }; + } + + if (this._globalContext.user) { + // sentry.interfaces.User + data.user = this._globalContext.user; + } + + // Include the environment if it's defined in globalOptions + if (globalOptions.environment) data.environment = globalOptions.environment; + + // Include the release if it's defined in globalOptions + if (globalOptions.release) data.release = globalOptions.release; + + // Include server_name if it's defined in globalOptions + if (globalOptions.serverName) data.server_name = globalOptions.serverName; + + data = this._sanitizeData(data); + + // Cleanup empty properties before sending them to the server + Object.keys(data).forEach(function(key) { + if (data[key] == null || data[key] === '' || isEmptyObject(data[key])) { + delete data[key]; + } + }); + + if (isFunction(globalOptions.dataCallback)) { + data = globalOptions.dataCallback(data) || data; + } + + // Why?????????? + if (!data || isEmptyObject(data)) { + return; + } + + // Check if the request should be filtered or not + if ( + isFunction(globalOptions.shouldSendCallback) && + !globalOptions.shouldSendCallback(data) + ) { + return; + } + + // Backoff state: Sentry server previously responded w/ an error (e.g. 429 - too many requests), + // so drop requests until "cool-off" period has elapsed. + if (this._shouldBackoff()) { + this._logDebug('warn', 'Raven dropped error due to backoff: ', data); + return; + } + + if (typeof globalOptions.sampleRate === 'number') { + if (Math.random() < globalOptions.sampleRate) { + this._sendProcessedPayload(data); + } + } else { + this._sendProcessedPayload(data); + } + }, + + _sanitizeData: function(data) { + return sanitize(data, this._globalOptions.sanitizeKeys); + }, + + _getUuid: function() { + return uuid4(); + }, + + _sendProcessedPayload: function(data, callback) { + var self = this; + var globalOptions = this._globalOptions; + + if (!this.isSetup()) return; + + // Try and clean up the packet before sending by truncating long values + data = this._trimPacket(data); + + // ideally duplicate error testing should occur *before* dataCallback/shouldSendCallback, + // but this would require copying an un-truncated copy of the data packet, which can be + // arbitrarily deep (extra_data) -- could be worthwhile? will revisit + if (!this._globalOptions.allowDuplicates && this._isRepeatData(data)) { + this._logDebug('warn', 'Raven dropped repeat event: ', data); + return; + } + + // Send along an event_id if not explicitly passed. + // This event_id can be used to reference the error within Sentry itself. + // Set lastEventId after we know the error should actually be sent + this._lastEventId = data.event_id || (data.event_id = this._getUuid()); + + // Store outbound payload after trim + this._lastData = data; + + this._logDebug('debug', 'Raven about to send:', data); + + var auth = { + sentry_version: '7', + sentry_client: 'raven-js/' + this.VERSION, + sentry_key: this._globalKey + }; + + if (this._globalSecret) { + auth.sentry_secret = this._globalSecret; + } + + var exception = data.exception && data.exception.values[0]; + + // only capture 'sentry' breadcrumb is autoBreadcrumbs is truthy + if ( + this._globalOptions.autoBreadcrumbs && + this._globalOptions.autoBreadcrumbs.sentry + ) { + this.captureBreadcrumb({ + category: 'sentry', + message: exception + ? (exception.type ? exception.type + ': ' : '') + exception.value + : data.message, + event_id: data.event_id, + level: data.level || 'error' // presume error unless specified + }); + } + + var url = this._globalEndpoint; + (globalOptions.transport || this._makeRequest).call(this, { + url: url, + auth: auth, + data: data, + options: globalOptions, + onSuccess: function success() { + self._resetBackoff(); + + self._triggerEvent('success', { + data: data, + src: url + }); + callback && callback(); + }, + onError: function failure(error) { + self._logDebug('error', 'Raven transport failed to send: ', error); + + if (error.request) { + self._setBackoffState(error.request); + } + + self._triggerEvent('failure', { + data: data, + src: url + }); + error = error || new Error('Raven send failed (no additional details provided)'); + callback && callback(error); + } + }); + }, + + _makeRequest: function(opts) { + // Auth is intentionally sent as part of query string (NOT as custom HTTP header) to avoid preflight CORS requests + var url = opts.url + '?' + urlencode(opts.auth); + + var evaluatedHeaders = null; + var evaluatedFetchParameters = {}; + + if (opts.options.headers) { + evaluatedHeaders = this._evaluateHash(opts.options.headers); + } + + if (opts.options.fetchParameters) { + evaluatedFetchParameters = this._evaluateHash(opts.options.fetchParameters); + } + + if (supportsFetch()) { + evaluatedFetchParameters.body = stringify(opts.data); + + var defaultFetchOptions = objectMerge({}, this._fetchDefaults); + var fetchOptions = objectMerge(defaultFetchOptions, evaluatedFetchParameters); + + if (evaluatedHeaders) { + fetchOptions.headers = evaluatedHeaders; + } + + return _window + .fetch(url, fetchOptions) + .then(function(response) { + if (response.ok) { + opts.onSuccess && opts.onSuccess(); + } else { + var error = new Error('Sentry error code: ' + response.status); + // It's called request only to keep compatibility with XHR interface + // and not add more redundant checks in setBackoffState method + error.request = response; + opts.onError && opts.onError(error); + } + }) + ['catch'](function() { + opts.onError && + opts.onError(new Error('Sentry error code: network unavailable')); + }); + } + + var request = _window.XMLHttpRequest && new _window.XMLHttpRequest(); + if (!request) return; + + // if browser doesn't support CORS (e.g. IE7), we are out of luck + var hasCORS = 'withCredentials' in request || typeof XDomainRequest !== 'undefined'; + + if (!hasCORS) return; + + if ('withCredentials' in request) { + request.onreadystatechange = function() { + if (request.readyState !== 4) { + return; + } else if (request.status === 200) { + opts.onSuccess && opts.onSuccess(); + } else if (opts.onError) { + var err = new Error('Sentry error code: ' + request.status); + err.request = request; + opts.onError(err); + } + }; + } else { + request = new XDomainRequest(); + // xdomainrequest cannot go http -> https (or vice versa), + // so always use protocol relative + url = url.replace(/^https?:/, ''); + + // onreadystatechange not supported by XDomainRequest + if (opts.onSuccess) { + request.onload = opts.onSuccess; + } + if (opts.onError) { + request.onerror = function() { + var err = new Error('Sentry error code: XDomainRequest'); + err.request = request; + opts.onError(err); + }; + } + } + + request.open('POST', url); + + if (evaluatedHeaders) { + each(evaluatedHeaders, function(key, value) { + request.setRequestHeader(key, value); + }); + } + + request.send(stringify(opts.data)); + }, + + _evaluateHash: function(hash) { + var evaluated = {}; + + for (var key in hash) { + if (hash.hasOwnProperty(key)) { + var value = hash[key]; + evaluated[key] = typeof value === 'function' ? value() : value; + } + } + + return evaluated; + }, + + _logDebug: function(level) { + // We allow `Raven.debug` and `Raven.config(DSN, { debug: true })` to not make backward incompatible API change + if ( + this._originalConsoleMethods[level] && + (this.debug || this._globalOptions.debug) + ) { + // In IE<10 console methods do not have their own 'apply' method + Function.prototype.apply.call( + this._originalConsoleMethods[level], + this._originalConsole, + [].slice.call(arguments, 1) + ); + } + }, + + _mergeContext: function(key, context) { + if (isUndefined(context)) { + delete this._globalContext[key]; + } else { + this._globalContext[key] = objectMerge(this._globalContext[key] || {}, context); + } + } +}; + +// Deprecations +Raven.prototype.setUser = Raven.prototype.setUserContext; +Raven.prototype.setReleaseContext = Raven.prototype.setRelease; + +module.exports = Raven; + +}).call(this,typeof global !== "undefined" ? global : typeof self !== "undefined" ? self : typeof window !== "undefined" ? window : {}) +},{"1":1,"2":2,"5":5,"6":6,"7":7,"8":8}],4:[function(_dereq_,module,exports){ +(function (global){ +/** + * Enforces a single instance of the Raven client, and the + * main entry point for Raven. If you are a consumer of the + * Raven library, you SHOULD load this file (vs raven.js). + **/ + +var RavenConstructor = _dereq_(3); + +// This is to be defensive in environments where window does not exist (see https://github.com/getsentry/raven-js/pull/785) +var _window = + typeof window !== 'undefined' + ? window + : typeof global !== 'undefined' ? global : typeof self !== 'undefined' ? self : {}; +var _Raven = _window.Raven; + +var Raven = new RavenConstructor(); + +/* + * Allow multiple versions of Raven to be installed. + * Strip Raven from the global context and returns the instance. + * + * @return {Raven} + */ +Raven.noConflict = function() { + _window.Raven = _Raven; + return Raven; +}; + +Raven.afterLoad(); + +module.exports = Raven; + +/** + * DISCLAIMER: + * + * Expose `Client` constructor for cases where user want to track multiple "sub-applications" in one larger app. + * It's not meant to be used by a wide audience, so pleaaase make sure that you know what you're doing before using it. + * Accidentally calling `install` multiple times, may result in an unexpected behavior that's very hard to debug. + * + * It's called `Client' to be in-line with Raven Node implementation. + * + * HOWTO: + * + * import Raven from 'raven-js'; + * + * const someAppReporter = new Raven.Client(); + * const someOtherAppReporter = new Raven.Client(); + * + * someAppReporter.config('__DSN__', { + * ...config goes here + * }); + * + * someOtherAppReporter.config('__OTHER_DSN__', { + * ...config goes here + * }); + * + * someAppReporter.captureMessage(...); + * someAppReporter.captureException(...); + * someAppReporter.captureBreadcrumb(...); + * + * someOtherAppReporter.captureMessage(...); + * someOtherAppReporter.captureException(...); + * someOtherAppReporter.captureBreadcrumb(...); + * + * It should "just work". + */ +module.exports.Client = RavenConstructor; + +}).call(this,typeof global !== "undefined" ? global : typeof self !== "undefined" ? self : typeof window !== "undefined" ? window : {}) +},{"3":3}],5:[function(_dereq_,module,exports){ +(function (global){ +var stringify = _dereq_(7); + +var _window = + typeof window !== 'undefined' + ? window + : typeof global !== 'undefined' + ? global + : typeof self !== 'undefined' + ? self + : {}; + +function isObject(what) { + return typeof what === 'object' && what !== null; +} + +// Yanked from https://git.io/vS8DV re-used under CC0 +// with some tiny modifications +function isError(value) { + switch (Object.prototype.toString.call(value)) { + case '[object Error]': + return true; + case '[object Exception]': + return true; + case '[object DOMException]': + return true; + default: + return value instanceof Error; + } +} + +function isErrorEvent(value) { + return Object.prototype.toString.call(value) === '[object ErrorEvent]'; +} + +function isDOMError(value) { + return Object.prototype.toString.call(value) === '[object DOMError]'; +} + +function isDOMException(value) { + return Object.prototype.toString.call(value) === '[object DOMException]'; +} + +function isUndefined(what) { + return what === void 0; +} + +function isFunction(what) { + return typeof what === 'function'; +} + +function isPlainObject(what) { + return Object.prototype.toString.call(what) === '[object Object]'; +} + +function isString(what) { + return Object.prototype.toString.call(what) === '[object String]'; +} + +function isArray(what) { + return Object.prototype.toString.call(what) === '[object Array]'; +} + +function isEmptyObject(what) { + if (!isPlainObject(what)) return false; + + for (var _ in what) { + if (what.hasOwnProperty(_)) { + return false; + } + } + return true; +} + +function supportsErrorEvent() { + try { + new ErrorEvent(''); // eslint-disable-line no-new + return true; + } catch (e) { + return false; + } +} + +function supportsDOMError() { + try { + new DOMError(''); // eslint-disable-line no-new + return true; + } catch (e) { + return false; + } +} + +function supportsDOMException() { + try { + new DOMException(''); // eslint-disable-line no-new + return true; + } catch (e) { + return false; + } +} + +function supportsFetch() { + if (!('fetch' in _window)) return false; + + try { + new Headers(); // eslint-disable-line no-new + new Request(''); // eslint-disable-line no-new + new Response(); // eslint-disable-line no-new + return true; + } catch (e) { + return false; + } +} + +// Despite all stars in the sky saying that Edge supports old draft syntax, aka 'never', 'always', 'origin' and 'default +// https://caniuse.com/#feat=referrer-policy +// It doesn't. And it throw exception instead of ignoring this parameter... +// REF: https://github.com/getsentry/raven-js/issues/1233 +function supportsReferrerPolicy() { + if (!supportsFetch()) return false; + + try { + // eslint-disable-next-line no-new + new Request('pickleRick', { + referrerPolicy: 'origin' + }); + return true; + } catch (e) { + return false; + } +} + +function supportsPromiseRejectionEvent() { + return typeof PromiseRejectionEvent === 'function'; +} + +function wrappedCallback(callback) { + function dataCallback(data, original) { + var normalizedData = callback(data) || data; + if (original) { + return original(normalizedData) || normalizedData; + } + return normalizedData; + } + + return dataCallback; +} + +function each(obj, callback) { + var i, j; + + if (isUndefined(obj.length)) { + for (i in obj) { + if (hasKey(obj, i)) { + callback.call(null, i, obj[i]); + } + } + } else { + j = obj.length; + if (j) { + for (i = 0; i < j; i++) { + callback.call(null, i, obj[i]); + } + } + } +} + +function objectMerge(obj1, obj2) { + if (!obj2) { + return obj1; + } + each(obj2, function(key, value) { + obj1[key] = value; + }); + return obj1; +} + +/** + * This function is only used for react-native. + * react-native freezes object that have already been sent over the + * js bridge. We need this function in order to check if the object is frozen. + * So it's ok that objectFrozen returns false if Object.isFrozen is not + * supported because it's not relevant for other "platforms". See related issue: + * https://github.com/getsentry/react-native-sentry/issues/57 + */ +function objectFrozen(obj) { + if (!Object.isFrozen) { + return false; + } + return Object.isFrozen(obj); +} + +function truncate(str, max) { + if (typeof max !== 'number') { + throw new Error('2nd argument to `truncate` function should be a number'); + } + if (typeof str !== 'string' || max === 0) { + return str; + } + return str.length <= max ? str : str.substr(0, max) + '\u2026'; +} + +/** + * hasKey, a better form of hasOwnProperty + * Example: hasKey(MainHostObject, property) === true/false + * + * @param {Object} host object to check property + * @param {string} key to check + */ +function hasKey(object, key) { + return Object.prototype.hasOwnProperty.call(object, key); +} + +function joinRegExp(patterns) { + // Combine an array of regular expressions and strings into one large regexp + // Be mad. + var sources = [], + i = 0, + len = patterns.length, + pattern; + + for (; i < len; i++) { + pattern = patterns[i]; + if (isString(pattern)) { + // If it's a string, we need to escape it + // Taken from: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions + sources.push(pattern.replace(/([.*+?^=!:${}()|\[\]\/\\])/g, '\\$1')); + } else if (pattern && pattern.source) { + // If it's a regexp already, we want to extract the source + sources.push(pattern.source); + } + // Intentionally skip other cases + } + return new RegExp(sources.join('|'), 'i'); +} + +function urlencode(o) { + var pairs = []; + each(o, function(key, value) { + pairs.push(encodeURIComponent(key) + '=' + encodeURIComponent(value)); + }); + return pairs.join('&'); +} + +// borrowed from https://tools.ietf.org/html/rfc3986#appendix-B +// intentionally using regex and not <a/> href parsing trick because React Native and other +// environments where DOM might not be available +function parseUrl(url) { + if (typeof url !== 'string') return {}; + var match = url.match(/^(([^:\/?#]+):)?(\/\/([^\/?#]*))?([^?#]*)(\?([^#]*))?(#(.*))?$/); + + // coerce to undefined values to empty string so we don't get 'undefined' + var query = match[6] || ''; + var fragment = match[8] || ''; + return { + protocol: match[2], + host: match[4], + path: match[5], + relative: match[5] + query + fragment // everything minus origin + }; +} +function uuid4() { + var crypto = _window.crypto || _window.msCrypto; + + if (!isUndefined(crypto) && crypto.getRandomValues) { + // Use window.crypto API if available + // eslint-disable-next-line no-undef + var arr = new Uint16Array(8); + crypto.getRandomValues(arr); + + // set 4 in byte 7 + arr[3] = (arr[3] & 0xfff) | 0x4000; + // set 2 most significant bits of byte 9 to '10' + arr[4] = (arr[4] & 0x3fff) | 0x8000; + + var pad = function(num) { + var v = num.toString(16); + while (v.length < 4) { + v = '0' + v; + } + return v; + }; + + return ( + pad(arr[0]) + + pad(arr[1]) + + pad(arr[2]) + + pad(arr[3]) + + pad(arr[4]) + + pad(arr[5]) + + pad(arr[6]) + + pad(arr[7]) + ); + } else { + // http://stackoverflow.com/questions/105034/how-to-create-a-guid-uuid-in-javascript/2117523#2117523 + return 'xxxxxxxxxxxx4xxxyxxxxxxxxxxxxxxx'.replace(/[xy]/g, function(c) { + var r = (Math.random() * 16) | 0, + v = c === 'x' ? r : (r & 0x3) | 0x8; + return v.toString(16); + }); + } +} + +/** + * Given a child DOM element, returns a query-selector statement describing that + * and its ancestors + * e.g. [HTMLElement] => body > div > input#foo.btn[name=baz] + * @param elem + * @returns {string} + */ +function htmlTreeAsString(elem) { + /* eslint no-extra-parens:0*/ + var MAX_TRAVERSE_HEIGHT = 5, + MAX_OUTPUT_LEN = 80, + out = [], + height = 0, + len = 0, + separator = ' > ', + sepLength = separator.length, + nextStr; + + while (elem && height++ < MAX_TRAVERSE_HEIGHT) { + nextStr = htmlElementAsString(elem); + // bail out if + // - nextStr is the 'html' element + // - the length of the string that would be created exceeds MAX_OUTPUT_LEN + // (ignore this limit if we are on the first iteration) + if ( + nextStr === 'html' || + (height > 1 && len + out.length * sepLength + nextStr.length >= MAX_OUTPUT_LEN) + ) { + break; + } + + out.push(nextStr); + + len += nextStr.length; + elem = elem.parentNode; + } + + return out.reverse().join(separator); +} + +/** + * Returns a simple, query-selector representation of a DOM element + * e.g. [HTMLElement] => input#foo.btn[name=baz] + * @param HTMLElement + * @returns {string} + */ +function htmlElementAsString(elem) { + var out = [], + className, + classes, + key, + attr, + i; + + if (!elem || !elem.tagName) { + return ''; + } + + out.push(elem.tagName.toLowerCase()); + if (elem.id) { + out.push('#' + elem.id); + } + + className = elem.className; + if (className && isString(className)) { + classes = className.split(/\s+/); + for (i = 0; i < classes.length; i++) { + out.push('.' + classes[i]); + } + } + var attrWhitelist = ['type', 'name', 'title', 'alt']; + for (i = 0; i < attrWhitelist.length; i++) { + key = attrWhitelist[i]; + attr = elem.getAttribute(key); + if (attr) { + out.push('[' + key + '="' + attr + '"]'); + } + } + return out.join(''); +} + +/** + * Returns true if either a OR b is truthy, but not both + */ +function isOnlyOneTruthy(a, b) { + return !!(!!a ^ !!b); +} + +/** + * Returns true if both parameters are undefined + */ +function isBothUndefined(a, b) { + return isUndefined(a) && isUndefined(b); +} + +/** + * Returns true if the two input exception interfaces have the same content + */ +function isSameException(ex1, ex2) { + if (isOnlyOneTruthy(ex1, ex2)) return false; + + ex1 = ex1.values[0]; + ex2 = ex2.values[0]; + + if (ex1.type !== ex2.type || ex1.value !== ex2.value) return false; + + // in case both stacktraces are undefined, we can't decide so default to false + if (isBothUndefined(ex1.stacktrace, ex2.stacktrace)) return false; + + return isSameStacktrace(ex1.stacktrace, ex2.stacktrace); +} + +/** + * Returns true if the two input stack trace interfaces have the same content + */ +function isSameStacktrace(stack1, stack2) { + if (isOnlyOneTruthy(stack1, stack2)) return false; + + var frames1 = stack1.frames; + var frames2 = stack2.frames; + + // Exit early if stacktrace is malformed + if (frames1 === undefined || frames2 === undefined) return false; + + // Exit early if frame count differs + if (frames1.length !== frames2.length) return false; + + // Iterate through every frame; bail out if anything differs + var a, b; + for (var i = 0; i < frames1.length; i++) { + a = frames1[i]; + b = frames2[i]; + if ( + a.filename !== b.filename || + a.lineno !== b.lineno || + a.colno !== b.colno || + a['function'] !== b['function'] + ) + return false; + } + return true; +} + +/** + * Polyfill a method + * @param obj object e.g. `document` + * @param name method name present on object e.g. `addEventListener` + * @param replacement replacement function + * @param track {optional} record instrumentation to an array + */ +function fill(obj, name, replacement, track) { + if (obj == null) return; + var orig = obj[name]; + obj[name] = replacement(orig); + obj[name].__raven__ = true; + obj[name].__orig__ = orig; + if (track) { + track.push([obj, name, orig]); + } +} + +/** + * Join values in array + * @param input array of values to be joined together + * @param delimiter string to be placed in-between values + * @returns {string} + */ +function safeJoin(input, delimiter) { + if (!isArray(input)) return ''; + + var output = []; + + for (var i = 0; i < input.length; i++) { + try { + output.push(String(input[i])); + } catch (e) { + output.push('[value cannot be serialized]'); + } + } + + return output.join(delimiter); +} + +// Default Node.js REPL depth +var MAX_SERIALIZE_EXCEPTION_DEPTH = 3; +// 50kB, as 100kB is max payload size, so half sounds reasonable +var MAX_SERIALIZE_EXCEPTION_SIZE = 50 * 1024; +var MAX_SERIALIZE_KEYS_LENGTH = 40; + +function utf8Length(value) { + return ~-encodeURI(value).split(/%..|./).length; +} + +function jsonSize(value) { + return utf8Length(JSON.stringify(value)); +} + +function serializeValue(value) { + if (typeof value === 'string') { + var maxLength = 40; + return truncate(value, maxLength); + } else if ( + typeof value === 'number' || + typeof value === 'boolean' || + typeof value === 'undefined' + ) { + return value; + } + + var type = Object.prototype.toString.call(value); + + // Node.js REPL notation + if (type === '[object Object]') return '[Object]'; + if (type === '[object Array]') return '[Array]'; + if (type === '[object Function]') + return value.name ? '[Function: ' + value.name + ']' : '[Function]'; + + return value; +} + +function serializeObject(value, depth) { + if (depth === 0) return serializeValue(value); + + if (isPlainObject(value)) { + return Object.keys(value).reduce(function(acc, key) { + acc[key] = serializeObject(value[key], depth - 1); + return acc; + }, {}); + } else if (Array.isArray(value)) { + return value.map(function(val) { + return serializeObject(val, depth - 1); + }); + } + + return serializeValue(value); +} + +function serializeException(ex, depth, maxSize) { + if (!isPlainObject(ex)) return ex; + + depth = typeof depth !== 'number' ? MAX_SERIALIZE_EXCEPTION_DEPTH : depth; + maxSize = typeof depth !== 'number' ? MAX_SERIALIZE_EXCEPTION_SIZE : maxSize; + + var serialized = serializeObject(ex, depth); + + if (jsonSize(stringify(serialized)) > maxSize) { + return serializeException(ex, depth - 1); + } + + return serialized; +} + +function serializeKeysForMessage(keys, maxLength) { + if (typeof keys === 'number' || typeof keys === 'string') return keys.toString(); + if (!Array.isArray(keys)) return ''; + + keys = keys.filter(function(key) { + return typeof key === 'string'; + }); + if (keys.length === 0) return '[object has no keys]'; + + maxLength = typeof maxLength !== 'number' ? MAX_SERIALIZE_KEYS_LENGTH : maxLength; + if (keys[0].length >= maxLength) return keys[0]; + + for (var usedKeys = keys.length; usedKeys > 0; usedKeys--) { + var serialized = keys.slice(0, usedKeys).join(', '); + if (serialized.length > maxLength) continue; + if (usedKeys === keys.length) return serialized; + return serialized + '\u2026'; + } + + return ''; +} + +function sanitize(input, sanitizeKeys) { + if (!isArray(sanitizeKeys) || (isArray(sanitizeKeys) && sanitizeKeys.length === 0)) + return input; + + var sanitizeRegExp = joinRegExp(sanitizeKeys); + var sanitizeMask = '********'; + var safeInput; + + try { + safeInput = JSON.parse(stringify(input)); + } catch (o_O) { + return input; + } + + function sanitizeWorker(workerInput) { + if (isArray(workerInput)) { + return workerInput.map(function(val) { + return sanitizeWorker(val); + }); + } + + if (isPlainObject(workerInput)) { + return Object.keys(workerInput).reduce(function(acc, k) { + if (sanitizeRegExp.test(k)) { + acc[k] = sanitizeMask; + } else { + acc[k] = sanitizeWorker(workerInput[k]); + } + return acc; + }, {}); + } + + return workerInput; + } + + return sanitizeWorker(safeInput); +} + +module.exports = { + isObject: isObject, + isError: isError, + isErrorEvent: isErrorEvent, + isDOMError: isDOMError, + isDOMException: isDOMException, + isUndefined: isUndefined, + isFunction: isFunction, + isPlainObject: isPlainObject, + isString: isString, + isArray: isArray, + isEmptyObject: isEmptyObject, + supportsErrorEvent: supportsErrorEvent, + supportsDOMError: supportsDOMError, + supportsDOMException: supportsDOMException, + supportsFetch: supportsFetch, + supportsReferrerPolicy: supportsReferrerPolicy, + supportsPromiseRejectionEvent: supportsPromiseRejectionEvent, + wrappedCallback: wrappedCallback, + each: each, + objectMerge: objectMerge, + truncate: truncate, + objectFrozen: objectFrozen, + hasKey: hasKey, + joinRegExp: joinRegExp, + urlencode: urlencode, + uuid4: uuid4, + htmlTreeAsString: htmlTreeAsString, + htmlElementAsString: htmlElementAsString, + isSameException: isSameException, + isSameStacktrace: isSameStacktrace, + parseUrl: parseUrl, + fill: fill, + safeJoin: safeJoin, + serializeException: serializeException, + serializeKeysForMessage: serializeKeysForMessage, + sanitize: sanitize +}; + +}).call(this,typeof global !== "undefined" ? global : typeof self !== "undefined" ? self : typeof window !== "undefined" ? window : {}) +},{"7":7}],6:[function(_dereq_,module,exports){ +(function (global){ +var utils = _dereq_(5); + +/* + TraceKit - Cross brower stack traces + + This was originally forked from github.com/occ/TraceKit, but has since been + largely re-written and is now maintained as part of raven-js. Tests for + this are in test/vendor. + + MIT license +*/ + +var TraceKit = { + collectWindowErrors: true, + debug: false +}; + +// This is to be defensive in environments where window does not exist (see https://github.com/getsentry/raven-js/pull/785) +var _window = + typeof window !== 'undefined' + ? window + : typeof global !== 'undefined' ? global : typeof self !== 'undefined' ? self : {}; + +// global reference to slice +var _slice = [].slice; +var UNKNOWN_FUNCTION = '?'; + +// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error#Error_types +var ERROR_TYPES_RE = /^(?:[Uu]ncaught (?:exception: )?)?(?:((?:Eval|Internal|Range|Reference|Syntax|Type|URI|)Error): )?(.*)$/; + +function getLocationHref() { + if (typeof document === 'undefined' || document.location == null) return ''; + return document.location.href; +} + +function getLocationOrigin() { + if (typeof document === 'undefined' || document.location == null) return ''; + + // Oh dear IE10... + if (!document.location.origin) { + return ( + document.location.protocol + + '//' + + document.location.hostname + + (document.location.port ? ':' + document.location.port : '') + ); + } + + return document.location.origin; +} + +/** + * TraceKit.report: cross-browser processing of unhandled exceptions + * + * Syntax: + * TraceKit.report.subscribe(function(stackInfo) { ... }) + * TraceKit.report.unsubscribe(function(stackInfo) { ... }) + * TraceKit.report(exception) + * try { ...code... } catch(ex) { TraceKit.report(ex); } + * + * Supports: + * - Firefox: full stack trace with line numbers, plus column number + * on top frame; column number is not guaranteed + * - Opera: full stack trace with line and column numbers + * - Chrome: full stack trace with line and column numbers + * - Safari: line and column number for the top frame only; some frames + * may be missing, and column number is not guaranteed + * - IE: line and column number for the top frame only; some frames + * may be missing, and column number is not guaranteed + * + * In theory, TraceKit should work on all of the following versions: + * - IE5.5+ (only 8.0 tested) + * - Firefox 0.9+ (only 3.5+ tested) + * - Opera 7+ (only 10.50 tested; versions 9 and earlier may require + * Exceptions Have Stacktrace to be enabled in opera:config) + * - Safari 3+ (only 4+ tested) + * - Chrome 1+ (only 5+ tested) + * - Konqueror 3.5+ (untested) + * + * Requires TraceKit.computeStackTrace. + * + * Tries to catch all unhandled exceptions and report them to the + * subscribed handlers. Please note that TraceKit.report will rethrow the + * exception. This is REQUIRED in order to get a useful stack trace in IE. + * If the exception does not reach the top of the browser, you will only + * get a stack trace from the point where TraceKit.report was called. + * + * Handlers receive a stackInfo object as described in the + * TraceKit.computeStackTrace docs. + */ +TraceKit.report = (function reportModuleWrapper() { + var handlers = [], + lastArgs = null, + lastException = null, + lastExceptionStack = null; + + /** + * Add a crash handler. + * @param {Function} handler + */ + function subscribe(handler) { + installGlobalHandler(); + handlers.push(handler); + } + + /** + * Remove a crash handler. + * @param {Function} handler + */ + function unsubscribe(handler) { + for (var i = handlers.length - 1; i >= 0; --i) { + if (handlers[i] === handler) { + handlers.splice(i, 1); + } + } + } + + /** + * Remove all crash handlers. + */ + function unsubscribeAll() { + uninstallGlobalHandler(); + handlers = []; + } + + /** + * Dispatch stack information to all handlers. + * @param {Object.<string, *>} stack + */ + function notifyHandlers(stack, isWindowError) { + var exception = null; + if (isWindowError && !TraceKit.collectWindowErrors) { + return; + } + for (var i in handlers) { + if (handlers.hasOwnProperty(i)) { + try { + handlers[i].apply(null, [stack].concat(_slice.call(arguments, 2))); + } catch (inner) { + exception = inner; + } + } + } + + if (exception) { + throw exception; + } + } + + var _oldOnerrorHandler, _onErrorHandlerInstalled; + + /** + * Ensures all global unhandled exceptions are recorded. + * Supported by Gecko and IE. + * @param {string} msg Error message. + * @param {string} url URL of script that generated the exception. + * @param {(number|string)} lineNo The line number at which the error + * occurred. + * @param {?(number|string)} colNo The column number at which the error + * occurred. + * @param {?Error} ex The actual Error object. + */ + function traceKitWindowOnError(msg, url, lineNo, colNo, ex) { + var stack = null; + // If 'ex' is ErrorEvent, get real Error from inside + var exception = utils.isErrorEvent(ex) ? ex.error : ex; + // If 'msg' is ErrorEvent, get real message from inside + var message = utils.isErrorEvent(msg) ? msg.message : msg; + + if (lastExceptionStack) { + TraceKit.computeStackTrace.augmentStackTraceWithInitialElement( + lastExceptionStack, + url, + lineNo, + message + ); + processLastException(); + } else if (exception && utils.isError(exception)) { + // non-string `exception` arg; attempt to extract stack trace + + // New chrome and blink send along a real error object + // Let's just report that like a normal error. + // See: https://mikewest.org/2013/08/debugging-runtime-errors-with-window-onerror + stack = TraceKit.computeStackTrace(exception); + notifyHandlers(stack, true); + } else { + var location = { + url: url, + line: lineNo, + column: colNo + }; + + var name = undefined; + var groups; + + if ({}.toString.call(message) === '[object String]') { + var groups = message.match(ERROR_TYPES_RE); + if (groups) { + name = groups[1]; + message = groups[2]; + } + } + + location.func = UNKNOWN_FUNCTION; + + stack = { + name: name, + message: message, + url: getLocationHref(), + stack: [location] + }; + notifyHandlers(stack, true); + } + + if (_oldOnerrorHandler) { + return _oldOnerrorHandler.apply(this, arguments); + } + + return false; + } + + function installGlobalHandler() { + if (_onErrorHandlerInstalled) { + return; + } + _oldOnerrorHandler = _window.onerror; + _window.onerror = traceKitWindowOnError; + _onErrorHandlerInstalled = true; + } + + function uninstallGlobalHandler() { + if (!_onErrorHandlerInstalled) { + return; + } + _window.onerror = _oldOnerrorHandler; + _onErrorHandlerInstalled = false; + _oldOnerrorHandler = undefined; + } + + function processLastException() { + var _lastExceptionStack = lastExceptionStack, + _lastArgs = lastArgs; + lastArgs = null; + lastExceptionStack = null; + lastException = null; + notifyHandlers.apply(null, [_lastExceptionStack, false].concat(_lastArgs)); + } + + /** + * Reports an unhandled Error to TraceKit. + * @param {Error} ex + * @param {?boolean} rethrow If false, do not re-throw the exception. + * Only used for window.onerror to not cause an infinite loop of + * rethrowing. + */ + function report(ex, rethrow) { + var args = _slice.call(arguments, 1); + if (lastExceptionStack) { + if (lastException === ex) { + return; // already caught by an inner catch block, ignore + } else { + processLastException(); + } + } + + var stack = TraceKit.computeStackTrace(ex); + lastExceptionStack = stack; + lastException = ex; + lastArgs = args; + + // If the stack trace is incomplete, wait for 2 seconds for + // slow slow IE to see if onerror occurs or not before reporting + // this exception; otherwise, we will end up with an incomplete + // stack trace + setTimeout(function() { + if (lastException === ex) { + processLastException(); + } + }, stack.incomplete ? 2000 : 0); + + if (rethrow !== false) { + throw ex; // re-throw to propagate to the top level (and cause window.onerror) + } + } + + report.subscribe = subscribe; + report.unsubscribe = unsubscribe; + report.uninstall = unsubscribeAll; + return report; +})(); + +/** + * TraceKit.computeStackTrace: cross-browser stack traces in JavaScript + * + * Syntax: + * s = TraceKit.computeStackTrace(exception) // consider using TraceKit.report instead (see below) + * Returns: + * s.name - exception name + * s.message - exception message + * s.stack[i].url - JavaScript or HTML file URL + * s.stack[i].func - function name, or empty for anonymous functions (if guessing did not work) + * s.stack[i].args - arguments passed to the function, if known + * s.stack[i].line - line number, if known + * s.stack[i].column - column number, if known + * + * Supports: + * - Firefox: full stack trace with line numbers and unreliable column + * number on top frame + * - Opera 10: full stack trace with line and column numbers + * - Opera 9-: full stack trace with line numbers + * - Chrome: full stack trace with line and column numbers + * - Safari: line and column number for the topmost stacktrace element + * only + * - IE: no line numbers whatsoever + * + * Tries to guess names of anonymous functions by looking for assignments + * in the source code. In IE and Safari, we have to guess source file names + * by searching for function bodies inside all page scripts. This will not + * work for scripts that are loaded cross-domain. + * Here be dragons: some function names may be guessed incorrectly, and + * duplicate functions may be mismatched. + * + * TraceKit.computeStackTrace should only be used for tracing purposes. + * Logging of unhandled exceptions should be done with TraceKit.report, + * which builds on top of TraceKit.computeStackTrace and provides better + * IE support by utilizing the window.onerror event to retrieve information + * about the top of the stack. + * + * Note: In IE and Safari, no stack trace is recorded on the Error object, + * so computeStackTrace instead walks its *own* chain of callers. + * This means that: + * * in Safari, some methods may be missing from the stack trace; + * * in IE, the topmost function in the stack trace will always be the + * caller of computeStackTrace. + * + * This is okay for tracing (because you are likely to be calling + * computeStackTrace from the function you want to be the topmost element + * of the stack trace anyway), but not okay for logging unhandled + * exceptions (because your catch block will likely be far away from the + * inner function that actually caused the exception). + * + */ +TraceKit.computeStackTrace = (function computeStackTraceWrapper() { + // Contents of Exception in various browsers. + // + // SAFARI: + // ex.message = Can't find variable: qq + // ex.line = 59 + // ex.sourceId = 580238192 + // ex.sourceURL = http://... + // ex.expressionBeginOffset = 96 + // ex.expressionCaretOffset = 98 + // ex.expressionEndOffset = 98 + // ex.name = ReferenceError + // + // FIREFOX: + // ex.message = qq is not defined + // ex.fileName = http://... + // ex.lineNumber = 59 + // ex.columnNumber = 69 + // ex.stack = ...stack trace... (see the example below) + // ex.name = ReferenceError + // + // CHROME: + // ex.message = qq is not defined + // ex.name = ReferenceError + // ex.type = not_defined + // ex.arguments = ['aa'] + // ex.stack = ...stack trace... + // + // INTERNET EXPLORER: + // ex.message = ... + // ex.name = ReferenceError + // + // OPERA: + // ex.message = ...message... (see the example below) + // ex.name = ReferenceError + // ex.opera#sourceloc = 11 (pretty much useless, duplicates the info in ex.message) + // ex.stacktrace = n/a; see 'opera:config#UserPrefs|Exceptions Have Stacktrace' + + /** + * Computes stack trace information from the stack property. + * Chrome and Gecko use this property. + * @param {Error} ex + * @return {?Object.<string, *>} Stack trace information. + */ + function computeStackTraceFromStackProp(ex) { + if (typeof ex.stack === 'undefined' || !ex.stack) return; + + var chrome = /^\s*at (?:(.*?) ?\()?((?:file|https?|blob|chrome-extension|native|eval|webpack|<anonymous>|[a-z]:|\/).*?)(?::(\d+))?(?::(\d+))?\)?\s*$/i; + var winjs = /^\s*at (?:((?:\[object object\])?.+) )?\(?((?:file|ms-appx(?:-web)|https?|webpack|blob):.*?):(\d+)(?::(\d+))?\)?\s*$/i; + // NOTE: blob urls are now supposed to always have an origin, therefore it's format + // which is `blob:http://url/path/with-some-uuid`, is matched by `blob.*?:\/` as well + var gecko = /^\s*(.*?)(?:\((.*?)\))?(?:^|@)((?:file|https?|blob|chrome|webpack|resource|moz-extension).*?:\/.*?|\[native code\]|[^@]*bundle)(?::(\d+))?(?::(\d+))?\s*$/i; + // Used to additionally parse URL/line/column from eval frames + var geckoEval = /(\S+) line (\d+)(?: > eval line \d+)* > eval/i; + var chromeEval = /\((\S*)(?::(\d+))(?::(\d+))\)/; + var lines = ex.stack.split('\n'); + var stack = []; + var submatch; + var parts; + var element; + var reference = /^(.*) is undefined$/.exec(ex.message); + + for (var i = 0, j = lines.length; i < j; ++i) { + if ((parts = chrome.exec(lines[i]))) { + var isNative = parts[2] && parts[2].indexOf('native') === 0; // start of line + var isEval = parts[2] && parts[2].indexOf('eval') === 0; // start of line + if (isEval && (submatch = chromeEval.exec(parts[2]))) { + // throw out eval line/column and use top-most line/column number + parts[2] = submatch[1]; // url + parts[3] = submatch[2]; // line + parts[4] = submatch[3]; // column + } + element = { + url: !isNative ? parts[2] : null, + func: parts[1] || UNKNOWN_FUNCTION, + args: isNative ? [parts[2]] : [], + line: parts[3] ? +parts[3] : null, + column: parts[4] ? +parts[4] : null + }; + } else if ((parts = winjs.exec(lines[i]))) { + element = { + url: parts[2], + func: parts[1] || UNKNOWN_FUNCTION, + args: [], + line: +parts[3], + column: parts[4] ? +parts[4] : null + }; + } else if ((parts = gecko.exec(lines[i]))) { + var isEval = parts[3] && parts[3].indexOf(' > eval') > -1; + if (isEval && (submatch = geckoEval.exec(parts[3]))) { + // throw out eval line/column and use top-most line number + parts[3] = submatch[1]; + parts[4] = submatch[2]; + parts[5] = null; // no column when eval + } else if (i === 0 && !parts[5] && typeof ex.columnNumber !== 'undefined') { + // FireFox uses this awesome columnNumber property for its top frame + // Also note, Firefox's column number is 0-based and everything else expects 1-based, + // so adding 1 + // NOTE: this hack doesn't work if top-most frame is eval + stack[0].column = ex.columnNumber + 1; + } + element = { + url: parts[3], + func: parts[1] || UNKNOWN_FUNCTION, + args: parts[2] ? parts[2].split(',') : [], + line: parts[4] ? +parts[4] : null, + column: parts[5] ? +parts[5] : null + }; + } else { + continue; + } + + if (!element.func && element.line) { + element.func = UNKNOWN_FUNCTION; + } + + if (element.url && element.url.substr(0, 5) === 'blob:') { + // Special case for handling JavaScript loaded into a blob. + // We use a synchronous AJAX request here as a blob is already in + // memory - it's not making a network request. This will generate a warning + // in the browser console, but there has already been an error so that's not + // that much of an issue. + var xhr = new XMLHttpRequest(); + xhr.open('GET', element.url, false); + xhr.send(null); + + // If we failed to download the source, skip this patch + if (xhr.status === 200) { + var source = xhr.responseText || ''; + + // We trim the source down to the last 300 characters as sourceMappingURL is always at the end of the file. + // Why 300? To be in line with: https://github.com/getsentry/sentry/blob/4af29e8f2350e20c28a6933354e4f42437b4ba42/src/sentry/lang/javascript/processor.py#L164-L175 + source = source.slice(-300); + + // Now we dig out the source map URL + var sourceMaps = source.match(/\/\/# sourceMappingURL=(.*)$/); + + // If we don't find a source map comment or we find more than one, continue on to the next element. + if (sourceMaps) { + var sourceMapAddress = sourceMaps[1]; + + // Now we check to see if it's a relative URL. + // If it is, convert it to an absolute one. + if (sourceMapAddress.charAt(0) === '~') { + sourceMapAddress = getLocationOrigin() + sourceMapAddress.slice(1); + } + + // Now we strip the '.map' off of the end of the URL and update the + // element so that Sentry can match the map to the blob. + element.url = sourceMapAddress.slice(0, -4); + } + } + } + + stack.push(element); + } + + if (!stack.length) { + return null; + } + + return { + name: ex.name, + message: ex.message, + url: getLocationHref(), + stack: stack + }; + } + + /** + * Adds information about the first frame to incomplete stack traces. + * Safari and IE require this to get complete data on the first frame. + * @param {Object.<string, *>} stackInfo Stack trace information from + * one of the compute* methods. + * @param {string} url The URL of the script that caused an error. + * @param {(number|string)} lineNo The line number of the script that + * caused an error. + * @param {string=} message The error generated by the browser, which + * hopefully contains the name of the object that caused the error. + * @return {boolean} Whether or not the stack information was + * augmented. + */ + function augmentStackTraceWithInitialElement(stackInfo, url, lineNo, message) { + var initial = { + url: url, + line: lineNo + }; + + if (initial.url && initial.line) { + stackInfo.incomplete = false; + + if (!initial.func) { + initial.func = UNKNOWN_FUNCTION; + } + + if (stackInfo.stack.length > 0) { + if (stackInfo.stack[0].url === initial.url) { + if (stackInfo.stack[0].line === initial.line) { + return false; // already in stack trace + } else if ( + !stackInfo.stack[0].line && + stackInfo.stack[0].func === initial.func + ) { + stackInfo.stack[0].line = initial.line; + return false; + } + } + } + + stackInfo.stack.unshift(initial); + stackInfo.partial = true; + return true; + } else { + stackInfo.incomplete = true; + } + + return false; + } + + /** + * Computes stack trace information by walking the arguments.caller + * chain at the time the exception occurred. This will cause earlier + * frames to be missed but is the only way to get any stack trace in + * Safari and IE. The top frame is restored by + * {@link augmentStackTraceWithInitialElement}. + * @param {Error} ex + * @return {?Object.<string, *>} Stack trace information. + */ + function computeStackTraceByWalkingCallerChain(ex, depth) { + var functionName = /function\s+([_$a-zA-Z\xA0-\uFFFF][_$a-zA-Z0-9\xA0-\uFFFF]*)?\s*\(/i, + stack = [], + funcs = {}, + recursion = false, + parts, + item, + source; + + for ( + var curr = computeStackTraceByWalkingCallerChain.caller; + curr && !recursion; + curr = curr.caller + ) { + if (curr === computeStackTrace || curr === TraceKit.report) { + // console.log('skipping internal function'); + continue; + } + + item = { + url: null, + func: UNKNOWN_FUNCTION, + line: null, + column: null + }; + + if (curr.name) { + item.func = curr.name; + } else if ((parts = functionName.exec(curr.toString()))) { + item.func = parts[1]; + } + + if (typeof item.func === 'undefined') { + try { + item.func = parts.input.substring(0, parts.input.indexOf('{')); + } catch (e) {} + } + + if (funcs['' + curr]) { + recursion = true; + } else { + funcs['' + curr] = true; + } + + stack.push(item); + } + + if (depth) { + // console.log('depth is ' + depth); + // console.log('stack is ' + stack.length); + stack.splice(0, depth); + } + + var result = { + name: ex.name, + message: ex.message, + url: getLocationHref(), + stack: stack + }; + augmentStackTraceWithInitialElement( + result, + ex.sourceURL || ex.fileName, + ex.line || ex.lineNumber, + ex.message || ex.description + ); + return result; + } + + /** + * Computes a stack trace for an exception. + * @param {Error} ex + * @param {(string|number)=} depth + */ + function computeStackTrace(ex, depth) { + var stack = null; + depth = depth == null ? 0 : +depth; + + try { + stack = computeStackTraceFromStackProp(ex); + if (stack) { + return stack; + } + } catch (e) { + if (TraceKit.debug) { + throw e; + } + } + + try { + stack = computeStackTraceByWalkingCallerChain(ex, depth + 1); + if (stack) { + return stack; + } + } catch (e) { + if (TraceKit.debug) { + throw e; + } + } + return { + name: ex.name, + message: ex.message, + url: getLocationHref() + }; + } + + computeStackTrace.augmentStackTraceWithInitialElement = augmentStackTraceWithInitialElement; + computeStackTrace.computeStackTraceFromStackProp = computeStackTraceFromStackProp; + + return computeStackTrace; +})(); + +module.exports = TraceKit; + +}).call(this,typeof global !== "undefined" ? global : typeof self !== "undefined" ? self : typeof window !== "undefined" ? window : {}) +},{"5":5}],7:[function(_dereq_,module,exports){ +/* + json-stringify-safe + Like JSON.stringify, but doesn't throw on circular references. + + Originally forked from https://github.com/isaacs/json-stringify-safe + version 5.0.1 on 3/8/2017 and modified to handle Errors serialization + and IE8 compatibility. Tests for this are in test/vendor. + + ISC license: https://github.com/isaacs/json-stringify-safe/blob/master/LICENSE +*/ + +exports = module.exports = stringify; +exports.getSerialize = serializer; + +function indexOf(haystack, needle) { + for (var i = 0; i < haystack.length; ++i) { + if (haystack[i] === needle) return i; + } + return -1; +} + +function stringify(obj, replacer, spaces, cycleReplacer) { + return JSON.stringify(obj, serializer(replacer, cycleReplacer), spaces); +} + +// https://github.com/ftlabs/js-abbreviate/blob/fa709e5f139e7770a71827b1893f22418097fbda/index.js#L95-L106 +function stringifyError(value) { + var err = { + // These properties are implemented as magical getters and don't show up in for in + stack: value.stack, + message: value.message, + name: value.name + }; + + for (var i in value) { + if (Object.prototype.hasOwnProperty.call(value, i)) { + err[i] = value[i]; + } + } + + return err; +} + +function serializer(replacer, cycleReplacer) { + var stack = []; + var keys = []; + + if (cycleReplacer == null) { + cycleReplacer = function(key, value) { + if (stack[0] === value) { + return '[Circular ~]'; + } + return '[Circular ~.' + keys.slice(0, indexOf(stack, value)).join('.') + ']'; + }; + } + + return function(key, value) { + if (stack.length > 0) { + var thisPos = indexOf(stack, this); + ~thisPos ? stack.splice(thisPos + 1) : stack.push(this); + ~thisPos ? keys.splice(thisPos, Infinity, key) : keys.push(key); + + if (~indexOf(stack, value)) { + value = cycleReplacer.call(this, key, value); + } + } else { + stack.push(value); + } + + return replacer == null + ? value instanceof Error ? stringifyError(value) : value + : replacer.call(this, key, value); + }; +} + +},{}],8:[function(_dereq_,module,exports){ +/* + * JavaScript MD5 + * https://github.com/blueimp/JavaScript-MD5 + * + * Copyright 2011, Sebastian Tschan + * https://blueimp.net + * + * Licensed under the MIT license: + * https://opensource.org/licenses/MIT + * + * Based on + * A JavaScript implementation of the RSA Data Security, Inc. MD5 Message + * Digest Algorithm, as defined in RFC 1321. + * Version 2.2 Copyright (C) Paul Johnston 1999 - 2009 + * Other contributors: Greg Holt, Andrew Kepert, Ydnar, Lostinet + * Distributed under the BSD License + * See http://pajhome.org.uk/crypt/md5 for more info. + */ + +/* +* Add integers, wrapping at 2^32. This uses 16-bit operations internally +* to work around bugs in some JS interpreters. +*/ +function safeAdd(x, y) { + var lsw = (x & 0xffff) + (y & 0xffff); + var msw = (x >> 16) + (y >> 16) + (lsw >> 16); + return (msw << 16) | (lsw & 0xffff); +} + +/* +* Bitwise rotate a 32-bit number to the left. +*/ +function bitRotateLeft(num, cnt) { + return (num << cnt) | (num >>> (32 - cnt)); +} + +/* +* These functions implement the four basic operations the algorithm uses. +*/ +function md5cmn(q, a, b, x, s, t) { + return safeAdd(bitRotateLeft(safeAdd(safeAdd(a, q), safeAdd(x, t)), s), b); +} +function md5ff(a, b, c, d, x, s, t) { + return md5cmn((b & c) | (~b & d), a, b, x, s, t); +} +function md5gg(a, b, c, d, x, s, t) { + return md5cmn((b & d) | (c & ~d), a, b, x, s, t); +} +function md5hh(a, b, c, d, x, s, t) { + return md5cmn(b ^ c ^ d, a, b, x, s, t); +} +function md5ii(a, b, c, d, x, s, t) { + return md5cmn(c ^ (b | ~d), a, b, x, s, t); +} + +/* +* Calculate the MD5 of an array of little-endian words, and a bit length. +*/ +function binlMD5(x, len) { + /* append padding */ + x[len >> 5] |= 0x80 << (len % 32); + x[(((len + 64) >>> 9) << 4) + 14] = len; + + var i; + var olda; + var oldb; + var oldc; + var oldd; + var a = 1732584193; + var b = -271733879; + var c = -1732584194; + var d = 271733878; + + for (i = 0; i < x.length; i += 16) { + olda = a; + oldb = b; + oldc = c; + oldd = d; + + a = md5ff(a, b, c, d, x[i], 7, -680876936); + d = md5ff(d, a, b, c, x[i + 1], 12, -389564586); + c = md5ff(c, d, a, b, x[i + 2], 17, 606105819); + b = md5ff(b, c, d, a, x[i + 3], 22, -1044525330); + a = md5ff(a, b, c, d, x[i + 4], 7, -176418897); + d = md5ff(d, a, b, c, x[i + 5], 12, 1200080426); + c = md5ff(c, d, a, b, x[i + 6], 17, -1473231341); + b = md5ff(b, c, d, a, x[i + 7], 22, -45705983); + a = md5ff(a, b, c, d, x[i + 8], 7, 1770035416); + d = md5ff(d, a, b, c, x[i + 9], 12, -1958414417); + c = md5ff(c, d, a, b, x[i + 10], 17, -42063); + b = md5ff(b, c, d, a, x[i + 11], 22, -1990404162); + a = md5ff(a, b, c, d, x[i + 12], 7, 1804603682); + d = md5ff(d, a, b, c, x[i + 13], 12, -40341101); + c = md5ff(c, d, a, b, x[i + 14], 17, -1502002290); + b = md5ff(b, c, d, a, x[i + 15], 22, 1236535329); + + a = md5gg(a, b, c, d, x[i + 1], 5, -165796510); + d = md5gg(d, a, b, c, x[i + 6], 9, -1069501632); + c = md5gg(c, d, a, b, x[i + 11], 14, 643717713); + b = md5gg(b, c, d, a, x[i], 20, -373897302); + a = md5gg(a, b, c, d, x[i + 5], 5, -701558691); + d = md5gg(d, a, b, c, x[i + 10], 9, 38016083); + c = md5gg(c, d, a, b, x[i + 15], 14, -660478335); + b = md5gg(b, c, d, a, x[i + 4], 20, -405537848); + a = md5gg(a, b, c, d, x[i + 9], 5, 568446438); + d = md5gg(d, a, b, c, x[i + 14], 9, -1019803690); + c = md5gg(c, d, a, b, x[i + 3], 14, -187363961); + b = md5gg(b, c, d, a, x[i + 8], 20, 1163531501); + a = md5gg(a, b, c, d, x[i + 13], 5, -1444681467); + d = md5gg(d, a, b, c, x[i + 2], 9, -51403784); + c = md5gg(c, d, a, b, x[i + 7], 14, 1735328473); + b = md5gg(b, c, d, a, x[i + 12], 20, -1926607734); + + a = md5hh(a, b, c, d, x[i + 5], 4, -378558); + d = md5hh(d, a, b, c, x[i + 8], 11, -2022574463); + c = md5hh(c, d, a, b, x[i + 11], 16, 1839030562); + b = md5hh(b, c, d, a, x[i + 14], 23, -35309556); + a = md5hh(a, b, c, d, x[i + 1], 4, -1530992060); + d = md5hh(d, a, b, c, x[i + 4], 11, 1272893353); + c = md5hh(c, d, a, b, x[i + 7], 16, -155497632); + b = md5hh(b, c, d, a, x[i + 10], 23, -1094730640); + a = md5hh(a, b, c, d, x[i + 13], 4, 681279174); + d = md5hh(d, a, b, c, x[i], 11, -358537222); + c = md5hh(c, d, a, b, x[i + 3], 16, -722521979); + b = md5hh(b, c, d, a, x[i + 6], 23, 76029189); + a = md5hh(a, b, c, d, x[i + 9], 4, -640364487); + d = md5hh(d, a, b, c, x[i + 12], 11, -421815835); + c = md5hh(c, d, a, b, x[i + 15], 16, 530742520); + b = md5hh(b, c, d, a, x[i + 2], 23, -995338651); + + a = md5ii(a, b, c, d, x[i], 6, -198630844); + d = md5ii(d, a, b, c, x[i + 7], 10, 1126891415); + c = md5ii(c, d, a, b, x[i + 14], 15, -1416354905); + b = md5ii(b, c, d, a, x[i + 5], 21, -57434055); + a = md5ii(a, b, c, d, x[i + 12], 6, 1700485571); + d = md5ii(d, a, b, c, x[i + 3], 10, -1894986606); + c = md5ii(c, d, a, b, x[i + 10], 15, -1051523); + b = md5ii(b, c, d, a, x[i + 1], 21, -2054922799); + a = md5ii(a, b, c, d, x[i + 8], 6, 1873313359); + d = md5ii(d, a, b, c, x[i + 15], 10, -30611744); + c = md5ii(c, d, a, b, x[i + 6], 15, -1560198380); + b = md5ii(b, c, d, a, x[i + 13], 21, 1309151649); + a = md5ii(a, b, c, d, x[i + 4], 6, -145523070); + d = md5ii(d, a, b, c, x[i + 11], 10, -1120210379); + c = md5ii(c, d, a, b, x[i + 2], 15, 718787259); + b = md5ii(b, c, d, a, x[i + 9], 21, -343485551); + + a = safeAdd(a, olda); + b = safeAdd(b, oldb); + c = safeAdd(c, oldc); + d = safeAdd(d, oldd); + } + return [a, b, c, d]; +} + +/* +* Convert an array of little-endian words to a string +*/ +function binl2rstr(input) { + var i; + var output = ''; + var length32 = input.length * 32; + for (i = 0; i < length32; i += 8) { + output += String.fromCharCode((input[i >> 5] >>> (i % 32)) & 0xff); + } + return output; +} + +/* +* Convert a raw string to an array of little-endian words +* Characters >255 have their high-byte silently ignored. +*/ +function rstr2binl(input) { + var i; + var output = []; + output[(input.length >> 2) - 1] = undefined; + for (i = 0; i < output.length; i += 1) { + output[i] = 0; + } + var length8 = input.length * 8; + for (i = 0; i < length8; i += 8) { + output[i >> 5] |= (input.charCodeAt(i / 8) & 0xff) << (i % 32); + } + return output; +} + +/* +* Calculate the MD5 of a raw string +*/ +function rstrMD5(s) { + return binl2rstr(binlMD5(rstr2binl(s), s.length * 8)); +} + +/* +* Calculate the HMAC-MD5, of a key and some data (raw strings) +*/ +function rstrHMACMD5(key, data) { + var i; + var bkey = rstr2binl(key); + var ipad = []; + var opad = []; + var hash; + ipad[15] = opad[15] = undefined; + if (bkey.length > 16) { + bkey = binlMD5(bkey, key.length * 8); + } + for (i = 0; i < 16; i += 1) { + ipad[i] = bkey[i] ^ 0x36363636; + opad[i] = bkey[i] ^ 0x5c5c5c5c; + } + hash = binlMD5(ipad.concat(rstr2binl(data)), 512 + data.length * 8); + return binl2rstr(binlMD5(opad.concat(hash), 512 + 128)); +} + +/* +* Convert a raw string to a hex string +*/ +function rstr2hex(input) { + var hexTab = '0123456789abcdef'; + var output = ''; + var x; + var i; + for (i = 0; i < input.length; i += 1) { + x = input.charCodeAt(i); + output += hexTab.charAt((x >>> 4) & 0x0f) + hexTab.charAt(x & 0x0f); + } + return output; +} + +/* +* Encode a string as utf-8 +*/ +function str2rstrUTF8(input) { + return unescape(encodeURIComponent(input)); +} + +/* +* Take string arguments and return either raw or hex encoded strings +*/ +function rawMD5(s) { + return rstrMD5(str2rstrUTF8(s)); +} +function hexMD5(s) { + return rstr2hex(rawMD5(s)); +} +function rawHMACMD5(k, d) { + return rstrHMACMD5(str2rstrUTF8(k), str2rstrUTF8(d)); +} +function hexHMACMD5(k, d) { + return rstr2hex(rawHMACMD5(k, d)); +} + +function md5(string, key, raw) { + if (!key) { + if (!raw) { + return hexMD5(string); + } + return rawMD5(string); + } + if (!raw) { + return hexHMACMD5(key, string); + } + return rawHMACMD5(key, string); +} + +module.exports = md5; + +},{}]},{},[4])(4) +}); diff --git a/browser/extensions/screenshots/build/selection.js b/browser/extensions/screenshots/build/selection.js new file mode 100644 index 0000000000..cea347e212 --- /dev/null +++ b/browser/extensions/screenshots/build/selection.js @@ -0,0 +1,125 @@ +/* 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.selection = (function () {let exports={}; class Selection { + constructor(x1, y1, x2, y2) { + this.x1 = x1; + this.y1 = y1; + this.x2 = x2; + this.y2 = y2; + } + + get top() { + return Math.min(this.y1, this.y2); + } + set top(val) { + if (this.y1 < this.y2) { + this.y1 = val; + } else { + this.y2 = val; + } + } + + get bottom() { + return Math.max(this.y1, this.y2); + } + set bottom(val) { + if (this.y1 > this.y2) { + this.y1 = val; + } else { + this.y2 = val; + } + } + + get left() { + return Math.min(this.x1, this.x2); + } + set left(val) { + if (this.x1 < this.x2) { + this.x1 = val; + } else { + this.x2 = val; + } + } + + get right() { + return Math.max(this.x1, this.x2); + } + set right(val) { + if (this.x1 > this.x2) { + this.x1 = val; + } else { + this.x2 = val; + } + } + + get width() { + return Math.abs(this.x2 - this.x1); + } + get height() { + return Math.abs(this.y2 - this.y1); + } + + rect() { + return { + top: Math.floor(this.top), + left: Math.floor(this.left), + bottom: Math.floor(this.bottom), + right: Math.floor(this.right), + }; + } + + union(other) { + return new Selection( + Math.min(this.left, other.left), + Math.min(this.top, other.top), + Math.max(this.right, other.right), + Math.max(this.bottom, other.bottom) + ); + } + + /** Sort x1/x2 and y1/y2 so x1<x2, y1<y2 */ + sortCoords() { + if (this.x1 > this.x2) { + [this.x1, this.x2] = [this.x2, this.x1]; + } + if (this.y1 > this.y2) { + [this.y1, this.y2] = [this.y2, this.y1]; + } + } + + clone() { + return new Selection(this.x1, this.y1, this.x2, this.y2); + } + + toJSON() { + return { + left: this.left, + right: this.right, + top: this.top, + bottom: this.bottom, + }; + } + + static getBoundingClientRect(el) { + if (!el.getBoundingClientRect) { + // Typically the <html> element or somesuch + return null; + } + const rect = el.getBoundingClientRect(); + if (!rect) { + return null; + } + return new Selection(rect.left, rect.top, rect.right, rect.bottom); + } +} + +if (typeof exports !== "undefined") { + exports.Selection = Selection; +} + +return exports; +})(); +null; + diff --git a/browser/extensions/screenshots/build/shot.js b/browser/extensions/screenshots/build/shot.js new file mode 100644 index 0000000000..57ccea5c38 --- /dev/null +++ b/browser/extensions/screenshots/build/shot.js @@ -0,0 +1,754 @@ +/* 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.shot = (function () {let exports={}; // Note: in this library we can't use any "system" dependencies because this can be used from multiple +// environments + +const isNode = typeof process !== "undefined" && Object.prototype.toString.call(process) === "[object process]"; +const URL = (isNode && require("url").URL) || window.URL; + +/** Throws an error if the condition isn't true. Any extra arguments after the condition + are used as console.error() arguments. */ +function assert(condition, ...args) { + if (condition) { + return; + } + console.error("Failed assertion", ...args); + throw new Error(`Failed assertion: ${args.join(" ")}`); +} + +/** True if `url` is a valid URL */ +function isUrl(url) { + try { + const parsed = new URL(url); + + if (parsed.protocol === "view-source:") { + return isUrl(url.substr("view-source:".length)); + } + + return true; + } catch (e) { + return false; + } +} + +function isValidClipImageUrl(url) { + return isUrl(url) && !(url.indexOf(")") > -1); +} + +function assertUrl(url) { + if (!url) { + throw new Error("Empty value is not URL"); + } + if (!isUrl(url)) { + const exc = new Error("Not a URL"); + exc.scheme = url.split(":")[0]; + throw exc; + } +} + +function isSecureWebUri(url) { + return isUrl(url) && url.toLowerCase().startsWith("https"); +} + +function assertOrigin(url) { + assertUrl(url); + if (url.search(/^https?:/i) !== -1) { + const match = (/^https?:\/\/[^/:]{1,4000}\/?$/i).exec(url); + if (!match) { + throw new Error("Bad origin, might include path"); + } + } +} + +function originFromUrl(url) { + if (!url) { + return null; + } + if (url.search(/^https?:/i) === -1) { + // Non-HTTP URLs don't have an origin + return null; + } + const match = (/^https?:\/\/[^/:]{1,4000}/i).exec(url); + if (match) { + return match[0]; + } + return null; +} + +/** Check if the given object has all of the required attributes, and no extra + attributes exception those in optional */ +function checkObject(obj, required, optional) { + if (typeof obj !== "object" || obj === null) { + throw new Error("Cannot check non-object: " + (typeof obj) + " that is " + JSON.stringify(obj)); + } + required = required || []; + for (const attr of required) { + if (!(attr in obj)) { + return false; + } + } + optional = optional || []; + for (const attr in obj) { + if (!required.includes(attr) && !optional.includes(attr)) { + return false; + } + } + return true; +} + +/** Create a JSON object from a normal object, given the required and optional + attributes (filtering out any other attributes). Optional attributes are + only kept when they are truthy. */ +function jsonify(obj, required, optional) { + required = required || []; + const result = {}; + for (const attr of required) { + result[attr] = obj[attr]; + } + optional = optional || []; + for (const attr of optional) { + if (obj[attr]) { + result[attr] = obj[attr]; + } + } + return result; +} + +/** True if the two objects look alike. Null, undefined, and absent properties + are all treated as equivalent. Traverses objects and arrays */ +function deepEqual(a, b) { + if ((a === null || a === undefined) && (b === null || b === undefined)) { + return true; + } + if (typeof a !== "object" || typeof b !== "object") { + return a === b; + } + if (Array.isArray(a)) { + if (!Array.isArray(b)) { + return false; + } + if (a.length !== b.length) { + return false; + } + for (let i = 0; i < a.length; i++) { + if (!deepEqual(a[i], b[i])) { + return false; + } + } + } + if (Array.isArray(b)) { + return false; + } + const seen = new Set(); + for (const attr of Object.keys(a)) { + if (!deepEqual(a[attr], b[attr])) { + return false; + } + seen.add(attr); + } + for (const attr of Object.keys(b)) { + if (!seen.has(attr)) { + if (!deepEqual(a[attr], b[attr])) { + return false; + } + } + } + return true; +} + +function makeRandomId() { + // Note: this isn't for secure contexts, only for non-conflicting IDs + let id = ""; + while (id.length < 12) { + let num; + if (!id) { + num = Date.now() % Math.pow(36, 3); + } else { + num = Math.floor(Math.random() * Math.pow(36, 3)); + } + id += num.toString(36); + } + return id; +} + +class AbstractShot { + + constructor(backend, id, attrs) { + attrs = attrs || {}; + assert((/^[a-zA-Z0-9]{1,4000}\/[a-z0-9._-]{1,4000}$/).test(id), "Bad ID (should be alphanumeric):", JSON.stringify(id)); + this._backend = backend; + this._id = id; + this.origin = attrs.origin || null; + this.fullUrl = attrs.fullUrl || null; + if ((!attrs.fullUrl) && attrs.url) { + console.warn("Received deprecated attribute .url"); + this.fullUrl = attrs.url; + } + if (this.origin && !isSecureWebUri(this.origin)) { + this.origin = ""; + } + if (this.fullUrl && !isSecureWebUri(this.fullUrl)) { + this.fullUrl = ""; + } + this.docTitle = attrs.docTitle || null; + this.userTitle = attrs.userTitle || null; + this.createdDate = attrs.createdDate || Date.now(); + this.siteName = attrs.siteName || null; + this.images = []; + if (attrs.images) { + this.images = attrs.images.map( + (json) => new this.Image(json)); + } + this.openGraph = attrs.openGraph || null; + this.twitterCard = attrs.twitterCard || null; + this.documentSize = attrs.documentSize || null; + this.thumbnail = attrs.thumbnail || null; + this.abTests = attrs.abTests || null; + this.firefoxChannel = attrs.firefoxChannel || null; + this._clips = {}; + if (attrs.clips) { + for (const clipId in attrs.clips) { + const clip = attrs.clips[clipId]; + this._clips[clipId] = new this.Clip(this, clipId, clip); + } + } + + const isProd = typeof process !== "undefined" && process.env.NODE_ENV === "production"; + + for (const attr in attrs) { + if (attr !== "clips" && attr !== "id" && !this.REGULAR_ATTRS.includes(attr) && !this.DEPRECATED_ATTRS.includes(attr)) { + if (isProd) { + console.warn("Unexpected attribute: " + attr); + } else { + throw new Error("Unexpected attribute: " + attr); + } + } else if (attr === "id") { + console.warn("passing id in attrs in AbstractShot constructor"); + console.trace(); + assert(attrs.id === this.id); + } + } + } + + /** Update any and all attributes in the json object, with deep updating + of `json.clips` */ + update(json) { + const ALL_ATTRS = ["clips"].concat(this.REGULAR_ATTRS); + assert(checkObject(json, [], ALL_ATTRS), "Bad attr to new Shot():", Object.keys(json)); + for (const attr in json) { + if (attr === "clips") { + continue; + } + if (typeof json[attr] === "object" && typeof this[attr] === "object" && this[attr] !== null) { + let val = this[attr]; + if (val.toJSON) { + val = val.toJSON(); + } + if (!deepEqual(json[attr], val)) { + this[attr] = json[attr]; + } + } else if (json[attr] !== this[attr] && + (json[attr] || this[attr])) { + this[attr] = json[attr]; + } + } + if (json.clips) { + for (const clipId in json.clips) { + if (!json.clips[clipId]) { + this.delClip(clipId); + } else if (!this.getClip(clipId)) { + this.setClip(clipId, json.clips[clipId]); + } else if (!deepEqual(this.getClip(clipId).toJSON(), json.clips[clipId])) { + this.setClip(clipId, json.clips[clipId]); + } + } + } + + } + + /** Returns a JSON version of this shot */ + toJSON() { + const result = {}; + for (const attr of this.REGULAR_ATTRS) { + let val = this[attr]; + if (val && val.toJSON) { + val = val.toJSON(); + } + result[attr] = val; + } + result.clips = {}; + for (const attr in this._clips) { + result.clips[attr] = this._clips[attr].toJSON(); + } + return result; + } + + /** A more minimal JSON representation for creating indexes of shots */ + asRecallJson() { + const result = {clips: {}}; + for (const attr of this.RECALL_ATTRS) { + let val = this[attr]; + if (val && val.toJSON) { + val = val.toJSON(); + } + result[attr] = val; + } + for (const name of this.clipNames()) { + result.clips[name] = this.getClip(name).toJSON(); + } + return result; + } + + get backend() { + return this._backend; + } + + get id() { + return this._id; + } + + get url() { + return this.fullUrl || this.origin; + } + set url(val) { + throw new Error(".url is read-only"); + } + + get fullUrl() { + return this._fullUrl; + } + set fullUrl(val) { + if (val) { + assertUrl(val); + } + this._fullUrl = val || undefined; + } + + get origin() { + return this._origin; + } + set origin(val) { + if (val) { + assertOrigin(val); + } + this._origin = val || undefined; + } + + get isOwner() { + return this._isOwner; + } + + set isOwner(val) { + this._isOwner = val || undefined; + } + + get filename() { + let filenameTitle = this.title; + const date = new Date(this.createdDate); + // eslint-disable-next-line no-control-regex + filenameTitle = filenameTitle.replace(/[:\\<>/!@&?"*.|\x00-\x1F]/g, " "); + filenameTitle = filenameTitle.replace(/\s{1,4000}/g, " "); + const filenameDate = new Date(date.getTime() - date.getTimezoneOffset() * 60 * 1000).toISOString().substring(0, 10); + let clipFilename = `Screenshot_${filenameDate} ${filenameTitle}`; + const clipFilenameBytesSize = clipFilename.length * 2; // JS STrings are UTF-16 + if (clipFilenameBytesSize > 251) { // 255 bytes (Usual filesystems max) - 4 for the ".png" file extension string + const excedingchars = (clipFilenameBytesSize - 246) / 2; // 251 - 5 for ellipsis "[...]" + clipFilename = clipFilename.substring(0, clipFilename.length - excedingchars); + clipFilename = clipFilename + "[...]"; + } + const clip = this.getClip(this.clipNames()[0]); + let extension = ".png"; + if (clip && clip.image && clip.image.type) { + if (clip.image.type === "jpeg") { + extension = ".jpg"; + } + } + return clipFilename + extension; + } + + get urlDisplay() { + if (!this.url) { + return null; + } + if (/^https?:\/\//i.test(this.url)) { + let txt = this.url; + txt = txt.replace(/^[a-z]{1,4000}:\/\//i, ""); + txt = txt.replace(/\/.{0,4000}/, ""); + txt = txt.replace(/^www\./i, ""); + return txt; + } else if (this.url.startsWith("data:")) { + return "data:url"; + } + let txt = this.url; + txt = txt.replace(/\?.{0,4000}/, ""); + return txt; + } + + get viewUrl() { + const url = this.backend + "/" + this.id; + return url; + } + + get creatingUrl() { + let url = `${this.backend}/creating/${this.id}`; + url += `?title=${encodeURIComponent(this.title || "")}`; + url += `&url=${encodeURIComponent(this.url)}`; + return url; + } + + get jsonUrl() { + return this.backend + "/data/" + this.id; + } + + get oembedUrl() { + return this.backend + "/oembed?url=" + encodeURIComponent(this.viewUrl); + } + + get docTitle() { + return this._title; + } + set docTitle(val) { + assert(val === null || typeof val === "string", "Bad docTitle:", val); + this._title = val; + } + + get openGraph() { + return this._openGraph || null; + } + set openGraph(val) { + assert(val === null || typeof val === "object", "Bad openGraph:", val); + if (val) { + assert(checkObject(val, [], this._OPENGRAPH_PROPERTIES), "Bad attr to openGraph:", Object.keys(val)); + this._openGraph = val; + } else { + this._openGraph = null; + } + } + + get twitterCard() { + return this._twitterCard || null; + } + set twitterCard(val) { + assert(val === null || typeof val === "object", "Bad twitterCard:", val); + if (val) { + assert(checkObject(val, [], this._TWITTERCARD_PROPERTIES), "Bad attr to twitterCard:", Object.keys(val)); + this._twitterCard = val; + } else { + this._twitterCard = null; + } + } + + get userTitle() { + return this._userTitle; + } + set userTitle(val) { + assert(val === null || typeof val === "string", "Bad userTitle:", val); + this._userTitle = val; + } + + get title() { + // FIXME: we shouldn't support both openGraph.title and ogTitle + const ogTitle = this.openGraph && this.openGraph.title; + const twitterTitle = this.twitterCard && this.twitterCard.title; + let title = this.userTitle || ogTitle || twitterTitle || this.docTitle || this.url; + if (Array.isArray(title)) { + title = title[0]; + } + if (!title) { + title = "Screenshot"; + } + return title; + } + + get createdDate() { + return this._createdDate; + } + set createdDate(val) { + assert(val === null || typeof val === "number", "Bad createdDate:", val); + this._createdDate = val; + } + + clipNames() { + const names = Object.getOwnPropertyNames(this._clips); + names.sort(function(a, b) { + return a.sortOrder < b.sortOrder ? 1 : 0; + }); + return names; + } + getClip(name) { + return this._clips[name]; + } + addClip(val) { + const name = makeRandomId(); + this.setClip(name, val); + return name; + } + setClip(name, val) { + const clip = new this.Clip(this, name, val); + this._clips[name] = clip; + } + delClip(name) { + if (!this._clips[name]) { + throw new Error("No existing clip with id: " + name); + } + delete this._clips[name]; + } + delAllClips() { + this._clips = {}; + } + biggestClipSortOrder() { + let biggest = 0; + for (const clipId in this._clips) { + biggest = Math.max(biggest, this._clips[clipId].sortOrder); + } + return biggest; + } + updateClipUrl(clipId, clipUrl) { + const clip = this.getClip(clipId); + if ( clip && clip.image ) { + clip.image.url = clipUrl; + } else { + console.warn("Tried to update the url of a clip with no image:", clip); + } + } + + get siteName() { + return this._siteName || null; + } + set siteName(val) { + assert(typeof val === "string" || !val); + this._siteName = val; + } + + get documentSize() { + return this._documentSize; + } + set documentSize(val) { + assert(typeof val === "object" || !val); + if (val) { + assert(checkObject(val, ["height", "width"], "Bad attr to documentSize:", Object.keys(val))); + assert(typeof val.height === "number"); + assert(typeof val.width === "number"); + this._documentSize = val; + } else { + this._documentSize = null; + } + } + + get thumbnail() { + return this._thumbnail; + } + set thumbnail(val) { + assert(typeof val === "string" || !val); + if (val) { + assert(isUrl(val)); + this._thumbnail = val; + } else { + this._thumbnail = null; + } + } + + get abTests() { + return this._abTests; + } + set abTests(val) { + if (val === null || val === undefined) { + this._abTests = null; + return; + } + assert(typeof val === "object", "abTests should be an object, not:", typeof val); + assert(!Array.isArray(val), "abTests should not be an Array"); + for (const name in val) { + assert(val[name] && typeof val[name] === "string", `abTests.${name} should be a string:`, typeof val[name]); + } + this._abTests = val; + } + + get firefoxChannel() { + return this._firefoxChannel; + } + set firefoxChannel(val) { + if (val === null || val === undefined) { + this._firefoxChannel = null; + return; + } + assert(typeof val === "string", "firefoxChannel should be a string, not:", typeof val); + this._firefoxChannel = val; + } + +} + +AbstractShot.prototype.REGULAR_ATTRS = (` +origin fullUrl docTitle userTitle createdDate images +siteName openGraph twitterCard documentSize +thumbnail abTests firefoxChannel +`).split(/\s+/g); + +// Attributes that will be accepted in the constructor, but ignored/dropped +AbstractShot.prototype.DEPRECATED_ATTRS = (` +microdata history ogTitle createdDevice head body htmlAttrs bodyAttrs headAttrs +readable hashtags comments showPage isPublic resources deviceId url +fullScreenThumbnail favicon +`).split(/\s+/g); + +AbstractShot.prototype.RECALL_ATTRS = (` +url docTitle userTitle createdDate openGraph twitterCard images thumbnail +`).split(/\s+/g); + +AbstractShot.prototype._OPENGRAPH_PROPERTIES = (` +title type url image audio description determiner locale site_name video +image:secure_url image:type image:width image:height +video:secure_url video:type video:width image:height +audio:secure_url audio:type +article:published_time article:modified_time article:expiration_time article:author article:section article:tag +book:author book:isbn book:release_date book:tag +profile:first_name profile:last_name profile:username profile:gender +`).split(/\s+/g); + +AbstractShot.prototype._TWITTERCARD_PROPERTIES = (` +card site title description image +player player:width player:height player:stream player:stream:content_type +`).split(/\s+/g); + +/** Represents one found image in the document (not a clip) */ +class _Image { + // FIXME: either we have to notify the shot of updates, or make + // this read-only + constructor(json) { + assert(typeof json === "object", "Clip Image given a non-object", json); + assert(checkObject(json, ["url"], ["dimensions", "title", "alt"]), "Bad attrs for Image:", Object.keys(json)); + assert(isUrl(json.url), "Bad Image url:", json.url); + this.url = json.url; + assert((!json.dimensions) || + (typeof json.dimensions.x === "number" && typeof json.dimensions.y === "number"), + "Bad Image dimensions:", json.dimensions); + this.dimensions = json.dimensions; + assert(typeof json.title === "string" || !json.title, "Bad Image title:", json.title); + this.title = json.title; + assert(typeof json.alt === "string" || !json.alt, "Bad Image alt:", json.alt); + this.alt = json.alt; + } + + toJSON() { + return jsonify(this, ["url"], ["dimensions"]); + } +} + +AbstractShot.prototype.Image = _Image; + +/** Represents a clip, either a text or image clip */ +class _Clip { + constructor(shot, id, json) { + this._shot = shot; + assert(checkObject(json, ["createdDate", "image"], ["sortOrder"]), "Bad attrs for Clip:", Object.keys(json)); + assert(typeof id === "string" && id, "Bad Clip id:", id); + this._id = id; + this.createdDate = json.createdDate; + if ("sortOrder" in json) { + assert(typeof json.sortOrder === "number" || !json.sortOrder, "Bad Clip sortOrder:", json.sortOrder); + } + if ("sortOrder" in json) { + this.sortOrder = json.sortOrder; + } else { + const biggestOrder = shot.biggestClipSortOrder(); + this.sortOrder = biggestOrder + 100; + } + this.image = json.image; + } + + toString() { + return `[Shot Clip id=${this.id} sortOrder=${this.sortOrder} image ${this.image.dimensions.x}x${this.image.dimensions.y}]`; + } + + toJSON() { + return jsonify(this, ["createdDate"], ["sortOrder", "image"]); + } + + get id() { + return this._id; + } + + get createdDate() { + return this._createdDate; + } + set createdDate(val) { + assert(typeof val === "number" || !val, "Bad Clip createdDate:", val); + this._createdDate = val; + } + + get image() { + return this._image; + } + set image(image) { + if (!image) { + this._image = undefined; + return; + } + assert(checkObject(image, ["url"], ["dimensions", "text", "location", "captureType", "type"]), "Bad attrs for Clip Image:", Object.keys(image)); + assert(isValidClipImageUrl(image.url), "Bad Clip image URL:", image.url); + assert( + image.captureType === "madeSelection" || + image.captureType === "selection" || + image.captureType === "visible" || + image.captureType === "auto" || + image.captureType === "fullPage" || + image.captureType === "fullPageTruncated" || + !image.captureType, "Bad image.captureType:", image.captureType); + assert(typeof image.text === "string" || !image.text, "Bad Clip image text:", image.text); + if (image.dimensions) { + assert(typeof image.dimensions.x === "number" && typeof image.dimensions.y === "number", "Bad Clip image dimensions:", image.dimensions); + } + if (image.type) { + assert(image.type === "png" || image.type === "jpeg", "Unexpected image type:", image.type); + } + assert(image.location && + typeof image.location.left === "number" && + typeof image.location.right === "number" && + typeof image.location.top === "number" && + typeof image.location.bottom === "number", "Bad Clip image pixel location:", image.location); + if (image.location.topLeftElement || image.location.topLeftOffset || + image.location.bottomRightElement || image.location.bottomRightOffset) { + assert(typeof image.location.topLeftElement === "string" && + image.location.topLeftOffset && + typeof image.location.topLeftOffset.x === "number" && + typeof image.location.topLeftOffset.y === "number" && + typeof image.location.bottomRightElement === "string" && + image.location.bottomRightOffset && + typeof image.location.bottomRightOffset.x === "number" && + typeof image.location.bottomRightOffset.y === "number", + "Bad Clip image element location:", image.location); + } + this._image = image; + } + + isDataUrl() { + if (this.image) { + return this.image.url.startsWith("data:"); + } + return false; + } + + get sortOrder() { + return this._sortOrder || null; + } + set sortOrder(val) { + assert(typeof val === "number" || !val, "Bad Clip sortOrder:", val); + this._sortOrder = val; + } + +} + +AbstractShot.prototype.Clip = _Clip; + +if (typeof exports !== "undefined") { + exports.AbstractShot = AbstractShot; + exports.originFromUrl = originFromUrl; + exports.isValidClipImageUrl = isValidClipImageUrl; +} + +return exports; +})(); +null; + diff --git a/browser/extensions/screenshots/build/thumbnailGenerator.js b/browser/extensions/screenshots/build/thumbnailGenerator.js new file mode 100644 index 0000000000..e51105fe94 --- /dev/null +++ b/browser/extensions/screenshots/build/thumbnailGenerator.js @@ -0,0 +1,154 @@ +/* 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.thumbnailGenerator = (function () {let exports={}; // This is used in webextension/background/takeshot.js, +// server/src/pages/shot/controller.js, and +// server/scr/pages/shotindex/view.js. It is used in a browser +// environment. + +// Resize down 1/2 at a time produces better image quality. +// Not quite as good as using a third-party filter (which will be +// slower), but good enough. +const maxResizeScaleFactor = 0.5; + +// The shot will be scaled or cropped down to 210px on x, and cropped or +// scaled down to a maximum of 280px on y. +// x: 210 +// y: <= 280 +const maxThumbnailWidth = 210; +const maxThumbnailHeight = 280; + +/** + * @param {int} imageHeight Height in pixels of the original image. + * @param {int} imageWidth Width in pixels of the original image. + * @returns {width, height, scaledX, scaledY} + */ +function getThumbnailDimensions(imageWidth, imageHeight) { + const displayAspectRatio = 3 / 4; + const imageAspectRatio = imageWidth / imageHeight; + let thumbnailImageWidth, thumbnailImageHeight; + let scaledX, scaledY; + + if (imageAspectRatio > displayAspectRatio) { + // "Landscape" mode + // Scale on y, crop on x + const yScaleFactor = (imageHeight > maxThumbnailHeight) ? (maxThumbnailHeight / imageHeight) : 1.0; + thumbnailImageHeight = scaledY = Math.round(imageHeight * yScaleFactor); + scaledX = Math.round(imageWidth * yScaleFactor); + thumbnailImageWidth = Math.min(scaledX, maxThumbnailWidth); + } else { + // "Portrait" mode + // Scale on x, crop on y + const xScaleFactor = (imageWidth > maxThumbnailWidth) ? (maxThumbnailWidth / imageWidth) : 1.0; + thumbnailImageWidth = scaledX = Math.round(imageWidth * xScaleFactor); + scaledY = Math.round(imageHeight * xScaleFactor); + // The CSS could widen the image, in which case we crop more off of y. + thumbnailImageHeight = Math.min(scaledY, maxThumbnailHeight, + maxThumbnailHeight / (maxThumbnailWidth / imageWidth)); + } + + return { + width: thumbnailImageWidth, + height: thumbnailImageHeight, + scaledX, + scaledY, + }; +} + +/** + * @param {dataUrl} String Data URL of the original image. + * @param {int} imageHeight Height in pixels of the original image. + * @param {int} imageWidth Width in pixels of the original image. + * @param {String} urlOrBlob 'blob' for a blob, otherwise data url. + * @returns A promise that resolves to the data URL or blob of the thumbnail image, or null. + */ +function createThumbnail(dataUrl, imageWidth, imageHeight, urlOrBlob) { + // There's cost associated with generating, transmitting, and storing + // thumbnails, so we'll opt out if the image size is below a certain threshold + const thumbnailThresholdFactor = 1.20; + const thumbnailWidthThreshold = maxThumbnailWidth * thumbnailThresholdFactor; + const thumbnailHeightThreshold = maxThumbnailHeight * thumbnailThresholdFactor; + + if (imageWidth <= thumbnailWidthThreshold && + imageHeight <= thumbnailHeightThreshold) { + // Do not create a thumbnail. + return Promise.resolve(null); + } + + const thumbnailDimensions = getThumbnailDimensions(imageWidth, imageHeight); + + return new Promise((resolve, reject) => { + const thumbnailImage = new Image(); + let srcWidth = imageWidth; + let srcHeight = imageHeight; + let destWidth, destHeight; + + thumbnailImage.onload = function() { + destWidth = Math.round(srcWidth * maxResizeScaleFactor); + destHeight = Math.round(srcHeight * maxResizeScaleFactor); + if (destWidth <= thumbnailDimensions.scaledX || destHeight <= thumbnailDimensions.scaledY) { + srcWidth = Math.round(srcWidth * (thumbnailDimensions.width / thumbnailDimensions.scaledX)); + srcHeight = Math.round(srcHeight * (thumbnailDimensions.height / thumbnailDimensions.scaledY)); + destWidth = thumbnailDimensions.width; + destHeight = thumbnailDimensions.height; + } + + const thumbnailCanvas = document.createElement("canvas"); + thumbnailCanvas.width = destWidth; + thumbnailCanvas.height = destHeight; + const ctx = thumbnailCanvas.getContext("2d"); + ctx.imageSmoothingEnabled = false; + + ctx.drawImage( + thumbnailImage, + 0, 0, srcWidth, srcHeight, + 0, 0, destWidth, destHeight); + + if (thumbnailCanvas.width <= thumbnailDimensions.width || + thumbnailCanvas.height <= thumbnailDimensions.height) { + if (urlOrBlob === "blob") { + thumbnailCanvas.toBlob((blob) => { + resolve(blob); + }); + } else { + resolve(thumbnailCanvas.toDataURL("image/png")); + } + return; + } + + srcWidth = destWidth; + srcHeight = destHeight; + thumbnailImage.src = thumbnailCanvas.toDataURL(); + }; + thumbnailImage.src = dataUrl; + }); +} + +function createThumbnailUrl(shot) { + const image = shot.getClip(shot.clipNames()[0]).image; + if (!image.url) { + return Promise.resolve(null); + } + return createThumbnail( + image.url, image.dimensions.x, image.dimensions.y, "dataurl"); +} + +function createThumbnailBlobFromPromise(shot, blobToUrlPromise) { + return blobToUrlPromise.then(dataUrl => { + const image = shot.getClip(shot.clipNames()[0]).image; + return createThumbnail( + dataUrl, image.dimensions.x, image.dimensions.y, "blob"); + }); +} + +if (typeof exports !== "undefined") { + exports.getThumbnailDimensions = getThumbnailDimensions; + exports.createThumbnailUrl = createThumbnailUrl; + exports.createThumbnailBlobFromPromise = createThumbnailBlobFromPromise; +} + +return exports; +})(); +null; + diff --git a/browser/extensions/screenshots/catcher.js b/browser/extensions/screenshots/catcher.js new file mode 100644 index 0000000000..f16c6a0202 --- /dev/null +++ b/browser/extensions/screenshots/catcher.js @@ -0,0 +1,101 @@ +/* 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"; + +// eslint-disable-next-line no-var +var global = this; + +this.catcher = (function() { + const exports = {}; + + let handler; + + let queue = []; + + const log = global.log; + + exports.unhandled = function(error, info) { + if (!error.noReport) { + log.error("Unhandled error:", error, info); + } + const e = makeError(error, info); + if (!handler) { + queue.push(e); + } else { + handler(e); + } + }; + + /** Turn an exception into an error object */ + function makeError(exc, info) { + let result; + if (exc.fromMakeError) { + result = exc; + } else { + result = { + fromMakeError: true, + name: exc.name || "ERROR", + message: String(exc), + stack: exc.stack, + }; + for (const attr in exc) { + result[attr] = exc[attr]; + } + } + if (info) { + for (const attr of Object.keys(info)) { + result[attr] = info[attr]; + } + } + return result; + } + + /** Wrap the function, and if it raises any exceptions then call unhandled() */ + exports.watchFunction = function watchFunction(func, quiet) { + return function() { + try { + return func.apply(this, arguments); + } catch (e) { + if (!quiet) { + exports.unhandled(e); + } + throw e; + } + }; + }; + + exports.watchPromise = function watchPromise(promise, quiet) { + return promise.catch((e) => { + if (quiet) { + if (!e.noReport) { + log.debug("------Error in promise:", e); + log.debug(e.stack); + } + } else { + if (!e.noReport) { + log.error("------Error in promise:", e); + log.error(e.stack); + } + exports.unhandled(makeError(e)); + } + throw e; + }); + }; + + exports.registerHandler = function(h) { + if (handler) { + log.error("registerHandler called after handler was already registered"); + return; + } + handler = h; + for (const error of queue) { + handler(error); + } + queue = []; + }; + + return exports; +})(); +null; diff --git a/browser/extensions/screenshots/clipboard.js b/browser/extensions/screenshots/clipboard.js new file mode 100644 index 0000000000..313a3e6372 --- /dev/null +++ b/browser/extensions/screenshots/clipboard.js @@ -0,0 +1,58 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/* globals catcher, assertIsBlankDocument */ + +"use strict"; + +this.clipboard = (function() { + const exports = {}; + + exports.copy = function(text) { + return new Promise((resolve, reject) => { + const element = document.createElement("iframe"); + element.src = browser.extension.getURL("blank.html"); + // We can't actually hide the iframe while copying, but we can make + // it close to invisible: + element.style.opacity = "0"; + element.style.width = "1px"; + element.style.height = "1px"; + element.style.display = "block"; + element.addEventListener("load", catcher.watchFunction(() => { + try { + const doc = element.contentDocument; + assertIsBlankDocument(doc); + const el = doc.createElement("textarea"); + doc.body.appendChild(el); + el.value = text; + if (!text) { + const exc = new Error("Clipboard copy given empty text"); + exc.noPopup = true; + catcher.unhandled(exc); + } + el.select(); + if (doc.activeElement !== el) { + const unhandledTag = doc.activeElement ? doc.activeElement.tagName : "No active element"; + const exc = new Error("Clipboard el.select failed"); + exc.activeElement = unhandledTag; + exc.noPopup = true; + catcher.unhandled(exc); + } + const copied = doc.execCommand("copy"); + if (!copied) { + catcher.unhandled(new Error("Clipboard copy failed")); + } + el.remove(); + resolve(copied); + } finally { + element.remove(); + } + }), {once: true}); + document.body.appendChild(element); + }); + }; + + return exports; +})(); +null; diff --git a/browser/extensions/screenshots/domainFromUrl.js b/browser/extensions/screenshots/domainFromUrl.js new file mode 100644 index 0000000000..421e752508 --- /dev/null +++ b/browser/extensions/screenshots/domainFromUrl.js @@ -0,0 +1,33 @@ +/* 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/. */ + +/** Returns the domain of a URL, but safely and in ASCII; URLs without domains + (such as about:blank) return the scheme, Unicode domains get stripped down + to ASCII */ + +"use strict"; + +this.domainFromUrl = (function() { + + return function urlDomainForId(location) { // eslint-disable-line no-unused-vars + let domain = location.hostname; + if (!domain) { + domain = location.origin.split(":")[0]; + if (!domain) { + domain = "unknown"; + } + } + if (domain.search(/^[a-z0-9._-]{1,1000}$/i) === -1) { + // Probably a unicode domain; we could use punycode but it wouldn't decode + // well in the URL anyway. Instead we'll punt. + domain = domain.replace(/[^a-z0-9._-]/ig, ""); + if (!domain) { + domain = "site"; + } + } + return domain; + }; + +})(); +null; diff --git a/browser/extensions/screenshots/experiments/screenshots/api.js b/browser/extensions/screenshots/experiments/screenshots/api.js new file mode 100644 index 0000000000..946ff3a5c0 --- /dev/null +++ b/browser/extensions/screenshots/experiments/screenshots/api.js @@ -0,0 +1,47 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/* globals browser, AppConstants, Services, ExtensionAPI */ + +"use strict"; + +ChromeUtils.defineModuleGetter(this, "AppConstants", + "resource://gre/modules/AppConstants.jsm"); +ChromeUtils.defineModuleGetter(this, "Services", + "resource://gre/modules/Services.jsm"); + +this.screenshots = class extends ExtensionAPI { + getAPI(context) { + const {extension} = context; + return { + experiments: { + screenshots: { + // If you are checking for 'nightly', also check for 'nightly-try'. + // + // Otherwise, just use the standard builds, but be aware of the many + // non-standard options that also exist (as of August 2018). + // + // Standard builds: + // 'esr' - ESR channel + // 'release' - release channel + // 'beta' - beta channel + // 'nightly' - nightly channel + // Non-standard / deprecated builds: + // 'aurora' - deprecated aurora channel (still observed in dxr) + // 'default' - local builds from source + // 'nightly-try' - nightly Try builds (QA may occasionally need to test with these) + getUpdateChannel() { + return AppConstants.MOZ_UPDATE_CHANNEL; + }, + isHistoryEnabled() { + return Services.prefs.getBoolPref("places.history.enabled", true); + }, + isUploadDisabled() { + return Services.prefs.getBoolPref("extensions.screenshots.upload-disabled", false); + }, + }, + }, + }; + } +}; diff --git a/browser/extensions/screenshots/experiments/screenshots/schema.json b/browser/extensions/screenshots/experiments/screenshots/schema.json new file mode 100644 index 0000000000..99cf39e7f1 --- /dev/null +++ b/browser/extensions/screenshots/experiments/screenshots/schema.json @@ -0,0 +1,29 @@ +[ + { + "namespace": "experiments.screenshots", + "description": "Firefox Screenshots internal API", + "functions": [ + { + "name": "getUpdateChannel", + "type": "function", + "description": "Returns the Firefox channel (AppConstants.MOZ_UPDATE_CHANNEL)", + "parameters": [], + "async": true + }, + { + "name": "isHistoryEnabled", + "type": "function", + "description": "Returns the value of the 'places.history.enabled' preference", + "parameters": [], + "async": true + }, + { + "name": "isUploadDisabled", + "type": "function", + "description": "Returns the value of the 'extensions.screenshots.upload-disabled' preference", + "parameters": [], + "async": true + } + ] + } +] diff --git a/browser/extensions/screenshots/icons/cancel.svg b/browser/extensions/screenshots/icons/cancel.svg new file mode 100644 index 0000000000..3da18aeab7 --- /dev/null +++ b/browser/extensions/screenshots/icons/cancel.svg @@ -0,0 +1,4 @@ +<!-- 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/. --> +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"><path d="M10.5 8.7L5.2 3.3c-.5-.5-1.3-.5-1.8 0s-.5 1.3 0 1.8l5.3 5.3-5.3 5.3c-.5.5-.5 1.3 0 1.8s1.3.5 1.8 0l5.3-5.3 5.3 5.3c.5.5 1.3.5 1.8 0s.5-1.3 0-1.8l-5.3-5.3 5.3-5.3c.5-.5.5-1.3 0-1.8s-1.3-.5-1.8 0l-5.3 5.4z" fill="#3e3d40"/></svg>
\ No newline at end of file diff --git a/browser/extensions/screenshots/icons/cloud.svg b/browser/extensions/screenshots/icons/cloud.svg new file mode 100644 index 0000000000..51eed6b6ac --- /dev/null +++ b/browser/extensions/screenshots/icons/cloud.svg @@ -0,0 +1,4 @@ +<!-- 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/. --> +<svg width="20" height="18" xmlns="http://www.w3.org/2000/svg"><g fill="#FFF" fill-rule="evenodd"><path d="M15 5.6h-.3C14.5 2.7 12 .5 9.2.5c-3 0-5.4 2.4-5.5 5.3C1.5 6.4 0 8.3 0 10.6c0 2.8 2.2 5 5 5a1 1 0 0 0 1-1v-.1a1 1 0 0 0-1-1c-1.7 0-3-1.3-3-3 0-1.3.8-2.5 2.2-2.9l1.4-.4.1-1.4c.1-1.9 1.6-3.3 3.5-3.3 1.8 0 3.4 1.4 3.5 3.2l.1 1.8h2.1c1.7 0 3 1.3 3 3s-1.3 3-3 3h-1.85a1.05 1.05 0 1 0 0 2.1H15c2.8 0 5-2.2 5-5s-2.2-5-5-5z" fill-rule="nonzero"/><path d="M10 11.414V17c0 .667-.333 1-1 1s-1-.333-1-1v-5.586l-.293.293a1 1 0 1 1-1.414-1.414L9 7.586l2.707 2.707a1 1 0 0 1-1.414 1.414L10 11.414z"/></g></svg>
\ No newline at end of file diff --git a/browser/extensions/screenshots/icons/copied-notification.svg b/browser/extensions/screenshots/icons/copied-notification.svg new file mode 100644 index 0000000000..2310b41aef --- /dev/null +++ b/browser/extensions/screenshots/icons/copied-notification.svg @@ -0,0 +1,4 @@ +<!-- 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/. --> +<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48"><path fill="context-fill" d="M44.121 24.879l-9-9A3 3 0 0 0 33 15h-3v-3a3 3 0 0 0-.879-2.121l-9-9A3 3 0 0 0 18 0H9a6 6 0 0 0-6 6v21a6 6 0 0 0 6 6h9v9a6 6 0 0 0 6 6h15a6 6 0 0 0 6-6V27a3 3 0 0 0-.879-2.121zM37.758 27H33v-4.758zm-15-15H18V7.242zM18 21v6H9V6h6v7.5a1.5 1.5 0 0 0 1.5 1.5H24a6 6 0 0 0-6 6zm6 21V21h6v7.5a1.5 1.5 0 0 0 1.5 1.5H39v12z"/></svg>
\ No newline at end of file diff --git a/browser/extensions/screenshots/icons/copy.svg b/browser/extensions/screenshots/icons/copy.svg new file mode 100644 index 0000000000..d601e73b21 --- /dev/null +++ b/browser/extensions/screenshots/icons/copy.svg @@ -0,0 +1,4 @@ +<!-- 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/. --> +<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"><path fill="#3e3d40" d="M14.707 8.293l-3-3A1 1 0 0 0 11 5h-1V4a1 1 0 0 0-.293-.707l-3-3A1 1 0 0 0 6 0H3a2 2 0 0 0-2 2v7a2 2 0 0 0 2 2h3v3a2 2 0 0 0 2 2h5a2 2 0 0 0 2-2V9a1 1 0 0 0-.293-.707zM12.586 9H11V7.414zm-5-5H6V2.414zM6 7v2H3V2h2v2.5a.5.5 0 0 0 .5.5H8a2 2 0 0 0-2 2zm2 7V7h2v2.5a.5.5 0 0 0 .5.5H13v4z"/></svg>
\ No newline at end of file diff --git a/browser/extensions/screenshots/icons/download-white.svg b/browser/extensions/screenshots/icons/download-white.svg new file mode 100644 index 0000000000..bb6a7de845 --- /dev/null +++ b/browser/extensions/screenshots/icons/download-white.svg @@ -0,0 +1,4 @@ +<!-- 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/. --> +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"><path d="M9.1 12L4.9 7.9c-.5-.5-1.3-.5-1.8 0s-.5 1.3 0 1.8l6.2 6.2c.5.5 1.3.5 1.8 0l6.2-6.2c.5-.5.5-1.3 0-1.8s-1.3-.5-1.8 0L11.6 12V1.2C11.6.6 11 0 10.3 0c-.7 0-1.2.6-1.2 1.2V12zM4 20c-.7 0-1.2-.6-1.2-1.2s.6-1.2 1.2-1.2h12.5c.7 0 1.2.6 1.2 1.2s-.5 1.2-1.2 1.2H4z" fill="#fff"/></svg>
\ No newline at end of file diff --git a/browser/extensions/screenshots/icons/download.svg b/browser/extensions/screenshots/icons/download.svg new file mode 100644 index 0000000000..e3075a5ccd --- /dev/null +++ b/browser/extensions/screenshots/icons/download.svg @@ -0,0 +1,4 @@ +<!-- 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/. --> +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"><path d="M9.1 12L4.9 7.9c-.5-.5-1.3-.5-1.8 0s-.5 1.3 0 1.8l6.2 6.2c.5.5 1.3.5 1.8 0l6.2-6.2c.5-.5.5-1.3 0-1.8s-1.3-.5-1.8 0L11.6 12V1.2C11.6.6 11 0 10.3 0c-.7 0-1.2.6-1.2 1.2V12zM4 20c-.7 0-1.2-.6-1.2-1.2s.6-1.2 1.2-1.2h12.5c.7 0 1.2.6 1.2 1.2s-.5 1.2-1.2 1.2H4z" fill="#3e3d40"/></svg>
\ No newline at end of file diff --git a/browser/extensions/screenshots/icons/help-16.svg b/browser/extensions/screenshots/icons/help-16.svg new file mode 100644 index 0000000000..614d024e2a --- /dev/null +++ b/browser/extensions/screenshots/icons/help-16.svg @@ -0,0 +1,4 @@ +<!-- 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/. --> +<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"><path fill="rgba(249, 249, 250, .8)" d="M8 1a7 7 0 1 0 7 7 7.008 7.008 0 0 0-7-7zm0 13a6 6 0 1 1 6-6 6.007 6.007 0 0 1-6 6zM8 3.125A2.7 2.7 0 0 0 5.125 6a.875.875 0 0 0 1.75 0c0-1 .6-1.125 1.125-1.125a1.105 1.105 0 0 1 1.13.744.894.894 0 0 1-.53 1.016A2.738 2.738 0 0 0 7.125 9v.337a.875.875 0 0 0 1.75 0v-.37a1.041 1.041 0 0 1 .609-.824A2.637 2.637 0 0 0 10.82 5.16 2.838 2.838 0 0 0 8 3.125zm0 7.625A1.25 1.25 0 1 0 9.25 12 1.25 1.25 0 0 0 8 10.75z"/></svg>
\ No newline at end of file diff --git a/browser/extensions/screenshots/icons/icon-highlight-32-v2.svg b/browser/extensions/screenshots/icons/icon-highlight-32-v2.svg new file mode 100644 index 0000000000..ef226edf27 --- /dev/null +++ b/browser/extensions/screenshots/icons/icon-highlight-32-v2.svg @@ -0,0 +1,4 @@ +<!-- 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/. --> +<svg width="32" height="32" xmlns="http://www.w3.org/2000/svg"><path d="M8 2a4 4 0 0 0-4 4h4V2zm12 0h-4v4h4V2zm8 0v4h4a4 4 0 0 0-4-4zM14 2h-4v4h4V2zm12 0h-4v4h4V2zm2 10h4V8h-4v4zm0 12a4 4 0 0 0 4-4h-4v4zm0-6h4v-4h-4v4zm-.882-4.334a4 4 0 0 0-5.57-.984l-7.67 5.662-3.936-2.76c.031-.193.05-.388.058-.584a4.976 4.976 0 0 0-2-3.978V8H4v2.1a5 5 0 1 0 3.916 8.948l2.484 1.738-2.8 1.964a4.988 4.988 0 1 0 2.3 3.266l17.218-12.35zM5 17.5a2.5 2.5 0 1 1 0-5 2.5 2.5 0 0 1 0 5zm0 12a2.5 2.5 0 1 1 0-5 2.5 2.5 0 0 1 0 5zm10.8-4.858l6.034 4.6a4 4 0 0 0 5.57-.984L19.28 22.2l-3.48 2.442z" fill="#989898"/></svg>
\ No newline at end of file diff --git a/browser/extensions/screenshots/icons/icon-v2.svg b/browser/extensions/screenshots/icons/icon-v2.svg new file mode 100644 index 0000000000..92b1473b61 --- /dev/null +++ b/browser/extensions/screenshots/icons/icon-v2.svg @@ -0,0 +1,4 @@ +<!-- 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/. --> +<svg viewBox="0 0 32 32" width="32" height="32" xmlns="http://www.w3.org/2000/svg" fill="context-fill" fill-opacity="context-fill-opacity"><path d="M8 2a4 4 0 0 0-4 4h4V2zm12 0h-4v4h4V2zm8 0v4h4a4 4 0 0 0-4-4zM14 2h-4v4h4V2zm12 0h-4v4h4V2zm2 10h4V8h-4v4zm0 12a4 4 0 0 0 4-4h-4v4zm0-6h4v-4h-4v4zm-.882-4.334a4 4 0 0 0-5.57-.984l-7.67 5.662-3.936-2.76c.031-.193.05-.388.058-.584a4.976 4.976 0 0 0-2-3.978V8H4v2.1a5 5 0 1 0 3.916 8.948l2.484 1.738-2.8 1.964a4.988 4.988 0 1 0 2.3 3.266l17.218-12.35zM5 17.5a2.5 2.5 0 1 1 0-5 2.5 2.5 0 0 1 0 5zm0 12a2.5 2.5 0 1 1 0-5 2.5 2.5 0 0 1 0 5zm10.8-4.858l6.034 4.6a4 4 0 0 0 5.57-.984L19.28 22.2l-3.48 2.442z"/></svg> diff --git a/browser/extensions/screenshots/icons/icon-welcome-face-without-eyes.svg b/browser/extensions/screenshots/icons/icon-welcome-face-without-eyes.svg new file mode 100644 index 0000000000..138308af57 --- /dev/null +++ b/browser/extensions/screenshots/icons/icon-welcome-face-without-eyes.svg @@ -0,0 +1,4 @@ +<!-- 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/. --> +<svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><style>.st0{fill:#fff}</style><g id="Visual-design"><g id="_x31_.2-Div-selection" transform="translate(-575 -503)"><g id="Introduction" transform="translate(250 503)"><g id="icon-welcomeface" transform="translate(325)"><g id="Layer_1_1_"><path id="Shape" class="st0" d="M11.4.9v2.9h-6c-.9 0-1.5.8-1.5 1.5v6H.8V3.8C.8 2.1 2.2.7 3.9.7h7.6v.2h-.1z"/><path id="Shape_1_" class="st0" d="M63.2 11.4h-3.1v-6c0-.8-.6-1.5-1.5-1.5h-6v-3h7.6c1.7 0 3.1 1.4 3.1 3.1l-.1 7.4z"/><path id="Shape_2_" class="st0" d="M52.6 63.2v-3.1h6c.9 0 1.5-.6 1.5-1.5v-6h3.1v7.6c0 1.7-1.4 3.1-3.1 3.1l-7.5-.1z"/><path id="Shape_3_" class="st0" d="M.8 52.7h3.1v6c0 .9.6 1.5 1.5 1.5h6v3.1H3.8c-1.7 0-3.1-1.4-3.1-3.1l.1-7.5z"/><path id="Shape_6_" class="st0" d="M33.3 49.2H33c-4.6-.1-7.8-3.6-7.9-3.8-.6-.8-.6-2 .1-2.7.8-.8 1.9-.6 2.6.1 0 0 2.3 2.6 5.2 2.6 1.8 0 3.6-.9 5.2-2.6.8-.8 1.9-.8 2.7 0s.8 1.9 0 2.7c-2.2 2.4-4.9 3.7-7.6 3.7z"/></g></g></g></g></g></svg>
\ No newline at end of file diff --git a/browser/extensions/screenshots/icons/menu-fullpage.svg b/browser/extensions/screenshots/icons/menu-fullpage.svg new file mode 100644 index 0000000000..14f5c843d7 --- /dev/null +++ b/browser/extensions/screenshots/icons/menu-fullpage.svg @@ -0,0 +1,4 @@ +<!-- 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/. --> +<svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 46 46"><style>.st2{fill:#004c66}</style><path id="bg" d="M7 42h32V5.1H7z" fill="#00fdff"/><g id="frame" transform="translate(0 6)"><path d="M40 5c.5 0 1 .4 1 1v24c0 .5-.5 1-1 1H6c-.6 0-1-.5-1-1V6c0-.6.4-1 1-1h34zM7 29h32V7H7v22z" fill="#3e3d40"/><path id="Fill-4" class="st2" d="M7 7h32V5H7z"/><path id="Fill-6" class="st2" d="M7 31h32v-2H7z"/></g><path id="dash" d="M38 11h1V9h-1v2zm0 3h1v-2h-1v2zm0 3h1v-2h-1v2zm0 3h1v-2h-1v2zm0 3h1v-2h-1v2zm0 3h1v-2h-1v2zm0 3h1v-2h-1v2zm0 3h1v-2h-1v2zm0 3h1v-2h-1v2zm0 3h1v-2h-1v2zm0 3h1v-2h-1v2zm-1 1h2v-1h-2v1zm-3 0h2v-1h-2v1zm-3 0h2v-1h-2v1zm-3 0h2v-1h-2v1zm-3 0h2v-1h-2v1zm-3 0h2v-1h-2v1zm-3 0h2v-1h-2v1zm-3 0h2v-1h-2v1zm-3 0h2v-1h-2v1zm-3 0h2v-1h-2v1zm-2-3H7v3h2v-1H8v-2zm-1-1h1v-2H7v2zm0-3h1v-2H7v2zm0-3h1v-2H7v2zm0-3h1v-2H7v2zm0-3h1v-2H7v2zm0-3h1v-2H7v2zm0-3h1v-2H7v2zm0-3h1v-2H7v2zm0-3h1v-2H7v2zm0-3h1V9H7v2zm2-6H7v3h1V6h1V5zm1 1h2V5h-2v1zm3 0h2V5h-2v1zm3 0h2V5h-2v1zm3 0h2V5h-2v1zm3 0h2V5h-2v1zm3 0h2V5h-2v1zm3 0h2V5h-2v1zm3 0h2V5h-2v1zm3 0h2V5h-2v1zm5-1h-2v1h1v2h1V5z" fill="#00d1e6"/></svg>
\ No newline at end of file diff --git a/browser/extensions/screenshots/icons/menu-myshot-white.svg b/browser/extensions/screenshots/icons/menu-myshot-white.svg new file mode 100644 index 0000000000..ffd61d8bfc --- /dev/null +++ b/browser/extensions/screenshots/icons/menu-myshot-white.svg @@ -0,0 +1,4 @@ +<!-- 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/. --> +<svg width="40" height="40" viewBox="0 0 46 46" xmlns="http://www.w3.org/2000/svg"><path d="M11 11.995a1 1 0 0 1 .995-.995h23.01a1 1 0 0 1 .995.995v23.01a1 1 0 0 1-.995.995h-23.01a1 1 0 0 1-.995-.995v-23.01zM11 25v-2h7v2h-7zm9-5h7v-2h-7v2zm9 5h7v-2h-7v2zm-9 4h7v-2h-7v2zm-2-18h2v25h-2V11zm9 0h2v25h-2V11z" fill="#FFF" fill-rule="evenodd"/></svg>
\ No newline at end of file diff --git a/browser/extensions/screenshots/icons/menu-myshot.svg b/browser/extensions/screenshots/icons/menu-myshot.svg new file mode 100644 index 0000000000..f442fdfb8d --- /dev/null +++ b/browser/extensions/screenshots/icons/menu-myshot.svg @@ -0,0 +1,4 @@ +<!-- 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/. --> +<svg width="46" height="46" xmlns="http://www.w3.org/2000/svg"><path d="M11 11.995a1 1 0 0 1 .995-.995h23.01a1 1 0 0 1 .995.995v23.01a1 1 0 0 1-.995.995h-23.01a1 1 0 0 1-.995-.995v-23.01zM11 25v-2h7v2h-7zm9-5h7v-2h-7v2zm9 5h7v-2h-7v2zm-9 4h7v-2h-7v2zm-2-18h2v25h-2V11zm9 0h2v25h-2V11z" fill="#3E3D40" fill-rule="evenodd"/></svg>
\ No newline at end of file diff --git a/browser/extensions/screenshots/icons/menu-visible.svg b/browser/extensions/screenshots/icons/menu-visible.svg new file mode 100644 index 0000000000..4322579943 --- /dev/null +++ b/browser/extensions/screenshots/icons/menu-visible.svg @@ -0,0 +1,4 @@ +<!-- 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/. --> +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 46 46"><path d="M5 12c0-.6.5-1 1-1h34c.6 0 1 .5 1 1v24c0 .6-.5 1-1 1H6c-.6 0-1-.5-1-1V12zm2 23V13h32v22H7z" fill="#3e3d40"/><path d="M7 35h32V13H7z" fill="#00fdff"/><path d="M38 19h1v-2h-1v2zm0 3h1v-2h-1v2zm0 3h1v-2h-1v2zm0 3h1v-2h-1v2zm0 3h1v-2h-1v2zm0 3h1v-2h-1v2zm-1 1h2v-1h-2v1zm-3 0h2v-1h-2v1zm-3 0h2v-1h-2v1zm-3 0h2v-1h-2v1zm-3 0h2v-1h-2v1zm-3 0h2v-1h-2v1zm-3 0h2v-1h-2v1zm-3 0h2v-1h-2v1zm-3 0h2v-1h-2v1zm-3 0h2v-1h-2v1zm-2-3H7v3h2v-1H8v-2zm-1-1h1v-2H7v2zm0-3h1v-2H7v2zm0-3h1v-2H7v2zm0-3h1v-2H7v2zm0-3h1v-2H7v2zm2-6H7v3h1v-2h1v-1zm1 1h2v-1h-2v1zm3 0h2v-1h-2v1zm3 0h2v-1h-2v1zm3 0h2v-1h-2v1zm3 0h2v-1h-2v1zm3 0h2v-1h-2v1zm3 0h2v-1h-2v1zm3 0h2v-1h-2v1zm3 0h2v-1h-2v1zm5-1h-2v1h1v2h1v-3z" fill="#00d1e6"/></svg>
\ No newline at end of file diff --git a/browser/extensions/screenshots/log.js b/browser/extensions/screenshots/log.js new file mode 100644 index 0000000000..96be7e57ed --- /dev/null +++ b/browser/extensions/screenshots/log.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/. */ + +/* globals buildSettings */ +/* eslint-disable no-console */ + +"use strict"; + +this.log = (function() { + const exports = {}; + + const levels = ["debug", "info", "warn", "error"]; + if (!levels.includes(buildSettings.logLevel)) { + console.warn("Invalid buildSettings.logLevel:", buildSettings.logLevel); + } + const shouldLog = {}; + + { + let startLogging = false; + for (const level of levels) { + if (buildSettings.logLevel === level) { + startLogging = true; + } + if (startLogging) { + shouldLog[level] = true; + } + } + } + + function stub() {} + exports.debug = exports.info = exports.warn = exports.error = stub; + + if (shouldLog.debug) { + exports.debug = console.debug; + } + + if (shouldLog.info) { + exports.info = console.info; + } + + if (shouldLog.warn) { + exports.warn = console.warn; + } + + if (shouldLog.error) { + exports.error = console.error; + } + + return exports; +})(); +null; diff --git a/browser/extensions/screenshots/makeUuid.js b/browser/extensions/screenshots/makeUuid.js new file mode 100644 index 0000000000..7ae77299e4 --- /dev/null +++ b/browser/extensions/screenshots/makeUuid.js @@ -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/. */ + +"use strict"; + +this.makeUuid = (function() { + + // generates a v4 UUID + return function makeUuid() { // eslint-disable-line no-unused-vars + // get sixteen unsigned 8 bit random values + const randomValues = window + .crypto + .getRandomValues(new Uint8Array(36)); + + return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, function(c) { + const i = Array.prototype.slice.call(arguments).slice(-2)[0]; // grab the `offset` parameter + const r = randomValues[i] % 16|0, v = c === "x" ? r : (r & 0x3 | 0x8); + return v.toString(16); + }); + }; +})(); +null; diff --git a/browser/extensions/screenshots/manifest.json b/browser/extensions/screenshots/manifest.json new file mode 100644 index 0000000000..782a244c46 --- /dev/null +++ b/browser/extensions/screenshots/manifest.json @@ -0,0 +1,91 @@ +{ + "manifest_version": 2, + "name": "Firefox Screenshots", + "version": "39.0.0", + "description": "Take clips and screenshots from the Web and save them temporarily or permanently.", + "author": "Mozilla <screenshots-feedback@mozilla.com>", + "homepage_url": "https://github.com/mozilla-services/screenshots", + "incognito": "spanning", + "applications": { + "gecko": { + "id": "screenshots@mozilla.org", + "strict_min_version": "57.0a1" + } + }, + "l10n_resources": [ "browser/screenshots.ftl" ], + "background": { + "scripts": [ + "build/buildSettings.js", + "background/startBackground.js" + ] + }, + "commands": { + "take-screenshot": { + "suggested_key": { + "default": "Ctrl+Shift+S" + }, + "description": "Open the Firefox Screenshots UI" + } + }, + "content_scripts": [ + { + "matches": ["https://screenshots.firefox.com/*"], + "js": [ + "build/buildSettings.js", + "log.js", + "catcher.js", + "selector/callBackground.js", + "sitehelper.js" + ] + } + ], + "page_action": { + "browser_style": true, + "default_icon" : { + "32": "icons/icon-v2.svg" + }, + "default_title": "__MSG_screenshots-context-menu__", + "show_matches": ["<all_urls>", "about:reader*"], + "pinned": false + }, + "icons": { + "32": "icons/icon-v2.svg" + }, + "web_accessible_resources": [ + "blank.html", + "icons/cancel.svg", + "icons/download.svg", + "icons/copy.svg", + "icons/icon-256.png", + "icons/help-16.svg", + "icons/menu-fullpage.svg", + "icons/menu-visible.svg", + "icons/menu-myshot.svg", + "icons/icon-welcome-face-without-eyes.svg" + ], + "permissions": [ + "activeTab", + "downloads", + "tabs", + "storage", + "notifications", + "clipboardWrite", + "contextMenus", + "mozillaAddons", + "telemetry", + "<all_urls>", + "https://screenshots.firefox.com/", + "resource://pdf.js/", + "about:reader*" + ], + "experiment_apis": { + "screenshots": { + "schema": "experiments/screenshots/schema.json", + "parent": { + "scopes": ["addon_parent"], + "script": "experiments/screenshots/api.js", + "paths": [["experiments", "screenshots"]] + } + } + } +} diff --git a/browser/extensions/screenshots/moz.build b/browser/extensions/screenshots/moz.build new file mode 100644 index 0000000000..3a1b161357 --- /dev/null +++ b/browser/extensions/screenshots/moz.build @@ -0,0 +1,81 @@ +# -*- 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 = ("Firefox", "Screenshots") + +# This file list is automatically generated by Screenshots' export scripts. +# AUTOMATIC INSERTION START +FINAL_TARGET_FILES.features["screenshots@mozilla.org"] += [ + "assertIsBlankDocument.js", + "assertIsTrusted.js", + "blank.html", + "blobConverters.js", + "catcher.js", + "clipboard.js", + "domainFromUrl.js", + "log.js", + "makeUuid.js", + "manifest.json", + "moz.build", + "randomString.js", + "sitehelper.js", +] + +FINAL_TARGET_FILES.features["screenshots@mozilla.org"]["background"] += [ + "background/analytics.js", + "background/auth.js", + "background/communication.js", + "background/deviceInfo.js", + "background/main.js", + "background/selectorLoader.js", + "background/senderror.js", + "background/startBackground.js", + "background/takeshot.js", +] + +FINAL_TARGET_FILES.features["screenshots@mozilla.org"]["build"] += [ + "build/buildSettings.js", + "build/inlineSelectionCss.js", + "build/raven.js", + "build/selection.js", + "build/shot.js", + "build/thumbnailGenerator.js", +] + +FINAL_TARGET_FILES.features["screenshots@mozilla.org"]["experiments"][ + "screenshots" +] += ["experiments/screenshots/api.js", "experiments/screenshots/schema.json"] + +FINAL_TARGET_FILES.features["screenshots@mozilla.org"]["icons"] += [ + "icons/cancel.svg", + "icons/cloud.svg", + "icons/copied-notification.svg", + "icons/copy.svg", + "icons/download-white.svg", + "icons/download.svg", + "icons/help-16.svg", + "icons/icon-highlight-32-v2.svg", + "icons/icon-v2.svg", + "icons/icon-welcome-face-without-eyes.svg", + "icons/menu-fullpage.svg", + "icons/menu-myshot-white.svg", + "icons/menu-myshot.svg", + "icons/menu-visible.svg", +] + +FINAL_TARGET_FILES.features["screenshots@mozilla.org"]["selector"] += [ + "selector/callBackground.js", + "selector/documentMetadata.js", + "selector/shooter.js", + "selector/ui.js", + "selector/uicontrol.js", + "selector/util.js", +] + +# AUTOMATIC INSERTION END + +BROWSER_CHROME_MANIFESTS += ["test/browser/browser.ini"] diff --git a/browser/extensions/screenshots/randomString.js b/browser/extensions/screenshots/randomString.js new file mode 100644 index 0000000000..ee016573d5 --- /dev/null +++ b/browser/extensions/screenshots/randomString.js @@ -0,0 +1,18 @@ +/* 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/. */ + +/* exported randomString */ + +"use strict"; + +this.randomString = function randomString(length, chars) { + const randomStringChars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; + chars = chars || randomStringChars; + let result = ""; + for (let i = 0; i < length; i++) { + result += chars[Math.floor(Math.random() * chars.length)]; + } + return result; +}; +null; diff --git a/browser/extensions/screenshots/selector/callBackground.js b/browser/extensions/screenshots/selector/callBackground.js new file mode 100644 index 0000000000..3fb8734c38 --- /dev/null +++ b/browser/extensions/screenshots/selector/callBackground.js @@ -0,0 +1,31 @@ +/* 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/. */ + +/* globals log */ + +"use strict"; + +this.callBackground = function callBackground(funcName, ...args) { + return browser.runtime.sendMessage({funcName, args}).then((result) => { + if (result && result.type === "success") { + return result.value; + } else if (result && result.type === "error") { + const exc = new Error(result.message || "Unknown error"); + exc.name = "BackgroundError"; + if ("errorCode" in result) { + exc.errorCode = result.errorCode; + } + if ("popupMessage" in result) { + exc.popupMessage = result.popupMessage; + } + throw exc; + } else { + log.error("Unexpected background result:", result); + const exc = new Error(`Bad response type from background page: ${result && result.type || undefined}`); + exc.resultType = result ? (result.type || "undefined") : "undefined result"; + throw exc; + } + }); +}; +null; diff --git a/browser/extensions/screenshots/selector/documentMetadata.js b/browser/extensions/screenshots/selector/documentMetadata.js new file mode 100644 index 0000000000..ceb532634a --- /dev/null +++ b/browser/extensions/screenshots/selector/documentMetadata.js @@ -0,0 +1,91 @@ +/* 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"; + +this.documentMetadata = (function() { + + function findSiteName() { + let el = document.querySelector("meta[property~='og:site_name'][content]"); + if (el) { + return el.getAttribute("content"); + } + // nytimes.com uses this property: + el = document.querySelector("meta[name='cre'][content]"); + if (el) { + return el.getAttribute("content"); + } + return null; + } + + function getOpenGraph() { + const openGraph = {}; + // If you update this, also update _OPENGRAPH_PROPERTIES in shot.js: + const forceSingle = `title type url`.split(" "); + const openGraphProperties = ` + title type url image audio description determiner locale site_name video + image:secure_url image:type image:width image:height + video:secure_url video:type video:width image:height + audio:secure_url audio:type + article:published_time article:modified_time article:expiration_time article:author article:section article:tag + book:author book:isbn book:release_date book:tag + profile:first_name profile:last_name profile:username profile:gender + `.split(/\s+/g); + for (const prop of openGraphProperties) { + let elems = document.querySelectorAll(`meta[property~='og:${prop}'][content]`); + if (forceSingle.includes(prop) && elems.length > 1) { + elems = [elems[0]]; + } + let value; + if (elems.length > 1) { + value = []; + for (const elem of elems) { + const v = elem.getAttribute("content"); + if (v) { + value.push(v); + } + } + if (!value.length) { + value = null; + } + } else if (elems.length === 1) { + value = elems[0].getAttribute("content"); + } + if (value) { + openGraph[prop] = value; + } + } + return openGraph; + } + + function getTwitterCard() { + const twitterCard = {}; + // If you update this, also update _TWITTERCARD_PROPERTIES in shot.js: + const properties = ` + card site title description image + player player:width player:height player:stream player:stream:content_type + `.split(/\s+/g); + for (const prop of properties) { + const elem = document.querySelector(`meta[name='twitter:${prop}'][content]`); + if (elem) { + const value = elem.getAttribute("content"); + if (value) { + twitterCard[prop] = value; + } + } + } + return twitterCard; + } + + return function documentMetadata() { + const result = {}; + result.docTitle = document.title; + result.siteName = findSiteName(); + result.openGraph = getOpenGraph(); + result.twitterCard = getTwitterCard(); + return result; + }; + +})(); +null; diff --git a/browser/extensions/screenshots/selector/shooter.js b/browser/extensions/screenshots/selector/shooter.js new file mode 100644 index 0000000000..d1a4a34e9e --- /dev/null +++ b/browser/extensions/screenshots/selector/shooter.js @@ -0,0 +1,147 @@ +/* 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/. */ + +/* globals global, documentMetadata, util, uicontrol, ui, catcher */ +/* globals buildSettings, domainFromUrl, randomString, shot, blobConverters */ + +"use strict"; + +this.shooter = (function() { // eslint-disable-line no-unused-vars + const exports = {}; + const { AbstractShot } = shot; + + const RANDOM_STRING_LENGTH = 16; + const MAX_CANVAS_DIMENSION = 32767; + let backend; + let shotObject; + const callBackground = global.callBackground; + const clipboard = global.clipboard; + + function regexpEscape(str) { + // http://stackoverflow.com/questions/3115150/how-to-escape-regular-expression-special-characters-using-javascript + return str.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, "\\$&"); + } + + function sanitizeError(data) { + const href = new RegExp(regexpEscape(window.location.href), "g"); + const origin = new RegExp(`${regexpEscape(window.location.origin)}[^ \t\n\r",>]*`, "g"); + const json = JSON.stringify(data) + .replace(href, "REDACTED_HREF") + .replace(origin, "REDACTED_URL"); + const result = JSON.parse(json); + return result; + } + + catcher.registerHandler((errorObj) => { + callBackground("reportError", sanitizeError(errorObj)); + }); + + function hideUIFrame() { + ui.iframe.hide(); + return Promise.resolve(null); + } + + function screenshotPage(dataUrl, selectedPos, type, screenshotTaskFn) { + let promise = Promise.resolve(dataUrl); + + if (!dataUrl) { + let isFullPage = type === "fullPage" || type == "fullPageTruncated"; + promise = callBackground( + "screenshotPage", + selectedPos.toJSON(), + isFullPage, + window.devicePixelRatio); + } + + catcher.watchPromise(promise.then((dataUrl) => { + screenshotTaskFn(dataUrl); + })); + } + + exports.downloadShot = function(selectedPos, previewDataUrl, type) { + const shotPromise = previewDataUrl ? Promise.resolve(previewDataUrl) : hideUIFrame(); + catcher.watchPromise(shotPromise.then(dataUrl => { + screenshotPage(dataUrl, selectedPos, type, url => { + let type = blobConverters.getTypeFromDataUrl(url); + type = type ? type.split("/", 2)[1] : null; + shotObject.delAllClips(); + shotObject.addClip({ + createdDate: Date.now(), + image: { + url, + type, + location: selectedPos, + }, + }); + ui.triggerDownload(url, shotObject.filename); + uicontrol.deactivate(); + }); + })); + }; + + exports.preview = function(selectedPos, type) { + catcher.watchPromise(hideUIFrame().then(dataUrl => { + screenshotPage(dataUrl, selectedPos, type, url => { + ui.iframe.usePreview(); + ui.Preview.display(url); + }); + })); + }; + + let copyInProgress = null; + exports.copyShot = function(selectedPos, previewDataUrl, type) { + // This is pretty slow. We'll ignore additional user triggered copy events + // while it is in progress. + if (copyInProgress) { + return; + } + // A max of five seconds in case some error occurs. + copyInProgress = setTimeout(() => { + copyInProgress = null; + }, 5000); + + const unsetCopyInProgress = () => { + if (copyInProgress) { + clearTimeout(copyInProgress); + copyInProgress = null; + } + }; + const shotPromise = previewDataUrl ? Promise.resolve(previewDataUrl) : hideUIFrame(); + catcher.watchPromise(shotPromise.then(dataUrl => { + screenshotPage(dataUrl, selectedPos, type, url => { + const blob = blobConverters.dataUrlToBlob(url); + catcher.watchPromise(callBackground("copyShotToClipboard", blob).then(() => { + uicontrol.deactivate(); + unsetCopyInProgress(); + }, unsetCopyInProgress)); + }); + })); + }; + + exports.sendEvent = function(...args) { + const maybeOptions = args[args.length - 1]; + + if (typeof maybeOptions === "object") { + maybeOptions.incognito = browser.extension.inIncognitoContext; + } else { + args.push({incognito: browser.extension.inIncognitoContext}); + } + + callBackground("sendEvent", ...args); + }; + + catcher.watchFunction(() => { + shotObject = new AbstractShot( + backend, + randomString(RANDOM_STRING_LENGTH) + "/" + domainFromUrl(location), + { + origin: shot.originFromUrl(location.href), + } + ); + shotObject.update(documentMetadata()); + })(); + + return exports; +})(); +null; diff --git a/browser/extensions/screenshots/selector/ui.js b/browser/extensions/screenshots/selector/ui.js new file mode 100644 index 0000000000..8a92c80470 --- /dev/null +++ b/browser/extensions/screenshots/selector/ui.js @@ -0,0 +1,828 @@ +/* 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/. */ + +/* globals log, util, catcher, inlineSelectionCss, callBackground, assertIsTrusted, assertIsBlankDocument, buildSettings blobConverters */ + +"use strict"; + +this.ui = (function() { // eslint-disable-line no-unused-vars + const exports = {}; + const SAVE_BUTTON_HEIGHT = 50; + + const { watchFunction } = catcher; + + exports.isHeader = function(el) { + while (el) { + if (el.classList && + (el.classList.contains("myshots-button") || + el.classList.contains("visible") || + el.classList.contains("full-page") || + el.classList.contains("cancel-shot"))) { + return true; + } + el = el.parentNode; + } + return false; + }; + + const substitutedCss = inlineSelectionCss.replace(/MOZ_EXTENSION([^"]+)/g, (match, filename) => { + return browser.extension.getURL(filename); + }); + + function makeEl(tagName, className) { + if (!iframe.document()) { + throw new Error("Attempted makeEl before iframe was initialized"); + } + const el = iframe.document().createElement(tagName); + if (className) { + el.className = className; + } + return el; + } + + function onResize() { + if (this.sizeTracking.windowDelayer) { + clearTimeout(this.sizeTracking.windowDelayer); + } + this.sizeTracking.windowDelayer = setTimeout(watchFunction(() => { + this.updateElementSize(true); + }), 50); + } + + function highContrastCheck(win) { + const doc = win.document; + const el = doc.createElement("div"); + el.style.backgroundImage = "url('#')"; + el.style.display = "none"; + doc.body.appendChild(el); + const computed = win.getComputedStyle(el); + doc.body.removeChild(el); + // When Windows is in High Contrast mode, Firefox replaces background + // image URLs with the string "none". + return (computed && computed.backgroundImage === "none"); + } + + const showMyShots = exports.showMyShots = function() { + return window.hasAnyShots; + }; + + function initializeIframe() { + const el = document.createElement("iframe"); + el.src = browser.extension.getURL("blank.html"); + el.style.zIndex = "99999999999"; + el.style.border = "none"; + el.style.top = "0"; + el.style.left = "0"; + el.style.margin = "0"; + el.scrolling = "no"; + el.style.clip = "auto"; + return el; + } + + const iframeSelection = exports.iframeSelection = { + element: null, + addClassName: "", + sizeTracking: { + timer: null, + windowDelayer: null, + lastHeight: null, + lastWidth: null, + }, + document: null, + window: null, + display(installHandlerOnDocument) { + return new Promise((resolve, reject) => { + if (!this.element) { + this.element = initializeIframe(); + this.element.id = "firefox-screenshots-selection-iframe"; + this.element.style.display = "none"; + this.element.style.setProperty("position", "absolute", "important"); + this.element.style.setProperty("background-color", "transparent"); + this.element.setAttribute("role", "dialog"); + this.updateElementSize(); + this.element.addEventListener("load", watchFunction(() => { + this.document = this.element.contentDocument; + this.window = this.element.contentWindow; + assertIsBlankDocument(this.document); + // eslint-disable-next-line no-unsanitized/property + this.document.documentElement.innerHTML = ` + <head> + <style>${substitutedCss}</style> + <title></title> + </head> + <body></body>`; + installHandlerOnDocument(this.document); + if (this.addClassName) { + this.document.body.className = this.addClassName; + } + this.document.documentElement.dir = browser.i18n.getMessage("@@bidi_dir"); + this.document.documentElement.lang = browser.i18n.getMessage("@@ui_locale"); + resolve(); + }), {once: true}); + document.body.appendChild(this.element); + } else { + resolve(); + } + }); + }, + + hide() { + this.element.style.display = "none"; + this.stopSizeWatch(); + }, + + unhide() { + this.updateElementSize(); + this.element.style.display = "block"; + catcher.watchPromise(callBackground("sendEvent", "internal", "unhide-selection-frame")); + if (highContrastCheck(this.element.contentWindow)) { + this.element.contentDocument.body.classList.add("hcm"); + } + this.initSizeWatch(); + this.element.focus(); + }, + + updateElementSize(force) { + // Note: if someone sizes down the page, then the iframe will keep the + // document from naturally shrinking. We use force to temporarily hide + // the element so that we can tell if the document shrinks + const visible = this.element.style.display !== "none"; + if (force && visible) { + this.element.style.display = "none"; + } + const height = Math.max( + document.documentElement.clientHeight, + document.body.clientHeight, + document.documentElement.scrollHeight, + document.body.scrollHeight); + if (height !== this.sizeTracking.lastHeight) { + this.sizeTracking.lastHeight = height; + this.element.style.height = height + "px"; + } + // Do not use window.innerWidth since that includes the width of the + // scroll bar. + const width = Math.max( + document.documentElement.clientWidth, + document.body.clientWidth, + document.documentElement.scrollWidth, + document.body.scrollWidth); + if (width !== this.sizeTracking.lastWidth) { + this.sizeTracking.lastWidth = width; + this.element.style.width = width + "px"; + // Since this frame has an absolute position relative to the parent + // document, if the parent document's body has a relative position and + // left and/or top not at 0, then the left and/or top of the parent + // document's body is not at (0, 0) of the viewport. That makes the + // frame shifted relative to the viewport. These margins negates that. + if (window.getComputedStyle(document.body).position === "relative") { + const docBoundingRect = document.documentElement.getBoundingClientRect(); + const bodyBoundingRect = document.body.getBoundingClientRect(); + this.element.style.marginLeft = `-${bodyBoundingRect.left - docBoundingRect.left}px`; + this.element.style.marginTop = `-${bodyBoundingRect.top - docBoundingRect.top}px`; + } + } + if (force && visible) { + this.element.style.display = "block"; + } + }, + + initSizeWatch() { + this.stopSizeWatch(); + this.sizeTracking.timer = setInterval(watchFunction(this.updateElementSize.bind(this)), 2000); + window.addEventListener("resize", this.onResize, true); + }, + + stopSizeWatch() { + if (this.sizeTracking.timer) { + clearTimeout(this.sizeTracking.timer); + this.sizeTracking.timer = null; + } + if (this.sizeTracking.windowDelayer) { + clearTimeout(this.sizeTracking.windowDelayer); + this.sizeTracking.windowDelayer = null; + } + this.sizeTracking.lastHeight = this.sizeTracking.lastWidth = null; + window.removeEventListener("resize", this.onResize, true); + }, + + getElementFromPoint(x, y) { + this.element.style.pointerEvents = "none"; + let el; + try { + el = document.elementFromPoint(x, y); + } finally { + this.element.style.pointerEvents = ""; + } + return el; + }, + + remove() { + this.stopSizeWatch(); + util.removeNode(this.element); + this.element = this.document = this.window = null; + }, + }; + + iframeSelection.onResize = watchFunction(assertIsTrusted(onResize.bind(iframeSelection)), true); + + const iframePreSelection = exports.iframePreSelection = { + element: null, + document: null, + window: null, + display(installHandlerOnDocument, standardOverlayCallbacks) { + return new Promise((resolve, reject) => { + if (!this.element) { + this.element = initializeIframe(); + this.element.id = "firefox-screenshots-preselection-iframe"; + this.element.style.setProperty("position", "fixed", "important"); + this.element.style.setProperty("background-color", "transparent"); + this.element.style.width = "100%"; + this.element.style.height = "100%"; + this.element.setAttribute("role", "dialog"); + this.element.addEventListener("load", watchFunction(() => { + this.document = this.element.contentDocument; + this.window = this.element.contentWindow; + assertIsBlankDocument(this.document); + // eslint-disable-next-line no-unsanitized/property + this.document.documentElement.innerHTML = ` + <head> + <link rel="localization" href="browser/screenshots.ftl"> + <style>${substitutedCss}</style> + <title></title> + </head> + <body> + <div class="preview-overlay precision-cursor"> + <div class="fixed-container"> + <div class="face-container"> + <div class="eye left"><div class="eyeball"></div></div> + <div class="eye right"><div class="eyeball"></div></div> + <div class="face"></div> + </div> + <div class="preview-instructions" data-l10n-id="screenshots-instructions"></div> + <button class="cancel-shot" data-l10n-id="screenshots-cancel-button"></button> + <div class="myshots-all-buttons-container"> + ${showMyShots() ? ` + <button class="myshots-button" tabindex="3" data-l10n-id="screenshots-my-shots-button"></button> + <div class="spacer"></div> + ` : ""} + <button class="visible" tabindex="2" data-l10n-id="screenshots-save-visible-button"></button> + <button class="full-page" tabindex="1" data-l10n-id="screenshots-save-page-button"></button> + </div> + </div> + </div> + </body>`; + installHandlerOnDocument(this.document); + if (this.addClassName) { + this.document.body.className = this.addClassName; + } + this.document.documentElement.dir = browser.i18n.getMessage("@@bidi_dir"); + this.document.documentElement.lang = browser.i18n.getMessage("@@ui_locale"); + const overlay = this.document.querySelector(".preview-overlay"); + if (showMyShots()) { + overlay.querySelector(".myshots-button").addEventListener( + "click", watchFunction(assertIsTrusted(standardOverlayCallbacks.onOpenMyShots))); + } + overlay.querySelector(".visible").addEventListener( + "click", watchFunction(assertIsTrusted(standardOverlayCallbacks.onClickVisible))); + overlay.querySelector(".full-page").addEventListener( + "click", watchFunction(assertIsTrusted(standardOverlayCallbacks.onClickFullPage))); + overlay.querySelector(".cancel-shot").addEventListener( + "click", watchFunction(assertIsTrusted(standardOverlayCallbacks.onClickCancel))); + + resolve(); + }), {once: true}); + document.body.appendChild(this.element); + } else { + resolve(); + } + }); + }, + + hide() { + window.removeEventListener("scroll", watchFunction(assertIsTrusted(this.onScroll))); + window.removeEventListener("resize", this.onResize, true); + if (this.element) { + this.element.style.display = "none"; + } + }, + + unhide() { + window.addEventListener("scroll", watchFunction(assertIsTrusted(this.onScroll))); + window.addEventListener("resize", this.onResize, true); + this.element.style.display = "block"; + catcher.watchPromise(callBackground("sendEvent", "internal", "unhide-preselection-frame")); + if (highContrastCheck(this.element.contentWindow)) { + this.element.contentDocument.body.classList.add("hcm"); + } + this.element.focus(); + }, + + onScroll() { + exports.HoverBox.hide(); + }, + + getElementFromPoint(x, y) { + this.element.style.pointerEvents = "none"; + let el; + try { + el = document.elementFromPoint(x, y); + } finally { + this.element.style.pointerEvents = ""; + } + return el; + }, + + remove() { + this.hide(); + util.removeNode(this.element); + this.element = this.document = this.window = null; + }, + }; + + let msgsPromise = callBackground("getStrings", [ + "screenshots-cancel-button", + "screenshots-copy-button-tooltip", + "screenshots-download-button-tooltip", + "screenshots-copy-button", + "screenshots-download-button", + ]); + + const iframePreview = exports.iframePreview = { + element: null, + document: null, + window: null, + display(installHandlerOnDocument, standardOverlayCallbacks) { + return new Promise((resolve, reject) => { + if (!this.element) { + this.element = initializeIframe(); + this.element.id = "firefox-screenshots-preview-iframe"; + this.element.style.display = "none"; + this.element.style.setProperty("position", "fixed", "important"); + this.element.style.setProperty("background-color", "transparent"); + this.element.style.height = "100%"; + this.element.style.width = "100%"; + this.element.setAttribute("role", "dialog"); + this.element.onload = watchFunction(() => { + msgsPromise.then(([cancelTitle, copyTitle, downloadTitle]) => { + assertIsBlankDocument(this.element.contentDocument); + this.document = this.element.contentDocument; + this.window = this.element.contentWindow; + // eslint-disable-next-line no-unsanitized/property + this.document.documentElement.innerHTML = ` + <head> + <link rel="localization" href="browser/screenshots.ftl"> + <style>${substitutedCss}</style> + <title></title> + </head> + <body> + <div class="preview-overlay"> + <div class="preview-image"> + <div class="preview-buttons"> + <button class="highlight-button-cancel" title="${cancelTitle}"> + <img src="${browser.extension.getURL("icons/cancel.svg")}" /> + </button> + <button class="highlight-button-copy" title="${copyTitle}"> + <img src="${browser.extension.getURL("icons/copy.svg")}" /> + <span data-l10n-id="screenshots-copy-button"/> + </button> + <button class="highlight-button-download" title="${downloadTitle}"> + <img src="${browser.extension.getURL("icons/download-white.svg")}" /> + <span data-l10n-id="screenshots-download-button"/> + </button> + </div> + <div class="preview-image-wrapper"></div> + </div> + <div class="loader" style="display:none"> + <div class="loader-inner"></div> + </div> + </div> + </body>`; + + installHandlerOnDocument(this.document); + this.document.documentElement.dir = browser.i18n.getMessage("@@bidi_dir"); + this.document.documentElement.lang = browser.i18n.getMessage("@@ui_locale"); + + const overlay = this.document.querySelector(".preview-overlay"); + overlay.querySelector(".highlight-button-copy").addEventListener( + "click", watchFunction(assertIsTrusted(standardOverlayCallbacks.onCopyPreview))); + overlay.querySelector(".highlight-button-download").addEventListener( + "click", watchFunction(assertIsTrusted(standardOverlayCallbacks.onDownloadPreview))); + overlay.querySelector(".highlight-button-cancel").addEventListener( + "click", watchFunction(assertIsTrusted(standardOverlayCallbacks.cancel))); + resolve(); + }); + }); + document.body.appendChild(this.element); + } else { + resolve(); + } + }); + }, + + hide() { + if (this.element) { + this.element.style.display = "none"; + } + }, + + unhide() { + this.element.style.display = "block"; + catcher.watchPromise(callBackground("sendEvent", "internal", "unhide-preview-frame")); + this.element.focus(); + }, + + showLoader() { + this.document.body.querySelector(".preview-image").style.display = "none"; + this.document.body.querySelector(".loader").style.display = ""; + }, + + remove() { + this.hide(); + util.removeNode(this.element); + this.element = null; + this.document = null; + }, + }; + + iframePreSelection.onResize = watchFunction(onResize.bind(iframePreSelection), true); + + const iframe = exports.iframe = { + currentIframe: iframePreSelection, + display(installHandlerOnDocument, standardOverlayCallbacks) { + return iframeSelection.display(installHandlerOnDocument) + .then(() => iframePreSelection.display(installHandlerOnDocument, standardOverlayCallbacks)) + .then(() => iframePreview.display(installHandlerOnDocument, standardOverlayCallbacks)); + }, + + hide() { + this.currentIframe.hide(); + }, + + unhide() { + this.currentIframe.unhide(); + }, + + showLoader() { + if (this.currentIframe.showLoader) { + this.currentIframe.showLoader(); + this.currentIframe.unhide(); + } + }, + + getElementFromPoint(x, y) { + return this.currentIframe.getElementFromPoint(x, y); + }, + + remove() { + iframeSelection.remove(); + iframePreSelection.remove(); + iframePreview.remove(); + }, + + getContentWindow() { + return this.currentIframe.element.contentWindow; + }, + + document() { + return this.currentIframe.document; + }, + + useSelection() { + if (this.currentIframe === iframePreSelection || this.currentIframe === iframePreview) { + this.hide(); + } + this.currentIframe = iframeSelection; + this.unhide(); + }, + + usePreSelection() { + if (this.currentIframe === iframeSelection || this.currentIframe === iframePreview) { + this.hide(); + } + this.currentIframe = iframePreSelection; + this.unhide(); + }, + + usePreview() { + if (this.currentIframe === iframeSelection || this.currentIframe === iframePreSelection) { + this.hide(); + } + this.currentIframe = iframePreview; + this.unhide(); + }, + }; + + const movements = ["topLeft", "top", "topRight", "left", "right", "bottomLeft", "bottom", "bottomRight"]; + + /** Creates the selection box */ + exports.Box = { + + async display(pos, callbacks) { + await this._createEl(); + if (callbacks !== undefined && callbacks.cancel) { + // We use onclick here because we don't want addEventListener + // to add multiple event handlers to the same button + this.cancel.onclick = watchFunction(assertIsTrusted(callbacks.cancel)); + this.cancel.style.display = ""; + } else { + this.cancel.style.display = "none"; + } + if (callbacks !== undefined && callbacks.save && this.save) { + // We use onclick here because we don't want addEventListener + // to add multiple event handlers to the same button + this.save.removeAttribute("disabled"); + this.save.onclick = watchFunction(assertIsTrusted((e) => { + this.save.setAttribute("disabled", "true"); + callbacks.save(e); + })); + this.save.style.display = ""; + } else if (this.save) { + this.save.style.display = "none"; + } + if (callbacks !== undefined && callbacks.download) { + this.download.removeAttribute("disabled"); + this.download.onclick = watchFunction(assertIsTrusted((e) => { + this.download.setAttribute("disabled", true); + callbacks.download(e); + e.preventDefault(); + e.stopPropagation(); + return false; + })); + this.download.style.display = ""; + } else { + this.download.style.display = "none"; + } + if (callbacks !== undefined && callbacks.copy) { + this.copy.removeAttribute("disabled"); + this.copy.onclick = watchFunction(assertIsTrusted((e) => { + this.copy.setAttribute("disabled", true); + callbacks.copy(e); + e.preventDefault(); + e.stopPropagation(); + })); + this.copy.style.display = ""; + } else { + this.copy.style.display = "none"; + } + + const winBottom = window.innerHeight; + const pageYOffset = window.pageYOffset; + + if ((pos.right - pos.left) < 78 || (pos.bottom - pos.top) < 78) { + this.el.classList.add("small-selection"); + } else { + this.el.classList.remove("small-selection"); + } + + // if the selection bounding box is w/in SAVE_BUTTON_HEIGHT px of the bottom of + // the window, flip controls into the box + if (pos.bottom > ((winBottom + pageYOffset) - SAVE_BUTTON_HEIGHT)) { + this.el.classList.add("bottom-selection"); + } else { + this.el.classList.remove("bottom-selection"); + } + + if (pos.right < 200) { + this.el.classList.add("left-selection"); + } else { + this.el.classList.remove("left-selection"); + } + this.el.style.top = `${pos.top}px`; + this.el.style.left = `${pos.left}px`; + this.el.style.height = `${pos.bottom - pos.top}px`; + this.el.style.width = `${pos.right - pos.left}px`; + this.bgTop.style.top = "0px"; + this.bgTop.style.height = `${pos.top}px`; + this.bgTop.style.left = "0px"; + this.bgTop.style.width = "100%"; + this.bgBottom.style.top = `${pos.bottom}px`; + this.bgBottom.style.height = `calc(100vh - ${pos.bottom}px)`; + this.bgBottom.style.left = "0px"; + this.bgBottom.style.width = "100%"; + this.bgLeft.style.top = `${pos.top}px`; + this.bgLeft.style.height = `${pos.bottom - pos.top}px`; + this.bgLeft.style.left = "0px"; + this.bgLeft.style.width = `${pos.left}px`; + this.bgRight.style.top = `${pos.top}px`; + this.bgRight.style.height = `${pos.bottom - pos.top}px`; + this.bgRight.style.left = `${pos.right}px`; + this.bgRight.style.width = `calc(100% - ${pos.right}px)`; + }, + + // used to eventually move the download-only warning + // when a user ends scrolling or ends resizing a window + delayExecution(delay, cb) { + let timer; + return function() { + if (typeof timer !== "undefined") { + clearTimeout(timer); + } + timer = setTimeout(cb, delay); + }; + }, + + remove() { + for (const name of ["el", "bgTop", "bgLeft", "bgRight", "bgBottom"]) { + if (name in this) { + util.removeNode(this[name]); + this[name] = null; + } + } + }, + + async _createEl() { + let boxEl = this.el; + if (boxEl) { + return; + } + let [cancelTitle, copyTitle, downloadTitle, copyText, downloadText ] = await msgsPromise; + boxEl = makeEl("div", "highlight"); + const buttons = makeEl("div", "highlight-buttons"); + const cancel = makeEl("button", "highlight-button-cancel"); + const cancelImg = makeEl("img"); + cancelImg.src = browser.extension.getURL("icons/cancel.svg"); + cancel.title = cancelTitle; + cancel.appendChild(cancelImg); + buttons.appendChild(cancel); + + const copy = makeEl("button", "highlight-button-copy"); + copy.title = copyTitle; + const copyImg = makeEl("img"); + const copyString = makeEl("span"); + copyString.textContent = copyText; + copyImg.src = browser.extension.getURL("icons/copy.svg"); + copy.appendChild(copyImg); + copy.appendChild(copyString); + buttons.appendChild(copy); + + const download = makeEl("button", "highlight-button-download"); + const downloadImg = makeEl("img"); + downloadImg.src = browser.extension.getURL("icons/download-white.svg"); + download.appendChild(downloadImg); + download.append(downloadText); + download.title = downloadTitle; + buttons.appendChild(download); + this.cancel = cancel; + this.download = download; + this.copy = copy; + + boxEl.appendChild(buttons); + for (const name of movements) { + const elTarget = makeEl("div", "mover-target direction-" + name); + const elMover = makeEl("div", "mover"); + elTarget.appendChild(elMover); + boxEl.appendChild(elTarget); + } + this.bgTop = makeEl("div", "bghighlight"); + iframe.document().body.appendChild(this.bgTop); + this.bgLeft = makeEl("div", "bghighlight"); + iframe.document().body.appendChild(this.bgLeft); + this.bgRight = makeEl("div", "bghighlight"); + iframe.document().body.appendChild(this.bgRight); + this.bgBottom = makeEl("div", "bghighlight"); + iframe.document().body.appendChild(this.bgBottom); + iframe.document().body.appendChild(boxEl); + this.el = boxEl; + }, + + draggerDirection(target) { + while (target) { + if (target.nodeType === document.ELEMENT_NODE) { + if (target.classList.contains("mover-target")) { + for (const name of movements) { + if (target.classList.contains("direction-" + name)) { + return name; + } + } + catcher.unhandled(new Error("Surprising mover element"), {element: target.outerHTML}); + log.warn("Got mover-target that wasn't a specific direction"); + } + } + target = target.parentNode; + } + return null; + }, + + isSelection(target) { + while (target) { + if (target.tagName === "BUTTON") { + return false; + } + if (target.nodeType === document.ELEMENT_NODE && target.classList.contains("highlight")) { + return true; + } + target = target.parentNode; + } + return false; + }, + + isControl(target) { + while (target) { + if (target.nodeType === document.ELEMENT_NODE && target.classList.contains("highlight-buttons")) { + return true; + } + target = target.parentNode; + } + return false; + }, + + clearSaveDisabled() { + if (!this.save) { + // Happens if we try to remove the disabled status after the worker + // has been shut down + return; + } + this.save.removeAttribute("disabled"); + }, + + el: null, + boxTopEl: null, + boxLeftEl: null, + boxRightEl: null, + boxBottomEl: null, + }; + + exports.HoverBox = { + + el: null, + + display(rect) { + if (!this.el) { + this.el = makeEl("div", "hover-highlight"); + iframe.document().body.appendChild(this.el); + } + this.el.style.display = ""; + this.el.style.top = (rect.top - 1) + "px"; + this.el.style.left = (rect.left - 1) + "px"; + this.el.style.width = (rect.right - rect.left + 2) + "px"; + this.el.style.height = (rect.bottom - rect.top + 2) + "px"; + }, + + hide() { + if (this.el) { + this.el.style.display = "none"; + } + }, + + remove() { + util.removeNode(this.el); + this.el = null; + }, + }; + + exports.PixelDimensions = { + el: null, + xEl: null, + yEl: null, + display(xPos, yPos, x, y) { + if (!this.el) { + this.el = makeEl("div", "pixel-dimensions"); + this.xEl = makeEl("div"); + this.el.appendChild(this.xEl); + this.yEl = makeEl("div"); + this.el.appendChild(this.yEl); + iframe.document().body.appendChild(this.el); + } + this.xEl.textContent = Math.round(x); + this.yEl.textContent = Math.round(y); + this.el.style.top = (yPos + 12) + "px"; + this.el.style.left = (xPos + 12) + "px"; + }, + remove() { + util.removeNode(this.el); + this.el = this.xEl = this.yEl = null; + }, + }; + + exports.Preview = { + display(dataUrl) { + const img = makeEl("IMG"); + const imgBlob = blobConverters.dataUrlToBlob(dataUrl); + img.src = iframe.getContentWindow().URL.createObjectURL(imgBlob); + iframe.document().querySelector(".preview-image-wrapper").appendChild(img); + }, + }; + + /** Removes every UI this module creates */ + exports.remove = function() { + for (const name in exports) { + if (name.startsWith("iframe")) { + continue; + } + if (typeof exports[name] === "object" && exports[name].remove) { + exports[name].remove(); + } + } + exports.iframe.remove(); + }; + + exports.triggerDownload = function(url, filename) { + return catcher.watchPromise(callBackground("downloadShot", {url, filename})); + }; + + exports.unload = exports.remove; + + return exports; +})(); +null; diff --git a/browser/extensions/screenshots/selector/uicontrol.js b/browser/extensions/screenshots/selector/uicontrol.js new file mode 100644 index 0000000000..f3aff2c5c8 --- /dev/null +++ b/browser/extensions/screenshots/selector/uicontrol.js @@ -0,0 +1,901 @@ +/* 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/. */ + +/* globals log, catcher, util, ui, slides */ +/* globals shooter, callBackground, selectorLoader, assertIsTrusted, buildSettings, selection */ + +"use strict"; + +this.uicontrol = (function() { + const exports = {}; + + /** ******************************************************** + * selection + */ + + /* States: + + "crosshairs": + Nothing has happened, and the crosshairs will follow the movement of the mouse + "draggingReady": + The user has pressed the mouse button, but hasn't moved enough to create a selection + "dragging": + The user has pressed down a mouse button, and is dragging out an area far enough to show a selection + "selected": + The user has selected an area + "resizing": + The user is resizing the selection + "cancelled": + Everything has been cancelled + "previewing": + The user is previewing the full-screen/visible image + + A mousedown goes from crosshairs to dragging. + A mouseup goes from dragging to selected + A click outside of the selection goes from selected to crosshairs + A click on one of the draggers goes from selected to resizing + + State variables: + + state (string, one of the above) + mousedownPos (object with x/y during draggingReady, shows where the selection started) + selectedPos (object with x/y/h/w during selected or dragging, gives the entire selection) + resizeDirection (string: top, topLeft, etc, during resizing) + resizeStartPos (x/y position where resizing started) + mouseupNoAutoselect (true if a mouseup in draggingReady should not trigger autoselect) + + */ + + const { watchFunction, watchPromise } = catcher; + + const MAX_PAGE_HEIGHT = buildSettings.maxImageHeight; + const MAX_PAGE_WIDTH = buildSettings.maxImageWidth; + // An autoselection smaller than these will be ignored entirely: + const MIN_DETECT_ABSOLUTE_HEIGHT = 10; + const MIN_DETECT_ABSOLUTE_WIDTH = 30; + // An autoselection smaller than these will not be preferred: + const MIN_DETECT_HEIGHT = 30; + const MIN_DETECT_WIDTH = 100; + // An autoselection bigger than either of these will be ignored: + const MAX_DETECT_HEIGHT = Math.max(window.innerHeight + 100, 700); + const MAX_DETECT_WIDTH = Math.max(window.innerWidth + 100, 1000); + // This is how close (in pixels) you can get to the edge of the window and then + // it will scroll: + const SCROLL_BY_EDGE = 20; + // This is how wide the inboard scrollbars are, generally 0 except on Mac + const SCROLLBAR_WIDTH = (window.navigator.platform.match(/Mac/i)) ? 17 : 0; + + + const { Selection } = selection; + const { sendEvent } = shooter; + const log = global.log; + + function round10(n) { + return Math.floor(n / 10) * 10; + } + + function eventOptionsForBox(box) { + return { + cd1: round10(Math.abs(box.bottom - box.top)), + cd2: round10(Math.abs(box.right - box.left)), + }; + } + + function eventOptionsForResize(boxStart, boxEnd) { + return { + cd1: round10( + (boxEnd.bottom - boxEnd.top) + - (boxStart.bottom - boxStart.top)), + cd2: round10( + (boxEnd.right - boxEnd.left) + - (boxStart.right - boxStart.left)), + }; + } + + function eventOptionsForMove(posStart, posEnd) { + return { + cd1: round10(posEnd.y - posStart.y), + cd2: round10(posEnd.x - posStart.x), + }; + } + + function downloadShot() { + const previewDataUrl = (captureType === "fullPageTruncated") ? null : dataUrl; + // Downloaded shots don't have dimension limits + removeDimensionLimitsOnFullPageShot(); + shooter.downloadShot(selectedPos, previewDataUrl, captureType); + } + + function copyShot() { + const previewDataUrl = (captureType === "fullPageTruncated") ? null : dataUrl; + // Copied shots don't have dimension limits + removeDimensionLimitsOnFullPageShot(); + shooter.copyShot(selectedPos, previewDataUrl, captureType); + } + + /** ********************************************* + * State and stateHandlers infrastructure + */ + + // This enumerates all the anchors on the selection, and what part of the + // selection they move: + const movements = { + topLeft: ["x1", "y1"], + top: [null, "y1"], + topRight: ["x2", "y1"], + left: ["x1", null], + right: ["x2", null], + bottomLeft: ["x1", "y2"], + bottom: [null, "y2"], + bottomRight: ["x2", "y2"], + move: ["*", "*"], + }; + + const doNotAutoselectTags = { + H1: true, + H2: true, + H3: true, + H4: true, + H5: true, + H6: true, + }; + + let captureType; + + function removeDimensionLimitsOnFullPageShot() { + if (captureType === "fullPageTruncated") { + captureType = "fullPage"; + selectedPos = new Selection( + 0, 0, + getDocumentWidth(), getDocumentHeight()); + } + } + + const standardDisplayCallbacks = { + cancel: () => { + sendEvent("cancel-shot", "overlay-cancel-button"); + exports.deactivate(); + }, download: () => { + sendEvent("download-shot", "overlay-download-button"); + downloadShot(); + }, copy: () => { + sendEvent("copy-shot", "overlay-copy-button"); + copyShot(); + }, + }; + + const standardOverlayCallbacks = { + cancel: () => { + sendEvent("cancel-shot", "cancel-preview-button"); + exports.deactivate(); + }, + onClickCancel: e => { + sendEvent("cancel-shot", "cancel-selection-button"); + e.preventDefault(); + e.stopPropagation(); + exports.deactivate(); + }, + onOpenMyShots: () => { + sendEvent("goto-myshots", "selection-button"); + callBackground("openMyShots") + .then(() => exports.deactivate()) + .catch(() => { + // Handled in communication.js + }); + }, + onClickVisible: () => { + sendEvent("capture-visible", "selection-button"); + selectedPos = new Selection( + window.scrollX, window.scrollY, + window.scrollX + document.documentElement.clientWidth, window.scrollY + window.innerHeight); + captureType = "visible"; + setState("previewing"); + }, + onClickFullPage: () => { + sendEvent("capture-full-page", "selection-button"); + captureType = "fullPage"; + const width = getDocumentWidth(); + if (width > MAX_PAGE_WIDTH) { + captureType = "fullPageTruncated"; + } + const height = getDocumentHeight(); + if (height > MAX_PAGE_HEIGHT) { + captureType = "fullPageTruncated"; + } + selectedPos = new Selection( + 0, 0, + width, height); + setState("previewing"); + }, + onDownloadPreview: () => { + sendEvent(`download-${captureType.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase()}`, "download-preview-button"); + downloadShot(); + }, + onCopyPreview: () => { + sendEvent(`copy-${captureType.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase()}`, "copy-preview-button"); + copyShot(); + }, + }; + + /** Holds all the objects that handle events for each state: */ + const stateHandlers = {}; + + function getState() { + return getState.state; + } + getState.state = "cancel"; + + function setState(s) { + if (!stateHandlers[s]) { + throw new Error("Unknown state: " + s); + } + const cur = getState.state; + const handler = stateHandlers[cur]; + if (handler.end) { + handler.end(); + } + getState.state = s; + if (stateHandlers[s].start) { + stateHandlers[s].start(); + } + } + + /** Various values that the states use: */ + let mousedownPos; + let selectedPos; + let resizeDirection; + let resizeStartPos; + let resizeStartSelected; + let resizeHasMoved; + let mouseupNoAutoselect = false; + let autoDetectRect; + + /** Represents a single x/y point, typically for a mouse click that doesn't have a drag: */ + class Pos { + constructor(x, y) { + this.x = x; + this.y = y; + } + + elementFromPoint() { + return ui.iframe.getElementFromPoint( + this.x - window.pageXOffset, + this.y - window.pageYOffset + ); + } + + distanceTo(x, y) { + return Math.sqrt(Math.pow(this.x - x, 2) + Math.pow(this.y - y, 2)); + } + } + + /** ********************************************* + * all stateHandlers + */ + + let dataUrl; + + stateHandlers.previewing = { + start() { + shooter.preview(selectedPos, captureType); + }, + }; + + stateHandlers.crosshairs = { + + cachedEl: null, + + start() { + selectedPos = mousedownPos = null; + this.cachedEl = null; + watchPromise(ui.iframe.display(installHandlersOnDocument, standardOverlayCallbacks).then(() => { + ui.iframe.usePreSelection(); + ui.Box.remove(); + })); + }, + + mousemove(event) { + ui.PixelDimensions.display(event.pageX, event.pageY, event.pageX, event.pageY); + if (event.target.classList && + (!event.target.classList.contains("preview-overlay"))) { + // User is hovering over a toolbar button or control + autoDetectRect = null; + if (this.cachedEl) { + this.cachedEl = null; + } + ui.HoverBox.hide(); + return; + } + let el; + if (event.target.classList && event.target.classList.contains("preview-overlay")) { + // The hover is on the overlay, so we need to figure out the real element + el = ui.iframe.getElementFromPoint( + event.pageX + window.scrollX - window.pageXOffset, + event.pageY + window.scrollY - window.pageYOffset + ); + const xpos = Math.floor(10 * (event.pageX - window.innerWidth / 2) / window.innerWidth); + const ypos = Math.floor(10 * (event.pageY - window.innerHeight / 2) / window.innerHeight); + + for (let i = 0; i < 2; i++) { + const move = `translate(${xpos}px, ${ypos}px)`; + event.target.getElementsByClassName("eyeball")[i].style.transform = move; + } + } else { + // The hover is on the element we care about, so we use that + el = event.target; + } + if (this.cachedEl && this.cachedEl === el) { + // Still hovering over the same element + return; + } + this.cachedEl = el; + this.setAutodetectBasedOnElement(el); + }, + + setAutodetectBasedOnElement(el) { + let lastRect; + let lastNode; + let rect; + let attemptExtend = false; + let node = el; + while (node) { + rect = Selection.getBoundingClientRect(node); + if (!rect) { + rect = lastRect; + break; + } + if (rect.width < MIN_DETECT_WIDTH || rect.height < MIN_DETECT_HEIGHT) { + // Avoid infinite loop for elements with zero or nearly zero height, + // like non-clearfixed float parents with or without borders. + break; + } + if (rect.width > MAX_DETECT_WIDTH || rect.height > MAX_DETECT_HEIGHT) { + // Then the last rectangle is better + rect = lastRect; + attemptExtend = true; + break; + } + if (rect.width >= MIN_DETECT_WIDTH && rect.height >= MIN_DETECT_HEIGHT) { + if (!doNotAutoselectTags[node.tagName]) { + break; + } + } + lastRect = rect; + lastNode = node; + node = node.parentNode; + } + if (rect && node) { + const evenBetter = this.evenBetterElement(node, rect); + if (evenBetter) { + node = lastNode = evenBetter; + rect = Selection.getBoundingClientRect(evenBetter); + attemptExtend = false; + } + } + if (rect && attemptExtend) { + let extendNode = lastNode.nextSibling; + while (extendNode) { + if (extendNode.nodeType === document.ELEMENT_NODE) { + break; + } + extendNode = extendNode.nextSibling; + if (!extendNode) { + const parent = lastNode.parentNode; + for (let i = 0; i < parent.childNodes.length; i++) { + if (parent.childNodes[i] === lastNode) { + extendNode = parent.childNodes[i + 1]; + } + } + } + } + if (extendNode) { + const extendSelection = Selection.getBoundingClientRect(extendNode); + const extendRect = rect.union(extendSelection); + if (extendRect.width <= MAX_DETECT_WIDTH && extendRect.height <= MAX_DETECT_HEIGHT) { + rect = extendRect; + } + } + } + + if (rect && (rect.width < MIN_DETECT_ABSOLUTE_WIDTH || rect.height < MIN_DETECT_ABSOLUTE_HEIGHT)) { + rect = null; + } + if (!rect) { + ui.HoverBox.hide(); + } else { + ui.HoverBox.display(rect); + } + autoDetectRect = rect; + }, + + /** When we find an element, maybe there's one that's just a little bit better... */ + evenBetterElement(node, origRect) { + let el = node.parentNode; + const ELEMENT_NODE = document.ELEMENT_NODE; + while (el && el.nodeType === ELEMENT_NODE) { + if (!el.getAttribute) { + return null; + } + const role = el.getAttribute("role"); + if (role === "article" || (el.className && typeof el.className === "string" && el.className.search("tweet ") !== -1)) { + const rect = Selection.getBoundingClientRect(el); + if (!rect) { + return null; + } + if (rect.width <= MAX_DETECT_WIDTH && rect.height <= MAX_DETECT_HEIGHT) { + return el; + } + return null; + } + el = el.parentNode; + } + return null; + }, + + mousedown(event) { + // FIXME: this is happening but we don't know why, we'll track it now + // but avoid popping up messages: + if (typeof ui === "undefined") { + const exc = new Error("Undefined ui in mousedown"); + exc.unloadTime = unloadTime; + exc.nowTime = Date.now(); + exc.noPopup = true; + exc.noReport = true; + throw exc; + } + if (ui.isHeader(event.target)) { + return undefined; + } + // If the pageX is greater than this, then probably it's an attempt to get + // to the scrollbar, or an actual scroll, and not an attempt to start the + // selection: + const maxX = window.innerWidth - SCROLLBAR_WIDTH; + if (event.pageX >= maxX) { + event.stopPropagation(); + event.preventDefault(); + return false; + } + + mousedownPos = new Pos(event.pageX + window.scrollX, event.pageY + window.scrollY); + setState("draggingReady"); + event.stopPropagation(); + event.preventDefault(); + return false; + }, + + end() { + ui.HoverBox.remove(); + ui.PixelDimensions.remove(); + }, + }; + + stateHandlers.draggingReady = { + minMove: 40, // px + minAutoImageWidth: 40, + minAutoImageHeight: 40, + maxAutoElementWidth: 800, + maxAutoElementHeight: 600, + + start() { + ui.iframe.usePreSelection(); + ui.Box.remove(); + }, + + mousemove(event) { + if (mousedownPos.distanceTo(event.pageX, event.pageY) > this.minMove) { + selectedPos = new Selection( + mousedownPos.x, + mousedownPos.y, + event.pageX + window.scrollX, + event.pageY + window.scrollY); + mousedownPos = null; + setState("dragging"); + } + }, + + mouseup(event) { + // If we don't get into "dragging" then we attempt an autoselect + if (mouseupNoAutoselect) { + sendEvent("cancel-selection", "selection-background-mousedown"); + setState("crosshairs"); + return false; + } + if (autoDetectRect) { + selectedPos = autoDetectRect; + selectedPos.x1 += window.scrollX; + selectedPos.y1 += window.scrollY; + selectedPos.x2 += window.scrollX; + selectedPos.y2 += window.scrollY; + autoDetectRect = null; + mousedownPos = null; + ui.iframe.useSelection(); + ui.Box.display(selectedPos, standardDisplayCallbacks); + sendEvent("make-selection", "selection-click", eventOptionsForBox(selectedPos)); + setState("selected"); + sendEvent("autoselect"); + } else { + sendEvent("no-selection", "no-element-found"); + setState("crosshairs"); + } + return undefined; + }, + + click(event) { + this.mouseup(event); + }, + + findGoodEl() { + let el = mousedownPos.elementFromPoint(); + if (!el) { + return null; + } + const isGoodEl = (el) => { + if (el.nodeType !== document.ELEMENT_NODE) { + return false; + } + if (el.tagName === "IMG") { + const rect = el.getBoundingClientRect(); + return rect.width >= this.minAutoImageWidth && rect.height >= this.minAutoImageHeight; + } + const display = window.getComputedStyle(el).display; + if (["block", "inline-block", "table"].includes(display)) { + return true; + // FIXME: not sure if this is useful: + // let rect = el.getBoundingClientRect(); + // return rect.width <= this.maxAutoElementWidth && rect.height <= this.maxAutoElementHeight; + } + return false; + }; + while (el) { + if (isGoodEl(el)) { + return el; + } + el = el.parentNode; + } + return null; + }, + + end() { + mouseupNoAutoselect = false; + }, + + }; + + stateHandlers.dragging = { + + start() { + ui.iframe.useSelection(); + ui.Box.display(selectedPos); + }, + + mousemove(event) { + selectedPos.x2 = util.truncateX(event.pageX); + selectedPos.y2 = util.truncateY(event.pageY); + scrollIfByEdge(event.pageX, event.pageY); + ui.Box.display(selectedPos); + ui.PixelDimensions.display(event.pageX, event.pageY, selectedPos.width, selectedPos.height); + }, + + mouseup(event) { + selectedPos.x2 = util.truncateX(event.pageX); + selectedPos.y2 = util.truncateY(event.pageY); + ui.Box.display(selectedPos, standardDisplayCallbacks); + sendEvent( + "make-selection", "selection-drag", + eventOptionsForBox({ + top: selectedPos.y1, + bottom: selectedPos.y2, + left: selectedPos.x1, + right: selectedPos.x2, + })); + setState("selected"); + }, + + end() { + ui.PixelDimensions.remove(); + }, + }; + + stateHandlers.selected = { + start() { + ui.iframe.useSelection(); + }, + + mousedown(event) { + const target = event.target; + if (target.tagName === "HTML") { + // This happens when you click on the scrollbar + return undefined; + } + const direction = ui.Box.draggerDirection(target); + if (direction) { + sendEvent("start-resize-selection", "handle"); + stateHandlers.resizing.startResize(event, direction); + } else if (ui.Box.isSelection(target)) { + sendEvent("start-move-selection", "selection"); + stateHandlers.resizing.startResize(event, "move"); + } else if (!ui.Box.isControl(target)) { + mousedownPos = new Pos(event.pageX, event.pageY); + setState("crosshairs"); + } + event.preventDefault(); + return false; + }, + }; + + stateHandlers.resizing = { + start() { + ui.iframe.useSelection(); + selectedPos.sortCoords(); + }, + + startResize(event, direction) { + selectedPos.sortCoords(); + resizeDirection = direction; + resizeStartPos = new Pos(event.pageX, event.pageY); + resizeStartSelected = selectedPos.clone(); + resizeHasMoved = false; + setState("resizing"); + }, + + mousemove(event) { + this._resize(event); + if (resizeDirection !== "move") { + ui.PixelDimensions.display(event.pageX, event.pageY, selectedPos.width, selectedPos.height); + } + return false; + }, + + mouseup(event) { + this._resize(event); + sendEvent("selection-resized"); + ui.Box.display(selectedPos, standardDisplayCallbacks); + if (resizeHasMoved) { + if (resizeDirection === "move") { + const startPos = new Pos(resizeStartSelected.left, resizeStartSelected.top); + const endPos = new Pos(selectedPos.left, selectedPos.top); + sendEvent( + "move-selection", "mouseup", + eventOptionsForMove(startPos, endPos)); + } else { + sendEvent( + "resize-selection", "mouseup", + eventOptionsForResize(resizeStartSelected, selectedPos)); + } + } else if (resizeDirection === "move") { + sendEvent("keep-resize-selection", "mouseup"); + } else { + sendEvent("keep-move-selection", "mouseup"); + } + setState("selected"); + }, + + _resize(event) { + const diffX = event.pageX - resizeStartPos.x; + const diffY = event.pageY - resizeStartPos.y; + const movement = movements[resizeDirection]; + if (movement[0]) { + let moveX = movement[0]; + moveX = moveX === "*" ? ["x1", "x2"] : [moveX]; + for (const moveDir of moveX) { + selectedPos[moveDir] = util.truncateX(resizeStartSelected[moveDir] + diffX); + } + } + if (movement[1]) { + let moveY = movement[1]; + moveY = moveY === "*" ? ["y1", "y2"] : [moveY]; + for (const moveDir of moveY) { + selectedPos[moveDir] = util.truncateY(resizeStartSelected[moveDir] + diffY); + } + } + if (diffX || diffY) { + resizeHasMoved = true; + } + scrollIfByEdge(event.pageX, event.pageY); + ui.Box.display(selectedPos); + }, + + end() { + resizeDirection = resizeStartPos = resizeStartSelected = null; + selectedPos.sortCoords(); + ui.PixelDimensions.remove(); + }, + }; + + stateHandlers.cancel = { + start() { + ui.iframe.hide(); + ui.Box.remove(); + }, + }; + + function getDocumentWidth() { + return Math.max( + document.body && document.body.clientWidth, + document.documentElement.clientWidth, + document.body && document.body.scrollWidth, + document.documentElement.scrollWidth); + } + function getDocumentHeight() { + return Math.max( + document.body && document.body.clientHeight, + document.documentElement.clientHeight, + document.body && document.body.scrollHeight, + document.documentElement.scrollHeight); + } + + function scrollIfByEdge(pageX, pageY) { + const top = window.scrollY; + const bottom = top + window.innerHeight; + const left = window.scrollX; + const right = left + window.innerWidth; + if (pageY + SCROLL_BY_EDGE >= bottom && bottom < getDocumentHeight()) { + window.scrollBy(0, SCROLL_BY_EDGE); + } else if (pageY - SCROLL_BY_EDGE <= top) { + window.scrollBy(0, -SCROLL_BY_EDGE); + } + if (pageX + SCROLL_BY_EDGE >= right && right < getDocumentWidth()) { + window.scrollBy(SCROLL_BY_EDGE, 0); + } else if (pageX - SCROLL_BY_EDGE <= left) { + window.scrollBy(-SCROLL_BY_EDGE, 0); + } + } + + /** ********************************************* + * Selection communication + */ + + exports.activate = function() { + if (!document.body) { + callBackground("abortStartShot"); + const tagName = String(document.documentElement.tagName || "").replace(/[^a-z0-9]/ig, ""); + sendEvent("abort-start-shot", `document-is-${tagName}`); + selectorLoader.unloadModules(); + return; + } + if (isFrameset()) { + callBackground("abortStartShot"); + sendEvent("abort-start-shot", "frame-page"); + selectorLoader.unloadModules(); + return; + } + addHandlers(); + setState("crosshairs"); + }; + + function isFrameset() { + return document.body.tagName === "FRAMESET"; + } + + exports.deactivate = function() { + try { + sendEvent("internal", "deactivate"); + setState("cancel"); + callBackground("closeSelector"); + selectorLoader.unloadModules(); + } catch (e) { + log.error("Error in deactivate", e); + // Sometimes this fires so late that the document isn't available + // We don't care about the exception, so we swallow it here + } + }; + + let unloadTime = 0; + + exports.unload = function() { + // Note that ui.unload() will be called on its own + unloadTime = Date.now(); + removeHandlers(); + }; + + /** ********************************************* + * Event handlers + */ + + const primedDocumentHandlers = new Map(); + let registeredDocumentHandlers = []; + + function addHandlers() { + ["mouseup", "mousedown", "mousemove", "click"].forEach((eventName) => { + const fn = watchFunction(assertIsTrusted((function(eventName, event) { + if (typeof event.button === "number" && event.button !== 0) { + // Not a left click + return undefined; + } + if (event.ctrlKey || event.shiftKey || event.altKey || event.metaKey) { + // Modified click of key + return undefined; + } + const state = getState(); + const handler = stateHandlers[state]; + if (handler[eventName]) { + return handler[eventName](event); + } + return undefined; + }).bind(null, eventName))); + primedDocumentHandlers.set(eventName, fn); + }); + primedDocumentHandlers.set("keyup", watchFunction(assertIsTrusted(keyupHandler))); + primedDocumentHandlers.set("keydown", watchFunction(assertIsTrusted(keydownHandler))); + window.document.addEventListener("visibilitychange", visibilityChangeHandler); + window.addEventListener("beforeunload", beforeunloadHandler); + } + + let mousedownSetOnDocument = false; + + function installHandlersOnDocument(docObj) { + for (const [eventName, handler] of primedDocumentHandlers) { + const watchHandler = watchFunction(handler); + const useCapture = eventName !== "keyup"; + docObj.addEventListener(eventName, watchHandler, useCapture); + registeredDocumentHandlers.push({name: eventName, doc: docObj, handler: watchHandler, useCapture}); + } + if (!mousedownSetOnDocument) { + const mousedownHandler = primedDocumentHandlers.get("mousedown"); + document.addEventListener("mousedown", mousedownHandler, true); + registeredDocumentHandlers.push({name: "mousedown", doc: document, handler: mousedownHandler, useCapture: true}); + mousedownSetOnDocument = true; + } + } + + function beforeunloadHandler() { + sendEvent("cancel-shot", "tab-load"); + exports.deactivate(); + } + + function keydownHandler(event) { + // In MacOS, the keyup event for 'c' is not fired when performing cmd+c. + if (event.code === "KeyC" && (event.ctrlKey || event.metaKey) + && ["previewing", "selected"].includes(getState.state)) { + catcher.watchPromise(callBackground("getPlatformOs").then(os => { + if ((event.ctrlKey && os !== "mac") || + (event.metaKey && os === "mac")) { + sendEvent("copy-shot", "keyboard-copy"); + copyShot(); + } + })); + } + } + + function keyupHandler(event) { + if (event.shiftKey || event.altKey || event.ctrlKey || event.metaKey) { + // unused modifier keys + return; + } + if ((event.key || event.code) === "Escape") { + sendEvent("cancel-shot", "keyboard-escape"); + exports.deactivate(); + } + // Enter to trigger Save or Download by default. But if the user tabbed to + // select another button, then we do not want this. + if ((event.key || event.code) === "Enter" + && getState.state === "selected" + && ui.iframe.document().activeElement.tagName === "BODY") { + sendEvent("download-shot", "keyboard-enter"); + downloadShot(); + } + } + + function visibilityChangeHandler(event) { + // The document is the event target + if (event.target.hidden) { + sendEvent("internal", "document-hidden"); + } + } + + function removeHandlers() { + window.removeEventListener("beforeunload", beforeunloadHandler); + window.document.removeEventListener("visibilitychange", visibilityChangeHandler); + for (const {name, doc, handler, useCapture} of registeredDocumentHandlers) { + doc.removeEventListener(name, handler, !!useCapture); + } + registeredDocumentHandlers = []; + } + + catcher.watchFunction(exports.activate)(); + + return exports; +})(); + +null; diff --git a/browser/extensions/screenshots/selector/util.js b/browser/extensions/screenshots/selector/util.js new file mode 100644 index 0000000000..91217d58c1 --- /dev/null +++ b/browser/extensions/screenshots/selector/util.js @@ -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/. */ + +"use strict"; + +this.util = (function() { // eslint-disable-line no-unused-vars + const exports = {}; + + /** Removes a node from its document, if it's a node and the node is attached to a parent */ + exports.removeNode = function(el) { + if (el && el.parentNode) { + el.remove(); + } + }; + + /** Truncates the X coordinate to the document size */ + exports.truncateX = function(x) { + const max = Math.max(document.documentElement.clientWidth, document.body.clientWidth, document.documentElement.scrollWidth, document.body.scrollWidth); + if (x < 0) { + return 0; + } else if (x > max) { + return max; + } + return x; + }; + + /** Truncates the Y coordinate to the document size */ + exports.truncateY = function(y) { + const max = Math.max(document.documentElement.clientHeight, document.body.clientHeight, document.documentElement.scrollHeight, document.body.scrollHeight); + if (y < 0) { + return 0; + } else if (y > max) { + return max; + } + return y; + }; + + // Pixels of wiggle the captured region gets in captureSelectedText: + const CAPTURE_WIGGLE = 10; + const ELEMENT_NODE = document.ELEMENT_NODE; + + exports.captureEnclosedText = function(box) { + const scrollX = window.scrollX; + const scrollY = window.scrollY; + const text = []; + function traverse(el) { + let elBox = el.getBoundingClientRect(); + elBox = { + top: elBox.top + scrollY, + bottom: elBox.bottom + scrollY, + left: elBox.left + scrollX, + right: elBox.right + scrollX, + }; + if (elBox.bottom < box.top || + elBox.top > box.bottom || + elBox.right < box.left || + elBox.left > box.right) { + // Totally outside of the box + return; + } + if (elBox.bottom > box.bottom + CAPTURE_WIGGLE || + elBox.top < box.top - CAPTURE_WIGGLE || + elBox.right > box.right + CAPTURE_WIGGLE || + elBox.left < box.left - CAPTURE_WIGGLE) { + // Partially outside the box + for (let i = 0; i < el.childNodes.length; i++) { + const child = el.childNodes[i]; + if (child.nodeType === ELEMENT_NODE) { + traverse(child); + } + } + return; + } + addText(el); + } + function addText(el) { + let t; + if (el.tagName === "IMG") { + t = el.getAttribute("alt") || el.getAttribute("title"); + } else if (el.tagName === "A") { + t = el.innerText; + if (el.getAttribute("href") && !el.getAttribute("href").startsWith("#")) { + t += " (" + el.href + ")"; + } + } else { + t = el.innerText; + } + if (t) { + text.push(t); + } + } + traverse(document.body); + if (text.length) { + let result = text.join("\n"); + result = result.replace(/^\s+/, ""); + result = result.replace(/\s+$/, ""); + result = result.replace(/[ \t]+\n/g, "\n"); + return result; + } + return null; + }; + + + return exports; +})(); +null; diff --git a/browser/extensions/screenshots/sitehelper.js b/browser/extensions/screenshots/sitehelper.js new file mode 100644 index 0000000000..916155de7b --- /dev/null +++ b/browser/extensions/screenshots/sitehelper.js @@ -0,0 +1,94 @@ +/* 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/. */ + +/* globals catcher, callBackground, content */ +/** This is a content script added to all screenshots.firefox.com pages, and allows the site to + communicate with the add-on */ + +"use strict"; + +this.sitehelper = (function() { + + // This gives us the content's copy of XMLHttpRequest, instead of the wrapped + // copy that this content script gets: + const ContentXMLHttpRequest = content.XMLHttpRequest; + + catcher.registerHandler((errorObj) => { + callBackground("reportError", errorObj); + }); + + + const capabilities = {}; + function registerListener(name, func) { + capabilities[name] = name; + document.addEventListener(name, func); + } + + function sendCustomEvent(name, detail) { + if (typeof detail === "object") { + // Note sending an object can lead to security problems, while a string + // is safe to transfer: + detail = JSON.stringify(detail); + } + document.dispatchEvent(new CustomEvent(name, {detail})); + } + + /** Set the cookie, even if third-party cookies are disabled in this browser + (when they are disabled, login from the background page won't set cookies) */ + function sendBackupCookieRequest(authHeaders) { + // See https://bugzilla.mozilla.org/show_bug.cgi?id=1295660 + // This bug would allow us to access window.content.XMLHttpRequest, and get + // a safer (not overridable by content) version of the object. + + // This is a very minimal attempt to verify that the XMLHttpRequest object we got + // is legitimate. It is not a good test. + if (Object.toString.apply(ContentXMLHttpRequest) !== "function XMLHttpRequest() {\n [native code]\n}") { + console.warn("Insecure copy of XMLHttpRequest"); + return; + } + const req = new ContentXMLHttpRequest(); + req.open("POST", "/api/set-login-cookie"); + for (const name in authHeaders) { + req.setRequestHeader(name, authHeaders[name]); + } + req.send(""); + req.onload = () => { + if (req.status !== 200) { + console.warn("Attempt to set Screenshots cookie via /api/set-login-cookie failed:", req.status, req.statusText, req.responseText); + } + }; + } + + registerListener("delete-everything", catcher.watchFunction((event) => { + // FIXME: reset some data in the add-on + }, false)); + + registerListener("request-login", catcher.watchFunction((event) => { + const shotId = event.detail; + catcher.watchPromise(callBackground("getAuthInfo", shotId || null).then((info) => { + if (info) { + sendBackupCookieRequest(info.authHeaders); + sendCustomEvent("login-successful", {deviceId: info.deviceId, accountId: info.accountId, isOwner: info.isOwner, backupCookieRequest: true}); + } + })); + })); + + registerListener("copy-to-clipboard", catcher.watchFunction(event => { + catcher.watchPromise(callBackground("copyShotToClipboard", event.detail)); + })); + + registerListener("show-notification", catcher.watchFunction(event => { + catcher.watchPromise(callBackground("showNotification", event.detail)); + })); + + // Depending on the script loading order, the site might get the addon-present event, + // but probably won't - instead the site will ask for that event after it has loaded + registerListener("request-addon-present", catcher.watchFunction(() => { + sendCustomEvent("addon-present", capabilities); + })); + + sendCustomEvent("addon-present", capabilities); + +})(); +null; diff --git a/browser/extensions/screenshots/test/browser/.eslintrc.yml b/browser/extensions/screenshots/test/browser/.eslintrc.yml new file mode 100644 index 0000000000..f5cf5d3929 --- /dev/null +++ b/browser/extensions/screenshots/test/browser/.eslintrc.yml @@ -0,0 +1,5 @@ +env: + node: true + +extends: + - plugin:mozilla/browser-test diff --git a/browser/extensions/screenshots/test/browser/browser.ini b/browser/extensions/screenshots/test/browser/browser.ini new file mode 100644 index 0000000000..9c27b311fe --- /dev/null +++ b/browser/extensions/screenshots/test/browser/browser.ini @@ -0,0 +1,4 @@ +[browser_screenshots_injection.js] +support-files = injection-page.html +[browser_screenshots_ui_check.js] +skip-if = os == 'win' && debug # Bug 1394967 diff --git a/browser/extensions/screenshots/test/browser/browser_screenshots_injection.js b/browser/extensions/screenshots/test/browser/browser_screenshots_injection.js new file mode 100644 index 0000000000..571d7d6615 --- /dev/null +++ b/browser/extensions/screenshots/test/browser/browser_screenshots_injection.js @@ -0,0 +1,72 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const TEST_PATH = getRootDirectory(gTestPath).replace( + "chrome://mochitests/content", + "https://example.com" +); + +/** + * Check that web content cannot break into screenshots. + */ +add_task(async function test_inject_srcdoc() { + // If Screenshots was disabled, enable it just for this test. + const addon = await AddonManager.getAddonByID("screenshots@mozilla.org"); + const isEnabled = addon.enabled; + if (!isEnabled) { + await addon.enable({allowSystemAddons: true}); + registerCleanupFunction(async () => { + await addon.disable({allowSystemAddons: true}); + }); + } + + await BrowserTestUtils.withNewTab(TEST_PATH + "injection-page.html", + async (browser) => { + // Set up the content hijacking. Do this so we can see it without + // awaiting - the promise should never resolve. + let response = null; + let responsePromise = SpecialPowers.spawn(browser, [], () => { + return new Promise(resolve => { + // We can't pass `resolve` directly because of sandboxing. + // `responseHandler` gets invoked from the content page. + content.wrappedJSObject.responseHandler = Cu.exportFunction( + function(arg) { + resolve(arg) + }, + content + ); + }); + }).then( + r => { + ok(false, "Should not have gotten HTML but got: " + r); + response = r; + }, + () => { + // Do nothing - we expect this to error when the test finishes + // and the actor is destroyed, while the promise still hasn't + // been resolved. We need to catch it in order not to throw + // uncaught rejection errors and inadvertently fail the test. + } + ); + + let error; + let errorPromise = new Promise(resolve => { + SpecialPowers.registerConsoleListener(msg => { + if (msg.message?.match(/iframe URL does not match expected blank.html/)) { + error = msg; + resolve(); + } + }); + }); + + // Now try to start the screenshot flow: + document.querySelector("keyset[id*=screenshot] > key").doCommand(); + await Promise.race([errorPromise, responsePromise]); + ok(error, "Should get the relevant error: " + error?.message); + ok(!response, "Should not get a response from the webpage."); + + SpecialPowers.postConsoleSentinel(); + }); +}); diff --git a/browser/extensions/screenshots/test/browser/browser_screenshots_ui_check.js b/browser/extensions/screenshots/test/browser/browser_screenshots_ui_check.js new file mode 100644 index 0000000000..f77d6c32ea --- /dev/null +++ b/browser/extensions/screenshots/test/browser/browser_screenshots_ui_check.js @@ -0,0 +1,94 @@ +"use strict"; + +ChromeUtils.defineModuleGetter(this, "AddonManager", + "resource://gre/modules/AddonManager.jsm"); + +const BUTTON_ID = "pageAction-panel-screenshots_mozilla_org"; + +function checkElements(expectPresent, l) { + for (const id of l) { + is(!!document.getElementById(id), expectPresent, "element " + id + (expectPresent ? " is" : " is not") + " present"); + } +} + +async function togglePageActionPanel() { + await promiseOpenPageActionPanel(); + EventUtils.synthesizeMouseAtCenter(BrowserPageActions.mainButtonNode, {}); + await promisePageActionPanelEvent("popuphidden"); +} + +function promiseOpenPageActionPanel() { + return BrowserTestUtils.waitForCondition(() => { + // Wait for the main page action button to become visible. It's hidden for + // some URIs, so depending on when this is called, it may not yet be quite + // visible. It's up to the caller to make sure it will be visible. + info("Waiting for main page action button to have non-0 size"); + const bounds = window.windowUtils.getBoundsWithoutFlushing(BrowserPageActions.mainButtonNode); + return bounds.width > 0 && bounds.height > 0; + }).then(() => { + // Wait for the panel to become open, by clicking the button if necessary. + info("Waiting for main page action panel to be open"); + if (BrowserPageActions.panelNode.state === "open") { + return Promise.resolve(); + } + const shownPromise = promisePageActionPanelEvent("popupshown"); + EventUtils.synthesizeMouseAtCenter(BrowserPageActions.mainButtonNode, {}); + return shownPromise; + }).then(() => { + // Wait for items in the panel to become visible. + return promisePageActionViewChildrenVisible(BrowserPageActions.mainViewNode); + }); +} + +function promisePageActionPanelEvent(eventType) { + return new Promise(resolve => { + const panel = BrowserPageActions.panelNode; + if ((eventType === "popupshown" && panel.state === "open") || + (eventType === "popuphidden" && panel.state === "closed")) { + executeSoon(resolve); + return; + } + panel.addEventListener(eventType, () => { + executeSoon(resolve); + }, { once: true }); + }); +} + +function promisePageActionViewChildrenVisible(panelViewNode) { + info("promisePageActionViewChildrenVisible waiting for a child node to be visible"); + return BrowserTestUtils.waitForCondition(() => { + const bodyNode = panelViewNode.firstElementChild; + for (const childNode of bodyNode.children) { + const bounds = window.windowUtils.getBoundsWithoutFlushing(childNode); + if (bounds.width > 0 && bounds.height > 0) { + return true; + } + } + return false; + }); +} + +add_task(async function() { + // If Screenshots was disabled, enable it just for this test. + const addon = await AddonManager.getAddonByID("screenshots@mozilla.org"); + const isEnabled = addon.enabled; + if (!isEnabled) { + await addon.enable({allowSystemAddons: true}); + registerCleanupFunction(async () => { + await addon.disable({allowSystemAddons: true}); + }); + } + + // Toggle the page action panel to get it to rebuild itself. An actionable + // page must be opened first. + const url = "http://example.com/browser_screenshots_ui_check"; + await BrowserTestUtils.withNewTab(url, async () => { // eslint-disable-line space-before-function-paren + await togglePageActionPanel(); + + await BrowserTestUtils.waitForCondition( + () => document.getElementById(BUTTON_ID), + "Screenshots button should be present", 100, 100); + + checkElements(true, [BUTTON_ID]); + }); +}); diff --git a/browser/extensions/screenshots/test/browser/injection-page.html b/browser/extensions/screenshots/test/browser/injection-page.html new file mode 100644 index 0000000000..c7579aa8b5 --- /dev/null +++ b/browser/extensions/screenshots/test/browser/injection-page.html @@ -0,0 +1,24 @@ +<body> +<script> +let callback = function(mutationsList, observer) { + for (let mutation of mutationsList) { + let [added] = mutation.addedNodes; + if (added instanceof HTMLIFrameElement && added.id == "firefox-screenshots-preview-iframe") { + added.srcdoc = "<html></html>"; + // Now we have to wait for the doc to be populated. + let interval = setInterval(() => { + console.log(added.contentDocument.innerHTML); + if (added.contentDocument.body.innerHTML) { + clearInterval(interval); + window.responseHandler(added.contentDocument.body.innerHTML); + } + }, 100); + observer.disconnect(); + } + } +}; + +observer = new MutationObserver(callback); +observer.observe(document.body, {childList: true}); +</script> +</body> diff --git a/browser/extensions/webcompat/about-compat/AboutCompat.jsm b/browser/extensions/webcompat/about-compat/AboutCompat.jsm new file mode 100644 index 0000000000..a90bd8ed80 --- /dev/null +++ b/browser/extensions/webcompat/about-compat/AboutCompat.jsm @@ -0,0 +1,36 @@ +/* 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 EXPORTED_SYMBOLS = ["AboutCompat"]; + +const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); + +const addonID = "webcompat@mozilla.org"; +const addonPageRelativeURL = "/about-compat/aboutCompat.html"; + +function AboutCompat() { + this.chromeURL = WebExtensionPolicy.getByID(addonID).getURL( + addonPageRelativeURL + ); +} +AboutCompat.prototype = { + QueryInterface: ChromeUtils.generateQI(["nsIAboutModule"]), + getURIFlags() { + return Ci.nsIAboutModule.URI_MUST_LOAD_IN_EXTENSION_PROCESS; + }, + + newChannel(aURI, aLoadInfo) { + const uri = Services.io.newURI(this.chromeURL); + const channel = Services.io.newChannelFromURIWithLoadInfo(uri, aLoadInfo); + channel.originalURI = aURI; + + channel.owner = ( + Services.scriptSecurityManager.createContentPrincipal || + Services.scriptSecurityManager.createCodebasePrincipal + )(uri, aLoadInfo.originAttributes); + return channel; + }, +}; diff --git a/browser/extensions/webcompat/about-compat/aboutCompat.css b/browser/extensions/webcompat/about-compat/aboutCompat.css new file mode 100644 index 0000000000..492296dfcf --- /dev/null +++ b/browser/extensions/webcompat/about-compat/aboutCompat.css @@ -0,0 +1,187 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +@media (any-pointer: fine) { + :root { + font-family: sans-serif; + margin: 40px auto; + min-width: 30em; + max-width: 60em; + } + + table { + width: 100%; + padding-bottom: 2em; + } + + .hidden { + display: none; + } + + .table-title-container { + align-items: center; + display: flex; + justify-content: space-between; + } + + .wide-button { + display: block; + min-height: 32px; + padding-inline: 30px; + } + + .submitting { + background-image: url(chrome://global/skin/icons/loading.png); + background-position: center; + background-repeat: no-repeat; + background-size: 16px; + } + + .submitting .submit-crash-button-label { + display: none; + } + + .failed-to-submit { + color: #ca8695; + } + + a.button-as-link { + appearance: none; + min-height: 30px; + color: var(--in-content-text-color) !important; + border: 1px solid var(--in-content-box-border-color) !important; + border-radius: 2px; + background-color: var(--in-content-page-background); + line-height: 30px; + margin: 4px 8px; + /* Ensure font-size isn't overridden by widget styling (e.g. in forms.css) */ + font-size: 1em; + } + + a.button-as-link:hover { + background-color: var(--in-content-box-background-hover) !important; + text-decoration: none; + } + + h2.lighter-font-weight { + font-weight: lighter; + } + + th { + text-align: start; + } +} + +@media (any-pointer: coarse), (any-pointer: none) { + * { + margin: 0; + padding: 0; + } + + html { + font-family: sans-serif; + font-size: 14px; + -moz-text-size-adjust: none; + background-color: #f5f5f5; + } + + table, + tr, + p { + display: block; + background: #fff; + } + + table { + border-top: 2px solid #0a84ff; + margin-top: -2px; + position: absolute; + width: 100%; + z-index: 1; + display: none; + } + + tr { + position: relative; + border-bottom: 1px solid #d7d9db; + padding: 1em; + } + + a { + color: #000; + font-size: 94%; + } + + .tab { + cursor: pointer; + position: relative; + z-index: 2; + display: inline-block; + text-align: left; + padding: 1em; + font-weight: bold; + border-top-left-radius: 3px; + border-top-right-radius: 3px; + border: 1px solid #d7d9db; + border-bottom: 0; + margin-bottom: 2px; + background: #f5f5f5; + color: #363b40; + font-size: 1em; + font-weight: bold; + padding: 1em; + } + + .tab.active { + border-bottom-color: #fff; + background: #fff; + margin-bottom: 0; + padding-bottom: calc(1em + 2px); + } + + .tab.active + table { + display: block; + } + + td { + display: block; + position: relative; + padding-inline-end: 6.5em; + } + + td[colspan="4"] { + padding: 1em; + font-style: italic; + text-align: center; + } + + td:not([colspan]):nth-child(1) { + font-weight: bold; + } + + td:not([colspan]):nth-child(1) { + padding-bottom: 0.25em; + } + + td:nth-child(3) { + display: contents; + } + + button { + background: #e8e8e7; + position: absolute; + top: 0; + bottom: 0; + inset-inline-end: 0; + width: 6em; + border: 0; + border-inline-start: 1px solid #d7d9db; + appearance: none; + color: #000; + } + + button::-moz-focus-inner { + border: 0; + } +} diff --git a/browser/extensions/webcompat/about-compat/aboutCompat.html b/browser/extensions/webcompat/about-compat/aboutCompat.html new file mode 100644 index 0000000000..31180f0b30 --- /dev/null +++ b/browser/extensions/webcompat/about-compat/aboutCompat.html @@ -0,0 +1,37 @@ +<!-- 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/. --> + +<!DOCTYPE HTML> +<html> +<head> + <base/> + + <!-- If you change this script tag you must update the hash in the extension's + `content_security_policy` 'sha256-MmZkN2QaIHhfRWPZ8TVRjijTn5Ci1iEabtTEWrt9CCo=' --> + <script>/* globals browser */ document.head.firstElementChild.href = browser.runtime.getURL("");</script> + + <meta charset="utf-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <link rel="stylesheet" href="about-compat/aboutCompat.css" /> + <link rel="stylesheet" media="screen and (pointer:fine), projection" type="text/css" + href="chrome://global/skin/in-content/common.css"/> + <link rel="localization" href="toolkit/about/aboutCompat.ftl"/> + <title data-l10n-id="text-title"></title> + <script src="about-compat/aboutCompat.js"></script> + </head> +<body> + <h2 class="tab active" data-l10n-id="label-overrides"></h2> + <table id="overrides"> + <col/> + <col/> + <col/> + </table> + <h2 class="tab" data-l10n-id="label-interventions"></h2> + <table id="interventions"> + <col/> + <col/> + <col/> + </table> +</body> +</html> diff --git a/browser/extensions/webcompat/about-compat/aboutCompat.js b/browser/extensions/webcompat/about-compat/aboutCompat.js new file mode 100644 index 0000000000..e5c9c4ff17 --- /dev/null +++ b/browser/extensions/webcompat/about-compat/aboutCompat.js @@ -0,0 +1,171 @@ +/* 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"; + +/* globals browser */ + +let availablePatches; + +const portToAddon = (function() { + let port; + + function connect() { + port = browser.runtime.connect({ name: "AboutCompatTab" }); + port.onMessage.addListener(onMessageFromAddon); + port.onDisconnect.addListener(e => { + port = undefined; + }); + } + + connect(); + + async function send(message) { + if (port) { + return port.postMessage(message); + } + return Promise.reject("background script port disconnected"); + } + + return { send }; +})(); + +const $ = function(sel) { + return document.querySelector(sel); +}; + +const DOMContentLoadedPromise = new Promise(resolve => { + document.addEventListener( + "DOMContentLoaded", + () => { + resolve(); + }, + { once: true } + ); +}); + +Promise.all([ + browser.runtime.sendMessage("getOverridesAndInterventions"), + DOMContentLoadedPromise, +]).then(([info]) => { + document.body.addEventListener("click", async evt => { + const ele = evt.target; + if (ele.nodeName === "BUTTON") { + const row = ele.closest("[data-id]"); + if (row) { + evt.preventDefault(); + ele.disabled = true; + const id = row.getAttribute("data-id"); + try { + await browser.runtime.sendMessage({ command: "toggle", id }); + } catch (_) { + ele.disabled = false; + } + } + } else if (ele.classList.contains("tab")) { + document.querySelectorAll(".tab").forEach(tab => { + tab.classList.remove("active"); + }); + ele.classList.add("active"); + } + }); + + availablePatches = info; + redraw(); +}); + +function onMessageFromAddon(msg) { + if ("interventionsChanged" in msg) { + redrawTable($("#interventions"), msg.interventionsChanged); + } + + if ("overridesChanged" in msg) { + redrawTable($("#overrides"), msg.overridesChanged); + } + + const id = msg.toggling || msg.toggled; + const button = $(`[data-id="${id}"] button`); + if (!button) { + return; + } + const active = msg.active; + document.l10n.setAttributes( + button, + active ? "label-disable" : "label-enable" + ); + button.disabled = !!msg.toggling; +} + +function redraw() { + if (!availablePatches) { + return; + } + const { overrides, interventions } = availablePatches; + const showHidden = location.hash === "#all"; + redrawTable($("#overrides"), overrides, showHidden); + redrawTable($("#interventions"), interventions, showHidden); +} + +function redrawTable(table, data, showHidden = false) { + const df = document.createDocumentFragment(); + table.querySelectorAll("tr").forEach(tr => { + tr.remove(); + }); + + let noEntriesMessage; + if (data === false) { + noEntriesMessage = "text-disabled-in-about-config"; + } else if (data.length === 0) { + noEntriesMessage = + table.id === "overrides" ? "text-no-overrides" : "text-no-interventions"; + } + + if (noEntriesMessage) { + const tr = document.createElement("tr"); + df.appendChild(tr); + + const td = document.createElement("td"); + td.setAttribute("colspan", "3"); + document.l10n.setAttributes(td, noEntriesMessage); + tr.appendChild(td); + + table.appendChild(df); + return; + } + + for (const row of data) { + if (row.hidden && !showHidden) { + continue; + } + + const tr = document.createElement("tr"); + tr.setAttribute("data-id", row.id); + df.appendChild(tr); + + let td = document.createElement("td"); + td.innerText = row.domain; + tr.appendChild(td); + + td = document.createElement("td"); + const a = document.createElement("a"); + const bug = row.bug; + a.href = `https://bugzilla.mozilla.org/show_bug.cgi?id=${bug}`; + document.l10n.setAttributes(a, "label-more-information", { bug }); + a.target = "_blank"; + td.appendChild(a); + tr.appendChild(td); + + td = document.createElement("td"); + tr.appendChild(td); + const button = document.createElement("button"); + document.l10n.setAttributes( + button, + row.active ? "label-disable" : "label-enable" + ); + td.appendChild(button); + } + table.appendChild(df); +} + +window.onhashchange = redraw; diff --git a/browser/extensions/webcompat/about-compat/aboutPage.js b/browser/extensions/webcompat/about-compat/aboutPage.js new file mode 100644 index 0000000000..e719551332 --- /dev/null +++ b/browser/extensions/webcompat/about-compat/aboutPage.js @@ -0,0 +1,48 @@ +/* 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"; + +/* global ExtensionAPI, Services, XPCOMUtils */ + +ChromeUtils.defineModuleGetter( + this, + "Services", + "resource://gre/modules/Services.jsm" +); + +XPCOMUtils.defineLazyServiceGetter( + this, + "resProto", + "@mozilla.org/network/protocol;1?name=resource", + "nsISubstitutingProtocolHandler" +); + +const ResourceSubstitution = "webcompat"; +const ProcessScriptURL = "resource://webcompat/aboutPageProcessScript.js"; +const ContractID = "@mozilla.org/network/protocol/about;1?what=compat"; + +this.aboutPage = class extends ExtensionAPI { + onStartup() { + const { rootURI } = this.extension; + + resProto.setSubstitution( + ResourceSubstitution, + Services.io.newURI("about-compat/", null, rootURI) + ); + + if (!(ContractID in Cc)) { + Services.ppmm.loadProcessScript(ProcessScriptURL, true); + this.processScriptRegistered = true; + } + } + + onShutdown() { + resProto.setSubstitution(ResourceSubstitution, null); + + if (this.processScriptRegistered) { + Services.ppmm.removeDelayedProcessScript(ProcessScriptURL); + } + } +}; diff --git a/browser/extensions/webcompat/about-compat/aboutPage.json b/browser/extensions/webcompat/about-compat/aboutPage.json new file mode 100644 index 0000000000..42e6114188 --- /dev/null +++ b/browser/extensions/webcompat/about-compat/aboutPage.json @@ -0,0 +1,6 @@ +[ + { + "namespace": "aboutCompat", + "description": "Enables the about:compat page" + } +] diff --git a/browser/extensions/webcompat/about-compat/aboutPageProcessScript.js b/browser/extensions/webcompat/about-compat/aboutPageProcessScript.js new file mode 100644 index 0000000000..e2e9866eb1 --- /dev/null +++ b/browser/extensions/webcompat/about-compat/aboutPageProcessScript.js @@ -0,0 +1,32 @@ +/* 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"; + +// Note: This script is used only when a static registration for our +// component is not already present in the libxul binary. + +const Cm = Components.manager.QueryInterface(Ci.nsIComponentRegistrar); + +const classID = Components.ID("{97bf9550-2a7b-11e9-b56e-0800200c9a66}"); + +if (!Cm.isCIDRegistered(classID)) { + const { ComponentUtils } = ChromeUtils.import( + "resource://gre/modules/ComponentUtils.jsm" + ); + + const factory = ComponentUtils.generateSingletonFactory(function() { + const { AboutCompat } = ChromeUtils.import( + "resource://webcompat/AboutCompat.jsm" + ); + return new AboutCompat(); + }); + + Cm.registerFactory( + classID, + "about:compat", + "@mozilla.org/network/protocol/about;1?what=compat", + factory + ); +} diff --git a/browser/extensions/webcompat/components.conf b/browser/extensions/webcompat/components.conf new file mode 100644 index 0000000000..ca5a6c3dbd --- /dev/null +++ b/browser/extensions/webcompat/components.conf @@ -0,0 +1,17 @@ +# -*- 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/. + +# Note: This file will add static component registration entries for our +# components to the libxul binary, though the actual component JSMs will be +# packaged with the extension. +Classes = [ + { + 'cid': '{97bf9550-2a7b-11e9-b56e-0800200c9a66}', + 'contract_ids': ['@mozilla.org/network/protocol/about;1?what=compat'], + 'jsm': 'resource://webcompat/AboutCompat.jsm', + 'constructor': 'AboutCompat', + }, +] diff --git a/browser/extensions/webcompat/data/injections.js b/browser/extensions/webcompat/data/injections.js new file mode 100644 index 0000000000..d97deca4d6 --- /dev/null +++ b/browser/extensions/webcompat/data/injections.js @@ -0,0 +1,467 @@ +/* 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"; + +/* globals module, require */ + +// This is a hack for the tests. +if (typeof InterventionHelpers === "undefined") { + var InterventionHelpers = require("../lib/intervention_helpers"); +} + +/** + * For detailed information on our policies, and a documention on this format + * and its possibilites, please check the Mozilla-Wiki at + * + * https://wiki.mozilla.org/Compatibility/Go_Faster_Addon/Override_Policies_and_Workflows#User_Agent_overrides + */ +const AVAILABLE_INJECTIONS = [ + { + id: "testbed-injection", + platform: "all", + domain: "webcompat-addon-testbed.herokuapp.com", + bug: "0000000", + hidden: true, + contentScripts: { + matches: ["*://webcompat-addon-testbed.herokuapp.com/*"], + css: [ + { + file: "injections/css/bug0000000-testbed-css-injection.css", + }, + ], + js: [ + { + file: "injections/js/bug0000000-testbed-js-injection.js", + }, + ], + }, + }, + { + id: "bug1452707", + platform: "desktop", + domain: "ib.absa.co.za", + bug: "1452707", + contentScripts: { + matches: ["https://ib.absa.co.za/*"], + js: [ + { + file: + "injections/js/bug1452707-window.controllers-shim-ib.absa.co.za.js", + }, + ], + }, + }, + { + id: "bug1457335", + platform: "desktop", + domain: "histography.io", + bug: "1457335", + contentScripts: { + matches: ["*://histography.io/*"], + js: [ + { + file: "injections/js/bug1457335-histography.io-ua-change.js", + }, + ], + }, + }, + { + id: "bug1472075", + platform: "desktop", + domain: "bankofamerica.com", + bug: "1472075", + contentScripts: { + matches: ["*://*.bankofamerica.com/*"], + js: [ + { + file: "injections/js/bug1472075-bankofamerica.com-ua-change.js", + }, + ], + }, + }, + { + id: "bug1570856", + platform: "android", + domain: "medium.com", + bug: "1570856", + contentScripts: { + matches: ["*://medium.com/*"], + js: [ + { + file: "injections/js/bug1570856-medium.com-menu-isTier1.js", + }, + ], + allFrames: true, + }, + }, + { + id: "bug1579159", + platform: "android", + domain: "m.tailieu.vn", + bug: "1579159", + contentScripts: { + matches: ["*://m.tailieu.vn/*", "*://m.elib.vn/*"], + js: [ + { + file: "injections/js/bug1579159-m.tailieu.vn-pdfjs-worker-disable.js", + }, + ], + allFrames: true, + }, + }, + { + id: "bug1551672", + platform: "android", + domain: "Sites using PDK 5 video", + bug: "1551672", + data: { + urls: ["https://*/*/tpPdk.js", "https://*/*/pdk/js/*/*.js"], + types: ["script"], + }, + customFunc: "pdk5fix", + }, + { + id: "bug1583366", + platform: "desktop", + domain: "Download prompt for files with no content-type", + bug: "1583366", + data: { + urls: ["https://ads-us.rd.linksynergy.com/as.php*"], + contentType: { + name: "content-type", + value: "text/html; charset=utf-8", + }, + }, + customFunc: "noSniffFix", + }, + { + id: "bug1561371", + platform: "android", + domain: "mail.google.com", + bug: "1561371", + contentScripts: { + matches: ["*://mail.google.com/*"], + css: [ + { + file: + "injections/css/bug1561371-mail.google.com-allow-horizontal-scrolling.css", + }, + ], + }, + }, + { + id: "bug1570119", + platform: "desktop", + domain: "teamcoco.com", + bug: "1570119", + contentScripts: { + matches: ["*://teamcoco.com/*"], + css: [ + { + file: "injections/css/bug1570119-teamcoco.com-scrollbar-width.css", + }, + ], + }, + }, + { + id: "bug1570328", + platform: "android", + domain: "developer.apple.com", + bug: "1570328", + contentScripts: { + matches: ["*://developer.apple.com/*"], + css: [ + { + file: + "injections/css/bug1570328-developer-apple.com-transform-scale.css", + }, + ], + }, + }, + { + id: "bug1575000", + platform: "all", + domain: "apply.lloydsbank.co.uk", + bug: "1575000", + contentScripts: { + matches: ["*://apply.lloydsbank.co.uk/*"], + css: [ + { + file: + "injections/css/bug1575000-apply.lloydsbank.co.uk-radio-buttons-fix.css", + }, + ], + }, + }, + { + id: "bug1605611", + platform: "android", + domain: "maps.google.com", + bug: "1605611", + contentScripts: { + matches: InterventionHelpers.matchPatternsForGoogle( + "*://www.google.", + "/maps*" + ), + css: [ + { + file: "injections/css/bug1605611-maps.google.com-directions-time.css", + }, + ], + js: [ + { + file: "injections/js/bug1605611-maps.google.com-directions-time.js", + }, + ], + }, + }, + { + id: "bug1610016", + platform: "android", + domain: "gaana.com", + bug: "1610016", + contentScripts: { + matches: ["https://gaana.com/*"], + css: [ + { + file: "injections/css/bug1610016-gaana.com-input-position-fix.css", + }, + ], + }, + }, + { + id: "bug1610358", + platform: "android", + domain: "pcloud.com", + bug: "1610358", + contentScripts: { + matches: ["https://www.pcloud.com/*"], + js: [ + { + file: "injections/js/bug1610358-pcloud.com-appVersion-change.js", + }, + ], + }, + }, + { + id: "bug1610344", + platform: "all", + domain: "directv.com.co", + bug: "1610344", + contentScripts: { + matches: ["https://*.directv.com.co/*"], + css: [ + { + file: + "injections/css/bug1610344-directv.com.co-hide-unsupported-message.css", + }, + ], + }, + }, + { + id: "bug1622062", + platform: "android", + domain: "$.detectSwipe fix", + bug: "1622062", + data: { + urls: ["https://eu.stemwijzer.nl/public/js/votematch.vendors.js"], + types: ["script"], + }, + customFunc: "detectSwipeFix", + }, + { + id: "bug1644830", + platform: "desktop", + domain: "usps.com", + bug: "1644830", + contentScripts: { + matches: ["https://*.usps.com/*"], + css: [ + { + file: + "injections/css/bug1644830-missingmail.usps.com-checkboxes-not-visible.css", + }, + ], + }, + }, + { + id: "bug1645064", + platform: "desktop", + domain: "s-kanava.fi", + bug: "1645064", + contentScripts: { + matches: ["https://www.s-kanava.fi/*"], + css: [ + { + file: "injections/css/bug1645064-s-kanava.fi-invisible-charts.css", + }, + ], + }, + }, + { + id: "bug1651917", + platform: "android", + domain: "teletrader.com", + bug: "1651917", + contentScripts: { + matches: ["*://*.teletrader.com/*"], + css: [ + { + file: + "injections/css/bug1651917-teletrader.com.body-transform-origin.css", + }, + ], + }, + }, + { + id: "bug1653075", + platform: "desktop", + domain: "livescience.com", + bug: "1653075", + contentScripts: { + matches: ["*://*.livescience.com/*"], + css: [ + { + file: "injections/css/bug1653075-livescience.com-scrollbar-width.css", + }, + ], + }, + }, + { + id: "bug1654865", + platform: "android", + domain: "sports.ndtv.com", + bug: "1654865", + contentScripts: { + matches: ["*://sports.ndtv.com/*"], + css: [ + { + file: "injections/css/bug1654865-sports.ndtv.com-float-fix.css", + }, + ], + }, + }, + { + id: "bug1654877", + platform: "android", + domain: "preev.com", + bug: "1654877", + contentScripts: { + matches: ["*://preev.com/*"], + css: [ + { + file: "injections/css/bug1654877-preev.com-moz-appearance-fix.css", + }, + ], + }, + }, + { + id: "bug1655049", + platform: "android", + domain: "dev.to", + bug: "1655049", + contentScripts: { + matches: ["*://dev.to/*"], + css: [ + { + file: "injections/css/bug1655049-dev.to-unclickable-button-fix.css", + }, + ], + }, + }, + { + id: "bug1654907", + platform: "android", + domain: "reactine.ca", + bug: "1654907", + contentScripts: { + matches: ["*://*.reactine.ca/*"], + css: [ + { + file: "injections/css/bug1654907-reactine.ca-hide-unsupported.css", + }, + ], + }, + }, + { + id: "bug1666771", + platform: "desktop", + domain: "zillow.com", + bug: "1666771", + contentScripts: { + allFrames: true, + matches: ["*://*.zillow.com/*"], + css: [ + { + file: "injections/css/bug1666771-zilow-map-overdraw.css", + }, + ], + }, + }, + { + id: "bug1631811", + platform: "all", + domain: "datastudio.google.com", + bug: "1631811", + contentScripts: { + matches: ["https://datastudio.google.com/embed/reporting/*"], + js: [ + { + file: "injections/js/bug1631811-datastudio.google.com-indexedDB.js", + }, + ], + allFrames: true, + }, + }, + { + id: "bug1665035", + platform: "desktop", + domain: "dckids.com", + bug: "1665035", + contentScripts: { + matches: [ + "https://d3qlaywcwingl6.cloudfront.net/content/*/Html5Game/*", + "https://d3qlaywcwingl6.cloudfront.net/*/game/content/*", + ], + js: [ + { + file: "injections/js/bug1665035-dckids.com-cookieEnabled.js", + }, + ], + allFrames: true, + }, + }, + { + id: "bug1677442", + platform: "desktop", + domain: "store.hp.com", + bug: "1677442", + contentScripts: { + matches: ["*://d3nkfb7815bs43.cloudfront.net/*forstore.hp.com*"], + js: [ + { + file: "injections/js/bug1677442-store.hp.com-disable-indexeddb.js", + }, + ], + allFrames: true, + }, + }, + { + id: "bug1682238", + platform: "desktop", + domain: "gamearter.com", + bug: "1682238", + contentScripts: { + matches: ["*://*.gamearter.com/*"], + js: [ + { + file: "injections/js/bug1682238-gamearter.com-ua-change.js", + }, + ], + }, + }, +]; + +module.exports = AVAILABLE_INJECTIONS; diff --git a/browser/extensions/webcompat/data/picture_in_picture_overrides.js b/browser/extensions/webcompat/data/picture_in_picture_overrides.js new file mode 100644 index 0000000000..e9a4e88657 --- /dev/null +++ b/browser/extensions/webcompat/data/picture_in_picture_overrides.js @@ -0,0 +1,60 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +/* globals browser */ + +let AVAILABLE_PIP_OVERRIDES; + +{ + // See PictureInPictureControls.jsm for these values. + // eslint-disable-next-line no-unused-vars + const TOGGLE_POLICIES = browser.pictureInPictureChild.getPolicies(); + const KEYBOARD_CONTROLS = browser.pictureInPictureChild.getKeyboardControls(); + + AVAILABLE_PIP_OVERRIDES = { + // The keys of this object are match patterns for URLs, as documented in + // https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/Match_patterns + // + // Example: + // + // "https://*.youtube.com/*": { + // policy: TOGGLE_POLICIES.THREE_QUARTERS, + // keyboardControls: KEYBOARD_CONTROLS.PLAY_PAUSE | KEYBOARD_CONTROLS.VOLUME, + // }, + // "https://*.twitch.tv/mikeconley_dot_ca/*": { + // policy: TOGGLE_POLICIES.TOP, + // keyboardControls: KEYBOARD_CONTROLS.NONE, + // }, + + instagram: { + "https://www.instagram.com/*": { policy: TOGGLE_POLICIES.ONE_QUARTER }, + }, + + laracasts: { + "https://*.laracasts.com/*": { policy: TOGGLE_POLICIES.ONE_QUARTER }, + }, + + netflix: { + "https://*.netflix.com/*": { keyboardControls: ~KEYBOARD_CONTROLS.SEEK }, + "https://*.netflix.com/browse": { policy: TOGGLE_POLICIES.HIDDEN }, + "https://*.netflix.com/latest": { policy: TOGGLE_POLICIES.HIDDEN }, + }, + + twitch: { + "https://*.twitch.tv/*": { policy: TOGGLE_POLICIES.ONE_QUARTER }, + "https://*.twitch.tech/*": { policy: TOGGLE_POLICIES.ONE_QUARTER }, + "https://*.twitch.a2z.com/*": { policy: TOGGLE_POLICIES.ONE_QUARTER }, + }, + + udemy: { + "https://*.udemy.com/*": { policy: TOGGLE_POLICIES.ONE_QUARTER }, + }, + + youtube: { + "https://*.youtube.com/*": { visibilityThreshold: 0.9 }, + }, + }; +} diff --git a/browser/extensions/webcompat/data/shims.js b/browser/extensions/webcompat/data/shims.js new file mode 100644 index 0000000000..8abdffac39 --- /dev/null +++ b/browser/extensions/webcompat/data/shims.js @@ -0,0 +1,253 @@ +/* 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"; + +/* globals module, require */ + +const AVAILABLE_SHIMS = [ + { + id: "LiveTestShim", + platform: "all", + name: "Live test shim", + bug: "livetest", + file: "live-test-shim.js", + matches: ["*://webcompat-addon-testbed.herokuapp.com/shims_test.js"], + needsShimHelpers: ["getOptions", "optIn"], + }, + { + id: "MochitestShim", + platform: "all", + name: "Test shim for Mochitests", + bug: "mochitest", + file: "mochitest-shim-1.js", + matches: [ + "*://example.com/browser/browser/extensions/webcompat/tests/browser/shims_test.js", + ], + needsShimHelpers: ["getOptions", "optIn"], + options: { + simpleOption: true, + complexOption: { a: 1, b: "test" }, + branchValue: { value: true, branches: [] }, + platformValue: { value: true, platform: "neverUsed" }, + }, + unblocksOnOptIn: ["*://trackertest.org/*"], + }, + { + disabled: true, + id: "MochitestShim2", + platform: "all", + name: "Test shim for Mochitests (disabled by default)", + bug: "mochitest", + file: "mochitest-shim-2.js", + matches: [ + "*://example.com/browser/browser/extensions/webcompat/tests/browser/shims_test_2.js", + ], + needsShimHelpers: ["getOptions", "optIn"], + options: { + simpleOption: true, + complexOption: { a: 1, b: "test" }, + branchValue: { value: true, branches: [] }, + platformValue: { value: true, platform: "neverUsed" }, + }, + unblocksOnOptIn: ["*://trackertest.org/*"], + }, + { + id: "MochitestShim3", + platform: "all", + name: "Test shim for Mochitests (host)", + bug: "mochitest", + file: "mochitest-shim-3.js", + notHosts: ["example.com"], + matches: [ + "*://example.com/browser/browser/extensions/webcompat/tests/browser/shims_test_3.js", + ], + }, + { + id: "MochitestShim4", + platform: "all", + name: "Test shim for Mochitests (notHost)", + bug: "mochitest", + file: "mochitest-shim-3.js", + hosts: ["example.net"], + matches: [ + "*://example.com/browser/browser/extensions/webcompat/tests/browser/shims_test_3.js", + ], + }, + { + id: "MochitestShim5", + platform: "all", + name: "Test shim for Mochitests (branch)", + bug: "mochitest", + file: "mochitest-shim-3.js", + branches: ["never matches"], + matches: [ + "*://example.com/browser/browser/extensions/webcompat/tests/browser/shims_test_3.js", + ], + }, + { + id: "MochitestShim6", + platform: "never matches", + name: "Test shim for Mochitests (platform)", + bug: "mochitest", + file: "mochitest-shim-3.js", + matches: [ + "*://example.com/browser/browser/extensions/webcompat/tests/browser/shims_test_3.js", + ], + }, + { + id: "AdSafeProtectedGoogleIMAAdapter", + platform: "all", + branches: ["nightly"], + name: "Ad Safe Protected Google IMA Adapter", + bug: "1508639", + file: "adsafeprotected-ima.js", + matches: ["*://static.adsafeprotected.com/vans-adapter-google-ima.js"], + needsShimHelpers: ["optIn"], + onlyIfBlockedByETP: true, + unblocksOnOptIn: ["*://pubads.g.doubleclick.net/gampad/ads"], + }, + { + id: "AdsByGoogle", + platform: "all", + branches: ["nightly"], + name: "Ads by Google", + bug: "1629644", + file: "empty-script.js", + matches: ["*://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js"], + onlyIfBlockedByETP: true, + }, + { + id: "BmAuth", + platform: "all", + branches: ["nightly"], + name: "BmAuth by 9c9media", + bug: "1486337", + file: "bmauth.js", + matches: ["*://auth.9c9media.ca/auth/main.js"], + onlyIfBlockedByETP: true, + }, + { + id: "Eluminate", + platform: "all", + branches: ["nightly"], + name: "Eluminate", + bug: "1503211", + file: "eluminate.js", + matches: ["*://libs.coremetrics.com/eluminate.js"], + onlyIfBlockedByETP: true, + }, + { + id: "FacebookSDK", + platform: "all", + branches: ["nightly"], + name: "Facebook SDK", + bug: "1226498", + file: "facebook-sdk.js", + matches: [ + "*://connect.facebook.net/*/sdk.js*", + "*://connect.facebook.net/*/all.js*", + ], + needsShimHelpers: ["optIn"], + onlyIfBlockedByETP: true, + unblocksOnOptIn: [ + "*://*.xx.fbcdn.net/*", // covers: + // "*://scontent-.*-\d.xx.fbcdn.net/*", + // "*://static.xx.fbcdn.net/rsrc.php/*", + + "*://www.facebook.com/plugins/comments.php*", + "*://www.facebook.com/plugins/comments/async/*", + "*://www.facebook.com/plugins/feedback.php*", + "*://www.facebook.com/plugins/like_box.php*", + ], + }, + { + id: "GoogleAnalytics", + platform: "all", + branches: ["nightly"], + name: "Google Analytics", + bug: "1493602", + file: "google-analytics.js", + matches: ["*://www.google-analytics.com/analytics.js"], + onlyIfBlockedByETP: true, + }, + { + id: "GoogleAnalyticsECommercePlugin", + platform: "all", + branches: ["nightly"], + name: "Google Analytics E-Commerce Plugin", + bug: "1620533", + file: "google-analytics-ecommerce-plugin.js", + matches: ["*://www.google-analytics.com/plugins/ua/ec.js"], + onlyIfBlockedByETP: true, + }, + { + id: "GoogleAnalyticsTagManager", + platform: "all", + branches: ["nightly"], + name: "Google Analytics Tag Manager", + bug: "1478593", + file: "google-analytics-tag-manager.js", + matches: ["*://www.google-analytics.com/gtm/js"], + onlyIfBlockedByETP: true, + }, + { + id: "GoogleAnalyticsLegacy", + platform: "all", + branches: ["nightly"], + name: "Legacy Google Analytics", + bug: "1487072", + file: "google-analytics-legacy.js", + matches: ["*://ssl.google-analytics.com/ga.js"], + onlyIfBlockedByETP: true, + }, + { + id: "GooglePublisherTags", + platform: "all", + branches: ["nightly"], + name: "Google Publisher Tags", + bug: "1600538", + file: "google-publisher-tags.js", + matches: [ + "*://www.googletagservices.com/tag/js/gpt.js", + "*://securepubads.g.doubleclick.net/tag/js/gpt.js", + "*://securepubads.g.doubleclick.net/gpt/pubads_impl_*.js", + ], + onlyIfBlockedByETP: true, + unblocksOnOptIn: ["*://pubads.g.doubleclick.net/ssai/event/*/streams"], + }, + { + id: "IMA3", + platform: "all", + branches: ["nightly"], + name: "IMA3", + bug: "1487373", + file: "empty-script.js", + onlyIfBlockedByETP: true, + matches: ["*://s0.2mdn.net/instream/html5/ima3.js"], + }, + { + id: "Rambler", + platform: "all", + branches: ["nightly"], + name: "Rambler Authenticator", + bug: "1606428", + file: "rambler-authenticator.js", + matches: ["*://id.rambler.ru/rambler-id-helper/auth_events.js"], + needsShimHelpers: ["optIn"], + onlyIfBlockedByETP: true, + }, + { + id: "RichRelevance", + platform: "all", + branches: ["nightly"], + name: "Rich Relevance", + bug: "1449347", + file: "rich-relevance.js", + matches: ["*://media.richrelevance.com/rrserver/js/1.2/p13n.js"], + onlyIfBlockedByETP: true, + }, +]; + +module.exports = AVAILABLE_SHIMS; diff --git a/browser/extensions/webcompat/data/ua_overrides.js b/browser/extensions/webcompat/data/ua_overrides.js new file mode 100644 index 0000000000..daa947989c --- /dev/null +++ b/browser/extensions/webcompat/data/ua_overrides.js @@ -0,0 +1,686 @@ +/* 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"; + +/* globals browser, module, require */ + +// This is a hack for the tests. +if (typeof InterventionHelpers === "undefined") { + var InterventionHelpers = require("../lib/intervention_helpers"); +} + +/** + * For detailed information on our policies, and a documention on this format + * and its possibilites, please check the Mozilla-Wiki at + * + * https://wiki.mozilla.org/Compatibility/Go_Faster_Addon/Override_Policies_and_Workflows#User_Agent_overrides + */ +const AVAILABLE_UA_OVERRIDES = [ + { + id: "testbed-override", + platform: "all", + domain: "webcompat-addon-testbed.herokuapp.com", + bug: "0000000", + config: { + hidden: true, + matches: ["*://webcompat-addon-testbed.herokuapp.com/*"], + uaTransformer: originalUA => { + return ( + UAHelpers.getPrefix(originalUA) + + " AppleWebKit/537.36 (KHTML, like Gecko) Chrome/54.0.2840.98 Safari/537.36 for WebCompat" + ); + }, + }, + }, + { + /* + * Bug 1577519 - att.tv - Create a UA override for att.tv for playback on desktop + * WebCompat issue #3846 - https://webcompat.com/issues/3846 + * + * att.tv (atttvnow.com) is blocking Firefox via UA sniffing. Spoofing as Chrome allows + * to access the site and playback works fine. This is former directvnow.com + */ + id: "bug1577519", + platform: "desktop", + domain: "att.tv", + bug: "1577519", + config: { + matches: ["*://*.att.tv/*"], + uaTransformer: originalUA => { + return ( + UAHelpers.getPrefix(originalUA) + + " AppleWebKit/537.36 (KHTML, like Gecko) Chrome/76.0.3809.132 Safari/537.36" + ); + }, + }, + }, + { + /* + * Bug 1570108 - steamcommunity.com - UA override for steamcommunity.com + * WebCompat issue #34171 - https://webcompat.com/issues/34171 + * + * steamcommunity.com blocks chat feature for Firefox users showing unsupported browser message. + * When spoofing as Chrome the chat works fine + */ + id: "bug1570108", + platform: "desktop", + domain: "steamcommunity.com", + bug: "1570108", + config: { + matches: ["*://steamcommunity.com/chat*"], + uaTransformer: originalUA => { + return ( + UAHelpers.getPrefix(originalUA) + + " AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3770.142 Safari/537.36" + ); + }, + }, + }, + { + /* + * Bug 1582582 - sling.com - UA override for sling.com + * WebCompat issue #17804 - https://webcompat.com/issues/17804 + * + * sling.com blocks Firefox users showing unsupported browser message. + * When spoofing as Chrome playing content works fine + */ + id: "bug1582582", + platform: "desktop", + domain: "sling.com", + bug: "1582582", + config: { + matches: ["https://watch.sling.com/*", "https://www.sling.com/*"], + uaTransformer: originalUA => { + return ( + UAHelpers.getPrefix(originalUA) + + " AppleWebKit/537.36 (KHTML, like Gecko) Chrome/76.0.3809.132 Safari/537.36" + ); + }, + }, + }, + { + /* + * Bug 1610026 - www.mobilesuica.com - UA override for www.mobilesuica.com + * WebCompat issue #4608 - https://webcompat.com/issues/4608 + * + * mobilesuica.com showing unsupported message for Firefox users + * Spoofing as Chrome allows to access the page + */ + id: "bug1610026", + platform: "all", + domain: "www.mobilesuica.com", + bug: "1610026", + config: { + matches: ["https://www.mobilesuica.com/*"], + uaTransformer: originalUA => { + return ( + UAHelpers.getPrefix(originalUA) + + " AppleWebKit/537.36 (KHTML, like Gecko) Chrome/79.0.3945.88 Safari/537.36" + ); + }, + }, + }, + { + /* + * Bug 945963 - tieba.baidu.com serves simplified mobile content to Firefox Android + * additionally, Bug 1525839 for more domains + * WebCompat issue #18455 - https://webcompat.com/issues/18455 + * + * tieba.baidu.com and tiebac.baidu.com serve a heavily simplified and less functional + * mobile experience to Firefox for Android users. Adding the AppleWebKit indicator + * to the User Agent gets us the same experience. + */ + id: "bug945963", + platform: "android", + domain: "tieba.baidu.com", + bug: "945963", + config: { + matches: [ + "*://baike.baidu.com/*", + "*://image.baidu.com/*", + "*://news.baidu.com/*", + "*://tieba.baidu.com/*", + "*://tiebac.baidu.com/*", + "*://wenku.baidu.com/*", + "*://zhidao.baidu.com/*", + ], + uaTransformer: originalUA => { + return UAHelpers.getDeviceAppropriateChromeUA(); + }, + }, + }, + { + /* + * Bug 1177298 - Write UA overrides for top Japanese Sites + * (Imported from ua-update.json.in) + * + * To receive the proper mobile version instead of the desktop version or + * a lower grade mobile experience, the UA is spoofed. + */ + id: "bug1177298-2", + platform: "android", + domain: "lohaco.jp", + bug: "1177298", + config: { + matches: ["*://*.lohaco.jp/*"], + uaTransformer: _ => { + return "Mozilla/5.0 (Linux; Android 5.0.2; Galaxy Nexus Build/IMM76B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/43.0.2357.93 Mobile Safari/537.36"; + }, + }, + }, + { + /* + * Bug 1177298 - Write UA overrides for top Japanese Sites + * (Imported from ua-update.json.in) + * + * To receive the proper mobile version instead of the desktop version or + * a lower grade mobile experience, the UA is spoofed. + */ + id: "bug1177298-3", + platform: "android", + domain: "nhk.or.jp", + bug: "1177298", + config: { + matches: ["*://*.nhk.or.jp/*"], + uaTransformer: originalUA => { + return originalUA + " AppleWebKit"; + }, + }, + }, + { + /* + * Bug 1385206 - Create UA override for rakuten.co.jp on Firefox Android + * (Imported from ua-update.json.in) + * + * rakuten.co.jp serves a Desktop version if Firefox is included in the UA. + */ + id: "bug1385206", + platform: "android", + domain: "rakuten.co.jp", + bug: "1385206", + config: { + matches: ["*://*.rakuten.co.jp/*"], + uaTransformer: originalUA => { + return originalUA.replace(/Firefox.+$/, ""); + }, + }, + }, + { + /* + * Bug 969844 - mobile.de sends desktop site to Firefox on Android + * + * mobile.de sends the desktop site to Firefox Mobile. + * Spoofing as Chrome works fine. + */ + id: "bug969844", + platform: "android", + domain: "mobile.de", + bug: "969844", + config: { + matches: ["*://*.mobile.de/*"], + uaTransformer: _ => { + return "Mozilla/5.0 (Linux; Android 6.0.1; SM-G920F Build/MMB29K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/55.0.2883.91 Mobile Safari/537.36"; + }, + }, + }, + { + /* + * Bug 1509831 - cc.com - Add UA override for CC.com + * WebCompat issue #329 - https://webcompat.com/issues/329 + * + * ComedyCentral blocks Firefox for not being able to play HLS, which was + * true in previous versions, but no longer is. With a spoofed Chrome UA, + * the site works just fine. + */ + id: "bug1509831", + platform: "android", + domain: "cc.com", + bug: "1509831", + config: { + matches: ["*://*.cc.com/*"], + uaTransformer: _ => { + return "Mozilla/5.0 (Linux; Android 6.0.1; SM-G920F Build/MMB29K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/55.0.2883.91 Mobile Safari/537.36"; + }, + }, + }, + { + /* + * Bug 1509873 - zmags.com - Add UA override for secure.viewer.zmags.com + * WebCompat issue #21576 - https://webcompat.com/issues/21576 + * + * The zmags viewer locks out Firefox Mobile with a "Browser unsupported" + * message, but tests showed that it works just fine with a Chrome UA. + * Outreach attempts were unsuccessful, and as the site has a relatively + * high rank, we alter the UA. + */ + id: "bug1509873", + platform: "android", + domain: "zmags.com", + bug: "1509873", + config: { + matches: ["*://*.viewer.zmags.com/*"], + uaTransformer: originalUA => { + return ( + UAHelpers.getPrefix(originalUA) + + " AppleWebKit/537.36 (KHTML, like Gecko) Chrome/55.0.2883.91 Mobile Safari/537.36" + ); + }, + }, + }, + { + /* + * Bug 1566253 - posts.google.com - Add UA override for posts.google.com + * WebCompat issue #17870 - https://webcompat.com/issues/17870 + * + * posts.google.com displaying "Your browser doesn't support this page". + * Spoofing as Chrome works fine. + */ + id: "bug1566253", + platform: "android", + domain: "posts.google.com", + bug: "1566253", + config: { + matches: ["*://posts.google.com/*"], + uaTransformer: _ => { + return "Mozilla/5.0 (Linux; Android 6.0.1; SM-G900M) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3770.101 Mobile Safari/537.36"; + }, + }, + }, + { + /* + * Bug 1574522 - UA override for enuri.com on Firefox for Android + * WebCompat issue #37139 - https://webcompat.com/issues/37139 + * + * enuri.com returns a different template for Firefox on Android + * based on server side UA detection. This results in page content cut offs. + * Spoofing as Chrome fixes the issue + */ + id: "bug1574522", + platform: "android", + domain: "enuri.com", + bug: "1574522", + config: { + matches: ["*://enuri.com/*"], + uaTransformer: _ => { + return "Mozilla/5.0 (Linux; Android 6.0.1; SM-G900M) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/76.0.3809.111 Mobile Safari/537.36"; + }, + }, + }, + { + /* + * Bug 1574564 - UA override for ceskatelevize.cz on Firefox for Android + * WebCompat issue #15467 - https://webcompat.com/issues/15467 + * + * ceskatelevize sets streamingProtocol depending on the User-Agent it sees + * in the request headers, returning DASH for Chrome, HLS for iOS, + * and Flash for Firefox Mobile. Since Mobile has no Flash, the video + * doesn't work. Spoofing as Chrome makes the video play + */ + id: "bug1574564", + platform: "android", + domain: "ceskatelevize.cz", + bug: "1574564", + config: { + matches: ["*://*.ceskatelevize.cz/*"], + uaTransformer: originalUA => { + return ( + UAHelpers.getPrefix(originalUA) + + " AppleWebKit/537.36 (KHTML, like Gecko) Chrome/76.0.3809.111 Mobile Safari/537.36" + ); + }, + }, + }, + { + /* + * Bug 1577250 - UA override for homebook.pl on Firefox for Android + * WebCompat issue #24044 - https://webcompat.com/issues/24044 + * + * homebook.pl shows desktop site on Firefox for Android based on + * UA detection. Spoofing as Chrome allows to get mobile site. + */ + id: "bug1577250", + platform: "android", + domain: "homebook.pl", + bug: "1577250", + config: { + matches: ["*://*.homebook.pl/*"], + uaTransformer: originalUA => { + return ( + UAHelpers.getPrefix(originalUA) + + " AppleWebKit/537.36 (KHTML, like Gecko) Chrome/76.0.3809.111 Mobile Safari/537.36" + ); + }, + }, + }, + { + /* + * Bug 1577267 - UA override for metfone.com.kh on Firefox for Android + * WebCompat issue #16363 - https://webcompat.com/issues/16363 + * + * metfone.com.kh has a server side UA detection which returns desktop site + * for Firefox for Android. Spoofing as Chrome allows to receive mobile version + */ + id: "bug1577267", + platform: "android", + domain: "metfone.com.kh", + bug: "1577267", + config: { + matches: ["*://*.metfone.com.kh/*"], + uaTransformer: originalUA => { + return ( + UAHelpers.getPrefix(originalUA) + + " AppleWebKit/537.36 (KHTML, like Gecko) Chrome/76.0.3809.111 Mobile Safari/537.36" + ); + }, + }, + }, + { + /* + * Bug 1598198 - User Agent extension for Samsung's galaxy.store URLs + * + * Samsung's galaxy.store shortlinks are supposed to redirect to a Samsung + * intent:// URL on Samsung devices, but to an error page on other brands. + * As we do not provide device info in our user agent string, this check + * fails, and even Samsung users land on an error page if they use Firefox + * for Android. + * This intervention adds a simple "Samsung" identifier to the User Agent + * on only the Galaxy Store URLs if the device happens to be a Samsung. + */ + id: "bug1598198", + platform: "android", + domain: "galaxy.store", + bug: "1598198", + config: { + matches: [ + "*://galaxy.store/*", + "*://dev.galaxy.store/*", + "*://stg.galaxy.store/*", + ], + uaTransformer: originalUA => { + if (!browser.systemManufacturer) { + return originalUA; + } + + const manufacturer = browser.systemManufacturer.getManufacturer(); + if (manufacturer && manufacturer.toLowerCase() === "samsung") { + return originalUA.replace("Mobile;", "Mobile; Samsung;"); + } + + return originalUA; + }, + }, + }, + { + /* + * Bug 1595215 - UA overrides for Uniqlo sites + * Webcompat issue #38825 - https://webcompat.com/issues/38825 + * + * To receive the proper mobile version instead of the desktop version or + * avoid redirect loop, the UA is spoofed. + */ + id: "bug1595215", + platform: "android", + domain: "uniqlo.com", + bug: "1595215", + config: { + matches: ["*://*.uniqlo.com/*"], + uaTransformer: originalUA => { + return originalUA + " Mobile Safari"; + }, + }, + }, + { + /* + * Bug 1621065 - UA overrides for bracketchallenge.ncaa.com + * Webcompat issue #49886 - https://webcompat.com/issues/49886 + * + * The NCAA bracket challenge website mistakenly classifies + * any non-Chrome browser on Android as "is_old_android". As a result, + * a modal is shown telling them they have security flaws. We have + * attempted to reach out for a fix (and clarification). + */ + id: "bug1621065", + platform: "android", + domain: "bracketchallenge.ncaa.com", + bug: "1621065", + config: { + matches: ["*://bracketchallenge.ncaa.com/*"], + uaTransformer: originalUA => { + return originalUA + " Chrome"; + }, + }, + }, + { + /* + * Bug 1622063 - UA override for wp1-ext.usps.gov + * Webcompat issue #29867 - https://webcompat.com/issues/29867 + * + * The Job Search site for USPS does not work for Firefox Mobile + * browsers (a 500 is returned). + */ + id: "bug1622063", + platform: "android", + domain: "wp1-ext.usps.gov", + bug: "1622063", + config: { + matches: ["*://wp1-ext.usps.gov/*"], + uaTransformer: originalUA => { + return UAHelpers.getDeviceAppropriateChromeUA(); + }, + }, + }, + { + /* + * Bug 1622081 - UA override for m2.bmo.com + * Webcompat issue #45019 - https://webcompat.com/issues/45019 + * + * Unless the UA string contains "Chrome", m2.bmo.com will + * display a modal saying the browser is out-of-date. + */ + id: "bug1622081", + platform: "android", + domain: "m2.bmo.com", + bug: "1622081", + config: { + matches: ["*://m2.bmo.com/*"], + uaTransformer: originalUA => { + return originalUA + " Chrome"; + }, + }, + }, + { + /* + * Bug 1628455 - UA override for autotrader.ca + * Webcompat issue #50961 - https://webcompat.com/issues/50961 + * + * autotrader.ca is showing desktop site for Firefox on Android + * based on server side UA detection. Spoofing as Chrome allows to + * get mobile experience + */ + id: "bug1628455", + platform: "android", + domain: "autotrader.ca", + bug: "1628455", + config: { + matches: ["https://*.autotrader.ca/*"], + uaTransformer: () => { + return UAHelpers.getDeviceAppropriateChromeUA(); + }, + }, + }, + { + /* + * Bug 1630280 - UA override for dominos.ch + * Webcompat issue #48273 - https://webcompat.com/issues/48273 + * + * dominos.ch is suggesting downloading their native app and showing + * an overlay that can't be removed in Firefox for Android. Spoofing + * as Chrome allows to continue to the site + */ + id: "bug1630280", + platform: "android", + domain: "dominos.ch", + bug: "1630280", + config: { + matches: ["https://*.dominos.ch/*"], + uaTransformer: () => { + return UAHelpers.getDeviceAppropriateChromeUA(); + }, + }, + }, + { + /* + * Bug 1563839 - rolb.santanderbank.com - Build UA override + * Bug 1646791 - bancosantander.es - Re-add UA override. + * Bug 1665129 - *.gruposantander.es - Add wildcard domains. + * WebCompat issue #33462 - https://webcompat.com/issues/33462 + * SuMo request - https://support.mozilla.org/es/questions/1291085 + * + * santanderbank expects UA to have 'like Gecko', otherwise it runs + * xmlDoc.onload whose support has been dropped. It results in missing labels in forms + * and some other issues. Adding 'like Gecko' fixes those issues. + */ + id: "bug1646791", + platform: "all", + domain: "santanderbank.com", + bug: "1646791", + config: { + matches: [ + "*://*.bancosantander.es/*", + "*://*.gruposantander.es/*", + "*://*.santander.co.uk/*", + "*://bob.santanderbank.com/*", + "*://rolb.santanderbank.com/*", + ], + uaTransformer: originalUA => { + return originalUA.replace("Gecko", "like Gecko"); + }, + }, + }, + { + /* + * Bug 1651292 - UA override for www.jp.square-enix.com + * Webcompat issue #53018 - https://webcompat.com/issues/53018 + * + * Unless the UA string contains "Chrome 66+", a section of + * www.jp.square-enix.com will show a never ending LOADING + * page. + */ + id: "bug1651292", + platform: "android", + domain: "www.jp.square-enix.com", + bug: "1651292", + config: { + matches: ["*://www.jp.square-enix.com/music/sem/page/FF7R/ost/*"], + uaTransformer: originalUA => { + return originalUA + " Chrome/83"; + }, + }, + }, + { + /* + * Bug 1654888 - UA override for ebuyer.com + * Webcompat issue #52463 - https://webcompat.com/issues/52463 + * + * This site returns desktop site based on server side UA detection. + * Spoofing as Chrome allows to get mobile experience + */ + id: "bug1654888", + platform: "android", + domain: "ebuyer.com", + bug: "1654888", + config: { + matches: ["*://*.ebuyer.com/*"], + uaTransformer: () => { + return UAHelpers.getDeviceAppropriateChromeUA(); + }, + }, + }, + { + /* + * Bug 1666754 - Mobile UA override for lffl.org + * Bug 1665720 - lffl.org article page takes 2x as much time to load on Moto G + * + * This site returns desktop site based on server side UA detection. + * Spoofing as Chrome allows to get mobile experience + */ + id: "bug1666754", + platform: "android", + domain: "lffl.org", + bug: "1666754", + config: { + matches: ["*://*.lffl.org/*"], + uaTransformer: () => { + return UAHelpers.getDeviceAppropriateChromeUA(); + }, + }, + }, + { + /* + * Bug 1679847 - Add UA override for avto.pro + * Webcompat issue #60043 - https://webcompat.com/issues/60043 + * + * Unless Chrome is in the UA, the site serves a desktop version + * on catalog pages + */ + id: "bug1679847", + platform: "android", + domain: "avto.pro", + bug: "1679847", + config: { + matches: ["https://avto.pro/catalog/*"], + uaTransformer: () => { + return UAHelpers.getDeviceAppropriateChromeUA(); + }, + }, + }, + { + /* + * Bug 1679869 - Add UA override for vh1.com + * Webcompat issue #52755 - https://webcompat.com/issues/52755 + * + * The site is not showing videos on Firefox on mobile stating + * that android 4.4.4 and chrome browser required + */ + id: "bug1679869", + platform: "android", + domain: "vh1.com", + bug: "1679869", + config: { + matches: ["*://*.vh1.com/*"], + uaTransformer: () => { + return UAHelpers.getDeviceAppropriateChromeUA(); + }, + }, + }, +]; + +const UAHelpers = { + getDeviceAppropriateChromeUA() { + if (!UAHelpers._deviceAppropriateChromeUA) { + const userAgent = + typeof navigator !== "undefined" ? navigator.userAgent : ""; + const RunningFirefoxVersion = (userAgent.match(/Firefox\/([0-9.]+)/) || [ + "", + "58.0", + ])[1]; + const RunningAndroidVersion = + userAgent.match(/Android\/[0-9.]+/) || "Android 6.0"; + const ChromeVersionToMimic = "76.0.3809.111"; + const ChromePhoneUA = `Mozilla/5.0 (Linux; ${RunningAndroidVersion}; Nexus 5 Build/MRA58N) FxQuantum/${RunningFirefoxVersion} AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${ChromeVersionToMimic} Mobile Safari/537.36`; + const ChromeTabletUA = `Mozilla/5.0 (Linux; ${RunningAndroidVersion}; Nexus 7 Build/JSS15Q) FxQuantum/${RunningFirefoxVersion} AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${ChromeVersionToMimic} Safari/537.36`; + const IsPhone = userAgent.includes("Mobile"); + UAHelpers._deviceAppropriateChromeUA = IsPhone + ? ChromePhoneUA + : ChromeTabletUA; + } + return UAHelpers._deviceAppropriateChromeUA; + }, + getPrefix(originalUA) { + return originalUA.substr(0, originalUA.indexOf(")") + 1); + }, +}; + +module.exports = AVAILABLE_UA_OVERRIDES; diff --git a/browser/extensions/webcompat/experiment-apis/aboutConfigPrefs.js b/browser/extensions/webcompat/experiment-apis/aboutConfigPrefs.js new file mode 100644 index 0000000000..08a4c3d091 --- /dev/null +++ b/browser/extensions/webcompat/experiment-apis/aboutConfigPrefs.js @@ -0,0 +1,57 @@ +/* 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"; + +/* global ExtensionAPI, ExtensionCommon, Services, XPCOMUtils */ + +XPCOMUtils.defineLazyModuleGetters(this, { + Services: "resource://gre/modules/Services.jsm", +}); + +this.aboutConfigPrefs = class extends ExtensionAPI { + getAPI(context) { + const EventManager = ExtensionCommon.EventManager; + const extensionIDBase = context.extension.id.split("@")[0]; + const extensionPrefNameBase = `extensions.${extensionIDBase}.`; + + return { + aboutConfigPrefs: { + onPrefChange: new EventManager({ + context, + name: "aboutConfigPrefs.onUAOverridesPrefChange", + register: (fire, name) => { + const prefName = `${extensionPrefNameBase}${name}`; + const callback = () => { + fire.async(name).catch(() => {}); // ignore Message Manager disconnects + }; + Services.prefs.addObserver(prefName, callback); + return () => { + Services.prefs.removeObserver(prefName, callback); + }; + }, + }).api(), + async getBranch(branchName) { + const branch = `${extensionPrefNameBase}${branchName}.`; + return Services.prefs.getChildList(branch).map(pref => { + const name = pref.replace(branch, ""); + return { name, value: Services.prefs.getBoolPref(pref) }; + }); + }, + async getPref(name) { + try { + return Services.prefs.getBoolPref( + `${extensionPrefNameBase}${name}` + ); + } catch (_) { + return undefined; + } + }, + async setPref(name, value) { + Services.prefs.setBoolPref(`${extensionPrefNameBase}${name}`, value); + }, + }, + }; + } +}; diff --git a/browser/extensions/webcompat/experiment-apis/aboutConfigPrefs.json b/browser/extensions/webcompat/experiment-apis/aboutConfigPrefs.json new file mode 100644 index 0000000000..44284f199c --- /dev/null +++ b/browser/extensions/webcompat/experiment-apis/aboutConfigPrefs.json @@ -0,0 +1,72 @@ +[ + { + "namespace": "aboutConfigPrefs", + "description": "experimental API extension to allow access to about:config preferences", + "events": [ + { + "name": "onPrefChange", + "type": "function", + "parameters": [ + { + "name": "name", + "type": "string", + "description": "The preference which changed" + } + ], + "extraParameters": [ + { + "name": "name", + "type": "string", + "description": "The preference to monitor" + } + ] + } + ], + "functions": [ + { + "name": "getBranch", + "type": "function", + "description": "Get all child prefs for a branch", + "parameters": [ + { + "name": "branchName", + "type": "string", + "description": "The branch name" + } + ], + "async": true + }, + { + "name": "getPref", + "type": "function", + "description": "Get a preference's value", + "parameters": [ + { + "name": "name", + "type": "string", + "description": "The preference name" + } + ], + "async": true + }, + { + "name": "setPref", + "type": "function", + "description": "Set a preference's value", + "parameters": [ + { + "name": "name", + "type": "string", + "description": "The preference name" + }, + { + "name": "value", + "type": "boolean", + "description": "The new value" + } + ], + "async": true + } + ] + } +] diff --git a/browser/extensions/webcompat/experiment-apis/appConstants.js b/browser/extensions/webcompat/experiment-apis/appConstants.js new file mode 100644 index 0000000000..7019eb6215 --- /dev/null +++ b/browser/extensions/webcompat/experiment-apis/appConstants.js @@ -0,0 +1,32 @@ +/* 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"; + +/* global ExtensionAPI, XPCOMUtils */ + +XPCOMUtils.defineLazyModuleGetters(this, { + AppConstants: "resource://gre/modules/AppConstants.jsm", +}); + +this.appConstants = class extends ExtensionAPI { + getAPI(context) { + return { + appConstants: { + getReleaseBranch: () => { + if (AppConstants.NIGHTLY_BUILD) { + return "nightly"; + } else if (AppConstants.MOZ_DEV_EDITION) { + return "dev_edition"; + } else if (AppConstants.EARLY_BETA_OR_EARLIER) { + return "early_beta_or_earlier"; + } else if (AppConstants.BETA_OR_RELEASE) { + return "beta_or_release"; + } + return "unknown"; + }, + }, + }; + } +}; diff --git a/browser/extensions/webcompat/experiment-apis/appConstants.json b/browser/extensions/webcompat/experiment-apis/appConstants.json new file mode 100644 index 0000000000..cf04915eca --- /dev/null +++ b/browser/extensions/webcompat/experiment-apis/appConstants.json @@ -0,0 +1,15 @@ +[ + { + "namespace": "appConstants", + "description": "experimental API to expose some app constants", + "functions": [ + { + "name": "getReleaseBranch", + "type": "function", + "description": "", + "async": true, + "parameters": [] + } + ] + } +] diff --git a/browser/extensions/webcompat/experiment-apis/experiments.js b/browser/extensions/webcompat/experiment-apis/experiments.js new file mode 100644 index 0000000000..d1ab77c312 --- /dev/null +++ b/browser/extensions/webcompat/experiment-apis/experiments.js @@ -0,0 +1,34 @@ +/* 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"; + +/* global ExtensionAPI, Services, XPCOMUtils */ + +XPCOMUtils.defineLazyModuleGetters(this, { + EventDispatcher: "resource://gre/modules/Messaging.jsm", + Services: "resource://gre/modules/Services.jsm", +}); + +this.experiments = class extends ExtensionAPI { + getAPI(context) { + function promiseActiveExperiments() { + return EventDispatcher.instance.sendRequestForResult({ + type: "Experiments:GetActive", + }); + } + return { + experiments: { + async isActive(name) { + if (!Services.androidBridge || !Services.androidBridge.isFennec) { + return undefined; + } + return promiseActiveExperiments().then(experiments => { + return experiments.includes(name); + }); + }, + }, + }; + } +}; diff --git a/browser/extensions/webcompat/experiment-apis/experiments.json b/browser/extensions/webcompat/experiment-apis/experiments.json new file mode 100644 index 0000000000..44f833215c --- /dev/null +++ b/browser/extensions/webcompat/experiment-apis/experiments.json @@ -0,0 +1,21 @@ +[ + { + "namespace": "experiments", + "description": "experimental API extension to allow checking the status of Fennec experiments via Switchboard", + "functions": [ + { + "name": "isActive", + "type": "function", + "description": "Determine if a given experiment is active.", + "parameters": [ + { + "name": "name", + "type": "string", + "description": "The experiment's name" + } + ], + "async": true + } + ] + } +] diff --git a/browser/extensions/webcompat/experiment-apis/matchPatterns.js b/browser/extensions/webcompat/experiment-apis/matchPatterns.js new file mode 100644 index 0000000000..422cba5fc4 --- /dev/null +++ b/browser/extensions/webcompat/experiment-apis/matchPatterns.js @@ -0,0 +1,30 @@ +/* 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"; + +/* global ExtensionAPI */ + +this.matchPatterns = class extends ExtensionAPI { + getAPI(context) { + return { + matchPatterns: { + getMatcher(patterns) { + const set = new MatchPatternSet(patterns); + return Cu.cloneInto( + { + matches: url => { + return set.matches(url); + }, + }, + context.cloneScope, + { + cloneFunctions: true, + } + ); + }, + }, + }; + } +}; diff --git a/browser/extensions/webcompat/experiment-apis/matchPatterns.json b/browser/extensions/webcompat/experiment-apis/matchPatterns.json new file mode 100644 index 0000000000..6fb4dc10fc --- /dev/null +++ b/browser/extensions/webcompat/experiment-apis/matchPatterns.json @@ -0,0 +1,29 @@ +[ + { + "namespace": "matchPatterns", + "description": "experimental API extension to expose MatchPattern functionality", + "functions": [ + { + "name": "getMatcher", + "type": "function", + "description": "get a MatchPatternSet", + "parameters": [ + { + "name": "patterns", + "description": "Array of string URL patterns to match", + "type": "array", + "items": { + "type": "string" + } + } + ], + "returns": { + "type": "object", + "properties": { + "matches": { "type": "function" } + } + } + } + ] + } +] diff --git a/browser/extensions/webcompat/experiment-apis/pictureInPicture.js b/browser/extensions/webcompat/experiment-apis/pictureInPicture.js new file mode 100644 index 0000000000..e78ddbf315 --- /dev/null +++ b/browser/extensions/webcompat/experiment-apis/pictureInPicture.js @@ -0,0 +1,90 @@ +/* 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"; + +/* global ChromeUtils, ExtensionAPI, Services */ +ChromeUtils.defineModuleGetter( + this, + "Services", + "resource://gre/modules/Services.jsm" +); + +ChromeUtils.defineModuleGetter( + this, + "KEYBOARD_CONTROLS", + "resource://gre/modules/PictureInPictureControls.jsm" +); + +ChromeUtils.defineModuleGetter( + this, + "TOGGLE_POLICIES", + "resource://gre/modules/PictureInPictureControls.jsm" +); + +ChromeUtils.defineModuleGetter( + this, + "AppConstants", + "resource://gre/modules/AppConstants.jsm" +); + +const TOGGLE_ENABLED_PREF = + "media.videocontrols.picture-in-picture.video-toggle.enabled"; + +/** + * This API is expected to be running in the parent process. + */ +this.pictureInPictureParent = class extends ExtensionAPI { + getAPI(context) { + return { + pictureInPictureParent: { + setOverrides(overrides) { + // The Picture-in-Picture toggle is only implemented for Desktop, so make + // this a no-op for non-Desktop builds. + if (AppConstants.platform == "android") { + return; + } + + Services.ppmm.sharedData.set( + "PictureInPicture:SiteOverrides", + overrides + ); + }, + }, + }; + } +}; + +/** + * This API is expected to be running in a content process - specifically, + * the WebExtension content process that the background scripts run in. We + * split these out so that they can return values synchronously to the + * background scripts. + */ +this.pictureInPictureChild = class extends ExtensionAPI { + getAPI(context) { + return { + pictureInPictureChild: { + getKeyboardControls() { + // The Picture-in-Picture toggle is only implemented for Desktop, so make + // this return nothing for non-Desktop builds. + if (AppConstants.platform == "android") { + return Cu.cloneInto({}, context.cloneScope); + } + + return Cu.cloneInto(KEYBOARD_CONTROLS, context.cloneScope); + }, + getPolicies() { + // The Picture-in-Picture toggle is only implemented for Desktop, so make + // this return nothing for non-Desktop builds. + if (AppConstants.platform == "android") { + return Cu.cloneInto({}, context.cloneScope); + } + + return Cu.cloneInto(TOGGLE_POLICIES, context.cloneScope); + }, + }, + }; + } +}; diff --git a/browser/extensions/webcompat/experiment-apis/pictureInPicture.json b/browser/extensions/webcompat/experiment-apis/pictureInPicture.json new file mode 100644 index 0000000000..5f34616b6e --- /dev/null +++ b/browser/extensions/webcompat/experiment-apis/pictureInPicture.json @@ -0,0 +1,51 @@ +[ + { + "namespace": "pictureInPictureParent", + "description": "Parent process methods for controlling the Picture-in-Picture feature.", + "functions": [ + { + "name": "setOverrides", + "type": "function", + "description": "Set Picture-in-Picture toggle position overrides", + "parameters": [ + { + "name": "overrides", + "type": "object", + "additionalProperties": { "type": "any" }, + "description": "The Picture-in-Picture toggle position overrides to set" + } + ] + } + ] + }, + { + "namespace": "pictureInPictureChild", + "description": "WebExtension process methods for querying the Picture-in-Picture feature.", + "functions": [ + { + "name": "getKeyboardControls", + "type": "function", + "description": "Get the Picture-in-Picture keyboard control override constants", + "parameters": [], + "returns": { + "type": "object", + "properties": {}, + "additionalProperties": { "type": "any" }, + "description": "The Picture-in-Picture keyboard control override constants" + } + }, + { + "name": "getPolicies", + "type": "function", + "description": "Get the Picture-in-Picture toggle position override constants", + "parameters": [], + "returns": { + "type": "object", + "properties": {}, + "additionalProperties": { "type": "any" }, + "description": "The Picture-in-Picture toggle position override constants" + } + } + ] + } +] diff --git a/browser/extensions/webcompat/experiment-apis/sharedPreferences.js b/browser/extensions/webcompat/experiment-apis/sharedPreferences.js new file mode 100644 index 0000000000..b2a19b3a6c --- /dev/null +++ b/browser/extensions/webcompat/experiment-apis/sharedPreferences.js @@ -0,0 +1,33 @@ +/* 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"; + +/* global ExtensionAPI, Services, XPCOMUtils */ + +XPCOMUtils.defineLazyModuleGetters(this, { + Services: "resource://gre/modules/Services.jsm", + SharedPreferences: "resource://gre/modules/SharedPreferences.jsm", +}); + +this.sharedPreferences = class extends ExtensionAPI { + getAPI(context) { + return { + sharedPreferences: { + async setCharPref(name, value) { + if (!Services.androidBridge || !Services.androidBridge.isFennec) { + return; + } + SharedPreferences.forApp().setCharPref(name, value); + }, + async setBoolPref(name, value) { + if (!Services.androidBridge || !Services.androidBridge.isFennec) { + return; + } + SharedPreferences.forApp().setBoolPref(name, value); + }, + }, + }; + } +}; diff --git a/browser/extensions/webcompat/experiment-apis/sharedPreferences.json b/browser/extensions/webcompat/experiment-apis/sharedPreferences.json new file mode 100644 index 0000000000..cf73414cf4 --- /dev/null +++ b/browser/extensions/webcompat/experiment-apis/sharedPreferences.json @@ -0,0 +1,44 @@ +[ + { + "namespace": "sharedPreferences", + "description": "experimental API extension to allow setting SharedPreferences on Fennec", + "functions": [ + { + "name": "setBoolPref", + "type": "function", + "description": "Set the value of a boolean Fennec SharedPreference", + "parameters": [ + { + "name": "name", + "type": "string", + "description": "The key name" + }, + { + "name": "value", + "type": "boolean", + "description": "The new value" + } + ], + "async": true + }, + { + "name": "setCharPref", + "type": "function", + "description": "Set the value of a string Fennec SharedPreference", + "parameters": [ + { + "name": "name", + "type": "string", + "description": "The key name" + }, + { + "name": "value", + "type": "string", + "description": "The new value" + } + ], + "async": true + } + ] + } +] diff --git a/browser/extensions/webcompat/experiment-apis/systemManufacturer.js b/browser/extensions/webcompat/experiment-apis/systemManufacturer.js new file mode 100644 index 0000000000..c3819c1128 --- /dev/null +++ b/browser/extensions/webcompat/experiment-apis/systemManufacturer.js @@ -0,0 +1,27 @@ +/* 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"; + +/* global ExtensionAPI, Services, XPCOMUtils */ + +XPCOMUtils.defineLazyModuleGetters(this, { + Services: "resource://gre/modules/Services.jsm", +}); + +this.systemManufacturer = class extends ExtensionAPI { + getAPI(context) { + return { + systemManufacturer: { + getManufacturer() { + try { + return Services.sysinfo.getProperty("manufacturer"); + } catch (_) { + return undefined; + } + }, + }, + }; + } +}; diff --git a/browser/extensions/webcompat/experiment-apis/systemManufacturer.json b/browser/extensions/webcompat/experiment-apis/systemManufacturer.json new file mode 100644 index 0000000000..c64fccc46d --- /dev/null +++ b/browser/extensions/webcompat/experiment-apis/systemManufacturer.json @@ -0,0 +1,20 @@ +[ + { + "namespace": "systemManufacturer", + "description": "experimental API extension to allow reading the device's manufacturer", + "functions": [ + { + "name": "getManufacturer", + "type": "function", + "description": "Get the device's manufacturer", + "parameters": [], + "returns": { + "type": "string", + "properties": {}, + "additionalProperties": { "type": "any" }, + "description": "The manufacturer's name." + } + } + ] + } +] diff --git a/browser/extensions/webcompat/experiment-apis/trackingProtection.js b/browser/extensions/webcompat/experiment-apis/trackingProtection.js new file mode 100644 index 0000000000..557090e974 --- /dev/null +++ b/browser/extensions/webcompat/experiment-apis/trackingProtection.js @@ -0,0 +1,168 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +/* global ExtensionAPI, ExtensionCommon, ExtensionParent, Services, XPCOMUtils */ + +XPCOMUtils.defineLazyModuleGetters(this, { + Services: "resource://gre/modules/Services.jsm", +}); + +XPCOMUtils.defineLazyGlobalGetters(this, ["URL", "ChannelWrapper"]); + +class Manager { + constructor() { + this._allowLists = new Map(); + } + + _ensureStarted() { + if (this._classifierObserver) { + return; + } + + this._unblockedChannelIds = new Set(); + this._channelClassifier = Cc[ + "@mozilla.org/url-classifier/channel-classifier-service;1" + ].getService(Ci.nsIChannelClassifierService); + this._classifierObserver = {}; + this._classifierObserver.observe = (subject, topic, data) => { + switch (topic) { + case "http-on-stop-request": { + const { channelId } = subject.QueryInterface(Ci.nsIIdentChannel); + this._unblockedChannelIds.delete(channelId); + break; + } + case "urlclassifier-before-block-channel": { + const channel = subject.QueryInterface( + Ci.nsIUrlClassifierBlockedChannel + ); + const { channelId, url } = channel; + let topHost; + try { + topHost = new URL(channel.topLevelUrl).hostname; + } catch (_) { + return; + } + for (const allowList of this._allowLists.values()) { + for (const entry of allowList.values()) { + const { matcher, hosts, notHosts } = entry; + if (matcher.matches(url)) { + if ( + !notHosts?.has(topHost) && + (hosts === true || hosts.has(topHost)) + ) { + this._unblockedChannelIds.add(channelId); + channel.unblock(); + return; + } + } + } + } + break; + } + } + }; + Services.obs.addObserver(this._classifierObserver, "http-on-stop-request"); + this._channelClassifier.addListener(this._classifierObserver); + } + + stop() { + if (!this._classifierObserver) { + return; + } + + Services.obs.removeObserver( + this._classifierObserver, + "http-on-stop-request" + ); + this._channelClassifier.removeListener(this._classifierObserver); + delete this._channelClassifier; + delete this._classifierObserver; + } + + wasChannelIdUnblocked(channelId) { + return this._unblockedChannelIds.has(channelId); + } + + allow(allowListId, patterns, { hosts, notHosts }) { + this._ensureStarted(); + + if (!this._allowLists.has(allowListId)) { + this._allowLists.set(allowListId, new Map()); + } + const allowList = this._allowLists.get(allowListId); + for (const pattern of patterns) { + if (!allowList.has(pattern)) { + allowList.set(pattern, { + matcher: new MatchPattern(pattern), + }); + } + const allowListPattern = allowList.get(pattern); + if (!hosts) { + allowListPattern.hosts = true; + } else { + if (!allowListPattern.hosts) { + allowListPattern.hosts = new Set(); + } + for (const host of hosts) { + allowListPattern.hosts.add(host); + } + } + if (notHosts) { + if (!allowListPattern.notHosts) { + allowListPattern.notHosts = new Set(); + } + for (const notHost of notHosts) { + allowListPattern.notHosts.add(notHost); + } + } + } + } + + revoke(allowListId) { + this._allowLists.delete(allowListId); + } +} +var manager = new Manager(); + +function getChannelId(context, requestId) { + const wrapper = ChannelWrapper.getRegisteredChannel( + requestId, + context.extension.policy, + context.xulBrowser.frameLoader.remoteTab + ); + return wrapper?.channel?.QueryInterface(Ci.nsIIdentChannel)?.channelId; +} + +this.trackingProtection = class extends ExtensionAPI { + onShutdown(isAppShutdown) { + if (manager) { + manager.stop(); + } + } + + getAPI(context) { + return { + trackingProtection: { + async allow(allowListId, patterns, options) { + manager.allow(allowListId, patterns, options); + }, + async revoke(allowListId) { + manager.revoke(allowListId); + }, + async wasRequestUnblocked(requestId) { + if (!manager) { + return false; + } + const channelId = getChannelId(context, requestId); + if (!channelId) { + return false; + } + return manager.wasChannelIdUnblocked(channelId); + }, + }, + }; + } +}; diff --git a/browser/extensions/webcompat/experiment-apis/trackingProtection.json b/browser/extensions/webcompat/experiment-apis/trackingProtection.json new file mode 100644 index 0000000000..2627d1ea0f --- /dev/null +++ b/browser/extensions/webcompat/experiment-apis/trackingProtection.json @@ -0,0 +1,75 @@ +[ + { + "namespace": "trackingProtection", + "description": "experimental API allow requests through ETP", + "functions": [ + { + "name": "allow", + "type": "function", + "description": "Add specific requests to a given allow-list", + "parameters": [ + { + "name": "allowlistId", + "type": "string" + }, + { + "name": "patterns", + "description": "Array of match patterns", + "type": "array", + "items": { + "type": "string" + } + }, + { + "name": "options", + "type": "object", + "optional": true, + "properties": { + "hosts": { + "description": "Hosts to limit this bypass to (optional)", + "type": "array", + "items": { + "type": "string" + }, + "optional": true + }, + "notHosts": { + "description": "Hosts to not allow this bypass for (optional)", + "type": "array", + "items": { + "type": "string" + }, + "optional": true + } + } + } + ], + "async": true + }, + { + "name": "revoke", + "type": "function", + "description": "Revokes the given allow-list", + "parameters": [ + { + "name": "allowListId", + "type": "string" + } + ], + "async": true + }, + { + "name": "wasRequestUnblocked", + "type": "function", + "description": "Whether the given requestId was unblocked by any allowList", + "parameters": [ + { + "name": "requestId", + "type": "string" + } + ], + "async": true + } + ] + } +] diff --git a/browser/extensions/webcompat/injections/css/bug0000000-testbed-css-injection.css b/browser/extensions/webcompat/injections/css/bug0000000-testbed-css-injection.css new file mode 100644 index 0000000000..1e82ee9722 --- /dev/null +++ b/browser/extensions/webcompat/injections/css/bug0000000-testbed-css-injection.css @@ -0,0 +1,3 @@ +#css-injection.red { + background-color: #0f0; +} diff --git a/browser/extensions/webcompat/injections/css/bug1561371-mail.google.com-allow-horizontal-scrolling.css b/browser/extensions/webcompat/injections/css/bug1561371-mail.google.com-allow-horizontal-scrolling.css new file mode 100644 index 0000000000..15a7fe1484 --- /dev/null +++ b/browser/extensions/webcompat/injections/css/bug1561371-mail.google.com-allow-horizontal-scrolling.css @@ -0,0 +1,12 @@ +/** + * mail.google.com - The HTML email view does not allow horizontal scrolling + * on Firefox mobile due to a missing CSS rule which is only served to Chrome. + * Bug #1561371 - https://bugzilla.mozilla.org/show_bug.cgi?id=1561371 + * + * HTML emails may sometimes contain content that does not wrap, yet the + * CSS served to Firefox Mobile does not permit scrolling horizontally. + * To prevent this UX frustration, we enable horizontal scrolling. + */ +body > #views { + overflow: auto; +} diff --git a/browser/extensions/webcompat/injections/css/bug1570119-teamcoco.com-scrollbar-width.css b/browser/extensions/webcompat/injections/css/bug1570119-teamcoco.com-scrollbar-width.css new file mode 100644 index 0000000000..7a6c2c07c2 --- /dev/null +++ b/browser/extensions/webcompat/injections/css/bug1570119-teamcoco.com-scrollbar-width.css @@ -0,0 +1,11 @@ +/** + * teamcoco.com - a scrollbar at the top covering navigation menu + * Bug #1570119 - https://bugzilla.mozilla.org/show_bug.cgi?id=1570119 + * + * The scrollbar is covering navigation items making them unusable. + * There are ::-webkit-scrollbar css rules already applied to the scrollbar, + * hiding it in Chrome. Adding the scrollbar-width: none fixes the issue in Firefox. + */ +.css-bdnz85 { + scrollbar-width: none; +} diff --git a/browser/extensions/webcompat/injections/css/bug1570328-developer-apple.com-transform-scale.css b/browser/extensions/webcompat/injections/css/bug1570328-developer-apple.com-transform-scale.css new file mode 100644 index 0000000000..2ffd45a361 --- /dev/null +++ b/browser/extensions/webcompat/injections/css/bug1570328-developer-apple.com-transform-scale.css @@ -0,0 +1,17 @@ +/** + * developer.apple.com - content of the page is shifted to the left + * Bug #1570328 - https://bugzilla.mozilla.org/show_bug.cgi?id=1570328 + * WebCompat issue #4070 - https://webcompat.com/issues/4070 + * + * The site is relying on zoom property which is not supported by Mozilla, + * see https://bugzilla.mozilla.org/show_bug.cgi?id=390936. Adding a combination + * of transform: scale(1.4), transform-origin and width fixes the issue + */ +@media only screen and (min-device-width: 320px) and (max-device-width: 980px), + (min-device-width: 1024px) and (max-device-width: 1024px) and (min-device-height: 1366px) and (max-device-height: 1366px) and (min-width: 320px) and (max-width: 980px) { + #tocContainer { + transform-origin: 0 0; + transform: scale(1.4); + width: 71.4%; + } +} diff --git a/browser/extensions/webcompat/injections/css/bug1575000-apply.lloydsbank.co.uk-radio-buttons-fix.css b/browser/extensions/webcompat/injections/css/bug1575000-apply.lloydsbank.co.uk-radio-buttons-fix.css new file mode 100644 index 0000000000..d6c7e82e26 --- /dev/null +++ b/browser/extensions/webcompat/injections/css/bug1575000-apply.lloydsbank.co.uk-radio-buttons-fix.css @@ -0,0 +1,11 @@ +/** + * apply.lloydsbank.co.uk - radio buttons are misplaced + * Bug #1575000 - https://bugzilla.mozilla.org/show_bug.cgi?id=1575000 + * WebCompat issue #34969 - https://webcompat.com/issues/34969 + * + * Radio buttons are displaced to the left due to positioning issue of ::before + * pseudo element, adding position relative to it's parent fixes the issue. + */ +.radio-content-field .radio.inline label span.text { + position: relative; +} diff --git a/browser/extensions/webcompat/injections/css/bug1605611-maps.google.com-directions-time.css b/browser/extensions/webcompat/injections/css/bug1605611-maps.google.com-directions-time.css new file mode 100644 index 0000000000..c0f4fae1f5 --- /dev/null +++ b/browser/extensions/webcompat/injections/css/bug1605611-maps.google.com-directions-time.css @@ -0,0 +1,24 @@ +/** + * Bug 1605611 - Cannot change Departure/arrival dates in Google Maps on Android + * + * This is step 3 - see injections/js/bug1605611-maps.google.com-directions-time.js. + * Google Maps calls .click() on a datetime-local input element, with the intent + * to show the native date picker. But the native date picker does not appear, + * because that only happens when a user initiated the click. + * To fix the problem of the date picker not appearing in Google Maps, alter the + * styles of the datetime-local input element, to be rendered on top of the + * usual UI (i.e. the icon and date/time text). This allows the user to summon + * the native date picker when they tap on the relevant UI in Google Maps. + */ + +.ml-route-options-picker-content-button + > #ml-route-options-time-selector-time-input { + z-index: 1; /* overrides -5000, to show on top of the icon AND the rendered date */ + opacity: 0; /* let the input element be fully transparent */ + width: 100vw; /* render over the rendered date from Maps' dialog */ + /* position this (absolute) element to fully cover the parent container */ + left: 0; + bottom: 0; + top: 0; + height: 100%; +} diff --git a/browser/extensions/webcompat/injections/css/bug1610016-gaana.com-input-position-fix.css b/browser/extensions/webcompat/injections/css/bug1610016-gaana.com-input-position-fix.css new file mode 100644 index 0000000000..258841fdda --- /dev/null +++ b/browser/extensions/webcompat/injections/css/bug1610016-gaana.com-input-position-fix.css @@ -0,0 +1,13 @@ +/** + * gaana.com - unable to accept T&C and Privacy Policy + * Bug #1610016 - https://bugzilla.mozilla.org/show_bug.cgi?id=1610016 + * WebCompat issue #29886 - https://webcompat.com/issues/29886 + * + * Unable to click on checkboxes due to input element floating to the right. + * More info https://bugzilla.mozilla.org/show_bug.cgi?id=997189. Adding explicit + * positioning to the input fixes the issue + */ +.agree_btns input { + top: 0; + left: 0; +} diff --git a/browser/extensions/webcompat/injections/css/bug1610344-directv.com.co-hide-unsupported-message.css b/browser/extensions/webcompat/injections/css/bug1610344-directv.com.co-hide-unsupported-message.css new file mode 100644 index 0000000000..80f94aa306 --- /dev/null +++ b/browser/extensions/webcompat/injections/css/bug1610344-directv.com.co-hide-unsupported-message.css @@ -0,0 +1,13 @@ +/** + * directv.com.co - Browser is not supported message + * Bug #1610344 - https://bugzilla.mozilla.org/show_bug.cgi?id=1610344 + * WebCompat issue #41822 - https://webcompat.com/issues/41822 + * + * directv.com.co is showing a "This browser is not supported" message in + * Firefox. Our tests indicated that everything is working just fine, and our + * previous contact attempts have not been successful. This intervention + * hides the large red unsupported banner. + */ +.browser-compatible.compatible.incompatible { + display: none; +} diff --git a/browser/extensions/webcompat/injections/css/bug1644830-missingmail.usps.com-checkboxes-not-visible.css b/browser/extensions/webcompat/injections/css/bug1644830-missingmail.usps.com-checkboxes-not-visible.css new file mode 100644 index 0000000000..191985b691 --- /dev/null +++ b/browser/extensions/webcompat/injections/css/bug1644830-missingmail.usps.com-checkboxes-not-visible.css @@ -0,0 +1,13 @@ +/** + * missingmail.usps.com - Unable to mark the check-boxes from "Disclaimer and + * Terms and Conditions" section + * Bug #1644830 - https://bugzilla.mozilla.org/show_bug.cgi?id=1644830 + * WebCompat issue #53950 - https://webcompat.com/issues/53950 + * + * missingmail.usps.com runs into a case of bug 997189, where an absolutely + * positioned inline-block element with floating siblings is shifter to the + * right, and thus invisible. + */ +.mrc-custom-checkbox-container input { + margin-left: -3rem; +} diff --git a/browser/extensions/webcompat/injections/css/bug1645064-s-kanava.fi-invisible-charts.css b/browser/extensions/webcompat/injections/css/bug1645064-s-kanava.fi-invisible-charts.css new file mode 100644 index 0000000000..d5c348ad25 --- /dev/null +++ b/browser/extensions/webcompat/injections/css/bug1645064-s-kanava.fi-invisible-charts.css @@ -0,0 +1,12 @@ +/** + * s-kanava.fi - The tables carousel is missing + * Bug #1645064 - https://bugzilla.mozilla.org/show_bug.cgi?id=1645064 + * WebCompat issue #53584 - https://webcompat.com/issues/53584 + * + * This site runs into a known Flex issue, see bug 1469649. However, the issue + * is easy to workaround in this case by explicitly specifying the width of + * the flex container. + */ +.carousel .slider-wrapper.axis-horizontal .slider .slide { + max-width: 100%; +} diff --git a/browser/extensions/webcompat/injections/css/bug1651917-teletrader.com.body-transform-origin.css b/browser/extensions/webcompat/injections/css/bug1651917-teletrader.com.body-transform-origin.css new file mode 100644 index 0000000000..e7a44a93d7 --- /dev/null +++ b/browser/extensions/webcompat/injections/css/bug1651917-teletrader.com.body-transform-origin.css @@ -0,0 +1,14 @@ +/** + * teletrader.com - content is shifted down and right + * Bug #1651917 - https://bugzilla.mozilla.org/show_bug.cgi?id=1651917 + * WebCompat issue #55217 - https://webcompat.com/issues/55217 + * + * The content is shifted down and right, because they use webkit prefixes + * for scaling and redefining the origin. Firefox doesn't support + * -webkit-transform-origin-x/y + * This is the object of https://bugzilla.mozilla.org/show_bug.cgi?id=1584881 + * Adding transform-origin: 0 0; to body fixes the issue + */ +body { + transform-origin: 0 0; +} diff --git a/browser/extensions/webcompat/injections/css/bug1653075-livescience.com-scrollbar-width.css b/browser/extensions/webcompat/injections/css/bug1653075-livescience.com-scrollbar-width.css new file mode 100644 index 0000000000..3d7a069676 --- /dev/null +++ b/browser/extensions/webcompat/injections/css/bug1653075-livescience.com-scrollbar-width.css @@ -0,0 +1,14 @@ +/** + * livescience.com - a scrollbar covering navigation menu + * Bug #1653075 - https://bugzilla.mozilla.org/show_bug.cgi?id=1653075 + * + * The scrollbar is covering navigation items and that makes them half hidden. + * There are some ::-webkit-scrollbar css rules applied to the scrollbar, + * making it thinner. Adding similar rules for Firefox fixes the issue. + */ +@media screen and (max-width: 900px) { + .trending-wrapper .trending-items { + scrollbar-width: thin; + scrollbar-color: #f9ae3b #f5f5f5; + } +} diff --git a/browser/extensions/webcompat/injections/css/bug1654865-sports.ndtv.com-float-fix.css b/browser/extensions/webcompat/injections/css/bug1654865-sports.ndtv.com-float-fix.css new file mode 100644 index 0000000000..f9edefb735 --- /dev/null +++ b/browser/extensions/webcompat/injections/css/bug1654865-sports.ndtv.com-float-fix.css @@ -0,0 +1,13 @@ +/** + * sports.ndtv.com - content is of the articles is not displayed + * Bug #1654865 - https://bugzilla.mozilla.org/show_bug.cgi?id=1654865 + * WebCompat issue #55377 - https://webcompat.com/issues/55377 + * + * The content has width:0, due to uncleared float and negative margin combination, + * which is https://bugzilla.mozilla.org/show_bug.cgi?id=1400958 + * Adding clear: both; to the element located above the affected div + * the fixes the issue + */ +.t-brd { + clear: both; +} diff --git a/browser/extensions/webcompat/injections/css/bug1654877-preev.com-moz-appearance-fix.css b/browser/extensions/webcompat/injections/css/bug1654877-preev.com-moz-appearance-fix.css new file mode 100644 index 0000000000..111ec522df --- /dev/null +++ b/browser/extensions/webcompat/injections/css/bug1654877-preev.com-moz-appearance-fix.css @@ -0,0 +1,15 @@ +/** + * preev.com - typed numbers are not fully visible + * Bug #1654877 - https://bugzilla.mozilla.org/show_bug.cgi?id=1654877 + * WebCompat issue #55099 - https://webcompat.com/issues/55099 + * + * It's hard to see the entered number because the spin button is + * taking too much space. While there is -moz-appearance: textfield, + * -webkit-appearance: none; underneath supersedes it, + * leaving the spin button visible. Adding -moz-appearance: textfield; + * as a separate rule fixes the issue + */ +input[type="number"], +input[type="text"] { + -moz-appearance: textfield; +} diff --git a/browser/extensions/webcompat/injections/css/bug1654907-reactine.ca-hide-unsupported.css b/browser/extensions/webcompat/injections/css/bug1654907-reactine.ca-hide-unsupported.css new file mode 100644 index 0000000000..2893e873ed --- /dev/null +++ b/browser/extensions/webcompat/injections/css/bug1654907-reactine.ca-hide-unsupported.css @@ -0,0 +1,12 @@ +/** + * reactine.ca - Unsupported browser message + * Bug #1654907 - https://bugzilla.mozilla.org/show_bug.cgi?id=1654907 + * WebCompat issue #55481 - https://webcompat.com/issues/55481 + * + * reactine.ca is showing "Sorry this browser is not supported." + * message if Firefox for Android based on UA detection. Site seems + * to be working fine, so this intervention is to hide this message + */ +#browser-alert { + display: none !important; +} diff --git a/browser/extensions/webcompat/injections/css/bug1655049-dev.to-unclickable-button-fix.css b/browser/extensions/webcompat/injections/css/bug1655049-dev.to-unclickable-button-fix.css new file mode 100644 index 0000000000..2e40f14955 --- /dev/null +++ b/browser/extensions/webcompat/injections/css/bug1655049-dev.to-unclickable-button-fix.css @@ -0,0 +1,12 @@ +/** + * dev.to - not possible to open social buttons menu + * Bug #1655049 - https://bugzilla.mozilla.org/show_bug.cgi?id=1655049 + * WebCompat issue #55782 - https://webcompat.com/issues/55782 + * + * Social buttons menu is not opening due to svg receiving the click + * instead of the button. See https://bugzilla.mozilla.org/show_bug.cgi?id=1654934. + * Adding pointer-events: none to the svg allows to open the menu + */ +#article-show-more-button > * { + pointer-events: none; +} diff --git a/browser/extensions/webcompat/injections/css/bug1666771-zilow-map-overdraw.css b/browser/extensions/webcompat/injections/css/bug1666771-zilow-map-overdraw.css new file mode 100644 index 0000000000..382ed99b50 --- /dev/null +++ b/browser/extensions/webcompat/injections/css/bug1666771-zilow-map-overdraw.css @@ -0,0 +1,17 @@ +/** + * zillow.com - Zillow using massive amounts of memory. + * Bug #1666771 - https://bugzilla.mozilla.org/show_bug.cgi?id=1666771 + * Bug #1662297 - https://bugzilla.mozilla.org/show_bug.cgi?id=1662297 + * + * Zillow's map is using a lot of memory, caused by large amounts of overdraw + * inside the map while rendering object boundaries. Setting `overflow: hidden` + * is a workaround until Zillow addressed this in a more permanent way. + * + * Note that this override is not without side effects: some lines in the map + * may/will be cut off. There is no side-effect free solution to this, and + * not intervening means the browser just freezes. + */ + +.zillow-map-layer svg.full-boundary-svg { + overflow: hidden !important; +} diff --git a/browser/extensions/webcompat/injections/js/bug0000000-testbed-js-injection.js b/browser/extensions/webcompat/injections/js/bug0000000-testbed-js-injection.js new file mode 100644 index 0000000000..4e7db8c5f9 --- /dev/null +++ b/browser/extensions/webcompat/injections/js/bug0000000-testbed-js-injection.js @@ -0,0 +1,11 @@ +"use strict"; + +/* globals exportFunction */ + +Object.defineProperty(window.wrappedJSObject, "isTestFeatureSupported", { + get: exportFunction(function() { + return true; + }, window), + + set: exportFunction(function() {}, window), +}); diff --git a/browser/extensions/webcompat/injections/js/bug1452707-window.controllers-shim-ib.absa.co.za.js b/browser/extensions/webcompat/injections/js/bug1452707-window.controllers-shim-ib.absa.co.za.js new file mode 100644 index 0000000000..d04dcd7638 --- /dev/null +++ b/browser/extensions/webcompat/injections/js/bug1452707-window.controllers-shim-ib.absa.co.za.js @@ -0,0 +1,29 @@ +"use strict"; + +/** + * Bug 1452707 - Build site patch for ib.absa.co.za + * WebCompat issue #16401 - https://webcompat.com/issues/16401 + * + * The online banking at ib.absa.co.za detect if window.controllers is a + * non-falsy value to detect if the current browser is Firefox or something + * else. In bug 1448045, this shim has been disabled for Firefox Nightly 61+, + * which breaks the UA detection on this site and results in a "Browser + * unsuppored" error message. + * + * This site patch simply sets window.controllers to a string, resulting in + * their check to work again. + */ + +/* globals exportFunction */ + +console.info( + "window.controllers has been shimmed for compatibility reasons. See https://webcompat.com/issues/16401 for details." +); + +Object.defineProperty(window.wrappedJSObject, "controllers", { + get: exportFunction(function() { + return true; + }, window), + + set: exportFunction(function() {}, window), +}); diff --git a/browser/extensions/webcompat/injections/js/bug1457335-histography.io-ua-change.js b/browser/extensions/webcompat/injections/js/bug1457335-histography.io-ua-change.js new file mode 100644 index 0000000000..8bbab329c4 --- /dev/null +++ b/browser/extensions/webcompat/injections/js/bug1457335-histography.io-ua-change.js @@ -0,0 +1,34 @@ +"use strict"; + +/** + * Bug 1457335 - histography.io - Override UA & navigator.vendor + * WebCompat issue #1804 - https://webcompat.com/issues/1804 + * + * This site is using a strict matching of navigator.userAgent and + * navigator.vendor to allow access for Safari or Chrome. Here, we set the + * values appropriately so we get recognized as Chrome. + */ + +/* globals exportFunction */ + +console.info( + "The user agent has been overridden for compatibility reasons. See https://webcompat.com/issues/1804 for details." +); + +const CHROME_UA = navigator.userAgent + " Chrome for WebCompat"; + +Object.defineProperty(window.navigator.wrappedJSObject, "userAgent", { + get: exportFunction(function() { + return CHROME_UA; + }, window), + + set: exportFunction(function() {}, window), +}); + +Object.defineProperty(window.navigator.wrappedJSObject, "vendor", { + get: exportFunction(function() { + return "Google Inc."; + }, window), + + set: exportFunction(function() {}, window), +}); diff --git a/browser/extensions/webcompat/injections/js/bug1472075-bankofamerica.com-ua-change.js b/browser/extensions/webcompat/injections/js/bug1472075-bankofamerica.com-ua-change.js new file mode 100644 index 0000000000..0f61422494 --- /dev/null +++ b/browser/extensions/webcompat/injections/js/bug1472075-bankofamerica.com-ua-change.js @@ -0,0 +1,48 @@ +"use strict"; + +/** + * Bug 1472075 - Build UA override for Bank of America for OSX & Linux + * WebCompat issue #2787 - https://webcompat.com/issues/2787 + * + * BoA is showing a red warning to Linux and macOS users, while accepting + * Windows users without warning. From our side, there is no difference here + * and we receive a lot of user complains about the warnings, so we spoof + * as Firefox on Windows in those cases. + */ + +/* globals exportFunction */ + +if (!navigator.platform.includes("Win")) { + console.info( + "The user agent has been overridden for compatibility reasons. See https://webcompat.com/issues/2787 for details." + ); + + const WINDOWS_UA = navigator.userAgent.replace( + /\(.*; rv:/i, + "(Windows NT 10.0; Win64; x64; rv:" + ); + + Object.defineProperty(window.navigator.wrappedJSObject, "userAgent", { + get: exportFunction(function() { + return WINDOWS_UA; + }, window), + + set: exportFunction(function() {}, window), + }); + + Object.defineProperty(window.navigator.wrappedJSObject, "appVersion", { + get: exportFunction(function() { + return "appVersion"; + }, window), + + set: exportFunction(function() {}, window), + }); + + Object.defineProperty(window.navigator.wrappedJSObject, "platform", { + get: exportFunction(function() { + return "Win64"; + }, window), + + set: exportFunction(function() {}, window), + }); +} diff --git a/browser/extensions/webcompat/injections/js/bug1570856-medium.com-menu-isTier1.js b/browser/extensions/webcompat/injections/js/bug1570856-medium.com-menu-isTier1.js new file mode 100644 index 0000000000..f8bb926b60 --- /dev/null +++ b/browser/extensions/webcompat/injections/js/bug1570856-medium.com-menu-isTier1.js @@ -0,0 +1,34 @@ +"use strict"; + +/** + * medium.com - Override window.GLOBALS.useragent.isTier1 to be true + * WebCompat issue #25844 - https://webcompat.com/issues/25844 + * + * This site is not showing main menu when scrolling. There is a GLOBALS variable + * at the bottom of the template being defined based on a server side UA detection. + * Setting window.GLOBALS.useragent.isTier1 to true makes the menu appear when scrolling + */ + +/* globals exportFunction */ + +console.info( + "window.GLOBALS.useragent.isTier1 has been set to true for compatibility reasons. See https://webcompat.com/issues/25844 for details." +); + +let globals = {}; + +Object.defineProperty(window.wrappedJSObject, "GLOBALS", { + get: exportFunction(function() { + return globals; + }, window), + + set: exportFunction(function(value = {}) { + globals = value; + + if (!globals.useragent) { + globals.useragent = {}; + } + + globals.useragent.isTier1 = true; + }, window), +}); diff --git a/browser/extensions/webcompat/injections/js/bug1579159-m.tailieu.vn-pdfjs-worker-disable.js b/browser/extensions/webcompat/injections/js/bug1579159-m.tailieu.vn-pdfjs-worker-disable.js new file mode 100644 index 0000000000..b6600e93f8 --- /dev/null +++ b/browser/extensions/webcompat/injections/js/bug1579159-m.tailieu.vn-pdfjs-worker-disable.js @@ -0,0 +1,28 @@ +"use strict"; + +/** + * m.tailieu.vn - Override PDFJS.disableWorker to be true + * WebCompat issue #39057 - https://webcompat.com/issues/39057 + * + * Custom viewer built with PDF.js is not working in Firefox for Android + * Disabling worker to match Chrome behavior fixes the issue + */ + +/* globals exportFunction */ + +console.info( + "window.PDFJS.disableWorker has been set to true for compatibility reasons. See https://webcompat.com/issues/39057 for details." +); + +let globals = {}; + +Object.defineProperty(window.wrappedJSObject, "PDFJS", { + get: exportFunction(function() { + return globals; + }, window), + + set: exportFunction(function(value = {}) { + globals = value; + globals.disableWorker = true; + }, window), +}); diff --git a/browser/extensions/webcompat/injections/js/bug1605611-maps.google.com-directions-time.js b/browser/extensions/webcompat/injections/js/bug1605611-maps.google.com-directions-time.js new file mode 100644 index 0000000000..aee07df0cc --- /dev/null +++ b/browser/extensions/webcompat/injections/js/bug1605611-maps.google.com-directions-time.js @@ -0,0 +1,82 @@ +"use strict"; + +/* globals exportFunction */ + +/** + * Bug 1605611 - Cannot change Departure/arrival dates in Google Maps on Android + * + * This patch does the following: + * 1. Re-enable the disabled "Leave now" button. + * 2. Fix the precision of datetime-local inputs (to minutes). + * 3. Fixup side effect from enabling the date picker UI via + * injections/css/bug1605611-maps.google.com-directions-time.css + * + * See https://bugzilla.mozilla.org/show_bug.cgi?id=1605611#c0 for details. + */ + +// Step 1. +document.addEventListener("DOMContentLoaded", () => { + // In case the element appeared before the MutationObserver was activated. + for (const elem of document.querySelectorAll( + ".ml-directions-time[disabled]" + )) { + elem.disabled = false; + } + // Start watching for the insertion of the "Leave now" button. + const moOptions = { + attributeFilter: ["disabled"], + attributes: true, + subtree: true, + }; + const mo = new MutationObserver(function(records) { + let restore = false; + for (const { target } of records) { + if (target.classList.contains("ml-directions-time")) { + if (!restore) { + restore = true; + mo.disconnect(); + } + target.disabled = false; + } + } + if (restore) { + mo.observe(document.body, moOptions); + } + }); + mo.observe(document.body, moOptions); +}); + +// Step 2. +const originalValueAsNumberGetter = Object.getOwnPropertyDescriptor( + HTMLInputElement.prototype.wrappedJSObject, + "valueAsNumber" +).get; +Object.defineProperty( + HTMLInputElement.prototype.wrappedJSObject, + "valueAsNumber", + { + configurable: true, + enumerable: true, + get: originalValueAsNumberGetter, + set: exportFunction(function(v) { + if (this.type === "datetime-local" && v) { + const d = new Date(v); + d.setSeconds(0); + d.setMilliseconds(0); + v = d.getTime(); + } + this.valueAsNumber = v; + }, window), + } +); + +// Step 3. +// injections/css/bug1605611-maps.google.com-directions-time.css fixes the bug, +// but a side effect of allowing the user to click on the datetime-local input +// is that the keyboard appears when the native date picker is closed. +// Fix this by unfocusing the datetime-local input upon focus. +document.addEventListener("focusin", ({ target }) => { + if (target.id === "ml-route-options-time-selector-time-input") { + target.blur(); + } +}); diff --git a/browser/extensions/webcompat/injections/js/bug1610358-pcloud.com-appVersion-change.js b/browser/extensions/webcompat/injections/js/bug1610358-pcloud.com-appVersion-change.js new file mode 100644 index 0000000000..c7654227e1 --- /dev/null +++ b/browser/extensions/webcompat/injections/js/bug1610358-pcloud.com-appVersion-change.js @@ -0,0 +1,25 @@ +"use strict"; + +/** + * Bug 1610358 - Add "mobile" to navigator.appVersion + * WebCompat issue #40353 - https://webcompat.com/issues/40353 + * + * the site expecting navigator.appVersion to contain "mobile", + * otherwise it's serving a tablet version for Firefox mobile + */ + +/* globals exportFunction */ + +console.info( + "The user agent has been overridden for compatibility reasons. See https://webcompat.com/issues/40353 for details." +); + +const APP_VERSION = navigator.appVersion + " mobile"; + +Object.defineProperty(window.navigator.wrappedJSObject, "appVersion", { + get: exportFunction(function() { + return APP_VERSION; + }, window), + + set: exportFunction(function() {}, window), +}); diff --git a/browser/extensions/webcompat/injections/js/bug1631811-datastudio.google.com-indexedDB.js b/browser/extensions/webcompat/injections/js/bug1631811-datastudio.google.com-indexedDB.js new file mode 100644 index 0000000000..63bb420d8d --- /dev/null +++ b/browser/extensions/webcompat/injections/js/bug1631811-datastudio.google.com-indexedDB.js @@ -0,0 +1,18 @@ +"use strict"; + +/** + * Bug 1631811 - disable indexedDB for datastudio.google.com iframes + * + * Indexed DB is disabled already for these iframes due to cookie blocking. + * This intervention changes the functionality from throwing a SecurityError + * when indexedDB is accessed to removing it from the window object + */ + +console.info( + "window.indexedDB has been overwritten for compatibility reasons. See https://bugzilla.mozilla.org/show_bug.cgi?id=1631811 for details." +); + +Object.defineProperty(window.wrappedJSObject, "indexedDB", { + get: undefined, + set: undefined, +}); diff --git a/browser/extensions/webcompat/injections/js/bug1665035-dckids.com-cookieEnabled.js b/browser/extensions/webcompat/injections/js/bug1665035-dckids.com-cookieEnabled.js new file mode 100644 index 0000000000..a243fae54a --- /dev/null +++ b/browser/extensions/webcompat/injections/js/bug1665035-dckids.com-cookieEnabled.js @@ -0,0 +1,30 @@ +"use strict"; + +/** + * Bug 1665035 - enable navigator.cookieEnabled and spoof window.navigator on Linux + * + * Some of the games are not starting because navigator.cookieEnabled + * returns false for trackers with ETP strict. Overwriting the value allows + * to play the games. In addition, Linux desktop devices are incorrectly + * flagged as mobile devices (even if ETP is disabled), so spoofing + * window.navigator.platform here. + */ + +console.info( + "window.cookieEnabled has been overwritten for compatibility reasons. See https://bugzilla.mozilla.org/show_bug.cgi?id=1665035 for details." +); + +Object.defineProperty(window.navigator.wrappedJSObject, "cookieEnabled", { + value: true, + writable: false, +}); + +if (navigator.platform.includes("Linux")) { + console.info( + "navigator.platform has been overwritten for compatibility reasons. See https://bugzilla.mozilla.org/show_bug.cgi?id=1665035 for details." + ); + Object.defineProperty(window.navigator.wrappedJSObject, "platform", { + value: "Win64", + writable: false, + }); +} diff --git a/browser/extensions/webcompat/injections/js/bug1677442-store.hp.com-disable-indexeddb.js b/browser/extensions/webcompat/injections/js/bug1677442-store.hp.com-disable-indexeddb.js new file mode 100644 index 0000000000..507e39cff5 --- /dev/null +++ b/browser/extensions/webcompat/injections/js/bug1677442-store.hp.com-disable-indexeddb.js @@ -0,0 +1,20 @@ +"use strict"; + +/** + * Bug 1677442 - disable indexedDB for d3nkfb7815bs43.cloudfront.net + * + * The site embeds an iframe with a 3D viewer. The request fails + * because BabylonJS (the 3d library) tries to access indexedDB + * from the third party context (d3nkfb7815bs43.cloudfront.net) + * Disabling indexedDB fixes it, causing it to fetch the 3d resource + * via network. + */ + +console.info( + "window.indexedDB has been overwritten for compatibility reasons. See https://bugzilla.mozilla.org/show_bug.cgi?id=1677442 for details." +); + +Object.defineProperty(window.wrappedJSObject, "indexedDB", { + get: undefined, + set: undefined, +}); diff --git a/browser/extensions/webcompat/injections/js/bug1682238-gamearter.com-ua-change.js b/browser/extensions/webcompat/injections/js/bug1682238-gamearter.com-ua-change.js new file mode 100644 index 0000000000..8475a9486f --- /dev/null +++ b/browser/extensions/webcompat/injections/js/bug1682238-gamearter.com-ua-change.js @@ -0,0 +1,27 @@ +"use strict"; + +/* + * Bug 1682238 - Override navigator.userAgent for gamearter.com on macOS 11.0 + * Bug 1680516 - Game is not loaded on gamearter.com + * + * Unity < 2021.1.0a2 is unable to correctly parse User Agents with + * "Mac OS X 11.0" in them, so let's override to "Mac OS X 10.16" instead + * for now. + */ + +/* globals exportFunction */ + +if (navigator.userAgent.includes("Mac OS X 11.")) { + console.info( + "The user agent has been overridden for compatibility reasons. See https://bugzilla.mozilla.org/show_bug.cgi?id=1680516 for details." + ); + + let originalUA = navigator.userAgent; + Object.defineProperty(window.navigator.wrappedJSObject, "userAgent", { + get: exportFunction(function() { + return originalUA.replace(/Mac OS X 11\.(\d)+;/, "Mac OS X 10.16;"); + }, window), + + set: exportFunction(function() {}, window), + }); +} diff --git a/browser/extensions/webcompat/lib/about_compat_broker.js b/browser/extensions/webcompat/lib/about_compat_broker.js new file mode 100644 index 0000000000..dc939b2b06 --- /dev/null +++ b/browser/extensions/webcompat/lib/about_compat_broker.js @@ -0,0 +1,123 @@ +/* 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"; + +/* global browser, module, onMessageFromTab */ + +class AboutCompatBroker { + constructor(bindings) { + this.portsToAboutCompatTabs = this.buildPorts(); + + this._injections = bindings.injections; + this._injections.bindAboutCompatBroker(this); + + this._uaOverrides = bindings.uaOverrides; + this._uaOverrides.bindAboutCompatBroker(this); + } + + buildPorts() { + const ports = new Set(); + + browser.runtime.onConnect.addListener(port => { + ports.add(port); + port.onDisconnect.addListener(function() { + ports.delete(port); + }); + }); + + async function broadcast(message) { + for (const port of ports) { + port.postMessage(message); + } + } + + return { broadcast }; + } + + filterOverrides(overrides) { + return overrides + .filter(override => override.availableOnPlatform) + .map(override => { + const { id, active, bug, domain, hidden } = override; + return { id, active, bug, domain, hidden }; + }); + } + + getOverrideOrInterventionById(id) { + for (const [type, things] of Object.entries({ + overrides: this._uaOverrides.getAvailableOverrides(), + interventions: this._injections.getAvailableInjections(), + })) { + for (const what of things) { + if (what.id === id) { + return { type, what }; + } + } + } + return {}; + } + + bootup() { + onMessageFromTab(msg => { + switch (msg.command || msg) { + case "toggle": { + const id = msg.id; + const { type, what } = this.getOverrideOrInterventionById(id); + if (!what) { + return Promise.reject( + `No such override or intervention to toggle: ${id}` + ); + } + this.portsToAboutCompatTabs + .broadcast({ toggling: id, active: what.active }) + .then(async () => { + switch (type) { + case "interventions": { + if (what.active) { + await this._injections.disableInjection(what); + } else { + await this._injections.enableInjection(what); + } + break; + } + case "overrides": { + if (what.active) { + await this._uaOverrides.disableOverride(what); + } else { + await this._uaOverrides.enableOverride(what); + } + break; + } + } + this.portsToAboutCompatTabs.broadcast({ + toggled: id, + active: what.active, + }); + }); + break; + } + case "getOverridesAndInterventions": { + return Promise.resolve({ + overrides: + (this._uaOverrides.isEnabled() && + this.filterOverrides( + this._uaOverrides.getAvailableOverrides() + )) || + false, + interventions: + (this._injections.isEnabled() && + this.filterOverrides( + this._injections.getAvailableInjections() + )) || + false, + }); + } + } + return undefined; + }); + } +} + +module.exports = AboutCompatBroker; diff --git a/browser/extensions/webcompat/lib/custom_functions.js b/browser/extensions/webcompat/lib/custom_functions.js new file mode 100644 index 0000000000..9bfc6fbdb5 --- /dev/null +++ b/browser/extensions/webcompat/lib/custom_functions.js @@ -0,0 +1,96 @@ +/* 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"; + +/* globals browser, module */ + +const replaceStringInRequest = ( + requestId, + inString, + outString, + inEncoding = "utf-8" +) => { + const filter = browser.webRequest.filterResponseData(requestId); + const decoder = new TextDecoder(inEncoding); + const encoder = new TextEncoder(); + const RE = new RegExp(inString, "g"); + const carryoverLength = inString.length; + let carryover = ""; + + filter.ondata = event => { + const replaced = ( + carryover + decoder.decode(event.data, { stream: true }) + ).replace(RE, outString); + filter.write(encoder.encode(replaced.slice(0, -carryoverLength))); + carryover = replaced.slice(-carryoverLength); + }; + + filter.onstop = event => { + if (carryover.length) { + filter.write(encoder.encode(carryover)); + } + filter.close(); + }; +}; + +const CUSTOM_FUNCTIONS = { + detectSwipeFix: injection => { + const { urls, types } = injection.data; + const listener = (injection.data.listener = ({ requestId }) => { + replaceStringInRequest( + requestId, + "preventDefault:true", + "preventDefault:false" + ); + return {}; + }); + browser.webRequest.onBeforeRequest.addListener(listener, { urls, types }, [ + "blocking", + ]); + }, + detectSwipeFixDisable: injection => { + const { listener } = injection.data; + browser.webRequest.onBeforeRequest.removeListener(listener); + delete injection.data.listener; + }, + noSniffFix: injection => { + const { urls, contentType } = injection.data; + const listener = (injection.data.listener = e => { + e.responseHeaders.push(contentType); + return { responseHeaders: e.responseHeaders }; + }); + + browser.webRequest.onHeadersReceived.addListener(listener, { urls }, [ + "blocking", + "responseHeaders", + ]); + }, + noSniffFixDisable: injection => { + const { listener } = injection.data; + browser.webRequest.onHeadersReceived.removeListener(listener); + delete injection.data.listener; + }, + pdk5fix: injection => { + const { urls, types } = injection.data; + const listener = (injection.data.listener = ({ requestId }) => { + replaceStringInRequest( + requestId, + "VideoContextChromeAndroid", + "VideoContextAndroid" + ); + return {}; + }); + browser.webRequest.onBeforeRequest.addListener(listener, { urls, types }, [ + "blocking", + ]); + }, + pdk5fixDisable: injection => { + const { listener } = injection.data; + browser.webRequest.onBeforeRequest.removeListener(listener); + delete injection.data.listener; + }, +}; + +module.exports = CUSTOM_FUNCTIONS; diff --git a/browser/extensions/webcompat/lib/injections.js b/browser/extensions/webcompat/lib/injections.js new file mode 100644 index 0000000000..6a7a6e8d4a --- /dev/null +++ b/browser/extensions/webcompat/lib/injections.js @@ -0,0 +1,163 @@ +/* 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"; + +/* globals browser, module */ + +class Injections { + constructor(availableInjections, customFunctions) { + this.INJECTION_PREF = "perform_injections"; + + this._injectionsEnabled = true; + + this._availableInjections = availableInjections; + this._activeInjections = new Map(); + this._customFunctions = customFunctions; + } + + bindAboutCompatBroker(broker) { + this._aboutCompatBroker = broker; + } + + bootup() { + browser.aboutConfigPrefs.onPrefChange.addListener(() => { + this.checkInjectionPref(); + }, this.INJECTION_PREF); + this.checkInjectionPref(); + } + + checkInjectionPref() { + browser.aboutConfigPrefs.getPref(this.INJECTION_PREF).then(value => { + if (value === undefined) { + browser.aboutConfigPrefs.setPref(this.INJECTION_PREF, true); + } else if (value === false) { + this.unregisterContentScripts(); + } else { + this.registerContentScripts(); + } + }); + } + + getAvailableInjections() { + return this._availableInjections; + } + + isEnabled() { + return this._injectionsEnabled; + } + + async registerContentScripts() { + const platformMatches = ["all"]; + let platformInfo = await browser.runtime.getPlatformInfo(); + platformMatches.push(platformInfo.os == "android" ? "android" : "desktop"); + + for (const injection of this._availableInjections) { + if (platformMatches.includes(injection.platform)) { + injection.availableOnPlatform = true; + await this.enableInjection(injection); + } + } + + this._injectionsEnabled = true; + this._aboutCompatBroker.portsToAboutCompatTabs.broadcast({ + interventionsChanged: this._aboutCompatBroker.filterOverrides( + this._availableInjections + ), + }); + } + + assignContentScriptDefaults(contentScripts) { + let finalConfig = Object.assign({}, contentScripts); + + if (!finalConfig.runAt) { + finalConfig.runAt = "document_start"; + } + + return finalConfig; + } + + async enableInjection(injection) { + if (injection.active) { + return undefined; + } + + if (injection.customFunc) { + return this.enableCustomInjection(injection); + } + + return this.enableContentScripts(injection); + } + + enableCustomInjection(injection) { + if (injection.customFunc in this._customFunctions) { + this._customFunctions[injection.customFunc](injection); + injection.active = true; + } else { + console.error( + `Provided function ${injection.customFunc} wasn't found in functions list` + ); + } + } + + async enableContentScripts(injection) { + try { + const handle = await browser.contentScripts.register( + this.assignContentScriptDefaults(injection.contentScripts) + ); + this._activeInjections.set(injection, handle); + injection.active = true; + } catch (ex) { + console.error( + "Registering WebCompat GoFaster content scripts failed: ", + ex + ); + } + } + + unregisterContentScripts() { + for (const injection of this._availableInjections) { + this.disableInjection(injection); + } + + this._injectionsEnabled = false; + this._aboutCompatBroker.portsToAboutCompatTabs.broadcast({ + interventionsChanged: false, + }); + } + + async disableInjection(injection) { + if (!injection.active) { + return undefined; + } + + if (injection.customFunc) { + return this.disableCustomInjections(injection); + } + + return this.disableContentScripts(injection); + } + + disableCustomInjections(injection) { + const disableFunc = injection.customFunc + "Disable"; + + if (disableFunc in this._customFunctions) { + this._customFunctions[disableFunc](injection); + injection.active = false; + } else { + console.error( + `Provided function ${disableFunc} for disabling injection wasn't found in functions list` + ); + } + } + + async disableContentScripts(injection) { + const contentScript = this._activeInjections.get(injection); + await contentScript.unregister(); + this._activeInjections.delete(injection); + injection.active = false; + } +} + +module.exports = Injections; diff --git a/browser/extensions/webcompat/lib/intervention_helpers.js b/browser/extensions/webcompat/lib/intervention_helpers.js new file mode 100644 index 0000000000..16ea6572f2 --- /dev/null +++ b/browser/extensions/webcompat/lib/intervention_helpers.js @@ -0,0 +1,233 @@ +/* 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"; + +/* globals module */ + +const GOOGLE_TLDS = [ + "com", + "ac", + "ad", + "ae", + "com.af", + "com.ag", + "com.ai", + "al", + "am", + "co.ao", + "com.ar", + "as", + "at", + "com.au", + "az", + "ba", + "com.bd", + "be", + "bf", + "bg", + "com.bh", + "bi", + "bj", + "com.bn", + "com.bo", + "com.br", + "bs", + "bt", + "co.bw", + "by", + "com.bz", + "ca", + "com.kh", + "cc", + "cd", + "cf", + "cat", + "cg", + "ch", + "ci", + "co.ck", + "cl", + "cm", + "cn", + "com.co", + "co.cr", + "com.cu", + "cv", + "com.cy", + "cz", + "de", + "dj", + "dk", + "dm", + "com.do", + "dz", + "com.ec", + "ee", + "com.eg", + "es", + "com.et", + "fi", + "com.fj", + "fm", + "fr", + "ga", + "ge", + "gf", + "gg", + "com.gh", + "com.gi", + "gl", + "gm", + "gp", + "gr", + "com.gt", + "gy", + "com.hk", + "hn", + "hr", + "ht", + "hu", + "co.id", + "iq", + "ie", + "co.il", + "im", + "co.in", + "io", + "is", + "it", + "je", + "com.jm", + "jo", + "co.jp", + "co.ke", + "ki", + "kg", + "co.kr", + "com.kw", + "kz", + "la", + "com.lb", + "com.lc", + "li", + "lk", + "co.ls", + "lt", + "lu", + "lv", + "com.ly", + "co.ma", + "md", + "me", + "mg", + "mk", + "ml", + "com.mm", + "mn", + "ms", + "com.mt", + "mu", + "mv", + "mw", + "com.mx", + "com.my", + "co.mz", + "com.na", + "ne", + "com.nf", + "com.ng", + "com.ni", + "nl", + "no", + "com.np", + "nr", + "nu", + "co.nz", + "com.om", + "com.pk", + "com.pa", + "com.pe", + "com.ph", + "pl", + "com.pg", + "pn", + "com.pr", + "ps", + "pt", + "com.py", + "com.qa", + "ro", + "rs", + "ru", + "rw", + "com.sa", + "com.sb", + "sc", + "se", + "com.sg", + "sh", + "si", + "sk", + "com.sl", + "sn", + "sm", + "so", + "st", + "sr", + "com.sv", + "td", + "tg", + "co.th", + "com.tj", + "tk", + "tl", + "tm", + "to", + "tn", + "com.tr", + "tt", + "com.tw", + "co.tz", + "com.ua", + "co.ug", + "co.uk", + "com", + "com.uy", + "co.uz", + "com.vc", + "co.ve", + "vg", + "co.vi", + "com.vn", + "vu", + "ws", + "co.za", + "co.zm", + "co.zw", +]; + +var InterventionHelpers = { + /** + * Useful helper to generate a list of domains with a fixed base domain and + * multiple country-TLDs or other cases with various TLDs. + * + * Example: + * matchPatternsForTLDs("*://mozilla.", "/*", ["com", "org"]) + * => ["*://mozilla.com/*", "*://mozilla.org/*"] + */ + matchPatternsForTLDs(base, suffix, tlds) { + return tlds.map(tld => base + tld + suffix); + }, + + /** + * A modified version of matchPatternsForTLDs that always returns the match + * list for all known Google country TLDs. + */ + matchPatternsForGoogle(base, suffix = "/*") { + return InterventionHelpers.matchPatternsForTLDs(base, suffix, GOOGLE_TLDS); + }, +}; + +module.exports = InterventionHelpers; diff --git a/browser/extensions/webcompat/lib/messaging_helper.js b/browser/extensions/webcompat/lib/messaging_helper.js new file mode 100644 index 0000000000..793fa03139 --- /dev/null +++ b/browser/extensions/webcompat/lib/messaging_helper.js @@ -0,0 +1,36 @@ +/* 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"; + +/* globals browser */ + +// By default, only the first handler for browser.runtime.onMessage which +// returns a value will get to return one. As such, we need to let them all +// receive the message, and all have a chance to return a response (with the +// first non-undefined result being the one that is ultimately returned). +// This way, about:compat and the shims library can both get a chance to +// process a message, and just return undefined if they wish to ignore it. + +const onMessageFromTab = (function() { + const handlers = new Set(); + + browser.runtime.onMessage.addListener((msg, sender) => { + const promises = [...handlers.values()].map(fn => fn(msg, sender)); + return Promise.allSettled(promises).then(results => { + for (const { reason, value } of results) { + if (reason) { + console.error(reason); + } else if (value !== undefined) { + return value; + } + } + return undefined; + }); + }); + + return function(handler) { + handlers.add(handler); + }; +})(); diff --git a/browser/extensions/webcompat/lib/module_shim.js b/browser/extensions/webcompat/lib/module_shim.js new file mode 100644 index 0000000000..2fd39fdbbd --- /dev/null +++ b/browser/extensions/webcompat/lib/module_shim.js @@ -0,0 +1,24 @@ +/* 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"; + +/** + * We cannot yet use proper JS modules within webextensions, as support for them + * is highly experimental and highly instable. So we end up just including all + * the JS files we need as separate background scripts, and since they all are + * executed within the same context, this works for our in-browser deployment. + * + * However, this code is tracked outside of mozilla-central, and we work on + * shipping this code in other products, like android-components as well. + * Because of that, we have automated tests running within that repository. To + * make our lives easier, we add `module.exports` statements to the JS source + * files, so we can easily import their contents into our NodeJS-based test + * suite. + * + * This works fine, but obviously, `module` is not defined when running + * in-browser. So let's use this empty object as a shim, so we don't run into + * runtime exceptions because of that. + */ +var module = {}; diff --git a/browser/extensions/webcompat/lib/picture_in_picture_overrides.js b/browser/extensions/webcompat/lib/picture_in_picture_overrides.js new file mode 100644 index 0000000000..febee193aa --- /dev/null +++ b/browser/extensions/webcompat/lib/picture_in_picture_overrides.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/. */ + +"use strict"; + +/* globals browser, module */ + +class PictureInPictureOverrides { + constructor(availableOverrides) { + this.pref = "enable_picture_in_picture_overrides"; + this._prefEnabledOverrides = new Set(); + this._availableOverrides = availableOverrides; + this.policies = browser.pictureInPictureChild.getPolicies(); + } + + async _checkGlobalPref() { + await browser.aboutConfigPrefs.getPref(this.pref).then(value => { + if (value === false) { + this._enabled = false; + } else { + if (value === undefined) { + browser.aboutConfigPrefs.setPref(this.pref, true); + } + this._enabled = true; + } + }); + } + + async _checkSpecificOverridePref(id, pref) { + const isDisabled = await browser.aboutConfigPrefs.getPref(pref); + if (isDisabled === true) { + this._prefEnabledOverrides.delete(id); + } else { + this._prefEnabledOverrides.add(id); + } + } + + bootup() { + const checkGlobal = async () => { + await this._checkGlobalPref(); + this._onAvailableOverridesChanged(); + }; + browser.aboutConfigPrefs.onPrefChange.addListener(checkGlobal, this.pref); + + const bootupPrefCheckPromises = [this._checkGlobalPref()]; + + for (const id of Object.keys(this._availableOverrides)) { + const pref = `disabled_picture_in_picture_overrides.${id}`; + const checkSingle = async () => { + await this._checkSpecificOverridePref(id, pref); + this._onAvailableOverridesChanged(); + }; + browser.aboutConfigPrefs.onPrefChange.addListener(checkSingle, pref); + bootupPrefCheckPromises.push(this._checkSpecificOverridePref(id, pref)); + } + + Promise.all(bootupPrefCheckPromises).then(() => { + this._onAvailableOverridesChanged(); + }); + } + + async _onAvailableOverridesChanged() { + const policies = await this.policies; + let enabledOverrides = {}; + for (const [id, override] of Object.entries(this._availableOverrides)) { + const enabled = this._enabled && this._prefEnabledOverrides.has(id); + for (const [url, policy] of Object.entries(override)) { + enabledOverrides[url] = enabled ? policy : policies.DEFAULT; + } + } + browser.pictureInPictureParent.setOverrides(enabledOverrides); + } +} diff --git a/browser/extensions/webcompat/lib/shim_messaging_helper.js b/browser/extensions/webcompat/lib/shim_messaging_helper.js new file mode 100644 index 0000000000..ee109713a5 --- /dev/null +++ b/browser/extensions/webcompat/lib/shim_messaging_helper.js @@ -0,0 +1,65 @@ +/* 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"; + +/* globals browser */ + +if (!window.Shims) { + window.Shims = new Map(); +} + +if (!window.ShimsHelperReady) { + window.ShimsHelperReady = true; + + browser.runtime.onMessage.addListener(details => { + const { shimId, warning } = details; + if (!shimId) { + return; + } + window.Shims.set(shimId, details); + if (warning) { + console.warn(warning); + } + }); + + async function handleMessage(port, shimId, messageId, message) { + let response; + const shim = window.Shims.get(shimId); + if (shim) { + const { needsShimHelpers, origin } = shim; + if (origin === location.origin) { + if (needsShimHelpers?.includes(message)) { + const msg = { shimId, message }; + try { + response = await browser.runtime.sendMessage(msg); + } catch (_) {} + } + } + } + port.postMessage({ messageId, response }); + } + + window.addEventListener( + "ShimConnects", + e => { + e.stopPropagation(); + e.preventDefault(); + const { port, pendingMessages, shimId } = e.detail; + const shim = window.Shims.get(shimId); + if (!shim) { + return; + } + port.onmessage = ({ data }) => { + handleMessage(port, shimId, data.messageId, data.message); + }; + for (const [messageId, message] of pendingMessages) { + handleMessage(port, shimId, messageId, message); + } + }, + true + ); + + window.dispatchEvent(new CustomEvent("ShimHelperReady")); +} diff --git a/browser/extensions/webcompat/lib/shims.js b/browser/extensions/webcompat/lib/shims.js new file mode 100644 index 0000000000..638411007b --- /dev/null +++ b/browser/extensions/webcompat/lib/shims.js @@ -0,0 +1,415 @@ +/* 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"; + +/* globals browser, module, onMessageFromTab */ + +const releaseBranchPromise = browser.appConstants.getReleaseBranch(); + +const platformPromise = browser.runtime.getPlatformInfo().then(info => { + return info.os === "android" ? "android" : "desktop"; +}); + +let debug = async function() { + if ((await releaseBranchPromise) !== "beta_or_release") { + console.debug.apply(this, arguments); + } +}; +let error = async function() { + if ((await releaseBranchPromise) !== "beta_or_release") { + console.error.apply(this, arguments); + } +}; +let warn = async function() { + if ((await releaseBranchPromise) !== "beta_or_release") { + console.warn.apply(this, arguments); + } +}; + +class Shim { + constructor(opts) { + const { matches, unblocksOnOptIn } = opts; + + this.branches = opts.branches; + this.bug = opts.bug; + this.file = opts.file; + this.hosts = opts.hosts; + this.id = opts.id; + this.matches = matches; + this.name = opts.name; + this.notHosts = opts.notHosts; + this.onlyIfBlockedByETP = opts.onlyIfBlockedByETP; + this._options = opts.options || {}; + this.needsShimHelpers = opts.needsShimHelpers; + this.platform = opts.platform || "all"; + this.unblocksOnOptIn = unblocksOnOptIn; + + this._hostOptIns = new Set(); + + this._disabledByConfig = opts.disabled; + this._disabledGlobally = false; + this._disabledByPlatform = false; + this._disabledByReleaseBranch = false; + + const pref = `disabled_shims.${this.id}`; + + browser.aboutConfigPrefs.onPrefChange.addListener(async () => { + const value = await browser.aboutConfigPrefs.getPref(pref); + this._disabledPrefValue = value; + this._onEnabledStateChanged(); + }, pref); + + this.ready = Promise.all([ + browser.aboutConfigPrefs.getPref(pref).then(value => { + this._disabledPrefValue = value; + }), + platformPromise.then(platform => { + this._disabledByPlatform = + this.platform !== "all" && this.platform !== platform; + return platform; + }), + releaseBranchPromise.then(branch => { + this._disabledByReleaseBranch = + this.branches && !this.branches.includes(branch); + return branch; + }), + ]).then(([_, platform, branch]) => { + this._preprocessOptions(platform, branch); + this._onEnabledStateChanged(); + }); + } + + _preprocessOptions(platform, branch) { + // options may be any value, but can optionally be gated for specified + // platform/branches, if in the format `{value, branches, platform}` + this.options = {}; + for (const [k, v] of Object.entries(this._options)) { + if (v?.value) { + if ( + (!v.platform || v.platform === platform) && + (!v.branches || v.branches.includes(branch)) + ) { + this.options[k] = v.value; + } + } else { + this.options[k] = v; + } + } + } + + get enabled() { + if (this._disabledGlobally) { + return false; + } + + if (this._disabledPrefValue !== undefined) { + return !this._disabledPrefValue; + } + + return ( + !this._disabledByConfig && + !this._disabledByPlatform && + !this._disabledByReleaseBranch + ); + } + + enable() { + this._disabledGlobally = false; + this._onEnabledStateChanged(); + } + + disable() { + this._disabledGlobally = true; + this._onEnabledStateChanged(); + } + + _onEnabledStateChanged() { + if (!this.enabled) { + return this._revokeRequestsInETP(); + } + return this._allowRequestsInETP(); + } + + _allowRequestsInETP() { + return browser.trackingProtection.allow(this.id, this.matches, { + hosts: this.hosts, + notHosts: this.notHosts, + }); + } + + _revokeRequestsInETP() { + return browser.trackingProtection.revoke(this.id); + } + + meantForHost(host) { + const { hosts, notHosts } = this; + if (hosts || notHosts) { + if ( + (notHosts && notHosts.includes(host)) || + (hosts && !hosts.includes(host)) + ) { + return false; + } + } + return true; + } + + isTriggeredByURL(url) { + if (!this.matches) { + return false; + } + + if (!this._matcher) { + this._matcher = browser.matchPatterns.getMatcher(this.matches); + } + + return this._matcher.matches(url); + } + + async onUserOptIn(host) { + const { unblocksOnOptIn } = this; + if (unblocksOnOptIn) { + await browser.trackingProtection.allow(this.id, unblocksOnOptIn, { + hosts: [host], + }); + } + + this._hostOptIns.add(host); + } + + hasUserOptedInAlready(host) { + return this._hostOptIns.has(host); + } +} + +class Shims { + constructor(availableShims) { + if (!browser.trackingProtection) { + console.error("Required experimental add-on APIs for shims unavailable"); + return; + } + + this._registerShims(availableShims); + + onMessageFromTab(this._onMessageFromShim.bind(this)); + + this.ENABLED_PREF = "enable_shims"; + browser.aboutConfigPrefs.onPrefChange.addListener(() => { + this._checkEnabledPref(); + }, this.ENABLED_PREF); + this._haveCheckedEnabledPref = this._checkEnabledPref(); + } + + _registerShims(shims) { + if (this.shims) { + throw new Error("_registerShims has already been called"); + } + + this.shims = new Map(); + for (const shimOpts of shims) { + const { id } = shimOpts; + if (!this.shims.has(id)) { + this.shims.set(shimOpts.id, new Shim(shimOpts)); + } + } + + const allShimPatterns = new Set(); + for (const { matches } of this.shims.values()) { + for (const matchPattern of matches) { + allShimPatterns.add(matchPattern); + } + } + + if (!allShimPatterns.size) { + debug("Skipping shims; none enabled"); + return; + } + + const urls = [...allShimPatterns]; + debug("Shimming these match patterns", urls); + + browser.webRequest.onBeforeRequest.addListener( + this._ensureShimForRequestOnTab.bind(this), + { urls, types: ["script"] }, + ["blocking"] + ); + } + + async _checkEnabledPref() { + await browser.aboutConfigPrefs.getPref(this.ENABLED_PREF).then(value => { + if (value === undefined) { + browser.aboutConfigPrefs.setPref(this.ENABLED_PREF, true); + } else if (value === false) { + this.enabled = false; + } else { + this.enabled = true; + } + }); + } + + get enabled() { + return this._enabled; + } + + set enabled(enabled) { + if (enabled === this._enabled) { + return; + } + + this._enabled = enabled; + + for (const shim of this.shims.values()) { + if (enabled) { + shim.enable(); + } else { + shim.disable(); + } + } + } + + async _onMessageFromShim(payload, sender, sendResponse) { + const { tab } = sender; + const { id, url } = tab; + const { shimId, message } = payload; + + // Ignore unknown messages (for instance, from about:compat). + if (message !== "getOptions" && message !== "optIn") { + return undefined; + } + + if (sender.id !== browser.runtime.id || id === -1) { + throw new Error("not allowed"); + } + + // Important! It is entirely possible for sites to spoof + // these messages, due to shims allowing web pages to + // communicate with the extension. + + const shim = this.shims.get(shimId); + if (!shim?.needsShimHelpers?.includes(message)) { + throw new Error("not allowed"); + } + + if (message === "getOptions") { + return shim.options; + } else if (message === "optIn") { + try { + await shim.onUserOptIn(new URL(url).hostname); + warn("** User opted in on tab ", id, "for", shimId); + } catch (err) { + console.error(err); + throw new Error("error"); + } + } + + return undefined; + } + + async _ensureShimForRequestOnTab(details) { + await this._haveCheckedEnabledPref; + + if (!this.enabled) { + return undefined; + } + + // We only ever reach this point if a request is for a URL which ought to + // be shimmed. We never get here if a request is blocked, and we only + // unblock requests if at least one shim matches it. + + const { frameId, originUrl, requestId, tabId, url } = details; + + // Ignore requests unrelated to tabs + if (tabId < 0) { + return undefined; + } + + // We need to base our checks not on the frame's host, but the tab's. + const topHost = new URL((await browser.tabs.get(tabId)).url).hostname; + const unblocked = await browser.trackingProtection.wasRequestUnblocked( + requestId + ); + + let shimToApply; + for (const shim of this.shims.values()) { + await shim.ready; + + if (!shim.enabled) { + continue; + } + + // Do not apply the shim if it is only meant to apply when strict mode ETP + // (content blocking) was going to block the request. + if (!unblocked && shim.onlyIfBlockedByETP) { + continue; + } + + if (!shim.meantForHost(topHost)) { + continue; + } + + // If the user has already opted in for this shim, all requests it covers + // should be allowed; no need for a shim anymore. + if (shim.hasUserOptedInAlready(topHost)) { + return undefined; + } + + // If this URL isn't meant for this shim, don't apply it. + if (!shim.isTriggeredByURL(url)) { + continue; + } + + shimToApply = shim; + break; + } + + if (shimToApply) { + // Note that sites may request the same shim twice, but because the requests + // may differ enough for some to fail (CSP/CORS/etc), we always re-run the + // shim JS just in case. Shims should gracefully handle this as well. + const { bug, file, id, name, needsShimHelpers } = shimToApply; + warn("Shimming", name, "on tabId", tabId, "frameId", frameId); + + const warning = `${name} is being shimmed by Firefox. See https://bugzilla.mozilla.org/show_bug.cgi?id=${bug} for details.`; + + try { + if (needsShimHelpers?.length) { + await browser.tabs.executeScript(tabId, { + file: "/lib/shim_messaging_helper.js", + frameId, + runAt: "document_start", + }); + const origin = new URL(originUrl).origin; + await browser.tabs.sendMessage( + tabId, + { origin, shimId: id, needsShimHelpers, warning }, + { frameId } + ); + } else { + await browser.tabs.executeScript(tabId, { + code: `console.warn(${JSON.stringify(warning)})`, + frameId, + runAt: "document_start", + }); + } + } catch (_) {} + + // If any shims matched the script to replace it, then let the original + // request complete without ever hitting the network, with a blank script. + return { redirectUrl: browser.runtime.getURL(`shims/${file}`) }; + } + + // Sanity check: if no shims are over-riding a given URL and it was meant to + // be blocked by ETP, then block it. + if (unblocked) { + error("unexpected:", url, "was not shimmed, and had to be re-blocked"); + return { cancel: true }; + } + + debug("allowing", url); + return undefined; + } +} + +module.exports = Shims; diff --git a/browser/extensions/webcompat/lib/ua_overrides.js b/browser/extensions/webcompat/lib/ua_overrides.js new file mode 100644 index 0000000000..7024583e4e --- /dev/null +++ b/browser/extensions/webcompat/lib/ua_overrides.js @@ -0,0 +1,265 @@ +/* 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"; + +/* globals browser, module */ + +class UAOverrides { + constructor(availableOverrides) { + this.OVERRIDE_PREF = "perform_ua_overrides"; + + this._overridesEnabled = true; + + this._availableOverrides = availableOverrides; + this._activeListeners = new Map(); + } + + bindAboutCompatBroker(broker) { + this._aboutCompatBroker = broker; + } + + bootup() { + browser.aboutConfigPrefs.onPrefChange.addListener(() => { + this.checkOverridePref(); + }, this.OVERRIDE_PREF); + this.checkOverridePref(); + } + + checkOverridePref() { + browser.aboutConfigPrefs.getPref(this.OVERRIDE_PREF).then(value => { + if (value === undefined) { + browser.aboutConfigPrefs.setPref(this.OVERRIDE_PREF, true); + } else if (value === false) { + this.unregisterUAOverrides(); + } else { + this.registerUAOverrides(); + } + }); + } + + getAvailableOverrides() { + return this._availableOverrides; + } + + isEnabled() { + return this._overridesEnabled; + } + + enableOverride(override) { + if (override.active) { + return; + } + + const { blocks, matches, telemetryKey, uaTransformer } = override.config; + const listener = details => { + // We set the "used" telemetry key if the user would have had the + // override applied, regardless of whether it is actually applied. + if (!details.frameId && override.shouldSendDetailedTelemetry) { + // For now, we only care about Telemetry on Fennec, where telemetry + // is sent in Java code (as part of the core ping). That code must + // be aware of each key we send, which we send as a SharedPreference. + browser.sharedPreferences.setBoolPref(`${telemetryKey}Used`, true); + } + + // Don't actually override the UA for an experiment if the user is not + // part of the experiment (unless they force-enabed the override). + if ( + !override.config.experiment || + override.experimentActive || + override.permanentPrefEnabled === true + ) { + for (const header of details.requestHeaders) { + if (header.name.toLowerCase() === "user-agent") { + // Don't override the UA if we're on a mobile device that has the + // "Request Desktop Site" mode enabled. The UA for the desktop mode + // is set inside Gecko with a simple string replace, so we can use + // that as a check, see https://searchfox.org/mozilla-central/rev/89d33e1c3b0a57a9377b4815c2f4b58d933b7c32/mobile/android/chrome/geckoview/GeckoViewSettingsChild.js#23-28 + let isMobileWithDesktopMode = + override.currentPlatform == "android" && + header.value.includes("X11; Linux x86_64"); + + if (!isMobileWithDesktopMode) { + header.value = uaTransformer(header.value); + } + } + } + } + return { requestHeaders: details.requestHeaders }; + }; + + browser.webRequest.onBeforeSendHeaders.addListener( + listener, + { urls: matches }, + ["blocking", "requestHeaders"] + ); + + const listeners = { onBeforeSendHeaders: listener }; + if (blocks) { + const blistener = details => { + return { cancel: true }; + }; + + browser.webRequest.onBeforeRequest.addListener( + blistener, + { urls: blocks }, + ["blocking"] + ); + + listeners.onBeforeRequest = blistener; + } + this._activeListeners.set(override, listeners); + override.active = true; + + // If telemetry is being collected, note the addon version. + if (telemetryKey) { + const { version } = browser.runtime.getManifest(); + browser.sharedPreferences.setCharPref(`${telemetryKey}Version`, version); + } + + // If collecting detailed telemetry on the override, note that it was activated. + if (override.shouldSendDetailedTelemetry) { + browser.sharedPreferences.setBoolPref(`${telemetryKey}Ready`, true); + } + } + + onOverrideConfigChanged(override) { + // Check whether the override should be hidden from about:compat. + override.hidden = override.config.hidden; + + // Also hide if the override is in an experiment the user is not part of. + if (override.config.experiment && !override.experimentActive) { + override.hidden = true; + } + + // Setting the override's permanent pref overrules whether it is hidden. + if (override.permanentPrefEnabled !== undefined) { + override.hidden = !override.permanentPrefEnabled; + } + + // Also check whether the override should be active. + let shouldBeActive = true; + + // Overrides can be force-deactivated by their permanent preference. + if (override.permanentPrefEnabled === false) { + shouldBeActive = false; + } + + // Only send detailed telemetry if the user is actively in an experiment or + // has opted into an experimental feature. + override.shouldSendDetailedTelemetry = + override.config.telemetryKey && + (override.experimentActive || override.permanentPrefEnabled); + + // Overrides gated behind an experiment the user is not part of do not + // have to be activated, unless they are gathering telemetry, or the + // user has force-enabled them with their permanent pref. + if ( + override.config.experiment && + !override.experimentActive && + !override.config.telemetryKey && + override.permanentPrefEnabled !== true + ) { + shouldBeActive = false; + } + + if (shouldBeActive) { + this.enableOverride(override); + } else { + this.disableOverride(override); + } + + if (this._overridesEnabled) { + this._aboutCompatBroker.portsToAboutCompatTabs.broadcast({ + overridesChanged: this._aboutCompatBroker.filterOverrides( + this._availableOverrides + ), + }); + } + } + + async registerUAOverrides() { + const platformMatches = ["all"]; + let platformInfo = await browser.runtime.getPlatformInfo(); + platformMatches.push(platformInfo.os == "android" ? "android" : "desktop"); + + for (const override of this._availableOverrides) { + if (platformMatches.includes(override.platform)) { + override.availableOnPlatform = true; + override.currentPlatform = platformInfo.os; + + // Note whether the user is actively in the override's experiment (if any). + override.experimentActive = false; + const experiment = override.config.experiment; + if (experiment) { + // We expect the definition to have either one string for 'experiment' + // (just one branch) or an array of strings (multiple branches). So + // here we turn the string case into a one-element array for the loop. + const branches = Array.isArray(experiment) + ? experiment + : [experiment]; + for (const branch of branches) { + if (await browser.experiments.isActive(branch)) { + override.experimentActive = true; + break; + } + } + } + + // If there is a specific about:config preference governing + // this override, monitor its state. + const pref = override.config.permanentPref; + override.permanentPrefEnabled = + pref && (await browser.aboutConfigPrefs.getPref(pref)); + if (pref) { + const checkOverridePref = () => { + browser.aboutConfigPrefs.getPref(pref).then(value => { + override.permanentPrefEnabled = value; + this.onOverrideConfigChanged(override); + }); + }; + browser.aboutConfigPrefs.onPrefChange.addListener( + checkOverridePref, + pref + ); + } + + this.onOverrideConfigChanged(override); + } + } + + this._overridesEnabled = true; + this._aboutCompatBroker.portsToAboutCompatTabs.broadcast({ + overridesChanged: this._aboutCompatBroker.filterOverrides( + this._availableOverrides + ), + }); + } + + unregisterUAOverrides() { + for (const override of this._availableOverrides) { + this.disableOverride(override); + } + + this._overridesEnabled = false; + this._aboutCompatBroker.portsToAboutCompatTabs.broadcast({ + overridesChanged: false, + }); + } + + disableOverride(override) { + if (!override.active) { + return; + } + + const listeners = this._activeListeners.get(override); + for (const [name, listener] of Object.entries(listeners)) { + browser.webRequest[name].removeListener(listener); + } + override.active = false; + this._activeListeners.delete(override); + } +} + +module.exports = UAOverrides; diff --git a/browser/extensions/webcompat/manifest.json b/browser/extensions/webcompat/manifest.json new file mode 100644 index 0000000000..2f4c84aeed --- /dev/null +++ b/browser/extensions/webcompat/manifest.json @@ -0,0 +1,144 @@ +{ + "manifest_version": 2, + "name": "Web Compatibility Interventions", + "description": "Urgent post-release fixes for web compatibility.", + "version": "19.0.0", + + "applications": { + "gecko": { + "id": "webcompat@mozilla.org", + "strict_min_version": "59.0b5" + } + }, + + "experiment_apis": { + "aboutConfigPrefs": { + "schema": "experiment-apis/aboutConfigPrefs.json", + "parent": { + "scopes": ["addon_parent"], + "script": "experiment-apis/aboutConfigPrefs.js", + "paths": [["aboutConfigPrefs"]] + } + }, + "appConstants": { + "schema": "experiment-apis/appConstants.json", + "parent": { + "scopes": ["addon_parent"], + "script": "experiment-apis/appConstants.js", + "paths": [["appConstants"]] + } + }, + "aboutPage": { + "schema": "about-compat/aboutPage.json", + "parent": { + "scopes": ["addon_parent"], + "script": "about-compat/aboutPage.js", + "events": ["startup"] + } + }, + "experiments": { + "schema": "experiment-apis/experiments.json", + "parent": { + "scopes": ["addon_parent"], + "script": "experiment-apis/experiments.js", + "paths": [["experiments"]] + } + }, + "matchPatterns": { + "schema": "experiment-apis/matchPatterns.json", + "child": { + "scopes": ["addon_child"], + "script": "experiment-apis/matchPatterns.js", + "paths": [["matchPatterns"]] + } + }, + "pictureInPictureChild": { + "schema": "experiment-apis/pictureInPicture.json", + "child": { + "scopes": ["addon_child"], + "script": "experiment-apis/pictureInPicture.js", + "paths": [["pictureInPictureChild"]] + } + }, + "pictureInPictureParent": { + "schema": "experiment-apis/pictureInPicture.json", + "parent": { + "scopes": ["addon_parent"], + "script": "experiment-apis/pictureInPicture.js", + "paths": [["pictureInPictureParent"]] + } + }, + "sharedPreferences": { + "schema": "experiment-apis/sharedPreferences.json", + "parent": { + "scopes": ["addon_parent"], + "script": "experiment-apis/sharedPreferences.js", + "paths": [["sharedPreferences"]] + } + }, + "systemManufacturer": { + "schema": "experiment-apis/systemManufacturer.json", + "child": { + "scopes": ["addon_child"], + "script": "experiment-apis/systemManufacturer.js", + "paths": [["systemManufacturer"]] + } + }, + "trackingProtection": { + "schema": "experiment-apis/trackingProtection.json", + "parent": { + "scopes": ["addon_parent"], + "script": "experiment-apis/trackingProtection.js", + "paths": [["trackingProtection"]] + } + } + }, + + "content_security_policy": "script-src 'self' 'sha256-MmZkN2QaIHhfRWPZ8TVRjijTn5Ci1iEabtTEWrt9CCo='; default-src 'self'; base-uri moz-extension://*; object-src 'none'", + + "permissions": [ + "tabs", + "webNavigation", + "webRequest", + "webRequestBlocking", + "<all_urls>" + ], + + "background": { + "scripts": [ + "lib/module_shim.js", + "lib/messaging_helper.js", + "lib/intervention_helpers.js", + "data/injections.js", + "data/picture_in_picture_overrides.js", + "data/shims.js", + "data/ua_overrides.js", + "lib/about_compat_broker.js", + "lib/custom_functions.js", + "lib/injections.js", + "lib/picture_in_picture_overrides.js", + "lib/shims.js", + "lib/ua_overrides.js", + "run.js" + ] + }, + + "web_accessible_resources": [ + "shims/adsafeprotected-ima.js", + "shims/bmauth.js", + "shims/eluminate.js", + "shims/empty-script.js", + "shims/facebook-sdk.js", + "shims/google-analytics-ecommerce-plugin.js", + "shims/google-analytics-legacy.js", + "shims/google-analytics-tag-manager.js", + "shims/google-analytics.js", + "shims/google-publisher-tags.js", + "shims/live-test-shim.js", + "shims/mochitest-shim-1.js", + "shims/mochitest-shim-2.js", + "shims/mochitest-shim-3.js", + "shims/rambler-authenticator.js", + "shims/rich-relevance.js" + ] +} diff --git a/browser/extensions/webcompat/moz.build b/browser/extensions/webcompat/moz.build new file mode 100644 index 0000000000..624db15176 --- /dev/null +++ b/browser/extensions/webcompat/moz.build @@ -0,0 +1,125 @@ +# -*- 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/. + +DEFINES["MOZ_APP_VERSION"] = CONFIG["MOZ_APP_VERSION"] +DEFINES["MOZ_APP_MAXVERSION"] = CONFIG["MOZ_APP_MAXVERSION"] + +FINAL_TARGET_FILES.features["webcompat@mozilla.org"] += [ + "manifest.json", + "run.js", +] + +FINAL_TARGET_FILES.features["webcompat@mozilla.org"]["about-compat"] += [ + "about-compat/aboutCompat.css", + "about-compat/aboutCompat.html", + "about-compat/aboutCompat.js", + "about-compat/AboutCompat.jsm", + "about-compat/aboutPage.js", + "about-compat/aboutPage.json", + "about-compat/aboutPageProcessScript.js", +] + +FINAL_TARGET_FILES.features["webcompat@mozilla.org"]["data"] += [ + "data/injections.js", + "data/picture_in_picture_overrides.js", + "data/shims.js", + "data/ua_overrides.js", +] + +FINAL_TARGET_FILES.features["webcompat@mozilla.org"]["experiment-apis"] += [ + "experiment-apis/aboutConfigPrefs.js", + "experiment-apis/aboutConfigPrefs.json", + "experiment-apis/appConstants.js", + "experiment-apis/appConstants.json", + "experiment-apis/experiments.js", + "experiment-apis/experiments.json", + "experiment-apis/matchPatterns.js", + "experiment-apis/matchPatterns.json", + "experiment-apis/pictureInPicture.js", + "experiment-apis/pictureInPicture.json", + "experiment-apis/sharedPreferences.js", + "experiment-apis/sharedPreferences.json", + "experiment-apis/systemManufacturer.js", + "experiment-apis/systemManufacturer.json", + "experiment-apis/trackingProtection.js", + "experiment-apis/trackingProtection.json", +] + +FINAL_TARGET_FILES.features["webcompat@mozilla.org"]["injections"]["css"] += [ + "injections/css/bug0000000-testbed-css-injection.css", + "injections/css/bug1561371-mail.google.com-allow-horizontal-scrolling.css", + "injections/css/bug1570119-teamcoco.com-scrollbar-width.css", + "injections/css/bug1570328-developer-apple.com-transform-scale.css", + "injections/css/bug1575000-apply.lloydsbank.co.uk-radio-buttons-fix.css", + "injections/css/bug1605611-maps.google.com-directions-time.css", + "injections/css/bug1610016-gaana.com-input-position-fix.css", + "injections/css/bug1610344-directv.com.co-hide-unsupported-message.css", + "injections/css/bug1644830-missingmail.usps.com-checkboxes-not-visible.css", + "injections/css/bug1645064-s-kanava.fi-invisible-charts.css", + "injections/css/bug1651917-teletrader.com.body-transform-origin.css", + "injections/css/bug1653075-livescience.com-scrollbar-width.css", + "injections/css/bug1654865-sports.ndtv.com-float-fix.css", + "injections/css/bug1654877-preev.com-moz-appearance-fix.css", + "injections/css/bug1654907-reactine.ca-hide-unsupported.css", + "injections/css/bug1655049-dev.to-unclickable-button-fix.css", + "injections/css/bug1666771-zilow-map-overdraw.css", +] + +FINAL_TARGET_FILES.features["webcompat@mozilla.org"]["injections"]["js"] += [ + "injections/js/bug0000000-testbed-js-injection.js", + "injections/js/bug1452707-window.controllers-shim-ib.absa.co.za.js", + "injections/js/bug1457335-histography.io-ua-change.js", + "injections/js/bug1472075-bankofamerica.com-ua-change.js", + "injections/js/bug1570856-medium.com-menu-isTier1.js", + "injections/js/bug1579159-m.tailieu.vn-pdfjs-worker-disable.js", + "injections/js/bug1605611-maps.google.com-directions-time.js", + "injections/js/bug1610358-pcloud.com-appVersion-change.js", + "injections/js/bug1631811-datastudio.google.com-indexedDB.js", + "injections/js/bug1665035-dckids.com-cookieEnabled.js", + "injections/js/bug1677442-store.hp.com-disable-indexeddb.js", + "injections/js/bug1682238-gamearter.com-ua-change.js", +] + +FINAL_TARGET_FILES.features["webcompat@mozilla.org"]["shims"] += [ + "shims/adsafeprotected-ima.js", + "shims/bmauth.js", + "shims/eluminate.js", + "shims/empty-script.js", + "shims/facebook-sdk.js", + "shims/google-analytics-ecommerce-plugin.js", + "shims/google-analytics-legacy.js", + "shims/google-analytics-tag-manager.js", + "shims/google-analytics.js", + "shims/google-publisher-tags.js", + "shims/live-test-shim.js", + "shims/mochitest-shim-1.js", + "shims/mochitest-shim-2.js", + "shims/mochitest-shim-3.js", + "shims/rambler-authenticator.js", + "shims/rich-relevance.js", +] + +FINAL_TARGET_FILES.features["webcompat@mozilla.org"]["lib"] += [ + "lib/about_compat_broker.js", + "lib/custom_functions.js", + "lib/injections.js", + "lib/intervention_helpers.js", + "lib/messaging_helper.js", + "lib/module_shim.js", + "lib/picture_in_picture_overrides.js", + "lib/shim_messaging_helper.js", + "lib/shims.js", + "lib/ua_overrides.js", +] + +XPCOM_MANIFESTS += [ + "components.conf", +] + +BROWSER_CHROME_MANIFESTS += ["tests/browser/browser.ini"] + +with Files("**"): + BUG_COMPONENT = ("Web Compatibility", "Tooling & Investigations") diff --git a/browser/extensions/webcompat/run.js b/browser/extensions/webcompat/run.js new file mode 100644 index 0000000000..923f9d7446 --- /dev/null +++ b/browser/extensions/webcompat/run.js @@ -0,0 +1,24 @@ +/* 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"; + +/* globals AboutCompatBroker, AVAILABLE_INJECTIONS, AVAILABLE_SHIMS, + AVAILABLE_PIP_OVERRIDES, AVAILABLE_UA_OVERRIDES, CUSTOM_FUNCTIONS, + Injections, PictureInPictureOverrides, Shims, UAOverrides */ + +const injections = new Injections(AVAILABLE_INJECTIONS, CUSTOM_FUNCTIONS); +const uaOverrides = new UAOverrides(AVAILABLE_UA_OVERRIDES); +const pipOverrides = new PictureInPictureOverrides(AVAILABLE_PIP_OVERRIDES); +const shims = new Shims(AVAILABLE_SHIMS); + +const aboutCompatBroker = new AboutCompatBroker({ + injections, + uaOverrides, +}); + +aboutCompatBroker.bootup(); +injections.bootup(); +uaOverrides.bootup(); +pipOverrides.bootup(); diff --git a/browser/extensions/webcompat/shims/adsafeprotected-ima.js b/browser/extensions/webcompat/shims/adsafeprotected-ima.js new file mode 100644 index 0000000000..de273de7d8 --- /dev/null +++ b/browser/extensions/webcompat/shims/adsafeprotected-ima.js @@ -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/. */ + +"use strict"; + +/** + * Bug 1508639 - Shim Ad Safe Protected's Google IMA adapter + */ + +if (!window.googleImaVansAdapter) { + const shimId = "AdSafeProtectedGoogleIMAAdapter"; + + const sendMessageToAddon = (function() { + const pendingMessages = new Map(); + const channel = new MessageChannel(); + channel.port1.onerror = console.error; + channel.port1.onmessage = event => { + const { messageId, response } = event.data; + const resolve = pendingMessages.get(messageId); + if (resolve) { + pendingMessages.delete(messageId); + resolve(response); + } + }; + function reconnect() { + const detail = { + pendingMessages: [...pendingMessages.values()], + port: channel.port2, + shimId, + }; + window.dispatchEvent(new CustomEvent("ShimConnects", { detail })); + } + window.addEventListener("ShimHelperReady", reconnect); + reconnect(); + return function(message) { + const messageId = + Math.random() + .toString(36) + .substring(2) + Date.now().toString(36); + return new Promise(resolve => { + const payload = { + message, + messageId, + shimId, + }; + pendingMessages.set(messageId, resolve); + channel.port1.postMessage(payload); + }); + }; + })(); + + window.googleImaVansAdapter = { + init: () => {}, + dispose: () => {}, + }; + + // Treat it as an opt-in when the user clicks on a video + // TODO: Improve this! It races to tell the bg script to unblock the ad from + // https://pubads.g.doubleclick.net/gampad/ads before the page loads them. + async function click(e) { + if (e.isTrusted && e.target.closest("#video-player")) { + document.documentElement.removeEventListener("click", click, true); + await sendMessageToAddon("optIn"); + // TODO: reload ima3.js? + } + } + document.documentElement.addEventListener("click", click, true); +} diff --git a/browser/extensions/webcompat/shims/bmauth.js b/browser/extensions/webcompat/shims/bmauth.js new file mode 100644 index 0000000000..944f2100d6 --- /dev/null +++ b/browser/extensions/webcompat/shims/bmauth.js @@ -0,0 +1,21 @@ +/* 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"; + +if (!window.BmAuth) { + window.BmAuth = { + init: () => new Promise(() => {}), + handleSignIn: () => { + // TODO: handle this properly! + }, + isAuthenticated: () => Promise.resolve(false), + addListener: () => {}, + api: { + event: { + addListener: () => {}, + }, + }, + }; +} diff --git a/browser/extensions/webcompat/shims/eluminate.js b/browser/extensions/webcompat/shims/eluminate.js new file mode 100644 index 0000000000..863d4ef2da --- /dev/null +++ b/browser/extensions/webcompat/shims/eluminate.js @@ -0,0 +1,68 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +if (!window.CM_DDX) { + window.CM_DDX = { + domReadyFired: false, + headScripts: true, + dispatcherLoadRequested: false, + firstPassFunctionBinding: false, + BAD_PAGE_ID_ELAPSED_TIMEOUT: 5000, + version: -1, + standalone: false, + test: { + syndicate: true, + testCounter: "", + doTest: false, + newWin: false, + process: () => {}, + }, + partner: {}, + invokeFunctionWhenAvailable: a => { + a(); + }, + gup: d => "", + privacy: { + isDoNotTrackEnabled: () => false, + setDoNotTrack: () => {}, + getDoNotTrack: () => false, + }, + setSubCookie: () => {}, + }; + + const noopfn = () => {}; + const w = window; + w.cmAddShared = noopfn; + w.cmCalcSKUString = noopfn; + w.cmCreateManualImpressionTag = noopfn; + w.cmCreateManualLinkClickTag = noopfn; + w.cmCreateManualPageviewTag = noopfn; + w.cmCreateOrderTag = noopfn; + w.cmCreatePageviewTag = noopfn; + w.cmRetrieveUserID = noopfn; + w.cmSetClientID = noopfn; + w.cmSetCurrencyCode = noopfn; + w.cmSetFirstPartyIDs = noopfn; + w.cmSetSubCookie = noopfn; + w.cmSetupCookieMigration = noopfn; + w.cmSetupNormalization = noopfn; + w.cmSetupOther = noopfn; + w.cmStartTagSet = noopfn; + + function cmExecuteTagQueue() { + var b = window.cmTagQueue; + if (b) { + if (!Array.isArray(b)) { + return undefined; + } + for (var a = 0; a < b.length; ++a) { + window[b[a][0]].apply(window, b[a].slice(1)); + } + } + return true; + } + cmExecuteTagQueue(); +} diff --git a/browser/extensions/webcompat/shims/empty-script.js b/browser/extensions/webcompat/shims/empty-script.js new file mode 100644 index 0000000000..d01f2ab537 --- /dev/null +++ b/browser/extensions/webcompat/shims/empty-script.js @@ -0,0 +1,5 @@ +/* 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 script is intentionally empty */ diff --git a/browser/extensions/webcompat/shims/facebook-sdk.js b/browser/extensions/webcompat/shims/facebook-sdk.js new file mode 100644 index 0000000000..95928be857 --- /dev/null +++ b/browser/extensions/webcompat/shims/facebook-sdk.js @@ -0,0 +1,198 @@ +/* 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"; + +/** + * Bug 1226498 - Shim Facebook SDK + * + * The Facebook SDK is commonly used by sites to allow users to authenticate + * for logins, but it is blocked by strict tracking protection. It is possible + * to shim the SDK and allow users to still opt into logging in via Facebook. + * It is also possible to replace any Facebook widgets or comments with + * placeholders that the user may click to opt into loading the content. + */ + +if (!window.FB) { + const originalUrl = document.currentScript.src; + const pendingParses = []; + + function getGUID() { + return ( + Math.random() + .toString(36) + .substring(2) + Date.now().toString(36) + ); + } + + const shimId = "FacebookSDK"; + + const sendMessageToAddon = (function() { + const pendingMessages = new Map(); + const channel = new MessageChannel(); + channel.port1.onerror = console.error; + channel.port1.onmessage = event => { + const { messageId, response } = event.data; + const resolve = pendingMessages.get(messageId); + if (resolve) { + pendingMessages.delete(messageId); + resolve(response); + } + }; + function reconnect() { + const detail = { + pendingMessages: [...pendingMessages.values()], + port: channel.port2, + shimId, + }; + window.dispatchEvent(new CustomEvent("ShimConnects", { detail })); + } + window.addEventListener("ShimHelperReady", reconnect); + reconnect(); + return function(message) { + const messageId = getGUID(); + return new Promise(resolve => { + const payload = { + message, + messageId, + shimId, + }; + pendingMessages.set(messageId, resolve); + channel.port1.postMessage(payload); + }); + }; + })(); + + let ready = false; + let initInfo; + const needPopup = + !/app_runner/.test(window.name) && !/iframe_canvas/.test(window.name); + const popupName = getGUID(); + + if (needPopup) { + const oldWindowOpen = window.open; + window.open = function(href, name, params) { + try { + const url = new URL(href); + if ( + url.protocol === "https:" && + (url.hostname === "m.facebook.com" || + url.hostname === "www.facebook.com") && + url.pathname.endsWith("/oauth") + ) { + name = popupName; + } + } catch (_) {} + return oldWindowOpen.call(window, href, name, params); + }; + } + + async function allowFacebookSDK(callback) { + await sendMessageToAddon("optIn"); + + window.FB = undefined; + const oldInit = window.fbAsyncInit; + window.fbAsyncInit = () => { + ready = true; + if (typeof initInfo !== "undefined") { + window.FB.init(initInfo); + } else if (oldInit) { + oldInit(); + } + if (callback) { + callback(); + } + }; + + const s = document.createElement("script"); + s.src = originalUrl; + await new Promise((resolve, reject) => { + s.onerror = reject; + s.onload = function() { + for (const args of pendingParses) { + window.FB.XFBML.parse.apply(window.FB.XFBML, args); + } + resolve(); + }; + document.head.appendChild(s); + }); + } + + function buildPopupParams() { + const { outerWidth, outerHeight, screenX, screenY } = window; + const { width, height } = window.screen; + const w = Math.min(width, 400); + const h = Math.min(height, 400); + const ua = navigator.userAgent; + const isMobile = ua.includes("Mobile") || ua.includes("Tablet"); + const left = screenX + (screenX < 0 ? width : 0) + (outerWidth - w) / 2; + const top = screenY + (screenY < 0 ? height : 0) + (outerHeight - h) / 2.5; + let params = `left=${left},top=${top},width=${w},height=${h},scrollbars=1,toolbar=0,location=1`; + if (!isMobile) { + params = `${params},width=${w},height=${h}`; + } + return params; + } + + async function doLogin(a, b) { + window.FB.login(a, b); + } + + function proxy(name, fn) { + return function() { + if (ready) { + return window.FB[name].apply(this, arguments); + } + return fn.apply(this, arguments); + }; + } + + window.FB = { + api: proxy("api", () => {}), + AppEvents: { + EventNames: {}, + logPageView: () => {}, + }, + Event: { + subscribe: () => {}, + }, + getAccessToken: proxy("getAccessToken", () => null), + getAuthResponse: proxy("getAuthResponse", () => { + return { status: "" }; + }), + getLoginStatus: proxy("getLoginStatus", cb => { + cb({ status: "" }); + }), + getUserID: proxy("getUserID", () => {}), + init: _initInfo => { + if (ready) { + doLogin(_initInfo); + } else { + initInfo = _initInfo; // in case the site is not using fbAsyncInit + } + }, + login: (a, b) => { + // We have to load Facebook's script, and then wait for it to call + // window.open. By that time, the popup blocker will likely trigger. + // So we open a popup now with about:blank, and then make sure FB + // will re-use that same popup later. + if (needPopup) { + window.open("about:blank", popupName, buildPopupParams()); + } + allowFacebookSDK(() => { + doLogin(a, b); + }); + }, + logout: proxy("logout", cb => cb()), + XFBML: { + parse: e => { + pendingParses.push([e]); + }, + }, + }; + + if (window.fbAsyncInit) { + window.fbAsyncInit(); + } +} diff --git a/browser/extensions/webcompat/shims/google-analytics-ecommerce-plugin.js b/browser/extensions/webcompat/shims/google-analytics-ecommerce-plugin.js new file mode 100644 index 0000000000..60b49df120 --- /dev/null +++ b/browser/extensions/webcompat/shims/google-analytics-ecommerce-plugin.js @@ -0,0 +1,13 @@ +/* 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"; + +if (!window.gaplugins) { + window.gaplugins = {}; +} + +if (!window.gaplugins.EC) { + window.gaplugins.EC = () => {}; +} diff --git a/browser/extensions/webcompat/shims/google-analytics-legacy.js b/browser/extensions/webcompat/shims/google-analytics-legacy.js new file mode 100644 index 0000000000..056186f75c --- /dev/null +++ b/browser/extensions/webcompat/shims/google-analytics-legacy.js @@ -0,0 +1,133 @@ +/* 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/. */ + +// based on https://github.com/gorhill/uBlock/blob/caa8e7d35ba61214a9d13e7d324b2bd2aa73237f/src/web_accessible_resources/google-analytics_ga.js + +"use strict"; + +if (!window._gaq) { + function noopfn() {} + + const gaq = { + Na: noopfn, + O: noopfn, + Sa: noopfn, + Ta: noopfn, + Va: noopfn, + _createAsyncTracker: noopfn, + _getAsyncTracker: noopfn, + _getPlugin: noopfn, + push: a => { + if (typeof a === "function") { + a(); + return; + } + if (!Array.isArray(a)) { + return; + } + if (a[0] === "_link" && typeof a[1] === "string") { + window.location.assign(a[1]); + } + if ( + a[0] === "_set" && + a[1] === "hitCallback" && + typeof a[2] === "function" + ) { + a[2](); + } + }, + }; + + const tracker = { + _addIgnoredOrganic: noopfn, + _addIgnoredRef: noopfn, + _addItem: noopfn, + _addOrganic: noopfn, + _addTrans: noopfn, + _clearIgnoredOrganic: noopfn, + _clearIgnoredRef: noopfn, + _clearOrganic: noopfn, + _cookiePathCopy: noopfn, + _deleteCustomVar: noopfn, + _getName: noopfn, + _setAccount: noopfn, + _getAccount: noopfn, + _getClientInfo: noopfn, + _getDetectFlash: noopfn, + _getDetectTitle: noopfn, + _getLinkerUrl: a => a, + _getLocalGifPath: noopfn, + _getServiceMode: noopfn, + _getVersion: noopfn, + _getVisitorCustomVar: noopfn, + _initData: noopfn, + _link: noopfn, + _linkByPost: noopfn, + _setAllowAnchor: noopfn, + _setAllowHash: noopfn, + _setAllowLinker: noopfn, + _setCampContentKey: noopfn, + _setCampMediumKey: noopfn, + _setCampNameKey: noopfn, + _setCampNOKey: noopfn, + _setCampSourceKey: noopfn, + _setCampTermKey: noopfn, + _setCampaignCookieTimeout: noopfn, + _setCampaignTrack: noopfn, + _setClientInfo: noopfn, + _setCookiePath: noopfn, + _setCookiePersistence: noopfn, + _setCookieTimeout: noopfn, + _setCustomVar: noopfn, + _setDetectFlash: noopfn, + _setDetectTitle: noopfn, + _setDomainName: noopfn, + _setLocalGifPath: noopfn, + _setLocalRemoteServerMode: noopfn, + _setLocalServerMode: noopfn, + _setReferrerOverride: noopfn, + _setRemoteServerMode: noopfn, + _setSampleRate: noopfn, + _setSessionTimeout: noopfn, + _setSiteSpeedSampleRate: noopfn, + _setSessionCookieTimeout: noopfn, + _setVar: noopfn, + _setVisitorCookieTimeout: noopfn, + _trackEvent: noopfn, + _trackPageLoadTime: noopfn, + _trackPageview: noopfn, + _trackSocial: noopfn, + _trackTiming: noopfn, + _trackTrans: noopfn, + _visitCode: noopfn, + }; + + const gat = { + _anonymizeIP: noopfn, + _createTracker: noopfn, + _forceSSL: noopfn, + _getPlugin: noopfn, + _getTracker: () => tracker, + _getTrackerByName: () => tracker, + _getTrackers: noopfn, + aa: noopfn, + ab: noopfn, + hb: noopfn, + la: noopfn, + oa: noopfn, + pa: noopfn, + u: noopfn, + }; + + window._gat = gat; + + const aa = window._gaq || []; + if (Array.isArray(aa)) { + while (aa[0]) { + gaq.push(aa.shift()); + } + } + + window._gaq = gaq.qf = gaq; +} diff --git a/browser/extensions/webcompat/shims/google-analytics-tag-manager.js b/browser/extensions/webcompat/shims/google-analytics-tag-manager.js new file mode 100644 index 0000000000..b8398f91b6 --- /dev/null +++ b/browser/extensions/webcompat/shims/google-analytics-tag-manager.js @@ -0,0 +1,24 @@ +/* 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/. */ + +// based on https://github.com/gorhill/uBlock/blob/caa8e7d35ba61214a9d13e7d324b2bd2aa73237f/src/web_accessible_resources/googletagmanager_gtm.js + +"use strict"; + +if (!window.ga) { + window.ga = () => {}; + + try { + window.dataLayer.hide.end(); + } catch (_) {} + + const dl = window.dataLayer; + if (typeof dl.push === "function") { + dl.push = o => { + if (o instanceof Object && typeof o.eventCallback === "function") { + setTimeout(o.eventCallback, 1); + } + }; + } +} diff --git a/browser/extensions/webcompat/shims/google-analytics.js b/browser/extensions/webcompat/shims/google-analytics.js new file mode 100644 index 0000000000..f0162b1f63 --- /dev/null +++ b/browser/extensions/webcompat/shims/google-analytics.js @@ -0,0 +1,45 @@ +/* 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/. */ + +// based on https://github.com/gorhill/uBlock/blob/8a1a8b103f56e4fcef1264e02dfd718a29bda006/src/web_accessible_resources/google-analytics_analytics.js + +"use strict"; + +if (!window[window.GoogleAnalyticsObject || "ga"]) { + function ga() { + const len = arguments.length; + if (!len) { + return; + } + const args = Array.from(arguments); + let fn; + let a = args[len - 1]; + if (a instanceof Object && a.hitCallback instanceof Function) { + fn = a.hitCallback; + } else { + const pos = args.indexOf("hitCallback"); + if (pos !== -1 && args[pos + 1] instanceof Function) { + fn = args[pos + 1]; + } + } + if (!(fn instanceof Function)) { + return; + } + try { + fn(); + } catch (_) {} + } + ga.create = () => {}; + ga.getByName = () => null; + ga.getAll = () => []; + ga.remove = () => {}; + ga.loaded = true; + + const gaName = window.GoogleAnalyticsObject || "ga"; + window[gaName] = ga; +} + +try { + window.dataLayer.hide.end(); +} catch (_) {} diff --git a/browser/extensions/webcompat/shims/google-publisher-tags.js b/browser/extensions/webcompat/shims/google-publisher-tags.js new file mode 100644 index 0000000000..756e29bac5 --- /dev/null +++ b/browser/extensions/webcompat/shims/google-publisher-tags.js @@ -0,0 +1,163 @@ +/* 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"; + +/** + * Bug 1600538 - Shim Google Publisher Tags + */ + +"use strict"; + +if (!window.googletag?.apiReady) { + const noopfn = function() {}; + const noopthisfn = function() { + return this; + }; + const noopnullfn = function() { + return null; + }; + const nooparrayfn = function() { + return []; + }; + const noopstrfn = function() { + return ""; + }; + + function newPassbackSlot() { + return { + display: noopfn, + get: noopnullfn, + set: noopthisfn, + setClickUrl: noopthisfn, + setTagForChildDirectedTreatment: noopthisfn, + setTargeting: noopthisfn, + updateTargetingFromMap: noopthisfn, + }; + } + + function display(id) { + const parent = document.getElementById(id); + if (parent) { + parent.appendChild(document.createElement("div")); + } + } + + const companionAdsService = { + addEventListener: noopthisfn, + enableSyncLoading: noopfn, + setRefreshUnfilledSlots: noopfn, + }; + + const contentService = { + addEventListener: noopthisfn, + setContent: noopfn, + }; + + const pubadsService = { + addEventListener: noopthisfn, + clear: noopfn, + clearCategoryExclusions: noopthisfn, + clearTagForChildDirectedTreatment: noopthisfn, + clearTargeting: noopthisfn, + collapseEmptyDivs: noopfn, + defineOutOfPagePassback: () => newPassbackSlot(), + definePassback: () => newPassbackSlot(), + disableInitialLoad: noopfn, + display, + enableAsyncRendering: noopfn, + enableSingleRequest: noopfn, + enableSyncRendering: noopfn, + enableVideoAds: noopfn, + get: noopnullfn, + getAttributeKeys: nooparrayfn, + getTargeting: noopfn, + getTargetingKeys: nooparrayfn, + getSlots: nooparrayfn, + refresh: noopfn, + set: noopthisfn, + setCategoryExclusion: noopthisfn, + setCentering: noopfn, + setCookieOptions: noopthisfn, + setForceSafeFrame: noopthisfn, + setLocation: noopthisfn, + setPublisherProvidedId: noopthisfn, + setRequestNonPersonalizedAds: noopthisfn, + setSafeFrameConfig: noopthisfn, + setTagForChildDirectedTreatment: noopthisfn, + setTargeting: noopthisfn, + setVideoContent: noopthisfn, + updateCorrelator: noopfn, + }; + + function newSizeMappingBuilder() { + return { + addSize: noopthisfn, + build: noopnullfn, + }; + } + + function newSlot() { + return { + addService: noopthisfn, + clearCategoryExclusions: noopthisfn, + clearTargeting: noopthisfn, + defineSizeMapping: noopthisfn, + get: noopnullfn, + getAdUnitPath: nooparrayfn, + getAttributeKeys: nooparrayfn, + getCategoryExclusions: nooparrayfn, + getDomId: noopstrfn, + getSlotElementId: noopstrfn, + getSlotId: noopthisfn, + getTargeting: nooparrayfn, + getTargetingKeys: nooparrayfn, + set: noopthisfn, + setCategoryExclusion: noopthisfn, + setClickUrl: noopthisfn, + setCollapseEmptyDiv: noopthisfn, + setTargeting: noopthisfn, + }; + } + + let gt = window.googletag; + if (!gt) { + gt = window.googletag = {}; + } + + for (const [key, value] of Object.entries({ + apiReady: true, + companionAds: () => companionAdsService, + content: () => contentService, + defineOutOfPageSlot: () => newSlot(), + defineSlot: () => newSlot(), + destroySlots: noopfn, + disablePublisherConsole: noopfn, + display, + enableServices: noopfn, + getVersion: noopstrfn, + pubads: () => pubadsService, + pubabsReady: true, + setAdIframeTitle: noopfn, + sizeMapping: () => newSizeMappingBuilder(), + })) { + gt[key] = value; + } + + function runCmd(fn) { + try { + fn(); + } catch (_) {} + return 1; + } + + const cmds = gt.cmd; + const newCmd = []; + newCmd.push = runCmd; + gt.cmd = newCmd; + + for (const cmd of cmds) { + runCmd(cmd); + } +} diff --git a/browser/extensions/webcompat/shims/live-test-shim.js b/browser/extensions/webcompat/shims/live-test-shim.js new file mode 100644 index 0000000000..552020820f --- /dev/null +++ b/browser/extensions/webcompat/shims/live-test-shim.js @@ -0,0 +1,84 @@ +/* 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"; + +/* globals browser */ + +if (!window.LiveTestShimPromise) { + const originalUrl = document.currentScript.src; + + const shimId = "LiveTestShim"; + + const sendMessageToAddon = (function() { + const pendingMessages = new Map(); + const channel = new MessageChannel(); + channel.port1.onerror = console.error; + channel.port1.onmessage = event => { + const { messageId, response } = event.data; + const resolve = pendingMessages.get(messageId); + if (resolve) { + pendingMessages.delete(messageId); + resolve(response); + } + }; + function reconnect() { + const detail = { + pendingMessages: [...pendingMessages.values()], + port: channel.port2, + shimId, + }; + window.dispatchEvent(new CustomEvent("ShimConnects", { detail })); + } + window.addEventListener("ShimHelperReady", reconnect); + reconnect(); + return function(message) { + const messageId = + Math.random() + .toString(36) + .substring(2) + Date.now().toString(36); + return new Promise(resolve => { + const payload = { + message, + messageId, + shimId, + }; + pendingMessages.set(messageId, resolve); + channel.port1.postMessage(payload); + }); + }; + })(); + + async function go(options) { + try { + const o = document.getElementById("shims"); + const cl = o.classList; + cl.remove("red"); + cl.add("green"); + o.innerText = JSON.stringify(options || ""); + } catch (_) {} + + if (window !== top) { + return; + } + + await sendMessageToAddon("optIn"); + + const s = document.createElement("script"); + s.src = originalUrl; + document.head.appendChild(s); + } + + window[`${shimId}Promise`] = sendMessageToAddon("getOptions").then( + options => { + if (document.readyState !== "loading") { + go(options); + } else { + window.addEventListener("DOMContentLoaded", () => { + go(options); + }); + } + } + ); +} diff --git a/browser/extensions/webcompat/shims/mochitest-shim-1.js b/browser/extensions/webcompat/shims/mochitest-shim-1.js new file mode 100644 index 0000000000..d18e965f3c --- /dev/null +++ b/browser/extensions/webcompat/shims/mochitest-shim-1.js @@ -0,0 +1,89 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +/* globals browser */ + +if (!window.MochitestShimPromise) { + const originalUrl = document.currentScript.src; + + const shimId = "MochitestShim"; + + const sendMessageToAddon = (function() { + const pendingMessages = new Map(); + const channel = new MessageChannel(); + channel.port1.onerror = console.error; + channel.port1.onmessage = event => { + const { messageId, response } = event.data; + const resolve = pendingMessages.get(messageId); + if (resolve) { + pendingMessages.delete(messageId); + resolve(response); + } + }; + function reconnect() { + const detail = { + pendingMessages: [...pendingMessages.values()], + port: channel.port2, + shimId, + }; + window.dispatchEvent(new CustomEvent("ShimConnects", { detail })); + } + window.addEventListener("ShimHelperReady", reconnect); + reconnect(); + return function(message) { + const messageId = + Math.random() + .toString(36) + .substring(2) + Date.now().toString(36); + return new Promise(resolve => { + const payload = { + message, + messageId, + shimId, + }; + pendingMessages.set(messageId, resolve); + channel.port1.postMessage(payload); + }); + }; + })(); + + async function go(options) { + try { + const o = document.getElementById("shims"); + const cl = o.classList; + cl.remove("red"); + cl.add("green"); + o.innerText = JSON.stringify(options || ""); + } catch (_) {} + + window.shimPromiseResolve("shimmed"); + + if (window !== top) { + window.optInPromiseResolve(false); + return; + } + + await sendMessageToAddon("optIn"); + + window.doingOptIn = true; + const s = document.createElement("script"); + s.src = originalUrl; + s.onerror = () => window.optInPromiseResolve("error"); + document.head.appendChild(s); + } + + window[`${shimId}Promise`] = new Promise(resolve => { + sendMessageToAddon("getOptions").then(options => { + if (document.readyState !== "loading") { + resolve(go(options)); + } else { + window.addEventListener("DOMContentLoaded", () => { + resolve(go(options)); + }); + } + }); + }); +} diff --git a/browser/extensions/webcompat/shims/mochitest-shim-2.js b/browser/extensions/webcompat/shims/mochitest-shim-2.js new file mode 100644 index 0000000000..3b60038599 --- /dev/null +++ b/browser/extensions/webcompat/shims/mochitest-shim-2.js @@ -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/. */ + +"use strict"; + +/* globals browser */ + +if (!window.testPromise) { + const originalUrl = document.currentScript.src; + + const shimId = "MochitestShim2"; + + const sendMessageToAddon = (function() { + const pendingMessages = new Map(); + const channel = new MessageChannel(); + channel.port1.onerror = console.error; + channel.port1.onmessage = event => { + const { messageId, response } = event.data; + const resolve = pendingMessages.get(messageId); + if (resolve) { + pendingMessages.delete(messageId); + resolve(response); + } + }; + function reconnect() { + const detail = { + pendingMessages: [...pendingMessages.values()], + port: channel.port2, + shimId, + }; + window.dispatchEvent(new CustomEvent("ShimConnects", { detail })); + } + window.addEventListener("ShimHelperReady", reconnect); + reconnect(); + return function(message) { + const messageId = + Math.random() + .toString(36) + .substring(2) + Date.now().toString(36); + return new Promise(resolve => { + const payload = { + message, + messageId, + shimId, + }; + pendingMessages.set(messageId, resolve); + channel.port1.postMessage(payload); + }); + }; + })(); + + async function go(options) { + try { + const o = document.getElementById("shims"); + const cl = o.classList; + cl.remove("red"); + cl.add("green"); + o.innerText = JSON.stringify(options || ""); + } catch (_) {} + + window.shimPromiseResolve("shimmed"); + + if (window !== top) { + window.optInPromiseResolve(false); + return; + } + + await sendMessageToAddon("optIn"); + + window.doingOptIn = true; + const s = document.createElement("script"); + s.src = originalUrl; + s.onerror = () => window.optInPromiseResolve("error"); + document.head.appendChild(s); + } + + sendMessageToAddon("getOptions").then(options => { + if (document.readyState !== "loading") { + go(options); + } else { + window.addEventListener("DOMContentLoaded", () => { + go(options); + }); + } + }); +} diff --git a/browser/extensions/webcompat/shims/mochitest-shim-3.js b/browser/extensions/webcompat/shims/mochitest-shim-3.js new file mode 100644 index 0000000000..dc0a8005f5 --- /dev/null +++ b/browser/extensions/webcompat/shims/mochitest-shim-3.js @@ -0,0 +1,7 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +window.shimPromiseResolve("shimmed"); diff --git a/browser/extensions/webcompat/shims/rambler-authenticator.js b/browser/extensions/webcompat/shims/rambler-authenticator.js new file mode 100644 index 0000000000..0554eabc2c --- /dev/null +++ b/browser/extensions/webcompat/shims/rambler-authenticator.js @@ -0,0 +1,86 @@ +/* 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"; + +if (!window.ramblerIdHelper) { + const originalScript = document.currentScript.src; + + const sendMessageToAddon = (function() { + const shimId = "Rambler"; + const pendingMessages = new Map(); + const channel = new MessageChannel(); + channel.port1.onerror = console.error; + channel.port1.onmessage = event => { + const { messageId, response } = event.data; + const resolve = pendingMessages.get(messageId); + if (resolve) { + pendingMessages.delete(messageId); + resolve(response); + } + }; + function reconnect() { + const detail = { + pendingMessages: [...pendingMessages.values()], + port: channel.port2, + shimId, + }; + window.dispatchEvent(new CustomEvent("ShimConnects", { detail })); + } + window.addEventListener("ShimHelperReady", reconnect); + reconnect(); + return function(message) { + const messageId = + Math.random() + .toString(36) + .substring(2) + Date.now().toString(36); + return new Promise(resolve => { + const payload = { + message, + messageId, + shimId, + }; + pendingMessages.set(messageId, resolve); + channel.port1.postMessage(payload); + }); + }; + })(); + + const ramblerIdHelper = { + getProfileInfo: (successCallback, errorCallback) => { + successCallback({}); + }, + openAuth: () => { + sendMessageToAddon("optIn").then(function() { + const openAuthArgs = arguments; + window.ramblerIdHelper = undefined; + const s = document.createElement("script"); + s.src = originalScript; + document.head.appendChild(s); + s.addEventListener("load", () => { + const helper = window.ramblerIdHelper; + for (const { fn, args } of callLog) { + helper[fn].apply(helper, args); + } + helper.openAuth.apply(helper, openAuthArgs); + }); + }); + }, + }; + + const callLog = []; + function addLoggedCall(fn) { + ramblerIdHelper[fn] = () => { + callLog.push({ fn, args: arguments }); + }; + } + + addLoggedCall("registerOnFrameCloseCallback"); + addLoggedCall("registerOnFrameRedirect"); + addLoggedCall("registerOnPossibleLoginCallback"); + addLoggedCall("registerOnPossibleLogoutCallback"); + addLoggedCall("registerOnPossibleOauthLoginCallback"); + + window.ramblerIdHelper = ramblerIdHelper; +} diff --git a/browser/extensions/webcompat/shims/rich-relevance.js b/browser/extensions/webcompat/shims/rich-relevance.js new file mode 100644 index 0000000000..d7f2802ac9 --- /dev/null +++ b/browser/extensions/webcompat/shims/rich-relevance.js @@ -0,0 +1,30 @@ +/* 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"; + +/** + * Bug 1449347 - Rich Relevance + */ + +"use strict"; + +if (!window.r3_common) { + const noopfn = () => {}; + + window.rr_flush_onload = noopfn; + window.r3 = noopfn; + window.r3_home = noopfn; + window.RR = noopfn; + window.r3_common = function() {}; + window.r3_common.prototype = { + addContext: noopfn, + addPlacementType: noopfn, + setUserId: noopfn, + setSessionId: noopfn, + setClickthruServer: noopfn, + setBaseUrl: noopfn, + setApiKey: noopfn, + }; +} diff --git a/browser/extensions/webcompat/tests/browser/browser.ini b/browser/extensions/webcompat/tests/browser/browser.ini new file mode 100644 index 0000000000..2a7bc6800d --- /dev/null +++ b/browser/extensions/webcompat/tests/browser/browser.ini @@ -0,0 +1,13 @@ +[DEFAULT] +support-files = + head.js + shims_test.js + shims_test_2.js + shims_test_3.js + iframe_test.html + shims_test.html + shims_test_2.html + shims_test_3.html + +[browser_shims.js] +skip-if = verify diff --git a/browser/extensions/webcompat/tests/browser/browser_shims.js b/browser/extensions/webcompat/tests/browser/browser_shims.js new file mode 100644 index 0000000000..cd07bb7390 --- /dev/null +++ b/browser/extensions/webcompat/tests/browser/browser_shims.js @@ -0,0 +1,73 @@ +"use strict"; + +registerCleanupFunction(() => { + UrlClassifierTestUtils.cleanupTestTrackers(); + Services.prefs.clearUserPref(TRACKING_PREF); +}); + +add_task(async function setup() { + await UrlClassifierTestUtils.addTestTrackers(); +}); + +add_task(async function test_shim_disabled_by_own_pref() { + // Test that a shim will not apply if disabled in about:config + + Services.prefs.setBoolPref(DISABLE_SHIM1_PREF, true); + Services.prefs.setBoolPref(TRACKING_PREF, true); + + await testShimDoesNotRun(); + + Services.prefs.clearUserPref(DISABLE_SHIM1_PREF); + Services.prefs.clearUserPref(TRACKING_PREF); +}); + +add_task(async function test_shim_disabled_by_global_pref() { + // Test that a shim will not apply if disabled in about:config + + Services.prefs.setBoolPref(GLOBAL_PREF, false); + Services.prefs.setBoolPref(DISABLE_SHIM1_PREF, false); + Services.prefs.setBoolPref(TRACKING_PREF, true); + + await testShimDoesNotRun(); + + Services.prefs.clearUserPref(GLOBAL_PREF); + Services.prefs.clearUserPref(DISABLE_SHIM1_PREF); + Services.prefs.clearUserPref(TRACKING_PREF); +}); + +add_task(async function test_shim_disabled_hosts_notHosts() { + Services.prefs.setBoolPref(TRACKING_PREF, true); + + await testShimDoesNotRun(false, SHIMMABLE_TEST_PAGE_3); + + Services.prefs.clearUserPref(TRACKING_PREF); +}); + +add_task(async function test_shim_disabled_overridden_by_pref() { + Services.prefs.setBoolPref(TRACKING_PREF, true); + + await testShimDoesNotRun(false, SHIMMABLE_TEST_PAGE_2); + + Services.prefs.setBoolPref(DISABLE_SHIM2_PREF, false); + + await testShimRuns(SHIMMABLE_TEST_PAGE_2); + + Services.prefs.clearUserPref(TRACKING_PREF); + Services.prefs.clearUserPref(DISABLE_SHIM2_PREF); +}); + +add_task(async function test_shim() { + // Test that a shim which only runs in strict mode works, and that it + // is permitted to opt into showing normally-blocked tracking content. + + Services.prefs.setBoolPref(TRACKING_PREF, true); + + await testShimRuns(SHIMMABLE_TEST_PAGE); + + // test that if the user opts in on one domain, they will still have to opt + // in on another domain which embeds an iframe to the first one. + + await testShimRuns(EMBEDDING_TEST_PAGE, 0, false, false); + + Services.prefs.clearUserPref(TRACKING_PREF); +}); diff --git a/browser/extensions/webcompat/tests/browser/head.js b/browser/extensions/webcompat/tests/browser/head.js new file mode 100644 index 0000000000..7bd1dde950 --- /dev/null +++ b/browser/extensions/webcompat/tests/browser/head.js @@ -0,0 +1,139 @@ +"use strict"; + +const TEST_ROOT = getRootDirectory(gTestPath).replace( + "chrome://mochitests/content", + "http://example.com" +); + +const THIRD_PARTY_ROOT = getRootDirectory(gTestPath).replace( + "chrome://mochitests/content", + "http://example.net" +); + +const SHIMMABLE_TEST_PAGE = `${TEST_ROOT}shims_test.html`; +const SHIMMABLE_TEST_PAGE_2 = `${TEST_ROOT}shims_test_2.html`; +const SHIMMABLE_TEST_PAGE_3 = `${TEST_ROOT}shims_test_3.html`; +const EMBEDDING_TEST_PAGE = `${THIRD_PARTY_ROOT}iframe_test.html`; + +const BLOCKED_TRACKER_URL = + "//trackertest.org/tests/toolkit/components/url-classifier/tests/mochitest/evil.js"; + +const DISABLE_SHIM1_PREF = "extensions.webcompat.disabled_shims.MochitestShim"; +const DISABLE_SHIM2_PREF = "extensions.webcompat.disabled_shims.MochitestShim2"; +const DISABLE_SHIM3_PREF = "extensions.webcompat.disabled_shims.MochitestShim3"; +const DISABLE_SHIM4_PREF = "extensions.webcompat.disabled_shims.MochitestShim4"; +const GLOBAL_PREF = "extensions.webcompat.enable_shims"; +const TRACKING_PREF = "privacy.trackingprotection.enabled"; + +const { UrlClassifierTestUtils } = ChromeUtils.import( + "resource://testing-common/UrlClassifierTestUtils.jsm" +); + +async function testShimRuns( + testPage, + frame, + trackersAllowed = true, + expectOptIn = true +) { + const tab = await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + opening: testPage, + waitForLoad: true, + }); + + const TrackingProtection = tab.ownerGlobal.TrackingProtection; + ok(TrackingProtection, "TP is attached to the tab"); + ok(TrackingProtection.enabled, "TP is enabled"); + + await SpecialPowers.spawn( + tab.linkedBrowser, + [[trackersAllowed, BLOCKED_TRACKER_URL, expectOptIn], frame], + async (args, _frame) => { + const window = _frame === undefined ? content : content.frames[_frame]; + + await SpecialPowers.spawn( + window, + args, + async (_trackersAllowed, trackerUrl, _expectOptIn) => { + const shimResult = await content.wrappedJSObject.shimPromise; + is("shimmed", shimResult, "Shim activated"); + + const optInResult = await content.wrappedJSObject.optInPromise; + is(_expectOptIn, optInResult, "Shim allowed opt in if appropriate"); + + const o = content.document.getElementById("shims"); + const cl = o.classList; + const opts = JSON.parse(o.innerText); + is( + undefined, + opts.branchValue, + "Shim script did not receive option for other branch" + ); + is( + undefined, + opts.platformValue, + "Shim script did not receive option for other platform" + ); + is( + true, + opts.simpleOption, + "Shim script received simple option correctly" + ); + ok(opts.complexOption, "Shim script received complex option"); + is( + 1, + opts.complexOption.a, + "Shim script received complex options correctly #1" + ); + is( + "test", + opts.complexOption.b, + "Shim script received complex options correctly #2" + ); + ok(cl.contains("green"), "Shim affected page correctly"); + } + ); + } + ); + + await BrowserTestUtils.removeTab(tab); +} + +async function testShimDoesNotRun( + trackersAllowed = false, + testPage = SHIMMABLE_TEST_PAGE +) { + const tab = await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + opening: testPage, + waitForLoad: true, + }); + + await SpecialPowers.spawn( + tab.linkedBrowser, + [trackersAllowed, BLOCKED_TRACKER_URL], + async (_trackersAllowed, trackerUrl) => { + const shimResult = await content.wrappedJSObject.shimPromise; + is("did not shim", shimResult, "Shim did not activate"); + + ok( + !content.document.getElementById("shims").classList.contains("green"), + "Shim script did not run" + ); + + is( + _trackersAllowed ? "ALLOWED" : "BLOCKED", + await new Promise(resolve => { + const s = content.document.createElement("script"); + s.src = trackerUrl; + s.onload = () => resolve("ALLOWED"); + s.onerror = () => resolve("BLOCKED"); + content.document.head.appendChild(s); + }), + "Normally-blocked resources blocked if appropriate" + ); + } + ); + + await BrowserTestUtils.removeTab(tab); +} diff --git a/browser/extensions/webcompat/tests/browser/iframe_test.html b/browser/extensions/webcompat/tests/browser/iframe_test.html new file mode 100644 index 0000000000..bf86cc3b0b --- /dev/null +++ b/browser/extensions/webcompat/tests/browser/iframe_test.html @@ -0,0 +1,17 @@ +<!DOCTYPE html> +<html> + <head> + <meta charset="utf8"> + <script> + window.shimPromise = new Promise(resolve => { + window.shimPromiseResolve = resolve; + }); + window.optInPromise = new Promise(resolve => { + window.optInPromiseResolve = resolve; + }); + </script> + </head> + <body> + <iframe src="http://example.com/browser/browser/extensions/webcompat/tests/browser/shims_test.html"></iframe> + </body> +</html> diff --git a/browser/extensions/webcompat/tests/browser/shims_test.html b/browser/extensions/webcompat/tests/browser/shims_test.html new file mode 100644 index 0000000000..9c0204615c --- /dev/null +++ b/browser/extensions/webcompat/tests/browser/shims_test.html @@ -0,0 +1,18 @@ +<!DOCTYPE html> +<html> + <head> + <meta charset="utf8"> + <script> + window.shimPromise = new Promise(resolve => { + window.shimPromiseResolve = resolve; + }); + window.optInPromise = new Promise(resolve => { + window.optInPromiseResolve = resolve; + }); + </script> + <script onerror="window.shimPromiseResolve('error')" src="shims_test.js"></script> + </head> + <body> + <div id="shims"></div> + </body> +</html> diff --git a/browser/extensions/webcompat/tests/browser/shims_test.js b/browser/extensions/webcompat/tests/browser/shims_test.js new file mode 100644 index 0000000000..4a55bee7ed --- /dev/null +++ b/browser/extensions/webcompat/tests/browser/shims_test.js @@ -0,0 +1,11 @@ +/* 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"; + +if (window.doingOptIn) { + window.optInPromiseResolve(true); +} else { + window.shimPromiseResolve("did not shim"); +} diff --git a/browser/extensions/webcompat/tests/browser/shims_test_2.html b/browser/extensions/webcompat/tests/browser/shims_test_2.html new file mode 100644 index 0000000000..38dfc26208 --- /dev/null +++ b/browser/extensions/webcompat/tests/browser/shims_test_2.html @@ -0,0 +1,18 @@ +<!DOCTYPE html> +<html> + <head> + <meta charset="utf8"> + <script> + window.shimPromise = new Promise(resolve => { + window.shimPromiseResolve = resolve; + }); + window.optInPromise = new Promise(resolve => { + window.optInPromiseResolve = resolve; + }); + </script> + <script onerror="window.shimPromiseResolve('error')" src="shims_test_2.js"></script> + </head> + <body> + <div id="shims"></div> + </body> +</html> diff --git a/browser/extensions/webcompat/tests/browser/shims_test_2.js b/browser/extensions/webcompat/tests/browser/shims_test_2.js new file mode 100644 index 0000000000..4a55bee7ed --- /dev/null +++ b/browser/extensions/webcompat/tests/browser/shims_test_2.js @@ -0,0 +1,11 @@ +/* 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"; + +if (window.doingOptIn) { + window.optInPromiseResolve(true); +} else { + window.shimPromiseResolve("did not shim"); +} diff --git a/browser/extensions/webcompat/tests/browser/shims_test_3.html b/browser/extensions/webcompat/tests/browser/shims_test_3.html new file mode 100644 index 0000000000..7f8848d447 --- /dev/null +++ b/browser/extensions/webcompat/tests/browser/shims_test_3.html @@ -0,0 +1,18 @@ +<!DOCTYPE html> +<html> + <head> + <meta charset="utf8"> + <script> + window.shimPromise = new Promise(resolve => { + window.shimPromiseResolve = resolve; + }); + window.optInPromise = new Promise(resolve => { + window.optInPromiseResolve = resolve; + }); + </script> + <script onerror="window.shimPromiseResolve('error')" src="shims_test_3.js"></script> + </head> + <body> + <div id="shims"></div> + </body> +</html> diff --git a/browser/extensions/webcompat/tests/browser/shims_test_3.js b/browser/extensions/webcompat/tests/browser/shims_test_3.js new file mode 100644 index 0000000000..9acb6cdcf1 --- /dev/null +++ b/browser/extensions/webcompat/tests/browser/shims_test_3.js @@ -0,0 +1,7 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +window.shimPromiseResolve("did not shim"); |