diff options
Diffstat (limited to 'browser/extensions/formautofill')
18 files changed, 1877 insertions, 0 deletions
diff --git a/browser/extensions/formautofill/.eslintrc.js b/browser/extensions/formautofill/.eslintrc.js new file mode 100644 index 000000000..ec89029e5 --- /dev/null +++ b/browser/extensions/formautofill/.eslintrc.js @@ -0,0 +1,474 @@ +"use strict"; + +module.exports = { // eslint-disable-line no-undef + "extends": "../../.eslintrc.js", + + "globals": { + "Components": true, + "dump": true, + "TextDecoder": false, + "TextEncoder": false, + }, + + "rules": { + // Rules from the mozilla plugin + "mozilla/balanced-listeners": "error", + "mozilla/no-aArgs": "warn", + "mozilla/no-cpows-in-tests": "warn", + "mozilla/var-only-at-top-level": "warn", + + "valid-jsdoc": ["error", { + "prefer": { + "return": "returns", + }, + "preferType": { + "Boolean": "boolean", + "Number": "number", + "String": "string", + "bool": "boolean", + }, + "requireParamDescription": false, + "requireReturn": false, + "requireReturnDescription": false, + }], + + // Braces only needed for multi-line arrow function blocks + // "arrow-body-style": ["error", "as-needed"], + + // Require spacing around => + "arrow-spacing": "error", + + // Always require spacing around a single line block + "block-spacing": "warn", + + // Forbid spaces inside the square brackets of array literals. + "array-bracket-spacing": ["error", "never"], + + // Forbid spaces inside the curly brackets of object literals. + "object-curly-spacing": ["error", "never"], + + // No space padding in parentheses + "space-in-parens": ["error", "never"], + + // Enforce one true brace style (opening brace on the same line) and avoid + // start and end braces on the same line. + "brace-style": ["error", "1tbs", {"allowSingleLine": true}], + + // No space before always a space after a comma + "comma-spacing": ["error", {"before": false, "after": true}], + + // Commas at the end of the line not the start + "comma-style": "error", + + // Don't require spaces around computed properties + "computed-property-spacing": ["warn", "never"], + + // Functions are not required to consistently return something or nothing + "consistent-return": "off", + + // Require braces around blocks that start a new line + "curly": ["error", "all"], + + // Always require a trailing EOL + "eol-last": "error", + + // Require function* name() + "generator-star-spacing": ["error", {"before": false, "after": true}], + + // Two space indent + "indent": ["error", 2, {"SwitchCase": 1}], + + // Space after colon not before in property declarations + "key-spacing": ["error", {"beforeColon": false, "afterColon": true, "mode": "minimum"}], + + // Require spaces before and after finally, catch, etc. + "keyword-spacing": "error", + + // Unix linebreaks + "linebreak-style": ["error", "unix"], + + // Always require parenthesis for new calls + "new-parens": "error", + + // Use [] instead of Array() + "no-array-constructor": "error", + + // No duplicate arguments in function declarations + "no-dupe-args": "error", + + // No duplicate keys in object declarations + "no-dupe-keys": "error", + + // No duplicate cases in switch statements + "no-duplicate-case": "error", + + // If an if block ends with a return no need for an else block + // "no-else-return": "error", + + // Disallow empty statements. This will report an error for: + // try { something(); } catch (e) {} + // but will not report it for: + // try { something(); } catch (e) { /* Silencing the error because ...*/ } + // which is a valid use case. + "no-empty": "error", + + // No empty character classes in regex + "no-empty-character-class": "error", + + // Disallow empty destructuring + "no-empty-pattern": "error", + + // No assiging to exception variable + "no-ex-assign": "error", + + // No using !! where casting to boolean is already happening + "no-extra-boolean-cast": "warn", + + // No double semicolon + "no-extra-semi": "error", + + // No overwriting defined functions + "no-func-assign": "error", + + // No invalid regular expresions + "no-invalid-regexp": "error", + + // No odd whitespace characters + "no-irregular-whitespace": "error", + + // No single if block inside an else block + "no-lonely-if": "warn", + + // No mixing spaces and tabs in indent + "no-mixed-spaces-and-tabs": ["error", "smart-tabs"], + + // Disallow use of multiple spaces (sometimes used to align const values, + // array or object items, etc.). It's hard to maintain and doesn't add that + // much benefit. + "no-multi-spaces": "warn", + + // No reassigning native JS objects + "no-native-reassign": "error", + + // No (!foo in bar) + "no-negated-in-lhs": "error", + + // Nested ternary statements are confusing + "no-nested-ternary": "error", + + // Use {} instead of new Object() + "no-new-object": "error", + + // No Math() or JSON() + "no-obj-calls": "error", + + // No octal literals + "no-octal": "error", + + // No redeclaring variables + "no-redeclare": "error", + + // No unnecessary comparisons + "no-self-compare": "error", + + // No spaces between function name and parentheses + "no-spaced-func": "warn", + + // No trailing whitespace + "no-trailing-spaces": "error", + + // Error on newline where a semicolon is needed + "no-unexpected-multiline": "error", + + // No unreachable statements + "no-unreachable": "error", + + // No expressions where a statement is expected + "no-unused-expressions": "error", + + // No declaring variables that are never used + "no-unused-vars": ["error", {"args": "none", "varsIgnorePattern": "^(Cc|Ci|Cr|Cu|EXPORTED_SYMBOLS)$"}], + + // No using variables before defined + "no-use-before-define": "error", + + // No using with + "no-with": "error", + + // Always require semicolon at end of statement + "semi": ["error", "always"], + + // Require space before blocks + "space-before-blocks": "error", + + // Never use spaces before function parentheses + "space-before-function-paren": ["error", {"anonymous": "never", "named": "never"}], + + // Require spaces around operators, except for a|"off". + "space-infix-ops": ["error", {"int32Hint": true}], + + // ++ and -- should not need spacing + "space-unary-ops": ["warn", {"nonwords": false}], + + // No comparisons to NaN + "use-isnan": "error", + + // Only check typeof against valid results + "valid-typeof": "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", + + // Allow trailing commas for easy list extension. Having them does not + // impair readability, but also not required either. + "comma-dangle": ["error", "always-multiline"], + + // Warn about cyclomatic complexity in functions. + "complexity": "warn", + + // Don't warn for inconsistent naming when capturing this (not so important + // with auto-binding fat arrow functions). + // "consistent-this": ["error", "self"], + + // Don't require a default case in switch statements. Avoid being forced to + // add a bogus default when you know all possible cases are handled. + "default-case": "off", + + // Enforce dots on the next line with property name. + "dot-location": ["error", "property"], + + // Encourage the use of dot notation whenever possible. + "dot-notation": "error", + + // Allow using == instead of ===, in the interest of landing something since + // the devtools codebase is split on convention here. + "eqeqeq": "off", + + // Don't require function expressions to have a name. + // This makes the code more verbose and hard to read. Our engine already + // does a fantastic job assigning a name to the function, which includes + // the enclosing function name, and worst case you have a line number that + // you can just look up. + "func-names": "off", + + // Allow use of function declarations and expressions. + "func-style": "off", + + // Don't enforce the maximum depth that blocks can be nested. The complexity + // rule is a better rule to check this. + "max-depth": "off", + + // Maximum length of a line. + // Disabled because we exceed this in too many places. + "max-len": ["off", 80], + + // Maximum depth callbacks can be nested. + "max-nested-callbacks": ["error", 4], + + // Don't limit the number of parameters that can be used in a function. + "max-params": "off", + + // Don't limit the maximum number of statement allowed in a function. We + // already have the complexity rule that's a better measurement. + "max-statements": "off", + + // Don't require a capital letter for constructors, only check if all new + // operators are followed by a capital letter. Don't warn when capitalized + // functions are used without the new operator. + "new-cap": ["off", {"capIsNew": false}], + + // Allow use of bitwise operators. + "no-bitwise": "off", + + // Disallow use of arguments.caller or arguments.callee. + "no-caller": "error", + + // Disallow the catch clause parameter name being the same as a variable in + // the outer scope, to avoid confusion. + "no-catch-shadow": "off", + + // Disallow assignment in conditional expressions. + "no-cond-assign": "error", + + // Disallow using the console API. + "no-console": "error", + + // Allow using constant expressions in conditions like while (true) + "no-constant-condition": "off", + + // Allow use of the continue statement. + "no-continue": "off", + + // Disallow control characters in regular expressions. + "no-control-regex": "error", + + // Disallow use of debugger. + "no-debugger": "error", + + // Disallow deletion of variables (deleting properties is fine). + "no-delete-var": "error", + + // Allow division operators explicitly at beginning of regular expression. + "no-div-regex": "off", + + // Disallow use of eval(). We have other APIs to evaluate code in content. + "no-eval": "error", + + // Disallow adding to native types + "no-extend-native": "error", + + // Disallow unnecessary function binding. + "no-extra-bind": "error", + + // Allow unnecessary parentheses, as they may make the code more readable. + "no-extra-parens": "off", + + // Disallow fallthrough of case statements, except if there is a comment. + "no-fallthrough": "error", + + // Allow the use of leading or trailing decimal points in numeric literals. + "no-floating-decimal": "off", + + // Allow comments inline after code. + "no-inline-comments": "off", + + // Disallow use of labels for anything other then loops and switches. + "no-labels": ["error", {"allowLoop": true}], + + // Disallow use of multiline strings (use template strings instead). + "no-multi-str": "warn", + + // Disallow multiple empty lines. + "no-multiple-empty-lines": ["warn", {"max": 2}], + + // Allow reassignment of function parameters. + "no-param-reassign": "off", + + // Allow string concatenation with __dirname and __filename (not a node env). + "no-path-concat": "off", + + // Allow use of unary operators, ++ and --. + "no-plusplus": "off", + + // Allow using process.env (not a node environment). + "no-process-env": "off", + + // Allow using process.exit (not a node environment). + "no-process-exit": "off", + + // Disallow usage of __proto__ property. + "no-proto": "error", + + // Disallow multiple spaces in a regular expression literal. + "no-regex-spaces": "error", + + // Allow reserved words being used as object literal keys. + "no-reserved-keys": "off", + + // Don't restrict usage of specified node modules (not a node environment). + "no-restricted-modules": "off", + + // 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", + + // Don't warn about declaration of variables already declared in the outer scope. + "no-shadow": "off", + + // Disallow shadowing of names such as arguments. + "no-shadow-restricted-names": "error", + + // Allow use of synchronous methods (not a node environment). + "no-sync": "off", + + // Allow the use of ternary operators. + "no-ternary": "off", + + // Disallow throwing literals (eg. throw "error" instead of + // throw new Error("error")). + "no-throw-literal": "error", + + // Disallow use of undeclared variables unless mentioned in a /* global */ + // block. Note that globals from head.js are automatically imported in tests + // by the import-headjs-globals rule form the mozilla eslint plugin. + "no-undef": "error", + + // Allow dangling underscores in identifiers (for privates). + "no-underscore-dangle": "off", + + // Allow use of undefined variable. + "no-undefined": "off", + + // Disallow the use of Boolean literals in conditional expressions. + "no-unneeded-ternary": "error", + + // We use var-only-at-top-level instead of no-var as we allow top level + // vars. + "no-var": "off", + + // Allow using TODO/FIXME comments. + "no-warning-comments": "off", + + // Don't require method and property shorthand syntax for object literals. + // We use this in the code a lot, but not consistently, and this seems more + // like something to check at code review time. + "object-shorthand": "off", + + // Allow more than one variable declaration per function. + "one-var": "off", + + // Disallow padding within blocks. + "padded-blocks": ["warn", "never"], + + // Don't require quotes around object literal property names. + "quote-props": "off", + + // Double quotes should be used. + "quotes": ["warn", "double", {"avoidEscape": true, "allowTemplateLiterals": true}], + + // Require use of the second argument for parseInt(). + "radix": "error", + + // Enforce spacing after semicolons. + "semi-spacing": ["error", {"before": false, "after": true}], + + // Don't require to sort variables within the same declaration block. + // Anyway, one-var is disabled. + "sort-vars": "off", + + // Require a space immediately following the // in a line comment. + "spaced-comment": ["error", "always"], + + // Require "use strict" to be defined globally in the script. + "strict": ["error", "global"], + + // Allow vars to be declared anywhere in the scope. + "vars-on-top": "off", + + // Don't require immediate function invocation to be wrapped in parentheses. + "wrap-iife": "off", + + // Don't require regex literals to be wrapped in parentheses (which + // supposedly prevent them from being mistaken for division operators). + "wrap-regex": "off", + + // Disallow Yoda conditions (where literal value comes first). + "yoda": "error", + + // disallow use of eval()-like methods + "no-implied-eval": "error", + + // Disallow function or variable declarations in nested blocks + "no-inner-declarations": "error", + + // Disallow usage of __iterator__ property + "no-iterator": "error", + + // Disallow labels that share a name with a variable + "no-label-var": "error", + + // Disallow creating new instances of String, Number, and Boolean + "no-new-wrappers": "error", + }, +}; diff --git a/browser/extensions/formautofill/bootstrap.js b/browser/extensions/formautofill/bootstrap.js new file mode 100644 index 000000000..0b3f355bd --- /dev/null +++ b/browser/extensions/formautofill/bootstrap.js @@ -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/. */ + +"use strict"; + +/* exported startup, shutdown, install, uninstall */ + +function startup() {} +function shutdown() {} +function install() {} +function uninstall() {} diff --git a/browser/extensions/formautofill/content/FormAutofillContent.jsm b/browser/extensions/formautofill/content/FormAutofillContent.jsm new file mode 100644 index 000000000..bde397580 --- /dev/null +++ b/browser/extensions/formautofill/content/FormAutofillContent.jsm @@ -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/. */ + +/* + * Implements a service used by DOM content to request Form Autofill. + */ + +"use strict"; + +const {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components; + +/** + * Handles profile autofill for a DOM Form element. + * @param {HTMLFormElement} form Form that need to be auto filled + */ +function FormAutofillHandler(form) { + this.form = form; + this.fieldDetails = []; +} + +FormAutofillHandler.prototype = { + /** + * DOM Form element to which this object is attached. + */ + form: null, + + /** + * 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. + */ + fieldDetails: null, + + /** + * Returns information from the form about fields that can be autofilled, and + * populates the fieldDetails array on this object accordingly. + * + * @returns {Array<Object>} Serializable data structure that can be sent to the user + * interface, or null if the operation failed because the constraints + * on the allowed fields were not honored. + */ + collectFormFields() { + let autofillData = []; + + for (let element of this.form.elements) { + // Query the interface and exclude elements that cannot be autocompleted. + if (!(element instanceof Ci.nsIDOMHTMLInputElement)) { + continue; + } + + // Exclude elements to which no autocomplete field has been assigned. + let info = element.getAutocompleteInfo(); + if (!info.fieldName || ["on", "off"].includes(info.fieldName)) { + continue; + } + + // Store the association between the field metadata and the element. + if (this.fieldDetails.some(f => f.section == info.section && + f.addressType == info.addressType && + f.contactType == info.contactType && + f.fieldName == info.fieldName)) { + // A field with the same identifier already exists. + return null; + } + + let inputFormat = { + section: info.section, + addressType: info.addressType, + contactType: info.contactType, + fieldName: info.fieldName, + }; + // Clone the inputFormat for caching the fields and elements together + let formatWithElement = Object.assign({}, inputFormat); + + inputFormat.index = autofillData.length; + autofillData.push(inputFormat); + + formatWithElement.element = element; + this.fieldDetails.push(formatWithElement); + } + + return autofillData; + }, + + /** + * Processes form fields that can be autofilled, and populates them with the + * data provided by backend. + * + * @param {Array<Object>} autofillResult + * Data returned by the user interface. + * [{ + * section: Value originally provided to the user interface. + * addressType: Value originally provided to the user interface. + * contactType: Value originally provided to the user interface. + * fieldName: Value originally provided to the user interface. + * value: String with which the field should be updated. + * index: Index to match the input in fieldDetails + * }], + * } + */ + autofillFormFields(autofillResult) { + for (let field of autofillResult) { + // Get the field details, if it was processed by the user interface. + let fieldDetail = this.fieldDetails[field.index]; + + // Avoid the invalid value set + if (!fieldDetail || !field.value) { + continue; + } + + let info = fieldDetail.element.getAutocompleteInfo(); + if (field.section != info.section || + field.addressType != info.addressType || + field.contactType != info.contactType || + field.fieldName != info.fieldName) { + Cu.reportError("Autocomplete tokens mismatched"); + continue; + } + + fieldDetail.element.setUserInput(field.value); + } + }, +}; + +this.EXPORTED_SYMBOLS = ["FormAutofillHandler"]; diff --git a/browser/extensions/formautofill/content/FormAutofillParent.jsm b/browser/extensions/formautofill/content/FormAutofillParent.jsm new file mode 100644 index 000000000..bdfe0f478 --- /dev/null +++ b/browser/extensions/formautofill/content/FormAutofillParent.jsm @@ -0,0 +1,173 @@ +/* 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.jsm for more details. + * + * [ + * { + * section, + * addressType, + * contactType, + * fieldName, + * value, + * index + * }, + * { + * // ... + * } + * ] + */ + +/* exported FormAutofillParent */ + +"use strict"; + +const {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components; + +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "OS", + "resource://gre/modules/osfile.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "ProfileStorage", + "resource://formautofill/ProfileStorage.jsm"); + +const PROFILE_JSON_FILE_NAME = "autofill-profiles.json"; + +let FormAutofillParent = { + _profileStore: null, + + /** + * Initializes ProfileStorage and registers the message handler. + */ + init: function() { + let storePath = + OS.Path.join(OS.Constants.Path.profileDir, PROFILE_JSON_FILE_NAME); + + this._profileStore = new ProfileStorage(storePath); + this._profileStore.initialize(); + + let mm = Cc["@mozilla.org/globalmessagemanager;1"] + .getService(Ci.nsIMessageListenerManager); + mm.addMessageListener("FormAutofill:PopulateFieldValues", this); + }, + + /** + * Handles the message coming from FormAutofillContent. + * + * @param {string} message.name The name of the message. + * @param {object} message.data The data of the message. + * @param {nsIFrameMessageManager} message.target Caller's message manager. + */ + receiveMessage: function({name, data, target}) { + switch (name) { + case "FormAutofill:PopulateFieldValues": + this._populateFieldValues(data, target); + break; + } + }, + + /** + * Returns the instance of ProfileStorage. To avoid syncing issues, anyone + * who needs to access the profile should request the instance by this instead + * of creating a new one. + * + * @returns {ProfileStorage} + */ + getProfileStore: function() { + return this._profileStore; + }, + + /** + * Uninitializes FormAutofillParent. This is for testing only. + * + * @private + */ + _uninit: function() { + if (this._profileStore) { + this._profileStore._saveImmediately(); + this._profileStore = null; + } + + let mm = Cc["@mozilla.org/globalmessagemanager;1"] + .getService(Ci.nsIMessageListenerManager); + mm.removeMessageListener("FormAutofill:PopulateFieldValues", this); + }, + + /** + * Populates the field values and notifies content to fill in. Exception will + * be thrown if there's no matching profile. + * + * @private + * @param {string} data.guid + * Indicates which profile to populate + * @param {Fields} data.fields + * The "fields" array collected from content. + * @param {nsIFrameMessageManager} target + * Content's message manager. + */ + _populateFieldValues({guid, fields}, target) { + this._profileStore.notifyUsed(guid); + this._fillInFields(this._profileStore.get(guid), fields); + target.sendAsyncMessage("FormAutofill:fillForm", {fields}); + }, + + /** + * Transforms a word with hyphen into camel case. + * (e.g. transforms "address-type" into "addressType".) + * + * @private + * @param {string} str The original string with hyphen. + * @returns {string} The camel-cased output string. + */ + _camelCase(str) { + return str.toLowerCase().replace(/-([a-z])/g, s => s[1].toUpperCase()); + }, + + /** + * Get the corresponding value from the specified profile according to a valid + * @autocomplete field name. + * + * Note that the field name doesn't need to match the property name defined in + * Profile object. This method can transform the raw data to fulfill it. (e.g. + * inputting "country-name" as "fieldName" will get a full name transformed + * from the country code that is recorded in "country" field.) + * + * @private + * @param {Profile} profile The specified profile. + * @param {string} fieldName A valid @autocomplete field name. + * @returns {string} The corresponding value. Returns "undefined" if there's + * no matching field. + */ + _getDataByFieldName(profile, fieldName) { + let key = this._camelCase(fieldName); + + // TODO: Transform the raw profile data to fulfill "fieldName" here. + + return profile[key]; + }, + + /** + * Fills in the "fields" array by the specified profile. + * + * @private + * @param {Profile} profile The specified profile to fill in. + * @param {Fields} fields The "fields" array collected from content. + */ + _fillInFields(profile, fields) { + for (let field of fields) { + let value = this._getDataByFieldName(profile, field.fieldName); + if (value !== undefined) { + field.value = value; + } + } + }, +}; + +this.EXPORTED_SYMBOLS = ["FormAutofillParent"]; diff --git a/browser/extensions/formautofill/content/ProfileStorage.jsm b/browser/extensions/formautofill/content/ProfileStorage.jsm new file mode 100644 index 000000000..843177d4e --- /dev/null +++ b/browser/extensions/formautofill/content/ProfileStorage.jsm @@ -0,0 +1,251 @@ +/* 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, using UTF-8 encoding. + * With indentation applied, the file would look like this: + * + * { + * version: 1, + * profiles: [ + * { + * guid, // 12 character... + * + * // profile + * organization, // Company + * streetAddress, // (Multiline) + * addressLevel2, // City/Town + * addressLevel1, // Province (Standardized code if possible) + * postalCode, + * country, // ISO 3166 + * tel, + * email, + * + * // metadata + * timeCreated, // in ms + * timeLastUsed, // in ms + * timeLastModified, // in ms + * timesUsed + * }, + * { + * // ... + * } + * ] + * } + */ + +"use strict"; + +const {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components; + +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); +Cu.import("resource://gre/modules/Services.jsm"); +Cu.import("resource://gre/modules/Task.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "JSONFile", + "resource://gre/modules/JSONFile.jsm"); + +XPCOMUtils.defineLazyServiceGetter(this, "gUUIDGenerator", + "@mozilla.org/uuid-generator;1", + "nsIUUIDGenerator"); + +const SCHEMA_VERSION = 1; + +// Name-related fields will be handled in follow-up bugs due to the complexity. +const VALID_FIELDS = [ + "organization", + "streetAddress", + "addressLevel2", + "addressLevel1", + "postalCode", + "country", + "tel", + "email", +]; + +function ProfileStorage(path) { + this._path = path; +} + +ProfileStorage.prototype = { + /** + * Loads the profile data from file to memory. + * + * @returns {Promise} + * @resolves When the operation finished successfully. + * @rejects JavaScript exception. + */ + initialize() { + this._store = new JSONFile({ + path: this._path, + dataPostProcessor: this._dataPostProcessor.bind(this), + }); + return this._store.load(); + }, + + /** + * Adds a new profile. + * + * @param {Profile} profile + * The new profile for saving. + */ + add(profile) { + this._store.ensureDataReady(); + + let profileToSave = this._normalizeProfile(profile); + + profileToSave.guid = gUUIDGenerator.generateUUID().toString() + .replace(/[{}-]/g, "").substring(0, 12); + + // Metadata + let now = Date.now(); + profileToSave.timeCreated = now; + profileToSave.timeLastModified = now; + profileToSave.timeLastUsed = 0; + profileToSave.timesUsed = 0; + + this._store.data.profiles.push(profileToSave); + + this._store.saveSoon(); + }, + + /** + * Update the specified profile. + * + * @param {string} guid + * Indicates which profile to update. + * @param {Profile} profile + * The new profile used to overwrite the old one. + */ + update(guid, profile) { + this._store.ensureDataReady(); + + let profileFound = this._findByGUID(guid); + if (!profileFound) { + throw new Error("No matching profile."); + } + + let profileToUpdate = this._normalizeProfile(profile); + for (let field of VALID_FIELDS) { + if (profileToUpdate[field] !== undefined) { + profileFound[field] = profileToUpdate[field]; + } else { + delete profileFound[field]; + } + } + + profileFound.timeLastModified = Date.now(); + + this._store.saveSoon(); + }, + + /** + * Notifies the stroage of the use of the specified profile, so we can update + * the metadata accordingly. + * + * @param {string} guid + * Indicates which profile to be notified. + */ + notifyUsed(guid) { + this._store.ensureDataReady(); + + let profileFound = this._findByGUID(guid); + if (!profileFound) { + throw new Error("No matching profile."); + } + + profileFound.timesUsed++; + profileFound.timeLastUsed = Date.now(); + + this._store.saveSoon(); + }, + + /** + * Removes the specified profile. No error occurs if the profile isn't found. + * + * @param {string} guid + * Indicates which profile to remove. + */ + remove(guid) { + this._store.ensureDataReady(); + + this._store.data.profiles = + this._store.data.profiles.filter(profile => profile.guid != guid); + this._store.saveSoon(); + }, + + /** + * Returns the profile with the specified GUID. + * + * @param {string} guid + * Indicates which profile to retrieve. + * @returns {Profile} + * A clone of the profile. + */ + get(guid) { + this._store.ensureDataReady(); + + let profileFound = this._findByGUID(guid); + if (!profileFound) { + throw new Error("No matching profile."); + } + + // Profile is cloned to avoid accidental modifications from outside. + return this._clone(profileFound); + }, + + /** + * Returns all profiles. + * + * @returns {Array.<Profile>} + * An array containing clones of all profiles. + */ + getAll() { + this._store.ensureDataReady(); + + // Profiles are cloned to avoid accidental modifications from outside. + return this._store.data.profiles.map(this._clone); + }, + + _clone(profile) { + return Object.assign({}, profile); + }, + + _findByGUID(guid) { + return this._store.data.profiles.find(profile => profile.guid == guid); + }, + + _normalizeProfile(profile) { + let result = {}; + for (let key in profile) { + if (!VALID_FIELDS.includes(key)) { + throw new Error(`"${key}" is not a valid field.`); + } + if (typeof profile[key] !== "string" && + typeof profile[key] !== "number") { + throw new Error(`"${key}" contains invalid data type.`); + } + + result[key] = profile[key]; + } + return result; + }, + + _dataPostProcessor(data) { + data.version = SCHEMA_VERSION; + if (!data.profiles) { + data.profiles = []; + } + return data; + }, + + // For test only. + _saveImmediately() { + return this._store._save(); + }, +}; + +this.EXPORTED_SYMBOLS = ["ProfileStorage"]; diff --git a/browser/extensions/formautofill/install.rdf.in b/browser/extensions/formautofill/install.rdf.in new file mode 100644 index 000000000..5e34051ba --- /dev/null +++ b/browser/extensions/formautofill/install.rdf.in @@ -0,0 +1,32 @@ +<?xml version="1.0"?> +<!-- 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/. --> + +#filter substitution + +<RDF xmlns="http://www.w3.org/1999/02/22-rdf-syntax-ns#" + xmlns:em="http://www.mozilla.org/2004/em-rdf#"> + + <Description about="urn:mozilla:install-manifest"> + <em:id>formautofill@mozilla.org</em:id> + <em:version>1.0</em:version> + <em:type>2</em:type> + <em:bootstrap>true</em:bootstrap> + <em:multiprocessCompatible>true</em:multiprocessCompatible> + + <!-- Target Application this extension can install into, + with minimum and maximum supported versions. --> + <em:targetApplication> + <Description> + <em:id>{ec8030f7-c20a-464f-9b0e-13a3a9e97384}</em:id> + <em:minVersion>@MOZ_APP_VERSION@</em:minVersion> + <em:maxVersion>@MOZ_APP_MAXVERSION@</em:maxVersion> + </Description> + </em:targetApplication> + + <!-- Front End MetaData --> + <em:name>Form Autofill</em:name> + <em:description>Autofill forms with saved profiles</em:description> + </Description> +</RDF> diff --git a/browser/extensions/formautofill/jar.mn b/browser/extensions/formautofill/jar.mn new file mode 100644 index 000000000..0cba721ef --- /dev/null +++ b/browser/extensions/formautofill/jar.mn @@ -0,0 +1,7 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +[features/formautofill@mozilla.org] chrome.jar: +% resource formautofill %content/ + content/ (content/*) diff --git a/browser/extensions/formautofill/moz.build b/browser/extensions/formautofill/moz.build new file mode 100644 index 000000000..92577db53 --- /dev/null +++ b/browser/extensions/formautofill/moz.build @@ -0,0 +1,22 @@ +# -*- 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['formautofill@mozilla.org'] += [ + 'bootstrap.js' +] + +FINAL_TARGET_PP_FILES.features['formautofill@mozilla.org'] += [ + 'install.rdf.in' +] + +BROWSER_CHROME_MANIFESTS += ['test/browser/browser.ini'] + +XPCSHELL_TESTS_MANIFESTS += ['test/unit/xpcshell.ini'] + +JAR_MANIFESTS += ['jar.mn'] diff --git a/browser/extensions/formautofill/test/browser/.eslintrc.js b/browser/extensions/formautofill/test/browser/.eslintrc.js new file mode 100644 index 000000000..52a2004c9 --- /dev/null +++ b/browser/extensions/formautofill/test/browser/.eslintrc.js @@ -0,0 +1,7 @@ +"use strict"; + +module.exports = { // eslint-disable-line no-undef + "extends": [ + "../../../../../testing/mochitest/browser.eslintrc.js", + ], +}; diff --git a/browser/extensions/formautofill/test/browser/browser.ini b/browser/extensions/formautofill/test/browser/browser.ini new file mode 100644 index 000000000..500224636 --- /dev/null +++ b/browser/extensions/formautofill/test/browser/browser.ini @@ -0,0 +1,3 @@ +[DEFAULT] + +[browser_check_installed.js] 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 000000000..b018c0f71 --- /dev/null +++ b/browser/extensions/formautofill/test/browser/browser_check_installed.js @@ -0,0 +1,14 @@ +"use strict"; + +add_task(function* test_enabled() { + let addon = yield new Promise( + resolve => AddonManager.getAddonByID("formautofill@mozilla.org", resolve) + ); + 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/unit/.eslintrc b/browser/extensions/formautofill/test/unit/.eslintrc new file mode 100644 index 000000000..8e33fb0c6 --- /dev/null +++ b/browser/extensions/formautofill/test/unit/.eslintrc @@ -0,0 +1,5 @@ +{ + "extends": [ + "../../../../../testing/xpcshell/xpcshell.eslintrc.js" + ], +} diff --git a/browser/extensions/formautofill/test/unit/head.js b/browser/extensions/formautofill/test/unit/head.js new file mode 100644 index 000000000..67e3bd60b --- /dev/null +++ b/browser/extensions/formautofill/test/unit/head.js @@ -0,0 +1,81 @@ +/** + * Provides infrastructure for automated login components tests. + */ + + /* exported importAutofillModule, getTempFile */ + +"use strict"; + +const {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components; + +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); +Cu.import("resource://gre/modules/Services.jsm"); +Cu.import("resource://testing-common/MockDocument.jsm"); + +// Redirect the path of the resouce in addon to the exact file path. +let defineLazyModuleGetter = XPCOMUtils.defineLazyModuleGetter; +XPCOMUtils.defineLazyModuleGetter = function() { + let result = /^resource\:\/\/formautofill\/(.+)$/.exec(arguments[2]); + if (result) { + arguments[2] = Services.io.newFileURI(do_get_file(result[1])).spec; + } + return defineLazyModuleGetter.apply(this, arguments); +}; + +// Load the module by Service newFileURI API for running extension's XPCShell test +function importAutofillModule(module) { + return Cu.import(Services.io.newFileURI(do_get_file(module)).spec); +} + +XPCOMUtils.defineLazyModuleGetter(this, "DownloadPaths", + "resource://gre/modules/DownloadPaths.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "FileUtils", + "resource://gre/modules/FileUtils.jsm"); + +// While the previous test file should have deleted all the temporary files it +// used, on Windows these might still be pending deletion on the physical file +// system. Thus, start from a new base number every time, to make a collision +// with a file that is still pending deletion highly unlikely. +let gFileCounter = Math.floor(Math.random() * 1000000); + +/** + * Returns a reference to a temporary file, that is guaranteed not to exist, and + * to have never been created before. + * + * @param {string} leafName + * Suggested leaf name for the file to be created. + * + * @returns {nsIFile} pointing to a non-existent file in a temporary directory. + * + * @note It is not enough to delete the file if it exists, or to delete the file + * after calling nsIFile.createUnique, because on Windows the delete + * operation in the file system may still be pending, preventing a new + * file with the same name to be created. + */ +function getTempFile(leafName) { + // Prepend a serial number to the extension in the suggested leaf name. + let [base, ext] = DownloadPaths.splitBaseNameAndExtension(leafName); + let finalLeafName = base + "-" + gFileCounter + ext; + gFileCounter++; + + // Get a file reference under the temporary directory for this test file. + let file = FileUtils.getFile("TmpD", [finalLeafName]); + do_check_false(file.exists()); + + do_register_cleanup(function() { + if (file.exists()) { + file.remove(false); + } + }); + + return file; +} + +add_task(function* test_common_initialize() { + Services.prefs.setBoolPref("dom.forms.autocomplete.experimental", true); + + // Clean up after every test. + do_register_cleanup(() => { + Services.prefs.setBoolPref("dom.forms.autocomplete.experimental", false); + }); +}); 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 000000000..a842e84e8 --- /dev/null +++ b/browser/extensions/formautofill/test/unit/test_autofillFormFields.js @@ -0,0 +1,200 @@ +/* + * Test for form auto fill content helper fill all inputs function. + */ + +"use strict"; + +let {FormAutofillHandler} = importAutofillModule("FormAutofillContent.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"><input id="country"> + <input id='email'><input id="tel"></form>`, + fieldDetails: [], + profileData: [], + expectedResult: { + "given-name": "", + "family-name": "", + "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"> + <input id="country" autocomplete="country"> + <input id="email" autocomplete="email"> + <input id="tel" autocomplete="tel"></form>`, + fieldDetails: [ + {"section": "", "addressType": "", "contactType": "", "fieldName": "given-name", "element": {}}, + {"section": "", "addressType": "", "contactType": "", "fieldName": "family-name", "element": {}}, + {"section": "", "addressType": "", "contactType": "", "fieldName": "street-address", "element": {}}, + {"section": "", "addressType": "", "contactType": "", "fieldName": "address-level2", "element": {}}, + {"section": "", "addressType": "", "contactType": "", "fieldName": "country", "element": {}}, + {"section": "", "addressType": "", "contactType": "", "fieldName": "email", "element": {}}, + {"section": "", "addressType": "", "contactType": "", "fieldName": "tel", "element": {}}, + ], + profileData: [ + {"section": "", "addressType": "", "fieldName": "given-name", "contactType": "", "index": 0, "value": "foo"}, + {"section": "", "addressType": "", "fieldName": "family-name", "contactType": "", "index": 1, "value": "bar"}, + {"section": "", "addressType": "", "fieldName": "street-address", "contactType": "", "index": 2, "value": "2 Harrison St"}, + {"section": "", "addressType": "", "fieldName": "address-level2", "contactType": "", "index": 3, "value": "San Francisco"}, + {"section": "", "addressType": "", "fieldName": "country", "contactType": "", "index": 4, "value": "US"}, + {"section": "", "addressType": "", "fieldName": "email", "contactType": "", "index": 5, "value": "foo@mozilla.com"}, + {"section": "", "addressType": "", "fieldName": "tel", "contactType": "", "index": 6, "value": "1234567"}, + ], + expectedResult: { + "given-name": "foo", + "family-name": "bar", + "street-addr": "2 Harrison St", + "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"> + <input id="country" autocomplete="shipping country"> + <input id='email' autocomplete="shipping email"> + <input id="tel" autocomplete="shipping tel"></form>`, + fieldDetails: [ + {"section": "", "addressType": "shipping", "contactType": "", "fieldName": "given-name", "element": {}}, + {"section": "", "addressType": "shipping", "contactType": "", "fieldName": "family-name", "element": {}}, + {"section": "", "addressType": "shipping", "contactType": "", "fieldName": "street-address", "element": {}}, + {"section": "", "addressType": "shipping", "contactType": "", "fieldName": "address-level2", "element": {}}, + {"section": "", "addressType": "shipping", "contactType": "", "fieldName": "country", "element": {}}, + {"section": "", "addressType": "shipping", "contactType": "", "fieldName": "email", "element": {}}, + {"section": "", "addressType": "shipping", "contactType": "", "fieldName": "tel", "element": {}}, + ], + profileData: [ + {"section": "", "addressType": "shipping", "fieldName": "given-name", "contactType": "", "index": 0, "value": "foo"}, + {"section": "", "addressType": "shipping", "fieldName": "family-name", "contactType": "", "index": 1, "value": "bar"}, + {"section": "", "addressType": "shipping", "fieldName": "street-address", "contactType": "", "index": 2, "value": "2 Harrison St"}, + {"section": "", "addressType": "shipping", "fieldName": "address-level2", "contactType": "", "index": 3, "value": "San Francisco"}, + {"section": "", "addressType": "shipping", "fieldName": "country", "contactType": "", "index": 4, "value": "US"}, + {"section": "", "addressType": "shipping", "fieldName": "email", "contactType": "", "index": 5, "value": "foo@mozilla.com"}, + {"section": "", "addressType": "shipping", "fieldName": "tel", "contactType": "", "index": 6, "value": "1234567"}, + ], + expectedResult: { + "given-name": "foo", + "family-name": "bar", + "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>`, + fieldDetails: [ + {"section": "", "addressType": "shipping", "contactType": "", "fieldName": "given-name", "element": {}}, + {"section": "", "addressType": "shipping", "contactType": "", "fieldName": "family-name", "element": {}}, + {"section": "", "addressType": "shipping", "contactType": "", "fieldName": "street-address", "element": {}}, + {"section": "", "addressType": "shipping", "contactType": "", "fieldName": "address-level2", "element": {}}, + {"section": "", "addressType": "shipping", "contactType": "", "fieldName": "country", "element": {}}, + {"section": "", "addressType": "shipping", "contactType": "", "fieldName": "email", "element": {}}, + {"section": "", "addressType": "shipping", "contactType": "", "fieldName": "tel", "element": {}}, + ], + profileData: [ + {"section": "", "addressType": "shipping", "fieldName": "given-name", "contactType": "", "index": 0, "value": "foo"}, + {"section": "", "addressType": "shipping", "fieldName": "family-name", "contactType": "", "index": 1, "value": "bar"}, + {"section": "", "addressType": "shipping", "fieldName": "street-address", "contactType": "", "index": 2, "value": "2 Harrison St"}, + {"section": "", "addressType": "shipping", "fieldName": "address-level2", "contactType": "", "index": 3, "value": "San Francisco"}, + {"section": "", "addressType": "shipping", "fieldName": "country", "contactType": "", "index": 4, "value": "US"}, + {"section": "", "addressType": "shipping", "fieldName": "email", "contactType": "", "index": 5}, + {"section": "", "addressType": "shipping", "fieldName": "tel", "contactType": "", "index": 6}, + ], + expectedResult: { + "given-name": "foo", + "family-name": "bar", + "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>`, + fieldDetails: [ + {"section": "", "addressType": "shipping", "contactType": "", "fieldName": "given-name", "element": {}}, + {"section": "", "addressType": "shipping", "contactType": "", "fieldName": "family-name", "element": {}}, + {"section": "", "addressType": "shipping", "contactType": "", "fieldName": "street-address", "element": {}}, + {"section": "", "addressType": "shipping", "contactType": "", "fieldName": "address-level2", "element": {}}, + {"section": "", "addressType": "shipping", "contactType": "", "fieldName": "country", "element": {}}, + {"section": "", "addressType": "shipping", "contactType": "", "fieldName": "email", "element": {}}, + {"section": "", "addressType": "shipping", "contactType": "", "fieldName": "tel", "element": {}}, + ], + profileData: [ + {"section": "", "addressType": "shipping", "fieldName": "given-name", "contactType": "", "index": 0, "value": "foo"}, + {"section": "", "addressType": "shipping", "fieldName": "family-name", "contactType": "", "index": 1, "value": "bar"}, + {"section": "", "addressType": "shipping", "fieldName": "street-address", "contactType": "", "index": 2, "value": "2 Harrison St"}, + {"section": "", "addressType": "shipping", "fieldName": "address-level2", "contactType": "", "index": 3, "value": "San Francisco"}, + {"section": "", "addressType": "shipping", "fieldName": "country", "contactType": "", "index": 4, "value": "US"}, + {"section": "", "addressType": "shipping", "fieldName": "email", "contactType": "", "index": 5, "value": "foo@mozilla.com"}, + {"section": "", "addressType": "shipping", "fieldName": "tel", "contactType": "", "index": 6, "value": "1234567"}, + ], + expectedResult: { + "given-name": "foo", + "family-name": "bar", + "street-addr": "", + "city": "", + "country": "", + "email": "foo@mozilla.com", + "tel": "1234567", + }, + }, +]; + +for (let tc of TESTCASES) { + (function() { + let testcase = tc; + add_task(function* () { + do_print("Starting testcase: " + testcase.description); + + let doc = MockDocument.createTestDocument("http://localhost:8080/test/", + testcase.document); + let form = doc.querySelector("form"); + let handler = new FormAutofillHandler(form); + + handler.fieldDetails = testcase.fieldDetails; + handler.fieldDetails.forEach((field, index) => { + field.element = doc.querySelectorAll("input")[index]; + }); + + handler.autofillFormFields(testcase.profileData); + for (let id in testcase.expectedResult) { + Assert.equal(doc.getElementById(id).value, testcase.expectedResult[id], + "Check the " + id + " fields were filled with correct data"); + } + }); + })(); +} 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 000000000..52549b746 --- /dev/null +++ b/browser/extensions/formautofill/test/unit/test_collectFormFields.js @@ -0,0 +1,122 @@ +/* + * Test for form auto fill content helper collectFormFields functions. + */ + +"use strict"; + +let {FormAutofillHandler} = importAutofillModule("FormAutofillContent.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"><input id="country"> + <input id='email'><input id="tel"></form>`, + returnedFormat: [], + fieldDetails: [], + }, + { + 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"> + <input id="country" autocomplete="country"> + <input id="email" autocomplete="email"> + <input id="tel" autocomplete="tel"></form>`, + returnedFormat: [ + {"section": "", "addressType": "", "contactType": "", "fieldName": "given-name", "index": 0}, + {"section": "", "addressType": "", "contactType": "", "fieldName": "family-name", "index": 1}, + {"section": "", "addressType": "", "contactType": "", "fieldName": "street-address", "index": 2}, + {"section": "", "addressType": "", "contactType": "", "fieldName": "address-level2", "index": 3}, + {"section": "", "addressType": "", "contactType": "", "fieldName": "country", "index": 4}, + {"section": "", "addressType": "", "contactType": "", "fieldName": "email", "index": 5}, + {"section": "", "addressType": "", "contactType": "", "fieldName": "tel", "index": 6}, + ], + fieldDetails: [ + {"section": "", "addressType": "", "contactType": "", "fieldName": "given-name", "element": {}}, + {"section": "", "addressType": "", "contactType": "", "fieldName": "family-name", "element": {}}, + {"section": "", "addressType": "", "contactType": "", "fieldName": "street-address", "element": {}}, + {"section": "", "addressType": "", "contactType": "", "fieldName": "address-level2", "element": {}}, + {"section": "", "addressType": "", "contactType": "", "fieldName": "country", "element": {}}, + {"section": "", "addressType": "", "contactType": "", "fieldName": "email", "element": {}}, + {"section": "", "addressType": "", "contactType": "", "fieldName": "tel", "element": {}}, + ], + }, + { + 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"> + <input id="country" autocomplete="shipping country"> + <input id='email' autocomplete="shipping email"> + <input id="tel" autocomplete="shipping tel"></form>`, + returnedFormat: [ + {"section": "", "addressType": "shipping", "contactType": "", "fieldName": "given-name", "index": 0}, + {"section": "", "addressType": "shipping", "contactType": "", "fieldName": "family-name", "index": 1}, + {"section": "", "addressType": "shipping", "contactType": "", "fieldName": "street-address", "index": 2}, + {"section": "", "addressType": "shipping", "contactType": "", "fieldName": "address-level2", "index": 3}, + {"section": "", "addressType": "shipping", "contactType": "", "fieldName": "country", "index": 4}, + {"section": "", "addressType": "shipping", "contactType": "", "fieldName": "email", "index": 5}, + {"section": "", "addressType": "shipping", "contactType": "", "fieldName": "tel", "index": 6}, + ], + fieldDetails: [ + {"section": "", "addressType": "shipping", "contactType": "", "fieldName": "given-name", "element": {}}, + {"section": "", "addressType": "shipping", "contactType": "", "fieldName": "family-name", "element": {}}, + {"section": "", "addressType": "shipping", "contactType": "", "fieldName": "street-address", "element": {}}, + {"section": "", "addressType": "shipping", "contactType": "", "fieldName": "address-level2", "element": {}}, + {"section": "", "addressType": "shipping", "contactType": "", "fieldName": "country", "element": {}}, + {"section": "", "addressType": "shipping", "contactType": "", "fieldName": "email", "element": {}}, + {"section": "", "addressType": "shipping", "contactType": "", "fieldName": "tel", "element": {}}, + ], + }, + { + 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>`, + returnedFormat: [ + {"section": "", "addressType": "shipping", "contactType": "", "fieldName": "given-name", "index": 0}, + {"section": "", "addressType": "shipping", "contactType": "", "fieldName": "family-name", "index": 1}, + {"section": "", "addressType": "shipping", "contactType": "", "fieldName": "street-address", "index": 2}, + {"section": "", "addressType": "shipping", "contactType": "", "fieldName": "address-level2", "index": 3}, + {"section": "", "addressType": "shipping", "contactType": "", "fieldName": "country", "index": 4}, + {"section": "", "addressType": "shipping", "contactType": "", "fieldName": "email", "index": 5}, + {"section": "", "addressType": "shipping", "contactType": "", "fieldName": "tel", "index": 6}, + ], + fieldDetails: [ + {"section": "", "addressType": "shipping", "contactType": "", "fieldName": "given-name", "element": {}}, + {"section": "", "addressType": "shipping", "contactType": "", "fieldName": "family-name", "element": {}}, + {"section": "", "addressType": "shipping", "contactType": "", "fieldName": "street-address", "element": {}}, + {"section": "", "addressType": "shipping", "contactType": "", "fieldName": "address-level2", "element": {}}, + {"section": "", "addressType": "shipping", "contactType": "", "fieldName": "country", "element": {}}, + {"section": "", "addressType": "shipping", "contactType": "", "fieldName": "email", "element": {}}, + {"section": "", "addressType": "shipping", "contactType": "", "fieldName": "tel", "element": {}}, + ], + }, +]; + +for (let tc of TESTCASES) { + (function() { + let testcase = tc; + add_task(function* () { + do_print("Starting testcase: " + testcase.description); + + let doc = MockDocument.createTestDocument("http://localhost:8080/test/", + testcase.document); + let form = doc.querySelector("form"); + let handler = new FormAutofillHandler(form); + + Assert.deepEqual(handler.collectFormFields(), testcase.returnedFormat, + "Check the format of form autofill were returned correctly"); + + Assert.deepEqual(handler.fieldDetails, testcase.fieldDetails, + "Check the fieldDetails were set correctly"); + }); + })(); +} diff --git a/browser/extensions/formautofill/test/unit/test_populateFieldValues.js b/browser/extensions/formautofill/test/unit/test_populateFieldValues.js new file mode 100644 index 000000000..1215cbd16 --- /dev/null +++ b/browser/extensions/formautofill/test/unit/test_populateFieldValues.js @@ -0,0 +1,106 @@ +/* + * Test for populating field values in Form Autofill Parent. + */ + +/* global FormAutofillParent */ + +"use strict"; + +importAutofillModule("FormAutofillParent.jsm"); + +do_get_profile(); + +const TEST_FIELDS = [ + {"section": "", "addressType": "shipping", "contactType": "", "fieldName": "organization"}, + {"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": "shipping", "contactType": "", "fieldName": "tel"}, + {"section": "", "addressType": "shipping", "contactType": "", "fieldName": "email"}, +]; + +const TEST_GUID = "test-guid"; + +const TEST_PROFILE = { + guid: TEST_GUID, + organization: "World Wide Web Consortium", + streetAddress: "32 Vassar Street\nMIT Room 32-G524", + addressLevel2: "Cambridge", + addressLevel1: "MA", + postalCode: "02139", + country: "US", + tel: "+1 617 253 5702", + email: "timbl@w3.org", +}; + +function camelCase(str) { + return str.toLowerCase().replace(/-([a-z])/g, s => s[1].toUpperCase()); +} + +add_task(function* test_populateFieldValues() { + FormAutofillParent.init(); + + let store = FormAutofillParent.getProfileStore(); + do_check_neq(store, null); + + store.get = function(guid) { + do_check_eq(guid, TEST_GUID); + return store._clone(TEST_PROFILE); + }; + + let notifyUsedCalledCount = 0; + store.notifyUsed = function(guid) { + do_check_eq(guid, TEST_GUID); + notifyUsedCalledCount++; + }; + + yield new Promise((resolve) => { + FormAutofillParent.receiveMessage({ + name: "FormAutofill:PopulateFieldValues", + data: { + guid: TEST_GUID, + fields: TEST_FIELDS, + }, + target: { + sendAsyncMessage: function(name, data) { + do_check_eq(name, "FormAutofill:fillForm"); + + let fields = data.fields; + do_check_eq(fields.length, TEST_FIELDS.length); + + for (let i = 0; i < fields.length; i++) { + do_check_eq(fields[i].fieldName, TEST_FIELDS[i].fieldName); + do_check_eq(fields[i].value, + TEST_PROFILE[camelCase(fields[i].fieldName)]); + } + + resolve(); + }, + }, + }); + }); + + do_check_eq(notifyUsedCalledCount, 1); + + FormAutofillParent._uninit(); + do_check_null(FormAutofillParent.getProfileStore()); +}); + +add_task(function* test_populateFieldValues_with_invalid_guid() { + FormAutofillParent.init(); + + Assert.throws(() => { + FormAutofillParent.receiveMessage({ + name: "FormAutofill:PopulateFieldValues", + data: { + guid: "invalid-guid", + fields: TEST_FIELDS, + }, + target: {}, + }); + }, /No matching profile\./); + + FormAutofillParent._uninit(); +}); diff --git a/browser/extensions/formautofill/test/unit/test_profileStorage.js b/browser/extensions/formautofill/test/unit/test_profileStorage.js new file mode 100644 index 000000000..018adedb8 --- /dev/null +++ b/browser/extensions/formautofill/test/unit/test_profileStorage.js @@ -0,0 +1,222 @@ +/** + * Tests ProfileStorage object. + */ + +/* global ProfileStorage */ + +"use strict"; + +Cu.import("resource://gre/modules/Task.jsm"); +Cu.import(Services.io.newFileURI(do_get_file("ProfileStorage.jsm")).spec); + +const TEST_STORE_FILE_NAME = "test-profile.json"; + +const TEST_PROFILE_1 = { + organization: "World Wide Web Consortium", + streetAddress: "32 Vassar Street\nMIT Room 32-G524", + addressLevel2: "Cambridge", + addressLevel1: "MA", + postalCode: "02139", + country: "US", + tel: "+1 617 253 5702", + email: "timbl@w3.org", +}; + +const TEST_PROFILE_2 = { + streetAddress: "Some Address", + country: "US", +}; + +const TEST_PROFILE_3 = { + streetAddress: "Other Address", + postalCode: "12345", +}; + +const TEST_PROFILE_WITH_INVALID_FIELD = { + streetAddress: "Another Address", + invalidField: "INVALID", +}; + +let prepareTestProfiles = Task.async(function* (path) { + let profileStorage = new ProfileStorage(path); + yield profileStorage.initialize(); + + profileStorage.add(TEST_PROFILE_1); + profileStorage.add(TEST_PROFILE_2); + yield profileStorage._saveImmediately(); +}); + +let do_check_profile_matches = (profileWithMeta, profile) => { + for (let key in profile) { + do_check_eq(profileWithMeta[key], profile[key]); + } +}; + +add_task(function* test_initialize() { + let path = getTempFile(TEST_STORE_FILE_NAME).path; + let profileStorage = new ProfileStorage(path); + yield profileStorage.initialize(); + + do_check_eq(profileStorage._store.data.version, 1); + do_check_eq(profileStorage._store.data.profiles.length, 0); + + let data = profileStorage._store.data; + + yield profileStorage._saveImmediately(); + + profileStorage = new ProfileStorage(path); + yield profileStorage.initialize(); + + Assert.deepEqual(profileStorage._store.data, data); +}); + +add_task(function* test_getAll() { + let path = getTempFile(TEST_STORE_FILE_NAME).path; + yield prepareTestProfiles(path); + + let profileStorage = new ProfileStorage(path); + yield profileStorage.initialize(); + + let profiles = profileStorage.getAll(); + + do_check_eq(profiles.length, 2); + do_check_profile_matches(profiles[0], TEST_PROFILE_1); + do_check_profile_matches(profiles[1], TEST_PROFILE_2); + + // Modifying output shouldn't affect the storage. + profiles[0].organization = "test"; + do_check_profile_matches(profileStorage.getAll()[0], TEST_PROFILE_1); +}); + +add_task(function* test_get() { + let path = getTempFile(TEST_STORE_FILE_NAME).path; + yield prepareTestProfiles(path); + + let profileStorage = new ProfileStorage(path); + yield profileStorage.initialize(); + + let profiles = profileStorage.getAll(); + let guid = profiles[0].guid; + + let profile = profileStorage.get(guid); + do_check_profile_matches(profile, TEST_PROFILE_1); + + // Modifying output shouldn't affect the storage. + profile.organization = "test"; + do_check_profile_matches(profileStorage.get(guid), TEST_PROFILE_1); + + Assert.throws(() => profileStorage.get("INVALID_GUID"), + /No matching profile\./); +}); + +add_task(function* test_add() { + let path = getTempFile(TEST_STORE_FILE_NAME).path; + yield prepareTestProfiles(path); + + let profileStorage = new ProfileStorage(path); + yield profileStorage.initialize(); + + let profiles = profileStorage.getAll(); + + do_check_eq(profiles.length, 2); + + do_check_profile_matches(profiles[0], TEST_PROFILE_1); + do_check_profile_matches(profiles[1], TEST_PROFILE_2); + + do_check_neq(profiles[0].guid, undefined); + do_check_neq(profiles[0].timeCreated, undefined); + do_check_eq(profiles[0].timeLastModified, profiles[0].timeCreated); + do_check_eq(profiles[0].timeLastUsed, 0); + do_check_eq(profiles[0].timesUsed, 0); + + Assert.throws(() => profileStorage.add(TEST_PROFILE_WITH_INVALID_FIELD), + /"invalidField" is not a valid field\./); +}); + +add_task(function* test_update() { + let path = getTempFile(TEST_STORE_FILE_NAME).path; + yield prepareTestProfiles(path); + + let profileStorage = new ProfileStorage(path); + yield profileStorage.initialize(); + + let profiles = profileStorage.getAll(); + let guid = profiles[1].guid; + let timeLastModified = profiles[1].timeLastModified; + + do_check_neq(profiles[1].country, undefined); + + profileStorage.update(guid, TEST_PROFILE_3); + yield profileStorage._saveImmediately(); + + profileStorage = new ProfileStorage(path); + yield profileStorage.initialize(); + + let profile = profileStorage.get(guid); + + do_check_eq(profile.country, undefined); + do_check_neq(profile.timeLastModified, timeLastModified); + do_check_profile_matches(profile, TEST_PROFILE_3); + + Assert.throws( + () => profileStorage.update("INVALID_GUID", TEST_PROFILE_3), + /No matching profile\./ + ); + + Assert.throws( + () => profileStorage.update(guid, TEST_PROFILE_WITH_INVALID_FIELD), + /"invalidField" is not a valid field\./ + ); +}); + +add_task(function* test_notifyUsed() { + let path = getTempFile(TEST_STORE_FILE_NAME).path; + yield prepareTestProfiles(path); + + let profileStorage = new ProfileStorage(path); + yield profileStorage.initialize(); + + let profiles = profileStorage.getAll(); + let guid = profiles[1].guid; + let timeLastUsed = profiles[1].timeLastUsed; + let timesUsed = profiles[1].timesUsed; + + profileStorage.notifyUsed(guid); + yield profileStorage._saveImmediately(); + + profileStorage = new ProfileStorage(path); + yield profileStorage.initialize(); + + let profile = profileStorage.get(guid); + + do_check_eq(profile.timesUsed, timesUsed + 1); + do_check_neq(profile.timeLastUsed, timeLastUsed); + + Assert.throws(() => profileStorage.notifyUsed("INVALID_GUID"), + /No matching profile\./); +}); + +add_task(function* test_remove() { + let path = getTempFile(TEST_STORE_FILE_NAME).path; + yield prepareTestProfiles(path); + + let profileStorage = new ProfileStorage(path); + yield profileStorage.initialize(); + + let profiles = profileStorage.getAll(); + let guid = profiles[1].guid; + + do_check_eq(profiles.length, 2); + + profileStorage.remove(guid); + yield profileStorage._saveImmediately(); + + profileStorage = new ProfileStorage(path); + yield profileStorage.initialize(); + + profiles = profileStorage.getAll(); + + do_check_eq(profiles.length, 1); + + Assert.throws(() => profileStorage.get(guid), /No matching profile\./); +}); diff --git a/browser/extensions/formautofill/test/unit/xpcshell.ini b/browser/extensions/formautofill/test/unit/xpcshell.ini new file mode 100644 index 000000000..2c5763681 --- /dev/null +++ b/browser/extensions/formautofill/test/unit/xpcshell.ini @@ -0,0 +1,12 @@ +[DEFAULT] +head = head.js +tail = +support-files = + ../../content/FormAutofillContent.jsm + ../../content/FormAutofillParent.jsm + ../../content/ProfileStorage.jsm + +[test_autofillFormFields.js] +[test_collectFormFields.js] +[test_populateFieldValues.js] +[test_profileStorage.js] |