author | Sean Lee <selee@mozilla.com> |
Sat, 16 Dec 2017 13:20:48 -0600 | |
changeset 717512 | c4dc41200b8d4cbf8aea20c626541ebda76fe6a6 |
parent 717511 | 774fd6c4bf790c46d481472f81146262155f6508 |
child 745286 | 37de47b41fa22a48fb2abf5b4a219870b0306a2b |
push id | 94711 |
push user | bmo:selee@mozilla.com |
push date | Tue, 09 Jan 2018 05:51:03 +0000 |
reviewers | lchang, ralin |
bugs | 1417834 |
milestone | 59.0a1 |
--- a/browser/extensions/formautofill/FormAutofillContent.jsm +++ b/browser/extensions/formautofill/FormAutofillContent.jsm @@ -98,17 +98,17 @@ AutofillProfileAutoCompleteSearch.protot let {activeInput, activeSection, activeFieldDetail, savedFieldNames} = FormAutofillContent; this.forceStop = false; this.log.debug("startSearch: for", searchString, "with input", activeInput); let isAddressField = FormAutofillUtils.isAddressField(activeFieldDetail.fieldName); let isInputAutofilled = activeFieldDetail.state == FIELD_STATES.AUTO_FILLED; let allFieldNames = activeSection.allFieldNames; - let filledRecordGUID = activeSection.getFilledRecordGUID(); + let filledRecordGUID = activeSection.filledRecordGUID; let searchPermitted = isAddressField ? FormAutofillUtils.isAutofillAddressesEnabled : FormAutofillUtils.isAutofillCreditCardsEnabled; let AutocompleteResult = isAddressField ? AddressResult : CreditCardResult; ProfileAutocomplete.lastProfileAutoCompleteFocusedInput = activeInput; // Fallback to form-history if ... // - specified autofill feature is pref off.
--- a/browser/extensions/formautofill/FormAutofillHandler.jsm +++ b/browser/extensions/formautofill/FormAutofillHandler.jsm @@ -26,246 +26,157 @@ XPCOMUtils.defineLazyModuleGetter(this, this.log = null; FormAutofillUtils.defineLazyLogGetter(this, this.EXPORTED_SYMBOLS[0]); const {FIELD_STATES} = FormAutofillUtils; class FormAutofillSection { constructor(fieldDetails, winUtils) { - this.address = { - /** - * Similar to the `_validDetails` but contains address fields only. - */ - fieldDetails: [], - /** - * String of the filled address' guid. - */ - filledRecordGUID: null, - }; - this.creditCard = { - /** - * Similar to the `_validDetails` but contains credit card fields only. - */ - fieldDetails: [], - /** - * String of the filled creditCard's' guid. - */ - filledRecordGUID: null, - }; + 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]: "-moz-autofill", // highlighted && grey color text [FIELD_STATES.PREVIEW]: "-moz-autofill-preview", }; - this.winUtils = winUtils; - - this.address.fieldDetails = fieldDetails.filter( - detail => FormAutofillUtils.isAddressField(detail.fieldName) - ); - if (this.address.fieldDetails.length < FormAutofillUtils.AUTOFILL_FIELDS_THRESHOLD) { - log.debug("Ignoring address related fields since the section has only", - this.address.fieldDetails.length, - "field(s)"); - this.address.fieldDetails = []; - } - - this.creditCard.fieldDetails = fieldDetails.filter( - detail => FormAutofillUtils.isCreditCardField(detail.fieldName) - ); - if (!this._isValidCreditCardForm(this.creditCard.fieldDetails)) { - log.debug("Invalid credit card section."); - this.creditCard.fieldDetails = []; + if (!this.isValidSection()) { + this.fieldDetails = []; + log.debug(`Ignoring ${this.constructor.name} related fields since it is an invalid section`); } this._cacheValue = { allFieldNames: null, - oneLineStreetAddress: null, matchingSelectOption: null, }; + } - this._validDetails = Array.of(...(this.address.fieldDetails), - ...(this.creditCard.fieldDetails)); - log.debug(this._validDetails.length, "valid fields in the section is collected."); + /* + * 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"); } - get validDetails() { - return this._validDetails; + /** + * 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. + */ + async prepareFillingProfile(profile) {} + + /* + * 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._validDetails.find( + return this.fieldDetails.find( detail => detail.elementWeakRef.get() == element ); } - _isValidCreditCardForm(fieldDetails) { - let ccNumberReason = ""; - let hasCCNumber = false; - let hasExpiryDate = false; - - for (let detail of fieldDetails) { - switch (detail.fieldName) { - case "cc-number": - hasCCNumber = true; - ccNumberReason = detail._reason; - break; - case "cc-exp": - case "cc-exp-month": - case "cc-exp-year": - hasExpiryDate = true; - break; - } - } - - return hasCCNumber && (ccNumberReason == "autocomplete" || hasExpiryDate); - } - get allFieldNames() { if (!this._cacheValue.allFieldNames) { - this._cacheValue.allFieldNames = this._validDetails.map(record => record.fieldName); + this._cacheValue.allFieldNames = this.fieldDetails.map(record => record.fieldName); } return this._cacheValue.allFieldNames; } - _getFieldDetailByName(fieldName) { - return this._validDetails.find(detail => detail.fieldName == fieldName); - } - - _getTargetSet() { - let fieldDetail = this._focusedDetail; - if (!fieldDetail) { - return null; - } - if (FormAutofillUtils.isAddressField(fieldDetail.fieldName)) { - return this.address; - } - if (FormAutofillUtils.isCreditCardField(fieldDetail.fieldName)) { - return this.creditCard; - } - return null; - } - - _getFieldDetails() { - let targetSet = this._getTargetSet(); - return targetSet ? targetSet.fieldDetails : []; - } - - getFilledRecordGUID() { - let targetSet = this._getTargetSet(); - return targetSet ? targetSet.filledRecordGUID : null; - } - - _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]; + getFieldDetailByName(fieldName) { + return this.fieldDetails.find(detail => detail.fieldName == fieldName); } - _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 && - (streetAddressDetail.elementWeakRef.get() instanceof Ci.nsIDOMHTMLInputElement)) { - 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"]; - } - } - } - - _matchSelectOptions(profile) { + matchSelectOptions(profile) { if (!this._cacheValue.matchingSelectOption) { this._cacheValue.matchingSelectOption = new WeakMap(); } for (let fieldName in profile) { - let fieldDetail = this._getFieldDetailByName(fieldName); + let fieldDetail = this.getFieldDetailByName(fieldName); if (!fieldDetail) { continue; } let element = fieldDetail.elementWeakRef.get(); if (ChromeUtils.getClassName(element) !== "HTMLSelectElement") { continue; } @@ -287,55 +198,19 @@ class FormAutofillSection { } // Delete the field so the phishing hint won't treat it as a "also fill" // field. delete profile[fieldName]; } } } - _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"); - } - } - - _adaptFieldMaxLength(profile) { + adaptFieldMaxLength(profile) { for (let key in profile) { - let detail = this._getFieldDetailByName(key); + let detail = this.getFieldDetailByName(key); if (!detail) { continue; } let element = detail.elementWeakRef.get(); if (!element) { continue; } @@ -350,59 +225,39 @@ class FormAutofillSection { } else { delete profile[key]; } } } getAdaptedProfiles(originalProfiles) { for (let profile of originalProfiles) { - this._addressTransformer(profile); - this._telTransformer(profile); - this._matchSelectOptions(profile); - this._creditCardExpDateTransformer(profile); - this._adaptFieldMaxLength(profile); + 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. */ async autofillFields(profile) { let focusedDetail = this._focusedDetail; if (!focusedDetail) { throw new Error("No fieldDetail for the focused input."); } - let targetSet = this._getTargetSet(); - if (FormAutofillUtils.isCreditCardField(focusedDetail.fieldName)) { - // When Master Password is enabled by users, the decryption process - // should prompt Master Password dialog to get the decrypted credit - // card number. Otherwise, the number can be decrypted with the default - // password. - if (profile["cc-number-encrypted"]) { - let decrypted = await this._decrypt(profile["cc-number-encrypted"], true); - if (!decrypted) { - // Early return if the decrypted is empty or undefined - return; - } - - profile["cc-number"] = decrypted; - } - } - + await this.prepareFillingProfile(profile); log.debug("profile in autofillFields:", profile); - targetSet.filledRecordGUID = profile.guid; - for (let fieldDetail of targetSet.fieldDetails) { + 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; @@ -445,24 +300,19 @@ class FormAutofillSection { * 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); - // Always show the decrypted credit card number when Master Password is - // disabled. - if (profile["cc-number-decrypted"]) { - profile["cc-number"] = profile["cc-number-decrypted"]; - } + this.preparePreviewProfile(profile); - let fieldDetails = this._getFieldDetails(); - for (let fieldDetail of fieldDetails) { + 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; } @@ -488,18 +338,17 @@ class FormAutofillSection { } /** * Clear preview text and background highlight of all fields. */ clearPreviewedFormFields() { log.debug("clear previewed fields in:", this.form); - let fieldDetails = this._getFieldDetails(); - for (let fieldDetail of fieldDetails) { + for (let fieldDetail of this.fieldDetails) { let element = fieldDetail.elementWeakRef.get(); if (!element) { log.warn(fieldDetail.fieldName, "is unreachable"); continue; } element.previewValue = ""; @@ -512,18 +361,17 @@ class FormAutofillSection { this._changeFieldState(fieldDetail, FIELD_STATES.NORMAL); } } /** * Clear value and highlight style of all filled fields. */ clearPopulatedForm() { - let fieldDetails = this._getFieldDetails(); - for (let fieldDetail of fieldDetails) { + 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 && @@ -582,151 +430,276 @@ class FormAutofillSection { break; } } fieldDetail.state = nextState; } resetFieldStates() { - for (let fieldDetail of this._validDetails) { + for (let fieldDetail of this.fieldDetails) { const element = fieldDetail.elementWeakRef.get(); element.removeEventListener("input", this, {mozSystemGroup: true}); this._changeFieldState(fieldDetail, FIELD_STATES.NORMAL); } - this.address.filledRecordGUID = null; - this.creditCard.filledRecordGUID = null; + this.filledRecordGUID = null; } isFilled() { - return !!(this.address.filledRecordGUID || this.creditCard.filledRecordGUID); + 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 == 0) { + return null; + } + + let data = { + guid: this.filledRecordGUID, + record: {}, + untouchedFields: [], + }; + + 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; } - _isAddressRecordCreatable(record) { + handleEvent(event) { + switch (event.type) { + case "input": { + if (!event.isTrusted) { + return; + } + const target = event.target; + const targetFieldDetail = this.getFieldDetailByElement(target); + + this._changeFieldState(targetFieldDetail, FIELD_STATES.NORMAL); + + 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 FormAutofillUtils.isAutofillAddressesEnabled; + } + + isRecordCreatable(record) { 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; } - _isCreditCardRecordCreatable(record) { - return record["cc-number"] && FormAutofillUtils.isCCNumber(record["cc-number"]); + _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 && + (streetAddressDetail.elementWeakRef.get() instanceof Ci.nsIDOMHTMLInputElement)) { + 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 = []; + } + } + } } /** - * Return the records that is converted from address/creditCard fieldDetails and - * only valid form records are included. - * - * @returns {Object} - * Consists of two record objects: address, creditCard. Each one can - * be omitted if there's no valid fields. 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. + * Replace tel with tel-national if tel violates the input element's + * restriction. + * @param {Object} profile + * A profile to be converted. */ - createRecords() { - let data = {}; - let target = []; + telTransformer(profile) { + if (!profile.tel || !profile["tel-national"]) { + return; + } - if (FormAutofillUtils.isAutofillAddressesEnabled) { - target.push("address"); - } - if (FormAutofillUtils.isAutofillCreditCardsEnabled) { - target.push("creditCard"); + let detail = this.getFieldDetailByName("tel"); + if (!detail) { + return; } - target.forEach(type => { - let details = this[type].fieldDetails; - if (!details || details.length == 0) { + 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; } - - data[type] = { - guid: this[type].filledRecordGUID, - record: {}, - untouchedFields: [], - }; - - details.forEach(detail => { - let element = detail.elementWeakRef.get(); - // Remove the unnecessary spaces - let value = element && element.value.trim(); - - // Try to abbreviate the value of select element. - if (type == "address" && - detail.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 - data[type].record[detail.fieldName] = ""; - return; - } - - let text = element.selectedOptions[0].text.trim(); - value = FormAutofillUtils.getAbbreviatedSubregionName([value, text]) || text; - } - - if (!value || value.length > FormAutofillUtils.MAX_FIELD_VALUE_LENGTH) { - // Keep the property and preserve more information for updating - data[type].record[detail.fieldName] = ""; - return; - } - - data[type].record[detail.fieldName] = value; - - if (detail.state == FIELD_STATES.AUTO_FILLED) { - data[type].untouchedFields.push(detail.fieldName); - } - }); - }); - - this._normalizeAddress(data.address); - - if (data.address && !this._isAddressRecordCreatable(data.address.record)) { - log.debug("No address record saving since there are only", - Object.keys(data.address.record).length, - "usable fields"); - delete data.address; } - if (data.creditCard && !this._isCreditCardRecordCreatable(data.creditCard.record)) { - log.debug("No credit card record saving since card number is invalid"); - delete data.creditCard; + 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"]; + } } - - // If both address and credit card exists, skip this metrics because it not a - // general case and each specific histogram might contains insufficient data set. - if (data.address && data.creditCard) { - this.timeStartedFillingMS = null; - } - - return data; } - _normalizeAddress(address) { + /* + * 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"); + 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; } } @@ -751,63 +724,149 @@ class FormAutofillSection { // (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 { + constructor(fieldDetails, winUtils) { + super(fieldDetails, winUtils); + } + + isValidSection() { + let ccNumberReason = ""; + let hasCCNumber = false; + let hasExpiryDate = false; + + for (let detail of this.fieldDetails) { + switch (detail.fieldName) { + case "cc-number": + hasCCNumber = true; + ccNumberReason = detail._reason; + break; + case "cc-exp": + case "cc-exp-month": + case "cc-exp-year": + hasExpiryDate = true; + break; + } + } + + return hasCCNumber && (ccNumberReason == "autocomplete" || hasExpiryDate); + } + + isEnabled() { + return FormAutofillUtils.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) { return new Promise((resolve) => { Services.cpmm.addMessageListener("FormAutofill:DecryptedString", function getResult(result) { Services.cpmm.removeMessageListener("FormAutofill:DecryptedString", getResult); resolve(result.data); }); Services.cpmm.sendAsyncMessage("FormAutofill:GetDecryptedString", {cipherText, reauth}); }); } - handleEvent(event) { - switch (event.type) { - case "input": { - if (!event.isTrusted) { - return; - } - const target = event.target; - const targetFieldDetail = this.getFieldDetailByElement(target); - const targetSet = this._getTargetSet(target); - - this._changeFieldState(targetFieldDetail, FIELD_STATES.NORMAL); - - let isAutofilled = false; - let dimFieldDetails = []; - for (const fieldDetail of targetSet.fieldDetails) { - const element = fieldDetail.elementWeakRef.get(); + /* + * 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); + } - 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); - } - targetSet.filledRecordGUID = null; - } - break; + /** + * 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. + * @override + */ + async prepareFillingProfile(profile) { + // When Master Password is enabled by users, the decryption process + // should prompt Master Password dialog to get the decrypted credit + // card number. Otherwise, the number can be decrypted with the default + // password. + if (profile["cc-number-encrypted"]) { + let decrypted = await this._decrypt(profile["cc-number-encrypted"], true); + + if (!decrypted) { + // Early return if the decrypted is empty or undefined + return; } + + profile["cc-number"] = decrypted; } } } /** * Handles profile autofill for a DOM Form element. */ class FormAutofillHandler { @@ -930,20 +989,27 @@ class FormAutofillHandler { * @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 of sections) { - let section = new FormAutofillSection(fieldDetails, this.winUtils); + 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); + } else { + throw new Error("Unknown field type."); + } this.sections.push(section); - allValidDetails.push(...section.validDetails); + allValidDetails.push(...section.fieldDetails); } for (let detail of allValidDetails) { let input = detail.elementWeakRef.get(); if (!input) { continue; } input.addEventListener("input", this, {mozSystemGroup: true}); @@ -1021,17 +1087,24 @@ class FormAutofillHandler { */ createRecords() { const records = { address: [], creditCard: [], }; for (const section of this.sections) { - const secRecords = section.createRecords(); - for (const [type, record] of Object.entries(secRecords)) { - records[type].push(record); + 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; } }
--- a/browser/extensions/formautofill/test/unit/test_autofillFormFields.js +++ b/browser/extensions/formautofill/test/unit/test_autofillFormFields.js @@ -11,17 +11,16 @@ let {MasterPassword} = Cu.import("resour 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: {}, - expectedFillingForm: "address", expectedResult: { "street-addr": "", "city": "", "country": "", "email": "", "tel": "", }, }, @@ -42,17 +41,16 @@ const TESTCASES = [ "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", }, - expectedFillingForm: "address", expectedResult: { "street-addr": "2 Harrison St line2", "city": "San Francisco", "country": "US", "email": "foo@mozilla.com", "tel": "1234567", }, }, @@ -72,17 +70,16 @@ const TESTCASES = [ profileData: { "guid": "123", "street-address": "2 Harrison St", "address-level2": "San Francisco", "country": "US", "email": "foo@mozilla.com", "tel": "1234567", }, - expectedFillingForm: "address", expectedResult: { "street-addr": "2 Harrison St", "city": "San Francisco", "country": "US", "email": "foo@mozilla.com", "tel": "1234567", }, }, @@ -99,17 +96,16 @@ const TESTCASES = [ profileData: { "guid": "123", "street-address": "2 Harrison St", "address-level2": "San Francisco", "country": "US", "email": "", "tel": "", }, - expectedFillingForm: "address", expectedResult: { "street-addr": "2 Harrison St", "city": "San Francisco", "country": "US", "email": "", "tel": "", }, }, @@ -126,17 +122,16 @@ const TESTCASES = [ profileData: { "guid": "123", "street-address": "", "address-level2": "", "country": "", "email": "foo@mozilla.com", "tel": "1234567", }, - expectedFillingForm: "address", expectedResult: { "street-addr": "", "city": "", "country": "", "email": "foo@mozilla.com", "tel": "1234567", }, }, @@ -155,17 +150,16 @@ const TESTCASES = [ </select> </form>`, focusedInputId: "given-name", profileData: { "guid": "123", "country": "US", "address-level1": "CA", }, - expectedFillingForm: "address", expectedResult: { "country": "US", "state": "CA", }, }, { description: "Form with autocomplete select elements and matching option texts", document: `<form> @@ -181,17 +175,16 @@ const TESTCASES = [ </select> </form>`, focusedInputId: "given-name", profileData: { "guid": "123", "country": "United States", "address-level1": "California", }, - expectedFillingForm: "address", expectedResult: { "country": "US", "state": "CA", }, }, { description: "Fill address fields in a form with addr and CC fields.", document: `<form> @@ -215,17 +208,16 @@ const TESTCASES = [ "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", }, - expectedFillingForm: "address", expectedResult: { "street-addr": "2 Harrison St line2", "city": "San Francisco", "country": "US", "email": "foo@mozilla.com", "tel": "1234567", "cc-number": "", "cc-name": "", @@ -254,17 +246,16 @@ const TESTCASES = [ focusedInputId: "cc-number", profileData: { "guid": "123", "cc-number": "1234000056780000", "cc-name": "test name", "cc-exp-month": "06", "cc-exp-year": "25", }, - expectedFillingForm: "creditCard", expectedResult: { "street-addr": "", "city": "", "country": "", "email": "", "tel": "", "cc-number": "1234000056780000", "cc-name": "test name", @@ -291,17 +282,16 @@ const TESTCASES_INPUT_UNCHANGED = [ </select> </form>`, focusedInputId: "given-name", profileData: { "guid": "123", "country": "US", "address-level1": "unknown state", }, - expectedFillingForm: "address", expectedResult: { "country": "US", "state": "", }, }, ]; const TESTCASES_FILL_SELECT = [ @@ -316,17 +306,16 @@ const TESTCASES_FILL_SELECT = [ <option value="CA">California</option> </select></form>`, focusedInputId: "given-name", profileData: { "guid": "123", "country": "US", "address-level1": "CA", }, - expectedFillingForm: "address", 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"> @@ -336,17 +325,16 @@ const TESTCASES_FILL_SELECT = [ <option value="ca">ca</option> </select></form>`, focusedInputId: "given-name", profileData: { "guid": "123", "country": "US", "address-level1": "CA", }, - expectedFillingForm: "address", 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"> @@ -356,17 +344,16 @@ const TESTCASES_FILL_SELECT = [ <option value="CA">CA</option> </select></form>`, focusedInputId: "given-name", profileData: { "guid": "123", "country": "US", "address-level1": " California ", }, - expectedFillingForm: "address", 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"> @@ -376,17 +363,16 @@ const TESTCASES_FILL_SELECT = [ <option value="US-WA">WA-Washington</option> </select></form>`, focusedInputId: "given-name", profileData: { "guid": "123", "country": "US", "address-level1": "WA", }, - expectedFillingForm: "address", expectedResult: { "state": "US-WA", }, }, // Country { description: "Form with country select elements", @@ -397,17 +383,16 @@ const TESTCASES_FILL_SELECT = [ <option value=""></option> <option value="US">United States</option> </select></form>`, focusedInputId: "given-name", profileData: { "guid": "123", "country": "US", }, - expectedFillingForm: "address", expectedResult: { "country": "US", }, }, { description: "Form with country select elements; with lower case key", document: `<form> <input id="given-name" autocomplete="given-name"> @@ -416,17 +401,16 @@ const TESTCASES_FILL_SELECT = [ <option value=""></option> <option value="us">us</option> </select></form>`, focusedInputId: "given-name", profileData: { "guid": "123", "country": "US", }, - expectedFillingForm: "address", expectedResult: { "country": "us", }, }, { description: "Form with country select elements; with alternative name 1", document: `<form> <input id="given-name" autocomplete="given-name"> @@ -435,17 +419,16 @@ const TESTCASES_FILL_SELECT = [ <option value=""></option> <option value="XX">United States</option> </select></form>`, focusedInputId: "given-name", profileData: { "guid": "123", "country": "US", }, - expectedFillingForm: "address", expectedResult: { "country": "XX", }, }, { description: "Form with country select elements; with alternative name 2", document: `<form> <input id="given-name" autocomplete="given-name"> @@ -454,17 +437,16 @@ const TESTCASES_FILL_SELECT = [ <option value=""></option> <option value="XX">America</option> </select></form>`, focusedInputId: "given-name", profileData: { "guid": "123", "country": "US", }, - expectedFillingForm: "address", expectedResult: { "country": "XX", }, }, { description: "Form with country select elements; with partial matching value", document: `<form> <input id="given-name" autocomplete="given-name"> @@ -473,17 +455,16 @@ const TESTCASES_FILL_SELECT = [ <option value=""></option> <option value="XX">Ship to America</option> </select></form>`, focusedInputId: "given-name", profileData: { "guid": "123", "country": "US", }, - expectedFillingForm: "address", expectedResult: { "country": "XX", }, }, ]; function do_test(testcases, testFn) { for (let tc of testcases) { @@ -513,39 +494,37 @@ function do_test(testcases, testFn) { throw e; } info("User canceled master password entry"); } return string; }; handler.collectFormFields(); + + let focusedInput = doc.getElementById(testcase.focusedInputId); + handler.focusedInput = focusedInput; + for (let section of handler.sections) { section._decrypt = decryptHelper; } - // TODO [Bug 1415077] We can assume all test cases with only one section - // should be filled. Eventually, the test needs to verify the filling - // feature in a multiple section case. - let handlerInfo = handler.sections[0][testcase.expectedFillingForm]; - handlerInfo.fieldDetails.forEach(field => { + 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 focusedInput = doc.getElementById(testcase.focusedInputId); - handler.focusedInput = focusedInput; let [adaptedProfile] = handler.activeSection.getAdaptedProfiles([testcase.profileData]); await handler.autofillFormFields(adaptedProfile, focusedInput); - Assert.equal(handlerInfo.filledRecordGUID, testcase.profileData.guid, + Assert.equal(handler.activeSection.filledRecordGUID, testcase.profileData.guid, "Check if filledRecordGUID is set correctly"); await Promise.all(promises); }); })(); } } do_test(TESTCASES, (testcase, element) => {
--- a/browser/extensions/formautofill/test/unit/test_collectFormFields.js +++ b/browser/extensions/formautofill/test/unit/test_collectFormFields.js @@ -7,28 +7,27 @@ Cu.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: [{ - addressFieldDetails: [ + 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"}, ], - creditCardFieldDetails: [], - }], + ], 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"}, @@ -45,33 +44,32 @@ const TESTCASES = [ <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: [{ - addressFieldDetails: [ + 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"}, - ], - creditCardFieldDetails: [ + ], [ {"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"}, @@ -85,28 +83,27 @@ const TESTCASES = [ 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: [{ - addressFieldDetails: [ + 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"}, ], - creditCardFieldDetails: [], - }], + ], 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"}, @@ -116,28 +113,27 @@ const TESTCASES = [ 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: [{ - addressFieldDetails: [ + 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"}, ], - creditCardFieldDetails: [], - }], + ], 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"}, @@ -146,122 +142,109 @@ const TESTCASES = [ { 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: [{ - addressFieldDetails: [ + sections: [ + [ {"section": "", "addressType": "shipping", "contactType": "", "fieldName": "given-name"}, {"section": "", "addressType": "shipping", "contactType": "", "fieldName": "family-name"}, {"section": "", "addressType": "shipping", "contactType": "", "fieldName": "street-address"}, - ], - creditCardFieldDetails: [ + ], [ {"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: [{ - addressFieldDetails: [], - creditCardFieldDetails: [], - }], + 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: [{ - addressFieldDetails: [], - creditCardFieldDetails: [], - }], + sections: [[]], validFieldDetails: [], }, { description: "An invalid credit card form due to non-autocomplete-attr cc-number and omitted cc-exp-*.", document: `<form> <input id="cc-name" autocomplete="cc-name"> <input id="cc-number" name="card-number"> </form>`, - sections: [{ - addressFieldDetails: [], - creditCardFieldDetails: [], - }], + sections: [[]], validFieldDetails: [], }, { description: "A valid credit card form with autocomplete-attr cc-number only.", document: `<form> <input id="cc-number" autocomplete="cc-number"> </form>`, - sections: [{ - addressFieldDetails: [], - creditCardFieldDetails: [ + 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: [{ - addressFieldDetails: [], - creditCardFieldDetails: [ + 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: [{ - addressFieldDetails: [], - creditCardFieldDetails: [ + 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", @@ -281,44 +264,37 @@ const TESTCASES = [ <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: [{ - addressFieldDetails: [ + 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"}, - ], - creditCardFieldDetails: [], - }, { - addressFieldDetails: [ + ], [ {"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"}, - ], - creditCardFieldDetails: [], - }, { - addressFieldDetails: [ + ], [ {"section": "", "addressType": "", "contactType": "", "fieldName": "tel-area-code"}, {"section": "", "addressType": "", "contactType": "", "fieldName": "tel-local-prefix"}, {"section": "", "addressType": "", "contactType": "", "fieldName": "tel-local-suffix"}, ], - creditCardFieldDetails: [], - }], + ], 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"}, @@ -340,26 +316,25 @@ const TESTCASES = [ <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: [{ - addressFieldDetails: [ + 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"}, ], - creditCardFieldDetails: [], - }], + ], 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"], @@ -371,33 +346,32 @@ const TESTCASES = [ <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: [{ - addressFieldDetails: [ + 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"}, ], - creditCardFieldDetails: [], - }], + ], 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"}, @@ -410,24 +384,23 @@ const TESTCASES = [ 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: [{ - addressFieldDetails: [ + sections: [ + [ {"section": "", "addressType": "shipping", "contactType": "", "fieldName": "given-name"}, {"section": "", "addressType": "shipping", "contactType": "", "fieldName": "family-name"}, {"section": "", "addressType": "shipping", "contactType": "", "fieldName": "tel"}, ], - creditCardFieldDetails: [], - }], + ], 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"], }, ]; @@ -459,36 +432,35 @@ for (let tc of TESTCASES) { }); } function verifyDetails(handlerDetails, testCaseDetails) { if (handlerDetails === null) { Assert.equal(handlerDetails, testCaseDetails); return; } - Assert.equal(handlerDetails.length, testCaseDetails.length); + 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.addressFieldDetails, ...section.creditCardFieldDetails); + 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); + 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.address.fieldDetails, testcase.sections[i].addressFieldDetails); - verifyDetails(section.creditCard.fieldDetails, testcase.sections[i].creditCardFieldDetails); + verifyDetails(section.fieldDetails, testcase.sections[i]); } verifyDetails(validFieldDetails, testcase.validFieldDetails); }); })(); }