summaryrefslogtreecommitdiffstats
path: root/browser/extensions/formautofill/test/unit
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-07 17:32:43 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-07 17:32:43 +0000
commit6bf0a5cb5034a7e684dcc3500e841785237ce2dd (patch)
treea68f146d7fa01f0134297619fbe7e33db084e0aa /browser/extensions/formautofill/test/unit
parentInitial commit. (diff)
downloadthunderbird-upstream.tar.xz
thunderbird-upstream.zip
Adding upstream version 1:115.7.0.upstream/1%115.7.0upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'browser/extensions/formautofill/test/unit')
-rw-r--r--browser/extensions/formautofill/test/unit/head.js357
-rw-r--r--browser/extensions/formautofill/test/unit/head_addressComponent.js69
-rw-r--r--browser/extensions/formautofill/test/unit/test_activeStatus.js176
-rw-r--r--browser/extensions/formautofill/test/unit/test_addressComponent_city.js27
-rw-r--r--browser/extensions/formautofill/test/unit/test_addressComponent_country.js47
-rw-r--r--browser/extensions/formautofill/test/unit/test_addressComponent_email.js74
-rw-r--r--browser/extensions/formautofill/test/unit/test_addressComponent_name.js101
-rw-r--r--browser/extensions/formautofill/test/unit/test_addressComponent_organization.js55
-rw-r--r--browser/extensions/formautofill/test/unit/test_addressComponent_postal_code.js57
-rw-r--r--browser/extensions/formautofill/test/unit/test_addressComponent_state.js32
-rw-r--r--browser/extensions/formautofill/test/unit/test_addressComponent_street_address.js56
-rw-r--r--browser/extensions/formautofill/test/unit/test_addressComponent_tel.js76
-rw-r--r--browser/extensions/formautofill/test/unit/test_addressDataLoader.js102
-rw-r--r--browser/extensions/formautofill/test/unit/test_addressRecords.js858
-rw-r--r--browser/extensions/formautofill/test/unit/test_autofillFormFields.js1078
-rw-r--r--browser/extensions/formautofill/test/unit/test_clearPopulatedForm.js116
-rw-r--r--browser/extensions/formautofill/test/unit/test_collectFormFields.js638
-rw-r--r--browser/extensions/formautofill/test/unit/test_createRecords.js525
-rw-r--r--browser/extensions/formautofill/test/unit/test_creditCardRecords.js926
-rw-r--r--browser/extensions/formautofill/test/unit/test_extractLabelStrings.js77
-rw-r--r--browser/extensions/formautofill/test/unit/test_findLabelElements.js100
-rw-r--r--browser/extensions/formautofill/test/unit/test_getAdaptedProfiles.js1300
-rw-r--r--browser/extensions/formautofill/test/unit/test_getAdaptedProfiles_locales.js272
-rw-r--r--browser/extensions/formautofill/test/unit/test_getCategoriesFromFieldNames.js95
-rw-r--r--browser/extensions/formautofill/test/unit/test_getCreditCardLogo.js25
-rw-r--r--browser/extensions/formautofill/test/unit/test_getFormInputDetails.js204
-rw-r--r--browser/extensions/formautofill/test/unit/test_getInfo.js363
-rw-r--r--browser/extensions/formautofill/test/unit/test_getRecords.js258
-rw-r--r--browser/extensions/formautofill/test/unit/test_isAddressAutofillAvailable.js74
-rw-r--r--browser/extensions/formautofill/test/unit/test_isCJKName.js80
-rw-r--r--browser/extensions/formautofill/test/unit/test_isCreditCardAutofillAvailable.js84
-rw-r--r--browser/extensions/formautofill/test/unit/test_isCreditCardOrAddressFieldType.js103
-rw-r--r--browser/extensions/formautofill/test/unit/test_known_strings.js148
-rw-r--r--browser/extensions/formautofill/test/unit/test_markAsAutofillField.js201
-rw-r--r--browser/extensions/formautofill/test/unit/test_migrateRecords.js382
-rw-r--r--browser/extensions/formautofill/test/unit/test_nameUtils.js289
-rw-r--r--browser/extensions/formautofill/test/unit/test_onFormSubmitted.js805
-rw-r--r--browser/extensions/formautofill/test/unit/test_parseAddressFormat.js66
-rw-r--r--browser/extensions/formautofill/test/unit/test_parseStreetAddress.js74
-rw-r--r--browser/extensions/formautofill/test/unit/test_phoneNumber.js399
-rw-r--r--browser/extensions/formautofill/test/unit/test_previewFormFields.js199
-rw-r--r--browser/extensions/formautofill/test/unit/test_profileAutocompleteResult.js450
-rw-r--r--browser/extensions/formautofill/test/unit/test_reconcile.js1173
-rw-r--r--browser/extensions/formautofill/test/unit/test_savedFieldNames.js106
-rw-r--r--browser/extensions/formautofill/test/unit/test_storage_remove.js88
-rw-r--r--browser/extensions/formautofill/test/unit/test_storage_syncfields.js498
-rw-r--r--browser/extensions/formautofill/test/unit/test_storage_tombstones.js190
-rw-r--r--browser/extensions/formautofill/test/unit/test_sync.js1017
-rw-r--r--browser/extensions/formautofill/test/unit/test_sync_deprecate_credit_card_v4.js248
-rw-r--r--browser/extensions/formautofill/test/unit/test_toOneLineAddress.js64
-rw-r--r--browser/extensions/formautofill/test/unit/test_transformFields.js972
-rw-r--r--browser/extensions/formautofill/test/unit/xpcshell.ini100
52 files changed, 15874 insertions, 0 deletions
diff --git a/browser/extensions/formautofill/test/unit/head.js b/browser/extensions/formautofill/test/unit/head.js
new file mode 100644
index 0000000000..e5353833ef
--- /dev/null
+++ b/browser/extensions/formautofill/test/unit/head.js
@@ -0,0 +1,357 @@
+/**
+ * Provides infrastructure for automated formautofill components tests.
+ */
+
+"use strict";
+
+var { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+var { ObjectUtils } = ChromeUtils.import(
+ "resource://gre/modules/ObjectUtils.jsm"
+);
+var { FormLikeFactory } = ChromeUtils.importESModule(
+ "resource://gre/modules/FormLikeFactory.sys.mjs"
+);
+var { FormAutofillHandler } = ChromeUtils.importESModule(
+ "resource://gre/modules/shared/FormAutofillHandler.sys.mjs"
+);
+var { AddonTestUtils, MockAsyncShutdown } = ChromeUtils.importESModule(
+ "resource://testing-common/AddonTestUtils.sys.mjs"
+);
+var { ExtensionTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/ExtensionXPCShellUtils.sys.mjs"
+);
+var { FileTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/FileTestUtils.sys.mjs"
+);
+var { MockDocument } = ChromeUtils.importESModule(
+ "resource://testing-common/MockDocument.sys.mjs"
+);
+var { sinon } = ChromeUtils.importESModule(
+ "resource://testing-common/Sinon.sys.mjs"
+);
+var { TestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/TestUtils.sys.mjs"
+);
+
+ChromeUtils.defineESModuleGetters(this, {
+ AddonManager: "resource://gre/modules/AddonManager.sys.mjs",
+ AddonManagerPrivate: "resource://gre/modules/AddonManager.sys.mjs",
+ ExtensionParent: "resource://gre/modules/ExtensionParent.sys.mjs",
+ FileUtils: "resource://gre/modules/FileUtils.sys.mjs",
+});
+
+{
+ // We're going to register a mock file source
+ // with region names based on en-US. This is
+ // necessary for tests that expect to match
+ // on region code display names.
+ const fs = [
+ {
+ path: "toolkit/intl/regionNames.ftl",
+ source: `
+region-name-us = United States
+region-name-nz = New Zealand
+region-name-au = Australia
+region-name-ca = Canada
+region-name-tw = Taiwan
+ `,
+ },
+ ];
+
+ let locales = Services.locale.packagedLocales;
+ const mockSource = L10nFileSource.createMock(
+ "mock",
+ "app",
+ locales,
+ "resource://mock_path",
+ fs
+ );
+ L10nRegistry.getInstance().registerSources([mockSource]);
+}
+
+do_get_profile();
+
+const EXTENSION_ID = "formautofill@mozilla.org";
+
+AddonTestUtils.init(this);
+AddonTestUtils.overrideCertDB();
+
+function SetPref(name, value) {
+ switch (typeof value) {
+ case "string":
+ Services.prefs.setCharPref(name, value);
+ break;
+ case "number":
+ Services.prefs.setIntPref(name, value);
+ break;
+ case "boolean":
+ Services.prefs.setBoolPref(name, value);
+ break;
+ default:
+ throw new Error("Unknown type");
+ }
+}
+
+// Return the current date rounded in the manner that sync does.
+function getDateForSync() {
+ return Math.round(Date.now() / 10) / 100;
+}
+
+async function loadExtension() {
+ AddonTestUtils.createAppInfo(
+ "xpcshell@tests.mozilla.org",
+ "XPCShell",
+ "1",
+ "1.9.2"
+ );
+ await AddonTestUtils.promiseStartupManager();
+
+ let extensionPath = Services.dirsvc.get("GreD", Ci.nsIFile);
+ extensionPath.append("browser");
+ extensionPath.append("features");
+ extensionPath.append(EXTENSION_ID);
+
+ if (!extensionPath.exists()) {
+ extensionPath.leafName = `${EXTENSION_ID}.xpi`;
+ }
+
+ let startupPromise = new Promise(resolve => {
+ const { apiManager } = ExtensionParent;
+ function onReady(event, extension) {
+ if (extension.id == EXTENSION_ID) {
+ apiManager.off("ready", onReady);
+ resolve();
+ }
+ }
+
+ apiManager.on("ready", onReady);
+ });
+
+ await AddonManager.installTemporaryAddon(extensionPath);
+ await startupPromise;
+}
+
+// Returns a reference to a temporary file that is guaranteed not to exist and
+// is cleaned up later. See FileTestUtils.getTempFile for details.
+function getTempFile(leafName) {
+ return FileTestUtils.getTempFile(leafName);
+}
+
+async function initProfileStorage(
+ fileName,
+ records,
+ collectionName = "addresses"
+) {
+ let { FormAutofillStorage } = ChromeUtils.importESModule(
+ "resource://autofill/FormAutofillStorage.sys.mjs"
+ );
+ let path = getTempFile(fileName).path;
+ let profileStorage = new FormAutofillStorage(path);
+ await profileStorage.initialize();
+
+ // AddonTestUtils inserts its own directory provider that manages TmpD.
+ // It removes that directory at shutdown, which races with shutdown
+ // handing in JSONFile/DeferredTask (which is used by FormAutofillStorage).
+ // Avoid the race by explicitly finalizing any formautofill JSONFile
+ // instances created manually by individual tests when the test finishes.
+ registerCleanupFunction(function finalizeAutofillStorage() {
+ return profileStorage._finalize();
+ });
+
+ if (!records || !Array.isArray(records)) {
+ return profileStorage;
+ }
+
+ let onChanged = TestUtils.topicObserved(
+ "formautofill-storage-changed",
+ (subject, data) =>
+ data == "add" && subject.wrappedJSObject.collectionName == collectionName
+ );
+ for (let record of records) {
+ Assert.ok(await profileStorage[collectionName].add(record));
+ await onChanged;
+ }
+ await profileStorage._saveImmediately();
+ return profileStorage;
+}
+
+function verifySectionAutofillResult(sections, expectedSectionsInfo) {
+ sections.forEach((section, index) => {
+ const expectedSection = expectedSectionsInfo[index];
+
+ const fieldDetails = section.fieldDetails;
+ const expectedFieldDetails = expectedSection.fields;
+
+ info(`verify autofill section[${index}]`);
+
+ fieldDetails.forEach((field, fieldIndex) => {
+ const expeceted = expectedFieldDetails[fieldIndex];
+
+ Assert.equal(
+ expeceted.autofill,
+ field.element.value,
+ `Autofilled value for element(id=${field.element.id}, field name=${field.fieldName}) should be equal`
+ );
+ });
+ });
+}
+
+function verifySectionFieldDetails(sections, expectedSectionsInfo) {
+ sections.forEach((section, index) => {
+ const expectedSection = expectedSectionsInfo[index];
+
+ const fieldDetails = section.fieldDetails;
+ const expectedFieldDetails = expectedSection.fields;
+
+ info(`section[${index}] ${expectedSection.description ?? ""}:`);
+ info(`FieldName Prediction Results: ${fieldDetails.map(i => i.fieldName)}`);
+ info(
+ `FieldName Expected Results: ${expectedFieldDetails.map(
+ detail => detail.fieldName
+ )}`
+ );
+ Assert.equal(
+ fieldDetails.length,
+ expectedFieldDetails.length,
+ `Expected field count.`
+ );
+
+ fieldDetails.forEach((field, fieldIndex) => {
+ const expectedFieldDetail = expectedFieldDetails[fieldIndex];
+
+ const expected = {
+ ...{
+ reason: "autocomplete",
+ section: "",
+ contactType: "",
+ addressType: "",
+ },
+ ...expectedSection.default,
+ ...expectedFieldDetail,
+ };
+
+ const keys = new Set([...Object.keys(field), ...Object.keys(expected)]);
+ ["autofill", "elementWeakRef", "confidence", "part"].forEach(k =>
+ keys.delete(k)
+ );
+
+ for (const key of keys) {
+ const expectedValue = expected[key];
+ const actualValue = field[key];
+ Assert.equal(
+ expectedValue,
+ actualValue,
+ `${key} should be equal, expect ${expectedValue}, got ${actualValue}`
+ );
+ }
+ });
+
+ Assert.equal(
+ section.isValidSection(),
+ !expectedSection.invalid,
+ `Should be an ${expectedSection.invalid ? "invalid" : "valid"} section`
+ );
+ });
+}
+
+var FormAutofillHeuristics, LabelUtils;
+var AddressDataLoader, FormAutofillUtils;
+
+function autofillFieldSelector(doc) {
+ return doc.querySelectorAll("input, select");
+}
+
+/**
+ * Returns the Sync change counter for a profile storage record. Synced records
+ * store additional metadata for tracking changes and resolving merge conflicts.
+ * Deleting a synced record replaces the record with a tombstone.
+ *
+ * @param {AutofillRecords} records
+ * The `AutofillRecords` instance to query.
+ * @param {string} guid
+ * The GUID of the record or tombstone.
+ * @returns {number}
+ * The change counter, or -1 if the record doesn't exist or hasn't
+ * been synced yet.
+ */
+function getSyncChangeCounter(records, guid) {
+ let record = records._findByGUID(guid, { includeDeleted: true });
+ if (!record) {
+ return -1;
+ }
+ let sync = records._getSyncMetaData(record);
+ if (!sync) {
+ return -1;
+ }
+ return sync.changeCounter;
+}
+
+/**
+ * Performs a partial deep equality check to determine if an object contains
+ * the given fields.
+ *
+ * @param {object} object
+ * The object to check. Unlike `ObjectUtils.deepEqual`, properties in
+ * `object` that are not in `fields` will be ignored.
+ * @param {object} fields
+ * The fields to match.
+ * @returns {boolean}
+ * Does `object` contain `fields` with matching values?
+ */
+function objectMatches(object, fields) {
+ let actual = {};
+ for (let key in fields) {
+ if (!object.hasOwnProperty(key)) {
+ return false;
+ }
+ actual[key] = object[key];
+ }
+ return ObjectUtils.deepEqual(actual, fields);
+}
+
+add_setup(async function head_initialize() {
+ Services.prefs.setBoolPref("extensions.experiments.enabled", true);
+ Services.prefs.setBoolPref("dom.forms.autocomplete.formautofill", true);
+
+ Services.prefs.setCharPref(
+ "extensions.formautofill.addresses.supported",
+ "on"
+ );
+ Services.prefs.setCharPref(
+ "extensions.formautofill.creditCards.supported",
+ "on"
+ );
+ Services.prefs.setBoolPref("extensions.formautofill.addresses.enabled", true);
+ Services.prefs.setBoolPref(
+ "extensions.formautofill.creditCards.enabled",
+ true
+ );
+
+ // Clean up after every test.
+ registerCleanupFunction(function head_cleanup() {
+ Services.prefs.clearUserPref("extensions.experiments.enabled");
+ Services.prefs.clearUserPref(
+ "extensions.formautofill.creditCards.supported"
+ );
+ Services.prefs.clearUserPref("extensions.formautofill.addresses.supported");
+ Services.prefs.clearUserPref("extensions.formautofill.creditCards.enabled");
+ Services.prefs.clearUserPref("dom.forms.autocomplete.formautofill");
+ Services.prefs.clearUserPref("extensions.formautofill.addresses.enabled");
+ Services.prefs.clearUserPref("extensions.formautofill.creditCards.enabled");
+ });
+
+ await loadExtension();
+});
+
+let OSKeyStoreTestUtils;
+add_setup(async function os_key_store_setup() {
+ ({ OSKeyStoreTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/OSKeyStoreTestUtils.sys.mjs"
+ ));
+ OSKeyStoreTestUtils.setup();
+ registerCleanupFunction(async function cleanup() {
+ await OSKeyStoreTestUtils.cleanup();
+ });
+});
diff --git a/browser/extensions/formautofill/test/unit/head_addressComponent.js b/browser/extensions/formautofill/test/unit/head_addressComponent.js
new file mode 100644
index 0000000000..472e4ee589
--- /dev/null
+++ b/browser/extensions/formautofill/test/unit/head_addressComponent.js
@@ -0,0 +1,69 @@
+"use strict";
+
+/* exported BOTH_EMPTY, A_IS_EMPTY, B_IS_EMPTY, A_CONTAINS_B, B_CONTAINS_A, SIMILAR, SAME, DIFFERENT, runIsValidTest, runCompareTest */
+
+const { AddressComparison, AddressComponent } = ChromeUtils.importESModule(
+ "resource://gre/modules/shared/AddressComponent.sys.mjs"
+);
+
+const { FormAutofill } = ChromeUtils.importESModule(
+ "resource://autofill/FormAutofill.sys.mjs"
+);
+
+const BOTH_EMPTY = AddressComparison.BOTH_EMPTY;
+const A_IS_EMPTY = AddressComparison.A_IS_EMPTY;
+const B_IS_EMPTY = AddressComparison.B_IS_EMPTY;
+const A_CONTAINS_B = AddressComparison.A_CONTAINS_B;
+const B_CONTAINS_A = AddressComparison.B_CONTAINS_A;
+const SIMILAR = AddressComparison.SIMILAR;
+const SAME = AddressComparison.SAME;
+const DIFFERENT = AddressComparison.DIFFERENT;
+
+function runIsValidTest(tests, fieldName, funcSetupRecord) {
+ let region = FormAutofill.DEFAULT_REGION;
+ for (const test of tests) {
+ if (!Array.isArray(test)) {
+ region = test.region;
+ info(`Change region to ${JSON.stringify(test.region)}`);
+ continue;
+ }
+
+ const [testValue, expected] = test;
+ const record = funcSetupRecord(testValue);
+
+ const field = new AddressComponent(record, region).getField(fieldName);
+ const result = field.isValid();
+ Assert.equal(
+ result,
+ expected,
+ `Expect isValid returns ${expected} for ${testValue}`
+ );
+ }
+}
+
+function runCompareTest(tests, fieldName, funcSetupRecord) {
+ let region = FormAutofill.DEFAULT_REGION;
+ for (const test of tests) {
+ if (!Array.isArray(test)) {
+ info(`change region to ${JSON.stringify(test.region)}`);
+ region = test.region;
+ continue;
+ }
+
+ const [v1, v2, expected] = test;
+ const r1 = funcSetupRecord(v1);
+ const f1 = new AddressComponent(r1, region).getField(fieldName);
+
+ const r2 = funcSetupRecord(v2);
+ const f2 = new AddressComponent(r2, region).getField(fieldName);
+
+ const result = AddressComparison.compare(f1, f2);
+ const resultString = AddressComparison.resultToString(result);
+ const expectedString = AddressComparison.resultToString(expected);
+ Assert.equal(
+ result,
+ expected,
+ `Expect ${expectedString} when comparing "${v1}" & "${v2}", got ${resultString}`
+ );
+ }
+}
diff --git a/browser/extensions/formautofill/test/unit/test_activeStatus.js b/browser/extensions/formautofill/test/unit/test_activeStatus.js
new file mode 100644
index 0000000000..47d79b02e5
--- /dev/null
+++ b/browser/extensions/formautofill/test/unit/test_activeStatus.js
@@ -0,0 +1,176 @@
+/*
+ * Test for status handling in Form Autofill Parent.
+ */
+
+"use strict";
+
+let FormAutofillStatus;
+
+add_setup(async () => {
+ ({ FormAutofillStatus } = ChromeUtils.importESModule(
+ "resource://autofill/FormAutofillParent.sys.mjs"
+ ));
+});
+
+add_task(async function test_activeStatus_init() {
+ sinon.spy(FormAutofillStatus, "updateStatus");
+
+ // Default status is null before initialization
+ Assert.equal(FormAutofillStatus._active, null);
+ Assert.equal(Services.ppmm.sharedData.get("FormAutofill:enabled"), undefined);
+
+ FormAutofillStatus.init();
+ // init shouldn't call updateStatus since that requires storage which will
+ // lead to startup time regressions.
+ Assert.equal(FormAutofillStatus.updateStatus.called, false);
+ Assert.equal(Services.ppmm.sharedData.get("FormAutofill:enabled"), undefined);
+
+ // Initialize profile storage
+ await FormAutofillStatus.formAutofillStorage.initialize();
+ await FormAutofillStatus.updateSavedFieldNames();
+ // Upon first initializing profile storage, status should be computed.
+ Assert.equal(FormAutofillStatus.updateStatus.called, true);
+ Assert.equal(Services.ppmm.sharedData.get("FormAutofill:enabled"), false);
+
+ FormAutofillStatus.uninit();
+});
+
+add_task(async function test_activeStatus_observe() {
+ FormAutofillStatus.init();
+ sinon.stub(FormAutofillStatus, "computeStatus");
+ sinon.spy(FormAutofillStatus, "onStatusChanged");
+
+ // _active = _computeStatus() => No need to trigger _onStatusChanged
+ FormAutofillStatus._active = true;
+ FormAutofillStatus.computeStatus.returns(true);
+ FormAutofillStatus.observe(
+ null,
+ "nsPref:changed",
+ "extensions.formautofill.addresses.enabled"
+ );
+ FormAutofillStatus.observe(
+ null,
+ "nsPref:changed",
+ "extensions.formautofill.creditCards.enabled"
+ );
+ Assert.equal(FormAutofillStatus.onStatusChanged.called, false);
+
+ // _active != computeStatus() => Need to trigger onStatusChanged
+ FormAutofillStatus.computeStatus.returns(false);
+ FormAutofillStatus.onStatusChanged.resetHistory();
+ FormAutofillStatus.observe(
+ null,
+ "nsPref:changed",
+ "extensions.formautofill.addresses.enabled"
+ );
+ FormAutofillStatus.observe(
+ null,
+ "nsPref:changed",
+ "extensions.formautofill.creditCards.enabled"
+ );
+ Assert.equal(FormAutofillStatus.onStatusChanged.called, true);
+
+ // profile changed => Need to trigger _onStatusChanged
+ await Promise.all(
+ ["add", "update", "remove", "reconcile"].map(async event => {
+ FormAutofillStatus.computeStatus.returns(!FormAutofillStatus._active);
+ FormAutofillStatus.onStatusChanged.resetHistory();
+ await FormAutofillStatus.observe(
+ null,
+ "formautofill-storage-changed",
+ event
+ );
+ Assert.equal(FormAutofillStatus.onStatusChanged.called, true);
+ })
+ );
+
+ // profile metadata updated => No need to trigger onStatusChanged
+ FormAutofillStatus.computeStatus.returns(!FormAutofillStatus._active);
+ FormAutofillStatus.onStatusChanged.resetHistory();
+ await FormAutofillStatus.observe(
+ null,
+ "formautofill-storage-changed",
+ "notifyUsed"
+ );
+ Assert.equal(FormAutofillStatus.onStatusChanged.called, false);
+
+ FormAutofillStatus.computeStatus.restore();
+});
+
+add_task(async function test_activeStatus_computeStatus() {
+ registerCleanupFunction(function cleanup() {
+ Services.prefs.clearUserPref("extensions.formautofill.addresses.enabled");
+ Services.prefs.clearUserPref("extensions.formautofill.creditCards.enabled");
+ });
+
+ sinon.stub(
+ FormAutofillStatus.formAutofillStorage.addresses,
+ "getSavedFieldNames"
+ );
+ FormAutofillStatus.formAutofillStorage.addresses.getSavedFieldNames.returns(
+ Promise.resolve(new Set())
+ );
+ sinon.stub(
+ FormAutofillStatus.formAutofillStorage.creditCards,
+ "getSavedFieldNames"
+ );
+ FormAutofillStatus.formAutofillStorage.creditCards.getSavedFieldNames.returns(
+ Promise.resolve(new Set())
+ );
+
+ // pref is enabled and profile is empty.
+ Services.prefs.setBoolPref("extensions.formautofill.addresses.enabled", true);
+ Services.prefs.setBoolPref(
+ "extensions.formautofill.creditCards.enabled",
+ true
+ );
+ Assert.equal(FormAutofillStatus.computeStatus(), false);
+
+ // pref is disabled and profile is empty.
+ Services.prefs.setBoolPref(
+ "extensions.formautofill.addresses.enabled",
+ false
+ );
+ Services.prefs.setBoolPref(
+ "extensions.formautofill.creditCards.enabled",
+ false
+ );
+ Assert.equal(FormAutofillStatus.computeStatus(), false);
+
+ FormAutofillStatus.formAutofillStorage.addresses.getSavedFieldNames.returns(
+ Promise.resolve(new Set(["given-name"]))
+ );
+ await FormAutofillStatus.observe(null, "formautofill-storage-changed", "add");
+
+ // pref is enabled and profile is not empty.
+ Services.prefs.setBoolPref("extensions.formautofill.addresses.enabled", true);
+ Assert.equal(FormAutofillStatus.computeStatus(), true);
+
+ // pref is partial enabled and profile is not empty.
+ Services.prefs.setBoolPref("extensions.formautofill.addresses.enabled", true);
+ Services.prefs.setBoolPref(
+ "extensions.formautofill.creditCards.enabled",
+ false
+ );
+ Assert.equal(FormAutofillStatus.computeStatus(), true);
+ Services.prefs.setBoolPref(
+ "extensions.formautofill.addresses.enabled",
+ false
+ );
+ Services.prefs.setBoolPref(
+ "extensions.formautofill.creditCards.enabled",
+ true
+ );
+ Assert.equal(FormAutofillStatus.computeStatus(), true);
+
+ // pref is disabled and profile is not empty.
+ Services.prefs.setBoolPref(
+ "extensions.formautofill.addresses.enabled",
+ false
+ );
+ Services.prefs.setBoolPref(
+ "extensions.formautofill.creditCards.enabled",
+ false
+ );
+ Assert.equal(FormAutofillStatus.computeStatus(), false);
+});
diff --git a/browser/extensions/formautofill/test/unit/test_addressComponent_city.js b/browser/extensions/formautofill/test/unit/test_addressComponent_city.js
new file mode 100644
index 0000000000..8bb448cfe4
--- /dev/null
+++ b/browser/extensions/formautofill/test/unit/test_addressComponent_city.js
@@ -0,0 +1,27 @@
+"use strict";
+
+const VALID_TESTS = [["New York City", true]];
+
+const COMPARE_TESTS = [
+ ["New York City", "New York City", SAME],
+ ["New York City", "new york city", SAME],
+ ["New York City", "New York City", SIMILAR], // Merge whitespace
+ ["Happy Valley-Goose Bay", "Happy Valley Goose Bay", SIMILAR], // Replace punctuation with whitespace
+ ["New York City", "New York", A_CONTAINS_B],
+ ["New York", "NewYork", DIFFERENT],
+ ["New York City", "City New York", DIFFERENT],
+];
+
+const TEST_FIELD_NAME = "City";
+
+add_task(async function test_isValid() {
+ runIsValidTest(VALID_TESTS, TEST_FIELD_NAME, value => {
+ return { "address-level2": value };
+ });
+});
+
+add_task(async function test_compare() {
+ runCompareTest(COMPARE_TESTS, TEST_FIELD_NAME, value => {
+ return { "address-level2": value };
+ });
+});
diff --git a/browser/extensions/formautofill/test/unit/test_addressComponent_country.js b/browser/extensions/formautofill/test/unit/test_addressComponent_country.js
new file mode 100644
index 0000000000..bf73309c60
--- /dev/null
+++ b/browser/extensions/formautofill/test/unit/test_addressComponent_country.js
@@ -0,0 +1,47 @@
+"use strict";
+
+const VALID_TESTS = [
+ ["United States", true],
+ ["Not United States", true], // Invalid country name will be replaced with the default region, so
+ // it is still valid
+];
+
+const COMPARE_TESTS = [
+ // United Stats, US, USA, America, U.S.A.
+ { region: "US" },
+ ["United States", "United States", SAME],
+ ["United States", "united states", SAME],
+ ["United States", "US", SAME],
+ ["America", "United States", SAME],
+ ["America", "US", SAME],
+ ["US", "USA", SAME],
+ ["United States", "U.S.A.", SAME], // Normalize
+
+ ["USB", "US", SAME],
+
+ // Canada, Can, CA
+ ["CA", "Canada", SAME],
+ ["CA", "CAN", SAME],
+ ["CA", "US", DIFFERENT],
+
+ { region: "DE" },
+ ["USB", "US", DIFFERENT],
+ ["United States", "Germany", DIFFERENT],
+
+ ["Invalid Country Name", "Germany", SAME],
+ ["AAA", "BBB", SAME],
+];
+
+const TEST_FIELD_NAME = "Country";
+
+add_task(async function test_isValid() {
+ runIsValidTest(VALID_TESTS, TEST_FIELD_NAME, value => {
+ return { country: value };
+ });
+});
+
+add_task(async function test_compare() {
+ runCompareTest(COMPARE_TESTS, TEST_FIELD_NAME, value => {
+ return { country: value };
+ });
+});
diff --git a/browser/extensions/formautofill/test/unit/test_addressComponent_email.js b/browser/extensions/formautofill/test/unit/test_addressComponent_email.js
new file mode 100644
index 0000000000..2c4b07a542
--- /dev/null
+++ b/browser/extensions/formautofill/test/unit/test_addressComponent_email.js
@@ -0,0 +1,74 @@
+"use strict";
+
+// TODO:
+// https://help.xmatters.com/ondemand/trial/valid_email_format.htm what is the allow characters???
+const VALID_TESTS = [
+ // Is Valid Test
+ // [email1, expected]
+ ["john.doe@mozilla.org", true],
+
+ ["", false], // empty
+ ["@mozilla.org", false], // without username
+ ["john.doe@", false], // without domain
+ ["john.doe@-mozilla.org", false], // domain starts with '-'
+ ["john.doe@mozilla.org-", false], // domain ends with '-'
+ ["john.doe@mozilla.-com.au", false], // sub-domain starts with '-'
+ ["john.doe@mozilla.com-.au", false], // sub-domain ends with '-'
+
+ ["john-doe@mozilla.org", true], // dash (ok)
+
+ // Special characters check
+ ["john.!#$%&'*+-/=?^_`{|}~doe@gmail.com", true],
+
+ ["john.doe@work@mozilla.org", false],
+ ["äbc@mail.com", false],
+
+ ["john.doe@" + "a".repeat(63) + ".org", true],
+ ["john.doe@" + "a".repeat(64) + ".org", false],
+
+ // The following are commented out since we're using a more relax email
+ // validation algorithm now.
+ /*
+ ["-john.doe@mozilla.org", false], // username starts with '-'
+ [".john.doe@mozilla.org", false], // username starts with '.'
+ ["john.doe-@mozilla.org", true], // username ends with '-' ???
+ ["john.doe.@mozilla.org", true], // username ends with '.' ???
+ ["john.doe@-mozilla.org", true], // domain starts with '.' ???
+ ["john..doe@mozilla.org", false], // consecutive period
+ ["john.-doe@mozilla.org", false], // period + dash
+ ["john-.doe@mozilla.org", false], // dash + period
+ ["john.doe@school.123", false],
+ ["peter-parker@spiderman", false],
+
+ ["a".repeat(64) + "@mydomain.com", true], // length of username
+ ["b".repeat(65) + "@mydomain.com", false],
+*/
+];
+
+const COMPARE_TESTS = [
+ // Same
+ ["test@mozilla.org", "test@mozilla.org", SAME],
+ ["john.doe@example.com", "jOhn.doE@example.com", SAME],
+ ["jan@gmail.com", "JAN@gmail.com", SAME],
+
+ // Different
+ ["jan@gmail.com", "jan1@gmail.com", DIFFERENT],
+ ["jan@gmail.com", "jan@gmail.com.au", DIFFERENT],
+ ["john#smith@gmail.com", "johnsmith@gmail.com", DIFFERENT],
+];
+
+const TEST_FIELD_NAME = "Email";
+
+add_setup(async () => {});
+
+add_task(async function test_isValid() {
+ runIsValidTest(VALID_TESTS, TEST_FIELD_NAME, value => {
+ return { email: value };
+ });
+});
+
+add_task(async function test_compare() {
+ runCompareTest(COMPARE_TESTS, TEST_FIELD_NAME, value => {
+ return { email: value };
+ });
+});
diff --git a/browser/extensions/formautofill/test/unit/test_addressComponent_name.js b/browser/extensions/formautofill/test/unit/test_addressComponent_name.js
new file mode 100644
index 0000000000..79fb1879d2
--- /dev/null
+++ b/browser/extensions/formautofill/test/unit/test_addressComponent_name.js
@@ -0,0 +1,101 @@
+"use strict";
+
+const VALID_TESTS = [
+ ["John Doe", true],
+ ["John O'Brian'", true],
+ ["John O-Brian'", true],
+ ["John Doe", true],
+];
+
+// prettier-ignore
+const COMPARE_TESTS = [
+ // Same
+ ["John", "John", SAME], // first name
+ ["John Doe", "John Doe", SAME], // first and last name
+ ["John Middle Doe", "John Middle Doe", SAME], // first, middle, and last name
+ ["John Mid1 Mid2 Doe", "John Mid1 Mid2 Doe", SAME],
+
+ // Same: case insenstive
+ ["John Doe", "john doe", SAME],
+
+ // Similar: whitespaces are merged
+ ["John Doe", "John Doe", SIMILAR],
+
+ // Similar: asscent and base
+ ["John Doe", "John Döe", SIMILAR], // asscent and base
+
+ // A Contains B
+ ["John Doe", "Doe", A_CONTAINS_B], // first + family name contains family name
+ ["John Doe", "John", A_CONTAINS_B], // first + family name contains first name
+ ["John Middle Doe", "Doe", A_CONTAINS_B], // [first, middle, last] contains [last]
+ ["John Middle Doe", "John", A_CONTAINS_B], // [first, middle, last] contains [first]
+ ["John Middle Doe", "Middle", A_CONTAINS_B], // [first, middle, last] contains [middle]
+ ["John Middle Doe", "Middle Doe", A_CONTAINS_B], // [first, middle, last] contains [middle, last]
+ ["John Middle Doe", "John Middle", A_CONTAINS_B], // [first, middle, last] contains [fisrt, middle]
+ ["John Middle Doe", "John Doe", A_CONTAINS_B], // [first, middle, last] contains [fisrt, last]
+ ["John Mary Jane Doe", "John Doe", A_CONTAINS_B], // [first, middle, last] contains [fisrt, last]
+
+ // Different
+ ["John Doe", "Jane Roe", DIFFERENT],
+ ["John Doe", "Doe John", DIFFERENT], // swap order
+ ["John Middle Doe", "Middle John", DIFFERENT],
+ ["John Middle Doe", "Doe Middle", DIFFERENT],
+ ["John Doe", "John Roe.", DIFFERENT], // different family name
+ ["John Doe", "Jane Doe", DIFFERENT], // different given name
+ ["John Middle Doe", "Jane Michael Doe", DIFFERENT], // different middle name
+
+ // Puncuation is either removed or replaced with white space
+ ["John O'Brian", "John OBrian", SIMILAR],
+ ["John O'Brian", "John O-Brian", SIMILAR],
+ ["John O'Brian", "John O Brian", SIMILAR],
+ ["John-Mary Doe", "JohnMary Doe", SIMILAR],
+ ["John-Mary Doe", "John'Mary Doe", SIMILAR],
+ ["John-Mary Doe", "John Mary Doe", SIMILAR],
+ ["John-Mary Doe", "John Mary", A_CONTAINS_B],
+
+ // Test Name Variants
+ ["John Doe", "J. Doe", A_CONTAINS_B], // first name to initial
+ ["John Doe", "J. doe", A_CONTAINS_B],
+ ["John Doe", "J. Doe", A_CONTAINS_B], // first name to initial without '.'
+
+ ["John Middle Doe", "J. Middle Doe", A_CONTAINS_B], // first name to initial, middle name unchanged
+ ["John Middle Doe", "J. Doe", A_CONTAINS_B], // first name to initial, no middle name
+
+ ["John Middle Doe", "John M. Doe", A_CONTAINS_B], // middle name to initial, first name unchanged
+ ["John Middle Doe", "J. M. Doe", A_CONTAINS_B], // first and middle name to initial
+ ["John Middle Doe", "J M Doe", A_CONTAINS_B], // first and middle name to initial without '.'
+ ["John Middle Doe", "John M. Doe", A_CONTAINS_B], // middle name with initial
+
+ // Test Name Variants: multiple middle name
+ ["John Mary Jane Doe", "J. MARY JANE Doe", A_CONTAINS_B], // first to initial
+ ["John Mary Jane Doe", "john. M. J. doe", A_CONTAINS_B], // middle name to initial
+ ["John Mary Jane Doe", "J. M. J. Doe", A_CONTAINS_B], // first & middle name to initial
+ ["John Mary Jane Doe", "J. M. Doe", A_CONTAINS_B], // first & part of the middle name to initial
+ ["John Mary Jane Doe", "John M. Doe", A_CONTAINS_B],
+ ["John Mary Jane Doe", "J. Doe", A_CONTAINS_B],
+
+ // Test Name Variants: merge initials
+ ["John Middle Doe", "JM Doe", A_CONTAINS_B],
+ ["John Mary Jane Doe", "JMJ. doe", A_CONTAINS_B],
+
+ // Different: Don't consider the cases when family name is abbreviated
+ ["John Middle Doe", "JMD", DIFFERENT],
+ ["John Middle Doe", "John Middle D.", DIFFERENT],
+ ["John Middle Doe", "J. M. D.", DIFFERENT],
+];
+
+const TEST_FIELD_NAME = "Name";
+
+add_setup(async () => {});
+
+add_task(async function test_isValid() {
+ runIsValidTest(VALID_TESTS, TEST_FIELD_NAME, value => {
+ return { name: value };
+ });
+});
+
+add_task(async function test_compare() {
+ runCompareTest(COMPARE_TESTS, TEST_FIELD_NAME, value => {
+ return { name: value };
+ });
+});
diff --git a/browser/extensions/formautofill/test/unit/test_addressComponent_organization.js b/browser/extensions/formautofill/test/unit/test_addressComponent_organization.js
new file mode 100644
index 0000000000..6790b83599
--- /dev/null
+++ b/browser/extensions/formautofill/test/unit/test_addressComponent_organization.js
@@ -0,0 +1,55 @@
+"use strict";
+
+// prettier-ignore
+const VALID_TESTS = [
+ ["Mozilla", true],
+ ["mozilla", true],
+ ["@Mozilla", true],
+ [" ", true], // A string only contains whitespace is treated as empty, which is considered as valid
+ ["-!@#%&*_(){}[:;\"',.?]", false], // Not valid when the organization name only contains punctuations
+];
+
+const COMPARE_TESTS = [
+ // Same
+ ["Mozilla", "Mozilla", SAME], // Exact the same
+
+ // Similar
+ ["Mozilla", "mozilla", SIMILAR], // Ignore case
+ ["Casavant Frères", "Casavant Freres", SIMILAR], // asscent and base
+ ["Graphik Dimensions, Ltd.", "Graphik Dimensions Ltd", SIMILAR], // Punctuation is stripped and trim space in the end
+ ["T & T Supermarket", "T&T Supermarket", SIMILAR], // & is stripped and merged consecutive whitespace
+ ["Food & Pharmacy", "Pharmacy & Food", SIMILAR], // Same tokens, different order
+ ["Johnson & Johnson", "Johnson", SIMILAR], // Can always find the same token in the other
+
+ // A Contains B
+ ["Mozilla Inc.", "Mozilla", A_CONTAINS_B], // Contain, the same prefix
+ ["The Walt Disney", "Walt Disney", A_CONTAINS_B], // Contain, the same suffix
+ ["Coca-Cola Company", "Coca Cola", A_CONTAINS_B], // Contain, strip punctuation
+
+ // Different
+ ["Meta", "facebook", DIFFERENT], // Completely different
+ ["Metro Inc.", "CGI Inc.", DIFFERENT], // Different prefix
+ ["AT&T Corp.", "AT&T Inc.", DIFFERENT], // Different suffix
+ ["AT&T Corp.", "AT&T Corporation", DIFFERENT], // Different suffix
+ ["Ben & Jerry's", "Ben & Jerrys", DIFFERENT], // Different because Jerry's becomes ["Jerry", "s"]
+ ["Arc'teryx", "Arcteryx", DIFFERENT], // Different because Arc'teryx' becomes ["Arc", "teryx"]
+ ["BMW", "Bayerische Motoren Werke", DIFFERENT],
+
+ ["Linens 'n Things", "Linens'n Things", SIMILAR], // Punctuation is replaced with whitespace, so both strings become "Linesns n Things"
+];
+
+const TEST_FIELD_NAME = "Organization";
+
+add_setup(async () => {});
+
+add_task(async function test_isValid() {
+ runIsValidTest(VALID_TESTS, TEST_FIELD_NAME, value => {
+ return { organization: value };
+ });
+});
+
+add_task(async function test_compare() {
+ runCompareTest(COMPARE_TESTS, TEST_FIELD_NAME, value => {
+ return { organization: value };
+ });
+});
diff --git a/browser/extensions/formautofill/test/unit/test_addressComponent_postal_code.js b/browser/extensions/formautofill/test/unit/test_addressComponent_postal_code.js
new file mode 100644
index 0000000000..a3150362d2
--- /dev/null
+++ b/browser/extensions/formautofill/test/unit/test_addressComponent_postal_code.js
@@ -0,0 +1,57 @@
+"use strict";
+
+const VALID_TESTS = [
+ { region: "US" },
+ ["1234", false], // too short
+ ["12345", true],
+ ["123456", false], // too long
+ ["1234A", false], // contain non-digit character
+ ["12345-123", false],
+ ["12345-1234", true],
+ ["12345-12345", false],
+ ["12345-1234A", false],
+ ["12345 1234", true], // Do we want to allow this?
+ ["12345_1234", false], // Do we want to allow this?
+
+ { region: "CA" },
+ ["M5T 1R5", true],
+ ["M5T1R5", true], // no space between the first and second parts is allowed
+ ["S4S 6X3", true],
+ ["M5T", false], // Only the first part
+ ["1R5", false], // Only the second part
+ ["D1B 1A1", false], // invalid first character, D
+ ["M5T 1R5A", false], // extra character at the end
+ ["M5T 1R5-", false], // extra character at the end
+ ["M5T-1R5", false], // hyphen in the wrong place
+ ["MT5 1R5", false], // missing letter in the first part
+ ["M5T 1R", false], // missing letter in the second part
+ ["M5T 1R55", false], // extra digit at the end
+ ["M5T 1R", false], // missing digit in the second part
+ ["M5T 1R5Q", false], // invalid second-to-last letter, Q
+];
+
+const COMPARE_TESTS = [
+ { region: "US" },
+ ["12345", "12345", SAME],
+ ["M5T 1R5", "m5t 1r5", SAME],
+ ["12345-1234", "12345 1234", SAME],
+ ["12345-1234", "12345", A_CONTAINS_B],
+ ["12345-1234", "12345#1234", SAME], // B is invalid
+ ["12345-1234", "1234", A_CONTAINS_B], // B is invalid
+];
+
+const TEST_FIELD_NAME = "PostalCode";
+
+add_setup(async () => {});
+
+add_task(async function test_isValid() {
+ runIsValidTest(VALID_TESTS, TEST_FIELD_NAME, value => {
+ return { "postal-code": value };
+ });
+});
+
+add_task(async function test_compare() {
+ runCompareTest(COMPARE_TESTS, TEST_FIELD_NAME, value => {
+ return { "postal-code": value };
+ });
+});
diff --git a/browser/extensions/formautofill/test/unit/test_addressComponent_state.js b/browser/extensions/formautofill/test/unit/test_addressComponent_state.js
new file mode 100644
index 0000000000..43e1de84dc
--- /dev/null
+++ b/browser/extensions/formautofill/test/unit/test_addressComponent_state.js
@@ -0,0 +1,32 @@
+"use strict";
+
+const VALID_TESTS = [
+ ["California", true],
+ ["california", true],
+ ["Californib", false],
+ ["CA", true],
+ ["CA.", true],
+ ["CC", false],
+];
+
+const COMPARE_TESTS = [
+ ["California", "california", SAME], // case insensitive
+ ["CA", "california", SAME],
+ ["CA", "ca", SAME],
+ ["California", "New Jersey", DIFFERENT],
+ ["New York", "New Jersey", DIFFERENT],
+];
+
+const TEST_FIELD_NAME = "State";
+
+add_task(async function test_isValid() {
+ runIsValidTest(VALID_TESTS, TEST_FIELD_NAME, value => {
+ return { "address-level1": value };
+ });
+});
+
+add_task(async function test_compare() {
+ runCompareTest(COMPARE_TESTS, TEST_FIELD_NAME, value => {
+ return { "address-level1": value };
+ });
+});
diff --git a/browser/extensions/formautofill/test/unit/test_addressComponent_street_address.js b/browser/extensions/formautofill/test/unit/test_addressComponent_street_address.js
new file mode 100644
index 0000000000..83f1cd7ef3
--- /dev/null
+++ b/browser/extensions/formautofill/test/unit/test_addressComponent_street_address.js
@@ -0,0 +1,56 @@
+"use strict";
+
+const VALID_TESTS = [
+ ["123 Main St. Apt 4, Floor 2", true],
+ ["This is a street", true],
+ ["A", true],
+ ["住址", true],
+ ["!#%&'*+", false],
+ ["1234", false],
+];
+
+const COMPARE_TESTS = [
+ { region: "US" },
+ ["123 Main St.", "123 Main St.", SAME], // Exactly the same with only street number and street name
+ ["123 Main St. Apt 4, Floor 2", "123 Main St. Apt 4, Floor 2", SAME],
+ ["123 Main St. Apt 4A, Floor 2", "123 main St. Apt 4a, Floor 2", SAME],
+ ["123 Main St. Apt 4, Floor 2", "123 Main St. Suite 4, 2nd fl", SAME], // Exactly the same after parsing
+ ["Main St.", "Main St.", SAME], // Exactly the same with only street name
+ ["Main St.", "main st.", SAME], // Exactly the same with only street name (case-insenstive)
+
+ ["123 Main St.", "Main St.", A_CONTAINS_B], // Street number is mergeable
+ ["123 Main Lane St.", "123 Main St.", A_CONTAINS_B], // Street name is mergeable
+ ["123 Main St. Apt 4", "Main St.", A_CONTAINS_B], // Apartment number is mergeable
+ ["123 Main St. Apt 4, Floor 2", "123 Main St., Floor 2", A_CONTAINS_B],
+ ["123 Main St. Floor 2", "Main St.", A_CONTAINS_B], // Floor number is mergeable
+ ["123 Main St. Apt 4, Floor 2", "123 Main St. Apt 4", A_CONTAINS_B],
+ ["123 North-South Road", "123 North South Road", SIMILAR], // Street number is mergeable
+
+ ["123 Main St. Apt 4, Floor 2", "1234 Main St. Apt 4, Floor 2", DIFFERENT], // Street number is different
+ ["123 Main St. Apt 4, Floor 2", "123 Mainn St. Apt 4, Floor 2", DIFFERENT], // Street name is different
+ [
+ "123 Lane Main St. Apt 4, Floor 2",
+ "123 Main Lane St. Apt 4, Floor 2",
+ DIFFERENT,
+ ], // Street name is different (token not in order)
+ ["123 Main St. Apt 4, Floor 2", "123 Main St. Apt 41, Floor 2", DIFFERENT], // Apartment number is different
+ ["123 Main St. Apt 4, Floor 2", "123 Main St. Apt 4, Floor 22", DIFFERENT], // Floor number is different
+
+ ["123 Main St. Apt 4, Floor 2", "123 Main St. Floor 2, Apt 4", DIFFERENT], //
+];
+
+const TEST_FIELD_NAME = "StreetAddress";
+
+add_setup(async () => {});
+
+add_task(async function test_isValid() {
+ runIsValidTest(VALID_TESTS, TEST_FIELD_NAME, value => {
+ return { "street-address": value };
+ });
+});
+
+add_task(async function test_compare() {
+ runCompareTest(COMPARE_TESTS, TEST_FIELD_NAME, value => {
+ return { "street-address": value };
+ });
+});
diff --git a/browser/extensions/formautofill/test/unit/test_addressComponent_tel.js b/browser/extensions/formautofill/test/unit/test_addressComponent_tel.js
new file mode 100644
index 0000000000..584ab9f8f3
--- /dev/null
+++ b/browser/extensions/formautofill/test/unit/test_addressComponent_tel.js
@@ -0,0 +1,76 @@
+/* import-globals-from head_addressComponent.js */
+
+"use strict";
+
+// prettier-ignore
+const VALID_TESTS = [
+ // US Valid format (XXX-XXX-XXXX) and first digit is between 2-9
+ ["200-234-5678", true], // First digit should between 2-9
+ ["100-234-5678", true], // First digit is not between 2-9, but currently not being checked
+ // when no country code is specified
+ ["555-abc-1234", true], // Non-digit characters are normalized according to ITU E.161 standard
+ ["55-555-5555", false], // The national number is too short (9 digits)
+
+ ["2-800-555-1234", false], // "2" is not US country code so we treat
+ // 2-800-555-1234 as the national number, which is too long (11 digits)
+
+ // Phone numbers with country code
+ ["1-800-555-1234", true], // Country code without plus sign
+ ["+1 200-234-5678", true], // Country code with plus sign and with a valid national number
+ ["+1 100-234-5678", false], // National number should be between 2-9
+ ["+1 55-555-5555", false], // National number is too short (9 digits)
+ ["+1 1-800-555-1234", true], // "+1" and "1" are both treated as coutnry code so national number
+ // is a valid number (800-555-1234)
+ ["+1 2-800-555-1234", false], // The national number is too long (11 digits)
+ ["+1 555-abc-1234", true], // Non-digit characters are normalized according to ITU E.161 standard
+];
+
+const COMPARE_TESTS = [
+ ["+1 520-248-6621", "+15202486621", SAME],
+ ["+1 520-248-6621", "1-520-248-6621", SAME],
+ ["+1 520-248-6621", "1(520)248-6621", SAME],
+ ["520-248-6621", "520-248-6621", SAME], // Both phone numbers don't have coutry code
+ ["520-248-6621", "+1 520-248-6621", SAME], // Compare phone number with and without country code
+
+ ["+1 520-248-6621", "248-6621", A_CONTAINS_B],
+ ["520-248-6621", "248-6621", A_CONTAINS_B],
+ ["0520-248-6621", "520-248-6621", A_CONTAINS_B],
+ ["48-6621", "6621", A_CONTAINS_B], // Both phone number are invalid
+
+ ["+1 520-248-6621", "+91 520-248-6622", DIFFERENT], // different national prefix and number
+ ["+1 520-248-6621", "+91 520-248-6621", DIFFERENT], // Same number, different national prefix
+ ["+1 520-248-6621", "+1 520-248-6622", DIFFERENT], // Same national prefix, different number
+ ["520-248-6621", "+91 520-248-6622", DIFFERENT], // Same test as above but with default region
+ ["520-248-6621", "+91 520-248-6621", DIFFERENT], // Same test as above but with default region
+ ["520-248-6621", "+1 520-248-6622", DIFFERENT], // Same test as above but with default region
+ ["520-248-6621", "520-248-6622", DIFFERENT],
+
+ // Normalize
+ ["+1 520-248-6621", "+1 ja0-bgt-mnc1", SAME],
+ ["+1 1-800-555-1234", "+1 800-555-1234", SAME],
+
+ // TODO: Support extension
+ //["+64 3 331-6005", "3 331 6005#1234", A_CONTAINS_B],
+];
+
+const TEST_FIELD_NAME = "Tel";
+
+add_setup(async () => {
+ Services.prefs.setBoolPref("browser.search.region", "US");
+
+ registerCleanupFunction(function head_cleanup() {
+ Services.prefs.clearUserPref("browser.search.region");
+ });
+});
+
+add_task(async function test_isValid() {
+ runIsValidTest(VALID_TESTS, TEST_FIELD_NAME, value => {
+ return { tel: value };
+ });
+});
+
+add_task(async function test_compare() {
+ runCompareTest(COMPARE_TESTS, TEST_FIELD_NAME, value => {
+ return { tel: value };
+ });
+});
diff --git a/browser/extensions/formautofill/test/unit/test_addressDataLoader.js b/browser/extensions/formautofill/test/unit/test_addressDataLoader.js
new file mode 100644
index 0000000000..6b9cfb102c
--- /dev/null
+++ b/browser/extensions/formautofill/test/unit/test_addressDataLoader.js
@@ -0,0 +1,102 @@
+"use strict";
+
+const SUPPORT_COUNTRIES_TESTCASES = [
+ {
+ country: "US",
+ properties: ["languages", "alternative_names", "sub_keys", "sub_names"],
+ },
+ {
+ country: "CA",
+ properties: ["languages", "name", "sub_keys", "sub_names"],
+ },
+ {
+ country: "DE",
+ properties: ["name"],
+ },
+];
+
+var AddressDataLoader, FormAutofillUtils;
+add_setup(async () => {
+ ({ AddressDataLoader, FormAutofillUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/shared/FormAutofillUtils.sys.mjs"
+ ));
+});
+
+add_task(async function test_initalState() {
+ // addressData should not exist
+ Assert.equal(AddressDataLoader._addressData, undefined);
+ // Verify _dataLoaded state
+ Assert.equal(AddressDataLoader._dataLoaded.country, false);
+ Assert.equal(AddressDataLoader._dataLoaded.level1.size, 0);
+});
+
+add_task(async function test_loadDataState() {
+ sinon.spy(AddressDataLoader, "_loadScripts");
+ let metadata = FormAutofillUtils.getCountryAddressData("US");
+ Assert.ok(AddressDataLoader._addressData, "addressData exists");
+ // Verify _dataLoaded state
+ Assert.equal(AddressDataLoader._dataLoaded.country, true);
+ Assert.equal(AddressDataLoader._dataLoaded.level1.size, 0);
+ // _loadScripts should be called
+ sinon.assert.called(AddressDataLoader._loadScripts);
+ // Verify metadata
+ Assert.equal(metadata.id, "data/US");
+ Assert.ok(
+ metadata.alternative_names,
+ "US alternative names should be loaded from extension"
+ );
+ AddressDataLoader._loadScripts.resetHistory();
+
+ // Load data without country
+ let newMetadata = FormAutofillUtils.getCountryAddressData();
+ // _loadScripts should not be called
+ sinon.assert.notCalled(AddressDataLoader._loadScripts);
+ Assert.deepEqual(
+ metadata,
+ newMetadata,
+ "metadata should be US if country is not specified"
+ );
+ AddressDataLoader._loadScripts.resetHistory();
+
+ // Load level 1 data that does not exist
+ let undefinedMetadata = FormAutofillUtils.getCountryAddressData("US", "CA");
+ // _loadScripts should be called
+ sinon.assert.called(AddressDataLoader._loadScripts);
+ Assert.equal(undefinedMetadata, undefined, "metadata should be undefined");
+ Assert.ok(
+ AddressDataLoader._dataLoaded.level1.has("US"),
+ "level 1 state array should be set even there's no valid metadata"
+ );
+ AddressDataLoader._loadScripts.resetHistory();
+
+ // Load level 1 data again
+ undefinedMetadata = FormAutofillUtils.getCountryAddressData("US", "AS");
+ Assert.equal(undefinedMetadata, undefined, "metadata should be undefined");
+ // _loadScripts should not be called
+ sinon.assert.notCalled(AddressDataLoader._loadScripts);
+});
+
+SUPPORT_COUNTRIES_TESTCASES.forEach(testcase => {
+ add_task(async function test_support_country() {
+ info("Starting testcase: Check " + testcase.country + " metadata");
+ let metadata = FormAutofillUtils.getCountryAddressData(testcase.country);
+ Assert.ok(
+ testcase.properties.every(key => metadata[key]),
+ "These properties should exist: " + testcase.properties
+ );
+ // Verify the multi-locale country
+ if (metadata.languages && metadata.languages.length > 1) {
+ let locales = FormAutofillUtils.getCountryAddressDataWithLocales(
+ testcase.country
+ );
+ Assert.equal(
+ metadata.languages.length,
+ locales.length,
+ "Total supported locales should be matched"
+ );
+ metadata.languages.forEach((lang, index) => {
+ Assert.equal(lang, locales[index].lang, `Should support ${lang}`);
+ });
+ }
+ });
+});
diff --git a/browser/extensions/formautofill/test/unit/test_addressRecords.js b/browser/extensions/formautofill/test/unit/test_addressRecords.js
new file mode 100644
index 0000000000..53db04ee38
--- /dev/null
+++ b/browser/extensions/formautofill/test/unit/test_addressRecords.js
@@ -0,0 +1,858 @@
+/**
+ * Tests FormAutofillStorage object with addresses records.
+ */
+
+"use strict";
+
+const TEST_STORE_FILE_NAME = "test-profile.json";
+const COLLECTION_NAME = "addresses";
+
+const TEST_ADDRESS_1 = {
+ "given-name": "Timothy",
+ "additional-name": "John",
+ "family-name": "Berners-Lee",
+ organization: "World Wide Web Consortium",
+ "street-address": "32 Vassar Street\nMIT Room 32-G524",
+ "address-level2": "Cambridge",
+ "address-level1": "MA",
+ "postal-code": "02139",
+ country: "US",
+ tel: "+16172535702",
+ email: "timbl@w3.org",
+ "unknown-1": "an unknown field from another client",
+};
+
+const TEST_ADDRESS_2 = {
+ "street-address": "Some Address",
+ country: "US",
+};
+
+const TEST_ADDRESS_3 = {
+ "given-name": "Timothy",
+ "family-name": "Berners-Lee",
+ "street-address": "Other Address",
+ "postal-code": "12345",
+};
+
+const TEST_ADDRESS_4 = {
+ "given-name": "Timothy",
+ "additional-name": "John",
+ "family-name": "Berners-Lee",
+ organization: "World Wide Web Consortium",
+};
+
+const TEST_ADDRESS_WITH_EMPTY_FIELD = {
+ name: "Tim Berners",
+ "street-address": "",
+};
+
+const TEST_ADDRESS_WITH_EMPTY_COMPUTED_FIELD = {
+ name: "",
+ "address-line1": "",
+ "address-line2": "",
+ "address-line3": "",
+ "country-name": "",
+ "tel-country-code": "",
+ "tel-national": "",
+ "tel-area-code": "",
+ "tel-local": "",
+ "tel-local-prefix": "",
+ "tel-local-suffix": "",
+ email: "timbl@w3.org",
+};
+
+const TEST_ADDRESS_WITH_INVALID_FIELD = {
+ "street-address": "Another Address",
+ email: { email: "invalidemail" },
+};
+
+const TEST_ADDRESS_EMPTY_AFTER_NORMALIZE = {
+ country: "XXXXXX",
+};
+
+const TEST_ADDRESS_EMPTY_AFTER_UPDATE_ADDRESS_2 = {
+ "street-address": "",
+ country: "XXXXXX",
+};
+
+const MERGE_TESTCASES = [
+ {
+ description: "Merge a superset",
+ addressInStorage: {
+ "given-name": "Timothy",
+ "street-address": "331 E. Evelyn Avenue",
+ tel: "+16509030800",
+ "unknown-1": "an unknown field from another client",
+ },
+ addressToMerge: {
+ "given-name": "Timothy",
+ "street-address": "331 E. Evelyn Avenue",
+ tel: "+16509030800",
+ country: "US",
+ "unknown-1": "an unknown field from another client",
+ },
+ expectedAddress: {
+ "given-name": "Timothy",
+ "street-address": "331 E. Evelyn Avenue",
+ tel: "+16509030800",
+ country: "US",
+ "unknown-1": "an unknown field from another client",
+ },
+ },
+ {
+ description: "Loose merge a subset",
+ addressInStorage: {
+ "given-name": "Timothy",
+ "street-address": "331 E. Evelyn Avenue",
+ tel: "+16509030800",
+ country: "US",
+ },
+ addressToMerge: {
+ "given-name": "Timothy",
+ "street-address": "331 E. Evelyn Avenue",
+ tel: "+16509030800",
+ },
+ expectedAddress: {
+ "given-name": "Timothy",
+ "street-address": "331 E. Evelyn Avenue",
+ tel: "+16509030800",
+ country: "US",
+ },
+ noNeedToUpdate: true,
+ },
+ {
+ description: "Strict merge a subset without empty string",
+ addressInStorage: {
+ "given-name": "Timothy",
+ "street-address": "331 E. Evelyn Avenue",
+ tel: "+16509030800",
+ country: "US",
+ },
+ addressToMerge: {
+ "given-name": "Timothy",
+ "street-address": "331 E. Evelyn Avenue",
+ tel: "+16509030800",
+ },
+ expectedAddress: {
+ "given-name": "Timothy",
+ "street-address": "331 E. Evelyn Avenue",
+ tel: "+16509030800",
+ country: "US",
+ },
+ strict: true,
+ noNeedToUpdate: true,
+ },
+ {
+ description: "Merge an address with partial overlaps",
+ addressInStorage: {
+ "given-name": "Timothy",
+ "street-address": "331 E. Evelyn Avenue",
+ tel: "+16509030800",
+ },
+ addressToMerge: {
+ "street-address": "331 E. Evelyn Avenue",
+ tel: "+16509030800",
+ country: "US",
+ },
+ expectedAddress: {
+ "given-name": "Timothy",
+ "street-address": "331 E. Evelyn Avenue",
+ tel: "+16509030800",
+ country: "US",
+ },
+ },
+ {
+ description:
+ "Merge an address with multi-line street-address in storage and single-line incoming one",
+ addressInStorage: {
+ "given-name": "Timothy",
+ "street-address": "331 E. Evelyn Avenue\nLine2",
+ tel: "+16509030800",
+ },
+ addressToMerge: {
+ "street-address": "331 E. Evelyn Avenue Line2",
+ tel: "+16509030800",
+ country: "US",
+ },
+ expectedAddress: {
+ "given-name": "Timothy",
+ "street-address": "331 E. Evelyn Avenue\nLine2",
+ tel: "+16509030800",
+ country: "US",
+ },
+ },
+ {
+ description:
+ "Merge an address with 3-line street-address in storage and 2-line incoming one",
+ addressInStorage: {
+ "given-name": "Timothy",
+ "street-address": "331 E. Evelyn Avenue\nLine2\nLine3",
+ tel: "+16509030800",
+ },
+ addressToMerge: {
+ "street-address": "331 E. Evelyn Avenue\nLine2 Line3",
+ tel: "+16509030800",
+ country: "US",
+ },
+ expectedAddress: {
+ "given-name": "Timothy",
+ "street-address": "331 E. Evelyn Avenue\nLine2\nLine3",
+ tel: "+16509030800",
+ country: "US",
+ },
+ },
+ {
+ description:
+ "Merge an address with single-line street-address in storage and multi-line incoming one",
+ addressInStorage: {
+ "given-name": "Timothy",
+ "street-address": "331 E. Evelyn Avenue Line2",
+ tel: "+16509030800",
+ },
+ addressToMerge: {
+ "street-address": "331 E. Evelyn Avenue\nLine2",
+ tel: "+16509030800",
+ country: "US",
+ },
+ expectedAddress: {
+ "given-name": "Timothy",
+ "street-address": "331 E. Evelyn Avenue\nLine2",
+ tel: "+16509030800",
+ country: "US",
+ },
+ },
+ {
+ description:
+ "Merge an address with 2-line street-address in storage and 3-line incoming one",
+ addressInStorage: {
+ "given-name": "Timothy",
+ "street-address": "331 E. Evelyn Avenue\nLine2 Line3",
+ tel: "+16509030800",
+ },
+ addressToMerge: {
+ "street-address": "331 E. Evelyn Avenue\nLine2\nLine3",
+ tel: "+16509030800",
+ country: "US",
+ },
+ expectedAddress: {
+ "given-name": "Timothy",
+ "street-address": "331 E. Evelyn Avenue\nLine2\nLine3",
+ tel: "+16509030800",
+ country: "US",
+ },
+ },
+ {
+ description: "Merge an address with the same amount of lines",
+ addressInStorage: {
+ "given-name": "Timothy",
+ "street-address": "331 E. Evelyn Avenue\nLine2\nLine3",
+ tel: "+16509030800",
+ },
+ addressToMerge: {
+ "street-address": "331 E. Evelyn\nAvenue Line2\nLine3",
+ tel: "+16509030800",
+ country: "US",
+ },
+ expectedAddress: {
+ "given-name": "Timothy",
+ "street-address": "331 E. Evelyn Avenue\nLine2\nLine3",
+ tel: "+16509030800",
+ country: "US",
+ },
+ },
+ {
+ description:
+ "Merge an address with superfluous external and internal whitespace in the street-address",
+ addressInStorage: {
+ "given-name": "Timothy",
+ "street-address": "331 E. Evelyn Avenue\nLine2\nLine3",
+ tel: "+16509030800",
+ },
+ addressToMerge: {
+ "street-address": " 331 E. Evelyn\n Avenue Line2\n Line3 ",
+ country: "US",
+ },
+ expectedAddress: {
+ "given-name": "Timothy",
+ "street-address": "331 E. Evelyn Avenue\nLine2\nLine3",
+ tel: "+16509030800",
+ country: "US",
+ },
+ },
+ {
+ description: "Merge an address with collapsed whitespace",
+ addressInStorage: {
+ "given-name": "Timothy",
+ "street-address": "331 E. Evelyn Avenue",
+ tel: "+16509030800",
+ },
+ addressToMerge: {
+ "street-address": "331 E.Evelyn Avenue",
+ country: "US",
+ },
+ expectedAddress: {
+ "given-name": "Timothy",
+ "street-address": "331 E. Evelyn Avenue",
+ tel: "+16509030800",
+ country: "US",
+ },
+ },
+ {
+ description: "Merge an address with punctuation and mIxEd-cAsE",
+ addressInStorage: {
+ "given-name": "Timothy",
+ "street-address": "331 E. Evelyn Avenue",
+ tel: "+16509030800",
+ },
+ addressToMerge: {
+ "street-address": "331.e.EVELYN AVENUE",
+ country: "US",
+ },
+ expectedAddress: {
+ "given-name": "Timothy",
+ "street-address": "331 E. Evelyn Avenue",
+ tel: "+16509030800",
+ country: "US",
+ },
+ },
+ {
+ description: "Merge an address with accent characters",
+ addressInStorage: {
+ "given-name": "Timothy",
+ "street-address": "331 E. Evelyn Straße",
+ tel: "+16509030800",
+ },
+ addressToMerge: {
+ "street-address": "331.e.EVELYN Strasse",
+ country: "US",
+ },
+ expectedAddress: {
+ "given-name": "Timothy",
+ "street-address": "331 E. Evelyn Straße",
+ tel: "+16509030800",
+ country: "US",
+ },
+ },
+ {
+ description: "Merge an address with a mIxEd-cAsE name",
+ addressInStorage: {
+ "given-name": "Timothy",
+ tel: "+16509030800",
+ },
+ addressToMerge: {
+ "given-name": "TIMOTHY",
+ tel: "+16509030800",
+ country: "US",
+ },
+ expectedAddress: {
+ "given-name": "Timothy",
+ tel: "+16509030800",
+ country: "US",
+ },
+ },
+];
+
+ChromeUtils.defineESModuleGetters(this, {
+ Preferences: "resource://gre/modules/Preferences.sys.mjs",
+});
+
+let do_check_record_matches = (recordWithMeta, record) => {
+ for (let key in record) {
+ Assert.equal(recordWithMeta[key], record[key]);
+ }
+};
+
+add_task(async function test_initialize() {
+ let profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME);
+
+ Assert.equal(profileStorage._store.data.version, 1);
+ Assert.equal(profileStorage._store.data.addresses.length, 0);
+
+ let data = profileStorage._store.data;
+ Assert.deepEqual(data.addresses, []);
+
+ await profileStorage._saveImmediately();
+
+ profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME);
+
+ Assert.deepEqual(profileStorage._store.data, data);
+ for (let { _sync } of profileStorage._store.data.addresses) {
+ Assert.ok(_sync);
+ Assert.equal(_sync.changeCounter, 1);
+ }
+});
+
+add_task(async function test_getAll() {
+ let profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME, [
+ TEST_ADDRESS_1,
+ TEST_ADDRESS_2,
+ ]);
+
+ let addresses = await profileStorage.addresses.getAll();
+
+ Assert.equal(addresses.length, 2);
+ do_check_record_matches(addresses[0], TEST_ADDRESS_1);
+ do_check_record_matches(addresses[1], TEST_ADDRESS_2);
+
+ // Check computed fields.
+ Assert.equal(addresses[0].name, "Timothy John Berners-Lee");
+ Assert.equal(addresses[0]["address-line1"], "32 Vassar Street");
+ Assert.equal(addresses[0]["address-line2"], "MIT Room 32-G524");
+
+ // Test with rawData set.
+ addresses = await profileStorage.addresses.getAll({ rawData: true });
+ Assert.equal(addresses[0].name, undefined);
+ Assert.equal(addresses[0]["address-line1"], undefined);
+ Assert.equal(addresses[0]["address-line2"], undefined);
+
+ // Modifying output shouldn't affect the storage.
+ addresses[0].organization = "test";
+ do_check_record_matches(
+ (await profileStorage.addresses.getAll())[0],
+ TEST_ADDRESS_1
+ );
+});
+
+add_task(async function test_get() {
+ let profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME, [
+ TEST_ADDRESS_1,
+ TEST_ADDRESS_2,
+ ]);
+
+ let addresses = await profileStorage.addresses.getAll();
+ let guid = addresses[0].guid;
+
+ let address = await profileStorage.addresses.get(guid);
+ do_check_record_matches(address, TEST_ADDRESS_1);
+
+ // Test with rawData set.
+ address = await profileStorage.addresses.get(guid, { rawData: true });
+ Assert.equal(address.name, undefined);
+ Assert.equal(address["address-line1"], undefined);
+ Assert.equal(address["address-line2"], undefined);
+
+ // Modifying output shouldn't affect the storage.
+ address.organization = "test";
+ do_check_record_matches(
+ await profileStorage.addresses.get(guid),
+ TEST_ADDRESS_1
+ );
+
+ Assert.equal(await profileStorage.addresses.get("INVALID_GUID"), null);
+});
+
+add_task(async function test_add() {
+ let profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME, [
+ TEST_ADDRESS_1,
+ TEST_ADDRESS_2,
+ ]);
+
+ let addresses = await profileStorage.addresses.getAll();
+
+ Assert.equal(addresses.length, 2);
+
+ do_check_record_matches(addresses[0], TEST_ADDRESS_1);
+ do_check_record_matches(addresses[1], TEST_ADDRESS_2);
+
+ Assert.notEqual(addresses[0].guid, undefined);
+ Assert.equal(addresses[0].version, 1);
+ Assert.notEqual(addresses[0].timeCreated, undefined);
+ Assert.equal(addresses[0].timeLastModified, addresses[0].timeCreated);
+ Assert.equal(addresses[0].timeLastUsed, 0);
+ Assert.equal(addresses[0].timesUsed, 0);
+
+ // Empty string should be deleted before saving.
+ await profileStorage.addresses.add(TEST_ADDRESS_WITH_EMPTY_FIELD);
+ let address = profileStorage.addresses._data[2];
+ Assert.equal(address.name, TEST_ADDRESS_WITH_EMPTY_FIELD.name);
+ Assert.equal(address["street-address"], undefined);
+
+ // Empty computed fields shouldn't cause any problem.
+ await profileStorage.addresses.add(TEST_ADDRESS_WITH_EMPTY_COMPUTED_FIELD);
+ address = profileStorage.addresses._data[3];
+ Assert.equal(address.email, TEST_ADDRESS_WITH_EMPTY_COMPUTED_FIELD.email);
+
+ await Assert.rejects(
+ profileStorage.addresses.add(TEST_ADDRESS_WITH_INVALID_FIELD),
+ /"email" contains invalid data type: object/
+ );
+
+ await Assert.rejects(
+ profileStorage.addresses.add({}),
+ /Record contains no valid field\./
+ );
+
+ await Assert.rejects(
+ profileStorage.addresses.add(TEST_ADDRESS_EMPTY_AFTER_NORMALIZE),
+ /Record contains no valid field\./
+ );
+});
+
+add_task(async function test_update() {
+ // Test assumes that when an entry is saved a second time, it's last modified date will
+ // be different from the first. With high values of precision reduction, we execute too
+ // fast for that to be true.
+ let timerPrecision = Preferences.get("privacy.reduceTimerPrecision");
+ Preferences.set("privacy.reduceTimerPrecision", false);
+
+ registerCleanupFunction(function () {
+ Preferences.set("privacy.reduceTimerPrecision", timerPrecision);
+ });
+
+ let profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME, [
+ TEST_ADDRESS_1,
+ TEST_ADDRESS_2,
+ ]);
+
+ let addresses = await profileStorage.addresses.getAll();
+ let guid = addresses[1].guid;
+ // We need to cheat a little due to race conditions of Date.now() when
+ // we're running these tests, so we subtract one and test accordingly
+ // in the times Date.now() returns the same timestamp
+ let timeLastModified = addresses[1].timeLastModified - 1;
+
+ let onChanged = TestUtils.topicObserved(
+ "formautofill-storage-changed",
+ (subject, data) =>
+ data == "update" &&
+ subject.wrappedJSObject.guid == guid &&
+ subject.wrappedJSObject.collectionName == COLLECTION_NAME
+ );
+
+ Assert.notEqual(addresses[1].country, undefined);
+
+ await profileStorage.addresses.update(guid, TEST_ADDRESS_3);
+ await onChanged;
+ await profileStorage._saveImmediately();
+
+ profileStorage.addresses.pullSyncChanges(); // force sync metadata, which we check below.
+
+ let address = await profileStorage.addresses.get(guid, { rawData: true });
+
+ Assert.equal(address.country, undefined);
+ Assert.ok(address.timeLastModified > timeLastModified);
+ do_check_record_matches(address, TEST_ADDRESS_3);
+ Assert.equal(getSyncChangeCounter(profileStorage.addresses, guid), 1);
+
+ // Test preserveOldProperties parameter and field with empty string.
+ await profileStorage.addresses.update(
+ guid,
+ TEST_ADDRESS_WITH_EMPTY_FIELD,
+ true
+ );
+ await onChanged;
+ await profileStorage._saveImmediately();
+
+ profileStorage.addresses.pullSyncChanges(); // force sync metadata, which we check below.
+
+ address = await profileStorage.addresses.get(guid, { rawData: true });
+
+ Assert.equal(address["given-name"], "Tim");
+ Assert.equal(address["family-name"], "Berners");
+ Assert.equal(address["street-address"], undefined);
+ Assert.equal(address["postal-code"], "12345");
+ Assert.notEqual(address.timeLastModified, timeLastModified);
+ Assert.equal(getSyncChangeCounter(profileStorage.addresses, guid), 2);
+
+ // Empty string should be deleted while updating.
+ await profileStorage.addresses.update(
+ profileStorage.addresses._data[0].guid,
+ TEST_ADDRESS_WITH_EMPTY_FIELD
+ );
+ address = profileStorage.addresses._data[0];
+ Assert.equal(address.name, TEST_ADDRESS_WITH_EMPTY_FIELD.name);
+ Assert.equal(address["street-address"], undefined);
+ Assert.equal(address[("unknown-1", "an unknown field from another client")]);
+
+ // Empty computed fields shouldn't cause any problem.
+ await profileStorage.addresses.update(
+ profileStorage.addresses._data[0].guid,
+ TEST_ADDRESS_WITH_EMPTY_COMPUTED_FIELD,
+ false
+ );
+ address = profileStorage.addresses._data[0];
+ Assert.equal(address.email, TEST_ADDRESS_WITH_EMPTY_COMPUTED_FIELD.email);
+ await profileStorage.addresses.update(
+ profileStorage.addresses._data[1].guid,
+ TEST_ADDRESS_WITH_EMPTY_COMPUTED_FIELD,
+ true
+ );
+ address = profileStorage.addresses._data[1];
+ Assert.equal(address.email, TEST_ADDRESS_WITH_EMPTY_COMPUTED_FIELD.email);
+
+ await Assert.rejects(
+ profileStorage.addresses.update("INVALID_GUID", TEST_ADDRESS_3),
+ /No matching record\./
+ );
+
+ await Assert.rejects(
+ profileStorage.addresses.update(guid, TEST_ADDRESS_WITH_INVALID_FIELD),
+ /"email" contains invalid data type: object/
+ );
+
+ await Assert.rejects(
+ profileStorage.addresses.update(guid, {}),
+ /Record contains no valid field\./
+ );
+
+ await Assert.rejects(
+ profileStorage.addresses.update(guid, TEST_ADDRESS_EMPTY_AFTER_NORMALIZE),
+ /Record contains no valid field\./
+ );
+
+ profileStorage.addresses.update(guid, TEST_ADDRESS_2);
+ await Assert.rejects(
+ profileStorage.addresses.update(
+ guid,
+ TEST_ADDRESS_EMPTY_AFTER_UPDATE_ADDRESS_2
+ ),
+ /Record contains no valid field\./
+ );
+});
+
+add_task(async function test_notifyUsed() {
+ let profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME, [
+ TEST_ADDRESS_1,
+ TEST_ADDRESS_2,
+ ]);
+
+ let addresses = await profileStorage.addresses.getAll();
+ let guid = addresses[1].guid;
+ let timeLastUsed = addresses[1].timeLastUsed;
+ let timesUsed = addresses[1].timesUsed;
+
+ profileStorage.addresses.pullSyncChanges(); // force sync metadata, which we check below.
+ let changeCounter = getSyncChangeCounter(profileStorage.addresses, guid);
+
+ let onChanged = TestUtils.topicObserved(
+ "formautofill-storage-changed",
+ (subject, data) =>
+ data == "notifyUsed" &&
+ subject.wrappedJSObject.guid == guid &&
+ subject.wrappedJSObject.collectionName == COLLECTION_NAME
+ );
+
+ profileStorage.addresses.notifyUsed(guid);
+ await onChanged;
+
+ let address = await profileStorage.addresses.get(guid);
+
+ Assert.equal(address.timesUsed, timesUsed + 1);
+ Assert.notEqual(address.timeLastUsed, timeLastUsed);
+
+ // Using a record should not bump its change counter.
+ Assert.equal(
+ getSyncChangeCounter(profileStorage.addresses, guid),
+ changeCounter
+ );
+
+ Assert.throws(
+ () => profileStorage.addresses.notifyUsed("INVALID_GUID"),
+ /No matching record\./
+ );
+});
+
+add_task(async function test_remove() {
+ let profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME, [
+ TEST_ADDRESS_1,
+ TEST_ADDRESS_2,
+ ]);
+
+ let addresses = await profileStorage.addresses.getAll();
+ let guid = addresses[1].guid;
+
+ let onChanged = TestUtils.topicObserved(
+ "formautofill-storage-changed",
+ (subject, data) =>
+ data == "remove" &&
+ subject.wrappedJSObject.guid == guid &&
+ subject.wrappedJSObject.collectionName == COLLECTION_NAME
+ );
+
+ Assert.equal(addresses.length, 2);
+
+ profileStorage.addresses.remove(guid);
+ await onChanged;
+
+ addresses = await profileStorage.addresses.getAll();
+
+ Assert.equal(addresses.length, 1);
+
+ Assert.equal(await profileStorage.addresses.get(guid), null);
+});
+
+MERGE_TESTCASES.forEach(testcase => {
+ add_task(async function test_merge() {
+ info("Starting testcase: " + testcase.description);
+ let profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME, [
+ testcase.addressInStorage,
+ ]);
+ let addresses = await profileStorage.addresses.getAll();
+ let guid = addresses[0].guid;
+ // We need to cheat a little due to race conditions of Date.now() when
+ // we're running these tests, so we subtract one and test accordingly
+ // in the times Date.now() returns the same timestamp
+ let timeLastModified = addresses[0].timeLastModified - 1;
+
+ // Merge address and verify the guid in notifyObservers subject
+ let onMerged = TestUtils.topicObserved(
+ "formautofill-storage-changed",
+ (subject, data) =>
+ data == "update" &&
+ subject.wrappedJSObject.guid == guid &&
+ subject.wrappedJSObject.collectionName == COLLECTION_NAME
+ );
+
+ // Force to create sync metadata.
+ profileStorage.addresses.pullSyncChanges();
+ Assert.equal(getSyncChangeCounter(profileStorage.addresses, guid), 1);
+
+ Assert.ok(
+ profileStorage.addresses.mergeIfPossible(
+ guid,
+ testcase.addressToMerge,
+ testcase.strict
+ )
+ );
+ if (!testcase.noNeedToUpdate) {
+ await onMerged;
+ }
+
+ addresses = await profileStorage.addresses.getAll();
+ Assert.equal(addresses.length, 1);
+ do_check_record_matches(addresses[0], testcase.expectedAddress);
+ if (testcase.noNeedToUpdate) {
+ // see timeLastModified for why we check -1
+ Assert.equal(addresses[0].timeLastModified - 1, timeLastModified);
+
+ // No need to bump the change counter if the data is unchanged.
+ Assert.equal(getSyncChangeCounter(profileStorage.addresses, guid), 1);
+ } else {
+ Assert.ok(addresses[0].timeLastModified > timeLastModified);
+
+ // Record merging should bump the change counter.
+ Assert.equal(getSyncChangeCounter(profileStorage.addresses, guid), 2);
+ }
+ });
+});
+
+add_task(async function test_merge_same_address() {
+ let profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME, [
+ TEST_ADDRESS_1,
+ ]);
+ let addresses = await profileStorage.addresses.getAll();
+ let guid = addresses[0].guid;
+ let timeLastModified = addresses[0].timeLastModified;
+
+ // Force to create sync metadata.
+ profileStorage.addresses.pullSyncChanges();
+ Assert.equal(getSyncChangeCounter(profileStorage.addresses, guid), 1);
+
+ // Merge same address will still return true but it won't update timeLastModified.
+ Assert.ok(profileStorage.addresses.mergeIfPossible(guid, TEST_ADDRESS_1));
+ Assert.equal(addresses[0].timeLastModified, timeLastModified);
+
+ // ... and won't bump the change counter, either.
+ Assert.equal(getSyncChangeCounter(profileStorage.addresses, guid), 1);
+});
+
+add_task(async function test_merge_unable_merge() {
+ let profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME, [
+ TEST_ADDRESS_1,
+ TEST_ADDRESS_2,
+ ]);
+
+ let addresses = await profileStorage.addresses.getAll();
+ let guid = addresses[1].guid;
+
+ // Force to create sync metadata.
+ profileStorage.addresses.pullSyncChanges();
+ Assert.equal(getSyncChangeCounter(profileStorage.addresses, guid), 1);
+
+ // Unable to merge because of conflict
+ Assert.equal(
+ await profileStorage.addresses.mergeIfPossible(guid, TEST_ADDRESS_3),
+ false
+ );
+
+ // Unable to merge because no overlap
+ Assert.equal(
+ await profileStorage.addresses.mergeIfPossible(guid, TEST_ADDRESS_4),
+ false
+ );
+
+ // Unable to strict merge because subset with empty string
+ let subset = Object.assign({}, TEST_ADDRESS_1);
+ subset.organization = "";
+ Assert.equal(
+ await profileStorage.addresses.mergeIfPossible(guid, subset, true),
+ false
+ );
+
+ // Shouldn't bump the change counter
+ Assert.equal(getSyncChangeCounter(profileStorage.addresses, guid), 1);
+});
+
+add_task(async function test_mergeToStorage() {
+ let profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME, [
+ TEST_ADDRESS_1,
+ TEST_ADDRESS_2,
+ ]);
+ // Merge an address to storage
+ let anotherAddress = profileStorage.addresses._clone(TEST_ADDRESS_2);
+ await profileStorage.addresses.add(anotherAddress);
+ anotherAddress.email = "timbl@w3.org";
+ Assert.equal(
+ (await profileStorage.addresses.mergeToStorage(anotherAddress)).length,
+ 2
+ );
+
+ Assert.equal(
+ (await profileStorage.addresses.getAll())[1].email,
+ anotherAddress.email
+ );
+ Assert.equal(
+ (await profileStorage.addresses.getAll())[2].email,
+ anotherAddress.email
+ );
+
+ // Empty computed fields shouldn't cause any problem.
+ Assert.equal(
+ (
+ await profileStorage.addresses.mergeToStorage(
+ TEST_ADDRESS_WITH_EMPTY_COMPUTED_FIELD
+ )
+ ).length,
+ 3
+ );
+});
+
+add_task(async function test_mergeToStorage_strict() {
+ let profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME, [
+ TEST_ADDRESS_1,
+ TEST_ADDRESS_2,
+ ]);
+ // Try to merge a subset with empty string
+ let anotherAddress = profileStorage.addresses._clone(TEST_ADDRESS_1);
+ anotherAddress.email = "";
+ Assert.equal(
+ (await profileStorage.addresses.mergeToStorage(anotherAddress, true))
+ .length,
+ 0
+ );
+ Assert.equal(
+ (await profileStorage.addresses.getAll())[0].email,
+ TEST_ADDRESS_1.email
+ );
+
+ // Empty computed fields shouldn't cause any problem.
+ Assert.equal(
+ (
+ await profileStorage.addresses.mergeToStorage(
+ TEST_ADDRESS_WITH_EMPTY_COMPUTED_FIELD,
+ true
+ )
+ ).length,
+ 1
+ );
+});
diff --git a/browser/extensions/formautofill/test/unit/test_autofillFormFields.js b/browser/extensions/formautofill/test/unit/test_autofillFormFields.js
new file mode 100644
index 0000000000..70de21cfe5
--- /dev/null
+++ b/browser/extensions/formautofill/test/unit/test_autofillFormFields.js
@@ -0,0 +1,1078 @@
+/*
+ * Test for form auto fill content helper fill all inputs function.
+ */
+/* eslint-disable mozilla/no-arbitrary-setTimeout */
+
+"use strict";
+
+const { setTimeout, clearTimeout } = ChromeUtils.importESModule(
+ "resource://gre/modules/Timer.sys.mjs"
+);
+const { OSKeyStore } = ChromeUtils.importESModule(
+ "resource://gre/modules/OSKeyStore.sys.mjs"
+);
+
+const TESTCASES = [
+ {
+ description: "Form without autocomplete property",
+ document: `<form><input id="given-name"><input id="family-name">
+ <input id="street-addr"><input id="city"><select id="country"></select>
+ <input id='email'><input id="tel"></form>`,
+ focusedInputId: "given-name",
+ profileData: {},
+ expectedResult: {
+ "street-addr": "",
+ city: "",
+ country: "",
+ email: "",
+ tel: "",
+ },
+ },
+ {
+ description: "Form with autocomplete properties and 1 token",
+ document: `<form><input id="given-name" autocomplete="given-name">
+ <input id="family-name" autocomplete="family-name">
+ <input id="street-addr" autocomplete="street-address">
+ <input id="city" autocomplete="address-level2">
+ <select id="country" autocomplete="country">
+ <option/>
+ <option value="US">United States</option>
+ </select>
+ <input id="email" autocomplete="email">
+ <input id="tel" autocomplete="tel"></form>`,
+ focusedInputId: "given-name",
+ profileData: {
+ guid: "123",
+ "street-address": "2 Harrison St line2",
+ "-moz-street-address-one-line": "2 Harrison St line2",
+ "address-level2": "San Francisco",
+ country: "US",
+ email: "foo@mozilla.com",
+ tel: "1234567",
+ },
+ expectedResult: {
+ "street-addr": "2 Harrison St line2",
+ city: "San Francisco",
+ country: "US",
+ email: "foo@mozilla.com",
+ tel: "1234567",
+ },
+ },
+ {
+ description: "Form with autocomplete properties and 2 tokens",
+ document: `<form><input id="given-name" autocomplete="shipping given-name">
+ <input id="family-name" autocomplete="shipping family-name">
+ <input id="street-addr" autocomplete="shipping street-address">
+ <input id="city" autocomplete="shipping address-level2">
+ <select id="country" autocomplete="shipping country">
+ <option/>
+ <option value="US">United States</option>
+ </select>
+ <input id='email' autocomplete="shipping email">
+ <input id="tel" autocomplete="shipping tel"></form>`,
+ focusedInputId: "given-name",
+ profileData: {
+ guid: "123",
+ "street-address": "2 Harrison St",
+ "address-level2": "San Francisco",
+ country: "US",
+ email: "foo@mozilla.com",
+ tel: "1234567",
+ },
+ expectedResult: {
+ "street-addr": "2 Harrison St",
+ city: "San Francisco",
+ country: "US",
+ email: "foo@mozilla.com",
+ tel: "1234567",
+ },
+ },
+ {
+ description:
+ "Form with autocomplete properties and profile is partly matched",
+ document: `<form><input id="given-name" autocomplete="shipping given-name">
+ <input id="family-name" autocomplete="shipping family-name">
+ <input id="street-addr" autocomplete="shipping street-address">
+ <input id="city" autocomplete="shipping address-level2">
+ <input id="country" autocomplete="shipping country">
+ <input id='email' autocomplete="shipping email">
+ <input id="tel" autocomplete="shipping tel"></form>`,
+ focusedInputId: "given-name",
+ profileData: {
+ guid: "123",
+ "street-address": "2 Harrison St",
+ "address-level2": "San Francisco",
+ country: "US",
+ email: "",
+ tel: "",
+ },
+ expectedResult: {
+ "street-addr": "2 Harrison St",
+ city: "San Francisco",
+ country: "US",
+ email: "",
+ tel: "",
+ },
+ },
+ {
+ description: "Form with autocomplete properties but mismatched",
+ document: `<form><input id="given-name" autocomplete="shipping given-name">
+ <input id="family-name" autocomplete="shipping family-name">
+ <input id="street-addr" autocomplete="billing street-address">
+ <input id="city" autocomplete="billing address-level2">
+ <input id="country" autocomplete="billing country">
+ <input id='email' autocomplete="shipping email">
+ <input id="tel" autocomplete="shipping tel"></form>`,
+ focusedInputId: "given-name",
+ profileData: {
+ guid: "123",
+ "street-address": "",
+ "address-level2": "",
+ country: "",
+ email: "foo@mozilla.com",
+ tel: "1234567",
+ },
+ expectedResult: {
+ "street-addr": "",
+ city: "",
+ country: "",
+ email: "foo@mozilla.com",
+ tel: "1234567",
+ },
+ },
+ {
+ description: `Form with elements that have autocomplete set to "off"`,
+ document: `<form>
+ <input id="given-name" autocomplete="off">
+ <input id="family-name" autocomplete="off">
+ <input id="street-address" autocomplete="off">
+ <input id="organization" autocomplete="off">
+ <input id="country" autocomplete="off">
+ </form>`,
+ focusedInputId: "given-name",
+ profileData: {
+ guid: "123",
+ "given-name": "John",
+ "family-name": "Doe",
+ "street-address": "2 Harrison St",
+ country: "US",
+ organization: "Test organization",
+ },
+ expectedResult: {
+ "given-name": "John",
+ "family-name": "Doe",
+ "street-address": "2 Harrison St",
+ organization: "Test organization",
+ country: "US",
+ },
+ },
+ {
+ description: `Form with autocomplete set to "off" and no autocomplete attribute on the form's elements`,
+ document: `<form autocomplete="off">
+ <input id="given-name">
+ <input id="family-name">
+ <input id="street-address">
+ <input id="city">
+ <input id="country">
+ </form>`,
+ focusedInputId: "given-name",
+ profileData: {
+ guid: "123",
+ "given-name": "John",
+ "family-name": "Doe",
+ "street-address": "2 Harrison St",
+ country: "US",
+ "address-level2": "Somewhere",
+ },
+ expectedResult: {
+ "given-name": "John",
+ "family-name": "Doe",
+ "street-address": "2 Harrison St",
+ city: "Somewhere",
+ country: "US",
+ },
+ },
+ {
+ description:
+ "Form with autocomplete select elements and matching option values",
+ document: `<form>
+ <input id="given-name" autocomplete="shipping given-name">
+ <select id="country" autocomplete="shipping country">
+ <option value=""></option>
+ <option value="US">United States</option>
+ </select>
+ <select id="state" autocomplete="shipping address-level1">
+ <option value=""></option>
+ <option value="CA">California</option>
+ <option value="WA">Washington</option>
+ </select>
+ </form>`,
+ focusedInputId: "given-name",
+ profileData: {
+ guid: "123",
+ country: "US",
+ "address-level1": "CA",
+ },
+ expectedResult: {
+ country: "US",
+ state: "CA",
+ },
+ },
+ {
+ description:
+ "Form with autocomplete select elements and matching option texts",
+ document: `<form>
+ <input id="given-name" autocomplete="shipping given-name">
+ <select id="country" autocomplete="shipping country">
+ <option value=""></option>
+ <option value="US">United States</option>
+ </select>
+ <select id="state" autocomplete="shipping address-level1">
+ <option value=""></option>
+ <option value="CA">California</option>
+ <option value="WA">Washington</option>
+ </select>
+ </form>`,
+ focusedInputId: "given-name",
+ profileData: {
+ guid: "123",
+ country: "United States",
+ "address-level1": "California",
+ },
+ expectedResult: {
+ country: "US",
+ state: "CA",
+ },
+ },
+ {
+ description: "Form with a readonly input and non-readonly inputs",
+ 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" readonly value="TEST CITY">
+ </form>`,
+ focusedInputId: "given-name",
+ profileData: {
+ guid: "123",
+ "given-name": "John",
+ "family-name": "Doe",
+ "street-address": "100 Main Street",
+ city: "Hamilton",
+ },
+ expectedResult: {
+ "given-name": "John",
+ "family-name": "Doe",
+ "street-addr": "100 Main Street",
+ city: "TEST CITY",
+ },
+ },
+ {
+ description: "Fill address fields in a form with addr and CC fields.",
+ document: `<form>
+ <input id="given-name" autocomplete="given-name">
+ <input id="family-name" autocomplete="family-name">
+ <input id="street-addr" autocomplete="street-address">
+ <input id="city" autocomplete="address-level2">
+ <select id="country" autocomplete="country">
+ <option/>
+ <option value="US">United States</option>
+ </select>
+ <input id="email" autocomplete="email">
+ <input id="tel" autocomplete="tel">
+ <input id="cc-number" autocomplete="cc-number">
+ <input id="cc-name" autocomplete="cc-name">
+ <input id="cc-exp-month" autocomplete="cc-exp-month">
+ <input id="cc-exp-year" autocomplete="cc-exp-year">
+ </form>`,
+ focusedInputId: "given-name",
+ profileData: {
+ guid: "123",
+ "street-address": "2 Harrison St line2",
+ "-moz-street-address-one-line": "2 Harrison St line2",
+ "address-level2": "San Francisco",
+ country: "US",
+ email: "foo@mozilla.com",
+ tel: "1234567",
+ },
+ expectedResult: {
+ "street-addr": "2 Harrison St line2",
+ city: "San Francisco",
+ country: "US",
+ email: "foo@mozilla.com",
+ tel: "1234567",
+ "cc-number": "",
+ "cc-name": "",
+ "cc-exp-month": "",
+ "cc-exp-year": "",
+ },
+ },
+ {
+ description:
+ "Fill credit card fields in a form with address and CC fields.",
+ document: `<form>
+ <input id="given-name" autocomplete="given-name">
+ <input id="family-name" autocomplete="family-name">
+ <input id="street-addr" autocomplete="street-address">
+ <input id="city" autocomplete="address-level2">
+ <select id="country" autocomplete="country">
+ <option/>
+ <option value="US">United States</option>
+ </select>
+ <input id="email" autocomplete="email">
+ <input id="tel" autocomplete="tel">
+ <input id="cc-number" autocomplete="cc-number">
+ <input id="cc-name" autocomplete="cc-name">
+ <input id="cc-exp-month" autocomplete="cc-exp-month">
+ <input id="cc-exp-year" autocomplete="cc-exp-year">
+ </form>`,
+ focusedInputId: "cc-number",
+ profileData: {
+ guid: "123",
+ "cc-number": "4111111111111111",
+ "cc-name": "test name",
+ "cc-exp-month": 6,
+ "cc-exp-year": 25,
+ },
+ expectedResult: {
+ "street-addr": "",
+ city: "",
+ country: "",
+ email: "",
+ tel: "",
+ "cc-number": "4111111111111111",
+ "cc-name": "test name",
+ "cc-exp-month": "06",
+ "cc-exp-year": "25",
+ },
+ },
+ {
+ description:
+ "Fill credit card fields in a form with a placeholder on expiration month input field",
+ document: `<form>
+ <input id="cc-number" autocomplete="cc-number">
+ <input id="cc-name" autocomplete="cc-name">
+ <input id="cc-exp-month" autocomplete="cc-exp-month" placeholder="MM">
+ <input id="cc-exp-year" autocomplete="cc-exp-year">
+ </form>
+ `,
+ focusedInputId: "cc-number",
+ profileData: {
+ guid: "123",
+ "cc-number": "4111111111111111",
+ "cc-name": "test name",
+ "cc-exp-month": 6,
+ "cc-exp-year": 25,
+ },
+ expectedResult: {
+ "cc-number": "4111111111111111",
+ "cc-name": "test name",
+ "cc-exp-month": "06",
+ "cc-exp-year": "25",
+ },
+ },
+ {
+ description:
+ "Fill credit card fields in a form without a placeholder on expiration month and expiration year input fields",
+ document: `<form>
+ <input id="cc-number" autocomplete="cc-number">
+ <input id="cc-name" autocomplete="cc-name">
+ <input id="cc-exp-month" autocomplete="cc-exp-month">
+ <input id="cc-exp-year" autocomplete="cc-exp-year">
+ </form>
+ `,
+ focusedInputId: "cc-number",
+ profileData: {
+ guid: "123",
+ "cc-number": "4111111111111111",
+ "cc-name": "test name",
+ "cc-exp-month": 6,
+ "cc-exp-year": 25,
+ },
+ expectedResult: {
+ "cc-number": "4111111111111111",
+ "cc-name": "test name",
+ "cc-exp-month": "06",
+ "cc-exp-year": "25",
+ },
+ },
+ {
+ description:
+ "Fill credit card fields in a form with a placeholder on expiration year input field",
+ document: `<form>
+ <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" placeholder="YY">
+ </form>
+ `,
+ focusedInputId: "cc-number",
+ profileData: {
+ guid: "123",
+ "cc-number": "4111111111111111",
+ "cc-name": "test name",
+ "cc-exp-month": 6,
+ "cc-exp-year": 2025,
+ },
+ expectedResult: {
+ "cc-number": "4111111111111111",
+ "cc-name": "test name",
+ "cc-exp-month": "06",
+ "cc-exp-year": "25",
+ },
+ },
+ {
+ description:
+ "Form with hidden input and visible input that share the same autocomplete attribute",
+ document: `<form>
+ <input id="hidden-cc" autocomplete="cc-number" hidden>
+ <input id="hidden-cc-2" autocomplete="cc-number" style="display:none">
+ <input id="visible-cc" autocomplete="cc-number">
+ <input id="hidden-name" autocomplete="cc-name" hidden>
+ <input id="hidden-name-2" autocomplete="cc-name" style="display:none">
+ <input id="visible-name" autocomplete="cc-name">
+ <input id="cc-exp-month" autocomplete="cc-exp-month">
+ <input id="cc-exp-year" autocomplete="cc-exp-year">
+ </form>`,
+ focusedInputId: "visible-cc",
+ profileData: {
+ guid: "123",
+ "cc-number": "4111111111111111",
+ "cc-name": "test name",
+ "cc-exp-month": 6,
+ "cc-exp-year": 25,
+ },
+ expectedResult: {
+ guid: "123",
+ "visible-cc": "4111111111111111",
+ "visible-name": "test name",
+ "cc-exp-month": "06",
+ "cc-exp-year": "25",
+ "hidden-cc": undefined,
+ "hidden-cc-2": undefined,
+ "hidden-name": undefined,
+ "hidden-name-2": undefined,
+ },
+ },
+ {
+ description:
+ "Fill credit card fields in a form where the value property is being used as a placeholder for cardholder name",
+ document: `<form>
+ <input id="cc-number" autocomplete="cc-number">
+ <input id="cc-name" autocomplete="cc-name" value="JOHN DOE">
+ <input id="cc-exp-month" autocomplete="cc-exp-month">
+ <input id="cc-exp-year" autocomplete="cc-exp-year">
+ </form>`,
+ focusedInputId: "cc-number",
+ profileData: {
+ guid: "123",
+ "cc-number": "4111111111111111",
+ "cc-name": "test name",
+ "cc-exp-month": 6,
+ "cc-exp-year": 25,
+ },
+ expectedResult: {
+ guid: "123",
+ "cc-number": "4111111111111111",
+ "cc-name": "test name",
+ "cc-exp-month": "06",
+ "cc-exp-year": "25",
+ },
+ },
+ {
+ description:
+ "Fill credit card number fields in a form with multiple cc-number inputs",
+ document: `<form>
+ <input id="cc-number1" maxlength="4">
+ <input id="cc-number2" maxlength="4">
+ <input id="cc-number3" maxlength="4">
+ <input id="cc-number4" maxlength="4">
+ <input id="cc-exp-month" autocomplete="cc-exp-month">
+ <input id="cc-exp-year" autocomplete="cc-exp-year">
+ </form>`,
+ focusedInputId: "cc-number1",
+ profileData: {
+ guid: "123",
+ "cc-number": "371449635398431",
+ "cc-exp-month": 6,
+ "cc-exp-year": 25,
+ },
+ expectedResult: {
+ guid: "123",
+ "cc-number1": "3714",
+ "cc-number2": "4963",
+ "cc-number3": "5398",
+ "cc-number4": "431",
+ "cc-exp-month": "06",
+ "cc-exp-year": "25",
+ },
+ },
+ {
+ description:
+ "Fill credit card number fields in a form with multiple valid credit card sections",
+ document: `<form>
+ <input id="cc-type1">
+ <input id="cc-number1" maxlength="4">
+ <input id="cc-number2" maxlength="4">
+ <input id="cc-number3" maxlength="4">
+ <input id="cc-number4" maxlength="4">
+ <input id="cc-exp-month1">
+ <input id="cc-exp-year1">
+ <input id="cc-type2">
+ <input id="cc-number5" maxlength="4">
+ <input id="cc-number6" maxlength="4">
+ <input id="cc-number7" maxlength="4">
+ <input id="cc-number8" maxlength="4">
+ <input id="cc-exp-month2">
+ <input id="cc-exp-year2">
+ <input>
+ <input>
+ <input>
+ </form>
+ `,
+ focusedInputId: "cc-number1",
+ profileData: {
+ guid: "123",
+ "cc-type": "mastercard",
+ "cc-number": "371449635398431",
+ "cc-exp-month": 6,
+ "cc-exp-year": 25,
+ },
+ expectedResult: {
+ guid: "123",
+ "cc-type1": "mastercard",
+ "cc-number1": "3714",
+ "cc-number2": "4963",
+ "cc-number3": "5398",
+ "cc-number4": "431",
+ "cc-exp-month1": "06",
+ "cc-exp-year1": "25",
+ "cc-type2": "",
+ "cc-number-5": "",
+ "cc-number-6": "",
+ "cc-number-7": "",
+ "cc-number-8": "",
+ "cc-exp-month2": "",
+ "cc-exp-year2": "",
+ },
+ },
+ {
+ description:
+ "Fill credit card fields in a form with placeholders on month and year and these inputs are type=tel",
+ document: `<form>
+ <input id="cardHolder">
+ <input id="cardNumber">
+ <input id="month" type="tel" name="month" placeholder="MM">
+ <input id="year" type="tel" name="year" placeholder="YY">
+ </form>
+ `,
+ focusedInputId: "cardHolder",
+ profileData: {
+ guid: "123",
+ "cc-number": "4111111111111111",
+ "cc-name": "test name",
+ "cc-exp-month": 6,
+ "cc-exp-year": 2025,
+ },
+ expectedResult: {
+ cardHolder: "test name",
+ cardNumber: "4111111111111111",
+ month: "06",
+ year: "25",
+ },
+ },
+];
+
+const TESTCASES_INPUT_UNCHANGED = [
+ {
+ description:
+ "Form with autocomplete select elements; with default and no matching options",
+ document: `<form>
+ <input id="given-name" autocomplete="shipping given-name">
+ <select id="country" autocomplete="shipping country">
+ <option value="US">United States</option>
+ </select>
+ <select id="state" autocomplete="shipping address-level1">
+ <option value=""></option>
+ <option value="CA">California</option>
+ <option value="WA">Washington</option>
+ </select>
+ </form>`,
+ focusedInputId: "given-name",
+ profileData: {
+ guid: "123",
+ country: "US",
+ "address-level1": "unknown state",
+ },
+ expectedResult: {
+ country: "US",
+ state: "",
+ },
+ },
+];
+
+const TESTCASES_FILL_SELECT = [
+ // US States
+ {
+ description: "Form with US states select elements",
+ document: `<form>
+ <input id="given-name" autocomplete="shipping given-name">
+ <input id="family-name" autocomplete="shipping family-name">
+ <select id="state" autocomplete="shipping address-level1">
+ <option value=""></option>
+ <option value="CA">California</option>
+ </select></form>`,
+ focusedInputId: "given-name",
+ profileData: {
+ guid: "123",
+ country: "US",
+ "address-level1": "CA",
+ },
+ expectedResult: {
+ state: "CA",
+ },
+ },
+ {
+ description:
+ "Form with US states select elements; with lower case state key",
+ document: `<form>
+ <input id="given-name" autocomplete="shipping given-name">
+ <input id="family-name" autocomplete="shipping family-name">
+ <select id="state" autocomplete="shipping address-level1">
+ <option value=""></option>
+ <option value="ca">ca</option>
+ </select></form>`,
+ focusedInputId: "given-name",
+ profileData: {
+ guid: "123",
+ country: "US",
+ "address-level1": "CA",
+ },
+ expectedResult: {
+ state: "ca",
+ },
+ },
+ {
+ description:
+ "Form with US states select elements; with state name and extra spaces",
+ document: `<form>
+ <input id="given-name" autocomplete="shipping given-name">
+ <input id="family-name" autocomplete="shipping family-name">
+ <select id="state" autocomplete="shipping address-level1">
+ <option value=""></option>
+ <option value="CA">CA</option>
+ </select></form>`,
+ focusedInputId: "given-name",
+ profileData: {
+ guid: "123",
+ country: "US",
+ "address-level1": " California ",
+ },
+ expectedResult: {
+ state: "CA",
+ },
+ },
+ {
+ description:
+ "Form with US states select elements; with partial state key match",
+ document: `<form>
+ <input id="given-name" autocomplete="shipping given-name">
+ <input id="family-name" autocomplete="shipping family-name">
+ <select id="state" autocomplete="shipping address-level1">
+ <option value=""></option>
+ <option value="US-WA">WA-Washington</option>
+ </select></form>`,
+ focusedInputId: "given-name",
+ profileData: {
+ guid: "123",
+ country: "US",
+ "address-level1": "WA",
+ },
+ expectedResult: {
+ state: "US-WA",
+ },
+ },
+
+ // Country
+ {
+ description: "Form with country select elements",
+ document: `<form>
+ <input id="given-name" autocomplete="given-name">
+ <input id="family-name" autocomplete="family-name">
+ <select id="country" autocomplete="country">
+ <option value=""></option>
+ <option value="US">United States</option>
+ </select></form>`,
+ focusedInputId: "given-name",
+ profileData: {
+ guid: "123",
+ country: "US",
+ },
+ expectedResult: {
+ country: "US",
+ },
+ },
+ {
+ description: "Form with country select elements; with lower case key",
+ document: `<form>
+ <input id="given-name" autocomplete="given-name">
+ <input id="family-name" autocomplete="family-name">
+ <select id="country" autocomplete="country">
+ <option value=""></option>
+ <option value="us">us</option>
+ </select></form>`,
+ focusedInputId: "given-name",
+ profileData: {
+ guid: "123",
+ country: "US",
+ },
+ expectedResult: {
+ country: "us",
+ },
+ },
+ {
+ description: "Form with country select elements; with alternative name 1",
+ document: `<form>
+ <input id="given-name" autocomplete="given-name">
+ <input id="family-name" autocomplete="family-name">
+ <select id="country" autocomplete="country">
+ <option value=""></option>
+ <option value="XX">United States</option>
+ </select></form>`,
+ focusedInputId: "given-name",
+ profileData: {
+ guid: "123",
+ country: "US",
+ },
+ expectedResult: {
+ country: "XX",
+ },
+ },
+ {
+ description: "Form with country select elements; with alternative name 2",
+ document: `<form>
+ <input id="given-name" autocomplete="given-name">
+ <input id="family-name" autocomplete="family-name">
+ <select id="country" autocomplete="country">
+ <option value=""></option>
+ <option value="XX">America</option>
+ </select></form>`,
+ focusedInputId: "given-name",
+ profileData: {
+ guid: "123",
+ country: "US",
+ },
+ expectedResult: {
+ country: "XX",
+ },
+ },
+ {
+ description:
+ "Form with country select elements; with partial matching value",
+ document: `<form>
+ <input id="given-name" autocomplete="given-name">
+ <input id="family-name" autocomplete="family-name">
+ <select id="country" autocomplete="country">
+ <option value=""></option>
+ <option value="XX">Ship to America</option>
+ </select></form>`,
+ focusedInputId: "given-name",
+ profileData: {
+ guid: "123",
+ country: "US",
+ },
+ expectedResult: {
+ country: "XX",
+ },
+ },
+ {
+ description:
+ "Fill credit card expiration month field in a form with select field",
+ document: `<form>
+ <input id="cc-number" autocomplete="cc-number">
+ <input id="cc-name" autocomplete="cc-name">
+ <select id="cc-exp-month" autocomplete="cc-exp-month">
+ <option value="">MM</option>
+ <option value="6">06</option>
+ </select></form>`,
+ focusedInputId: "cc-number",
+ profileData: {
+ guid: "123",
+ "cc-number": "4111111111111111",
+ "cc-name": "test name",
+ "cc-exp-month": 6,
+ "cc-exp-year": 25,
+ },
+ expectedResult: {
+ "cc-number": "4111111111111111",
+ "cc-name": "test name",
+ "cc-exp-month": "6",
+ "cc-exp-year": "2025",
+ },
+ },
+ {
+ description:
+ "Fill credit card information correctly when one of the card type options is 'American Express'",
+ document: `<form>
+ <select id="cc-type" autocomplete="cc-type">
+ <option value="">Please select</option>
+ <option value="MA">Mastercard</option>
+ <option value="AX">American Express</option>
+ </select>
+ <input id="cc-number" autocomplete="cc-number">
+ <input id="cc-name" autocomplete="cc-name">
+ <input id="cc-exp-month" autocomplete="cc-exp-month">
+ <input id="cc-exp-year" autocomplete="cc-exp-year">
+ </form>`,
+ focusedInputId: "cc-number",
+ profileData: {
+ guid: "123",
+ "cc-number": "378282246310005",
+ "cc-type": "amex",
+ "cc-name": "test name",
+ "cc-exp-month": 8,
+ "cc-exp-year": 26,
+ },
+ expectedResult: {
+ guid: "123",
+ "cc-number": "378282246310005",
+ "cc-type": "AX",
+ "cc-name": "test name",
+ "cc-exp-month": 8,
+ "cc-exp-year": 26,
+ },
+ },
+];
+
+const TESTCASES_BOTH_CHANGED_AND_UNCHANGED = [
+ {
+ description:
+ "Form with a disabled input and non-disabled inputs. The 'country' field should not change",
+ 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="country" autocomplete="country" disabled value="DE">
+ </form>`,
+ focusedInputId: "given-name",
+ profileData: {
+ guid: "123",
+ "given-name": "John",
+ "family-name": "Doe",
+ "street-address": "100 Main Street",
+ country: "CA",
+ },
+ expectedResult: {
+ "given-name": "John",
+ "family-name": "Doe",
+ "street-addr": "100 Main Street",
+ country: "DE",
+ },
+ },
+];
+
+function do_test(testcases, testFn) {
+ for (let tc of testcases) {
+ (function () {
+ let testcase = tc;
+ add_task(async function () {
+ info("Starting testcase: " + testcase.description);
+ let ccNumber = testcase.profileData["cc-number"];
+ if (ccNumber) {
+ testcase.profileData["cc-number-encrypted"] =
+ await OSKeyStore.encrypt(ccNumber);
+ delete testcase.profileData["cc-number"];
+ }
+
+ let doc = MockDocument.createTestDocument(
+ "http://localhost:8080/test/",
+ testcase.document
+ );
+ let form = doc.querySelector("form");
+ let formLike = FormLikeFactory.createFromForm(form);
+ let handler = new FormAutofillHandler(formLike);
+ let promises = [];
+ // Replace the internal decrypt method with OSKeyStore API,
+ // but don't pass the reauth parameter to avoid triggering
+ // reauth login dialog in these tests.
+ let decryptHelper = async (cipherText, reauth) => {
+ return OSKeyStore.decrypt(cipherText, false);
+ };
+ handler.collectFormFields();
+
+ let focusedInput = doc.getElementById(testcase.focusedInputId);
+ try {
+ handler.focusedInput = focusedInput;
+ } catch (e) {
+ if (e.message.includes("WeakMap key must be an object")) {
+ throw new Error(
+ `Couldn't find the focusedInputId in the current form! Make sure focusedInputId exists in your test form! testcase description:${testcase.description}`
+ );
+ } else {
+ throw e;
+ }
+ }
+
+ for (let section of handler.sections) {
+ section._decrypt = decryptHelper;
+ }
+
+ handler.activeSection.fieldDetails.forEach(field => {
+ let element = field.elementWeakRef.get();
+ if (!testcase.profileData[field.fieldName]) {
+ // Avoid waiting for `change` event of a input with a blank value to
+ // be filled.
+ return;
+ }
+ promises.push(...testFn(testcase, element));
+ });
+
+ let [adaptedProfile] = handler.activeSection.getAdaptedProfiles([
+ testcase.profileData,
+ ]);
+ await handler.autofillFormFields(adaptedProfile, focusedInput);
+ Assert.equal(
+ handler.activeSection.filledRecordGUID,
+ testcase.profileData.guid,
+ "Check if filledRecordGUID is set correctly"
+ );
+ await Promise.all(promises);
+ });
+ })();
+ }
+}
+
+do_test(TESTCASES, (testcase, element) => {
+ let id = element.id;
+ return [
+ new Promise(resolve => {
+ element.addEventListener(
+ "input",
+ () => {
+ Assert.ok(true, "Checking " + id + " field fires input event");
+ resolve();
+ },
+ { once: true }
+ );
+ }),
+ new Promise(resolve => {
+ element.addEventListener(
+ "change",
+ () => {
+ Assert.ok(true, "Checking " + id + " field fires change event");
+ Assert.equal(
+ element.value,
+ testcase.expectedResult[id],
+ "Check the " + id + " field was filled with correct data"
+ );
+ resolve();
+ },
+ { once: true }
+ );
+ }),
+ ];
+});
+
+do_test(TESTCASES_INPUT_UNCHANGED, (testcase, element) => {
+ return [
+ new Promise((resolve, reject) => {
+ // Make sure no change or input event is fired when no change occurs.
+ let cleaner;
+ let timer = setTimeout(() => {
+ let id = element.id;
+ element.removeEventListener("change", cleaner);
+ element.removeEventListener("input", cleaner);
+ Assert.equal(
+ element.value,
+ testcase.expectedResult[id],
+ "Check no value is changed on the " + id + " field"
+ );
+ resolve();
+ }, 1000);
+ cleaner = event => {
+ clearTimeout(timer);
+ reject(`${event.type} event should not fire`);
+ };
+ element.addEventListener("change", cleaner);
+ element.addEventListener("input", cleaner);
+ }),
+ ];
+});
+
+do_test(TESTCASES_FILL_SELECT, (testcase, element) => {
+ let id = element.id;
+ return [
+ new Promise(resolve => {
+ element.addEventListener(
+ "input",
+ () => {
+ Assert.equal(
+ element.value,
+ testcase.expectedResult[id],
+ "Check the " + id + " field was filled with correct data"
+ );
+ resolve();
+ },
+ { once: true }
+ );
+ }),
+ ];
+});
+
+do_test(TESTCASES_BOTH_CHANGED_AND_UNCHANGED, (testcase, element) => {
+ // Ensure readonly and disabled inputs are not autofilled
+ if (element.readOnly || element.disabled) {
+ return [
+ new Promise((resolve, reject) => {
+ // Make sure no change or input event is fired when no change occurs.
+ let cleaner;
+ let timer = setTimeout(() => {
+ let id = element.id;
+ element.removeEventListener("change", cleaner);
+ element.removeEventListener("input", cleaner);
+ Assert.equal(
+ element.value,
+ testcase.expectedResult[id],
+ "Check no value is changed on the " + id + " field"
+ );
+ resolve();
+ }, 1000);
+ cleaner = event => {
+ clearTimeout(timer);
+ reject(`${event.type} event should not fire`);
+ };
+ element.addEventListener("change", cleaner);
+ element.addEventListener("input", cleaner);
+ }),
+ ];
+ }
+ let id = element.id;
+ // Ensure that non-disabled and non-readonly fields are filled correctly
+ return [
+ new Promise(resolve => {
+ element.addEventListener(
+ "input",
+ () => {
+ Assert.ok(true, "Checking " + id + " field fires input event");
+ resolve();
+ },
+ { once: true }
+ );
+ }),
+ new Promise(resolve => {
+ element.addEventListener(
+ "change",
+ () => {
+ Assert.ok(true, "Checking " + id + " field fires change event");
+ Assert.equal(
+ element.value,
+ testcase.expectedResult[id],
+ "Check the " + id + " field was filled with correct data"
+ );
+ resolve();
+ },
+ { once: true }
+ );
+ }),
+ ];
+});
diff --git a/browser/extensions/formautofill/test/unit/test_clearPopulatedForm.js b/browser/extensions/formautofill/test/unit/test_clearPopulatedForm.js
new file mode 100644
index 0000000000..db8ccb7621
--- /dev/null
+++ b/browser/extensions/formautofill/test/unit/test_clearPopulatedForm.js
@@ -0,0 +1,116 @@
+/* Any copyright is dedicated to the Public Domain.
+http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const TESTCASES = [
+ {
+ description: "Clear populated address form with text inputs",
+ document: `<form>
+ <input id="given-name">
+ <input id="family-name">
+ <input id="street-addr">
+ <input id="city">
+ </form>`,
+ focusedInputId: "given-name",
+ profileData: {
+ "given-name": "John",
+ "family-name": "Doe",
+ "street-addr": "1000 Main Street",
+ city: "Nowhere",
+ },
+ expectedResult: {
+ "given-name": "",
+ "family-name": "",
+ "street-addr": "",
+ city: "",
+ },
+ },
+ {
+ description: "Clear populated address form with select and text inputs",
+ document: `<form>
+ <input id="given-name">
+ <input id="family-name">
+ <input id="street-addr">
+ <select id="state">
+ <option value="AL">Alabama</option>
+ <option value="AK">Alaska</option>
+ <option value="OH">Ohio</option>
+ </select>
+ </form>`,
+ focusedInputId: "given-name",
+ profileData: {
+ "given-name": "John",
+ "family-name": "Doe",
+ "street-addr": "1000 Main Street",
+ state: "OH",
+ },
+ expectedResult: {
+ "given-name": "",
+ "family-name": "",
+ "street-addr": "",
+ state: "AL",
+ },
+ },
+ {
+ description:
+ "Clear populated address form with select element with selected attribute and text inputs",
+ document: `<form>
+ <input id="given-name">
+ <input id="family-name">
+ <input id="street-addr">
+ <select id="state">
+ <option value="AL">Alabama</option>
+ <option selected value="AK">Alaska</option>
+ <option value="OH">Ohio</option>
+ </select>
+ </form>`,
+ focusedInputId: "given-name",
+ profileData: {
+ "given-name": "John",
+ "family-name": "Doe",
+ "street-addr": "1000 Main Street",
+ state: "OH",
+ },
+ expectedResult: {
+ "given-name": "",
+ "family-name": "",
+ "street-addr": "",
+ state: "AK",
+ },
+ },
+];
+
+add_task(async function do_test() {
+ let { FormAutofillHandler } = ChromeUtils.importESModule(
+ "resource://gre/modules/shared/FormAutofillHandler.sys.mjs"
+ );
+ for (let test of TESTCASES) {
+ info("Test case: " + test.description);
+ let testDoc = MockDocument.createTestDocument(
+ "http://localhost:8080/test",
+ test.document
+ );
+ let form = testDoc.querySelector("form");
+ let formLike = FormLikeFactory.createFromForm(form);
+ let handler = new FormAutofillHandler(formLike);
+ handler.collectFormFields();
+ let focusedInput = testDoc.getElementById(test.focusedInputId);
+ handler.focusedInput = focusedInput;
+ let [adaptedProfile] = handler.activeSection.getAdaptedProfiles([
+ test.profileData,
+ ]);
+ await handler.autofillFormFields(adaptedProfile, focusedInput);
+
+ handler.activeSection.clearPopulatedForm();
+ handler.activeSection.fieldDetails.forEach(detail => {
+ let element = detail.elementWeakRef.get();
+ let id = element.id;
+ Assert.equal(
+ element.value,
+ test.expectedResult[id],
+ `Check the ${id} field was restored to the correct value`
+ );
+ });
+ }
+});
diff --git a/browser/extensions/formautofill/test/unit/test_collectFormFields.js b/browser/extensions/formautofill/test/unit/test_collectFormFields.js
new file mode 100644
index 0000000000..ac56d29c69
--- /dev/null
+++ b/browser/extensions/formautofill/test/unit/test_collectFormFields.js
@@ -0,0 +1,638 @@
+/*
+ * Test for form auto fill content helper collectFormFields functions.
+ */
+
+"use strict";
+
+var FormAutofillHandler;
+add_setup(async () => {
+ ({ FormAutofillHandler } = ChromeUtils.importESModule(
+ "resource://gre/modules/shared/FormAutofillHandler.sys.mjs"
+ ));
+});
+
+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: [
+ [
+ { fieldName: "given-name" },
+ { fieldName: "family-name" },
+ { fieldName: "address-line1" },
+ { fieldName: "address-level2" },
+ { fieldName: "country" },
+ { fieldName: "email" },
+ { fieldName: "tel" },
+ ],
+ ],
+ ids: [
+ "given-name",
+ "family-name",
+ "street-addr",
+ "city",
+ "country",
+ "email",
+ "phone",
+ ],
+ },
+ {
+ description:
+ "An address and credit card form with autocomplete properties and 1 token",
+ document: `<form>
+ <input id="given-name" autocomplete="given-name">
+ <input id="family-name" autocomplete="family-name">
+ <input id="street-address" autocomplete="street-address">
+ <input id="address-level2" autocomplete="address-level2">
+ <select id="country" autocomplete="country"></select>
+ <input id="email" autocomplete="email">
+ <input id="tel" autocomplete="tel">
+ <input id="cc-number" autocomplete="cc-number">
+ <input id="cc-name" autocomplete="cc-name">
+ <input id="cc-exp-month" autocomplete="cc-exp-month">
+ <input id="cc-exp-year" autocomplete="cc-exp-year">
+ </form>`,
+ sections: [
+ [
+ { fieldName: "given-name" },
+ { fieldName: "family-name" },
+ { fieldName: "street-address" },
+ { fieldName: "address-level2" },
+ { fieldName: "country" },
+ { fieldName: "email" },
+ { fieldName: "tel" },
+ ],
+ [
+ { fieldName: "cc-number" },
+ { fieldName: "cc-name" },
+ { fieldName: "cc-exp-month" },
+ { fieldName: "cc-exp-year" },
+ ],
+ ],
+ },
+ {
+ description: "An address form with autocomplete properties and 2 tokens",
+ document: `<form><input id="given-name" autocomplete="shipping given-name">
+ <input id="family-name" autocomplete="shipping family-name">
+ <input id="street-address" autocomplete="shipping street-address">
+ <input id="address-level2" autocomplete="shipping address-level2">
+ <input id="country" autocomplete="shipping country">
+ <input id='email' autocomplete="shipping email">
+ <input id="tel" autocomplete="shipping tel"></form>`,
+ sections: [
+ [
+ { addressType: "shipping", fieldName: "given-name" },
+ { addressType: "shipping", fieldName: "family-name" },
+ { addressType: "shipping", fieldName: "street-address" },
+ { addressType: "shipping", fieldName: "address-level2" },
+ { addressType: "shipping", fieldName: "country" },
+ { addressType: "shipping", fieldName: "email" },
+ { addressType: "shipping", fieldName: "tel" },
+ ],
+ ],
+ },
+ {
+ description:
+ "Form with autocomplete properties and profile is partly matched",
+ document: `<form><input id="given-name" autocomplete="shipping given-name">
+ <input id="family-name" autocomplete="shipping family-name">
+ <input id="street-address" autocomplete="shipping street-address">
+ <input id="address-level2" autocomplete="shipping address-level2">
+ <select id="country" autocomplete="shipping country"></select>
+ <input id='email' autocomplete="shipping email">
+ <input id="tel" autocomplete="shipping tel"></form>`,
+ sections: [
+ [
+ { addressType: "shipping", fieldName: "given-name" },
+ { addressType: "shipping", fieldName: "family-name" },
+ { addressType: "shipping", fieldName: "street-address" },
+ { addressType: "shipping", fieldName: "address-level2" },
+ { addressType: "shipping", fieldName: "country" },
+ { addressType: "shipping", fieldName: "email" },
+ { addressType: "shipping", fieldName: "tel" },
+ ],
+ ],
+ },
+ {
+ description: "It's a valid address and credit card form.",
+ document: `<form>
+ <input id="given-name" autocomplete="shipping given-name">
+ <input id="family-name" autocomplete="shipping family-name">
+ <input id="street-address" autocomplete="shipping street-address">
+ <input id="cc-number" autocomplete="shipping cc-number">
+ </form>`,
+ sections: [
+ [
+ { addressType: "shipping", fieldName: "given-name" },
+ { addressType: "shipping", fieldName: "family-name" },
+ { addressType: "shipping", fieldName: "street-address" },
+ ],
+ [{ addressType: "shipping", 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: [],
+ },
+ /*
+ * Valid Credit Card Form with autocomplete attribute
+ */
+ {
+ description: "@autocomplete - A valid credit card form",
+ document: `<form>
+ <input id="cc-number" autocomplete="cc-number">
+ <input id="cc-name" autocomplete="cc-name">
+ <input id="cc-exp" autocomplete="cc-exp">
+ </form>`,
+ sections: [
+ [
+ { fieldName: "cc-number" },
+ { fieldName: "cc-name" },
+ { fieldName: "cc-exp" },
+ ],
+ ],
+ },
+ {
+ description: "@autocomplete - A valid credit card form without cc-numner",
+ document: `<form>
+ <input id="cc-name" autocomplete="cc-name">
+ <input id="cc-exp" autocomplete="cc-exp">
+ </form>`,
+ sections: [[{ fieldName: "cc-name" }, { fieldName: "cc-exp" }]],
+ },
+ {
+ description: "@autocomplete - A valid cc-number only form",
+ document: `<form><input id="cc-number" autocomplete="cc-number"></form>`,
+ sections: [[{ fieldName: "cc-number" }]],
+ },
+ {
+ description: "@autocomplete - A valid cc-name only form",
+ document: `<form><input id="cc-name" autocomplete="cc-name"></form>`,
+ sections: [[{ fieldName: "cc-name" }]],
+ },
+ {
+ description: "@autocomplete - A valid cc-exp only form",
+ document: `<form><input id="cc-exp" autocomplete="cc-exp"></form>`,
+ sections: [[{ fieldName: "cc-exp" }]],
+ },
+ {
+ description: "@autocomplete - A valid cc-exp-month + cc-exp-year form",
+ document: `<form>
+ <input id="cc-exp-month" autocomplete="cc-exp-month">
+ <input id="cc-exp-year" autocomplete="cc-exp-year">
+ </form>`,
+ sections: [[{ fieldName: "cc-exp-month" }, { fieldName: "cc-exp-year" }]],
+ },
+ {
+ description: "@autocomplete - A valid cc-exp-month only form",
+ document: `<form><input id="cc-exp-month" autocomplete="cc-exp-month"></form>`,
+ sections: [[{ fieldName: "cc-exp-month" }]],
+ },
+ {
+ description: "@autocomplete - A valid cc-exp-year only form",
+ document: `<form><input id="cc-exp-year" autocomplete="cc-exp-year"></form>`,
+ sections: [[{ fieldName: "cc-exp-year" }]],
+ },
+ /*
+ * Valid Credit Card Form when cc-number or cc-name is detected by fathom
+ */
+ {
+ description:
+ "A valid credit card form without autocomplete attribute (cc-number is detected by fathom)",
+ document: `<form>
+ <input id="cc-number" name="cc-number">
+ <input id="cc-name" name="cc-name">
+ <input id="cc-exp" name="cc-exp">
+ </form>`,
+ sections: [
+ [
+ { fieldName: "cc-number" },
+ { fieldName: "cc-name" },
+ { fieldName: "cc-exp" },
+ ],
+ ],
+ prefs: [
+ [
+ "extensions.formautofill.creditCards.heuristics.fathom.testConfidence",
+ "0.8",
+ ],
+ ],
+ },
+ {
+ description:
+ "A valid credit card form without autocomplete attribute (only cc-number is detected by fathom)",
+ document: `<form>
+ <input id="cc-number" name="cc-number">
+ <input id="cc-name" name="cc-name">
+ <input id="cc-exp" name="cc-exp">
+ </form>`,
+ sections: [
+ [
+ { fieldName: "cc-number" },
+ { fieldName: "cc-name" },
+ { fieldName: "cc-exp" },
+ ],
+ ],
+ prefs: [
+ [
+ "extensions.formautofill.creditCards.heuristics.fathom.testConfidence",
+ "0.8",
+ ],
+ [
+ "extensions.formautofill.creditCards.heuristics.fathom.types",
+ "cc-number",
+ ],
+ ],
+ },
+ {
+ description:
+ "A valid credit card form without autocomplete attribute (only cc-name is detected by fathom)",
+ document: `<form>
+ <input id="cc-name" name="cc-name">
+ <input id="cc-exp" name="cc-exp">
+ </form>`,
+ sections: [[{ fieldName: "cc-name" }, { fieldName: "cc-exp" }]],
+ prefs: [
+ [
+ "extensions.formautofill.creditCards.heuristics.fathom.testConfidence",
+ "0.8",
+ ],
+ [
+ "extensions.formautofill.creditCards.heuristics.fathom.types",
+ "cc-name",
+ ],
+ ],
+ },
+ /*
+ * Invalid Credit Card Form when a cc-number or cc-name is detected by fathom
+ */
+ {
+ description:
+ "A credit card form is invalid when a fathom detected cc-number field is the only field in the form",
+ document: `<form><input id="cc-number" name="cc-number"></form>`,
+ sections: [],
+ prefs: [
+ [
+ "extensions.formautofill.creditCards.heuristics.fathom.highConfidenceThreshold",
+ "0.9",
+ ],
+ [
+ "extensions.formautofill.creditCards.heuristics.fathom.testConfidence",
+ "0.8",
+ ],
+ ],
+ },
+ {
+ description:
+ "A credit card form is invalid when a fathom detected cc-name field is the only field in the form",
+ document: `<form><input id="cc-name" name="cc-name"></form>`,
+ sections: [],
+ prefs: [
+ [
+ "extensions.formautofill.creditCards.heuristics.fathom.highConfidenceThreshold",
+ "0.9",
+ ],
+ [
+ "extensions.formautofill.creditCards.heuristics.fathom.testConfidence",
+ "0.8",
+ ],
+ ],
+ },
+ /*
+ * Valid Credit Card Form when a cc-number or cc-name only form is detected by fathom (field is high confidence)
+ */
+ {
+ description:
+ "A cc-number only form is considered a valid credit card form when fathom is confident and there is no other <input> in the form",
+ document: `<form><input id="cc-number" name="cc-number"></form>`,
+ sections: [[{ fieldName: "cc-number" }]],
+ prefs: [
+ [
+ "extensions.formautofill.creditCards.heuristics.fathom.highConfidenceThreshold",
+ "0.95",
+ ],
+ [
+ "extensions.formautofill.creditCards.heuristics.fathom.testConfidence",
+ "0.99",
+ ],
+ ],
+ },
+ {
+ description:
+ "A cc-name only form is considered a valid credit card form when fathom is confident and there is no other <input> in the form",
+ document: `<form><input id="cc-name" name="cc-name"></form>`,
+ sections: [[{ fieldName: "cc-name" }]],
+ prefs: [
+ [
+ "extensions.formautofill.creditCards.heuristics.fathom.highConfidenceThreshold",
+ "0.95",
+ ],
+ [
+ "extensions.formautofill.creditCards.heuristics.fathom.testConfidence",
+ "0.99",
+ ],
+ ],
+ },
+ /*
+ * Invalid Credit Card Form when none of the fields is identified by fathom
+ */
+ {
+ description:
+ "A credit card form is invalid when none of the fields are identified by fathom or autocomplete",
+ document: `<form>
+ <input id="cc-number" name="cc-number">
+ <input id="cc-name" name="cc-name">
+ <input id="cc-exp" name="cc-exp">
+ </form>`,
+ sections: [],
+ prefs: [
+ ["extensions.formautofill.creditCards.heuristics.fathom.types", ""],
+ ],
+ },
+ // Special Cases
+ {
+ description:
+ "A credit card form with a high-confidence cc-name field is still considered invalid when there is another <input> field",
+ document: `<form>
+ <input id="cc-name" name="cc-name">
+ <input id="password" type="password">
+ </form>`,
+ sections: [],
+ prefs: [
+ [
+ "extensions.formautofill.creditCards.heuristics.fathom.highConfidenceThreshold",
+ "0.95",
+ ],
+ [
+ "extensions.formautofill.creditCards.heuristics.fathom.testConfidence",
+ "0.96",
+ ],
+ ],
+ },
+ {
+ description: "A valid credit card form with multiple cc-number fields",
+ document: `<form>
+ <input id="cc-number1" maxlength="4">
+ <input id="cc-number2" maxlength="4">
+ <input id="cc-number3" maxlength="4">
+ <input id="cc-number4" maxlength="4">
+ <input id="cc-exp-month" autocomplete="cc-exp-month">
+ <input id="cc-exp-year" autocomplete="cc-exp-year">
+ </form>`,
+ sections: [
+ [
+ { fieldName: "cc-number" },
+ { fieldName: "cc-number" },
+ { fieldName: "cc-number" },
+ { fieldName: "cc-number" },
+ { fieldName: "cc-exp-month" },
+ { fieldName: "cc-exp-year" },
+ ],
+ ],
+ ids: [
+ "cc-number1",
+ "cc-number2",
+ "cc-number3",
+ "cc-number4",
+ "cc-exp-month",
+ "cc-exp-year",
+ ],
+ },
+ {
+ description: "Three sets of adjacent phone number fields",
+ document: `<form>
+ <input id="shippingAC" name="phone" maxlength="3">
+ <input id="shippingPrefix" name="phone" maxlength="3">
+ <input id="shippingSuffix" name="phone" maxlength="4">
+ <input id="shippingTelExt" name="extension">
+
+ <input id="billingAC" name="phone" maxlength="3">
+ <input id="billingPrefix" name="phone" maxlength="3">
+ <input id="billingSuffix" name="phone" maxlength="4">
+
+ <input id="otherCC" name="phone" maxlength="3">
+ <input id="otherAC" name="phone" maxlength="3">
+ <input id="otherPrefix" name="phone" maxlength="3">
+ <input id="otherSuffix" name="phone" maxlength="4">
+ </form>`,
+ sections: [
+ [
+ { fieldName: "tel-area-code" },
+ { fieldName: "tel-local-prefix" },
+ { fieldName: "tel-local-suffix" },
+ { fieldName: "tel-extension" },
+ ],
+ [
+ { fieldName: "tel-area-code" },
+ { fieldName: "tel-local-prefix" },
+ { 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.
+ { fieldName: "tel-country-code" },
+ ],
+ [
+ { fieldName: "tel-area-code" },
+ { fieldName: "tel-local-prefix" },
+ { fieldName: "tel-local-suffix" },
+ ],
+ ],
+ ids: [
+ "shippingAC",
+ "shippingPrefix",
+ "shippingSuffix",
+ "shippingTelExt",
+ "billingAC",
+ "billingPrefix",
+ "billingSuffix",
+ "otherCC",
+ "otherAC",
+ "otherPrefix",
+ "otherSuffix",
+ ],
+ },
+ {
+ description:
+ "Do not dedup the same field names of the different telephone fields.",
+ document: `<form>
+ <input id="i1" autocomplete="given-name">
+ <input id="i2" autocomplete="family-name">
+ <input id="i3" autocomplete="street-address">
+ <input id="i4" autocomplete="email">
+
+ <input id="homePhone" maxlength="10">
+ <input id="mobilePhone" maxlength="10">
+ <input id="officePhone" maxlength="10">
+ </form>`,
+ sections: [
+ [
+ { fieldName: "given-name" },
+ { fieldName: "family-name" },
+ { fieldName: "street-address" },
+ { fieldName: "email" },
+ { fieldName: "tel" },
+ { fieldName: "tel" },
+ { fieldName: "tel" },
+ ],
+ ],
+ ids: ["i1", "i2", "i3", "i4", "homePhone", "mobilePhone", "officePhone"],
+ },
+ {
+ description:
+ "The duplicated phones of a single one and a set with ac, prefix, suffix.",
+ document: `<form>
+ <input id="i1" autocomplete="shipping given-name">
+ <input id="i2" autocomplete="shipping family-name">
+ <input id="i3" autocomplete="shipping street-address">
+ <input id="i4" autocomplete="shipping email">
+ <input id="singlePhone" autocomplete="shipping tel">
+ <input id="shippingAreaCode" autocomplete="shipping tel-area-code">
+ <input id="shippingPrefix" autocomplete="shipping tel-local-prefix">
+ <input id="shippingSuffix" autocomplete="shipping tel-local-suffix">
+ </form>`,
+ sections: [
+ [
+ { addressType: "shipping", fieldName: "given-name" },
+ { addressType: "shipping", fieldName: "family-name" },
+ { addressType: "shipping", fieldName: "street-address" },
+ { addressType: "shipping", 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.
+ { addressType: "shipping", fieldName: "tel" },
+ { addressType: "shipping", fieldName: "tel-area-code" },
+ { addressType: "shipping", fieldName: "tel-local-prefix" },
+ { addressType: "shipping", fieldName: "tel-local-suffix" },
+ ],
+ ],
+ ids: [
+ "i1",
+ "i2",
+ "i3",
+ "i4",
+ "singlePhone",
+ "shippingAreaCode",
+ "shippingPrefix",
+ "shippingSuffix",
+ ],
+ },
+ {
+ description: "Always adopt the info from autocomplete attribute.",
+ document: `<form>
+ <input id="given-name" autocomplete="shipping given-name">
+ <input id="family-name" autocomplete="shipping family-name">
+ <input id="dummyAreaCode" autocomplete="shipping tel" maxlength="3">
+ <input id="dummyPrefix" autocomplete="shipping tel" maxlength="3">
+ <input id="dummySuffix" autocomplete="shipping tel" maxlength="4">
+ </form>`,
+ sections: [
+ [
+ { addressType: "shipping", fieldName: "given-name" },
+ { addressType: "shipping", fieldName: "family-name" },
+ { addressType: "shipping", fieldName: "tel" },
+ { addressType: "shipping", fieldName: "tel" },
+ { addressType: "shipping", fieldName: "tel" },
+ ],
+ ],
+ ids: [
+ "given-name",
+ "family-name",
+ "dummyAreaCode",
+ "dummyPrefix",
+ "dummySuffix",
+ ],
+ },
+];
+
+function verifyDetails(handlerDetails, testCaseDetails) {
+ if (handlerDetails === null) {
+ Assert.equal(handlerDetails, testCaseDetails);
+ return;
+ }
+ Assert.equal(handlerDetails.length, testCaseDetails.length, "field count");
+ handlerDetails.forEach((detail, index) => {
+ Assert.equal(
+ detail.fieldName,
+ testCaseDetails[index].fieldName,
+ "fieldName"
+ );
+ Assert.equal(
+ detail.section,
+ testCaseDetails[index].section ?? "",
+ "section"
+ );
+ Assert.equal(
+ detail.addressType,
+ testCaseDetails[index].addressType ?? "",
+ "addressType"
+ );
+ Assert.equal(
+ detail.contactType,
+ testCaseDetails[index].contactType ?? "",
+ "contactType"
+ );
+ Assert.equal(
+ detail.elementWeakRef.get(),
+ testCaseDetails[index].elementWeakRef.get(),
+ "DOM reference"
+ );
+ });
+}
+
+for (let tc of TESTCASES) {
+ (function () {
+ let testcase = tc;
+ add_task(async function () {
+ info("Starting testcase: " + testcase.description);
+
+ if (testcase.prefs) {
+ testcase.prefs.forEach(pref => SetPref(pref[0], pref[1]));
+ }
+
+ let doc = MockDocument.createTestDocument(
+ "http://localhost:8080/test/",
+ testcase.document
+ );
+ testcase.sections.flat().forEach((field, idx) => {
+ let elementRef = doc.getElementById(
+ testcase.ids?.[idx] ?? field.fieldName
+ );
+ field.elementWeakRef = Cu.getWeakReference(elementRef);
+ });
+
+ let form = doc.querySelector("form");
+ let formLike = FormLikeFactory.createFromForm(form);
+
+ let handler = new FormAutofillHandler(formLike);
+ let validFieldDetails = handler.collectFormFields();
+
+ Assert.equal(
+ handler.sections.length,
+ testcase.sections.length,
+ "section count"
+ );
+ for (let i = 0; i < handler.sections.length; i++) {
+ let section = handler.sections[i];
+ verifyDetails(section.fieldDetails, testcase.sections[i]);
+ }
+ verifyDetails(validFieldDetails, testcase.sections.flat());
+
+ if (testcase.prefs) {
+ testcase.prefs.forEach(pref => Services.prefs.clearUserPref(pref[0]));
+ }
+ });
+ })();
+}
diff --git a/browser/extensions/formautofill/test/unit/test_createRecords.js b/browser/extensions/formautofill/test/unit/test_createRecords.js
new file mode 100644
index 0000000000..3d028e808e
--- /dev/null
+++ b/browser/extensions/formautofill/test/unit/test_createRecords.js
@@ -0,0 +1,525 @@
+/*
+ * Test for the normalization of records created by FormAutofillHandler.
+ */
+
+"use strict";
+
+var FormAutofillHandler;
+add_task(async function seutp() {
+ ({ FormAutofillHandler } = ChromeUtils.importESModule(
+ "resource://gre/modules/shared/FormAutofillHandler.sys.mjs"
+ ));
+});
+
+const TESTCASES = [
+ {
+ description:
+ "Don't contain a field whose length of value is greater than 200",
+ document: `<form>
+ <input id="given-name" autocomplete="given-name">
+ <input id="organization" autocomplete="organization">
+ <input id="address-level1" autocomplete="address-level1">
+ <input id="country" autocomplete="country">
+ <input id="cc-number" autocomplete="cc-number">
+ <input id="cc-name" autocomplete="cc-name">
+ </form>`,
+ formValue: {
+ "given-name": "John",
+ organization: "*".repeat(200),
+ "address-level1": "*".repeat(201),
+ country: "US",
+ "cc-number": "1111222233334444",
+ "cc-name": "*".repeat(201),
+ },
+ expectedRecord: {
+ address: [
+ {
+ "given-name": "John",
+ organization: "*".repeat(200),
+ "address-level1": "",
+ country: "US",
+ },
+ ],
+ creditCard: [
+ {
+ "cc-number": "1111222233334444",
+ "cc-name": "",
+ },
+ ],
+ },
+ },
+ {
+ description: "Don't create address record if filled data is less than 3",
+ document: `<form>
+ <input id="given-name" autocomplete="given-name">
+ <input id="organization" autocomplete="organization">
+ <input id="country" autocomplete="country">
+ </form>`,
+ formValue: {
+ "given-name": "John",
+ organization: "Mozilla",
+ },
+ expectedRecord: {
+ address: [],
+ creditCard: [],
+ },
+ },
+ {
+ description: `"country" using @autocomplete shouldn't be identified aggressively`,
+ document: `<form>
+ <input id="given-name" autocomplete="given-name">
+ <input id="organization" autocomplete="organization">
+ <input id="country" autocomplete="country">
+ </form>`,
+ formValue: {
+ "given-name": "John",
+ organization: "Mozilla",
+ country: "United States",
+ },
+ expectedRecord: {
+ // "United States" is not a valid country, only country-name. See isRecordCreatable.
+ address: [],
+ creditCard: [],
+ },
+ },
+ {
+ description: `"country" using heuristics should be identified aggressively`,
+ document: `<form>
+ <input id="given-name" autocomplete="given-name">
+ <input id="organization" autocomplete="organization">
+ <input id="country" name="country">
+ </form>`,
+ formValue: {
+ "given-name": "John",
+ organization: "Mozilla",
+ country: "United States",
+ },
+ expectedRecord: {
+ address: [
+ {
+ "given-name": "John",
+ organization: "Mozilla",
+ country: "US",
+ },
+ ],
+ creditCard: [],
+ },
+ },
+ {
+ description: `"tel" related fields should be concatenated`,
+ document: `<form>
+ <input id="given-name" autocomplete="given-name">
+ <input id="organization" autocomplete="organization">
+ <input id="tel-country-code" autocomplete="tel-country-code">
+ <input id="tel-national" autocomplete="tel-national">
+ </form>`,
+ formValue: {
+ "given-name": "John",
+ organization: "Mozilla",
+ "tel-country-code": "+1",
+ "tel-national": "1234567890",
+ },
+ expectedRecord: {
+ address: [
+ {
+ "given-name": "John",
+ organization: "Mozilla",
+ tel: "+11234567890",
+ },
+ ],
+ creditCard: [],
+ },
+ },
+ {
+ description: `"tel" should be removed if it's too short`,
+ document: `<form>
+ <input id="given-name" autocomplete="given-name">
+ <input id="organization" autocomplete="organization">
+ <input id="country" autocomplete="country">
+ <input id="tel" autocomplete="tel-national">
+ </form>`,
+ formValue: {
+ "given-name": "John",
+ organization: "Mozilla",
+ country: "US",
+ tel: "1234",
+ },
+ expectedRecord: {
+ address: [
+ {
+ "given-name": "John",
+ organization: "Mozilla",
+ country: "US",
+ tel: "",
+ },
+ ],
+ creditCard: [],
+ },
+ },
+ {
+ description: `"tel" should be removed if it's too long`,
+ document: `<form>
+ <input id="given-name" autocomplete="given-name">
+ <input id="organization" autocomplete="organization">
+ <input id="country" autocomplete="country">
+ <input id="tel" autocomplete="tel-national">
+ </form>`,
+ formValue: {
+ "given-name": "John",
+ organization: "Mozilla",
+ country: "US",
+ tel: "1234567890123456",
+ },
+ expectedRecord: {
+ address: [
+ {
+ "given-name": "John",
+ organization: "Mozilla",
+ country: "US",
+ tel: "",
+ },
+ ],
+ creditCard: [],
+ },
+ },
+ {
+ description: `"tel" should be removed if it contains invalid characters`,
+ document: `<form>
+ <input id="given-name" autocomplete="given-name">
+ <input id="organization" autocomplete="organization">
+ <input id="country" autocomplete="country">
+ <input id="tel" autocomplete="tel-national">
+ </form>`,
+ formValue: {
+ "given-name": "John",
+ organization: "Mozilla",
+ country: "US",
+ tel: "12345###!!!",
+ },
+ expectedRecord: {
+ address: [
+ {
+ "given-name": "John",
+ organization: "Mozilla",
+ country: "US",
+ tel: "",
+ },
+ ],
+ creditCard: [],
+ },
+ },
+ {
+ description: "All name related fields should be counted as 1 field only.",
+ document: `<form>
+ <input id="given-name" autocomplete="given-name">
+ <input id="family-name" autocomplete="family-name">
+ <input id="organization" autocomplete="organization">
+ </form>`,
+ formValue: {
+ "given-name": "John",
+ "family-name": "Doe",
+ organization: "Mozilla",
+ },
+ expectedRecord: {
+ address: [],
+ creditCard: [],
+ },
+ },
+ {
+ description:
+ "All telephone related fields should be counted as 1 field only.",
+ document: `<form>
+ <input id="tel-country-code" autocomplete="tel-country-code">
+ <input id="tel-area-code" autocomplete="tel-area-code">
+ <input id="tel-local" autocomplete="tel-local">
+ <input id="organization" autocomplete="organization">
+ </form>`,
+ formValue: {
+ "tel-country-code": "+1",
+ "tel-area-code": "123",
+ "tel-local": "4567890",
+ organization: "Mozilla",
+ },
+ expectedRecord: {
+ address: [],
+ creditCard: [],
+ },
+ },
+ {
+ description:
+ "A credit card form with the value of cc-number, cc-exp, cc-name and cc-type.",
+ document: `<form>
+ <input id="cc-number" autocomplete="cc-number">
+ <input id="cc-name" autocomplete="cc-name">
+ <input id="cc-exp" autocomplete="cc-exp">
+ <input id="cc-type" autocomplete="cc-type">
+ </form>`,
+ formValue: {
+ "cc-number": "5105105105105100",
+ "cc-name": "Foo Bar",
+ "cc-exp": "2022-06",
+ "cc-type": "Visa",
+ },
+ expectedRecord: {
+ address: [],
+ creditCard: [
+ {
+ "cc-number": "5105105105105100",
+ "cc-name": "Foo Bar",
+ "cc-exp": "2022-06",
+ "cc-type": "Visa",
+ "cc-exp-month": "6",
+ "cc-exp-year": "2022",
+ },
+ ],
+ },
+ },
+ {
+ description: "A credit card form with cc-number value only.",
+ document: `<form>
+ <input id="cc-number" autocomplete="cc-number">
+ </form>`,
+ formValue: {
+ "cc-number": "4111111111111111",
+ },
+ expectedRecord: {
+ address: [],
+ creditCard: [
+ {
+ "cc-number": "4111111111111111",
+ },
+ ],
+ },
+ },
+ {
+ description: "A credit card form must have cc-number value.",
+ document: `<form>
+ <input id="cc-number" autocomplete="cc-number">
+ <input id="cc-name" autocomplete="cc-name">
+ <input id="cc-exp" autocomplete="cc-exp">
+ </form>`,
+ formValue: {
+ "cc-number": "",
+ "cc-name": "Foo Bar",
+ "cc-exp": "2022-06",
+ },
+ expectedRecord: {
+ address: [],
+ creditCard: [],
+ },
+ },
+ {
+ description: "A credit card form must have cc-number field.",
+ document: `<form>
+ <input id="cc-name" autocomplete="cc-name">
+ <input id="cc-exp" autocomplete="cc-exp">
+ </form>`,
+ formValue: {
+ "cc-name": "Foo Bar",
+ "cc-exp": "2022-06",
+ },
+ expectedRecord: {
+ address: [],
+ creditCard: [],
+ },
+ },
+ {
+ description: "A form with multiple sections",
+ document: `<form>
+ <input id="given-name" autocomplete="given-name">
+ <input id="organization" autocomplete="organization">
+ <input id="country" autocomplete="country">
+
+ <input id="given-name-shipping" autocomplete="shipping given-name">
+ <input id="family-name-shipping" autocomplete="shipping family-name">
+ <input id="organization-shipping" autocomplete="shipping organization">
+ <input id="country-shipping" autocomplete="shipping country">
+
+ <input id="given-name-billing" autocomplete="billing given-name">
+ <input id="organization-billing" autocomplete="billing organization">
+ <input id="country-billing" autocomplete="billing country">
+
+ <input id="cc-number-section-one" autocomplete="section-one cc-number">
+ <input id="cc-name-section-one" autocomplete="section-one cc-name">
+
+ <input id="cc-number-section-two" autocomplete="section-two cc-number">
+ <input id="cc-name-section-two" autocomplete="section-two cc-name">
+ <input id="cc-exp-section-two" autocomplete="section-two cc-exp">
+ </form>`,
+ formValue: {
+ "given-name": "Bar",
+ organization: "Foo",
+ country: "US",
+
+ "given-name-shipping": "John",
+ "family-name-shipping": "Doe",
+ "organization-shipping": "Mozilla",
+ "country-shipping": "US",
+
+ "given-name-billing": "Foo",
+ "organization-billing": "Bar",
+ "country-billing": "US",
+
+ "cc-number-section-one": "4111111111111111",
+ "cc-name-section-one": "John",
+
+ "cc-number-section-two": "5105105105105100",
+ "cc-name-section-two": "Foo Bar",
+ "cc-exp-section-two": "2026-26",
+ },
+ expectedRecord: {
+ address: [
+ {
+ "given-name": "Bar",
+ organization: "Foo",
+ country: "US",
+ },
+ {
+ "given-name": "John",
+ "family-name": "Doe",
+ organization: "Mozilla",
+ country: "US",
+ },
+ {
+ "given-name": "Foo",
+ organization: "Bar",
+ country: "US",
+ },
+ ],
+ creditCard: [
+ {
+ "cc-number": "4111111111111111",
+ "cc-name": "John",
+ },
+ {
+ "cc-number": "5105105105105100",
+ "cc-name": "Foo Bar",
+ "cc-exp": "2026-26",
+ },
+ ],
+ },
+ },
+ {
+ description: "A credit card form with a cc-type select.",
+ document: `<form>
+ <input id="cc-number" autocomplete="cc-number">
+ <label for="field1">Card Type:</label>
+ <select id="field1">
+ <option value="visa" selected>Visa</option>
+ </select>
+ </form>`,
+ formValue: {
+ "cc-number": "5105105105105100",
+ },
+ expectedRecord: {
+ address: [],
+ creditCard: [
+ {
+ "cc-number": "5105105105105100",
+ "cc-type": "visa",
+ },
+ ],
+ },
+ },
+ {
+ description: "A credit card form with a cc-type select from label.",
+ document: `<form>
+ <input id="cc-number" autocomplete="cc-number">
+ <label for="cc-type">Card Type:</label>
+ <select id="cc-type">
+ <option value="V" selected>Visa</option>
+ <option value="A">American Express</option>
+ </select>
+ </form>`,
+ formValue: {
+ "cc-number": "5105105105105100",
+ "cc-type": "A",
+ },
+ expectedRecord: {
+ address: [],
+ creditCard: [
+ {
+ "cc-number": "5105105105105100",
+ "cc-type": "amex",
+ },
+ ],
+ },
+ },
+ {
+ description:
+ "A credit card form with separate expiry fields should have normalized expiry data.",
+ document: `<form>
+ <input id="cc-number" autocomplete="cc-number">
+ <input id="cc-exp-month" autocomplete="cc-exp-month">
+ <input id="cc-exp-year" autocomplete="cc-exp-year">
+ </form>`,
+ formValue: {
+ "cc-number": "5105105105105100",
+ "cc-exp-month": "05",
+ "cc-exp-year": "26",
+ },
+ expectedRecord: {
+ address: [],
+ creditCard: [
+ {
+ "cc-number": "5105105105105100",
+ "cc-exp-month": "5",
+ "cc-exp-year": "2026",
+ },
+ ],
+ },
+ },
+ {
+ description:
+ "A credit card form with combined expiry fields should have normalized expiry data.",
+ document: `<form>
+ <input id="cc-number" autocomplete="cc-number">
+ <input id="cc-exp" autocomplete="cc-exp">
+ </form>`,
+ formValue: {
+ "cc-number": "5105105105105100",
+ "cc-exp": "07/27",
+ },
+ expectedRecord: {
+ address: [],
+ creditCard: [
+ {
+ "cc-number": "5105105105105100",
+ "cc-exp": "07/27",
+ "cc-exp-month": "7",
+ "cc-exp-year": "2027",
+ },
+ ],
+ },
+ },
+];
+
+for (let testcase of TESTCASES) {
+ add_task(async function () {
+ info("Starting testcase: " + testcase.description);
+
+ let doc = MockDocument.createTestDocument(
+ "http://localhost:8080/test/",
+ testcase.document
+ );
+ let form = doc.querySelector("form");
+ let formLike = FormLikeFactory.createFromForm(form);
+ let handler = new FormAutofillHandler(formLike);
+
+ handler.collectFormFields();
+
+ for (let id in testcase.formValue) {
+ doc.getElementById(id).value = testcase.formValue[id];
+ }
+
+ let record = handler.createRecords();
+
+ let expectedRecord = testcase.expectedRecord;
+ for (let type in record) {
+ Assert.deepEqual(
+ record[type].map(secRecord => secRecord.record),
+ expectedRecord[type]
+ );
+ }
+ });
+}
diff --git a/browser/extensions/formautofill/test/unit/test_creditCardRecords.js b/browser/extensions/formautofill/test/unit/test_creditCardRecords.js
new file mode 100644
index 0000000000..3011fe885f
--- /dev/null
+++ b/browser/extensions/formautofill/test/unit/test_creditCardRecords.js
@@ -0,0 +1,926 @@
+/**
+ * Tests FormAutofillStorage object with creditCards records.
+ */
+
+"use strict";
+
+ChromeUtils.defineESModuleGetters(this, {
+ Preferences: "resource://gre/modules/Preferences.sys.mjs",
+});
+const { CreditCard } = ChromeUtils.importESModule(
+ "resource://gre/modules/CreditCard.sys.mjs"
+);
+
+let FormAutofillStorage;
+let CREDIT_CARD_SCHEMA_VERSION;
+add_setup(async () => {
+ ({ FormAutofillStorage } = ChromeUtils.importESModule(
+ "resource://autofill/FormAutofillStorage.sys.mjs"
+ ));
+ ({ CREDIT_CARD_SCHEMA_VERSION } = ChromeUtils.importESModule(
+ "resource://autofill/FormAutofillStorageBase.sys.mjs"
+ ));
+});
+
+const TEST_STORE_FILE_NAME = "test-credit-card.json";
+const COLLECTION_NAME = "creditCards";
+
+const TEST_CREDIT_CARD_1 = {
+ "cc-name": "John Doe",
+ "cc-number": "4929001587121045",
+ "cc-exp-month": 4,
+ "cc-exp-year": 2017,
+};
+
+const TEST_CREDIT_CARD_2 = {
+ "cc-name": "Timothy Berners-Lee",
+ "cc-number": "5103059495477870",
+ "cc-exp-month": 12,
+ "cc-exp-year": 2022,
+};
+
+const TEST_CREDIT_CARD_3 = {
+ "cc-number": "3589993783099582",
+ "cc-exp-month": 1,
+ "cc-exp-year": 2000,
+};
+
+const TEST_CREDIT_CARD_4 = {
+ "cc-name": "Foo Bar",
+ "cc-number": "3589993783099582",
+};
+
+const TEST_CREDIT_CARD_WITH_BILLING_ADDRESS = {
+ "cc-name": "J. Smith",
+ "cc-number": "4111111111111111",
+ billingAddressGUID: "9m6hf4gfr6ge",
+};
+
+const TEST_CREDIT_CARD_WITH_EMPTY_FIELD = {
+ billingAddressGUID: "",
+ "cc-name": "",
+ "cc-number": "344060747836806",
+ "cc-exp-month": 1,
+};
+
+const TEST_CREDIT_CARD_WITH_EMPTY_COMPUTED_FIELD = {
+ "cc-given-name": "",
+ "cc-additional-name": "",
+ "cc-family-name": "",
+ "cc-exp": "",
+ "cc-number": "5415425865751454",
+};
+
+const TEST_CREDIT_CARD_WITH_2_DIGITS_YEAR = {
+ "cc-number": "344060747836806",
+ "cc-exp-month": 1,
+ "cc-exp-year": 12,
+};
+
+const TEST_CREDIT_CARD_WITH_INVALID_FIELD = {
+ "cc-name": "John Doe",
+ "cc-number": "344060747836806",
+ "cc-type": { invalid: "invalid" },
+};
+
+const TEST_CREDIT_CARD_WITH_INVALID_EXPIRY_DATE = {
+ "cc-name": "John Doe",
+ "cc-number": "5103059495477870",
+ "cc-exp-month": 13,
+ "cc-exp-year": -3,
+};
+
+const TEST_CREDIT_CARD_WITH_SPACES_BETWEEN_DIGITS = {
+ "cc-name": "John Doe",
+ "cc-number": "5103 0594 9547 7870",
+};
+
+const TEST_CREDIT_CARD_EMPTY_AFTER_NORMALIZE = {
+ "cc-exp-month": 13,
+};
+
+const TEST_CREDIT_CARD_EMPTY_AFTER_UPDATE_CREDIT_CARD_1 = {
+ "cc-name": "",
+ "cc-number": "",
+ "cc-exp-month": 13,
+ "cc-exp-year": "",
+};
+
+const MERGE_TESTCASES = [
+ {
+ description: "Merge a superset",
+ creditCardInStorage: {
+ "cc-number": "4929001587121045",
+ "cc-exp-month": 4,
+ "cc-exp-year": 2017,
+ "unknown-1": "an unknown field from another client",
+ },
+ creditCardToMerge: {
+ "cc-name": "John Doe",
+ "cc-number": "4929001587121045",
+ "cc-exp-month": 4,
+ "cc-exp-year": 2017,
+ "unknown-1": "an unknown field from another client",
+ },
+ expectedCreditCard: {
+ "cc-name": "John Doe",
+ "cc-number": "4929001587121045",
+ "cc-exp-month": 4,
+ "cc-exp-year": 2017,
+ "unknown-1": "an unknown field from another client",
+ },
+ },
+ {
+ description: "Merge a superset with billingAddressGUID",
+ creditCardInStorage: {
+ "cc-number": "4929001587121045",
+ },
+ creditCardToMerge: {
+ "cc-number": "4929001587121045",
+ billingAddressGUID: "ijsnbhfr",
+ },
+ expectedCreditCard: {
+ "cc-number": "4929001587121045",
+ billingAddressGUID: "ijsnbhfr",
+ },
+ },
+ {
+ description: "Merge a subset",
+ creditCardInStorage: {
+ "cc-name": "John Doe",
+ "cc-number": "4929001587121045",
+ "cc-exp-month": 4,
+ "cc-exp-year": 2017,
+ },
+ creditCardToMerge: {
+ "cc-number": "4929001587121045",
+ "cc-exp-month": 4,
+ "cc-exp-year": 2017,
+ },
+ expectedCreditCard: {
+ "cc-name": "John Doe",
+ "cc-number": "4929001587121045",
+ "cc-exp-month": 4,
+ "cc-exp-year": 2017,
+ },
+ noNeedToUpdate: true,
+ },
+ {
+ description: "Merge a subset with billingAddressGUID",
+ creditCardInStorage: {
+ "cc-number": "4929001587121045",
+ billingAddressGUID: "8fhdb3ug6",
+ },
+ creditCardToMerge: {
+ "cc-number": "4929001587121045",
+ },
+ expectedCreditCard: {
+ billingAddressGUID: "8fhdb3ug6",
+ "cc-number": "4929001587121045",
+ },
+ noNeedToUpdate: true,
+ },
+ {
+ description: "Merge an creditCard with partial overlaps",
+ creditCardInStorage: {
+ "cc-name": "John Doe",
+ "cc-number": "4929001587121045",
+ },
+ creditCardToMerge: {
+ "cc-number": "4929001587121045",
+ "cc-exp-month": 4,
+ "cc-exp-year": 2017,
+ },
+ expectedCreditCard: {
+ "cc-name": "John Doe",
+ "cc-number": "4929001587121045",
+ "cc-exp-month": 4,
+ "cc-exp-year": 2017,
+ },
+ },
+];
+
+let prepareTestCreditCards = async function (path) {
+ let profileStorage = new FormAutofillStorage(path);
+ await profileStorage.initialize();
+
+ let onChanged = TestUtils.topicObserved(
+ "formautofill-storage-changed",
+ (subject, data) =>
+ data == "add" &&
+ subject.wrappedJSObject.guid &&
+ subject.wrappedJSObject.collectionName == COLLECTION_NAME
+ );
+ Assert.ok(await profileStorage.creditCards.add(TEST_CREDIT_CARD_1));
+ await onChanged;
+ Assert.ok(await profileStorage.creditCards.add(TEST_CREDIT_CARD_2));
+ await onChanged;
+ await profileStorage._saveImmediately();
+};
+
+let reCCNumber = /^(\*+)(.{4})$/;
+
+let do_check_credit_card_matches = (creditCardWithMeta, creditCard) => {
+ for (let key in creditCard) {
+ if (key == "cc-number") {
+ let matches = reCCNumber.exec(creditCardWithMeta["cc-number"]);
+ Assert.notEqual(matches, null);
+ Assert.equal(
+ creditCardWithMeta["cc-number"].length,
+ creditCard["cc-number"].length
+ );
+ Assert.equal(creditCard["cc-number"].endsWith(matches[2]), true);
+ Assert.notEqual(creditCard["cc-number-encrypted"], "");
+ } else {
+ Assert.equal(creditCardWithMeta[key], creditCard[key], "Testing " + key);
+ }
+ }
+};
+
+add_task(async function test_initialize() {
+ let path = getTempFile(TEST_STORE_FILE_NAME).path;
+ let profileStorage = new FormAutofillStorage(path);
+ await profileStorage.initialize();
+
+ Assert.equal(profileStorage._store.data.version, 1);
+ Assert.equal(profileStorage._store.data.creditCards.length, 0);
+
+ let data = profileStorage._store.data;
+ Assert.deepEqual(data.creditCards, []);
+
+ await profileStorage._saveImmediately();
+
+ profileStorage = new FormAutofillStorage(path);
+ await profileStorage.initialize();
+
+ Assert.deepEqual(profileStorage._store.data, data);
+});
+
+add_task(async function test_getAll() {
+ let path = getTempFile(TEST_STORE_FILE_NAME).path;
+ await prepareTestCreditCards(path);
+
+ let profileStorage = new FormAutofillStorage(path);
+ await profileStorage.initialize();
+
+ let creditCards = await profileStorage.creditCards.getAll();
+
+ Assert.equal(creditCards.length, 2);
+ do_check_credit_card_matches(creditCards[0], TEST_CREDIT_CARD_1);
+ do_check_credit_card_matches(creditCards[1], TEST_CREDIT_CARD_2);
+
+ // Check computed fields.
+ Assert.equal(creditCards[0]["cc-given-name"], "John");
+ Assert.equal(creditCards[0]["cc-family-name"], "Doe");
+ Assert.equal(creditCards[0]["cc-exp"], "2017-04");
+
+ // Test with rawData set.
+ creditCards = await profileStorage.creditCards.getAll({ rawData: true });
+ Assert.equal(creditCards[0]["cc-given-name"], undefined);
+ Assert.equal(creditCards[0]["cc-family-name"], undefined);
+ Assert.equal(creditCards[0]["cc-exp"], undefined);
+
+ // Modifying output shouldn't affect the storage.
+ creditCards[0]["cc-name"] = "test";
+ do_check_credit_card_matches(
+ (await profileStorage.creditCards.getAll())[0],
+ TEST_CREDIT_CARD_1
+ );
+});
+
+add_task(async function test_get() {
+ let path = getTempFile(TEST_STORE_FILE_NAME).path;
+ await prepareTestCreditCards(path);
+
+ let profileStorage = new FormAutofillStorage(path);
+ await profileStorage.initialize();
+
+ let creditCards = await profileStorage.creditCards.getAll();
+ let guid = creditCards[0].guid;
+
+ let creditCard = await profileStorage.creditCards.get(guid);
+ do_check_credit_card_matches(creditCard, TEST_CREDIT_CARD_1);
+
+ // Modifying output shouldn't affect the storage.
+ creditCards[0]["cc-name"] = "test";
+ do_check_credit_card_matches(
+ await profileStorage.creditCards.get(guid),
+ TEST_CREDIT_CARD_1
+ );
+
+ Assert.equal(await profileStorage.creditCards.get("INVALID_GUID"), null);
+});
+
+add_task(async function test_add() {
+ let path = getTempFile(TEST_STORE_FILE_NAME).path;
+ await prepareTestCreditCards(path);
+
+ let profileStorage = new FormAutofillStorage(path);
+ await profileStorage.initialize();
+
+ let creditCards = await profileStorage.creditCards.getAll();
+
+ Assert.equal(creditCards.length, 2);
+
+ do_check_credit_card_matches(creditCards[0], TEST_CREDIT_CARD_1);
+ do_check_credit_card_matches(creditCards[1], TEST_CREDIT_CARD_2);
+
+ Assert.notEqual(creditCards[0].guid, undefined);
+ Assert.equal(creditCards[0].version, CREDIT_CARD_SCHEMA_VERSION);
+ Assert.notEqual(creditCards[0].timeCreated, undefined);
+ Assert.equal(creditCards[0].timeLastModified, creditCards[0].timeCreated);
+ Assert.equal(creditCards[0].timeLastUsed, 0);
+ Assert.equal(creditCards[0].timesUsed, 0);
+
+ // Empty string should be deleted before saving.
+ await profileStorage.creditCards.add(TEST_CREDIT_CARD_WITH_EMPTY_FIELD);
+ let creditCard = profileStorage.creditCards._data[2];
+ Assert.equal(
+ creditCard["cc-exp-month"],
+ TEST_CREDIT_CARD_WITH_EMPTY_FIELD["cc-exp-month"]
+ );
+ Assert.equal(creditCard["cc-name"], undefined);
+ Assert.equal(creditCard.billingAddressGUID, undefined);
+
+ // Empty computed fields shouldn't cause any problem.
+ await profileStorage.creditCards.add(
+ TEST_CREDIT_CARD_WITH_EMPTY_COMPUTED_FIELD
+ );
+ creditCard = profileStorage.creditCards._data[3];
+ Assert.equal(
+ creditCard["cc-number"],
+ CreditCard.getLongMaskedNumber(
+ TEST_CREDIT_CARD_WITH_EMPTY_COMPUTED_FIELD["cc-number"]
+ )
+ );
+
+ await Assert.rejects(
+ profileStorage.creditCards.add(TEST_CREDIT_CARD_WITH_INVALID_FIELD),
+ /"cc-type" contains invalid data type: object/
+ );
+
+ await Assert.rejects(
+ profileStorage.creditCards.add({}),
+ /Record contains no valid field\./
+ );
+
+ await Assert.rejects(
+ profileStorage.creditCards.add(TEST_CREDIT_CARD_EMPTY_AFTER_NORMALIZE),
+ /Record contains no valid field\./
+ );
+});
+
+add_task(async function test_addWithBillingAddress() {
+ let path = getTempFile(TEST_STORE_FILE_NAME).path;
+ let profileStorage = new FormAutofillStorage(path);
+ await profileStorage.initialize();
+
+ let creditCards = await profileStorage.creditCards.getAll();
+
+ Assert.equal(creditCards.length, 0);
+
+ await profileStorage.creditCards.add(TEST_CREDIT_CARD_WITH_BILLING_ADDRESS);
+
+ creditCards = await profileStorage.creditCards.getAll();
+ Assert.equal(creditCards.length, 1);
+ do_check_credit_card_matches(
+ creditCards[0],
+ TEST_CREDIT_CARD_WITH_BILLING_ADDRESS
+ );
+});
+
+add_task(async function test_update() {
+ // Test assumes that when an entry is saved a second time, it's last modified date will
+ // be different from the first. With high values of precision reduction, we execute too
+ // fast for that to be true.
+ let timerPrecision = Preferences.get("privacy.reduceTimerPrecision");
+ Preferences.set("privacy.reduceTimerPrecision", false);
+
+ registerCleanupFunction(function () {
+ Preferences.set("privacy.reduceTimerPrecision", timerPrecision);
+ });
+
+ let path = getTempFile(TEST_STORE_FILE_NAME).path;
+ await prepareTestCreditCards(path);
+
+ let profileStorage = new FormAutofillStorage(path);
+ await profileStorage.initialize();
+
+ let creditCards = await profileStorage.creditCards.getAll();
+ let guid = creditCards[1].guid;
+ let timeLastModified = creditCards[1].timeLastModified;
+
+ let onChanged = TestUtils.topicObserved(
+ "formautofill-storage-changed",
+ (subject, data) =>
+ data == "update" &&
+ subject.wrappedJSObject.guid == guid &&
+ subject.wrappedJSObject.collectionName == COLLECTION_NAME
+ );
+
+ Assert.notEqual(creditCards[1]["cc-name"], undefined);
+ await profileStorage.creditCards.update(guid, TEST_CREDIT_CARD_3);
+ await onChanged;
+ await profileStorage._saveImmediately();
+
+ profileStorage = new FormAutofillStorage(path);
+ await profileStorage.initialize();
+
+ let creditCard = await profileStorage.creditCards.get(guid);
+
+ Assert.equal(creditCard["cc-name"], undefined);
+ Assert.notEqual(creditCard.timeLastModified, timeLastModified);
+ do_check_credit_card_matches(creditCard, TEST_CREDIT_CARD_3);
+
+ // Empty string should be deleted while updating.
+ await profileStorage.creditCards.update(
+ profileStorage.creditCards._data[0].guid,
+ TEST_CREDIT_CARD_WITH_EMPTY_FIELD
+ );
+ creditCard = profileStorage.creditCards._data[0];
+ Assert.equal(
+ creditCard["cc-exp-month"],
+ TEST_CREDIT_CARD_WITH_EMPTY_FIELD["cc-exp-month"]
+ );
+ Assert.equal(creditCard["cc-name"], undefined);
+ Assert.equal(creditCard["cc-type"], "amex");
+ Assert.equal(creditCard.billingAddressGUID, undefined);
+
+ // Empty computed fields shouldn't cause any problem.
+ await profileStorage.creditCards.update(
+ profileStorage.creditCards._data[0].guid,
+ TEST_CREDIT_CARD_WITH_EMPTY_COMPUTED_FIELD,
+ false
+ );
+ creditCard = profileStorage.creditCards._data[0];
+ Assert.equal(
+ creditCard["cc-number"],
+ CreditCard.getLongMaskedNumber(
+ TEST_CREDIT_CARD_WITH_EMPTY_COMPUTED_FIELD["cc-number"]
+ )
+ );
+ await profileStorage.creditCards.update(
+ profileStorage.creditCards._data[1].guid,
+ TEST_CREDIT_CARD_WITH_EMPTY_COMPUTED_FIELD,
+ true
+ );
+ creditCard = profileStorage.creditCards._data[1];
+ Assert.equal(
+ creditCard["cc-number"],
+ CreditCard.getLongMaskedNumber(
+ TEST_CREDIT_CARD_WITH_EMPTY_COMPUTED_FIELD["cc-number"]
+ )
+ );
+
+ // Decryption failure of existing record should not prevent it from being updated.
+ creditCard = profileStorage.creditCards._data[0];
+ creditCard["cc-number-encrypted"] = "INVALID";
+ await profileStorage.creditCards.update(
+ profileStorage.creditCards._data[0].guid,
+ TEST_CREDIT_CARD_WITH_EMPTY_COMPUTED_FIELD,
+ false
+ );
+ creditCard = profileStorage.creditCards._data[0];
+ Assert.equal(
+ creditCard["cc-number"],
+ CreditCard.getLongMaskedNumber(
+ TEST_CREDIT_CARD_WITH_EMPTY_COMPUTED_FIELD["cc-number"]
+ )
+ );
+
+ await Assert.rejects(
+ profileStorage.creditCards.update("INVALID_GUID", TEST_CREDIT_CARD_3),
+ /No matching record\./
+ );
+
+ await Assert.rejects(
+ profileStorage.creditCards.update(
+ guid,
+ TEST_CREDIT_CARD_WITH_INVALID_FIELD
+ ),
+ /"cc-type" contains invalid data type: object/
+ );
+
+ await Assert.rejects(
+ profileStorage.creditCards.update(guid, {}),
+ /Record contains no valid field\./
+ );
+
+ await Assert.rejects(
+ profileStorage.creditCards.update(
+ guid,
+ TEST_CREDIT_CARD_EMPTY_AFTER_NORMALIZE
+ ),
+ /Record contains no valid field\./
+ );
+
+ await profileStorage.creditCards.update(guid, TEST_CREDIT_CARD_1);
+ await Assert.rejects(
+ profileStorage.creditCards.update(
+ guid,
+ TEST_CREDIT_CARD_EMPTY_AFTER_UPDATE_CREDIT_CARD_1
+ ),
+ /Record contains no valid field\./
+ );
+});
+
+add_task(async function test_validate() {
+ let path = getTempFile(TEST_STORE_FILE_NAME).path;
+
+ let profileStorage = new FormAutofillStorage(path);
+ await profileStorage.initialize();
+
+ await profileStorage.creditCards.add(
+ TEST_CREDIT_CARD_WITH_INVALID_EXPIRY_DATE
+ );
+ await profileStorage.creditCards.add(TEST_CREDIT_CARD_WITH_2_DIGITS_YEAR);
+ await profileStorage.creditCards.add(
+ TEST_CREDIT_CARD_WITH_SPACES_BETWEEN_DIGITS
+ );
+ let creditCards = await profileStorage.creditCards.getAll();
+
+ Assert.equal(creditCards[0]["cc-exp-month"], undefined);
+ Assert.equal(creditCards[0]["cc-exp-year"], undefined);
+ Assert.equal(creditCards[0]["cc-exp"], undefined);
+
+ let month = TEST_CREDIT_CARD_WITH_2_DIGITS_YEAR["cc-exp-month"];
+ let year =
+ parseInt(TEST_CREDIT_CARD_WITH_2_DIGITS_YEAR["cc-exp-year"], 10) + 2000;
+ Assert.equal(creditCards[1]["cc-exp-month"], month);
+ Assert.equal(creditCards[1]["cc-exp-year"], year);
+ Assert.equal(
+ creditCards[1]["cc-exp"],
+ year + "-" + month.toString().padStart(2, "0")
+ );
+
+ Assert.equal(creditCards[2]["cc-number"].length, 16);
+});
+
+add_task(async function test_notifyUsed() {
+ let path = getTempFile(TEST_STORE_FILE_NAME).path;
+ await prepareTestCreditCards(path);
+
+ let profileStorage = new FormAutofillStorage(path);
+ await profileStorage.initialize();
+
+ let creditCards = await profileStorage.creditCards.getAll();
+ let guid = creditCards[1].guid;
+ let timeLastUsed = creditCards[1].timeLastUsed;
+ let timesUsed = creditCards[1].timesUsed;
+
+ let onChanged = TestUtils.topicObserved(
+ "formautofill-storage-changed",
+ (subject, data) =>
+ data == "notifyUsed" &&
+ subject.wrappedJSObject.collectionName == COLLECTION_NAME &&
+ subject.wrappedJSObject.guid == guid
+ );
+
+ profileStorage.creditCards.notifyUsed(guid);
+ await onChanged;
+ await profileStorage._saveImmediately();
+
+ profileStorage = new FormAutofillStorage(path);
+ await profileStorage.initialize();
+
+ let creditCard = await profileStorage.creditCards.get(guid);
+
+ Assert.equal(creditCard.timesUsed, timesUsed + 1);
+ Assert.notEqual(creditCard.timeLastUsed, timeLastUsed);
+
+ Assert.throws(
+ () => profileStorage.creditCards.notifyUsed("INVALID_GUID"),
+ /No matching record\./
+ );
+});
+
+add_task(async function test_remove() {
+ let path = getTempFile(TEST_STORE_FILE_NAME).path;
+ await prepareTestCreditCards(path);
+
+ let profileStorage = new FormAutofillStorage(path);
+ await profileStorage.initialize();
+
+ let creditCards = await profileStorage.creditCards.getAll();
+ let guid = creditCards[1].guid;
+
+ let onChanged = TestUtils.topicObserved(
+ "formautofill-storage-changed",
+ (subject, data) =>
+ data == "remove" &&
+ subject.wrappedJSObject.guid == guid &&
+ subject.wrappedJSObject.collectionName == COLLECTION_NAME
+ );
+
+ Assert.equal(creditCards.length, 2);
+
+ profileStorage.creditCards.remove(guid);
+ await onChanged;
+ await profileStorage._saveImmediately();
+
+ profileStorage = new FormAutofillStorage(path);
+ await profileStorage.initialize();
+
+ creditCards = await profileStorage.creditCards.getAll();
+
+ Assert.equal(creditCards.length, 1);
+
+ Assert.equal(await profileStorage.creditCards.get(guid), null);
+});
+
+MERGE_TESTCASES.forEach(testcase => {
+ add_task(async function test_merge() {
+ info("Starting testcase: " + testcase.description);
+ let profileStorage = await initProfileStorage(
+ TEST_STORE_FILE_NAME,
+ [testcase.creditCardInStorage],
+ "creditCards"
+ );
+ let creditCards = await profileStorage.creditCards.getAll();
+ let guid = creditCards[0].guid;
+ let timeLastModified = creditCards[0].timeLastModified;
+ // Merge creditCard and verify the guid in notifyObservers subject
+ let onMerged = TestUtils.topicObserved(
+ "formautofill-storage-changed",
+ (subject, data) =>
+ data == "update" &&
+ subject.wrappedJSObject.guid == guid &&
+ subject.wrappedJSObject.collectionName == COLLECTION_NAME
+ );
+ // Force to create sync metadata.
+ profileStorage.creditCards.pullSyncChanges();
+ Assert.equal(getSyncChangeCounter(profileStorage.creditCards, guid), 1);
+ Assert.ok(
+ await profileStorage.creditCards.mergeIfPossible(
+ guid,
+ testcase.creditCardToMerge
+ )
+ );
+ if (!testcase.noNeedToUpdate) {
+ await onMerged;
+ }
+ creditCards = await profileStorage.creditCards.getAll();
+ Assert.equal(creditCards.length, 1);
+ do_check_credit_card_matches(creditCards[0], testcase.expectedCreditCard);
+ if (!testcase.noNeedToUpdate) {
+ // Record merging should update timeLastModified and bump the change counter.
+ Assert.notEqual(creditCards[0].timeLastModified, timeLastModified);
+ Assert.equal(getSyncChangeCounter(profileStorage.creditCards, guid), 2);
+ } else {
+ // Subset record merging should not update timeLastModified and the change
+ // counter is still the same.
+ Assert.equal(creditCards[0].timeLastModified, timeLastModified);
+ Assert.equal(getSyncChangeCounter(profileStorage.creditCards, guid), 1);
+ }
+ });
+});
+
+add_task(async function test_merge_unable_merge() {
+ let profileStorage = await initProfileStorage(
+ TEST_STORE_FILE_NAME,
+ [TEST_CREDIT_CARD_1],
+ "creditCards"
+ );
+
+ let creditCards = await profileStorage.creditCards.getAll();
+ let guid = creditCards[0].guid;
+ // Force to create sync metadata.
+ profileStorage.creditCards.pullSyncChanges();
+ Assert.equal(getSyncChangeCounter(profileStorage.creditCards, guid), 1);
+
+ // Unable to merge because of conflict
+ let anotherCreditCard = profileStorage.creditCards._clone(TEST_CREDIT_CARD_1);
+ anotherCreditCard["cc-name"] = "Foo Bar";
+ Assert.equal(
+ await profileStorage.creditCards.mergeIfPossible(guid, anotherCreditCard),
+ false
+ );
+ // The change counter is unchanged.
+ Assert.equal(getSyncChangeCounter(profileStorage.creditCards, guid), 1);
+
+ // Unable to merge because no credit card number
+ anotherCreditCard = profileStorage.creditCards._clone(TEST_CREDIT_CARD_1);
+ anotherCreditCard["cc-number"] = "";
+ Assert.equal(
+ await profileStorage.creditCards.mergeIfPossible(guid, anotherCreditCard),
+ false
+ );
+ // The change counter is still unchanged.
+ Assert.equal(getSyncChangeCounter(profileStorage.creditCards, guid), 1);
+});
+
+add_task(async function test_mergeToStorage() {
+ let profileStorage = await initProfileStorage(
+ TEST_STORE_FILE_NAME,
+ [TEST_CREDIT_CARD_3, TEST_CREDIT_CARD_4],
+ "creditCards"
+ );
+ // Merge a creditCard to storage
+ let anotherCreditCard = profileStorage.creditCards._clone(TEST_CREDIT_CARD_3);
+ anotherCreditCard["cc-name"] = "Foo Bar";
+ Assert.equal(
+ (await profileStorage.creditCards.mergeToStorage(anotherCreditCard)).length,
+ 2
+ );
+ Assert.equal(
+ (await profileStorage.creditCards.getAll())[0]["cc-name"],
+ "Foo Bar"
+ );
+ Assert.equal(
+ (await profileStorage.creditCards.getAll())[0]["cc-exp"],
+ "2000-01"
+ );
+ Assert.equal(
+ (await profileStorage.creditCards.getAll())[1]["cc-name"],
+ "Foo Bar"
+ );
+ Assert.equal(
+ (await profileStorage.creditCards.getAll())[1]["cc-exp"],
+ "2000-01"
+ );
+
+ // Empty computed fields shouldn't cause any problem.
+ Assert.equal(
+ (
+ await profileStorage.creditCards.mergeToStorage(
+ TEST_CREDIT_CARD_WITH_EMPTY_COMPUTED_FIELD
+ )
+ ).length,
+ 0
+ );
+});
+
+add_task(async function test_getDuplicateRecords() {
+ let profileStorage = await initProfileStorage(
+ TEST_STORE_FILE_NAME,
+ [TEST_CREDIT_CARD_3],
+ "creditCards"
+ );
+ let guid = profileStorage.creditCards._data[0].guid;
+
+ // Absolutely a duplicate.
+ let getDuplicateRecords =
+ profileStorage.creditCards.getDuplicateRecords(TEST_CREDIT_CARD_3);
+ let dupe = (await getDuplicateRecords.next()).value;
+ Assert.equal(dupe.guid, guid);
+
+ // Absolutely not a duplicate.
+ getDuplicateRecords =
+ profileStorage.creditCards.getDuplicateRecords(TEST_CREDIT_CARD_1);
+ dupe = (await getDuplicateRecords.next()).value;
+ Assert.equal(dupe, null);
+
+ // Subset with the same number is a duplicate.
+ let record = Object.assign({}, TEST_CREDIT_CARD_3);
+ delete record["cc-exp-month"];
+ getDuplicateRecords = profileStorage.creditCards.getDuplicateRecords(record);
+ dupe = (await getDuplicateRecords.next()).value;
+ Assert.equal(dupe.guid, guid);
+
+ // Superset with the same number is a duplicate.
+ record = Object.assign({}, TEST_CREDIT_CARD_3);
+ record["cc-name"] = "John Doe";
+ getDuplicateRecords = profileStorage.creditCards.getDuplicateRecords(record);
+ dupe = (await getDuplicateRecords.next()).value;
+ Assert.equal(dupe.guid, guid);
+
+ // Numbers with the same last 4 digits shouldn't be treated as a duplicate.
+ record = Object.assign({}, TEST_CREDIT_CARD_3);
+ let last4Digits = record["cc-number"].substr(-4);
+ getDuplicateRecords = profileStorage.creditCards.getDuplicateRecords(record);
+ dupe = (await getDuplicateRecords.next()).value;
+ Assert.equal(dupe.guid, guid);
+
+ // This number differs from TEST_CREDIT_CARD_3 by swapping the order of the
+ // 09 and 90 adjacent digits, which is still a valid credit card number.
+ record["cc-number"] = "358999378390" + last4Digits;
+
+ // We don't treat numbers with the same last 4 digits as a duplicate.
+ getDuplicateRecords = profileStorage.creditCards.getDuplicateRecords(record);
+ dupe = (await getDuplicateRecords.next()).value;
+ Assert.equal(dupe, null);
+});
+
+add_task(async function test_getDuplicateRecordsMatch() {
+ let profileStorage = await initProfileStorage(
+ TEST_STORE_FILE_NAME,
+ [TEST_CREDIT_CARD_2],
+ "creditCards"
+ );
+ let guid = profileStorage.creditCards._data[0].guid;
+
+ // Absolutely a duplicate.
+ let getDuplicateRecords =
+ profileStorage.creditCards.getDuplicateRecords(TEST_CREDIT_CARD_2);
+ let dupe = (await getDuplicateRecords.next()).value;
+ Assert.equal(dupe.guid, guid);
+
+ // Absolutely not a duplicate.
+ getDuplicateRecords =
+ profileStorage.creditCards.getDuplicateRecords(TEST_CREDIT_CARD_1);
+ dupe = (await getDuplicateRecords.next()).value;
+ Assert.equal(dupe, null);
+
+ record = Object.assign({}, TEST_CREDIT_CARD_2);
+
+ // We change month from `1` to `2`
+ record["cc-exp-month"] = 2;
+ getDuplicateRecords = profileStorage.creditCards.getDuplicateRecords(record);
+ dupe = (await getDuplicateRecords.next()).value;
+ Assert.equal(dupe.guid, guid);
+
+ // We change year from `2000` to `2001`
+ record["cc-exp-year"] = 2001;
+ getDuplicateRecords = profileStorage.creditCards.getDuplicateRecords(record);
+ dupe = (await getDuplicateRecords.next()).value;
+ Assert.equal(dupe.guid, guid);
+
+ // New name, same card
+ record["cc-name"] = "John Doe";
+ getDuplicateRecords = profileStorage.creditCards.getDuplicateRecords(record);
+ dupe = (await getDuplicateRecords.next()).value;
+ Assert.equal(dupe.guid, guid);
+});
+
+add_task(async function test_getMatchRecord() {
+ let profileStorage = await initProfileStorage(
+ TEST_STORE_FILE_NAME,
+ [TEST_CREDIT_CARD_2],
+ "creditCards"
+ );
+ let guid = profileStorage.creditCards._data[0].guid;
+
+ const TEST_FIELDS = {
+ "cc-name": "John Doe",
+ "cc-exp-month": 10,
+ "cc-exp-year": 2001,
+ };
+
+ // Absolutely a match.
+ let getMatchRecords =
+ profileStorage.creditCards.getMatchRecords(TEST_CREDIT_CARD_2);
+ let match = (await getMatchRecords.next()).value;
+ Assert.equal(match.guid, guid);
+
+ // Subset with the same number is a match.
+ for (const field of Object.keys(TEST_FIELDS)) {
+ let record = Object.assign({}, TEST_CREDIT_CARD_2);
+ delete record[field];
+ getMatchRecords = profileStorage.creditCards.getMatchRecords(record);
+ match = (await getMatchRecords.next()).value;
+ Assert.equal(match.guid, guid);
+ }
+
+ // Subset with different number is not a match.
+ for (const field of Object.keys(TEST_FIELDS)) {
+ let record = Object.assign({}, TEST_CREDIT_CARD_2, {
+ "cc-number": TEST_CREDIT_CARD_1["cc-number"],
+ });
+ delete record[field];
+ getMatchRecords = profileStorage.creditCards.getMatchRecords(record);
+ match = (await getMatchRecords.next()).value;
+ Assert.equal(match, null);
+ }
+
+ // Superset with the same number is not a match.
+ for (const [field, value] of Object.entries(TEST_FIELDS)) {
+ let record = Object.assign({}, TEST_CREDIT_CARD_2);
+ record[field] = value;
+ getMatchRecords = profileStorage.creditCards.getMatchRecords(record);
+ match = (await getMatchRecords.next()).value;
+ Assert.equal(match, null);
+ }
+
+ // Superset with different number is not a match.
+ for (const [field, value] of Object.entries(TEST_FIELDS)) {
+ let record = Object.assign({}, TEST_CREDIT_CARD_2, {
+ "cc-number": TEST_CREDIT_CARD_1["cc-number"],
+ });
+ record[field] = value;
+ getMatchRecords = profileStorage.creditCards.getMatchRecords(record);
+ match = (await getMatchRecords.next()).value;
+ Assert.equal(match, null);
+ }
+});
+
+add_task(async function test_creditCardFillDisabled() {
+ Services.prefs.setBoolPref(
+ "extensions.formautofill.creditCards.enabled",
+ false
+ );
+
+ let path = getTempFile(TEST_STORE_FILE_NAME).path;
+ let profileStorage = new FormAutofillStorage(path);
+ await profileStorage.initialize();
+
+ Assert.equal(
+ !!profileStorage.creditCards,
+ true,
+ "credit card records initialized and available."
+ );
+
+ Services.prefs.setBoolPref(
+ "extensions.formautofill.creditCards.enabled",
+ true
+ );
+});
diff --git a/browser/extensions/formautofill/test/unit/test_extractLabelStrings.js b/browser/extensions/formautofill/test/unit/test_extractLabelStrings.js
new file mode 100644
index 0000000000..4c03c263f8
--- /dev/null
+++ b/browser/extensions/formautofill/test/unit/test_extractLabelStrings.js
@@ -0,0 +1,77 @@
+"use strict";
+
+var { LabelUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/shared/LabelUtils.sys.mjs"
+);
+
+const TESTCASES = [
+ {
+ description: "A label element contains one input element.",
+ document: `<label id="typeA"> label type A
+ <!-- This comment should not be extracted. -->
+ <input type="text">
+ <script>FOO</script>
+ <noscript>FOO</noscript>
+ <option>FOO</option>
+ <style>FOO</style>
+ </label>`,
+ inputId: "typeA",
+ expectedStrings: ["label type A"],
+ },
+ {
+ description: "A label element with inner div contains one input element.",
+ document: `<label id="typeB"> label type B
+ <!-- This comment should not be extracted. -->
+ <script>FOO</script>
+ <noscript>FOO</noscript>
+ <option>FOO</option>
+ <style>FOO</style>
+ <div> inner div
+ <input type="text">
+ </div>
+ </label>`,
+ inputId: "typeB",
+ expectedStrings: ["label type B", "inner div"],
+ },
+ {
+ description:
+ "A label element with inner prefix/postfix strings contains span elements.",
+ document: `<label id="typeC"> label type C
+ <!-- This comment should not be extracted. -->
+ <script>FOO</script>
+ <noscript>FOO</noscript>
+ <option>FOO</option>
+ <style>FOO</style>
+ <div> inner div prefix
+ <span>test C-1 </span>
+ <span> test C-2</span>
+ inner div postfix
+ </div>
+ </label>`,
+ inputId: "typeC",
+ expectedStrings: [
+ "label type C",
+ "inner div prefix",
+ "test C-1",
+ "test C-2",
+ "inner div postfix",
+ ],
+ },
+];
+
+TESTCASES.forEach(testcase => {
+ add_task(async function () {
+ info("Starting testcase: " + testcase.description);
+ LabelUtils._labelStrings = new WeakMap();
+
+ let doc = MockDocument.createTestDocument(
+ "http://localhost:8080/test/",
+ testcase.document
+ );
+
+ let element = doc.getElementById(testcase.inputId);
+ let strings = LabelUtils.extractLabelStrings(element);
+
+ Assert.deepEqual(strings, testcase.expectedStrings);
+ });
+});
diff --git a/browser/extensions/formautofill/test/unit/test_findLabelElements.js b/browser/extensions/formautofill/test/unit/test_findLabelElements.js
new file mode 100644
index 0000000000..956ace83f6
--- /dev/null
+++ b/browser/extensions/formautofill/test/unit/test_findLabelElements.js
@@ -0,0 +1,100 @@
+"use strict";
+
+var { LabelUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/shared/LabelUtils.sys.mjs"
+);
+
+const TESTCASES = [
+ {
+ description: "Input contains in a label element.",
+ document: `<form>
+ <label id="labelA"> label type A
+ <input id="typeA" type="text">
+ </label>
+ </form>`,
+ inputId: "typeA",
+ expectedLabelIds: ["labelA"],
+ },
+ {
+ description: "Input contains in a label element.",
+ document: `<label id="labelB"> label type B
+ <div> inner div
+ <input id="typeB" type="text">
+ </div>
+ </label>`,
+ inputId: "typeB",
+ expectedLabelIds: ["labelB"],
+ },
+ {
+ description: '"for" attribute used to indicate input by one label.',
+ document: `<label id="labelC" for="typeC">label type C</label>
+ <input id="typeC" type="text">`,
+ inputId: "typeC",
+ expectedLabelIds: ["labelC"],
+ },
+ {
+ description: '"for" attribute used to indicate input by multiple labels.',
+ document: `<form>
+ <label id="labelD1" for="typeD">label type D1</label>
+ <label id="labelD2" for="typeD">label type D2</label>
+ <label id="labelD3" for="typeD">label type D3</label>
+ <input id="typeD" type="text">
+ </form>`,
+ inputId: "typeD",
+ expectedLabelIds: ["labelD1", "labelD2", "labelD3"],
+ },
+ {
+ description:
+ '"for" attribute used to indicate input by multiple labels with space prefix/postfix.',
+ document: `<label id="labelE1" for="typeE">label type E1</label>
+ <label id="labelE2" for="typeE ">label type E2</label>
+ <label id="labelE3" for=" TYPEe">label type E3</label>
+ <label id="labelE4" for=" typeE ">label type E4</label>
+ <input id=" typeE " type="text">`,
+ inputId: " typeE ",
+ expectedLabelIds: [],
+ },
+ {
+ description: "Input contains in a label element.",
+ document: `<label id="labelF"> label type F
+ <label for="dummy"> inner label
+ <input id="typeF" type="text">
+ <input id="dummy" type="text">
+ </div>
+ </label>`,
+ inputId: "typeF",
+ expectedLabelIds: ["labelF"],
+ },
+ {
+ description:
+ '"for" attribute used to indicate input by labels out of the form.',
+ document: `<label id="labelG1" for="typeG">label type G1</label>
+ <form>
+ <label id="labelG2" for="typeG">label type G2</label>
+ <input id="typeG" type="text">
+ </form>
+ <label id="labelG3" for="typeG">label type G3</label>`,
+ inputId: "typeG",
+ expectedLabelIds: ["labelG1", "labelG2", "labelG3"],
+ },
+];
+
+TESTCASES.forEach(testcase => {
+ add_task(async function () {
+ info("Starting testcase: " + testcase.description);
+
+ let doc = MockDocument.createTestDocument(
+ "http://localhost:8080/test/",
+ testcase.document
+ );
+
+ let input = doc.getElementById(testcase.inputId);
+ let labels = LabelUtils.findLabelElements(input);
+
+ Assert.deepEqual(
+ labels.map(l => l.id),
+ testcase.expectedLabelIds
+ );
+ LabelUtils.clearLabelMap();
+ });
+});
diff --git a/browser/extensions/formautofill/test/unit/test_getAdaptedProfiles.js b/browser/extensions/formautofill/test/unit/test_getAdaptedProfiles.js
new file mode 100644
index 0000000000..63def3d8d9
--- /dev/null
+++ b/browser/extensions/formautofill/test/unit/test_getAdaptedProfiles.js
@@ -0,0 +1,1300 @@
+/*
+ * Test for form auto fill content helper fill all inputs function.
+ */
+
+"use strict";
+
+var FormAutofillHandler;
+add_task(async function () {
+ ({ FormAutofillHandler } = ChromeUtils.importESModule(
+ "resource://gre/modules/shared/FormAutofillHandler.sys.mjs"
+ ));
+});
+
+const DEFAULT_ADDRESS_RECORD = {
+ guid: "123",
+ "street-address": "2 Harrison St\nline2\nline3",
+ "address-line1": "2 Harrison St",
+ "address-line2": "line2",
+ "address-line3": "line3",
+ "address-level1": "CA",
+ country: "US",
+ tel: "+19876543210",
+ "tel-national": "9876543210",
+};
+
+const ADDRESS_RECORD_2 = {
+ guid: "address2",
+ "given-name": "John",
+ "additional-name": "Middle",
+ "family-name": "Doe",
+ "postal-code": "940012345",
+};
+
+const DEFAULT_CREDITCARD_RECORD = {
+ guid: "123",
+ "cc-exp-month": 1,
+ "cc-exp-year": 2025,
+ "cc-exp": "2025-01",
+};
+
+const DEFAULT_EXPECTED_CREDITCARD_RECORD = {
+ guid: "123",
+ "cc-exp-month": 1,
+ "cc-exp-year": 2025,
+ "cc-exp": "01/2025",
+};
+
+const getCCExpMonthFormatted = () => {
+ return DEFAULT_CREDITCARD_RECORD["cc-exp-month"].toString().padStart(2, "0");
+};
+
+const getCCExpYearFormatted = () => {
+ return DEFAULT_CREDITCARD_RECORD["cc-exp-year"].toString().substring(2);
+};
+
+// Bug 1767130: If a form has separate inputs for expiry month and year,
+// we will always transform month into MM
+const DEFAULT_EXPECTED_CREDITCARD_RECORD_SEPARATE_EXPIRY = {
+ ...DEFAULT_CREDITCARD_RECORD,
+ "cc-exp-month-formatted": getCCExpMonthFormatted(),
+};
+
+const TESTCASES = [
+ {
+ description: "Address form with street-address",
+ document: `<form>
+ <input autocomplete="given-name">
+ <input autocomplete="family-name">
+ <input id="street-addr" autocomplete="street-address">
+ </form>`,
+ profileData: [{ ...DEFAULT_ADDRESS_RECORD }],
+ expectedResult: [
+ {
+ guid: "123",
+ "street-address": "2 Harrison St line2 line3",
+ "-moz-street-address-one-line": "2 Harrison St line2 line3",
+ "address-line1": "2 Harrison St",
+ "address-line2": "line2",
+ "address-line3": "line3",
+ "address-level1": "CA",
+ country: "US",
+ tel: "+19876543210",
+ "tel-national": "9876543210",
+ },
+ ],
+ },
+ {
+ description: "Address form with street-address, address-line[1, 2, 3]",
+ document: `<form>
+ <input id="street-addr" autocomplete="street-address">
+ <input id="line1" autocomplete="address-line1">
+ <input id="line2" autocomplete="address-line2">
+ <input id="line3" autocomplete="address-line3">
+ </form>`,
+ profileData: [{ ...DEFAULT_ADDRESS_RECORD }],
+ expectedResult: [
+ {
+ guid: "123",
+ "street-address": "2 Harrison St line2 line3",
+ "-moz-street-address-one-line": "2 Harrison St line2 line3",
+ "address-line1": "2 Harrison St",
+ "address-line2": "line2",
+ "address-line3": "line3",
+ "address-level1": "CA",
+ country: "US",
+ tel: "+19876543210",
+ "tel-national": "9876543210",
+ },
+ ],
+ },
+ {
+ description: "Address form with street-address, address-line1",
+ document: `<form>
+ <input autocomplete="given-name">
+ <input id="street-addr" autocomplete="street-address">
+ <input id="line1" autocomplete="address-line1">
+ </form>`,
+ profileData: [{ ...DEFAULT_ADDRESS_RECORD }],
+ expectedResult: [
+ {
+ guid: "123",
+ "street-address": "2 Harrison St line2 line3",
+ "-moz-street-address-one-line": "2 Harrison St line2 line3",
+ "address-line1": "2 Harrison St line2 line3",
+ "address-line2": "line2",
+ "address-line3": "line3",
+ "address-level1": "CA",
+ country: "US",
+ tel: "+19876543210",
+ "tel-national": "9876543210",
+ },
+ ],
+ },
+ {
+ description: "Address form with street-address, address-line[1, 2]",
+ document: `<form>
+ <input id="street-addr" autocomplete="street-address">
+ <input id="line1" autocomplete="address-line1">
+ <input id="line2" autocomplete="address-line2">
+ </form>`,
+ profileData: [{ ...DEFAULT_ADDRESS_RECORD }],
+ expectedResult: [
+ {
+ guid: "123",
+ "street-address": "2 Harrison St line2 line3",
+ "-moz-street-address-one-line": "2 Harrison St line2 line3",
+ "address-line1": "2 Harrison St",
+ "address-line2": "line2 line3",
+ "address-line3": "line3",
+ "address-level1": "CA",
+ country: "US",
+ tel: "+19876543210",
+ "tel-national": "9876543210",
+ },
+ ],
+ },
+ {
+ description:
+ "Address form with street-address, address-line[1, 3]" +
+ ", determined by autocomplete attr",
+ document: `<form>
+ <input id="street-addr" autocomplete="street-address">
+ <input id="line1" autocomplete="address-line1">
+ <input id="line3" autocomplete="address-line3">
+ </form>`,
+ profileData: [{ ...DEFAULT_ADDRESS_RECORD }],
+ expectedResult: [
+ {
+ guid: "123",
+ "street-address": "2 Harrison St line2 line3",
+ "-moz-street-address-one-line": "2 Harrison St line2 line3",
+ // Since the form is missing address-line2 field, the value of
+ // address-line1 should contain line2 value as well.
+ "address-line1": "2 Harrison St line2",
+ "address-line2": "line2",
+ "address-line3": "line3",
+ "address-level1": "CA",
+ country: "US",
+ tel: "+19876543210",
+ "tel-national": "9876543210",
+ },
+ ],
+ },
+ {
+ description:
+ "Address form with street-address, address-line[1, 3]" +
+ ", determined by heuristics",
+ document: `<form>
+ <input id="street-address">
+ <input id="address-line1">
+ <input id="address-line3">
+ </form>`,
+ profileData: [{ ...DEFAULT_ADDRESS_RECORD }],
+ expectedResult: [
+ {
+ guid: "123",
+ "street-address": "2 Harrison St line2 line3",
+ "-moz-street-address-one-line": "2 Harrison St line2 line3",
+ // Since the form is missing address-line2 field, the value of
+ // address-line1 should contain line2 value as well.
+ "address-line1": "2 Harrison St line2",
+ "address-line2": "line2",
+ "address-line3": "line3",
+ "address-level1": "CA",
+ country: "US",
+ tel: "+19876543210",
+ "tel-national": "9876543210",
+ },
+ ],
+ },
+ {
+ description: "Address form with exact matching options in select",
+ document: `<form>
+ <input autocomplete="given-name">
+ <select autocomplete="address-level1">
+ <option id="option-address-level1-XX" value="XX">Dummy</option>
+ <option id="option-address-level1-CA" value="CA">California</option>
+ </select>
+ <select autocomplete="country">
+ <option id="option-country-XX" value="XX">Dummy</option>
+ <option id="option-country-US" value="US">United States</option>
+ </select>
+ </form>`,
+ profileData: [{ ...DEFAULT_ADDRESS_RECORD }],
+ expectedResult: [
+ {
+ guid: "123",
+ "street-address": "2 Harrison St\nline2\nline3",
+ "-moz-street-address-one-line": "2 Harrison St line2 line3",
+ "address-line1": "2 Harrison St",
+ "address-line2": "line2",
+ "address-line3": "line3",
+ "address-level1": "CA",
+ country: "US",
+ tel: "+19876543210",
+ "tel-national": "9876543210",
+ },
+ ],
+ expectedOptionElements: [
+ {
+ "address-level1": "option-address-level1-CA",
+ country: "option-country-US",
+ },
+ ],
+ },
+ {
+ description: "Address form with inexact matching options in select",
+ document: `<form>
+ <input autocomplete="given-name">
+ <select autocomplete="address-level1">
+ <option id="option-address-level1-XX" value="XX">Dummy</option>
+ <option id="option-address-level1-OO" value="OO">California</option>
+ </select>
+ <select autocomplete="country">
+ <option id="option-country-XX" value="XX">Dummy</option>
+ <option id="option-country-OO" value="OO">United States</option>
+ </select>
+ </form>`,
+ profileData: [{ ...DEFAULT_ADDRESS_RECORD }],
+ expectedResult: [
+ {
+ guid: "123",
+ "street-address": "2 Harrison St\nline2\nline3",
+ "-moz-street-address-one-line": "2 Harrison St line2 line3",
+ "address-line1": "2 Harrison St",
+ "address-line2": "line2",
+ "address-line3": "line3",
+ "address-level1": "CA",
+ country: "US",
+ tel: "+19876543210",
+ "tel-national": "9876543210",
+ },
+ ],
+ expectedOptionElements: [
+ {
+ "address-level1": "option-address-level1-OO",
+ country: "option-country-OO",
+ },
+ ],
+ },
+ {
+ description: "Address form with value-omitted options in select",
+ document: `<form>
+ <input autocomplete="given-name">
+ <select autocomplete="address-level1">
+ <option id="option-address-level1-1" value="">Dummy</option>
+ <option id="option-address-level1-2" value="">California</option>
+ </select>
+ <select autocomplete="country">
+ <option id="option-country-1" value="">Dummy</option>
+ <option id="option-country-2" value="">United States</option>
+ </select>
+ </form>`,
+ profileData: [{ ...DEFAULT_ADDRESS_RECORD }],
+ expectedResult: [
+ {
+ guid: "123",
+ "street-address": "2 Harrison St\nline2\nline3",
+ "-moz-street-address-one-line": "2 Harrison St line2 line3",
+ "address-line1": "2 Harrison St",
+ "address-line2": "line2",
+ "address-line3": "line3",
+ "address-level1": "CA",
+ country: "US",
+ tel: "+19876543210",
+ "tel-national": "9876543210",
+ },
+ ],
+ expectedOptionElements: [
+ {
+ "address-level1": "option-address-level1-2",
+ country: "option-country-2",
+ },
+ ],
+ },
+ {
+ description: "Address form with options with the same value in select ",
+ document: `<form>
+ <input autocomplete="given-name">
+ <select autocomplete="address-level1">
+ <option id="option-address-level1-same1" value="same">Dummy</option>
+ <option id="option-address-level1-same2" value="same">California</option>
+ </select>
+ <select autocomplete="country">
+ <option id="option-country-same1" value="sametoo">Dummy</option>
+ <option id="option-country-same2" value="sametoo">United States</option>
+ </select>
+ </form>`,
+ profileData: [{ ...DEFAULT_ADDRESS_RECORD }],
+ expectedResult: [
+ {
+ guid: "123",
+ "street-address": "2 Harrison St\nline2\nline3",
+ "-moz-street-address-one-line": "2 Harrison St line2 line3",
+ "address-line1": "2 Harrison St",
+ "address-line2": "line2",
+ "address-line3": "line3",
+ "address-level1": "CA",
+ country: "US",
+ tel: "+19876543210",
+ "tel-national": "9876543210",
+ },
+ ],
+ expectedOptionElements: [
+ {
+ "address-level1": "option-address-level1-same2",
+ country: "option-country-same2",
+ },
+ ],
+ },
+ {
+ description:
+ "Address form without matching options in select for address-level1 and country",
+ document: `<form>
+ <input autocomplete="given-name">
+ <select autocomplete="address-level1">
+ <option id="option-address-level1-dummy1" value="">Dummy</option>
+ <option id="option-address-level1-dummy2" value="">Dummy 2</option>
+ </select>
+ <select autocomplete="country">
+ <option id="option-country-dummy1" value="">Dummy</option>
+ <option id="option-country-dummy2" value="">Dummy 2</option>
+ </select>
+ </form>`,
+ profileData: [{ ...DEFAULT_ADDRESS_RECORD }],
+ expectedResult: [
+ {
+ guid: "123",
+ "street-address": "2 Harrison St\nline2\nline3",
+ "-moz-street-address-one-line": "2 Harrison St line2 line3",
+ "address-line1": "2 Harrison St",
+ "address-line2": "line2",
+ "address-line3": "line3",
+ tel: "+19876543210",
+ "tel-national": "9876543210",
+ },
+ ],
+ },
+ {
+ description:
+ "Change the tel value of a profile to tel-national for a field without pattern and maxlength.",
+ document: `<form>
+ <input id="telephone">
+ <input id="line1" autocomplete="address-line1">
+ <input id="line2" autocomplete="address-line2">
+ </form>`,
+ profileData: [{ ...DEFAULT_ADDRESS_RECORD }],
+ expectedResult: [
+ {
+ guid: "123",
+ "street-address": "2 Harrison St\nline2\nline3",
+ "-moz-street-address-one-line": "2 Harrison St line2 line3",
+ "address-line1": "2 Harrison St",
+ "address-line2": "line2 line3",
+ "address-line3": "line3",
+ "address-level1": "CA",
+ country: "US",
+ tel: "9876543210",
+ "tel-national": "9876543210",
+ },
+ ],
+ },
+ {
+ description:
+ 'Do not change the profile for an autocomplete="tel" field without patern and maxlength.',
+ document: `<form>
+ <input id="tel" autocomplete="tel">
+ <input id="line1" autocomplete="address-line1">
+ <input id="line2" autocomplete="address-line2">
+ </form>`,
+ profileData: [{ ...DEFAULT_ADDRESS_RECORD }],
+ expectedResult: [
+ {
+ guid: "123",
+ "street-address": "2 Harrison St\nline2\nline3",
+ "-moz-street-address-one-line": "2 Harrison St line2 line3",
+ "address-line1": "2 Harrison St",
+ "address-line2": "line2 line3",
+ "address-line3": "line3",
+ "address-level1": "CA",
+ country: "US",
+ tel: "+19876543210",
+ "tel-national": "9876543210",
+ },
+ ],
+ },
+ {
+ description:
+ 'autocomplete="tel" field with `maxlength` can be filled with `tel` value.',
+ document: `<form>
+ <input id="telephone" autocomplete="tel" maxlength="12">
+ <input id="line1" autocomplete="address-line1">
+ <input id="line2" autocomplete="address-line2">
+ </form>`,
+ profileData: [{ ...DEFAULT_ADDRESS_RECORD }],
+ expectedResult: [
+ {
+ guid: "123",
+ "street-address": "2 Harrison St\nline2\nline3",
+ "-moz-street-address-one-line": "2 Harrison St line2 line3",
+ "address-line1": "2 Harrison St",
+ "address-line2": "line2 line3",
+ "address-line3": "line3",
+ "address-level1": "CA",
+ country: "US",
+ tel: "+19876543210",
+ "tel-national": "9876543210",
+ },
+ ],
+ },
+ {
+ description:
+ "Still fill `tel-national` in a `tel` field with `maxlength` can be filled with `tel` value.",
+ document: `<form>
+ <input id="telephone" maxlength="12">
+ <input id="line1" autocomplete="address-line1">
+ <input id="line2" autocomplete="address-line2">
+ </form>`,
+ profileData: [{ ...DEFAULT_ADDRESS_RECORD }],
+ expectedResult: [
+ {
+ guid: "123",
+ "street-address": "2 Harrison St\nline2\nline3",
+ "-moz-street-address-one-line": "2 Harrison St line2 line3",
+ "address-line1": "2 Harrison St",
+ "address-line2": "line2 line3",
+ "address-line3": "line3",
+ "address-level1": "CA",
+ country: "US",
+ tel: "9876543210",
+ "tel-national": "9876543210",
+ },
+ ],
+ },
+ {
+ description:
+ "`tel` field with `maxlength` can be filled with `tel-national` value.",
+ document: `<form>
+ <input id="telephone" maxlength="10">
+ <input id="line1" autocomplete="address-line1">
+ <input id="line2" autocomplete="address-line2">
+ </form>`,
+ profileData: [{ ...DEFAULT_ADDRESS_RECORD }],
+ expectedResult: [
+ {
+ guid: "123",
+ "street-address": "2 Harrison St\nline2\nline3",
+ "-moz-street-address-one-line": "2 Harrison St line2 line3",
+ "address-line1": "2 Harrison St",
+ "address-line2": "line2 line3",
+ "address-line3": "line3",
+ "address-level1": "CA",
+ country: "US",
+ tel: "9876543210",
+ "tel-national": "9876543210",
+ },
+ ],
+ },
+ {
+ description:
+ "`tel` field with `pattern` attr can be filled with `tel` value.",
+ document: `<form>
+ <input id="telephone" pattern="[+][0-9]+">
+ <input id="line1" autocomplete="address-line1">
+ <input id="line2" autocomplete="address-line2">
+ </form>`,
+ profileData: [{ ...DEFAULT_ADDRESS_RECORD }],
+ expectedResult: [
+ {
+ guid: "123",
+ "street-address": "2 Harrison St\nline2\nline3",
+ "-moz-street-address-one-line": "2 Harrison St line2 line3",
+ "address-line1": "2 Harrison St",
+ "address-line2": "line2 line3",
+ "address-line3": "line3",
+ "address-level1": "CA",
+ country: "US",
+ tel: "+19876543210",
+ "tel-national": "9876543210",
+ },
+ ],
+ },
+ {
+ description:
+ "Change the tel value of a profile to tel-national one when the pattern is matched.",
+ document: `<form>
+ <input id="telephone" pattern="\d*">
+ <input id="line1" autocomplete="address-line1">
+ <input id="line2" autocomplete="address-line2">
+ </form>`,
+ profileData: [{ ...DEFAULT_ADDRESS_RECORD }],
+ expectedResult: [
+ {
+ guid: "123",
+ "street-address": "2 Harrison St\nline2\nline3",
+ "-moz-street-address-one-line": "2 Harrison St line2 line3",
+ "address-line1": "2 Harrison St",
+ "address-line2": "line2 line3",
+ "address-line3": "line3",
+ "address-level1": "CA",
+ country: "US",
+ tel: "9876543210",
+ "tel-national": "9876543210",
+ },
+ ],
+ },
+ {
+ description: 'Matching pattern when a field is with autocomplete="tel".',
+ document: `<form>
+ <input id="tel" autocomplete="tel" pattern="[0-9]+">
+ <input id="line1" autocomplete="address-line1">
+ <input id="line2" autocomplete="address-line2">
+ </form>`,
+ profileData: [{ ...DEFAULT_ADDRESS_RECORD }],
+ expectedResult: [
+ {
+ guid: "123",
+ "street-address": "2 Harrison St\nline2\nline3",
+ "-moz-street-address-one-line": "2 Harrison St line2 line3",
+ "address-line1": "2 Harrison St",
+ "address-line2": "line2 line3",
+ "address-line3": "line3",
+ "address-level1": "CA",
+ country: "US",
+ tel: "9876543210",
+ "tel-national": "9876543210",
+ },
+ ],
+ },
+ {
+ description:
+ "Checking maxlength of tel field first when a field is with maxlength.",
+ document: `<form>
+ <input id="tel" autocomplete="tel" maxlength="10">
+ <input id="line1" autocomplete="address-line1">
+ <input id="line2" autocomplete="address-line2">
+ </form>`,
+ profileData: [{ ...DEFAULT_ADDRESS_RECORD }],
+ expectedResult: [
+ {
+ guid: "123",
+ "street-address": "2 Harrison St\nline2\nline3",
+ "-moz-street-address-one-line": "2 Harrison St line2 line3",
+ "address-line1": "2 Harrison St",
+ "address-line2": "line2 line3",
+ "address-line3": "line3",
+ "address-level1": "CA",
+ country: "US",
+ tel: "9876543210",
+ "tel-national": "9876543210",
+ },
+ ],
+ },
+ {
+ description: "Address form with maxlength restriction",
+ document: `<form>
+ <input autocomplete="given-name" maxlength="1">
+ <input autocomplete="additional-name" maxlength="1">
+ <input autocomplete="family-name" maxlength="1">
+ <input autocomplete="postal-code" maxlength="5">
+ </form>`,
+ profileData: [{ ...ADDRESS_RECORD_2 }],
+ expectedResult: [
+ {
+ guid: "address2",
+ "given-name": "J",
+ "additional-name": "M",
+ "family-name": "D",
+ "postal-code": "94001",
+ },
+ ],
+ },
+ {
+ description:
+ "Address form with the special cases of the maxlength restriction",
+ document: `<form>
+ <input autocomplete="given-name" maxlength="-1">
+ <input autocomplete="additional-name" maxlength="0">
+ <input autocomplete="family-name" maxlength="1">
+ </form>`,
+ profileData: [{ ...ADDRESS_RECORD_2 }],
+ expectedResult: [
+ {
+ guid: "address2",
+ "given-name": "John",
+ "family-name": "D",
+ "postal-code": "940012345",
+ },
+ ],
+ },
+ {
+ description:
+ "Credit card form with separate fields for expiration month and year",
+ document: `<form>
+ <input autocomplete="cc-number">
+ <input autocomplete="cc-exp-month">
+ <input autocomplete="cc-exp-year">
+ </form`,
+ profileData: [{ ...DEFAULT_CREDITCARD_RECORD }],
+ expectedResult: [{ ...DEFAULT_EXPECTED_CREDITCARD_RECORD_SEPARATE_EXPIRY }],
+ },
+ {
+ description:
+ "Credit Card form with matching options of cc-exp-year and cc-exp-month",
+ document: `<form>
+ <input autocomplete="cc-number">
+ <select autocomplete="cc-exp-month">
+ <option id="option-cc-exp-month-01" value="1">01</option>
+ <option id="option-cc-exp-month-02" value="2">02</option>
+ <option id="option-cc-exp-month-03" value="3">03</option>
+ <option id="option-cc-exp-month-04" value="4">04</option>
+ <option id="option-cc-exp-month-05" value="5">05</option>
+ <option id="option-cc-exp-month-06" value="6">06</option>
+ <option id="option-cc-exp-month-07" value="7">07</option>
+ <option id="option-cc-exp-month-08" value="8">08</option>
+ <option id="option-cc-exp-month-09" value="9">09</option>
+ <option id="option-cc-exp-month-10" value="10">10</option>
+ <option id="option-cc-exp-month-11" value="11">11</option>
+ <option id="option-cc-exp-month-12" value="12">12</option>
+ </select>
+ <select autocomplete="cc-exp-year">
+ <option id="option-cc-exp-year-17" value="2017">17</option>
+ <option id="option-cc-exp-year-18" value="2018">18</option>
+ <option id="option-cc-exp-year-19" value="2019">19</option>
+ <option id="option-cc-exp-year-20" value="2020">20</option>
+ <option id="option-cc-exp-year-21" value="2021">21</option>
+ <option id="option-cc-exp-year-22" value="2022">22</option>
+ <option id="option-cc-exp-year-23" value="2023">23</option>
+ <option id="option-cc-exp-year-24" value="2024">24</option>
+ <option id="option-cc-exp-year-25" value="2025">25</option>
+ <option id="option-cc-exp-year-26" value="2026">26</option>
+ <option id="option-cc-exp-year-27" value="2027">27</option>
+ <option id="option-cc-exp-year-28" value="2028">28</option>
+ </select>
+ </form>`,
+ profileData: [{ ...DEFAULT_CREDITCARD_RECORD }],
+ expectedResult: [DEFAULT_CREDITCARD_RECORD],
+ expectedOptionElements: [
+ {
+ "cc-exp-month": "option-cc-exp-month-01",
+ "cc-exp-year": "option-cc-exp-year-25",
+ },
+ ],
+ },
+ {
+ description: "Credit Card form with matching options which contain labels",
+ document: `<form>
+ <input autocomplete="cc-number">
+ <select autocomplete="cc-exp-month">
+ <option value="" selected="selected">Month</option>
+ <option label="01 - January" id="option-cc-exp-month-01" value="object:17">dummy</option>
+ <option label="02 - February" id="option-cc-exp-month-02" value="object:18">dummy</option>
+ <option label="03 - March" id="option-cc-exp-month-03" value="object:19">dummy</option>
+ <option label="04 - April" id="option-cc-exp-month-04" value="object:20">dummy</option>
+ <option label="05 - May" id="option-cc-exp-month-05" value="object:21">dummy</option>
+ <option label="06 - June" id="option-cc-exp-month-06" value="object:22">dummy</option>
+ <option label="07 - July" id="option-cc-exp-month-07" value="object:23">dummy</option>
+ <option label="08 - August" id="option-cc-exp-month-08" value="object:24">dummy</option>
+ <option label="09 - September" id="option-cc-exp-month-09" value="object:25">dummy</option>
+ <option label="10 - October" id="option-cc-exp-month-10" value="object:26">dummy</option>
+ <option label="11 - November" id="option-cc-exp-month-11" value="object:27">dummy</option>
+ <option label="12 - December" id="option-cc-exp-month-12" value="object:28">dummy</option>
+ </select>
+ <select autocomplete="cc-exp-year">
+ <option value="" selected="selected">Year</option>
+ <option label="2017" id="option-cc-exp-year-17" value="object:29">dummy</option>
+ <option label="2018" id="option-cc-exp-year-18" value="object:30">dummy</option>
+ <option label="2019" id="option-cc-exp-year-19" value="object:31">dummy</option>
+ <option label="2020" id="option-cc-exp-year-20" value="object:32">dummy</option>
+ <option label="2021" id="option-cc-exp-year-21" value="object:33">dummy</option>
+ <option label="2022" id="option-cc-exp-year-22" value="object:34">dummy</option>
+ <option label="2023" id="option-cc-exp-year-23" value="object:35">dummy</option>
+ <option label="2024" id="option-cc-exp-year-24" value="object:36">dummy</option>
+ <option label="2025" id="option-cc-exp-year-25" value="object:37">dummy</option>
+ <option label="2026" id="option-cc-exp-year-26" value="object:38">dummy</option>
+ <option label="2027" id="option-cc-exp-year-27" value="object:39">dummy</option>
+ <option label="2028" id="option-cc-exp-year-28" value="object:40">dummy</option>
+ <option label="2029" id="option-cc-exp-year-29" value="object:41">dummy</option>
+ <option label="2030" id="option-cc-exp-year-30" value="object:42">dummy</option>
+ <option label="2031" id="option-cc-exp-year-31" value="object:43">dummy</option>
+ <option label="2032" id="option-cc-exp-year-32" value="object:44">dummy</option>
+ <option label="2033" id="option-cc-exp-year-33" value="object:45">dummy</option>
+ <option label="2034" id="option-cc-exp-year-34" value="object:46">dummy</option>
+ <option label="2035" id="option-cc-exp-year-35" value="object:47">dummy</option>
+ </select>
+ </form>`,
+ profileData: [{ ...DEFAULT_CREDITCARD_RECORD }],
+ expectedResult: [DEFAULT_CREDITCARD_RECORD],
+ expectedOptionElements: [
+ {
+ "cc-exp-month": "option-cc-exp-month-01",
+ "cc-exp-year": "option-cc-exp-year-25",
+ },
+ ],
+ },
+ {
+ description: "Compound cc-exp: {MON1}/{YEAR2}",
+ document: `<form>
+ <input autocomplete="cc-number">
+ <select autocomplete="cc-exp">
+ <option value="3/17">3/17</option>
+ <option value="1/25" id="selected-cc-exp">1/25</option>
+ </select></form>`,
+ profileData: [{ ...DEFAULT_CREDITCARD_RECORD }],
+ expectedResult: [DEFAULT_EXPECTED_CREDITCARD_RECORD],
+ expectedOptionElements: [{ "cc-exp": "selected-cc-exp" }],
+ },
+ {
+ description: "Compound cc-exp: {MON1}/{YEAR4}",
+ document: `<form>
+ <input autocomplete="cc-number">
+ <select autocomplete="cc-exp">
+ <option value="3/2017">3/2017</option>
+ <option value="1/2025" id="selected-cc-exp">1/2025</option>
+ </select></form>`,
+ profileData: [{ ...DEFAULT_CREDITCARD_RECORD }],
+ expectedResult: [DEFAULT_EXPECTED_CREDITCARD_RECORD],
+ expectedOptionElements: [{ "cc-exp": "selected-cc-exp" }],
+ },
+ {
+ description: "Compound cc-exp: {MON2}/{YEAR2}",
+ document: `<form>
+ <input autocomplete="cc-number">
+ <select autocomplete="cc-exp">
+ <option value="03/17">03/17</option>
+ <option value="01/25" id="selected-cc-exp">01/25</option>
+ </select></form>`,
+ profileData: [{ ...DEFAULT_CREDITCARD_RECORD }],
+ expectedResult: [DEFAULT_EXPECTED_CREDITCARD_RECORD],
+ expectedOptionElements: [{ "cc-exp": "selected-cc-exp" }],
+ },
+ {
+ description: "Compound cc-exp: {MON2}/{YEAR4}",
+ document: `<form>
+ <input autocomplete="cc-number">
+ <select autocomplete="cc-exp">
+ <option value="03/2017">03/2017</option>
+ <option value="01/2025" id="selected-cc-exp">01/2025</option>
+ </select></form>`,
+ profileData: [{ ...DEFAULT_CREDITCARD_RECORD }],
+ expectedResult: [DEFAULT_EXPECTED_CREDITCARD_RECORD],
+ expectedOptionElements: [{ "cc-exp": "selected-cc-exp" }],
+ },
+ {
+ description: "Compound cc-exp: {MON1}-{YEAR2}",
+ document: `<form>
+ <input autocomplete="cc-number">
+ <select autocomplete="cc-exp">
+ <option value="3-17">3-17</option>
+ <option value="1-25" id="selected-cc-exp">1-25</option>
+ </select></form>`,
+ profileData: [{ ...DEFAULT_CREDITCARD_RECORD }],
+ expectedResult: [DEFAULT_EXPECTED_CREDITCARD_RECORD],
+ expectedOptionElements: [{ "cc-exp": "selected-cc-exp" }],
+ },
+ {
+ description: "Compound cc-exp: {MON1}-{YEAR4}",
+ document: `<form>
+ <input autocomplete="cc-number">
+ <select autocomplete="cc-exp">
+ <option value="3-2017">3-2017</option>
+ <option value="1-2025" id="selected-cc-exp">1-2025</option>
+ </select></form>`,
+ profileData: [{ ...DEFAULT_CREDITCARD_RECORD }],
+ expectedResult: [DEFAULT_EXPECTED_CREDITCARD_RECORD],
+ expectedOptionElements: [{ "cc-exp": "selected-cc-exp" }],
+ },
+ {
+ description: "Compound cc-exp: {MON2}-{YEAR2}",
+ document: `<form>
+ <input autocomplete="cc-number">
+ <select autocomplete="cc-exp">
+ <option value="03-17">03-17</option>
+ <option value="01-25" id="selected-cc-exp">01-25</option>
+ </select></form>`,
+ profileData: [{ ...DEFAULT_CREDITCARD_RECORD }],
+ expectedResult: [DEFAULT_EXPECTED_CREDITCARD_RECORD],
+ expectedOptionElements: [{ "cc-exp": "selected-cc-exp" }],
+ },
+ {
+ description: "Compound cc-exp: {MON2}-{YEAR4}",
+ document: `<form>
+ <input autocomplete="cc-number">
+ <select autocomplete="cc-exp">
+ <option value="03-2017">03-2017</option>
+ <option value="01-2025" id="selected-cc-exp">01-2025</option>
+ </select></form>`,
+ profileData: [{ ...DEFAULT_CREDITCARD_RECORD }],
+ expectedResult: [DEFAULT_EXPECTED_CREDITCARD_RECORD],
+ expectedOptionElements: [{ "cc-exp": "selected-cc-exp" }],
+ },
+ {
+ description: "Compound cc-exp: {YEAR2}-{MON2}",
+ document: `<form>
+ <input autocomplete="cc-number">
+ <select autocomplete="cc-exp">
+ <option value="17-03">17-03</option>
+ <option value="25-01" id="selected-cc-exp">25-01</option>
+ </select></form>`,
+ profileData: [{ ...DEFAULT_CREDITCARD_RECORD }],
+ expectedResult: [DEFAULT_EXPECTED_CREDITCARD_RECORD],
+ expectedOptionElements: [{ "cc-exp": "selected-cc-exp" }],
+ },
+ {
+ description: "Compound cc-exp: {YEAR4}-{MON2}",
+ document: `<form>
+ <input autocomplete="cc-number">
+ <select autocomplete="cc-exp">
+ <option value="2017-03">2017-03</option>
+ <option value="2025-01" id="selected-cc-exp">2025-01</option>
+ </select></form>`,
+ profileData: [{ ...DEFAULT_CREDITCARD_RECORD }],
+ expectedResult: [DEFAULT_EXPECTED_CREDITCARD_RECORD],
+ expectedOptionElements: [{ "cc-exp": "selected-cc-exp" }],
+ },
+ {
+ description: "Compound cc-exp: {YEAR4}/{MON2}",
+ document: `<form>
+ <input autocomplete="cc-number">
+ <select autocomplete="cc-exp">
+ <option value="2017/3">2017/3</option>
+ <option value="2025/1" id="selected-cc-exp">2025/1</option>
+ </select></form>`,
+ profileData: [{ ...DEFAULT_CREDITCARD_RECORD }],
+ expectedResult: [DEFAULT_EXPECTED_CREDITCARD_RECORD],
+ expectedOptionElements: [{ "cc-exp": "selected-cc-exp" }],
+ },
+ {
+ description: "Compound cc-exp: {MON2}{YEAR2}",
+ document: `<form>
+ <input autocomplete="cc-number">
+ <select autocomplete="cc-exp">
+ <option value="0317">0317</option>
+ <option value="0125" id="selected-cc-exp">0125</option>
+ </select></form>`,
+ profileData: [{ ...DEFAULT_CREDITCARD_RECORD }],
+ expectedResult: [DEFAULT_EXPECTED_CREDITCARD_RECORD],
+ expectedOptionElements: [{ "cc-exp": "selected-cc-exp" }],
+ },
+ {
+ description: "Compound cc-exp: {YEAR2}{MON2}",
+ document: `<form>
+ <input autocomplete="cc-number">
+ <select autocomplete="cc-exp">
+ <option value="1703">1703</option>
+ <option value="2501" id="selected-cc-exp">2501</option>
+ </select></form>`,
+ profileData: [{ ...DEFAULT_CREDITCARD_RECORD }],
+ expectedResult: [DEFAULT_EXPECTED_CREDITCARD_RECORD],
+ expectedOptionElements: [{ "cc-exp": "selected-cc-exp" }],
+ },
+ {
+ description: "Fill a cc-exp without cc-exp-month value in the profile",
+ document: `<form>
+ <input autocomplete="cc-number">
+ <select autocomplete="cc-exp">
+ <option value="03/17">03/17</option>
+ <option value="01/25">01/25</option>
+ </select></form>`,
+ profileData: [
+ {
+ guid: "123",
+ "cc-exp-year": 2025,
+ },
+ ],
+ expectedResult: [
+ {
+ guid: "123",
+ "cc-exp-year": 2025,
+ },
+ ],
+ expectedOptionElements: [],
+ },
+ {
+ description: "Fill a cc-exp without cc-exp-year value in the profile",
+ document: `<form>
+ <input autocomplete="cc-number">
+ <select autocomplete="cc-exp">
+ <option value="03/17">03/17</option>
+ <option value="01/25">01/25</option>
+ </select></form>`,
+ profileData: [
+ {
+ guid: "123",
+ "cc-exp-month": 1,
+ },
+ ],
+ expectedResult: [
+ {
+ guid: "123",
+ "cc-exp-month": 1,
+ },
+ ],
+ expectedOptionElements: [],
+ },
+ {
+ description: "Fill a cc-exp* without cc-exp-month value in the profile",
+ document: `<form>
+ <input autocomplete="cc-number">
+ <select autocomplete="cc-exp-month">
+ <option value="03">03</option>
+ <option value="01">01</option>
+ </select>
+ <select autocomplete="cc-exp-year">
+ <option value="17">2017</option>
+ <option value="25">2025</option>
+ </select>
+ </form>`,
+ profileData: [
+ {
+ guid: "123",
+ "cc-exp-year": 2025,
+ },
+ ],
+ expectedResult: [
+ {
+ guid: "123",
+ "cc-exp-year": 2025,
+ },
+ ],
+ expectedOptionElements: [],
+ },
+ {
+ description: "Fill a cc-exp* without cc-exp-year value in the profile",
+ document: `<form>
+ <input autocomplete="cc-number">
+ <select autocomplete="cc-exp-month">
+ <option value="03">03</option>
+ <option value="01">01</option>
+ </select>
+ <select autocomplete="cc-exp-year">
+ <option value="17">2017</option>
+ <option value="25">2025</option>
+ </select>
+ </form>`,
+ profileData: [
+ {
+ guid: "123",
+ "cc-exp-month": 1,
+ },
+ ],
+ expectedResult: [
+ {
+ guid: "123",
+ "cc-exp-month": 1,
+ },
+ ],
+ expectedOptionElements: [],
+ },
+ {
+ description:
+ "Fill a cc-exp field using adjacent label (MM/YY) as expiry string placeholder",
+ document: `<form>
+ <input autocomplete="cc-number">
+ <label>Expiry (MM/YY)</label>
+ <input autocomplete="cc-exp">
+ </form>
+ `,
+ profileData: [DEFAULT_CREDITCARD_RECORD],
+ expectedResult: [
+ { ...DEFAULT_EXPECTED_CREDITCARD_RECORD, "cc-exp": "01/25" },
+ ],
+ },
+ {
+ description:
+ "Fill a cc-exp field using adjacent label (MM - YY) as expiry string placeholder",
+ document: `<form>
+ <input autocomplete="cc-number">
+ <label>Expiry (MM - YY)</label>
+ <input autocomplete="cc-exp">
+ </form>
+ `,
+ profileData: [DEFAULT_CREDITCARD_RECORD],
+ expectedResult: [
+ { ...DEFAULT_EXPECTED_CREDITCARD_RECORD, "cc-exp": "01-25" },
+ ],
+ },
+ {
+ description: "Fill a cc-exp field correctly while ignoring unrelated label",
+ document: `<form>
+ <label>Credit card number label</label>
+ <input autocomplete="cc-number">
+ <input autocomplete="cc-exp">
+ </form>
+ `,
+ profileData: [DEFAULT_CREDITCARD_RECORD],
+ expectedResult: [DEFAULT_EXPECTED_CREDITCARD_RECORD],
+ },
+ {
+ description: "Fill a cc-exp without placeholder on the cc-exp field",
+ document: `<form><input autocomplete="cc-number">
+ <input autocomplete="cc-exp"></form>`,
+ profileData: [DEFAULT_CREDITCARD_RECORD],
+ expectedResult: [DEFAULT_EXPECTED_CREDITCARD_RECORD],
+ },
+ {
+ description:
+ "Fill a cc-exp with whitespace placeholder on the cc-exp field",
+ document: `<form><input autocomplete="cc-number">
+ <input autocomplete="cc-exp" placeholder=" "></form>`,
+ profileData: [DEFAULT_CREDITCARD_RECORD],
+ expectedResult: [DEFAULT_EXPECTED_CREDITCARD_RECORD],
+ },
+ {
+ description: "Use placeholder to adjust cc-exp format [mm/yy].",
+ document: `<form><input autocomplete="cc-number">
+ <input placeholder="mm/yy" autocomplete="cc-exp"></form>`,
+ profileData: [{ ...DEFAULT_CREDITCARD_RECORD }],
+ expectedResult: [
+ {
+ ...DEFAULT_CREDITCARD_RECORD,
+ "cc-exp": "01/25",
+ },
+ ],
+ },
+ {
+ description: "Use placeholder to adjust cc-exp format [mm / yy].",
+ document: `<form><input autocomplete="cc-number">
+ <input placeholder="mm / yy" autocomplete="cc-exp"></form>`,
+ profileData: [{ ...DEFAULT_CREDITCARD_RECORD }],
+ expectedResult: [
+ {
+ ...DEFAULT_CREDITCARD_RECORD,
+ "cc-exp": "01/25",
+ },
+ ],
+ },
+ {
+ description: "Use placeholder to adjust cc-exp format [MM / YY].",
+ document: `<form><input autocomplete="cc-number">
+ <input placeholder="MM / YY" autocomplete="cc-exp"></form>`,
+ profileData: [{ ...DEFAULT_CREDITCARD_RECORD }],
+ expectedResult: [
+ {
+ ...DEFAULT_CREDITCARD_RECORD,
+ "cc-exp": "01/25",
+ },
+ ],
+ },
+ {
+ description: "Use placeholder to adjust cc-exp format [mm / yyyy].",
+ document: `<form><input autocomplete="cc-number">
+ <input placeholder="mm / yyyy" autocomplete="cc-exp"></form>`,
+ profileData: [{ ...DEFAULT_CREDITCARD_RECORD }],
+ expectedResult: [
+ {
+ ...DEFAULT_CREDITCARD_RECORD,
+ "cc-exp": "01/2025",
+ },
+ ],
+ },
+ {
+ description: "Use placeholder to adjust cc-exp format [mm - yyyy].",
+ document: `<form><input autocomplete="cc-number">
+ <input placeholder="mm - yyyy" autocomplete="cc-exp"></form>`,
+ profileData: [{ ...DEFAULT_CREDITCARD_RECORD }],
+ expectedResult: [
+ {
+ ...DEFAULT_CREDITCARD_RECORD,
+ "cc-exp": "01-2025",
+ },
+ ],
+ },
+ {
+ description: "Use placeholder to adjust cc-exp format [yyyy-mm].",
+ document: `<form><input autocomplete="cc-number">
+ <input placeholder="yyyy-mm" autocomplete="cc-exp"></form>`,
+ profileData: [{ ...DEFAULT_CREDITCARD_RECORD }],
+ expectedResult: [
+ {
+ ...DEFAULT_CREDITCARD_RECORD,
+ "cc-exp": "2025-01",
+ },
+ ],
+ },
+ {
+ description: "Use placeholder to adjust cc-exp format [mmm yyyy].",
+ document: `<form><input autocomplete="cc-number">
+ <input placeholder="mmm yyyy" autocomplete="cc-exp"></form>`,
+ profileData: [{ ...DEFAULT_CREDITCARD_RECORD }],
+ expectedResult: [DEFAULT_EXPECTED_CREDITCARD_RECORD],
+ },
+ {
+ description: "Use placeholder to adjust cc-exp format [mm foo yyyy].",
+ document: `<form><input autocomplete="cc-number">
+ <input placeholder="mm foo yyyy" autocomplete="cc-exp"></form>`,
+ profileData: [{ ...DEFAULT_CREDITCARD_RECORD }],
+ expectedResult: [DEFAULT_EXPECTED_CREDITCARD_RECORD],
+ },
+ {
+ description: "Use placeholder to adjust cc-exp format [mm - - yyyy].",
+ document: `<form><input autocomplete="cc-number">
+ <input placeholder="mm - - yyyy" autocomplete="cc-exp"></form>`,
+ profileData: [{ ...DEFAULT_CREDITCARD_RECORD }],
+ expectedResult: [DEFAULT_EXPECTED_CREDITCARD_RECORD],
+ },
+ {
+ description: "Use placeholder to adjust cc-exp-month field [mm].",
+ document: `<form>
+ <input autocomplete="cc-number">
+ <input autocomplete="cc-exp-month" placeholder="MM">
+ <input autocomplete="cc-exp-year">
+ </form>`,
+ profileData: [{ ...DEFAULT_CREDITCARD_RECORD }],
+ expectedResult: [
+ {
+ ...DEFAULT_CREDITCARD_RECORD,
+ "cc-exp-month-formatted": getCCExpMonthFormatted(),
+ },
+ ],
+ },
+ {
+ description: "Use placeholder to adjust cc-exp-year field [yy].",
+ document: `<form>
+ <input autocomplete="cc-number">
+ <input autocomplete="cc-exp-month">
+ <input autocomplete="cc-exp-year" placeholder="YY">
+ </form>`,
+ profileData: [{ ...DEFAULT_CREDITCARD_RECORD }],
+ expectedResult: [
+ {
+ ...DEFAULT_EXPECTED_CREDITCARD_RECORD_SEPARATE_EXPIRY,
+ "cc-exp-year-formatted": getCCExpYearFormatted(),
+ },
+ ],
+ },
+ {
+ description: "Test maxlength=2 on numeric fields.",
+ document: `<form>
+ <input autocomplete="cc-number">
+ <input autocomplete="cc-exp-month" maxlength="2">
+ <input autocomplete="cc-exp-year" maxlength="2">
+ </form>`,
+ profileData: [{ ...DEFAULT_CREDITCARD_RECORD }],
+ expectedResult: [
+ {
+ ...DEFAULT_EXPECTED_CREDITCARD_RECORD_SEPARATE_EXPIRY,
+ "cc-exp-year": 25,
+ },
+ ],
+ },
+ {
+ description: "Test maxlength=4 on numeric fields.",
+ document: `<form>
+ <input autocomplete="cc-number">
+ <input autocomplete="cc-exp-month" maxlength="4">
+ <input autocomplete="cc-exp-year" maxlength="4">
+ </form>`,
+ profileData: [{ ...DEFAULT_CREDITCARD_RECORD }],
+ expectedResult: [DEFAULT_EXPECTED_CREDITCARD_RECORD_SEPARATE_EXPIRY],
+ },
+ // Bug 1687679: The default value of an expiration month, when filled in an input element,
+ // is a two character length string. Because of this, testing a maxlength of 1 is invalid.
+ {
+ description: "Test maxlength=1 on numeric fields.",
+ document: `<form>
+ <input autocomplete="cc-number">
+ <input autocomplete="cc-exp-month" maxlength="1">
+ <input autocomplete="cc-exp-year" maxlength="1">
+ </form>`,
+ profileData: [{ ...DEFAULT_CREDITCARD_RECORD }],
+ expectedResult: [
+ {
+ ...DEFAULT_EXPECTED_CREDITCARD_RECORD_SEPARATE_EXPIRY,
+ "cc-exp-year": 5,
+ "cc-exp-month": 1,
+ },
+ ],
+ },
+ {
+ description: "Test maxlength=0 on numeric fields.",
+ document: `<form>
+ <input autocomplete="cc-number">
+ <input autocomplete="cc-exp-month" maxlength="0">
+ <input autocomplete="cc-exp-year" maxlength="0">
+ </form>`,
+ profileData: [{ ...DEFAULT_CREDITCARD_RECORD }],
+ expectedResult: [
+ {
+ guid: DEFAULT_CREDITCARD_RECORD.guid,
+ "cc-exp": DEFAULT_CREDITCARD_RECORD["cc-exp"],
+ },
+ ],
+ },
+ {
+ // It appears that negative values do not get propagated.
+ description: "Test maxlength=-2 on numeric fields.",
+ document: `<form>
+ <input autocomplete="cc-number">
+ <input autocomplete="cc-exp-month" maxlength="-2">
+ <input autocomplete="cc-exp-year" maxlength="-2">
+ </form>`,
+ profileData: [{ ...DEFAULT_CREDITCARD_RECORD }],
+ expectedResult: [DEFAULT_EXPECTED_CREDITCARD_RECORD_SEPARATE_EXPIRY],
+ },
+ {
+ description: "Test maxlength=10 on numeric fields.",
+ document: `<form>
+ <input autocomplete="cc-number">
+ <input autocomplete="cc-exp-month" maxlength="10">
+ <input autocomplete="cc-exp-year" maxlength="10">
+ </form>`,
+ profileData: [{ ...DEFAULT_CREDITCARD_RECORD }],
+ expectedResult: [DEFAULT_EXPECTED_CREDITCARD_RECORD_SEPARATE_EXPIRY],
+ },
+ {
+ description: "Test (special case) maxlength=5 on cc-exp field.",
+ document: `<form>
+ <input autocomplete="cc-number">
+ <input autocomplete="cc-exp" maxlength="5">
+ </form>`,
+ profileData: [{ ...DEFAULT_CREDITCARD_RECORD }],
+ expectedResult: [
+ {
+ ...DEFAULT_CREDITCARD_RECORD,
+ "cc-exp": "01/25",
+ },
+ ],
+ },
+];
+
+for (let testcase of TESTCASES) {
+ add_task(async function () {
+ info("Starting testcase: " + testcase.description);
+
+ let doc = MockDocument.createTestDocument(
+ "http://localhost:8080/test/",
+ testcase.document
+ );
+ let form = doc.querySelector("form");
+ let formLike = FormLikeFactory.createFromForm(form);
+ let handler = new FormAutofillHandler(formLike);
+
+ handler.collectFormFields();
+ handler.focusedInput = form.elements[0];
+
+ let adaptedRecords = handler.activeSection.getAdaptedProfiles(
+ testcase.profileData
+ );
+ Assert.deepEqual(adaptedRecords, testcase.expectedResult);
+
+ if (testcase.expectedOptionElements) {
+ testcase.expectedOptionElements.forEach((expectedOptionElement, i) => {
+ for (let field in expectedOptionElement) {
+ let select = form.querySelector(`[autocomplete=${field}]`);
+ let expectedOption = doc.getElementById(expectedOptionElement[field]);
+ Assert.notEqual(expectedOption, null);
+
+ let value = testcase.profileData[i][field];
+ let cache =
+ handler.activeSection._cacheValue.matchingSelectOption.get(select);
+ let targetOption = cache[value] && cache[value].get();
+ Assert.notEqual(targetOption, null);
+
+ Assert.equal(targetOption, expectedOption);
+ }
+ });
+ }
+ });
+}
diff --git a/browser/extensions/formautofill/test/unit/test_getAdaptedProfiles_locales.js b/browser/extensions/formautofill/test/unit/test_getAdaptedProfiles_locales.js
new file mode 100644
index 0000000000..476559c43d
--- /dev/null
+++ b/browser/extensions/formautofill/test/unit/test_getAdaptedProfiles_locales.js
@@ -0,0 +1,272 @@
+/* Any copyright is dedicated to the Public Domain.
+http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/*
+ * Test to ensure locale specific placeholders for credit card fields are properly used
+ * to transform various values in the profile.
+ */
+
+"use strict";
+
+const DEFAULT_CREDITCARD_RECORD = {
+ guid: "123",
+ "cc-exp-month": 1,
+ "cc-exp-year": 2025,
+ "cc-exp": "2025-01",
+};
+
+const getCCExpYearFormatted = () => {
+ return DEFAULT_CREDITCARD_RECORD["cc-exp-year"].toString().substring(2);
+};
+
+const getCCExpMonthFormatted = () => {
+ return DEFAULT_CREDITCARD_RECORD["cc-exp-month"].toString().padStart(2, "0");
+};
+
+const DEFAULT_EXPECTED_CREDITCARD_RECORD_SEPARATE_EXPIRY_FIELDS = {
+ ...DEFAULT_CREDITCARD_RECORD,
+ "cc-exp-month-formatted": getCCExpMonthFormatted(),
+ "cc-exp-year-formatted": getCCExpYearFormatted(),
+};
+
+const FR_TESTCASES = [
+ {
+ description: "Use placeholder to adjust cc-exp format [mm/aa].",
+ document: `<form><input autocomplete="cc-number">
+ <input placeholder="mm/aa" autocomplete="cc-exp"></form>`,
+ profileData: [Object.assign({}, DEFAULT_CREDITCARD_RECORD)],
+ expectedResult: [
+ Object.assign({}, DEFAULT_CREDITCARD_RECORD, {
+ "cc-exp": "01/25",
+ }),
+ ],
+ },
+ {
+ description: "Use placeholder to adjust cc-exp format [mm / aa].",
+ document: `<form><input autocomplete="cc-number">
+ <input placeholder="mm / aa" autocomplete="cc-exp"></form>`,
+ profileData: [Object.assign({}, DEFAULT_CREDITCARD_RECORD)],
+ expectedResult: [
+ Object.assign({}, DEFAULT_CREDITCARD_RECORD, {
+ "cc-exp": "01/25",
+ }),
+ ],
+ },
+ {
+ description: "Use placeholder to adjust cc-exp format [MM / AA].",
+ document: `<form><input autocomplete="cc-number">
+ <input placeholder="MM / AA" autocomplete="cc-exp"></form>`,
+ profileData: [Object.assign({}, DEFAULT_CREDITCARD_RECORD)],
+ expectedResult: [
+ Object.assign({}, DEFAULT_CREDITCARD_RECORD, {
+ "cc-exp": "01/25",
+ }),
+ ],
+ },
+ {
+ description: "Use placeholder to adjust cc-exp format [mm / aaaa].",
+ document: `<form><input autocomplete="cc-number">
+ <input placeholder="mm / aaaa" autocomplete="cc-exp"></form>`,
+ profileData: [Object.assign({}, DEFAULT_CREDITCARD_RECORD)],
+ expectedResult: [
+ Object.assign({}, DEFAULT_CREDITCARD_RECORD, {
+ "cc-exp": "01/2025",
+ }),
+ ],
+ },
+ {
+ description: "Use placeholder to adjust cc-exp format [mm - aaaa].",
+ document: `<form><input autocomplete="cc-number">
+ <input placeholder="mm - aaaa" autocomplete="cc-exp"></form>`,
+ profileData: [Object.assign({}, DEFAULT_CREDITCARD_RECORD)],
+ expectedResult: [
+ Object.assign({}, DEFAULT_CREDITCARD_RECORD, {
+ "cc-exp": "01-2025",
+ }),
+ ],
+ },
+ {
+ description: "Use placeholder to adjust cc-exp format [aaaa-mm].",
+ document: `<form><input autocomplete="cc-number">
+ <input placeholder="aaaa-mm" autocomplete="cc-exp"></form>`,
+ profileData: [Object.assign({}, DEFAULT_CREDITCARD_RECORD)],
+ expectedResult: [
+ Object.assign({}, DEFAULT_CREDITCARD_RECORD, {
+ "cc-exp": "2025-01",
+ }),
+ ],
+ },
+ {
+ description: "Use placeholder to adjust cc-exp-year field [aa].",
+ document: `<form>
+ <input autocomplete="cc-number">
+ <input autocomplete="cc-exp-month">
+ <input autocomplete="cc-exp-year" placeholder="AA">
+ </form>`,
+ profileData: [Object.assign({}, DEFAULT_CREDITCARD_RECORD)],
+ expectedResult: [
+ { ...DEFAULT_EXPECTED_CREDITCARD_RECORD_SEPARATE_EXPIRY_FIELDS },
+ ],
+ },
+];
+
+const DE_TESTCASES = [
+ {
+ description: "Use placeholder to adjust cc-exp format [mm / jj].",
+ document: `<form><input autocomplete="cc-number">
+ <input placeholder="mm / jj" autocomplete="cc-exp"></form>`,
+ profileData: [Object.assign({}, DEFAULT_CREDITCARD_RECORD)],
+ expectedResult: [
+ Object.assign({}, DEFAULT_CREDITCARD_RECORD, {
+ "cc-exp": "01/25",
+ }),
+ ],
+ },
+ {
+ description: "Use placeholder to adjust cc-exp format [MM / JJ].",
+ document: `<form><input autocomplete="cc-number">
+ <input placeholder="MM / JJ" autocomplete="cc-exp"></form>`,
+ profileData: [Object.assign({}, DEFAULT_CREDITCARD_RECORD)],
+ expectedResult: [
+ Object.assign({}, DEFAULT_CREDITCARD_RECORD, {
+ "cc-exp": "01/25",
+ }),
+ ],
+ },
+ {
+ description: "Use placeholder to adjust cc-exp format [mm / jjjj].",
+ document: `<form><input autocomplete="cc-number">
+ <input placeholder="mm / jjjj" autocomplete="cc-exp"></form>`,
+ profileData: [Object.assign({}, DEFAULT_CREDITCARD_RECORD)],
+ expectedResult: [
+ Object.assign({}, DEFAULT_CREDITCARD_RECORD, {
+ "cc-exp": "01/2025",
+ }),
+ ],
+ },
+ {
+ description: "Use placeholder to adjust cc-exp format [MM / JJJJ].",
+ document: `<form><input autocomplete="cc-number">
+ <input placeholder="MM / JJJJ" autocomplete="cc-exp"></form>`,
+ profileData: [Object.assign({}, DEFAULT_CREDITCARD_RECORD)],
+ expectedResult: [
+ Object.assign({}, DEFAULT_CREDITCARD_RECORD, {
+ "cc-exp": "01/2025",
+ }),
+ ],
+ },
+ {
+ description: "Use placeholder to adjust cc-exp format [mm - jj].",
+ document: `<form><input autocomplete="cc-number">
+ <input placeholder="mm - jj" autocomplete="cc-exp"></form>`,
+ profileData: [Object.assign({}, DEFAULT_CREDITCARD_RECORD)],
+ expectedResult: [
+ Object.assign({}, DEFAULT_CREDITCARD_RECORD, {
+ "cc-exp": "01-25",
+ }),
+ ],
+ },
+ {
+ description: "Use placeholder to adjust cc-exp format [MM - JJ].",
+ document: `<form><input autocomplete="cc-number">
+ <input placeholder="MM - JJ" autocomplete="cc-exp"></form>`,
+ profileData: [Object.assign({}, DEFAULT_CREDITCARD_RECORD)],
+ expectedResult: [
+ Object.assign({}, DEFAULT_CREDITCARD_RECORD, {
+ "cc-exp": "01-25",
+ }),
+ ],
+ },
+ {
+ description: "Use placeholder to adjust cc-exp format [mm - jjjj].",
+ document: `<form><input autocomplete="cc-number">
+ <input placeholder="mm - jjjj" autocomplete="cc-exp"></form>`,
+ profileData: [Object.assign({}, DEFAULT_CREDITCARD_RECORD)],
+ expectedResult: [
+ Object.assign({}, DEFAULT_CREDITCARD_RECORD, {
+ "cc-exp": "01-2025",
+ }),
+ ],
+ },
+ {
+ description: "Use placeholder to adjust cc-exp format [MM - JJJJ].",
+ document: `<form><input autocomplete="cc-number">
+ <input placeholder="MM - JJJJ" autocomplete="cc-exp"></form>`,
+ profileData: [Object.assign({}, DEFAULT_CREDITCARD_RECORD)],
+ expectedResult: [
+ Object.assign({}, DEFAULT_CREDITCARD_RECORD, {
+ "cc-exp": "01-2025",
+ }),
+ ],
+ },
+ {
+ description: "Use placeholder to adjust cc-exp format [jjjj - mm].",
+ document: `<form><input autocomplete="cc-number">
+ <input placeholder="jjjj - mm" autocomplete="cc-exp"></form>`,
+ profileData: [Object.assign({}, DEFAULT_CREDITCARD_RECORD)],
+ expectedResult: [
+ Object.assign({}, DEFAULT_CREDITCARD_RECORD, {
+ "cc-exp": "2025-01",
+ }),
+ ],
+ },
+ {
+ description: "Use placeholder to adjust cc-exp-year field [jj].",
+ document: `<form>
+ <input autocomplete="cc-number">
+ <input autocomplete="cc-exp-month">
+ <input autocomplete="cc-exp-year" placeholder="JJ">
+ </form>`,
+ profileData: [Object.assign({}, DEFAULT_CREDITCARD_RECORD)],
+ expectedResult: [
+ { ...DEFAULT_EXPECTED_CREDITCARD_RECORD_SEPARATE_EXPIRY_FIELDS },
+ ],
+ },
+];
+
+const TESTCASES = [FR_TESTCASES, DE_TESTCASES];
+
+for (let localeTests of TESTCASES) {
+ for (let testcase of localeTests) {
+ add_task(async function () {
+ info("Starting testcase: " + testcase.description);
+
+ let doc = MockDocument.createTestDocument(
+ "http://localhost:8080/test/",
+ testcase.document
+ );
+ let form = doc.querySelector("form");
+ let formLike = FormLikeFactory.createFromForm(form);
+ let handler = new FormAutofillHandler(formLike);
+
+ handler.collectFormFields();
+ handler.focusedInput = form.elements[0];
+ let adaptedRecords = handler.activeSection.getAdaptedProfiles(
+ testcase.profileData
+ );
+ Assert.deepEqual(adaptedRecords, testcase.expectedResult);
+
+ if (testcase.expectedOptionElements) {
+ testcase.expectedOptionElements.forEach((expectedOptionElement, i) => {
+ for (let field in expectedOptionElement) {
+ let select = form.querySelector(`[autocomplete=${field}]`);
+ let expectedOption = doc.getElementById(
+ expectedOptionElement[field]
+ );
+ Assert.notEqual(expectedOption, null);
+
+ let value = testcase.profileData[i][field];
+ let cache =
+ handler.activeSection._cacheValue.matchingSelectOption.get(
+ select
+ );
+ let targetOption = cache[value] && cache[value].get();
+ Assert.notEqual(targetOption, null);
+
+ Assert.equal(targetOption, expectedOption);
+ }
+ });
+ }
+ });
+ }
+}
diff --git a/browser/extensions/formautofill/test/unit/test_getCategoriesFromFieldNames.js b/browser/extensions/formautofill/test/unit/test_getCategoriesFromFieldNames.js
new file mode 100644
index 0000000000..66f4c18ea9
--- /dev/null
+++ b/browser/extensions/formautofill/test/unit/test_getCategoriesFromFieldNames.js
@@ -0,0 +1,95 @@
+"use strict";
+
+var FormAutofillUtils;
+add_task(async function () {
+ ({ FormAutofillUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/shared/FormAutofillUtils.sys.mjs"
+ ));
+});
+
+add_task(async function test_isAddressField_isCreditCardField() {
+ const TEST_CASES = {
+ "given-name": {
+ isAddressField: true,
+ isCreditCardField: false,
+ },
+ organization: {
+ isAddressField: true,
+ isCreditCardField: false,
+ },
+ "address-line2": {
+ isAddressField: true,
+ isCreditCardField: false,
+ },
+ tel: {
+ isAddressField: true,
+ isCreditCardField: false,
+ },
+ email: {
+ isAddressField: true,
+ isCreditCardField: false,
+ },
+ "cc-number": {
+ isAddressField: false,
+ isCreditCardField: true,
+ },
+ UNKNOWN: {
+ isAddressField: false,
+ isCreditCardField: false,
+ },
+ "": {
+ isAddressField: false,
+ isCreditCardField: false,
+ },
+ };
+
+ for (let fieldName of Object.keys(TEST_CASES)) {
+ info("Starting testcase: " + fieldName);
+ let field = TEST_CASES[fieldName];
+ Assert.equal(
+ FormAutofillUtils.isAddressField(fieldName),
+ field.isAddressField,
+ "isAddressField"
+ );
+ Assert.equal(
+ FormAutofillUtils.isCreditCardField(fieldName),
+ field.isCreditCardField,
+ "isCreditCardField"
+ );
+ }
+});
+
+add_task(async function test_getCategoriesFromFieldNames() {
+ const TEST_CASES = [
+ {
+ fieldNames: ["given-name", "family-name", "name", "tel", "organization"],
+ set: ["name", "tel", "organization"],
+ },
+ {
+ fieldNames: [
+ "address-line2",
+ "family-name",
+ "name",
+ "tel",
+ "organization",
+ "email",
+ ],
+ set: ["address", "name", "tel", "organization", "email"],
+ },
+ {
+ fieldNames: ["address-line2", "family-name", "", "name", "tel", "UNKOWN"],
+ set: ["address", "name", "tel"],
+ },
+ {
+ fieldNames: ["tel", "family-name", "", "name", "tel", "UNKOWN"],
+ set: ["tel", "name"],
+ },
+ ];
+
+ for (let tc of TEST_CASES) {
+ let categories = FormAutofillUtils.getCategoriesFromFieldNames(
+ tc.fieldNames
+ );
+ Assert.deepEqual(Array.from(categories), tc.set);
+ }
+});
diff --git a/browser/extensions/formautofill/test/unit/test_getCreditCardLogo.js b/browser/extensions/formautofill/test/unit/test_getCreditCardLogo.js
new file mode 100644
index 0000000000..e740d6102a
--- /dev/null
+++ b/browser/extensions/formautofill/test/unit/test_getCreditCardLogo.js
@@ -0,0 +1,25 @@
+/* Any copyright is dedicated to the Public Domain.
+http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+add_task(async function test_getCreditCardLogo() {
+ const { CreditCard } = ChromeUtils.importESModule(
+ "resource://gre/modules/CreditCard.sys.mjs"
+ );
+ // Credit card logos can be either PNG or SVG
+ // so we construct an array that includes both of these file extensions
+ // and test to see if the logo from getCreditCardLogo matches.
+ for (let network of CreditCard.getSupportedNetworks()) {
+ const PATH_PREFIX = "chrome://formautofill/content/third-party/cc-logo-";
+ let actual = CreditCard.getCreditCardLogo(network);
+ Assert.ok(
+ [".png", ".svg"].map(x => PATH_PREFIX + network + x).includes(actual)
+ );
+ }
+ let genericLogo = CreditCard.getCreditCardLogo("null");
+ Assert.equal(
+ genericLogo,
+ "chrome://formautofill/content/icon-credit-card-generic.svg"
+ );
+});
diff --git a/browser/extensions/formautofill/test/unit/test_getFormInputDetails.js b/browser/extensions/formautofill/test/unit/test_getFormInputDetails.js
new file mode 100644
index 0000000000..0ca9def464
--- /dev/null
+++ b/browser/extensions/formautofill/test/unit/test_getFormInputDetails.js
@@ -0,0 +1,204 @@
+"use strict";
+
+var FormAutofillContent;
+add_task(async function () {
+ ({ FormAutofillContent } = ChromeUtils.importESModule(
+ "resource://autofill/FormAutofillContent.sys.mjs"
+ ));
+});
+
+const TESTCASES = [
+ {
+ description: "Form containing 5 fields with autocomplete attribute.",
+ document: `<form id="form1">
+ <input id="street-addr" autocomplete="street-address">
+ <input id="city" autocomplete="address-level2">
+ <select id="country" autocomplete="country"></select>
+ <input id="email" autocomplete="email">
+ <input id="tel" autocomplete="tel">
+ </form>`,
+ targetInput: ["street-addr", "email"],
+ expectedResult: [
+ {
+ input: {
+ section: "",
+ addressType: "",
+ contactType: "",
+ fieldName: "street-address",
+ },
+ formId: "form1",
+ form: [
+ {
+ section: "",
+ addressType: "",
+ contactType: "",
+ fieldName: "street-address",
+ },
+ {
+ section: "",
+ addressType: "",
+ contactType: "",
+ fieldName: "address-level2",
+ },
+ {
+ section: "",
+ addressType: "",
+ contactType: "",
+ fieldName: "country",
+ },
+ { section: "", addressType: "", contactType: "", fieldName: "email" },
+ { section: "", addressType: "", contactType: "", fieldName: "tel" },
+ ],
+ },
+ {
+ input: {
+ section: "",
+ addressType: "",
+ contactType: "",
+ fieldName: "email",
+ },
+ formId: "form1",
+ form: [
+ {
+ section: "",
+ addressType: "",
+ contactType: "",
+ fieldName: "street-address",
+ },
+ {
+ section: "",
+ addressType: "",
+ contactType: "",
+ fieldName: "address-level2",
+ },
+ {
+ section: "",
+ addressType: "",
+ contactType: "",
+ fieldName: "country",
+ },
+ { section: "", addressType: "", contactType: "", fieldName: "email" },
+ { section: "", addressType: "", contactType: "", fieldName: "tel" },
+ ],
+ },
+ ],
+ },
+ {
+ description: "2 forms that are able to be auto filled",
+ document: `<form id="form2">
+ <input id="home-addr" autocomplete="street-address">
+ <input id="city" autocomplete="address-level2">
+ <select id="country" autocomplete="country"></select>
+ </form>
+ <form id="form3">
+ <input id="office-addr" autocomplete="street-address">
+ <input id="email" autocomplete="email">
+ <input id="tel" autocomplete="tel">
+ </form>`,
+ targetInput: ["home-addr", "office-addr"],
+ expectedResult: [
+ {
+ input: {
+ section: "",
+ addressType: "",
+ contactType: "",
+ fieldName: "street-address",
+ },
+ formId: "form2",
+ form: [
+ {
+ section: "",
+ addressType: "",
+ contactType: "",
+ fieldName: "street-address",
+ },
+ {
+ section: "",
+ addressType: "",
+ contactType: "",
+ fieldName: "address-level2",
+ },
+ {
+ section: "",
+ addressType: "",
+ contactType: "",
+ fieldName: "country",
+ },
+ ],
+ },
+ {
+ input: {
+ section: "",
+ addressType: "",
+ contactType: "",
+ fieldName: "street-address",
+ },
+ formId: "form3",
+ form: [
+ {
+ section: "",
+ addressType: "",
+ contactType: "",
+ fieldName: "street-address",
+ },
+ { section: "", addressType: "", contactType: "", fieldName: "email" },
+ { section: "", addressType: "", contactType: "", fieldName: "tel" },
+ ],
+ },
+ ],
+ },
+];
+
+function inputDetailAssertion(detail, expected) {
+ Assert.equal(detail.section, expected.section);
+ Assert.equal(detail.addressType, expected.addressType);
+ Assert.equal(detail.contactType, expected.contactType);
+ Assert.equal(detail.fieldName, expected.fieldName);
+ Assert.equal(detail.elementWeakRef.get(), expected.elementWeakRef.get());
+}
+
+TESTCASES.forEach(testcase => {
+ add_task(async function () {
+ info("Starting testcase: " + testcase.description);
+
+ let doc = MockDocument.createTestDocument(
+ "http://localhost:8080/test/",
+ testcase.document
+ );
+
+ for (let i in testcase.targetInput) {
+ let input = doc.getElementById(testcase.targetInput[i]);
+ FormAutofillContent.identifyAutofillFields(input);
+ FormAutofillContent.updateActiveInput(input);
+
+ // Put the input element reference to `element` to make sure the result of
+ // `activeFieldDetail` contains the same input element.
+ testcase.expectedResult[i].input.elementWeakRef =
+ Cu.getWeakReference(input);
+
+ inputDetailAssertion(
+ FormAutofillContent.activeFieldDetail,
+ testcase.expectedResult[i].input
+ );
+
+ let formDetails = testcase.expectedResult[i].form;
+ for (let formDetail of formDetails) {
+ // Compose a query string to get the exact reference of <input>/<select>
+ // element, e.g. #form1 > *[autocomplete="street-address"]
+ let queryString =
+ "#" +
+ testcase.expectedResult[i].formId +
+ " > *[autocomplete=" +
+ formDetail.fieldName +
+ "]";
+ formDetail.elementWeakRef = Cu.getWeakReference(
+ doc.querySelector(queryString)
+ );
+ }
+
+ FormAutofillContent.activeFormDetails.forEach((detail, index) => {
+ inputDetailAssertion(detail, formDetails[index]);
+ });
+ }
+ });
+});
diff --git a/browser/extensions/formautofill/test/unit/test_getInfo.js b/browser/extensions/formautofill/test/unit/test_getInfo.js
new file mode 100644
index 0000000000..802fcd79e9
--- /dev/null
+++ b/browser/extensions/formautofill/test/unit/test_getInfo.js
@@ -0,0 +1,363 @@
+"use strict";
+
+var { FormAutofillHeuristics } = ChromeUtils.importESModule(
+ "resource://gre/modules/shared/FormAutofillHeuristics.sys.mjs"
+);
+var { LabelUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/shared/LabelUtils.sys.mjs"
+);
+var { FormAutofill } = ChromeUtils.importESModule(
+ "resource://autofill/FormAutofill.sys.mjs"
+);
+
+const TESTCASES = [
+ {
+ description: "Input element in a label element",
+ document: `<form>
+ <label> E-Mail
+ <input id="targetElement" type="text">
+ </label>
+ </form>`,
+ elementId: "targetElement",
+ expectedReturnValue: ["email", null, null],
+ },
+ {
+ description:
+ "A label element is out of the form contains the related input",
+ document: `<label for="targetElement"> E-Mail</label>
+ <form>
+ <input id="targetElement" type="text">
+ </form>`,
+ elementId: "targetElement",
+ expectedReturnValue: ["email", null, null],
+ },
+ {
+ description: "A label element contains span element",
+ document: `<label for="targetElement">FOO<span>E-Mail</span>BAR</label>
+ <form>
+ <input id="targetElement" type="text">
+ </form>`,
+ elementId: "targetElement",
+ expectedReturnValue: ["email", null, null],
+ },
+ {
+ description: "The signature in 'name' attr of an input",
+ document: `<input id="targetElement" name="email" type="text">`,
+ elementId: "targetElement",
+ expectedReturnValue: ["email", null, null],
+ },
+ {
+ description: "The signature in 'id' attr of an input",
+ document: `<input id="targetElement_email" name="tel" type="text">`,
+ elementId: "targetElement_email",
+ expectedReturnValue: ["email", null, null],
+ },
+ {
+ description: "Select element in a label element",
+ document: `<form>
+ <label> State
+ <select id="targetElement"></select>
+ </label>
+ </form>`,
+ elementId: "targetElement",
+ expectedReturnValue: ["address-level1", null, null],
+ },
+ {
+ description: "A select element without a form wrapped",
+ document: `<label for="targetElement">State</label>
+ <select id="targetElement"></select>`,
+ elementId: "targetElement",
+ expectedReturnValue: ["address-level1", null, null],
+ },
+ {
+ description: "address line input",
+ document: `<label for="targetElement">street</label>
+ <input id="targetElement" type="text">`,
+ elementId: "targetElement",
+ expectedReturnValue: ["street-address", null, null],
+ },
+ {
+ description: "CJK character - Traditional Chinese",
+ document: `<label> 郵遞區號
+ <input id="targetElement" />
+ </label>`,
+ elementId: "targetElement",
+ expectedReturnValue: ["postal-code", null, null],
+ },
+ {
+ description: "CJK character - Japanese",
+ document: `<label> 郵便番号
+ <input id="targetElement" />
+ </label>`,
+ elementId: "targetElement",
+ expectedReturnValue: ["postal-code", null, null],
+ },
+ {
+ description: "CJK character - Korean",
+ document: `<label> 우편 번호
+ <input id="targetElement" />
+ </label>`,
+ elementId: "targetElement",
+ expectedReturnValue: ["postal-code", null, null],
+ },
+ {
+ description: "",
+ document: `<input id="targetElement" name="fullname">`,
+ elementId: "targetElement",
+ expectedReturnValue: ["name", null, null],
+ },
+ {
+ description: 'input element with "submit" type',
+ document: `<input id="targetElement" type="submit" />`,
+ elementId: "targetElement",
+ expectedReturnValue: [null, null, null],
+ },
+ {
+ description: "The signature in 'name' attr of an email input",
+ document: `<input id="targetElement" name="email" type="number">`,
+ elementId: "targetElement",
+ expectedReturnValue: ["email", null, null],
+ },
+ {
+ description: 'input element with "email" type',
+ document: `<input id="targetElement" type="email" />`,
+ elementId: "targetElement",
+ expectedReturnValue: ["email", null, null],
+ },
+ {
+ description: "Exclude United State string",
+ document: `<label>United State
+ <input id="targetElement" />
+ </label>`,
+ elementId: "targetElement",
+ expectedReturnValue: [null, null, null],
+ },
+ {
+ description: '"County" field with "United State" string',
+ document: `<label>United State County
+ <input id="targetElement" />
+ </label>`,
+ elementId: "targetElement",
+ expectedReturnValue: ["address-level1", null, null],
+ },
+ {
+ description: '"city" field with double "United State" string',
+ document: `<label>United State united sTATE city
+ <input id="targetElement" />
+ </label>`,
+ elementId: "targetElement",
+ expectedReturnValue: ["address-level2", null, null],
+ },
+ {
+ description: "Verify credit card number",
+ document: `<form>
+ <label for="targetElement"> Card Number</label>
+ <input id="targetElement" type="text">
+ </form>`,
+ elementId: "targetElement",
+ expectedReturnValue: ["cc-number", null, 1],
+ },
+ {
+ description: "Identify credit card type field",
+ document: `<form>
+ <label for="targetElement">Card Type</label>
+ <input id="targetElement" type="text">
+ </form>`,
+ elementId: "targetElement",
+ expectedReturnValue: ["cc-type", null, null],
+ },
+ {
+ description: `Identify address field when contained in a form with autocomplete="off"`,
+ document: `<form autocomplete="off">
+ <input id="given-name">
+ </form>`,
+ elementId: "given-name",
+ expectedReturnValue: ["given-name", null, null],
+ },
+ {
+ description: `Identify address field that has a placeholder but no label associated with it`,
+ document: `<form>
+ <input id="targetElement" placeholder="Name">
+ </form>`,
+ elementId: "targetElement",
+ expectedReturnValue: ["name", null, null],
+ },
+ {
+ description: `Identify address field that has a placeholder, no associated label, and its autocomplete attribute is "off"`,
+ document: `<form>
+ <input id="targetElement" placeholder="Address" autocomplete="off">
+ </form>`,
+ elementId: "targetElement",
+ expectedReturnValue: ["street-address", null, null],
+ },
+ {
+ description: `Identify address field that has a placeholder, no associated label, and the form's autocomplete attribute is "off"`,
+ document: `<form autocomplete="off">
+ <input id="targetElement" placeholder="Country">
+ </form>`,
+ elementId: "targetElement",
+ expectedReturnValue: ["country", null, null],
+ },
+];
+
+add_setup(async function () {
+ Services.prefs.setStringPref(
+ "extensions.formautofill.creditCards.heuristics.fathom.testConfidence",
+ "1"
+ );
+
+ registerCleanupFunction(() => {
+ Services.prefs.clearUserPref(
+ "extensions.formautofill.creditCards.heuristics.fathom.testConfidence"
+ );
+ });
+});
+
+TESTCASES.forEach(testcase => {
+ add_task(async function () {
+ info("Starting testcase: " + testcase.description);
+
+ let doc = MockDocument.createTestDocument(
+ "http://localhost:8080/test/",
+ testcase.document
+ );
+
+ let element = doc.getElementById(testcase.elementId);
+ let value = FormAutofillHeuristics.inferFieldInfo(element);
+
+ Assert.deepEqual(value, testcase.expectedReturnValue);
+ LabelUtils.clearLabelMap();
+ });
+});
+
+add_task(async function test_regexp_list() {
+ info("Verify the fieldName support for select element.");
+ let SUPPORT_LIST = {
+ email: null, // email
+ "tel-extension": null, // tel-extension
+ phone: null, // tel
+ organization: null, // organization
+ "street-address": null, // street-address
+ address1: null, // address-line1
+ address2: null, // address-line2
+ address3: null, // address-line3
+ city: "address-level2",
+ region: "address-level1",
+ "postal-code": null, // postal-code
+ country: "country",
+ fullname: null, // name
+ fname: null, // given-name
+ mname: null, // additional-name
+ lname: null, // family-name
+ cardholder: null, // cc-name
+ "cc-number": null, // cc-number
+ addmonth: "cc-exp-month",
+ addyear: "cc-exp-year",
+ };
+ for (let label of Object.keys(SUPPORT_LIST)) {
+ let testcase = {
+ description: `A select element supports ${label} or not`,
+ document: `<select id="${label}"></select>`,
+ elementId: label,
+ expectedReturnValue: SUPPORT_LIST[label]
+ ? [SUPPORT_LIST[label], null, null]
+ : [null, null, null],
+ };
+ info(testcase.description);
+ info(testcase.document);
+ let doc = MockDocument.createTestDocument(
+ "http://localhost:8080/test/",
+ testcase.document
+ );
+
+ let element = doc.getElementById(testcase.elementId);
+ let value = FormAutofillHeuristics.inferFieldInfo(element);
+
+ Assert.deepEqual(value, testcase.expectedReturnValue, label);
+ }
+ LabelUtils.clearLabelMap();
+});
+
+add_task(async function test_autofill_creditCards_autocomplete_off_pref() {
+ let document = `<form autocomplete="off">
+ <label for="targetElement"> Card Number</label>
+ <input id="targetElement" type="text">
+ </form>`;
+ let expected = [null, null, null];
+ info(`Set pref so that credit card autofill respects autocomplete="off"`);
+ Services.prefs.setBoolPref(
+ FormAutofill.AUTOFILL_CREDITCARDS_AUTOCOMPLETE_OFF_PREF,
+ false
+ );
+ let doc = MockDocument.createTestDocument(
+ "http://localhost:8080/test/",
+ document
+ );
+ let element = doc.getElementById("targetElement");
+ let value = FormAutofillHeuristics.inferFieldInfo(element);
+
+ Assert.deepEqual(value, expected);
+ document = `<form>
+ <label for="targetElement"> Card Number</label>
+ <input id="targetElement" type="text">
+ </form>`;
+ expected = ["cc-number", null, 1];
+ info(
+ `Set pref so that credit card autofill does not respect autocomplete="off"`
+ );
+ Services.prefs.setBoolPref(
+ FormAutofill.AUTOFILL_CREDITCARDS_AUTOCOMPLETE_OFF_PREF,
+ true
+ );
+ doc = MockDocument.createTestDocument(
+ "http://localhost:8080/test/",
+ document
+ );
+ element = doc.getElementById("targetElement");
+ value = FormAutofillHeuristics.inferFieldInfo(element);
+
+ Assert.deepEqual(value, expected);
+ Services.prefs.clearUserPref(
+ FormAutofill.AUTOFILL_CREDITCARDS_AUTOCOMPLETE_OFF_PREF
+ );
+});
+
+add_task(async function test_autofill_addresses_autocomplete_off_pref() {
+ let document = `<form autocomplete="off">
+ <input id="given-name">
+ </form>`;
+ let expected = [null, null, null];
+ info(`Set pref so that address autofill respects autocomplete="off"`);
+ Services.prefs.setBoolPref(
+ FormAutofill.AUTOFILL_ADDRESSES_AUTOCOMPLETE_OFF_PREF,
+ false
+ );
+ let doc = MockDocument.createTestDocument(
+ "http://localhost:8080/test/",
+ document
+ );
+ let element = doc.getElementById("given-name");
+ let value = FormAutofillHeuristics.inferFieldInfo(element);
+
+ Assert.deepEqual(value, expected);
+ document = `<form>
+ <input id="given-name">
+ </form>`;
+ expected = ["given-name", null, null];
+ info(`Set pref so that address autofill does not respect autocomplete="off"`);
+ Services.prefs.setBoolPref(
+ FormAutofill.AUTOFILL_ADDRESSES_AUTOCOMPLETE_OFF_PREF,
+ true
+ );
+ doc = MockDocument.createTestDocument(
+ "http://localhost:8080/test/",
+ document
+ );
+ element = doc.getElementById("given-name");
+ value = FormAutofillHeuristics.inferFieldInfo(element);
+
+ Assert.deepEqual(value, expected);
+ Services.prefs.clearUserPref(
+ FormAutofill.AUTOFILL_ADDRESSES_AUTOCOMPLETE_OFF_PREF
+ );
+});
diff --git a/browser/extensions/formautofill/test/unit/test_getRecords.js b/browser/extensions/formautofill/test/unit/test_getRecords.js
new file mode 100644
index 0000000000..9a7e5e6ac7
--- /dev/null
+++ b/browser/extensions/formautofill/test/unit/test_getRecords.js
@@ -0,0 +1,258 @@
+/*
+ * Test for make sure getRecords can retrieve right collection from storage.
+ */
+
+"use strict";
+
+const { CreditCard } = ChromeUtils.importESModule(
+ "resource://gre/modules/CreditCard.sys.mjs"
+);
+
+let FormAutofillParent, FormAutofillStatus;
+let OSKeyStore;
+add_setup(async () => {
+ ({ FormAutofillParent, FormAutofillStatus } = ChromeUtils.importESModule(
+ "resource://autofill/FormAutofillParent.sys.mjs"
+ ));
+ ({ OSKeyStore } = ChromeUtils.importESModule(
+ "resource://gre/modules/OSKeyStore.sys.mjs"
+ ));
+});
+
+const TEST_ADDRESS_1 = {
+ "given-name": "Timothy",
+ "additional-name": "John",
+ "family-name": "Berners-Lee",
+ organization: "World Wide Web Consortium",
+ "street-address": "32 Vassar Street\nMIT Room 32-G524",
+ "address-level2": "Cambridge",
+ "address-level1": "MA",
+ "postal-code": "02139",
+ country: "US",
+ tel: "+16172535702",
+ email: "timbl@w3.org",
+};
+
+const TEST_ADDRESS_2 = {
+ "street-address": "Some Address",
+ country: "US",
+};
+
+let TEST_CREDIT_CARD_1 = {
+ "cc-name": "John Doe",
+ "cc-number": "4111111111111111",
+ "cc-exp-month": 4,
+ "cc-exp-year": 2017,
+ "cc-type": "visa",
+};
+
+let TEST_CREDIT_CARD_2 = {
+ "cc-name": "John Dai",
+ "cc-number": "4929001587121045",
+ "cc-exp-month": 2,
+ "cc-exp-year": 2017,
+ "cc-type": "visa",
+};
+
+add_task(async function test_getRecords() {
+ FormAutofillStatus.init();
+
+ await FormAutofillStatus.formAutofillStorage.initialize();
+ let fakeResult = {
+ addresses: [
+ {
+ "given-name": "Timothy",
+ "additional-name": "John",
+ "family-name": "Berners-Lee",
+ organization: "World Wide Web Consortium",
+ },
+ ],
+ creditCards: [
+ {
+ "cc-name": "John Doe",
+ "cc-number": "4111111111111111",
+ "cc-exp-month": 4,
+ "cc-exp-year": 2017,
+ },
+ ],
+ };
+
+ for (let collectionName of ["addresses", "creditCards", "nonExisting"]) {
+ let collection = FormAutofillStatus.formAutofillStorage[collectionName];
+ let expectedResult = fakeResult[collectionName] || [];
+
+ if (collection) {
+ sinon.stub(collection, "getAll");
+ collection.getAll.returns(Promise.resolve(expectedResult));
+ }
+ await FormAutofillParent._getRecords({ collectionName });
+ if (collection) {
+ Assert.equal(collection.getAll.called, true);
+ collection.getAll.restore();
+ }
+ }
+});
+
+add_task(async function test_getRecords_addresses() {
+ await FormAutofillStatus.formAutofillStorage.initialize();
+ let mockAddresses = [TEST_ADDRESS_1, TEST_ADDRESS_2];
+ let collection = FormAutofillStatus.formAutofillStorage.addresses;
+ sinon.stub(collection, "getAll");
+ collection.getAll.returns(Promise.resolve(mockAddresses));
+
+ let testCases = [
+ {
+ description: "If the search string could match 1 address",
+ filter: {
+ collectionName: "addresses",
+ info: { fieldName: "street-address" },
+ searchString: "Some",
+ },
+ expectedResult: [TEST_ADDRESS_2],
+ },
+ {
+ description: "If the search string could match multiple addresses",
+ filter: {
+ collectionName: "addresses",
+ info: { fieldName: "country" },
+ searchString: "u",
+ },
+ expectedResult: [TEST_ADDRESS_1, TEST_ADDRESS_2],
+ },
+ {
+ description: "If the search string could not match any address",
+ filter: {
+ collectionName: "addresses",
+ info: { fieldName: "street-address" },
+ searchString: "test",
+ },
+ expectedResult: [],
+ },
+ {
+ description: "If the search string is empty",
+ filter: {
+ collectionName: "addresses",
+ info: { fieldName: "street-address" },
+ searchString: "",
+ },
+ expectedResult: [TEST_ADDRESS_1, TEST_ADDRESS_2],
+ },
+ {
+ description:
+ "Check if the filtering logic is free from searching special chars",
+ filter: {
+ collectionName: "addresses",
+ info: { fieldName: "street-address" },
+ searchString: ".*",
+ },
+ expectedResult: [],
+ },
+ {
+ description:
+ "Prevent broken while searching the property that does not exist",
+ filter: {
+ collectionName: "addresses",
+ info: { fieldName: "tel" },
+ searchString: "1",
+ },
+ expectedResult: [],
+ },
+ ];
+
+ for (let testCase of testCases) {
+ info("Starting testcase: " + testCase.description);
+ let result = await FormAutofillParent._getRecords(testCase.filter);
+ Assert.deepEqual(result, testCase.expectedResult);
+ }
+});
+
+add_task(async function test_getRecords_creditCards() {
+ await FormAutofillStatus.formAutofillStorage.initialize();
+ let collection = FormAutofillStatus.formAutofillStorage.creditCards;
+ let encryptedCCRecords = await Promise.all(
+ [TEST_CREDIT_CARD_1, TEST_CREDIT_CARD_2].map(async record => {
+ let clonedRecord = Object.assign({}, record);
+ clonedRecord["cc-number"] = CreditCard.getLongMaskedNumber(
+ record["cc-number"]
+ );
+ clonedRecord["cc-number-encrypted"] = await OSKeyStore.encrypt(
+ record["cc-number"]
+ );
+ return clonedRecord;
+ })
+ );
+ sinon
+ .stub(collection, "getAll")
+ .callsFake(() =>
+ Promise.resolve([
+ Object.assign({}, encryptedCCRecords[0]),
+ Object.assign({}, encryptedCCRecords[1]),
+ ])
+ );
+
+ let testCases = [
+ {
+ description: "If the search string could match multiple creditCards",
+ filter: {
+ collectionName: "creditCards",
+ info: { fieldName: "cc-name" },
+ searchString: "John",
+ },
+ expectedResult: encryptedCCRecords,
+ },
+ {
+ description: "If the search string could not match any creditCard",
+ filter: {
+ collectionName: "creditCards",
+ info: { fieldName: "cc-name" },
+ searchString: "T",
+ },
+ expectedResult: [],
+ },
+ {
+ description:
+ "Return all creditCards if focused field is cc number; " +
+ "if the search string could match multiple creditCards",
+ filter: {
+ collectionName: "creditCards",
+ info: { fieldName: "cc-number" },
+ searchString: "4",
+ },
+ expectedResult: encryptedCCRecords,
+ },
+ {
+ description: "If the search string could match 1 creditCard",
+ filter: {
+ collectionName: "creditCards",
+ info: { fieldName: "cc-name" },
+ searchString: "John Doe",
+ },
+ mpEnabled: true,
+ expectedResult: encryptedCCRecords.slice(0, 1),
+ },
+ {
+ description: "Return all creditCards if focused field is cc number",
+ filter: {
+ collectionName: "creditCards",
+ info: { fieldName: "cc-number" },
+ searchString: "411",
+ },
+ mpEnabled: true,
+ expectedResult: encryptedCCRecords,
+ },
+ ];
+
+ for (let testCase of testCases) {
+ info("Starting testcase: " + testCase.description);
+ if (testCase.mpEnabled) {
+ let tokendb = Cc["@mozilla.org/security/pk11tokendb;1"].createInstance(
+ Ci.nsIPK11TokenDB
+ );
+ let token = tokendb.getInternalKeyToken();
+ token.reset();
+ token.initPassword("password");
+ }
+ let result = await FormAutofillParent._getRecords(testCase.filter);
+ Assert.deepEqual(result, testCase.expectedResult);
+ }
+});
diff --git a/browser/extensions/formautofill/test/unit/test_isAddressAutofillAvailable.js b/browser/extensions/formautofill/test/unit/test_isAddressAutofillAvailable.js
new file mode 100644
index 0000000000..2d2940c33e
--- /dev/null
+++ b/browser/extensions/formautofill/test/unit/test_isAddressAutofillAvailable.js
@@ -0,0 +1,74 @@
+/* Any copyright is dedicated to the Public Domain.
+http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * Test enabling address autofill in specific locales and regions.
+ */
+
+"use strict";
+
+const { FormAutofill } = ChromeUtils.importESModule(
+ "resource://autofill/FormAutofill.sys.mjs"
+);
+
+add_task(async function test_defaultTestEnvironment() {
+ Assert.equal(
+ Services.prefs.getCharPref("extensions.formautofill.addresses.supported"),
+ "on"
+ );
+});
+
+add_task(async function test_default_supported_module_and_autofill_region() {
+ Services.prefs.setCharPref("browser.search.region", "US");
+ registerCleanupFunction(function cleanupRegion() {
+ Services.prefs.clearUserPref("browser.search.region");
+ });
+
+ let addon = await AddonManager.getAddonByID(EXTENSION_ID);
+ await addon.reload();
+
+ Assert.equal(FormAutofill.isAutofillAddressesAvailable, true);
+ Assert.equal(FormAutofill.isAutofillAddressesEnabled, true);
+});
+
+add_task(
+ async function test_supported_creditCard_region_unsupported_address_region() {
+ Services.prefs.setCharPref(
+ "extensions.formautofill.addresses.supported",
+ "detect"
+ );
+ Services.prefs.setCharPref(
+ "extensions.formautofill.creditCards.supported",
+ "detect"
+ );
+ Services.prefs.setCharPref("browser.search.region", "FR");
+ Services.prefs.setCharPref(
+ "extensions.formautofill.addresses.supportedCountries",
+ "US,CA"
+ );
+ Services.prefs.setCharPref(
+ "extensions.formautofill.creditCards.supportedCountries",
+ "US,CA,FR"
+ );
+ registerCleanupFunction(function cleanupPrefs() {
+ Services.prefs.clearUserPref("browser.search.region");
+ Services.prefs.clearUserPref(
+ "extensions.formautofill.addresses.supportedCountries"
+ );
+ Services.prefs.clearUserPref(
+ "extensions.formautofill.addresses.supported"
+ );
+ Services.prefs.clearUserPref(
+ "extensions.formautofill.creditCards.supported"
+ );
+ });
+
+ let addon = await AddonManager.getAddonByID(EXTENSION_ID);
+ await addon.reload();
+ Assert.ok(
+ Services.prefs.getBoolPref("extensions.formautofill.creditCards.enabled")
+ );
+ Assert.equal(FormAutofill.isAutofillAddressesAvailable, false);
+ Assert.equal(FormAutofill.isAutofillAddressesEnabled, false);
+ }
+);
diff --git a/browser/extensions/formautofill/test/unit/test_isCJKName.js b/browser/extensions/formautofill/test/unit/test_isCJKName.js
new file mode 100644
index 0000000000..f0e50b60f9
--- /dev/null
+++ b/browser/extensions/formautofill/test/unit/test_isCJKName.js
@@ -0,0 +1,80 @@
+/**
+ * Tests the "isCJKName" function of FormAutofillNameUtils object.
+ */
+
+"use strict";
+
+var FormAutofillNameUtils;
+add_setup(async () => {
+ ({ FormAutofillNameUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/shared/FormAutofillNameUtils.sys.mjs"
+ ));
+});
+
+// Test cases is initially copied from
+// https://cs.chromium.org/chromium/src/components/autofill/core/browser/autofill_data_util_unittest.cc
+const TESTCASES = [
+ {
+ // Non-CJK language with only ASCII characters.
+ fullName: "Homer Jay Simpson",
+ expectedResult: false,
+ },
+ {
+ // Non-CJK language with some ASCII characters.
+ fullName: "Éloïse Paré",
+ expectedResult: false,
+ },
+ {
+ // Non-CJK language with no ASCII characters.
+ fullName: "Σωκράτης",
+ expectedResult: false,
+ },
+ {
+ // (Simplified) Chinese name, Unihan.
+ fullName: "刘翔",
+ expectedResult: true,
+ },
+ {
+ // (Simplified) Chinese name, Unihan, with an ASCII space.
+ fullName: "成 龙",
+ expectedResult: true,
+ },
+ {
+ // Korean name, Hangul.
+ fullName: "송지효",
+ expectedResult: true,
+ },
+ {
+ // Korean name, Hangul, with an 'IDEOGRAPHIC SPACE' (U+3000).
+ fullName: "김 종국",
+ expectedResult: true,
+ },
+ {
+ // Japanese name, Unihan.
+ fullName: "山田貴洋",
+ expectedResult: true,
+ },
+ {
+ // Japanese name, Katakana, with a 'KATAKANA MIDDLE DOT' (U+30FB).
+ fullName: "ビル・ゲイツ",
+ expectedResult: true,
+ },
+ {
+ // Japanese name, Katakana, with a 'MIDDLE DOT' (U+00B7) (likely a typo).
+ fullName: "ビル·ゲイツ",
+ expectedResult: true,
+ },
+ {
+ // CJK names don't have a middle name, so a 3-part name is bogus to us.
+ fullName: "반 기 문",
+ expectedResult: false,
+ },
+];
+
+add_task(async function test_isCJKName() {
+ TESTCASES.forEach(testcase => {
+ info("Starting testcase: " + testcase.fullName);
+ let result = FormAutofillNameUtils._isCJKName(testcase.fullName);
+ Assert.equal(result, testcase.expectedResult);
+ });
+});
diff --git a/browser/extensions/formautofill/test/unit/test_isCreditCardAutofillAvailable.js b/browser/extensions/formautofill/test/unit/test_isCreditCardAutofillAvailable.js
new file mode 100644
index 0000000000..5be5101ee6
--- /dev/null
+++ b/browser/extensions/formautofill/test/unit/test_isCreditCardAutofillAvailable.js
@@ -0,0 +1,84 @@
+/**
+ * Test enabling the feature in specific locales and regions.
+ */
+
+"use strict";
+
+const { FormAutofill } = ChromeUtils.importESModule(
+ "resource://autofill/FormAutofill.sys.mjs"
+);
+
+add_task(async function test_defaultTestEnvironment() {
+ Assert.ok(Services.prefs.getBoolPref("dom.forms.autocomplete.formautofill"));
+});
+
+add_task(async function test_detect_unsupportedRegion() {
+ Services.prefs.setCharPref(
+ "extensions.formautofill.creditCards.supported",
+ "detect"
+ );
+ Services.prefs.setCharPref(
+ "extensions.formautofill.creditCards.supportedCountries",
+ "US,CA"
+ );
+ Services.prefs.setCharPref("browser.search.region", "ZZ");
+ registerCleanupFunction(function cleanupRegion() {
+ Services.prefs.clearUserPref("browser.search.region");
+ Services.prefs.clearUserPref(
+ "extensions.formautofill.creditCards.supported"
+ );
+ Services.prefs.clearUserPref("extensions.formautofill.addresses.supported");
+ Services.prefs.clearUserPref(
+ "extensions.formautofill.creditCards.supportedCountries"
+ );
+ });
+
+ let addon = await AddonManager.getAddonByID(EXTENSION_ID);
+ await addon.reload();
+
+ Assert.equal(
+ FormAutofill.isAutofillCreditCardsAvailable,
+ false,
+ "Credit card autofill should not be available"
+ );
+ Assert.equal(
+ FormAutofill.isAutofillCreditCardsEnabled,
+ false,
+ "Credit card autofill should not be enabled"
+ );
+});
+
+add_task(async function test_detect_supportedRegion() {
+ Services.prefs.setCharPref(
+ "extensions.formautofill.creditCards.supported",
+ "detect"
+ );
+ Services.prefs.setCharPref(
+ "extensions.formautofill.creditCards.supportedCountries",
+ "US,CA"
+ );
+ Services.prefs.setCharPref("browser.search.region", "US");
+ registerCleanupFunction(function cleanupRegion() {
+ Services.prefs.clearUserPref("browser.search.region");
+ Services.prefs.clearUserPref(
+ "extensions.formautofill.creditCards.supported"
+ );
+ Services.prefs.clearUserPref(
+ "extensions.formautofill.creditCards.supportedCountries"
+ );
+ });
+
+ let addon = await AddonManager.getAddonByID(EXTENSION_ID);
+ await addon.reload();
+
+ Assert.equal(
+ FormAutofill.isAutofillCreditCardsAvailable,
+ true,
+ "Credit card autofill should be available"
+ );
+ Assert.equal(
+ FormAutofill.isAutofillCreditCardsEnabled,
+ true,
+ "Credit card autofill should be enabled"
+ );
+});
diff --git a/browser/extensions/formautofill/test/unit/test_isCreditCardOrAddressFieldType.js b/browser/extensions/formautofill/test/unit/test_isCreditCardOrAddressFieldType.js
new file mode 100644
index 0000000000..872e9cfcda
--- /dev/null
+++ b/browser/extensions/formautofill/test/unit/test_isCreditCardOrAddressFieldType.js
@@ -0,0 +1,103 @@
+"use strict";
+
+var FormAutofillUtils;
+add_setup(async () => {
+ ({ FormAutofillUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/shared/FormAutofillUtils.sys.mjs"
+ ));
+});
+
+const TESTCASES = [
+ {
+ document: `<input id="targetElement" type="text">`,
+ fieldId: "targetElement",
+ expectedResult: true,
+ },
+ {
+ document: `<input id="targetElement" type="email">`,
+ fieldId: "targetElement",
+ expectedResult: true,
+ },
+ {
+ document: `<input id="targetElement" type="number">`,
+ fieldId: "targetElement",
+ expectedResult: true,
+ },
+ {
+ document: `<input id="targetElement" type="tel">`,
+ fieldId: "targetElement",
+ expectedResult: true,
+ },
+ {
+ document: `<input id="targetElement" type="radio">`,
+ fieldId: "targetElement",
+ expectedResult: false,
+ },
+ {
+ document: `<input id="targetElement" type="text" autocomplete="off">`,
+ fieldId: "targetElement",
+ expectedResult: true,
+ },
+ {
+ document: `<input id="targetElement">`,
+ fieldId: "targetElement",
+ expectedResult: true,
+ },
+ {
+ document: `<input id="targetElement" type="unknown">`,
+ fieldId: "targetElement",
+ expectedResult: true,
+ },
+ {
+ document: `<input id="targetElement" value="JOHN DOE">`,
+ fieldId: "targetElement",
+ expectedResult: true,
+ },
+ {
+ document: `<select id="targetElement" autocomplete="off"></select>`,
+ fieldId: "targetElement",
+ expectedResult: true,
+ },
+ {
+ document: `<select id="targetElement"></select>`,
+ fieldId: "targetElement",
+ expectedResult: true,
+ },
+ {
+ document: `<select id="targetElement" multiple></select>`,
+ fieldId: "targetElement",
+ expectedResult: true,
+ },
+ {
+ document: `<div id="targetElement"></div>`,
+ fieldId: "targetElement",
+ expectedResult: false,
+ },
+ {
+ document: `<input id="targetElement" type="text" readonly>`,
+ fieldId: "targetElement",
+ expectedResult: true,
+ },
+ {
+ document: `<input id="targetElement" type="text" disabled>`,
+ fieldId: "targetElement",
+ expectedResult: true,
+ },
+];
+
+TESTCASES.forEach(testcase => {
+ add_task(async function () {
+ info("Starting testcase: " + testcase.document);
+
+ let doc = MockDocument.createTestDocument(
+ "http://localhost:8080/test/",
+ testcase.document
+ );
+
+ let field = doc.getElementById(testcase.fieldId);
+ Assert.equal(
+ FormAutofillUtils.isCreditCardOrAddressFieldType(field),
+ testcase.expectedResult
+ );
+ });
+});
diff --git a/browser/extensions/formautofill/test/unit/test_known_strings.js b/browser/extensions/formautofill/test/unit/test_known_strings.js
new file mode 100644
index 0000000000..b3e69dc776
--- /dev/null
+++ b/browser/extensions/formautofill/test/unit/test_known_strings.js
@@ -0,0 +1,148 @@
+"use strict";
+/* global FormAutofillHeuristics: true */
+
+const KNOWN_NAMES = {
+ "cc-name": ["cc-name", "card-name", "cardholder-name", "cardholder"],
+ "cc-number": [
+ "cc-number",
+ "cc-num",
+ "card-number",
+ "card-num",
+ "number",
+ "cc",
+ "cc-no",
+ "card-no",
+ "credit-card",
+ "numero-carte",
+ "carte",
+ "carte-credit",
+ "num-carte",
+ "cb-num",
+ ],
+ "cc-exp": [
+ "cc-exp",
+ "card-exp",
+ "cc-expiration",
+ "card-expiration",
+ "cc-ex",
+ "card-ex",
+ "card-expire",
+ "card-expiry",
+ "validite",
+ "expiration",
+ "expiry",
+ "mm-yy",
+ "mm-yyyy",
+ "yy-mm",
+ "yyyy-mm",
+ "expiration-date",
+ "payment-card-expiration",
+ "payment-cc-date",
+ ],
+ "cc-exp-month": [
+ "exp-month",
+ "cc-exp-month",
+ "cc-month",
+ "card-month",
+ "cc-mo",
+ "card-mo",
+ "exp-mo",
+ "card-exp-mo",
+ "cc-exp-mo",
+ "card-expiration-month",
+ "expiration-month",
+ "cc-mm",
+ "cc-m",
+ "card-mm",
+ "card-m",
+ "card-exp-mm",
+ "cc-exp-mm",
+ "exp-mm",
+ "exp-m",
+ "expire-month",
+ "expire-mo",
+ "expiry-month",
+ "expiry-mo",
+ "card-expire-month",
+ "card-expire-mo",
+ "card-expiry-month",
+ "card-expiry-mo",
+ "mois-validite",
+ "mois-expiration",
+ "m-validite",
+ "m-expiration",
+ "expiry-date-field-month",
+ "expiration-date-month",
+ "expiration-date-mm",
+ "exp-mon",
+ "validity-mo",
+ "exp-date-mo",
+ "cb-date-mois",
+ "date-m",
+ ],
+ "cc-exp-year": [
+ "exp-year",
+ "cc-exp-year",
+ "cc-year",
+ "card-year",
+ "cc-yr",
+ "card-yr",
+ "exp-yr",
+ "card-exp-yr",
+ "cc-exp-yr",
+ "card-expiration-year",
+ "expiration-year",
+ "cc-yy",
+ "cc-y",
+ "card-yy",
+ "card-y",
+ "card-exp-yy",
+ "cc-exp-yy",
+ "exp-yy",
+ "exp-y",
+ "cc-yyyy",
+ "card-yyyy",
+ "card-exp-yyyy",
+ "cc-exp-yyyy",
+ "expire-year",
+ "expire-yr",
+ "expiry-year",
+ "expiry-yr",
+ "card-expire-year",
+ "card-expire-yr",
+ "card-expiry-year",
+ "card-expiry-yr",
+ "an-validite",
+ "an-expiration",
+ "annee-validite",
+ "annee-expiration",
+ "expiry-date-field-year",
+ "expiration-date-year",
+ "cb-date-ann",
+ "expiration-date-yy",
+ "expiration-date-yyyy",
+ "validity-year",
+ "exp-date-year",
+ "date-y",
+ ],
+};
+
+add_setup(async () => {
+ ({ FormAutofillHeuristics } = ChromeUtils.importESModule(
+ "resource://gre/modules/shared/FormAutofillHeuristics.sys.mjs"
+ ));
+});
+
+for (let field in KNOWN_NAMES) {
+ KNOWN_NAMES[field].forEach(name => {
+ add_task(async () => {
+ ok(
+ FormAutofillHeuristics.testRegex(
+ FormAutofillHeuristics.RULES[field],
+ name
+ ),
+ `RegExp for ${field} matches string '${name}'`
+ );
+ });
+ });
+}
diff --git a/browser/extensions/formautofill/test/unit/test_markAsAutofillField.js b/browser/extensions/formautofill/test/unit/test_markAsAutofillField.js
new file mode 100644
index 0000000000..72960fcaf2
--- /dev/null
+++ b/browser/extensions/formautofill/test/unit/test_markAsAutofillField.js
@@ -0,0 +1,201 @@
+"use strict";
+
+const TESTCASES = [
+ {
+ description: "Form containing 8 fields with autocomplete attribute.",
+ document: `<form>
+ <input id="given-name" autocomplete="given-name">
+ <input id="additional-name" autocomplete="additional-name">
+ <input id="family-name" autocomplete="family-name">
+ <input id="street-addr" autocomplete="street-address">
+ <input id="city" autocomplete="address-level2">
+ <input id="country" autocomplete="country">
+ <input id="email" autocomplete="email">
+ <input id="tel" autocomplete="tel">
+ <input id="without-autocomplete-1">
+ <input id="without-autocomplete-2">
+ </form>`,
+ targetElementId: "given-name",
+ expectedResult: [
+ "given-name",
+ "additional-name",
+ "family-name",
+ "street-addr",
+ "city",
+ "country",
+ "email",
+ "tel",
+ ],
+ },
+ {
+ description: "Form containing only 2 fields with autocomplete attribute.",
+ document: `<form>
+ <input id="street-addr" autocomplete="street-address">
+ <input id="city" autocomplete="address-level2">
+ <input id="without-autocomplete-1">
+ <input id="without-autocomplete-2">
+ </form>`,
+ targetElementId: "street-addr",
+ expectedResult: [],
+ },
+ {
+ description: "Fields without form element.",
+ document: `<input id="street-addr" autocomplete="street-address">
+ <input id="city" autocomplete="address-level2">
+ <input id="country" autocomplete="country">
+ <input id="email" autocomplete="email">
+ <input id="tel" autocomplete="tel">
+ <input id="without-autocomplete-1">
+ <input id="without-autocomplete-2">`,
+ targetElementId: "street-addr",
+ expectedResult: ["street-addr", "city", "country", "email", "tel"],
+ },
+ {
+ description: "Form containing credit card autocomplete attributes.",
+ document: `<form>
+ <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>`,
+ targetElementId: "cc-number",
+ expectedResult: ["cc-number", "cc-name", "cc-exp-month", "cc-exp-year"],
+ },
+ {
+ description:
+ "Form containing multiple cc-number fields without autocomplete attributes.",
+ document: `<form>
+ <input id="cc-number1" maxlength="4">
+ <input id="cc-number2" maxlength="4">
+ <input id="cc-number3" maxlength="4">
+ <input id="cc-number4" maxlength="4">
+ <input id="cc-name">
+ <input id="cc-exp-month">
+ <input id="cc-exp-year">
+ </form>`,
+ targetElementId: "cc-number1",
+ expectedResult: [
+ "cc-number1",
+ "cc-number2",
+ "cc-number3",
+ "cc-number4",
+ "cc-name",
+ "cc-exp-month",
+ "cc-exp-year",
+ ],
+ },
+ {
+ description:
+ "Invalid form containing three consecutive cc-number fields without autocomplete attributes.",
+ document: `<form>
+ <input id="cc-number1" maxlength="4">
+ <input id="cc-number2" maxlength="4">
+ <input id="cc-number3" maxlength="4">
+ </form>`,
+ targetElementId: "cc-number1",
+ expectedResult: [],
+ prefs: [
+ [
+ "extensions.formautofill.creditCards.heuristics.fathom.testConfidence",
+ "1.0",
+ ],
+ ],
+ },
+ {
+ description:
+ "Invalid form containing five consecutive cc-number fields without autocomplete attributes.",
+ document: `<form>
+ <input id="cc-number1" maxlength="4">
+ <input id="cc-number2" maxlength="4">
+ <input id="cc-number3" maxlength="4">
+ <input id="cc-number4" maxlength="4">
+ <input id="cc-number5" maxlength="4">
+ </form>`,
+ targetElementId: "cc-number1",
+ expectedResult: [],
+ prefs: [
+ [
+ "extensions.formautofill.creditCards.heuristics.fathom.testConfidence",
+ "1.0",
+ ],
+ ],
+ },
+ {
+ description:
+ "Valid form containing three consecutive cc-number fields without autocomplete attributes.",
+ document: `<form>
+ <input id="cc-number1" maxlength="4">
+ <input id="cc-number2" maxlength="4">
+ <input id="cc-number3" maxlength="4">
+ <input id="cc-name">
+ <input id="cc-exp-month">
+ <input id="cc-exp-year">
+ </form>`,
+ targetElementId: "cc-number1",
+ expectedResult: ["cc-number3", "cc-name", "cc-exp-month", "cc-exp-year"],
+ prefs: [
+ [
+ "extensions.formautofill.creditCards.heuristics.fathom.testConfidence",
+ "1.0",
+ ],
+ ],
+ },
+ {
+ description:
+ "Valid form containing five consecutive cc-number fields without autocomplete attributes.",
+ document: `<form>
+ <input id="cc-number1" maxlength="4">
+ <input id="cc-number2" maxlength="4">
+ <input id="cc-number3" maxlength="4">
+ <input id="cc-number4" maxlength="4">
+ <input id="cc-number5" maxlength="4">
+ <input id="cc-name">
+ <input id="cc-exp-month">
+ <input id="cc-exp-year">
+ </form>`,
+ targetElementId: "cc-number1",
+ expectedResult: ["cc-number5", "cc-name", "cc-exp-month", "cc-exp-year"],
+ },
+];
+
+let markedFieldId = [];
+
+var FormAutofillContent;
+add_setup(async () => {
+ ({ FormAutofillContent } = ChromeUtils.importESModule(
+ "resource://autofill/FormAutofillContent.sys.mjs"
+ ));
+
+ FormAutofillContent._markAsAutofillField = function (field) {
+ markedFieldId.push(field.id);
+ };
+});
+
+TESTCASES.forEach(testcase => {
+ add_task(async function () {
+ info("Starting testcase: " + testcase.description);
+
+ if (testcase.prefs) {
+ testcase.prefs.forEach(pref => SetPref(pref[0], pref[1]));
+ }
+
+ markedFieldId = [];
+
+ let doc = MockDocument.createTestDocument(
+ "http://localhost:8080/test/",
+ testcase.document
+ );
+ let element = doc.getElementById(testcase.targetElementId);
+ FormAutofillContent.identifyAutofillFields(element);
+
+ Assert.deepEqual(
+ markedFieldId,
+ testcase.expectedResult,
+ "Check the fields were marked correctly."
+ );
+
+ if (testcase.prefs) {
+ testcase.prefs.forEach(pref => Services.prefs.clearUserPref(pref[0]));
+ }
+ });
+});
diff --git a/browser/extensions/formautofill/test/unit/test_migrateRecords.js b/browser/extensions/formautofill/test/unit/test_migrateRecords.js
new file mode 100644
index 0000000000..24b13b4322
--- /dev/null
+++ b/browser/extensions/formautofill/test/unit/test_migrateRecords.js
@@ -0,0 +1,382 @@
+/**
+ * Tests the migration algorithm in profileStorage.
+ */
+
+"use strict";
+
+let FormAutofillStorage;
+add_setup(async () => {
+ ({ FormAutofillStorage } = ChromeUtils.importESModule(
+ "resource://autofill/FormAutofillStorage.sys.mjs"
+ ));
+});
+
+const TEST_STORE_FILE_NAME = "test-profile.json";
+
+const { ADDRESS_SCHEMA_VERSION } = ChromeUtils.importESModule(
+ "resource://autofill/FormAutofillStorageBase.sys.mjs"
+);
+const { CREDIT_CARD_SCHEMA_VERSION } = ChromeUtils.importESModule(
+ "resource://autofill/FormAutofillStorageBase.sys.mjs"
+);
+
+const ADDRESS_TESTCASES = [
+ {
+ description:
+ "The record version is equal to the current version. The migration shouldn't be invoked.",
+ record: {
+ guid: "test-guid",
+ version: ADDRESS_SCHEMA_VERSION,
+ "given-name": "Timothy",
+ name: "John", // The cached name field doesn't align "given-name" but it
+ // won't be recomputed because the migration isn't invoked.
+ },
+ expectedResult: {
+ guid: "test-guid",
+ version: ADDRESS_SCHEMA_VERSION,
+ "given-name": "Timothy",
+ name: "John",
+ },
+ },
+ {
+ description:
+ "The record version is greater than the current version. The migration shouldn't be invoked.",
+ record: {
+ guid: "test-guid",
+ version: 99,
+ "given-name": "Timothy",
+ name: "John",
+ },
+ expectedResult: {
+ guid: "test-guid",
+ version: 99,
+ "given-name": "Timothy",
+ name: "John",
+ },
+ },
+ {
+ description:
+ "The record version is less than the current version. The migration should be invoked.",
+ record: {
+ guid: "test-guid",
+ version: 0,
+ "given-name": "Timothy",
+ name: "John",
+ },
+ expectedResult: {
+ guid: "test-guid",
+ version: ADDRESS_SCHEMA_VERSION,
+ "given-name": "Timothy",
+ name: "Timothy",
+ },
+ },
+ {
+ description:
+ "The record version is omitted. The migration should be invoked.",
+ record: {
+ guid: "test-guid",
+ "given-name": "Timothy",
+ name: "John",
+ "unknown-1": "an unknown field from another client",
+ },
+ expectedResult: {
+ guid: "test-guid",
+ version: ADDRESS_SCHEMA_VERSION,
+ "given-name": "Timothy",
+ name: "Timothy",
+ "unknown-1": "an unknown field from another client",
+ },
+ },
+ {
+ description:
+ "The record version is an invalid value. The migration should be invoked.",
+ record: {
+ guid: "test-guid",
+ version: "ABCDE",
+ "given-name": "Timothy",
+ name: "John",
+ "unknown-1": "an unknown field from another client",
+ },
+ expectedResult: {
+ guid: "test-guid",
+ version: ADDRESS_SCHEMA_VERSION,
+ "given-name": "Timothy",
+ name: "Timothy",
+ "unknown-1": "an unknown field from another client",
+ },
+ },
+ {
+ description:
+ "The omitted computed fields should be always recomputed even the record version is up-to-date.",
+ record: {
+ guid: "test-guid",
+ version: ADDRESS_SCHEMA_VERSION,
+ "given-name": "Timothy",
+ },
+ expectedResult: {
+ guid: "test-guid",
+ version: ADDRESS_SCHEMA_VERSION,
+ "given-name": "Timothy",
+ name: "Timothy",
+ },
+ },
+ {
+ description: "The migration shouldn't be invoked on tombstones.",
+ record: {
+ guid: "test-guid",
+ timeLastModified: 12345,
+ deleted: true,
+ },
+ expectedResult: {
+ guid: "test-guid",
+ timeLastModified: 12345,
+ deleted: true,
+
+ // Make sure no new fields are appended.
+ version: undefined,
+ name: undefined,
+ },
+ },
+];
+
+const CREDIT_CARD_TESTCASES = [
+ {
+ description:
+ "The record version is equal to the current version. The migration shouldn't be invoked.",
+ record: {
+ guid: "test-guid",
+ version: CREDIT_CARD_SCHEMA_VERSION,
+ "cc-name": "Timothy",
+ "cc-given-name": "John", // The cached "cc-given-name" field doesn't align
+ // "cc-name" but it won't be recomputed because
+ // the migration isn't invoked.
+ },
+ expectedResult: {
+ guid: "test-guid",
+ version: CREDIT_CARD_SCHEMA_VERSION,
+ "cc-name": "Timothy",
+ "cc-given-name": "John",
+ },
+ },
+ {
+ description:
+ "The record version is greater than the current version. The migration shouldn't be invoked.",
+ record: {
+ guid: "test-guid",
+ version: 99,
+ "cc-name": "Timothy",
+ "cc-given-name": "John",
+ },
+ expectedResult: {
+ guid: "test-guid",
+ version: 99,
+ "cc-name": "Timothy",
+ "cc-given-name": "John",
+ },
+ },
+ {
+ description:
+ "The record version is less than the current version. The migration should be invoked.",
+ record: {
+ guid: "test-guid",
+ version: 0,
+ "cc-name": "Timothy",
+ "cc-given-name": "John",
+ },
+ expectedResult: {
+ guid: "test-guid",
+ version: CREDIT_CARD_SCHEMA_VERSION,
+ "cc-name": "Timothy",
+ "cc-given-name": "Timothy",
+ },
+ },
+ {
+ description:
+ "The record version is omitted. The migration should be invoked.",
+ record: {
+ guid: "test-guid",
+ "cc-name": "Timothy",
+ "cc-given-name": "John",
+ "unknown-1": "an unknown field from another client",
+ },
+ expectedResult: {
+ guid: "test-guid",
+ version: CREDIT_CARD_SCHEMA_VERSION,
+ "cc-name": "Timothy",
+ "cc-given-name": "Timothy",
+ "unknown-1": "an unknown field from another client",
+ },
+ },
+ {
+ description:
+ "The record version is an invalid value. The migration should be invoked.",
+ record: {
+ guid: "test-guid",
+ version: "ABCDE",
+ "cc-name": "Timothy",
+ "cc-given-name": "John",
+ "unknown-1": "an unknown field from another client",
+ },
+ expectedResult: {
+ guid: "test-guid",
+ version: CREDIT_CARD_SCHEMA_VERSION,
+ "cc-name": "Timothy",
+ "cc-given-name": "Timothy",
+ "unknown-1": "an unknown field from another client",
+ },
+ },
+ {
+ description:
+ "The omitted computed fields should be always recomputed even the record version is up-to-date.",
+ record: {
+ guid: "test-guid",
+ version: CREDIT_CARD_SCHEMA_VERSION,
+ "cc-name": "Timothy",
+ },
+ expectedResult: {
+ guid: "test-guid",
+ version: CREDIT_CARD_SCHEMA_VERSION,
+ "cc-name": "Timothy",
+ "cc-given-name": "Timothy",
+ },
+ },
+ {
+ description: "The migration shouldn't be invoked on tombstones.",
+ record: {
+ guid: "test-guid",
+ timeLastModified: 12345,
+ deleted: true,
+ },
+ expectedResult: {
+ guid: "test-guid",
+ timeLastModified: 12345,
+ deleted: true,
+
+ // Make sure no new fields are appended.
+ version: undefined,
+ "cc-given-name": undefined,
+ },
+ },
+];
+
+let do_check_record_matches = (expectedRecord, record) => {
+ for (let key in expectedRecord) {
+ Assert.equal(expectedRecord[key], record[key]);
+ }
+};
+
+add_task(async function test_migrateAddressRecords() {
+ let path = getTempFile(TEST_STORE_FILE_NAME).path;
+
+ let profileStorage = new FormAutofillStorage(path);
+ await profileStorage.initialize();
+
+ for (let testcase of ADDRESS_TESTCASES) {
+ info(testcase.description);
+ profileStorage._store.data.addresses = [testcase.record];
+ await profileStorage.addresses._migrateRecord(testcase.record, 0);
+ do_check_record_matches(
+ testcase.expectedResult,
+ profileStorage.addresses._data[0]
+ );
+ }
+});
+
+add_task(async function test_migrateCreditCardRecords() {
+ let path = getTempFile(TEST_STORE_FILE_NAME).path;
+
+ let profileStorage = new FormAutofillStorage(path);
+ await profileStorage.initialize();
+
+ for (let testcase of CREDIT_CARD_TESTCASES) {
+ info(testcase.description);
+ profileStorage._store.data.creditCards = [testcase.record];
+ await profileStorage.creditCards._migrateRecord(testcase.record, 0);
+ do_check_record_matches(
+ testcase.expectedResult,
+ profileStorage.creditCards._data[0]
+ );
+ }
+});
+
+add_task(async function test_migrateEncryptedCreditCardNumber() {
+ let path = getTempFile(TEST_STORE_FILE_NAME).path;
+
+ let profileStorage = new FormAutofillStorage(path);
+ await profileStorage.initialize();
+
+ info("v1 and v2 schema cards should be abandoned.");
+
+ let v1record = {
+ guid: "test-guid1",
+ version: 1,
+ "cc-name": "Timothy",
+ "cc-number-encrypted": "aaaa",
+ };
+
+ let v2record = {
+ guid: "test-guid2",
+ version: 2,
+ "cc-name": "Bob",
+ "cc-number-encrypted": "bbbb",
+ };
+
+ profileStorage._store.data.creditCards = [v1record, v2record];
+ await profileStorage.creditCards._migrateRecord(v1record, 0);
+ await profileStorage.creditCards._migrateRecord(v2record, 1);
+ v1record = profileStorage.creditCards._data[0];
+ v2record = profileStorage.creditCards._data[1];
+
+ Assert.ok(v1record.deleted);
+ Assert.ok(v2record.deleted);
+});
+
+add_task(async function test_migrateDeprecatedCreditCardV4() {
+ let path = getTempFile(TEST_STORE_FILE_NAME).path;
+
+ let profileStorage = new FormAutofillStorage(path);
+ await profileStorage.initialize();
+
+ let records = [
+ {
+ guid: "test-guid1",
+ version: CREDIT_CARD_SCHEMA_VERSION,
+ "cc-name": "Alice",
+ _sync: {
+ changeCounter: 0,
+ lastSyncedFields: {},
+ },
+ },
+ {
+ guid: "test-guid2",
+ version: 4,
+ "cc-name": "Timothy",
+ _sync: {
+ changeCounter: 0,
+ lastSyncedFields: {},
+ },
+ },
+ {
+ guid: "test-guid3",
+ version: 4,
+ "cc-name": "Bob",
+ },
+ ];
+
+ profileStorage._store.data.creditCards = records;
+ for (let idx = 0; idx < records.length; idx++) {
+ await profileStorage.creditCards._migrateRecord(records[idx], idx);
+ }
+
+ profileStorage.creditCards.pullSyncChanges();
+
+ // Record that has already synced before, do not sync again
+ equal(getSyncChangeCounter(profileStorage.creditCards, records[0].guid), 0);
+
+ // alaways force sync v4 record
+ equal(records[1].version, CREDIT_CARD_SCHEMA_VERSION);
+ equal(getSyncChangeCounter(profileStorage.creditCards, records[1].guid), 1);
+
+ equal(records[2].version, CREDIT_CARD_SCHEMA_VERSION);
+ equal(getSyncChangeCounter(profileStorage.creditCards, records[2].guid), 1);
+});
diff --git a/browser/extensions/formautofill/test/unit/test_nameUtils.js b/browser/extensions/formautofill/test/unit/test_nameUtils.js
new file mode 100644
index 0000000000..1b8bdb6d49
--- /dev/null
+++ b/browser/extensions/formautofill/test/unit/test_nameUtils.js
@@ -0,0 +1,289 @@
+/**
+ * Tests FormAutofillNameUtils object.
+ */
+
+"use strict";
+
+var FormAutofillNameUtils;
+add_task(async function () {
+ ({ FormAutofillNameUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/shared/FormAutofillNameUtils.sys.mjs"
+ ));
+});
+
+// Test cases initially copied from
+// https://cs.chromium.org/chromium/src/components/autofill/core/browser/autofill_data_util_unittest.cc
+const TESTCASES = [
+ {
+ description: "Full name including given, middle and family names",
+ fullName: "Homer Jay Simpson",
+ nameParts: {
+ given: "Homer",
+ middle: "Jay",
+ family: "Simpson",
+ },
+ },
+ {
+ description: "No middle name",
+ fullName: "Moe Szyslak",
+ nameParts: {
+ given: "Moe",
+ middle: "",
+ family: "Szyslak",
+ },
+ },
+ {
+ description: "Common name prefixes removed",
+ fullName: "Reverend Timothy Lovejoy",
+ nameParts: {
+ given: "Timothy",
+ middle: "",
+ family: "Lovejoy",
+ },
+ expectedFullName: "Timothy Lovejoy",
+ },
+ {
+ description: "Common name suffixes removed",
+ fullName: "John Frink Phd",
+ nameParts: {
+ given: "John",
+ middle: "",
+ family: "Frink",
+ },
+ expectedFullName: "John Frink",
+ },
+ {
+ description: "Exception to the name suffix removal",
+ fullName: "John Ma",
+ nameParts: {
+ given: "John",
+ middle: "",
+ family: "Ma",
+ },
+ },
+ {
+ description: "Common family name prefixes not considered a middle name",
+ fullName: "Milhouse Van Houten",
+ nameParts: {
+ given: "Milhouse",
+ middle: "",
+ family: "Van Houten",
+ },
+ },
+
+ // CJK names have reverse order (surname goes first, given name goes second).
+ {
+ description: "Chinese name, Unihan",
+ fullName: "孫 德明",
+ nameParts: {
+ given: "德明",
+ middle: "",
+ family: "孫",
+ },
+ expectedFullName: "孫德明",
+ },
+ {
+ description: 'Chinese name, Unihan, "IDEOGRAPHIC SPACE"',
+ fullName: "孫 德明",
+ nameParts: {
+ given: "德明",
+ middle: "",
+ family: "孫",
+ },
+ expectedFullName: "孫德明",
+ },
+ {
+ description: "Korean name, Hangul",
+ fullName: "홍 길동",
+ nameParts: {
+ given: "길동",
+ middle: "",
+ family: "홍",
+ },
+ expectedFullName: "홍길동",
+ },
+ {
+ description: "Japanese name, Unihan",
+ fullName: "山田 貴洋",
+ nameParts: {
+ given: "貴洋",
+ middle: "",
+ family: "山田",
+ },
+ expectedFullName: "山田貴洋",
+ },
+
+ // In Japanese, foreign names use 'KATAKANA MIDDLE DOT' (U+30FB) as a
+ // separator. There is no consensus for the ordering. For now, we use the same
+ // ordering as regular Japanese names ("last・first").
+ {
+ description: "Foreign name in Japanese, Katakana",
+ fullName: "ゲイツ・ビル",
+ nameParts: {
+ given: "ビル",
+ middle: "",
+ family: "ゲイツ",
+ },
+ expectedFullName: "ゲイツビル",
+ },
+
+ // 'KATAKANA MIDDLE DOT' is occasionally typoed as 'MIDDLE DOT' (U+00B7).
+ {
+ description: "Foreign name in Japanese, Katakana",
+ fullName: "ゲイツ·ビル",
+ nameParts: {
+ given: "ビル",
+ middle: "",
+ family: "ゲイツ",
+ },
+ expectedFullName: "ゲイツビル",
+ },
+
+ // CJK names don't usually have a space in the middle, but most of the time,
+ // the surname is only one character (in Chinese & Korean).
+ {
+ description: "Korean name, Hangul",
+ fullName: "최성훈",
+ nameParts: {
+ given: "성훈",
+ middle: "",
+ family: "최",
+ },
+ },
+ {
+ description: "(Simplified) Chinese name, Unihan",
+ fullName: "刘翔",
+ nameParts: {
+ given: "翔",
+ middle: "",
+ family: "刘",
+ },
+ },
+ {
+ description: "(Traditional) Chinese name, Unihan",
+ fullName: "劉翔",
+ nameParts: {
+ given: "翔",
+ middle: "",
+ family: "劉",
+ },
+ },
+
+ // There are a few exceptions. Occasionally, the surname has two characters.
+ {
+ description: "Korean name, Hangul",
+ fullName: "남궁도",
+ nameParts: {
+ given: "도",
+ middle: "",
+ family: "남궁",
+ },
+ },
+ {
+ description: "Korean name, Hangul",
+ fullName: "황보혜정",
+ nameParts: {
+ given: "혜정",
+ middle: "",
+ family: "황보",
+ },
+ },
+ {
+ description: "(Traditional) Chinese name, Unihan",
+ fullName: "歐陽靖",
+ nameParts: {
+ given: "靖",
+ middle: "",
+ family: "歐陽",
+ },
+ },
+
+ // In Korean, some 2-character surnames are rare/ambiguous, like "강전": "강"
+ // is a common surname, and "전" can be part of a given name. In those cases,
+ // we assume it's 1/2 for 3-character names, or 2/2 for 4-character names.
+ {
+ description: "Korean name, Hangul",
+ fullName: "강전희",
+ nameParts: {
+ given: "전희",
+ middle: "",
+ family: "강",
+ },
+ },
+ {
+ description: "Korean name, Hangul",
+ fullName: "황목치승",
+ nameParts: {
+ given: "치승",
+ middle: "",
+ family: "황목",
+ },
+ },
+
+ // It occasionally happens that a full name is 2 characters, 1/1.
+ {
+ description: "Korean name, Hangul",
+ fullName: "이도",
+ nameParts: {
+ given: "도",
+ middle: "",
+ family: "이",
+ },
+ },
+ {
+ description: "Korean name, Hangul",
+ fullName: "孫文",
+ nameParts: {
+ given: "文",
+ middle: "",
+ family: "孫",
+ },
+ },
+
+ // These are no CJK names for us, they're just bogus.
+ {
+ description: "Bogus",
+ fullName: "Homer シンプソン",
+ nameParts: {
+ given: "Homer",
+ middle: "",
+ family: "シンプソン",
+ },
+ },
+ {
+ description: "Bogus",
+ fullName: "ホーマー Simpson",
+ nameParts: {
+ given: "ホーマー",
+ middle: "",
+ family: "Simpson",
+ },
+ },
+ {
+ description: "CJK has a middle-name, too unusual",
+ fullName: "반 기 문",
+ nameParts: {
+ given: "반",
+ middle: "기",
+ family: "문",
+ },
+ },
+];
+
+add_task(async function test_splitName() {
+ TESTCASES.forEach(testcase => {
+ if (testcase.fullName) {
+ info("Starting testcase: " + testcase.description);
+ let nameParts = FormAutofillNameUtils.splitName(testcase.fullName);
+ Assert.deepEqual(nameParts, testcase.nameParts);
+ }
+ });
+});
+
+add_task(async function test_joinName() {
+ TESTCASES.forEach(testcase => {
+ info("Starting testcase: " + testcase.description);
+ let name = FormAutofillNameUtils.joinNameParts(testcase.nameParts);
+ Assert.equal(name, testcase.expectedFullName || testcase.fullName);
+ });
+});
diff --git a/browser/extensions/formautofill/test/unit/test_onFormSubmitted.js b/browser/extensions/formautofill/test/unit/test_onFormSubmitted.js
new file mode 100644
index 0000000000..1c252e04bb
--- /dev/null
+++ b/browser/extensions/formautofill/test/unit/test_onFormSubmitted.js
@@ -0,0 +1,805 @@
+"use strict";
+
+var FormAutofillContent;
+add_setup(async () => {
+ ({ FormAutofillContent } = ChromeUtils.importESModule(
+ "resource://autofill/FormAutofillContent.sys.mjs"
+ ));
+});
+
+const DEFAULT_TEST_DOC = `<form id="form1">
+ <input id="street-addr" autocomplete="street-address">
+ <select id="address-level1" autocomplete="address-level1">
+ <option value=""></option>
+ <option value="AL">Alabama</option>
+ <option value="AK">Alaska</option>
+ <option value="AP">Armed Forces Pacific</option>
+
+ <option value="ca">california</option>
+ <option value="AR">US-Arkansas</option>
+ <option value="US-CA">California</option>
+ <option value="CA">California</option>
+ <option value="US-AZ">US_Arizona</option>
+ <option value="Ariz">Arizonac</option>
+ </select>
+ <input id="city" autocomplete="address-level2">
+ <input id="country" autocomplete="country">
+ <input id="email" autocomplete="email">
+ <input id="tel" autocomplete="tel">
+ <input id="cc-name" autocomplete="cc-name">
+ <input id="cc-number" autocomplete="cc-number">
+ <input id="cc-exp-month" autocomplete="cc-exp-month">
+ <input id="cc-exp-year" autocomplete="cc-exp-year">
+ <select id="cc-type">
+ <option value="">Select</option>
+ <option value="visa">Visa</option>
+ <option value="mastercard">Master Card</option>
+ <option value="amex">American Express</option>
+ </select>
+ <input id="submit" type="submit">
+ </form>`;
+const TARGET_ELEMENT_ID = "street-addr";
+
+const TESTCASES = [
+ {
+ description:
+ "Should not trigger address saving if the number of fields is less than 3",
+ document: DEFAULT_TEST_DOC,
+ targetElementId: TARGET_ELEMENT_ID,
+ formValue: {
+ "street-addr": "331 E. Evelyn Avenue",
+ tel: "1-650-903-0800",
+ },
+ expectedResult: {
+ formSubmission: false,
+ },
+ },
+ {
+ description: "Should not trigger credit card saving if number is empty",
+ document: DEFAULT_TEST_DOC,
+ targetElementId: TARGET_ELEMENT_ID,
+ formValue: {
+ "cc-name": "John Doe",
+ "cc-exp-month": 12,
+ "cc-exp-year": 2000,
+ },
+ expectedResult: {
+ formSubmission: false,
+ },
+ },
+ {
+ description:
+ "Should not trigger credit card saving if there is more than one cc-number field but less than four fields",
+ document: `<form id="form1">
+ <input id="cc-type" autocomplete="cc-type">
+ <input id="cc-name" autocomplete="cc-name">
+ <input id="cc-number1" maxlength="4">
+ <input id="cc-number2" maxlength="4">
+ <input id="cc-number3" maxlength="4">
+ <input id="cc-exp-month" autocomplete="cc-exp-month">
+ <input id="cc-exp-year" autocomplete="cc-exp-year">
+ <input id="submit" type="submit">
+ </form>
+ `,
+ targetElementId: "cc-name",
+ formValue: {
+ "cc-name": "John Doe",
+ "cc-number1": "3714",
+ "cc-number2": "4963",
+ "cc-number3": "5398",
+ "cc-exp-month": 12,
+ "cc-exp-year": 2000,
+ "cc-type": "amex",
+ },
+ expectedResult: {
+ formSubmission: false,
+ },
+ },
+ {
+ description: "Trigger address saving",
+ document: DEFAULT_TEST_DOC,
+ targetElementId: TARGET_ELEMENT_ID,
+ formValue: {
+ "street-addr": "331 E. Evelyn Avenue",
+ country: "US",
+ tel: "1-650-903-0800",
+ },
+ expectedResult: {
+ formSubmission: true,
+ records: {
+ address: [
+ {
+ guid: null,
+ record: {
+ "street-address": "331 E. Evelyn Avenue",
+ "address-level1": "",
+ "address-level2": "",
+ country: "US",
+ email: "",
+ tel: "1-650-903-0800",
+ },
+ untouchedFields: [],
+ },
+ ],
+ creditCard: [],
+ },
+ },
+ },
+ {
+ description: "Trigger credit card saving",
+ document: DEFAULT_TEST_DOC,
+ targetElementId: "cc-type",
+ formValue: {
+ "cc-name": "John Doe",
+ "cc-number": "5105105105105100",
+ "cc-exp-month": 12,
+ "cc-exp-year": 2000,
+ "cc-type": "amex",
+ },
+ expectedResult: {
+ formSubmission: true,
+ records: {
+ address: [],
+ creditCard: [
+ {
+ guid: null,
+ record: {
+ "cc-name": "John Doe",
+ "cc-number": "5105105105105100",
+ "cc-exp-month": 12,
+ "cc-exp-year": 2000,
+ "cc-type": "amex",
+ },
+ untouchedFields: [],
+ },
+ ],
+ },
+ },
+ },
+ {
+ description: "Trigger credit card saving using multiple cc-number fields",
+ document: `<form id="form1">
+ <input id="cc-type" autocomplete="cc-type">
+ <input id="cc-name" autocomplete="cc-name">
+ <input id="cc-number1" maxlength="4">
+ <input id="cc-number2" maxlength="4">
+ <input id="cc-number3" maxlength="4">
+ <input id="cc-number4" maxlength="4">
+ <input id="cc-exp-month" autocomplete="cc-exp-month">
+ <input id="cc-exp-year" autocomplete="cc-exp-year">
+ <input id="submit" type="submit">
+ </form>`,
+ targetElementId: "cc-type",
+ formValue: {
+ "cc-name": "John Doe",
+ "cc-number1": "3714",
+ "cc-number2": "4963",
+ "cc-number3": "5398",
+ "cc-number4": "431",
+ "cc-exp-month": 12,
+ "cc-exp-year": 2000,
+ "cc-type": "amex",
+ },
+ expectedResult: {
+ formSubmission: true,
+ records: {
+ address: [],
+ creditCard: [
+ {
+ guid: null,
+ record: {
+ "cc-name": "John Doe",
+ "cc-number": "371449635398431",
+ "cc-exp-month": 12,
+ "cc-exp-year": 2000,
+ "cc-type": "amex",
+ },
+ untouchedFields: [],
+ },
+ ],
+ },
+ },
+ },
+ {
+ description: "Trigger address and credit card saving",
+ document: DEFAULT_TEST_DOC,
+ targetElementId: TARGET_ELEMENT_ID,
+ formValue: {
+ "street-addr": "331 E. Evelyn Avenue",
+ country: "US",
+ tel: "1-650-903-0800",
+ "cc-name": "John Doe",
+ "cc-number": "5105105105105100",
+ "cc-exp-month": 12,
+ "cc-exp-year": 2000,
+ "cc-type": "visa",
+ },
+ expectedResult: {
+ formSubmission: true,
+ records: {
+ address: [
+ {
+ guid: null,
+ record: {
+ "street-address": "331 E. Evelyn Avenue",
+ "address-level1": "",
+ "address-level2": "",
+ country: "US",
+ email: "",
+ tel: "1-650-903-0800",
+ },
+ untouchedFields: [],
+ },
+ ],
+ creditCard: [
+ {
+ guid: null,
+ record: {
+ "cc-name": "John Doe",
+ "cc-number": "5105105105105100",
+ "cc-exp-month": 12,
+ "cc-exp-year": 2000,
+ "cc-type": "visa",
+ },
+ untouchedFields: [],
+ },
+ ],
+ },
+ },
+ },
+ {
+ description: "Profile saved with trimmed string",
+ document: DEFAULT_TEST_DOC,
+ targetElementId: TARGET_ELEMENT_ID,
+ formValue: {
+ "street-addr": "331 E. Evelyn Avenue ",
+ country: "US",
+ tel: " 1-650-903-0800",
+ },
+ expectedResult: {
+ formSubmission: true,
+ records: {
+ address: [
+ {
+ guid: null,
+ record: {
+ "street-address": "331 E. Evelyn Avenue",
+ "address-level1": "",
+ "address-level2": "",
+ country: "US",
+ email: "",
+ tel: "1-650-903-0800",
+ },
+ untouchedFields: [],
+ },
+ ],
+ creditCard: [],
+ },
+ },
+ },
+ {
+ description: "Eliminate the field that is empty after trimmed",
+ document: DEFAULT_TEST_DOC,
+ targetElementId: TARGET_ELEMENT_ID,
+ formValue: {
+ "street-addr": "331 E. Evelyn Avenue",
+ country: "US",
+ email: " ",
+ tel: "1-650-903-0800",
+ },
+ expectedResult: {
+ formSubmission: true,
+ records: {
+ address: [
+ {
+ guid: null,
+ record: {
+ "street-address": "331 E. Evelyn Avenue",
+ "address-level1": "",
+ "address-level2": "",
+ country: "US",
+ email: "",
+ tel: "1-650-903-0800",
+ },
+ untouchedFields: [],
+ },
+ ],
+ creditCard: [],
+ },
+ },
+ },
+ {
+ description: "Save state with regular select option",
+ document: DEFAULT_TEST_DOC,
+ targetElementId: TARGET_ELEMENT_ID,
+ formValue: {
+ "address-level1": "CA",
+ "street-addr": "331 E. Evelyn Avenue",
+ country: "US",
+ },
+ expectedResult: {
+ formSubmission: true,
+ records: {
+ address: [
+ {
+ guid: null,
+ record: {
+ "address-level1": "CA",
+ "address-level2": "",
+ "street-address": "331 E. Evelyn Avenue",
+ country: "US",
+ email: "",
+ tel: "",
+ },
+ untouchedFields: [],
+ },
+ ],
+ creditCard: [],
+ },
+ },
+ },
+ {
+ description: "Save state with lowercase value",
+ document: DEFAULT_TEST_DOC,
+ targetElementId: TARGET_ELEMENT_ID,
+ formValue: {
+ "address-level1": "ca",
+ "street-addr": "331 E. Evelyn Avenue",
+ country: "US",
+ },
+ expectedResult: {
+ formSubmission: true,
+ records: {
+ address: [
+ {
+ guid: null,
+ record: {
+ "address-level1": "CA",
+ "address-level2": "",
+ "street-address": "331 E. Evelyn Avenue",
+ country: "US",
+ email: "",
+ tel: "",
+ },
+ untouchedFields: [],
+ },
+ ],
+ creditCard: [],
+ },
+ },
+ },
+ {
+ description: "Save state with a country code prefixed to the label",
+ document: DEFAULT_TEST_DOC,
+ targetElementId: TARGET_ELEMENT_ID,
+ formValue: {
+ "address-level1": "AR",
+ "street-addr": "331 E. Evelyn Avenue",
+ country: "US",
+ },
+ expectedResult: {
+ formSubmission: true,
+ records: {
+ address: [
+ {
+ guid: null,
+ record: {
+ "address-level1": "AR",
+ "address-level2": "",
+ "street-address": "331 E. Evelyn Avenue",
+ country: "US",
+ email: "",
+ tel: "",
+ },
+ untouchedFields: [],
+ },
+ ],
+ creditCard: [],
+ },
+ },
+ },
+ {
+ description: "Save state with a country code prefixed to the value",
+ document: DEFAULT_TEST_DOC,
+ targetElementId: TARGET_ELEMENT_ID,
+ formValue: {
+ "address-level1": "US-CA",
+ "street-addr": "331 E. Evelyn Avenue",
+ country: "US",
+ },
+ expectedResult: {
+ formSubmission: true,
+ records: {
+ address: [
+ {
+ guid: null,
+ record: {
+ "address-level1": "CA",
+ "address-level2": "",
+ "street-address": "331 E. Evelyn Avenue",
+ country: "US",
+ email: "",
+ tel: "",
+ },
+ untouchedFields: [],
+ },
+ ],
+ creditCard: [],
+ },
+ },
+ },
+ {
+ description:
+ "Save state with a country code prefixed to the value and label",
+ document: DEFAULT_TEST_DOC,
+ targetElementId: TARGET_ELEMENT_ID,
+ formValue: {
+ "address-level1": "US-AZ",
+ "street-addr": "331 E. Evelyn Avenue",
+ country: "US",
+ },
+ expectedResult: {
+ formSubmission: true,
+ records: {
+ address: [
+ {
+ guid: null,
+ record: {
+ "address-level1": "AZ",
+ "address-level2": "",
+ "street-address": "331 E. Evelyn Avenue",
+ country: "US",
+ email: "",
+ tel: "",
+ },
+ untouchedFields: [],
+ },
+ ],
+ creditCard: [],
+ },
+ },
+ },
+ {
+ description:
+ "Should save select label instead when failed to abbreviate the value",
+ document: DEFAULT_TEST_DOC,
+ targetElementId: TARGET_ELEMENT_ID,
+ formValue: {
+ "address-level1": "Ariz",
+ "street-addr": "331 E. Evelyn Avenue",
+ country: "US",
+ },
+ expectedResult: {
+ formSubmission: true,
+ records: {
+ address: [
+ {
+ guid: null,
+ record: {
+ "address-level1": "Arizonac",
+ "address-level2": "",
+ "street-address": "331 E. Evelyn Avenue",
+ country: "US",
+ email: "",
+ tel: "",
+ },
+ untouchedFields: [],
+ },
+ ],
+ creditCard: [],
+ },
+ },
+ },
+ {
+ description: "Shouldn't save select with multiple selections",
+ document: DEFAULT_TEST_DOC,
+ targetElementId: TARGET_ELEMENT_ID,
+ formValue: {
+ "address-level1": ["AL", "AK", "AP"],
+ "street-addr": "331 E. Evelyn Avenue",
+ country: "US",
+ tel: "1-650-903-0800",
+ },
+ expectedResult: {
+ formSubmission: true,
+ records: {
+ address: [
+ {
+ guid: null,
+ record: {
+ "street-address": "331 E. Evelyn Avenue",
+ "address-level1": "",
+ "address-level2": "",
+ country: "US",
+ tel: "1-650-903-0800",
+ email: "",
+ },
+ untouchedFields: [],
+ },
+ ],
+ creditCard: [],
+ },
+ },
+ },
+ {
+ description: "Shouldn't save select with empty value",
+ document: DEFAULT_TEST_DOC,
+ targetElementId: TARGET_ELEMENT_ID,
+ formValue: {
+ "address-level1": "",
+ "street-addr": "331 E. Evelyn Avenue",
+ country: "US",
+ tel: "1-650-903-0800",
+ },
+ expectedResult: {
+ formSubmission: true,
+ records: {
+ address: [
+ {
+ guid: null,
+ record: {
+ "street-address": "331 E. Evelyn Avenue",
+ "address-level1": "",
+ "address-level2": "",
+ country: "US",
+ tel: "1-650-903-0800",
+ email: "",
+ },
+ untouchedFields: [],
+ },
+ ],
+ creditCard: [],
+ },
+ },
+ },
+ {
+ description: "Shouldn't save tel whose length is too short",
+ document: DEFAULT_TEST_DOC,
+ targetElementId: TARGET_ELEMENT_ID,
+ formValue: {
+ "street-addr": "331 E. Evelyn Avenue",
+ "address-level1": "CA",
+ country: "US",
+ tel: "1234",
+ },
+ expectedResult: {
+ formSubmission: true,
+ records: {
+ address: [
+ {
+ guid: null,
+ record: {
+ "street-address": "331 E. Evelyn Avenue",
+ "address-level1": "CA",
+ "address-level2": "",
+ country: "US",
+ tel: "",
+ email: "",
+ },
+ untouchedFields: [],
+ },
+ ],
+ creditCard: [],
+ },
+ },
+ },
+ {
+ description: "Shouldn't save tel whose length is too long",
+ document: DEFAULT_TEST_DOC,
+ targetElementId: TARGET_ELEMENT_ID,
+ formValue: {
+ "street-addr": "331 E. Evelyn Avenue",
+ "address-level1": "CA",
+ country: "US",
+ tel: "1234567890123456",
+ },
+ expectedResult: {
+ formSubmission: true,
+ records: {
+ address: [
+ {
+ guid: null,
+ record: {
+ "street-address": "331 E. Evelyn Avenue",
+ "address-level1": "CA",
+ "address-level2": "",
+ country: "US",
+ tel: "",
+ email: "",
+ },
+ untouchedFields: [],
+ },
+ ],
+ creditCard: [],
+ },
+ },
+ },
+ {
+ description: "Shouldn't save tel which contains invalid characters",
+ document: DEFAULT_TEST_DOC,
+ targetElementId: TARGET_ELEMENT_ID,
+ formValue: {
+ "street-addr": "331 E. Evelyn Avenue",
+ "address-level1": "CA",
+ country: "US",
+ tel: "12345###!!",
+ },
+ expectedResult: {
+ formSubmission: true,
+ records: {
+ address: [
+ {
+ guid: null,
+ record: {
+ "street-address": "331 E. Evelyn Avenue",
+ "address-level1": "CA",
+ "address-level2": "",
+ country: "US",
+ tel: "",
+ email: "",
+ },
+ untouchedFields: [],
+ },
+ ],
+ creditCard: [],
+ },
+ },
+ },
+];
+
+add_task(async function handle_invalid_form() {
+ info("Starting testcase: Test an invalid form element");
+ let doc = MockDocument.createTestDocument(
+ "http://localhost:8080/test",
+ DEFAULT_TEST_DOC
+ );
+ let fakeForm = doc.createElement("form");
+ sinon.spy(FormAutofillContent, "_onFormSubmit");
+
+ FormAutofillContent.formSubmitted(fakeForm, null);
+ Assert.equal(FormAutofillContent._onFormSubmit.called, false);
+ FormAutofillContent._onFormSubmit.restore();
+});
+
+add_task(async function autofill_disabled() {
+ let doc = MockDocument.createTestDocument(
+ "http://localhost:8080/test",
+ DEFAULT_TEST_DOC
+ );
+ let form = doc.getElementById("form1");
+ form.reset();
+
+ let testcase = {
+ "street-addr": "331 E. Evelyn Avenue",
+ country: "US",
+ tel: "+16509030800",
+ "cc-number": "1111222233334444",
+ };
+ for (let key in testcase) {
+ let input = doc.getElementById(key);
+ input.value = testcase[key];
+ }
+
+ let element = doc.getElementById(TARGET_ELEMENT_ID);
+ FormAutofillContent.identifyAutofillFields(element);
+
+ sinon.stub(FormAutofillContent, "_onFormSubmit");
+
+ // "_onFormSubmit" shouldn't be called if both "addresses" and "creditCards"
+ // are disabled.
+ Services.prefs.setBoolPref(
+ "extensions.formautofill.addresses.enabled",
+ false
+ );
+ Services.prefs.setBoolPref(
+ "extensions.formautofill.creditCards.enabled",
+ false
+ );
+ FormAutofillContent.formSubmitted(form, null);
+ Assert.equal(FormAutofillContent._onFormSubmit.called, false);
+ FormAutofillContent._onFormSubmit.resetHistory();
+
+ // "_onFormSubmit" should be called as usual.
+ Services.prefs.clearUserPref("extensions.formautofill.addresses.enabled");
+ Services.prefs.clearUserPref("extensions.formautofill.creditCards.enabled");
+
+ Services.prefs.setBoolPref(
+ "extensions.formautofill.creditCards.enabled",
+ true
+ );
+
+ FormAutofillContent.formSubmitted(form, null);
+ Assert.equal(FormAutofillContent._onFormSubmit.called, true);
+ Assert.notDeepEqual(FormAutofillContent._onFormSubmit.args[0][0].address, []);
+ Assert.notDeepEqual(
+ FormAutofillContent._onFormSubmit.args[0][0].creditCard,
+ []
+ );
+ FormAutofillContent._onFormSubmit.resetHistory();
+
+ // "address" should be empty if "addresses" pref is disabled.
+ Services.prefs.setBoolPref(
+ "extensions.formautofill.addresses.enabled",
+ false
+ );
+ FormAutofillContent.formSubmitted(form, null);
+ Assert.equal(FormAutofillContent._onFormSubmit.called, true);
+ Assert.deepEqual(FormAutofillContent._onFormSubmit.args[0][0].address, []);
+ Assert.notDeepEqual(
+ FormAutofillContent._onFormSubmit.args[0][0].creditCard,
+ []
+ );
+ FormAutofillContent._onFormSubmit.resetHistory();
+ Services.prefs.clearUserPref("extensions.formautofill.addresses.enabled");
+
+ // "creditCard" should be empty if "creditCards" pref is disabled.
+ Services.prefs.setBoolPref(
+ "extensions.formautofill.creditCards.enabled",
+ false
+ );
+ FormAutofillContent.formSubmitted(form, null);
+ Assert.deepEqual(FormAutofillContent._onFormSubmit.called, true);
+ Assert.notDeepEqual(FormAutofillContent._onFormSubmit.args[0][0].address, []);
+ Assert.deepEqual(FormAutofillContent._onFormSubmit.args[0][0].creditCard, []);
+ FormAutofillContent._onFormSubmit.resetHistory();
+ Services.prefs.clearUserPref("extensions.formautofill.creditCards.enabled");
+
+ FormAutofillContent._onFormSubmit.restore();
+});
+
+TESTCASES.forEach(testcase => {
+ add_task(async function check_records_saving_is_called_correctly() {
+ info("Starting testcase: " + testcase.description);
+
+ Services.prefs.setBoolPref(
+ "extensions.formautofill.creditCards.enabled",
+ true
+ );
+ let doc = MockDocument.createTestDocument(
+ "http://localhost:8080/test/",
+ testcase.document
+ );
+ let form = doc.getElementById("form1");
+ form.reset();
+ for (let key in testcase.formValue) {
+ let input = doc.getElementById(key);
+ let value = testcase.formValue[key];
+ if (ChromeUtils.getClassName(input) === "HTMLSelectElement" && value) {
+ input.multiple = Array.isArray(value);
+ [...input.options].forEach(option => {
+ option.selected = value.includes(option.value);
+ });
+ } else {
+ input.value = testcase.formValue[key];
+ }
+ }
+ sinon.stub(FormAutofillContent, "_onFormSubmit");
+
+ let element = doc.getElementById(testcase.targetElementId);
+ FormAutofillContent.identifyAutofillFields(element);
+ FormAutofillContent.formSubmitted(form, null);
+
+ Assert.equal(
+ FormAutofillContent._onFormSubmit.called,
+ testcase.expectedResult.formSubmission,
+ "Check expected onFormSubmit.called"
+ );
+ if (FormAutofillContent._onFormSubmit.called) {
+ for (let ccRecord of FormAutofillContent._onFormSubmit.args[0][0]
+ .creditCard) {
+ delete ccRecord.flowId;
+ }
+ for (let addrRecord of FormAutofillContent._onFormSubmit.args[0][0]
+ .address) {
+ delete addrRecord.flowId;
+ }
+
+ Assert.deepEqual(
+ FormAutofillContent._onFormSubmit.args[0][0],
+ testcase.expectedResult.records
+ );
+ }
+ FormAutofillContent._onFormSubmit.restore();
+ Services.prefs.clearUserPref("extensions.formautofill.creditCards.enabled");
+ });
+});
diff --git a/browser/extensions/formautofill/test/unit/test_parseAddressFormat.js b/browser/extensions/formautofill/test/unit/test_parseAddressFormat.js
new file mode 100644
index 0000000000..df6c3e3069
--- /dev/null
+++ b/browser/extensions/formautofill/test/unit/test_parseAddressFormat.js
@@ -0,0 +1,66 @@
+"use strict";
+
+var FormAutofillUtils;
+add_setup(async () => {
+ ({ FormAutofillUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/shared/FormAutofillUtils.sys.mjs"
+ ));
+});
+
+add_task(async function test_parseAddressFormat() {
+ const TEST_CASES = [
+ {
+ fmt: "%N%n%O%n%A%n%C, %S %Z", // US
+ parsed: [
+ { fieldId: "name", newLine: true },
+ { fieldId: "organization", newLine: true },
+ { fieldId: "street-address", newLine: true },
+ { fieldId: "address-level2" },
+ { fieldId: "address-level1" },
+ { fieldId: "postal-code" },
+ ],
+ },
+ {
+ fmt: "%N%n%O%n%A%n%C %S %Z", // CA
+ parsed: [
+ { fieldId: "name", newLine: true },
+ { fieldId: "organization", newLine: true },
+ { fieldId: "street-address", newLine: true },
+ { fieldId: "address-level2" },
+ { fieldId: "address-level1" },
+ { fieldId: "postal-code" },
+ ],
+ },
+ {
+ fmt: "%N%n%O%n%A%n%Z %C", // DE
+ parsed: [
+ { fieldId: "name", newLine: true },
+ { fieldId: "organization", newLine: true },
+ { fieldId: "street-address", newLine: true },
+ { fieldId: "postal-code" },
+ { fieldId: "address-level2" },
+ ],
+ },
+ {
+ fmt: "%N%n%O%n%A%n%D%n%C%n%S %Z", // IE
+ parsed: [
+ { fieldId: "name", newLine: true },
+ { fieldId: "organization", newLine: true },
+ { fieldId: "street-address", newLine: true },
+ { fieldId: "address-level3", newLine: true },
+ { fieldId: "address-level2", newLine: true },
+ { fieldId: "address-level1" },
+ { fieldId: "postal-code" },
+ ],
+ },
+ ];
+
+ Assert.throws(
+ () => FormAutofillUtils.parseAddressFormat(),
+ /fmt string is missing./,
+ "Should throw if fmt is empty"
+ );
+ for (let tc of TEST_CASES) {
+ Assert.deepEqual(FormAutofillUtils.parseAddressFormat(tc.fmt), tc.parsed);
+ }
+});
diff --git a/browser/extensions/formautofill/test/unit/test_parseStreetAddress.js b/browser/extensions/formautofill/test/unit/test_parseStreetAddress.js
new file mode 100644
index 0000000000..dc924d2ce8
--- /dev/null
+++ b/browser/extensions/formautofill/test/unit/test_parseStreetAddress.js
@@ -0,0 +1,74 @@
+"use strict";
+
+const { AddressParser } = ChromeUtils.import(
+ "resource://gre/modules/shared/AddressParser.jsm"
+);
+
+// To add a new test entry to a "TESTCASES" variable,
+// you would need to create a new array containing two elements.
+// - The first element is a string representing a street address to be parsed.
+// - The second element is an array containing the expected output after parsing the address,
+// which should follow the format of
+// [street number, street name, apartment number (if applicable), floor number (if applicable)].
+//
+// Note. If we expect the passed street address cannot be parsed, set the second element to null.
+const TESTCASES = [
+ ["123 Main St. Apt 4, Floor 2", [123, "Main St.", "4", 2]],
+ ["32 Vassar Street MIT Room 4", [32, "Vassar Street MIT", "4", null]],
+ ["32 Vassar Street MIT", [32, "Vassar Street MIT", null, null]],
+ [
+ "32 Vassar Street MIT Room 32-G524",
+ [32, "Vassar Street MIT", "32-G524", null],
+ ],
+ ["163 W Hastings\nSuite 209", [163, "W Hastings", "209", null]],
+ ["1234 Elm St. Apt 4, Floor 2", [1234, "Elm St.", "4", 2]],
+ ["456 Oak Drive, Unit 2A", [456, "Oak Drive", "2A", null]],
+ ["789 Maple Ave, Suite 300", [789, "Maple Ave", "300", null]],
+ ["321 Willow Lane, #5", [321, "Willow Lane", "5", null]],
+ ["654 Pine Circle, Apt B", [654, "Pine Circle", "B", null]],
+ ["987 Birch Court, 3rd Floor", [987, "Birch Court", null, 3]],
+ ["234 Cedar Way, Unit 6-2", [234, "Cedar Way", "6-2", null]],
+ ["345 Cherry St, Ste 12", [345, "Cherry St", "12", null]],
+ ["234 Palm St, Bldg 1, Apt 12", null],
+];
+
+add_task(async function test_parseStreetAddress() {
+ for (const TEST of TESTCASES) {
+ let [address, expected] = TEST;
+ const result = AddressParser.parseStreetAddress(address);
+ if (!expected) {
+ Assert.equal(result, null, "Expect failure to parse this street address");
+ continue;
+ }
+
+ const options = {
+ trim: true,
+ ignore_case: true,
+ };
+
+ const expectedSN = AddressParser.normalizeString(expected[0], options);
+ Assert.equal(
+ result.street_number,
+ expectedSN,
+ `expect street number to be ${expectedSN}, but got ${result.street_number}`
+ );
+ const expectedSNA = AddressParser.normalizeString(expected[1], options);
+ Assert.equal(
+ result.street_name,
+ expectedSNA,
+ `expect street name to be ${expectedSNA}, but got ${result.street_name}`
+ );
+ const expectedAN = AddressParser.normalizeString(expected[2], options);
+ Assert.equal(
+ result.apartment_number,
+ expectedAN,
+ `expect apartment number to be ${expectedAN}, but got ${result.apartment_number}`
+ );
+ const expectedFN = AddressParser.normalizeString(expected[3], options);
+ Assert.equal(
+ result.floor_number,
+ expectedFN,
+ `expect floor number to be ${expectedFN}, but got ${result.floor_number}`
+ );
+ }
+});
diff --git a/browser/extensions/formautofill/test/unit/test_phoneNumber.js b/browser/extensions/formautofill/test/unit/test_phoneNumber.js
new file mode 100644
index 0000000000..1c1d67e166
--- /dev/null
+++ b/browser/extensions/formautofill/test/unit/test_phoneNumber.js
@@ -0,0 +1,399 @@
+/**
+ * Tests PhoneNumber.jsm and PhoneNumberNormalizer.jsm.
+ */
+
+"use strict";
+
+var PhoneNumber, PhoneNumberNormalizer;
+add_setup(async () => {
+ ({ PhoneNumber } = ChromeUtils.importESModule(
+ "resource://autofill/phonenumberutils/PhoneNumber.sys.mjs"
+ ));
+ ({ PhoneNumberNormalizer } = ChromeUtils.importESModule(
+ "resource://autofill/phonenumberutils/PhoneNumberNormalizer.sys.mjs"
+ ));
+});
+
+function IsPlain(dial, expected) {
+ let result = PhoneNumber.IsPlain(dial);
+ Assert.equal(result, expected);
+}
+
+function Normalize(dial, expected) {
+ let result = PhoneNumberNormalizer.Normalize(dial);
+ Assert.equal(result, expected);
+}
+
+function CantParse(dial, currentRegion) {
+ let result = PhoneNumber.Parse(dial, currentRegion);
+ Assert.equal(null, result);
+}
+
+function Parse(dial, currentRegion) {
+ let result = PhoneNumber.Parse(dial, currentRegion);
+ Assert.notEqual(result, null);
+ return result;
+}
+
+function Test(dial, currentRegion, nationalNumber, region) {
+ let result = Parse(dial, currentRegion);
+ Assert.equal(result.nationalNumber, nationalNumber);
+ Assert.equal(result.region, region);
+ return result;
+}
+
+function TestProperties(dial, currentRegion) {
+ let result = Parse(dial, currentRegion);
+ Assert.ok(result.internationalFormat);
+ Assert.ok(result.internationalNumber);
+ Assert.ok(result.nationalFormat);
+ Assert.ok(result.nationalNumber);
+ Assert.ok(result.countryName);
+ Assert.ok(result.countryCode);
+}
+
+function Format(
+ dial,
+ currentRegion,
+ nationalNumber,
+ region,
+ nationalFormat,
+ internationalFormat
+) {
+ let result = Test(dial, currentRegion, nationalNumber, region);
+ Assert.equal(result.nationalFormat, nationalFormat);
+ Assert.equal(result.internationalFormat, internationalFormat);
+ return result;
+}
+
+function AllEqual(list, currentRegion) {
+ let parsedList = list.map(item => Parse(item, currentRegion));
+ let firstItem = parsedList.shift();
+ for (let item of parsedList) {
+ Assert.deepEqual(item, firstItem);
+ }
+}
+
+add_task(async function test_phoneNumber() {
+ // Test whether could a string be a phone number.
+ IsPlain(null, false);
+ IsPlain("", false);
+ IsPlain("1", true);
+ IsPlain("*2", true); // Real number used in Venezuela
+ IsPlain("*8", true); // Real number used in Venezuela
+ IsPlain("12", true);
+ IsPlain("123", true);
+ IsPlain("1a2", false);
+ IsPlain("12a", false);
+ IsPlain("1234", true);
+ IsPlain("123a", false);
+ IsPlain("+", true);
+ IsPlain("+1", true);
+ IsPlain("+12", true);
+ IsPlain("+123", true);
+ IsPlain("()123", false);
+ IsPlain("(1)23", false);
+ IsPlain("(12)3", false);
+ IsPlain("(123)", false);
+ IsPlain("(123)4", false);
+ IsPlain("(123)4", false);
+ IsPlain("123;ext=", false);
+ IsPlain("123;ext=1", false);
+ IsPlain("123;ext=1234567", false);
+ IsPlain("123;ext=12345678", false);
+ IsPlain("123 ext:1", false);
+ IsPlain("123 ext:1#", false);
+ IsPlain("123-1#", false);
+ IsPlain("123 1#", false);
+ IsPlain("123 12345#", false);
+ IsPlain("123 +123456#", false);
+
+ // Getting international number back from intl number.
+ TestProperties("+19497262896");
+
+ // Test parsing national numbers.
+ Parse("033316005", "NZ");
+ Parse("03-331 6005", "NZ");
+ Parse("03 331 6005", "NZ");
+ // Testing international prefixes.
+ // Should strip country code.
+ Parse("0064 3 331 6005", "NZ");
+
+ // Test CA before US because CA has to import meta-information for US.
+ Parse("4031234567", "CA");
+ Parse("(416) 585-4319", "CA");
+ Parse("647-967-4357", "CA");
+ Parse("416-716-8768", "CA");
+ Parse("18002684646", "CA");
+ Parse("416-445-9119", "CA");
+ Parse("1-800-668-6866", "CA");
+ Parse("(416) 453-6486", "CA");
+ Parse("(647) 268-4778", "CA");
+ Parse("647-218-1313", "CA");
+ Parse("+1 647-209-4642", "CA");
+ Parse("416-559-0133", "CA");
+ Parse("+1 647-639-4118", "CA");
+ Parse("+12898803664", "CA");
+ Parse("780-901-4687", "CA");
+ Parse("+14167070550", "CA");
+ Parse("+1-647-522-6487", "CA");
+ Parse("(416) 877-0880", "CA");
+
+ // Try again, but this time we have an international number with region rode US. It should
+ // recognize the country code and parse accordingly.
+ Parse("01164 3 331 6005", "US");
+ Parse("+64 3 331 6005", "US");
+ Parse("64(0)64123456", "NZ");
+ // Check that using a "/" is fine in a phone number.
+ Parse("123/45678", "DE");
+ Parse("123-456-7890", "US");
+
+ // Test parsing international numbers.
+ Parse("+1 (650) 333-6000", "NZ");
+ Parse("1-650-333-6000", "US");
+ // Calling the US number from Singapore by using different service providers
+ // 1st test: calling using SingTel IDD service (IDD is 001)
+ Parse("0011-650-333-6000", "SG");
+ // 2nd test: calling using StarHub IDD service (IDD is 008)
+ Parse("0081-650-333-6000", "SG");
+ // 3rd test: calling using SingTel V019 service (IDD is 019)
+ Parse("0191-650-333-6000", "SG");
+ // Calling the US number from Poland
+ Parse("0~01-650-333-6000", "PL");
+ // Using "++" at the start.
+ Parse("++1 (650) 333-6000", "PL");
+ // Using a full-width plus sign.
+ Parse("\uFF0B1 (650) 333-6000", "SG");
+ // The whole number, including punctuation, is here represented in full-width form.
+ Parse(
+ "\uFF0B\uFF11\u3000\uFF08\uFF16\uFF15\uFF10\uFF09" +
+ "\u3000\uFF13\uFF13\uFF13\uFF0D\uFF16\uFF10\uFF10\uFF10",
+ "SG"
+ );
+
+ // Test parsing with leading zeros.
+ Parse("+39 02-36618 300", "NZ");
+ Parse("02-36618 300", "IT");
+ Parse("312 345 678", "IT");
+
+ // Test parsing numbers in Argentina.
+ Parse("+54 9 343 555 1212", "AR");
+ Parse("0343 15 555 1212", "AR");
+ Parse("+54 9 3715 65 4320", "AR");
+ Parse("03715 15 65 4320", "AR");
+ Parse("+54 11 3797 0000", "AR");
+ Parse("011 3797 0000", "AR");
+ Parse("+54 3715 65 4321", "AR");
+ Parse("03715 65 4321", "AR");
+ Parse("+54 23 1234 0000", "AR");
+ Parse("023 1234 0000", "AR");
+
+ // Test numbers in Mexico
+ Parse("+52 (449)978-0001", "MX");
+ Parse("01 (449)978-0001", "MX");
+ Parse("(449)978-0001", "MX");
+ Parse("+52 1 33 1234-5678", "MX");
+ Parse("044 (33) 1234-5678", "MX");
+ Parse("045 33 1234-5678", "MX");
+
+ // Test that lots of spaces are ok.
+ Parse("0 3 3 3 1 6 0 0 5", "NZ");
+
+ // Test omitting the current region. This is only valid when the number starts
+ // with a '+'.
+ Parse("+64 3 331 6005");
+ Parse("+64 3 331 6005", null);
+
+ // US numbers
+ Format(
+ "19497261234",
+ "US",
+ "9497261234",
+ "US",
+ "(949) 726-1234",
+ "+1 949-726-1234"
+ );
+
+ // Try a couple german numbers from the US with various access codes.
+ Format(
+ "49451491934",
+ "US",
+ "0451491934",
+ "DE",
+ "0451 491934",
+ "+49 451 491934"
+ );
+ Format(
+ "+49451491934",
+ "US",
+ "0451491934",
+ "DE",
+ "0451 491934",
+ "+49 451 491934"
+ );
+ Format(
+ "01149451491934",
+ "US",
+ "0451491934",
+ "DE",
+ "0451 491934",
+ "+49 451 491934"
+ );
+
+ // Now try dialing the same number from within the German region.
+ Format(
+ "451491934",
+ "DE",
+ "0451491934",
+ "DE",
+ "0451 491934",
+ "+49 451 491934"
+ );
+ Format(
+ "0451491934",
+ "DE",
+ "0451491934",
+ "DE",
+ "0451 491934",
+ "+49 451 491934"
+ );
+
+ // Numbers in italy keep the leading 0 in the city code when dialing internationally.
+ Format(
+ "0577-555-555",
+ "IT",
+ "0577555555",
+ "IT",
+ "05 7755 5555",
+ "+39 05 7755 5555"
+ );
+
+ // Colombian international number without the leading "+"
+ Format("5712234567", "CO", "12234567", "CO", "(1) 2234567", "+57 1 2234567");
+
+ // Telefonica tests
+ Format(
+ "612123123",
+ "ES",
+ "612123123",
+ "ES",
+ "612 12 31 23",
+ "+34 612 12 31 23"
+ );
+
+ // Chile mobile number from a landline
+ Format(
+ "0997654321",
+ "CL",
+ "997654321",
+ "CL",
+ "(99) 765 4321",
+ "+56 99 765 4321"
+ );
+
+ // Chile mobile number from another mobile number
+ Format(
+ "997654321",
+ "CL",
+ "997654321",
+ "CL",
+ "(99) 765 4321",
+ "+56 99 765 4321"
+ );
+
+ // Dialing 911 in the US. This is not a national number.
+ CantParse("911", "US");
+
+ // China mobile number with a 0 in it
+ Format(
+ "15955042864",
+ "CN",
+ "015955042864",
+ "CN",
+ "0159 5504 2864",
+ "+86 159 5504 2864"
+ );
+
+ // Testing international region numbers.
+ CantParse("883510000000091", "001");
+ Format(
+ "+883510000000092",
+ "001",
+ "510000000092",
+ "001",
+ "510 000 000 092",
+ "+883 510 000 000 092"
+ );
+ Format(
+ "883510000000093",
+ "FR",
+ "510000000093",
+ "001",
+ "510 000 000 093",
+ "+883 510 000 000 093"
+ );
+ Format(
+ "+883510000000094",
+ "FR",
+ "510000000094",
+ "001",
+ "510 000 000 094",
+ "+883 510 000 000 094"
+ );
+ Format(
+ "883510000000095",
+ "US",
+ "510000000095",
+ "001",
+ "510 000 000 095",
+ "+883 510 000 000 095"
+ );
+ Format(
+ "+883510000000096",
+ "US",
+ "510000000096",
+ "001",
+ "510 000 000 096",
+ "+883 510 000 000 096"
+ );
+ CantParse("979510000012", "001");
+ Format(
+ "+979510000012",
+ "001",
+ "510000012",
+ "001",
+ "5 1000 0012",
+ "+979 5 1000 0012"
+ );
+
+ // Test normalizing numbers. Only 0-9,#* are valid in a phone number.
+ Normalize("+ABC # * , 9 _ 1 _0", "+222#*,910");
+ Normalize("ABCDEFGHIJKLMNOPQRSTUVWXYZ", "22233344455566677778889999");
+ Normalize("abcdefghijklmnopqrstuvwxyz", "22233344455566677778889999");
+
+ // 8 and 9 digit numbers with area code in Brazil with collect call prefix (90)
+ AllEqual(
+ [
+ "01187654321",
+ "0411187654321",
+ "551187654321",
+ "90411187654321",
+ "+551187654321",
+ ],
+ "BR"
+ );
+ AllEqual(
+ [
+ "011987654321",
+ "04111987654321",
+ "5511987654321",
+ "904111987654321",
+ "+5511987654321",
+ ],
+ "BR"
+ );
+
+ Assert.equal(PhoneNumberNormalizer.Normalize("123abc", true), "123");
+ Assert.equal(PhoneNumberNormalizer.Normalize("12345", true), "12345");
+ Assert.equal(PhoneNumberNormalizer.Normalize("1abcd", false), "12223");
+});
diff --git a/browser/extensions/formautofill/test/unit/test_previewFormFields.js b/browser/extensions/formautofill/test/unit/test_previewFormFields.js
new file mode 100644
index 0000000000..a75f24de20
--- /dev/null
+++ b/browser/extensions/formautofill/test/unit/test_previewFormFields.js
@@ -0,0 +1,199 @@
+/* Any copyright is dedicated to the Public Domain.
+http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Bug 1762063 - we need to fix this pattern of having to wrap destructuring calls in parentheses.
+// We can't do a standard destructuring call because FormAutofillUtils is already declared as a var in head.js
+({ FormAutofillUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/shared/FormAutofillUtils.sys.mjs"
+));
+const { FIELD_STATES } = FormAutofillUtils;
+const PREVIEW = FIELD_STATES.PREVIEW;
+const NORMAL = FIELD_STATES.NORMAL;
+
+const { OSKeyStore } = ChromeUtils.importESModule(
+ "resource://gre/modules/OSKeyStore.sys.mjs"
+);
+
+const TESTCASES = [
+ {
+ description: "Preview best case address form",
+ 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">
+ </form>`,
+ focusedInputId: "given-name",
+ profileData: {
+ "given-name": "John",
+ "family-name": "Doe",
+ "street-address": "100 Main Street",
+ "address-level2": "Hamilton",
+ },
+ expectedResultState: {
+ "given-name": PREVIEW,
+ "family-name": PREVIEW,
+ "street-address": PREVIEW,
+ "address-level2": PREVIEW,
+ },
+ },
+ {
+ description: "Preview form with a readonly input and non-readonly inputs",
+ 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" readonly value="TEST CITY">
+ </form>`,
+ focusedInputId: "given-name",
+ profileData: {
+ "given-name": "John",
+ "family-name": "Doe",
+ "street-address": "100 Main Street",
+ city: "Hamilton",
+ },
+ expectedResultState: {
+ "given-name": PREVIEW,
+ "family-name": PREVIEW,
+ "street-address": PREVIEW,
+ "address-level2": undefined,
+ },
+ },
+ {
+ description: "Preview form with a disabled input and non-disabled inputs",
+ 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="country" autocomplete="country" disabled value="US">
+ </form>`,
+ focusedInputId: "given-name",
+ profileData: {
+ "given-name": "John",
+ "family-name": "Doe",
+ "street-address": "100 Main Street",
+ country: "CA",
+ },
+ expectedResultState: {
+ "given-name": PREVIEW,
+ "family-name": PREVIEW,
+ "street-address": PREVIEW,
+ country: undefined,
+ },
+ },
+ {
+ description:
+ "Preview form with autocomplete select elements and matching option values",
+ document: `<form>
+ <input id="given-name" autocomplete="shipping given-name">
+ <select id="country" autocomplete="shipping country">
+ <option value=""></option>
+ <option value="US">United States</option>
+ </select>
+ <select id="state" autocomplete="shipping address-level1">
+ <option value=""></option>
+ <option value="CA">California</option>
+ <option value="WA">Washington</option>
+ </select>
+ </form>`,
+ focusedInputId: "given-name",
+ profileData: {
+ country: "US",
+ "address-level1": "CA",
+ },
+ expectedResultState: {
+ "given-name": NORMAL,
+ country: PREVIEW,
+ "address-level1": PREVIEW,
+ },
+ },
+ {
+ description: "Preview best case credit card form",
+ document: `<form>
+ <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">
+ <input id="cc-csc" autocomplete="cc-csc">
+ </form>
+ `,
+ focusedInputId: "cc-number",
+ profileData: {
+ guid: "123",
+ "cc-number": "4111111111111111",
+ "cc-name": "test name",
+ "cc-exp-month": 6,
+ "cc-exp-year": 25,
+ },
+ expectedResultState: {
+ "cc-number": PREVIEW,
+ "cc-name": PREVIEW,
+ "cc-exp-month": PREVIEW,
+ "cc-exp-year": PREVIEW,
+ "cc-csc": NORMAL,
+ },
+ },
+];
+
+function run_tests(testcases) {
+ for (let testcase of testcases) {
+ add_task(async function () {
+ info("Starting testcase: " + testcase.description);
+ let doc = MockDocument.createTestDocument(
+ "http://localhost:8080/test/",
+ testcase.document
+ );
+ let form = doc.querySelector("form");
+ let formLike = FormLikeFactory.createFromForm(form);
+ let handler = new FormAutofillHandler(formLike);
+
+ // Replace the internal decrypt method with OSKeyStore API,
+ // but don't pass the reauth parameter to avoid triggering
+ // reauth login dialog in these tests.
+ let decryptHelper = async (cipherText, reauth) => {
+ return OSKeyStore.decrypt(cipherText, false);
+ };
+ handler.collectFormFields();
+
+ let focusedInput = doc.getElementById(testcase.focusedInputId);
+ try {
+ handler.focusedInput = focusedInput;
+ } catch (e) {
+ if (e.message.includes("WeakMap key must be an object")) {
+ throw new Error(
+ `Couldn't find the focusedInputId in the current form! Make sure focusedInputId exists in your test form! testcase description:${testcase.description}`
+ );
+ } else {
+ throw e;
+ }
+ }
+
+ for (let section of handler.sections) {
+ section._decrypt = decryptHelper;
+ }
+
+ let [adaptedProfile] = handler.activeSection.getAdaptedProfiles([
+ testcase.profileData,
+ ]);
+
+ await handler.activeSection.previewFormFields(adaptedProfile);
+
+ for (let field of handler.fieldDetails) {
+ let actual = handler.getFilledStateByElement(
+ field.elementWeakRef.get()
+ );
+ let expected = testcase.expectedResultState[field.fieldName];
+ info(`Checking ${field.fieldName} state`);
+ Assert.equal(
+ actual,
+ expected,
+ "Check if preview state is set correctly"
+ );
+ }
+ });
+ }
+}
+
+run_tests(TESTCASES);
diff --git a/browser/extensions/formautofill/test/unit/test_profileAutocompleteResult.js b/browser/extensions/formautofill/test/unit/test_profileAutocompleteResult.js
new file mode 100644
index 0000000000..7a28f64634
--- /dev/null
+++ b/browser/extensions/formautofill/test/unit/test_profileAutocompleteResult.js
@@ -0,0 +1,450 @@
+"use strict";
+
+var AddressResult, CreditCardResult;
+add_setup(async () => {
+ ({ AddressResult, CreditCardResult } = ChromeUtils.importESModule(
+ "resource://autofill/ProfileAutoCompleteResult.sys.mjs"
+ ));
+});
+
+let matchingProfiles = [
+ {
+ guid: "test-guid-1",
+ "given-name": "Timothy",
+ "family-name": "Berners-Lee",
+ name: "Timothy Berners-Lee",
+ organization: "Sesame Street",
+ "street-address": "123 Sesame Street.",
+ "address-line1": "123 Sesame Street.",
+ tel: "1-345-345-3456.",
+ },
+ {
+ guid: "test-guid-2",
+ "given-name": "John",
+ "family-name": "Doe",
+ name: "John Doe",
+ organization: "Mozilla",
+ "street-address": "331 E. Evelyn Avenue",
+ "address-line1": "331 E. Evelyn Avenue",
+ tel: "1-650-903-0800",
+ },
+ {
+ guid: "test-guid-3",
+ organization: "",
+ "street-address": "321, No Name St. 2nd line 3rd line",
+ "-moz-street-address-one-line": "321, No Name St. 2nd line 3rd line",
+ "address-line1": "321, No Name St.",
+ "address-line2": "2nd line",
+ "address-line3": "3rd line",
+ tel: "1-000-000-0000",
+ },
+];
+
+let allFieldNames = [
+ "given-name",
+ "family-name",
+ "street-address",
+ "address-line1",
+ "address-line2",
+ "address-line3",
+ "organization",
+ "tel",
+];
+
+let addressTestCases = [
+ {
+ description: "Focus on an `organization` field",
+ options: {},
+ matchingProfiles,
+ allFieldNames,
+ searchString: "",
+ fieldName: "organization",
+ expected: {
+ searchResult: Ci.nsIAutoCompleteResult.RESULT_SUCCESS,
+ defaultIndex: 0,
+ items: [
+ {
+ value: "",
+ style: "autofill-profile",
+ comment: JSON.stringify(matchingProfiles[0]),
+ label: JSON.stringify({
+ primary: "Sesame Street",
+ secondary: "123 Sesame Street.",
+ }),
+ image: "",
+ },
+ {
+ value: "",
+ style: "autofill-profile",
+ comment: JSON.stringify(matchingProfiles[1]),
+ label: JSON.stringify({
+ primary: "Mozilla",
+ secondary: "331 E. Evelyn Avenue",
+ }),
+ image: "",
+ },
+ ],
+ },
+ },
+ {
+ description: "Focus on an `tel` field",
+ options: {},
+ matchingProfiles,
+ allFieldNames,
+ searchString: "",
+ fieldName: "tel",
+ expected: {
+ searchResult: Ci.nsIAutoCompleteResult.RESULT_SUCCESS,
+ defaultIndex: 0,
+ items: [
+ {
+ value: "",
+ style: "autofill-profile",
+ comment: JSON.stringify(matchingProfiles[0]),
+ label: JSON.stringify({
+ primary: "1-345-345-3456.",
+ secondary: "123 Sesame Street.",
+ }),
+ image: "",
+ },
+ {
+ value: "",
+ style: "autofill-profile",
+ comment: JSON.stringify(matchingProfiles[1]),
+ label: JSON.stringify({
+ primary: "1-650-903-0800",
+ secondary: "331 E. Evelyn Avenue",
+ }),
+ image: "",
+ },
+ {
+ value: "",
+ style: "autofill-profile",
+ comment: JSON.stringify(matchingProfiles[2]),
+ label: JSON.stringify({
+ primary: "1-000-000-0000",
+ secondary: "321, No Name St. 2nd line 3rd line",
+ }),
+ image: "",
+ },
+ ],
+ },
+ },
+ {
+ description: "Focus on an `street-address` field",
+ options: {},
+ matchingProfiles,
+ allFieldNames,
+ searchString: "",
+ fieldName: "street-address",
+ expected: {
+ searchResult: Ci.nsIAutoCompleteResult.RESULT_SUCCESS,
+ defaultIndex: 0,
+ items: [
+ {
+ value: "",
+ style: "autofill-profile",
+ comment: JSON.stringify(matchingProfiles[0]),
+ label: JSON.stringify({
+ primary: "123 Sesame Street.",
+ secondary: "Timothy Berners-Lee",
+ }),
+ image: "",
+ },
+ {
+ value: "",
+ style: "autofill-profile",
+ comment: JSON.stringify(matchingProfiles[1]),
+ label: JSON.stringify({
+ primary: "331 E. Evelyn Avenue",
+ secondary: "John Doe",
+ }),
+ image: "",
+ },
+ {
+ value: "",
+ style: "autofill-profile",
+ comment: JSON.stringify(matchingProfiles[2]),
+ label: JSON.stringify({
+ primary: "321, No Name St. 2nd line 3rd line",
+ secondary: "1-000-000-0000",
+ }),
+ image: "",
+ },
+ ],
+ },
+ },
+ {
+ description: "Focus on an `address-line1` field",
+ options: {},
+ matchingProfiles,
+ allFieldNames,
+ searchString: "",
+ fieldName: "address-line1",
+ expected: {
+ searchResult: Ci.nsIAutoCompleteResult.RESULT_SUCCESS,
+ defaultIndex: 0,
+ items: [
+ {
+ value: "",
+ style: "autofill-profile",
+ comment: JSON.stringify(matchingProfiles[0]),
+ label: JSON.stringify({
+ primary: "123 Sesame Street.",
+ secondary: "Timothy Berners-Lee",
+ }),
+ image: "",
+ },
+ {
+ value: "",
+ style: "autofill-profile",
+ comment: JSON.stringify(matchingProfiles[1]),
+ label: JSON.stringify({
+ primary: "331 E. Evelyn Avenue",
+ secondary: "John Doe",
+ }),
+ image: "",
+ },
+ {
+ value: "",
+ style: "autofill-profile",
+ comment: JSON.stringify(matchingProfiles[2]),
+ label: JSON.stringify({
+ primary: "321, No Name St.",
+ secondary: "1-000-000-0000",
+ }),
+ image: "",
+ },
+ ],
+ },
+ },
+ {
+ description: "No matching profiles",
+ options: {},
+ matchingProfiles: [],
+ allFieldNames,
+ searchString: "",
+ fieldName: "",
+ expected: {
+ searchResult: Ci.nsIAutoCompleteResult.RESULT_NOMATCH,
+ defaultIndex: 0,
+ items: [],
+ },
+ },
+ {
+ description: "Search with failure",
+ options: { resultCode: Ci.nsIAutoCompleteResult.RESULT_FAILURE },
+ matchingProfiles: [],
+ allFieldNames,
+ searchString: "",
+ fieldName: "",
+ expected: {
+ searchResult: Ci.nsIAutoCompleteResult.RESULT_FAILURE,
+ defaultIndex: 0,
+ items: [],
+ },
+ },
+];
+
+matchingProfiles = [
+ {
+ guid: "test-guid-1",
+ "cc-name": "Timothy Berners-Lee",
+ "cc-number": "************6785",
+ "cc-exp-month": 12,
+ "cc-exp-year": 2014,
+ "cc-type": "visa",
+ },
+ {
+ guid: "test-guid-2",
+ "cc-name": "John Doe",
+ "cc-number": "************1234",
+ "cc-exp-month": 4,
+ "cc-exp-year": 2014,
+ "cc-type": "amex",
+ },
+ {
+ guid: "test-guid-3",
+ "cc-number": "************5678",
+ "cc-exp-month": 8,
+ "cc-exp-year": 2018,
+ },
+];
+
+allFieldNames = ["cc-name", "cc-number", "cc-exp-month", "cc-exp-year"];
+
+let creditCardTestCases = [
+ {
+ description: "Focus on a `cc-name` field",
+ options: {},
+ matchingProfiles,
+ allFieldNames,
+ searchString: "",
+ fieldName: "cc-name",
+ expected: {
+ searchResult: Ci.nsIAutoCompleteResult.RESULT_SUCCESS,
+ defaultIndex: 0,
+ items: [
+ {
+ value: "",
+ style: "autofill-profile",
+ comment: JSON.stringify(matchingProfiles[0]),
+ label: JSON.stringify({
+ primary: "Timothy Berners-Lee",
+ secondary: "****6785",
+ ariaLabel: "Visa Timothy Berners-Lee ****6785",
+ }),
+ image: "chrome://formautofill/content/third-party/cc-logo-visa.svg",
+ },
+ {
+ value: "",
+ style: "autofill-profile",
+ comment: JSON.stringify(matchingProfiles[1]),
+ label: JSON.stringify({
+ primary: "John Doe",
+ secondary: "****1234",
+ ariaLabel: "American Express John Doe ****1234",
+ }),
+ image: "chrome://formautofill/content/third-party/cc-logo-amex.png",
+ },
+ ],
+ },
+ },
+ {
+ description: "Focus on a `cc-number` field",
+ options: {},
+ matchingProfiles,
+ allFieldNames,
+ searchString: "",
+ fieldName: "cc-number",
+ expected: {
+ searchResult: Ci.nsIAutoCompleteResult.RESULT_SUCCESS,
+ defaultIndex: 0,
+ items: [
+ {
+ value: "",
+ style: "autofill-profile",
+ comment: JSON.stringify(matchingProfiles[0]),
+ label: JSON.stringify({
+ primaryAffix: "****",
+ primary: "6785",
+ secondary: "Timothy Berners-Lee",
+ ariaLabel: "Visa **** 6785 Timothy Berners-Lee",
+ }),
+ image: "chrome://formautofill/content/third-party/cc-logo-visa.svg",
+ },
+ {
+ value: "",
+ style: "autofill-profile",
+ comment: JSON.stringify(matchingProfiles[1]),
+ label: JSON.stringify({
+ primaryAffix: "****",
+ primary: "1234",
+ secondary: "John Doe",
+ ariaLabel: "American Express **** 1234 John Doe",
+ }),
+ image: "chrome://formautofill/content/third-party/cc-logo-amex.png",
+ },
+ {
+ value: "",
+ style: "autofill-profile",
+ comment: JSON.stringify(matchingProfiles[2]),
+ label: JSON.stringify({
+ primaryAffix: "****",
+ primary: "5678",
+ secondary: "",
+ ariaLabel: "**** 5678",
+ }),
+ image: "chrome://formautofill/content/icon-credit-card-generic.svg",
+ },
+ ],
+ },
+ },
+ {
+ description: "No matching profiles",
+ options: {},
+ matchingProfiles: [],
+ allFieldNames,
+ searchString: "",
+ fieldName: "",
+ expected: {
+ searchResult: Ci.nsIAutoCompleteResult.RESULT_NOMATCH,
+ defaultIndex: 0,
+ items: [],
+ },
+ },
+ {
+ description: "Search with failure",
+ options: { resultCode: Ci.nsIAutoCompleteResult.RESULT_FAILURE },
+ matchingProfiles: [],
+ allFieldNames,
+ searchString: "",
+ fieldName: "",
+ expected: {
+ searchResult: Ci.nsIAutoCompleteResult.RESULT_FAILURE,
+ defaultIndex: 0,
+ items: [],
+ },
+ },
+];
+
+add_task(async function test_all_patterns() {
+ let testSets = [
+ {
+ collectionConstructor: AddressResult,
+ testCases: addressTestCases,
+ },
+ {
+ collectionConstructor: CreditCardResult,
+ testCases: creditCardTestCases,
+ },
+ ];
+
+ testSets.forEach(({ collectionConstructor, testCases }) => {
+ testCases.forEach(testCase => {
+ info("Starting testcase: " + testCase.description);
+ let actual = new collectionConstructor(
+ testCase.searchString,
+ testCase.fieldName,
+ testCase.allFieldNames,
+ testCase.matchingProfiles,
+ testCase.options
+ );
+ let expectedValue = testCase.expected;
+ let expectedItemLength = expectedValue.items.length;
+ // If the last item shows up as a footer, we expect one more item
+ // than expected.
+ if (actual.getStyleAt(actual.matchCount - 1) == "autofill-footer") {
+ expectedItemLength++;
+ }
+
+ equal(actual.searchResult, expectedValue.searchResult);
+ equal(actual.defaultIndex, expectedValue.defaultIndex);
+ equal(actual.matchCount, expectedItemLength);
+ expectedValue.items.forEach((item, index) => {
+ equal(actual.getValueAt(index), item.value);
+ equal(actual.getCommentAt(index), item.comment);
+ equal(actual.getLabelAt(index), item.label);
+ equal(actual.getStyleAt(index), item.style);
+ equal(actual.getImageAt(index), item.image);
+ });
+
+ if (expectedValue.items.length) {
+ Assert.throws(
+ () => actual.getValueAt(expectedItemLength),
+ /Index out of range\./
+ );
+
+ Assert.throws(
+ () => actual.getLabelAt(expectedItemLength),
+ /Index out of range\./
+ );
+
+ Assert.throws(
+ () => actual.getCommentAt(expectedItemLength),
+ /Index out of range\./
+ );
+ }
+ });
+ });
+});
diff --git a/browser/extensions/formautofill/test/unit/test_reconcile.js b/browser/extensions/formautofill/test/unit/test_reconcile.js
new file mode 100644
index 0000000000..1700f89fe3
--- /dev/null
+++ b/browser/extensions/formautofill/test/unit/test_reconcile.js
@@ -0,0 +1,1173 @@
+"use strict";
+
+const TEST_STORE_FILE_NAME = "test-profile.json";
+const { CREDIT_CARD_SCHEMA_VERSION } = ChromeUtils.importESModule(
+ "resource://autofill/FormAutofillStorageBase.sys.mjs"
+);
+
+// NOTE: a guide to reading these test-cases:
+// parent: What the local record looked like the last time we wrote the
+// record to the Sync server.
+// local: What the local record looks like now. IOW, the differences between
+// 'parent' and 'local' are changes recently made which we wish to sync.
+// remote: An incoming record we need to apply (ie, a record that was possibly
+// changed on a remote device)
+//
+// To further help understanding this, a few of the testcases are annotated.
+const ADDRESS_RECONCILE_TESTCASES = [
+ {
+ description: "Local change",
+ parent: {
+ // So when we last wrote the record to the server, it had these values.
+ guid: "2bbd2d8fbc6b",
+ version: 1,
+ "given-name": "Mark",
+ "family-name": "Hammond",
+ },
+ local: [
+ {
+ // The current local record - by comparing against parent we can see that
+ // only the given-name has changed locally.
+ "given-name": "Skip",
+ "family-name": "Hammond",
+ },
+ ],
+ remote: {
+ // This is the incoming record. It has the same values as "parent", so
+ // we can deduce the record hasn't actually been changed remotely so we
+ // can safely ignore the incoming record and write our local changes.
+ guid: "2bbd2d8fbc6b",
+ version: 1,
+ "given-name": "Mark",
+ "family-name": "Hammond",
+ },
+ reconciled: {
+ guid: "2bbd2d8fbc6b",
+ "given-name": "Skip",
+ "family-name": "Hammond",
+ },
+ },
+ {
+ description: "Remote change",
+ parent: {
+ guid: "e3680e9f890d",
+ version: 1,
+ "given-name": "Mark",
+ "family-name": "Hammond",
+ },
+ local: [
+ {
+ "given-name": "Mark",
+ "family-name": "Hammond",
+ },
+ ],
+ remote: {
+ guid: "e3680e9f890d",
+ version: 1,
+ "given-name": "Skip",
+ "family-name": "Hammond",
+ },
+ reconciled: {
+ guid: "e3680e9f890d",
+ "given-name": "Skip",
+ "family-name": "Hammond",
+ },
+ },
+ {
+ description: "New local field",
+ parent: {
+ guid: "0cba738b1be0",
+ version: 1,
+ "given-name": "Mark",
+ "family-name": "Hammond",
+ },
+ local: [
+ {
+ "given-name": "Mark",
+ "family-name": "Hammond",
+ tel: "123456",
+ },
+ ],
+ remote: {
+ guid: "0cba738b1be0",
+ version: 1,
+ "given-name": "Mark",
+ "family-name": "Hammond",
+ },
+ reconciled: {
+ guid: "0cba738b1be0",
+ "given-name": "Mark",
+ "family-name": "Hammond",
+ tel: "123456",
+ },
+ },
+ {
+ description: "New remote field",
+ parent: {
+ guid: "be3ef97f8285",
+ version: 1,
+ "given-name": "Mark",
+ "family-name": "Hammond",
+ },
+ local: [
+ {
+ "given-name": "Mark",
+ "family-name": "Hammond",
+ },
+ ],
+ remote: {
+ guid: "be3ef97f8285",
+ version: 1,
+ "given-name": "Mark",
+ "family-name": "Hammond",
+ tel: "123456",
+ },
+ reconciled: {
+ guid: "be3ef97f8285",
+ "given-name": "Mark",
+ "family-name": "Hammond",
+ tel: "123456",
+ },
+ },
+ {
+ description: "Deleted field locally",
+ parent: {
+ guid: "9627322248ec",
+ version: 1,
+ "given-name": "Mark",
+ "family-name": "Hammond",
+ tel: "123456",
+ },
+ local: [
+ {
+ "given-name": "Mark",
+ "family-name": "Hammond",
+ },
+ ],
+ remote: {
+ guid: "9627322248ec",
+ version: 1,
+ "given-name": "Mark",
+ "family-name": "Hammond",
+ tel: "123456",
+ },
+ reconciled: {
+ guid: "9627322248ec",
+ "given-name": "Mark",
+ "family-name": "Hammond",
+ },
+ },
+ {
+ description: "Deleted field remotely",
+ parent: {
+ guid: "7d7509f3eeb2",
+ version: 1,
+ "given-name": "Mark",
+ "family-name": "Hammond",
+ tel: "123456",
+ },
+ local: [
+ {
+ "given-name": "Mark",
+ "family-name": "Hammond",
+ tel: "123456",
+ },
+ ],
+ remote: {
+ guid: "7d7509f3eeb2",
+ version: 1,
+ "given-name": "Mark",
+ "family-name": "Hammond",
+ },
+ reconciled: {
+ guid: "7d7509f3eeb2",
+ "given-name": "Mark",
+ "family-name": "Hammond",
+ },
+ },
+ {
+ description: "Local and remote changes to unrelated fields",
+ parent: {
+ // The last time we wrote this to the server, country was NZ.
+ guid: "e087a06dfc57",
+ version: 1,
+ "given-name": "Mark",
+ "family-name": "Hammond",
+ country: "NZ",
+ // We also had an unknown field we round-tripped
+ foo: "bar",
+ },
+ local: [
+ {
+ // The current local record - so locally we've changed given-name to Skip.
+ "given-name": "Skip",
+ "family-name": "Hammond",
+ country: "NZ",
+ },
+ ],
+ remote: {
+ // Remotely, we've changed the country to AU.
+ guid: "e087a06dfc57",
+ version: 1,
+ "given-name": "Mark",
+ "family-name": "Hammond",
+ country: "AU",
+ // This is a new unknown field that should send instead!
+ "unknown-1": "an unknown field from another client",
+ },
+ reconciled: {
+ guid: "e087a06dfc57",
+ "given-name": "Skip",
+ "family-name": "Hammond",
+ country: "AU",
+ // This is a new unknown field that should send instead!
+ "unknown-1": "an unknown field from another client",
+ },
+ },
+ {
+ description: "Multiple local changes",
+ parent: {
+ guid: "340a078c596f",
+ version: 1,
+ "given-name": "Mark",
+ "family-name": "Hammond",
+ tel: "123456",
+ },
+ local: [
+ {
+ "given-name": "Skip",
+ "family-name": "Hammond",
+ },
+ {
+ "given-name": "Skip",
+ "family-name": "Hammond",
+ organization: "Mozilla",
+ },
+ ],
+ remote: {
+ guid: "340a078c596f",
+ version: 1,
+ "given-name": "Mark",
+ "family-name": "Hammond",
+ tel: "123456",
+ country: "AU",
+ },
+ reconciled: {
+ guid: "340a078c596f",
+ "given-name": "Skip",
+ "family-name": "Hammond",
+ organization: "Mozilla",
+ country: "AU",
+ },
+ },
+ {
+ // Local and remote diverged from the shared parent, but the values are the
+ // same, so we shouldn't fork.
+ description: "Same change to local and remote",
+ parent: {
+ guid: "0b3a72a1bea2",
+ version: 1,
+ "given-name": "Mark",
+ "family-name": "Hammond",
+ // unknown fields we previously roundtripped
+ foo: "bar",
+ },
+ local: [
+ {
+ "given-name": "Skip",
+ "family-name": "Hammond",
+ },
+ ],
+ remote: {
+ guid: "0b3a72a1bea2",
+ version: 1,
+ "given-name": "Skip",
+ "family-name": "Hammond",
+ // New unknown field that should be the new round trip
+ "unknown-1": "an unknown field from another client",
+ },
+ reconciled: {
+ guid: "0b3a72a1bea2",
+ "given-name": "Skip",
+ "family-name": "Hammond",
+ },
+ },
+ {
+ description: "Conflicting changes to single field",
+ parent: {
+ // This is what we last wrote to the sync server.
+ guid: "62068784d089",
+ version: 1,
+ "given-name": "Mark",
+ "family-name": "Hammond",
+ "unknown-1": "an unknown field from another client",
+ },
+ local: [
+ {
+ // The current version of the local record - the given-name has changed locally.
+ "given-name": "Skip",
+ "family-name": "Hammond",
+ },
+ ],
+ remote: {
+ // An incoming record has a different given-name than any of the above!
+ guid: "62068784d089",
+ version: 1,
+ "given-name": "Kip",
+ "family-name": "Hammond",
+ "unknown-1": "an unknown field from another client",
+ },
+ forked: {
+ // So we've forked the local record to a new GUID (and the next sync is
+ // going to write this as a new record)
+ "given-name": "Skip",
+ "family-name": "Hammond",
+ "unknown-1": "an unknown field from another client",
+ },
+ reconciled: {
+ // And we've updated the local version of the record to be the remote version.
+ guid: "62068784d089",
+ "given-name": "Kip",
+ "family-name": "Hammond",
+ "unknown-1": "an unknown field from another client",
+ },
+ },
+ {
+ description: "Conflicting changes to multiple fields",
+ parent: {
+ guid: "244dbb692e94",
+ version: 1,
+ "given-name": "Mark",
+ "family-name": "Hammond",
+ country: "NZ",
+ },
+ local: [
+ {
+ "given-name": "Skip",
+ "family-name": "Hammond",
+ country: "AU",
+ },
+ ],
+ remote: {
+ guid: "244dbb692e94",
+ version: 1,
+ "given-name": "Kip",
+ "family-name": "Hammond",
+ country: "CA",
+ },
+ forked: {
+ "given-name": "Skip",
+ "family-name": "Hammond",
+ country: "AU",
+ },
+ reconciled: {
+ guid: "244dbb692e94",
+ "given-name": "Kip",
+ "family-name": "Hammond",
+ country: "CA",
+ },
+ },
+ {
+ description: "Field deleted locally, changed remotely",
+ parent: {
+ guid: "6fc45e03d19a",
+ version: 1,
+ "given-name": "Mark",
+ "family-name": "Hammond",
+ country: "AU",
+ },
+ local: [
+ {
+ "given-name": "Mark",
+ "family-name": "Hammond",
+ },
+ ],
+ remote: {
+ guid: "6fc45e03d19a",
+ version: 1,
+ "given-name": "Mark",
+ "family-name": "Hammond",
+ country: "NZ",
+ },
+ forked: {
+ "given-name": "Mark",
+ "family-name": "Hammond",
+ },
+ reconciled: {
+ guid: "6fc45e03d19a",
+ "given-name": "Mark",
+ "family-name": "Hammond",
+ country: "NZ",
+ },
+ },
+ {
+ description: "Field changed locally, deleted remotely",
+ parent: {
+ guid: "fff9fa27fa18",
+ version: 1,
+ "given-name": "Mark",
+ "family-name": "Hammond",
+ country: "AU",
+ },
+ local: [
+ {
+ "given-name": "Mark",
+ "family-name": "Hammond",
+ country: "NZ",
+ },
+ ],
+ remote: {
+ guid: "fff9fa27fa18",
+ version: 1,
+ "given-name": "Mark",
+ "family-name": "Hammond",
+ },
+ forked: {
+ "given-name": "Mark",
+ "family-name": "Hammond",
+ country: "NZ",
+ },
+ reconciled: {
+ guid: "fff9fa27fa18",
+ "given-name": "Mark",
+ "family-name": "Hammond",
+ },
+ },
+ {
+ // Created, last modified should be synced; last used and times used should
+ // be local. Remote created time older than local, remote modified time
+ // newer than local.
+ description:
+ "Created, last modified time reconciliation without local changes",
+ parent: {
+ guid: "5113f329c42f",
+ version: 1,
+ "given-name": "Mark",
+ "family-name": "Hammond",
+ timeCreated: 1234,
+ timeLastModified: 5678,
+ timeLastUsed: 5678,
+ timesUsed: 6,
+ },
+ local: [],
+ remote: {
+ guid: "5113f329c42f",
+ version: 1,
+ "given-name": "Mark",
+ "family-name": "Hammond",
+ timeCreated: 1200,
+ timeLastModified: 5700,
+ timeLastUsed: 5700,
+ timesUsed: 3,
+ },
+ reconciled: {
+ guid: "5113f329c42f",
+ "given-name": "Mark",
+ "family-name": "Hammond",
+ timeCreated: 1200,
+ timeLastModified: 5700,
+ timeLastUsed: 5678,
+ timesUsed: 6,
+ },
+ },
+ {
+ // Local changes, remote created time newer than local, remote modified time
+ // older than local.
+ description:
+ "Created, last modified time reconciliation with local changes",
+ parent: {
+ guid: "791e5608b80a",
+ version: 1,
+ "given-name": "Mark",
+ "family-name": "Hammond",
+ timeCreated: 1234,
+ timeLastModified: 5678,
+ timeLastUsed: 5678,
+ timesUsed: 6,
+ },
+ local: [
+ {
+ "given-name": "Skip",
+ "family-name": "Hammond",
+ },
+ ],
+ remote: {
+ guid: "791e5608b80a",
+ version: 1,
+ "given-name": "Mark",
+ "family-name": "Hammond",
+ timeCreated: 1300,
+ timeLastModified: 5000,
+ timeLastUsed: 5000,
+ timesUsed: 3,
+ },
+ reconciled: {
+ guid: "791e5608b80a",
+ "given-name": "Skip",
+ "family-name": "Hammond",
+ timeCreated: 1234,
+ timeLastUsed: 5678,
+ timesUsed: 6,
+ },
+ },
+];
+
+const CREDIT_CARD_RECONCILE_TESTCASES = [
+ {
+ description: "Local change",
+ parent: {
+ // So when we last wrote the record to the server, it had these values.
+ guid: "2bbd2d8fbc6b",
+ version: CREDIT_CARD_SCHEMA_VERSION,
+ "cc-name": "John Doe",
+ "cc-number": "4111111111111111",
+ "unknown-1": "an unknown field from another client",
+ },
+ local: [
+ {
+ // The current local record - by comparing against parent we can see that
+ // only the cc-number has changed locally.
+ "cc-name": "John Doe",
+ "cc-number": "4929001587121045",
+ },
+ ],
+ remote: {
+ // This is the incoming record. It has the same values as "parent", so
+ // we can deduce the record hasn't actually been changed remotely so we
+ // can safely ignore the incoming record and write our local changes.
+ guid: "2bbd2d8fbc6b",
+ version: CREDIT_CARD_SCHEMA_VERSION,
+ "cc-name": "John Doe",
+ "cc-number": "4111111111111111",
+ "unknown-2": "a newer unknown field",
+ },
+ reconciled: {
+ guid: "2bbd2d8fbc6b",
+ "cc-name": "John Doe",
+ "cc-number": "4929001587121045",
+ "unknown-2": "a newer unknown field",
+ },
+ },
+ {
+ description: "Remote change",
+ parent: {
+ guid: "e3680e9f890d",
+ version: CREDIT_CARD_SCHEMA_VERSION,
+ "cc-name": "John Doe",
+ "cc-number": "4111111111111111",
+ "unknown-1": "unknown field",
+ },
+ local: [
+ {
+ "cc-name": "John Doe",
+ "cc-number": "4111111111111111",
+ },
+ ],
+ remote: {
+ guid: "e3680e9f890d",
+ version: CREDIT_CARD_SCHEMA_VERSION,
+ "cc-name": "John Doe",
+ "cc-number": "4929001587121045",
+ "unknown-1": "unknown field",
+ },
+ reconciled: {
+ guid: "e3680e9f890d",
+ "cc-name": "John Doe",
+ "cc-number": "4929001587121045",
+ "unknown-1": "unknown field",
+ },
+ },
+
+ {
+ description: "New local field",
+ parent: {
+ guid: "0cba738b1be0",
+ version: CREDIT_CARD_SCHEMA_VERSION,
+ "cc-name": "John Doe",
+ "cc-number": "4111111111111111",
+ },
+ local: [
+ {
+ "cc-name": "John Doe",
+ "cc-number": "4111111111111111",
+ "cc-exp-month": 12,
+ },
+ ],
+ remote: {
+ guid: "0cba738b1be0",
+ version: CREDIT_CARD_SCHEMA_VERSION,
+ "cc-name": "John Doe",
+ "cc-number": "4111111111111111",
+ },
+ reconciled: {
+ guid: "0cba738b1be0",
+ "cc-name": "John Doe",
+ "cc-number": "4111111111111111",
+ "cc-exp-month": 12,
+ },
+ },
+ {
+ description: "New remote field",
+ parent: {
+ guid: "be3ef97f8285",
+ version: CREDIT_CARD_SCHEMA_VERSION,
+ "cc-name": "John Doe",
+ "cc-number": "4111111111111111",
+ },
+ local: [
+ {
+ "cc-name": "John Doe",
+ "cc-number": "4111111111111111",
+ },
+ ],
+ remote: {
+ guid: "be3ef97f8285",
+ version: CREDIT_CARD_SCHEMA_VERSION,
+ "cc-name": "John Doe",
+ "cc-number": "4111111111111111",
+ "cc-exp-month": 12,
+ },
+ reconciled: {
+ guid: "be3ef97f8285",
+ "cc-name": "John Doe",
+ "cc-number": "4111111111111111",
+ "cc-exp-month": 12,
+ },
+ },
+ {
+ description: "Deleted field locally",
+ parent: {
+ guid: "9627322248ec",
+ version: CREDIT_CARD_SCHEMA_VERSION,
+ "cc-name": "John Doe",
+ "cc-number": "4111111111111111",
+ "cc-exp-month": 12,
+ },
+ local: [
+ {
+ "cc-name": "John Doe",
+ "cc-number": "4111111111111111",
+ },
+ ],
+ remote: {
+ guid: "9627322248ec",
+ version: CREDIT_CARD_SCHEMA_VERSION,
+ "cc-name": "John Doe",
+ "cc-number": "4111111111111111",
+ "cc-exp-month": 12,
+ },
+ reconciled: {
+ guid: "9627322248ec",
+ "cc-name": "John Doe",
+ "cc-number": "4111111111111111",
+ },
+ },
+ {
+ description: "Deleted field remotely",
+ parent: {
+ guid: "7d7509f3eeb2",
+ version: CREDIT_CARD_SCHEMA_VERSION,
+ "cc-name": "John Doe",
+ "cc-number": "4111111111111111",
+ "cc-exp-month": 12,
+ },
+ local: [
+ {
+ "cc-name": "John Doe",
+ "cc-number": "4111111111111111",
+ "cc-exp-month": 12,
+ },
+ ],
+ remote: {
+ guid: "7d7509f3eeb2",
+ version: CREDIT_CARD_SCHEMA_VERSION,
+ "cc-name": "John Doe",
+ "cc-number": "4111111111111111",
+ },
+ reconciled: {
+ guid: "7d7509f3eeb2",
+ "cc-name": "John Doe",
+ "cc-number": "4111111111111111",
+ },
+ },
+ {
+ description: "Local and remote changes to unrelated fields",
+ parent: {
+ // The last time we wrote this to the server, "cc-exp-month" was 12.
+ guid: "e087a06dfc57",
+ version: CREDIT_CARD_SCHEMA_VERSION,
+ "cc-name": "John Doe",
+ "cc-number": "4111111111111111",
+ "cc-exp-month": 12,
+ "unknown-1": "unknown field",
+ },
+ local: [
+ {
+ // The current local record - so locally we've changed "cc-number".
+ "cc-name": "John Doe",
+ "cc-number": "4929001587121045",
+ "cc-exp-month": 12,
+ },
+ ],
+ remote: {
+ // Remotely, we've changed "cc-exp-month" to 1.
+ guid: "e087a06dfc57",
+ version: CREDIT_CARD_SCHEMA_VERSION,
+ "cc-name": "John Doe",
+ "cc-number": "4111111111111111",
+ "cc-exp-month": 1,
+ "unknown-2": "a newer unknown field",
+ },
+ reconciled: {
+ guid: "e087a06dfc57",
+ "cc-name": "John Doe",
+ "cc-number": "4929001587121045",
+ "cc-exp-month": 1,
+ "unknown-2": "a newer unknown field",
+ },
+ },
+ {
+ description: "Multiple local changes",
+ parent: {
+ guid: "340a078c596f",
+ version: CREDIT_CARD_SCHEMA_VERSION,
+ "cc-name": "John Doe",
+ "cc-number": "4111111111111111",
+ "unknown-1": "unknown field",
+ },
+ local: [
+ {
+ "cc-name": "Skip",
+ "cc-number": "4111111111111111",
+ },
+ {
+ "cc-name": "Skip",
+ "cc-number": "4111111111111111",
+ "cc-exp-month": 12,
+ },
+ ],
+ remote: {
+ guid: "340a078c596f",
+ version: CREDIT_CARD_SCHEMA_VERSION,
+ "cc-name": "John Doe",
+ "cc-number": "4111111111111111",
+ "cc-exp-year": 2000,
+ "unknown-1": "unknown field",
+ },
+ reconciled: {
+ guid: "340a078c596f",
+ "cc-name": "Skip",
+ "cc-number": "4111111111111111",
+ "cc-exp-month": 12,
+ "cc-exp-year": 2000,
+ "unknown-1": "unknown field",
+ },
+ },
+ {
+ // Local and remote diverged from the shared parent, but the values are the
+ // same, so we shouldn't fork.
+ description: "Same change to local and remote",
+ parent: {
+ guid: "0b3a72a1bea2",
+ version: CREDIT_CARD_SCHEMA_VERSION,
+ "cc-name": "John Doe",
+ "cc-number": "4111111111111111",
+ },
+ local: [
+ {
+ "cc-name": "John Doe",
+ "cc-number": "4929001587121045",
+ },
+ ],
+ remote: {
+ guid: "0b3a72a1bea2",
+ version: CREDIT_CARD_SCHEMA_VERSION,
+ "cc-name": "John Doe",
+ "cc-number": "4929001587121045",
+ },
+ reconciled: {
+ guid: "0b3a72a1bea2",
+ "cc-name": "John Doe",
+ "cc-number": "4929001587121045",
+ },
+ },
+ {
+ description: "Conflicting changes to single field",
+ parent: {
+ // This is what we last wrote to the sync server.
+ guid: "62068784d089",
+ version: CREDIT_CARD_SCHEMA_VERSION,
+ "cc-name": "John Doe",
+ "cc-number": "4111111111111111",
+ "unknown-1": "unknown field",
+ },
+ local: [
+ {
+ // The current version of the local record - the cc-number has changed locally.
+ "cc-name": "John Doe",
+ "cc-number": "5103059495477870",
+ },
+ ],
+ remote: {
+ // An incoming record has a different cc-number than any of the above!
+ guid: "62068784d089",
+ version: CREDIT_CARD_SCHEMA_VERSION,
+ "cc-name": "John Doe",
+ "cc-number": "4929001587121045",
+ "unknown-1": "unknown field",
+ },
+ forked: {
+ // So we've forked the local record to a new GUID (and the next sync is
+ // going to write this as a new record)
+ "cc-name": "John Doe",
+ "cc-number": "5103059495477870",
+ "unknown-1": "unknown field",
+ },
+ reconciled: {
+ // And we've updated the local version of the record to be the remote version.
+ guid: "62068784d089",
+ "cc-name": "John Doe",
+ "cc-number": "4929001587121045",
+ "unknown-1": "unknown field",
+ },
+ },
+ {
+ description: "Conflicting changes to multiple fields",
+ parent: {
+ guid: "244dbb692e94",
+ version: CREDIT_CARD_SCHEMA_VERSION,
+ "cc-name": "John Doe",
+ "cc-number": "4111111111111111",
+ "cc-exp-month": 12,
+ },
+ local: [
+ {
+ "cc-name": "John Doe",
+ "cc-number": "5103059495477870",
+ "cc-exp-month": 1,
+ },
+ ],
+ remote: {
+ guid: "244dbb692e94",
+ version: CREDIT_CARD_SCHEMA_VERSION,
+ "cc-name": "John Doe",
+ "cc-number": "4929001587121045",
+ "cc-exp-month": 3,
+ },
+ forked: {
+ "cc-name": "John Doe",
+ "cc-number": "5103059495477870",
+ "cc-exp-month": 1,
+ },
+ reconciled: {
+ guid: "244dbb692e94",
+ "cc-name": "John Doe",
+ "cc-number": "4929001587121045",
+ "cc-exp-month": 3,
+ },
+ },
+ {
+ description: "Field deleted locally, changed remotely",
+ parent: {
+ guid: "6fc45e03d19a",
+ version: CREDIT_CARD_SCHEMA_VERSION,
+ "cc-name": "John Doe",
+ "cc-number": "4111111111111111",
+ "cc-exp-month": 12,
+ },
+ local: [
+ {
+ "cc-name": "John Doe",
+ "cc-number": "4111111111111111",
+ },
+ ],
+ remote: {
+ guid: "6fc45e03d19a",
+ version: CREDIT_CARD_SCHEMA_VERSION,
+ "cc-name": "John Doe",
+ "cc-number": "4111111111111111",
+ "cc-exp-month": 3,
+ },
+ forked: {
+ "cc-name": "John Doe",
+ "cc-number": "4111111111111111",
+ },
+ reconciled: {
+ guid: "6fc45e03d19a",
+ "cc-name": "John Doe",
+ "cc-number": "4111111111111111",
+ "cc-exp-month": 3,
+ },
+ },
+ {
+ description: "Field changed locally, deleted remotely",
+ parent: {
+ guid: "fff9fa27fa18",
+ version: CREDIT_CARD_SCHEMA_VERSION,
+ "cc-name": "John Doe",
+ "cc-number": "4111111111111111",
+ "cc-exp-month": 12,
+ },
+ local: [
+ {
+ "cc-name": "John Doe",
+ "cc-number": "4111111111111111",
+ "cc-exp-month": 3,
+ },
+ ],
+ remote: {
+ guid: "fff9fa27fa18",
+ version: CREDIT_CARD_SCHEMA_VERSION,
+ "cc-name": "John Doe",
+ "cc-number": "4111111111111111",
+ },
+ forked: {
+ "cc-name": "John Doe",
+ "cc-number": "4111111111111111",
+ "cc-exp-month": 3,
+ },
+ reconciled: {
+ guid: "fff9fa27fa18",
+ "cc-name": "John Doe",
+ "cc-number": "4111111111111111",
+ },
+ },
+ {
+ // Created, last modified should be synced; last used and times used should
+ // be local. Remote created time older than local, remote modified time
+ // newer than local.
+ description:
+ "Created, last modified time reconciliation without local changes",
+ parent: {
+ guid: "5113f329c42f",
+ version: CREDIT_CARD_SCHEMA_VERSION,
+ "cc-name": "John Doe",
+ "cc-number": "4111111111111111",
+ timeCreated: 1234,
+ timeLastModified: 5678,
+ timeLastUsed: 5678,
+ timesUsed: 6,
+ },
+ local: [],
+ remote: {
+ guid: "5113f329c42f",
+ version: CREDIT_CARD_SCHEMA_VERSION,
+ "cc-name": "John Doe",
+ "cc-number": "4111111111111111",
+ timeCreated: 1200,
+ timeLastModified: 5700,
+ timeLastUsed: 5700,
+ timesUsed: 3,
+ },
+ reconciled: {
+ guid: "5113f329c42f",
+ "cc-name": "John Doe",
+ "cc-number": "4111111111111111",
+ timeCreated: 1200,
+ timeLastModified: 5700,
+ timeLastUsed: 5678,
+ timesUsed: 6,
+ },
+ },
+ {
+ // Local changes, remote created time newer than local, remote modified time
+ // older than local.
+ description:
+ "Created, last modified time reconciliation with local changes",
+ parent: {
+ guid: "791e5608b80a",
+ version: CREDIT_CARD_SCHEMA_VERSION,
+ "cc-name": "John Doe",
+ "cc-number": "4111111111111111",
+ timeCreated: 1234,
+ timeLastModified: 5678,
+ timeLastUsed: 5678,
+ timesUsed: 6,
+ },
+ local: [
+ {
+ "cc-name": "John Doe",
+ "cc-number": "4929001587121045",
+ },
+ ],
+ remote: {
+ guid: "791e5608b80a",
+ version: CREDIT_CARD_SCHEMA_VERSION,
+ "cc-name": "John Doe",
+ "cc-number": "4111111111111111",
+ timeCreated: 1300,
+ timeLastModified: 5000,
+ timeLastUsed: 5000,
+ timesUsed: 3,
+ },
+ reconciled: {
+ guid: "791e5608b80a",
+ "cc-name": "John Doe",
+ "cc-number": "4929001587121045",
+ timeCreated: 1234,
+ timeLastUsed: 5678,
+ timesUsed: 6,
+ },
+ },
+];
+
+add_task(async function test_reconcile_unknown_version() {
+ let profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME);
+
+ // Cross-version reconciliation isn't supported yet. See bug 1377204.
+ await Assert.rejects(
+ profileStorage.addresses.reconcile({
+ guid: "31d83d2725ec",
+ version: 3,
+ "given-name": "Mark",
+ "family-name": "Hammond",
+ }),
+ /Got unknown record version/
+ );
+});
+
+add_task(async function test_reconcile_idempotent() {
+ let profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME);
+
+ let guid = "de1ba7b094fe";
+ await profileStorage.addresses.add(
+ {
+ guid,
+ version: 1,
+ "given-name": "Mark",
+ "family-name": "Hammond",
+ // an unknown field from a previous sync
+ foo: "bar",
+ },
+ { sourceSync: true }
+ );
+ await profileStorage.addresses.update(guid, {
+ "given-name": "Skip",
+ "family-name": "Hammond",
+ organization: "Mozilla",
+ });
+
+ let remote = {
+ guid,
+ version: 1,
+ "given-name": "Mark",
+ "family-name": "Hammond",
+ tel: "123456",
+ "unknown-1": "an unknown field from another client",
+ };
+
+ {
+ let { forkedGUID } = await profileStorage.addresses.reconcile(remote);
+ let updatedRecord = await profileStorage.addresses.get(guid, {
+ rawData: true,
+ });
+
+ ok(!forkedGUID, "First merge should not fork record");
+ ok(
+ objectMatches(updatedRecord, {
+ guid: "de1ba7b094fe",
+ "given-name": "Skip",
+ "family-name": "Hammond",
+ organization: "Mozilla",
+ tel: "123456",
+ "unknown-1": "an unknown field from another client",
+ }),
+ "First merge should merge local and remote changes"
+ );
+ }
+
+ {
+ let { forkedGUID } = await profileStorage.addresses.reconcile(remote);
+ let updatedRecord = await profileStorage.addresses.get(guid, {
+ rawData: true,
+ });
+
+ ok(!forkedGUID, "Second merge should not fork record");
+ ok(
+ objectMatches(updatedRecord, {
+ guid: "de1ba7b094fe",
+ "given-name": "Skip",
+ "family-name": "Hammond",
+ organization: "Mozilla",
+ tel: "123456",
+ "unknown-1": "an unknown field from another client",
+ }),
+ "Second merge should not change record"
+ );
+ }
+});
+
+add_task(async function test_reconcile_three_way_merge() {
+ let TESTCASES = {
+ addresses: ADDRESS_RECONCILE_TESTCASES,
+ creditCards: CREDIT_CARD_RECONCILE_TESTCASES,
+ };
+
+ for (let collectionName in TESTCASES) {
+ info(`Start to test reconcile on ${collectionName}`);
+
+ let profileStorage = await initProfileStorage(
+ TEST_STORE_FILE_NAME,
+ null,
+ collectionName
+ );
+
+ for (let test of TESTCASES[collectionName]) {
+ info(test.description);
+
+ await profileStorage[collectionName].add(test.parent, {
+ sourceSync: true,
+ });
+
+ for (let updatedRecord of test.local) {
+ await profileStorage[collectionName].update(
+ test.parent.guid,
+ updatedRecord
+ );
+ }
+
+ let localRecord = await profileStorage[collectionName].get(
+ test.parent.guid,
+ {
+ rawData: true,
+ }
+ );
+
+ let onReconciled = TestUtils.topicObserved(
+ "formautofill-storage-changed",
+ (subject, data) =>
+ data == "reconcile" &&
+ subject.wrappedJSObject.collectionName == collectionName
+ );
+ let { forkedGUID } = await profileStorage[collectionName].reconcile(
+ test.remote
+ );
+ await onReconciled;
+ let reconciledRecord = await profileStorage[collectionName].get(
+ test.parent.guid,
+ {
+ rawData: true,
+ }
+ );
+ if (forkedGUID) {
+ let forkedRecord = await profileStorage[collectionName].get(
+ forkedGUID,
+ {
+ rawData: true,
+ }
+ );
+
+ notEqual(forkedRecord.guid, reconciledRecord.guid);
+ equal(forkedRecord.timeLastModified, localRecord.timeLastModified);
+ ok(
+ objectMatches(forkedRecord, test.forked),
+ `${test.description} should fork record`
+ );
+ } else {
+ ok(!test.forked, `${test.description} should not fork record`);
+ }
+
+ ok(objectMatches(reconciledRecord, test.reconciled));
+ }
+ }
+});
diff --git a/browser/extensions/formautofill/test/unit/test_savedFieldNames.js b/browser/extensions/formautofill/test/unit/test_savedFieldNames.js
new file mode 100644
index 0000000000..6e3474c06d
--- /dev/null
+++ b/browser/extensions/formautofill/test/unit/test_savedFieldNames.js
@@ -0,0 +1,106 @@
+/*
+ * Test for keeping the valid fields information in sharedData.
+ */
+
+"use strict";
+
+let FormAutofillStatus;
+
+add_setup(async () => {
+ ({ FormAutofillStatus } = ChromeUtils.importESModule(
+ "resource://autofill/FormAutofillParent.sys.mjs"
+ ));
+});
+
+add_task(async function test_profileSavedFieldNames_init() {
+ FormAutofillStatus.init();
+ sinon.stub(FormAutofillStatus, "updateSavedFieldNames");
+
+ await FormAutofillStatus.formAutofillStorage.initialize();
+ Assert.equal(FormAutofillStatus.updateSavedFieldNames.called, true);
+
+ FormAutofillStatus.uninit();
+});
+
+add_task(async function test_profileSavedFieldNames_observe() {
+ FormAutofillStatus.init();
+
+ // profile changed => Need to trigger updateValidFields
+ ["add", "update", "remove", "reconcile", "removeAll"].forEach(event => {
+ FormAutofillStatus.observe(null, "formautofill-storage-changed", event);
+ Assert.equal(FormAutofillStatus.updateSavedFieldNames.called, true);
+ });
+
+ // profile metadata updated => no need to trigger updateValidFields
+ FormAutofillStatus.updateSavedFieldNames.resetHistory();
+ FormAutofillStatus.observe(
+ null,
+ "formautofill-storage-changed",
+ "notifyUsed"
+ );
+ Assert.equal(FormAutofillStatus.updateSavedFieldNames.called, false);
+ FormAutofillStatus.updateSavedFieldNames.restore();
+});
+
+add_task(async function test_profileSavedFieldNames_update() {
+ registerCleanupFunction(function cleanup() {
+ Services.prefs.clearUserPref("extensions.formautofill.addresses.enabled");
+ });
+
+ Object.defineProperty(
+ FormAutofillStatus.formAutofillStorage.addresses,
+ "_data",
+ { writable: true }
+ );
+
+ FormAutofillStatus.formAutofillStorage.addresses._data = [];
+
+ // The set is empty if there's no profile in the store.
+ await FormAutofillStatus.updateSavedFieldNames();
+ Assert.equal(
+ Services.ppmm.sharedData.get("FormAutofill:savedFieldNames").size,
+ 0
+ );
+
+ // 2 profiles with 4 valid fields.
+ FormAutofillStatus.formAutofillStorage.addresses._data = [
+ {
+ guid: "test-guid-1",
+ organization: "Sesame Street",
+ "street-address": "123 Sesame Street.",
+ tel: "1-345-345-3456",
+ email: "",
+ timeCreated: 0,
+ timeLastUsed: 0,
+ timeLastModified: 0,
+ timesUsed: 0,
+ },
+ {
+ guid: "test-guid-2",
+ organization: "Mozilla",
+ "street-address": "331 E. Evelyn Avenue",
+ tel: "1-650-903-0800",
+ country: "US",
+ timeCreated: 0,
+ timeLastUsed: 0,
+ timeLastModified: 0,
+ timesUsed: 0,
+ },
+ ];
+
+ await FormAutofillStatus.updateSavedFieldNames();
+
+ let autofillSavedFieldNames = Services.ppmm.sharedData.get(
+ "FormAutofill:savedFieldNames"
+ );
+ Assert.equal(autofillSavedFieldNames.size, 4);
+ Assert.equal(autofillSavedFieldNames.has("organization"), true);
+ Assert.equal(autofillSavedFieldNames.has("street-address"), true);
+ Assert.equal(autofillSavedFieldNames.has("tel"), true);
+ Assert.equal(autofillSavedFieldNames.has("email"), false);
+ Assert.equal(autofillSavedFieldNames.has("guid"), false);
+ Assert.equal(autofillSavedFieldNames.has("timeCreated"), false);
+ Assert.equal(autofillSavedFieldNames.has("timeLastUsed"), false);
+ Assert.equal(autofillSavedFieldNames.has("timeLastModified"), false);
+ Assert.equal(autofillSavedFieldNames.has("timesUsed"), false);
+});
diff --git a/browser/extensions/formautofill/test/unit/test_storage_remove.js b/browser/extensions/formautofill/test/unit/test_storage_remove.js
new file mode 100644
index 0000000000..0c33440ea9
--- /dev/null
+++ b/browser/extensions/formautofill/test/unit/test_storage_remove.js
@@ -0,0 +1,88 @@
+/**
+ * Tests removing all address/creditcard records.
+ */
+
+"use strict";
+
+let FormAutofillStorage;
+add_setup(async () => {
+ ({ FormAutofillStorage } = ChromeUtils.importESModule(
+ "resource://autofill/FormAutofillStorage.sys.mjs"
+ ));
+});
+
+const TEST_STORE_FILE_NAME = "test-tombstones.json";
+
+const TEST_ADDRESS_1 = {
+ "given-name": "Timothy",
+ "additional-name": "John",
+ "family-name": "Berners-Lee",
+ organization: "World Wide Web Consortium",
+ "street-address": "32 Vassar Street\nMIT Room 32-G524",
+ "address-level2": "Cambridge",
+ "address-level1": "MA",
+ "postal-code": "02139",
+ country: "US",
+ tel: "+1 617 253 5702",
+ email: "timbl@w3.org",
+};
+
+const TEST_ADDRESS_2 = {
+ "street-address": "Some Address",
+ country: "US",
+};
+
+const TEST_CREDIT_CARD_1 = {
+ "cc-name": "John Doe",
+ "cc-number": "4111111111111111",
+ "cc-exp-month": 4,
+ "cc-exp-year": 2017,
+};
+
+const TEST_CREDIT_CARD_2 = {
+ "cc-name": "Timothy Berners-Lee",
+ "cc-number": "4929001587121045",
+ "cc-exp-month": 12,
+ "cc-exp-year": 2022,
+};
+
+// Like add_task, but actually adds 2 - one for addresses and one for cards.
+function add_storage_task(test_function) {
+ add_task(async function () {
+ let path = getTempFile(TEST_STORE_FILE_NAME).path;
+ let profileStorage = new FormAutofillStorage(path);
+ await profileStorage.initialize();
+ let address_records = [TEST_ADDRESS_1, TEST_ADDRESS_2];
+ let cc_records = [TEST_CREDIT_CARD_1, TEST_CREDIT_CARD_2];
+
+ for (let [storage, record] of [
+ [profileStorage.addresses, address_records],
+ [profileStorage.creditCards, cc_records],
+ ]) {
+ await test_function(storage, record);
+ }
+ });
+}
+
+add_storage_task(async function test_remove_everything(storage, records) {
+ info("check simple tombstone semantics");
+
+ let guid = await storage.add(records[0]);
+ Assert.equal((await storage.getAll()).length, 1);
+
+ storage.pullSyncChanges(); // force sync metadata, which triggers tombstone behaviour.
+
+ storage.remove(guid);
+
+ await storage.add(records[1]);
+ // getAll() is still 1 as we deleted the first.
+ Assert.equal((await storage.getAll()).length, 1);
+
+ // check we have the tombstone.
+ Assert.equal((await storage.getAll({ includeDeleted: true })).length, 2);
+
+ storage.removeAll();
+
+ // should have deleted both the existing and deleted records.
+ Assert.equal((await storage.getAll({ includeDeleted: true })).length, 0);
+});
diff --git a/browser/extensions/formautofill/test/unit/test_storage_syncfields.js b/browser/extensions/formautofill/test/unit/test_storage_syncfields.js
new file mode 100644
index 0000000000..e304aa4df0
--- /dev/null
+++ b/browser/extensions/formautofill/test/unit/test_storage_syncfields.js
@@ -0,0 +1,498 @@
+/**
+ * Tests FormAutofillStorage objects support for sync related fields.
+ */
+
+"use strict";
+
+// The duplication of some of these fixtures between tests is unfortunate.
+const TEST_STORE_FILE_NAME = "test-profile.json";
+
+const TEST_ADDRESS_1 = {
+ "given-name": "Timothy",
+ "additional-name": "John",
+ "family-name": "Berners-Lee",
+ organization: "World Wide Web Consortium",
+ "street-address": "32 Vassar Street\nMIT Room 32-G524",
+ "address-level2": "Cambridge",
+ "address-level1": "MA",
+ "postal-code": "02139",
+ country: "US",
+ tel: "+1 617 253 5702",
+ email: "timbl@w3.org",
+ "unknown-1": "an unknown field we roundtrip",
+};
+
+const TEST_ADDRESS_2 = {
+ "street-address": "Some Address",
+ country: "US",
+};
+
+const TEST_ADDRESS_3 = {
+ "street-address": "Other Address",
+ "postal-code": "12345",
+};
+
+// storage.get() doesn't support getting deleted items. However, this test
+// wants to do that, so rather than making .get() support that just for this
+// test, we use this helper.
+async function findGUID(storage, guid, options) {
+ let all = await storage.getAll(options);
+ let records = all.filter(r => r.guid == guid);
+ equal(records.length, 1);
+ return records[0];
+}
+
+add_task(async function test_changeCounter() {
+ let profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME, [
+ TEST_ADDRESS_1,
+ ]);
+
+ let [address] = await profileStorage.addresses.getAll();
+ // new records don't get the sync metadata.
+ equal(getSyncChangeCounter(profileStorage.addresses, address.guid), -1);
+ // But we can force one.
+ profileStorage.addresses.pullSyncChanges();
+ equal(getSyncChangeCounter(profileStorage.addresses, address.guid), 1);
+});
+
+add_task(async function test_pushChanges() {
+ let profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME, [
+ TEST_ADDRESS_1,
+ TEST_ADDRESS_2,
+ ]);
+
+ profileStorage.addresses.pullSyncChanges(); // force sync metadata for all items
+
+ let [, address] = await profileStorage.addresses.getAll();
+ let guid = address.guid;
+ let changeCounter = getSyncChangeCounter(profileStorage.addresses, guid);
+
+ // Pretend we're doing a sync now, and an update occured mid-sync.
+ let changes = {
+ [guid]: {
+ profile: address,
+ counter: changeCounter,
+ modified: address.timeLastModified,
+ synced: true,
+ },
+ };
+
+ let onChanged = TestUtils.topicObserved(
+ "formautofill-storage-changed",
+ (subject, data) => data == "update"
+ );
+ await profileStorage.addresses.update(guid, TEST_ADDRESS_3);
+ await onChanged;
+
+ changeCounter = getSyncChangeCounter(profileStorage.addresses, guid);
+ Assert.equal(changeCounter, 2);
+
+ profileStorage.addresses.pushSyncChanges(changes);
+ address = await profileStorage.addresses.get(guid);
+ changeCounter = getSyncChangeCounter(profileStorage.addresses, guid);
+
+ // Counter should still be 1, since our sync didn't record the mid-sync change
+ Assert.equal(
+ changeCounter,
+ 1,
+ "Counter shouldn't be zero because it didn't record update"
+ );
+
+ // now, push a new set of changes, which should make the changeCounter 0
+ profileStorage.addresses.pushSyncChanges({
+ [guid]: {
+ profile: address,
+ counter: changeCounter,
+ modified: address.timeLastModified,
+ synced: true,
+ },
+ });
+
+ changeCounter = getSyncChangeCounter(profileStorage.addresses, guid);
+ Assert.equal(changeCounter, 0);
+});
+
+async function checkingSyncChange(action, callback) {
+ let onChanged = TestUtils.topicObserved(
+ "formautofill-storage-changed",
+ (subject, data) => data == action
+ );
+ await callback();
+ let [subject] = await onChanged;
+ ok(
+ subject.wrappedJSObject.sourceSync,
+ "change notification should have source sync"
+ );
+}
+
+add_task(async function test_add_sourceSync() {
+ let profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME, []);
+
+ // Hardcode a guid so that we don't need to generate a dynamic regex
+ let guid = "aaaaaaaaaaaa";
+ let testAddr = Object.assign({ guid, version: 1 }, TEST_ADDRESS_1);
+
+ await checkingSyncChange("add", async () =>
+ profileStorage.addresses.add(testAddr, { sourceSync: true })
+ );
+
+ let changeCounter = getSyncChangeCounter(profileStorage.addresses, guid);
+ equal(changeCounter, 0);
+
+ await Assert.rejects(
+ profileStorage.addresses.add({ guid, deleted: true }, { sourceSync: true }),
+ /Record aaaaaaaaaaaa already exists/
+ );
+});
+
+add_task(async function test_add_tombstone_sourceSync() {
+ let profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME, []);
+
+ let guid = profileStorage.addresses._generateGUID();
+ let testAddr = { guid, deleted: true };
+ await checkingSyncChange("add", async () =>
+ profileStorage.addresses.add(testAddr, { sourceSync: true })
+ );
+
+ let added = await findGUID(profileStorage.addresses, guid, {
+ includeDeleted: true,
+ });
+ ok(added);
+ equal(getSyncChangeCounter(profileStorage.addresses, guid), 0);
+ ok(added.deleted);
+
+ // Adding same record again shouldn't throw (or change anything)
+ await checkingSyncChange("add", async () =>
+ profileStorage.addresses.add(testAddr, { sourceSync: true })
+ );
+
+ added = await findGUID(profileStorage.addresses, guid, {
+ includeDeleted: true,
+ });
+ equal(getSyncChangeCounter(profileStorage.addresses, guid), 0);
+ ok(added.deleted);
+});
+
+add_task(async function test_add_resurrects_tombstones() {
+ let profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME, []);
+
+ let guid = profileStorage.addresses._generateGUID();
+
+ // Add a tombstone.
+ await profileStorage.addresses.add({ guid, deleted: true });
+
+ // You can't re-add an item with an explicit GUID.
+ let resurrected = Object.assign({}, TEST_ADDRESS_1, { guid, version: 1 });
+ await Assert.rejects(
+ profileStorage.addresses.add(resurrected),
+ /"(guid|version)" is not a valid field/
+ );
+
+ // But Sync can!
+ let guid3 = await profileStorage.addresses.add(resurrected, {
+ sourceSync: true,
+ });
+ equal(guid, guid3);
+
+ let got = await profileStorage.addresses.get(guid);
+ equal(got["given-name"], TEST_ADDRESS_1["given-name"]);
+});
+
+add_task(async function test_remove_sourceSync_localChanges() {
+ let profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME, [
+ TEST_ADDRESS_1,
+ ]);
+ profileStorage.addresses.pullSyncChanges(); // force sync metadata
+
+ let [{ guid }] = await profileStorage.addresses.getAll();
+
+ equal(getSyncChangeCounter(profileStorage.addresses, guid), 1);
+ // try and remove a record stored locally with local changes
+ await checkingSyncChange("remove", async () =>
+ profileStorage.addresses.remove(guid, { sourceSync: true })
+ );
+
+ let record = await profileStorage.addresses.get(guid);
+ ok(record);
+ equal(getSyncChangeCounter(profileStorage.addresses, guid), 1);
+});
+
+add_task(async function test_remove_sourceSync_unknown() {
+ // remove a record not stored locally
+ let profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME, []);
+
+ let guid = profileStorage.addresses._generateGUID();
+ await checkingSyncChange("remove", async () =>
+ profileStorage.addresses.remove(guid, { sourceSync: true })
+ );
+
+ let tombstone = await findGUID(profileStorage.addresses, guid, {
+ includeDeleted: true,
+ });
+ ok(tombstone.deleted);
+ equal(getSyncChangeCounter(profileStorage.addresses, guid), 0);
+});
+
+add_task(async function test_remove_sourceSync_unchanged() {
+ // Remove a local record without a change counter.
+ let profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME, []);
+
+ let guid = profileStorage.addresses._generateGUID();
+ let addr = Object.assign({ guid, version: 1 }, TEST_ADDRESS_1);
+ // add a record with sourceSync to guarantee changeCounter == 0
+ await checkingSyncChange("add", async () =>
+ profileStorage.addresses.add(addr, { sourceSync: true })
+ );
+
+ equal(getSyncChangeCounter(profileStorage.addresses, guid), 0);
+
+ await checkingSyncChange("remove", async () =>
+ profileStorage.addresses.remove(guid, { sourceSync: true })
+ );
+
+ let tombstone = await findGUID(profileStorage.addresses, guid, {
+ includeDeleted: true,
+ });
+ ok(tombstone.deleted);
+ equal(getSyncChangeCounter(profileStorage.addresses, guid), 0);
+});
+
+add_task(async function test_pullSyncChanges() {
+ let profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME, [
+ TEST_ADDRESS_1,
+ TEST_ADDRESS_2,
+ ]);
+
+ let startAddresses = await profileStorage.addresses.getAll();
+ equal(startAddresses.length, 2);
+ // All should start without sync metadata
+ for (let { guid } of profileStorage.addresses._store.data.addresses) {
+ let changeCounter = getSyncChangeCounter(profileStorage.addresses, guid);
+ equal(changeCounter, -1);
+ }
+ profileStorage.addresses.pullSyncChanges(); // force sync metadata
+
+ let addedDirectGUID = profileStorage.addresses._generateGUID();
+ let testAddr = Object.assign(
+ { guid: addedDirectGUID, version: 1 },
+ TEST_ADDRESS_1,
+ TEST_ADDRESS_3
+ );
+
+ await checkingSyncChange("add", async () =>
+ profileStorage.addresses.add(testAddr, { sourceSync: true })
+ );
+
+ let tombstoneGUID = profileStorage.addresses._generateGUID();
+ await checkingSyncChange("add", async () =>
+ profileStorage.addresses.add(
+ { guid: tombstoneGUID, deleted: true },
+ { sourceSync: true }
+ )
+ );
+
+ let onChanged = TestUtils.topicObserved(
+ "formautofill-storage-changed",
+ (subject, data) => data == "remove"
+ );
+
+ profileStorage.addresses.remove(startAddresses[0].guid);
+ await onChanged;
+
+ let addresses = await profileStorage.addresses.getAll({
+ includeDeleted: true,
+ });
+
+ // Should contain changes with a change counter
+ let changes = profileStorage.addresses.pullSyncChanges();
+ equal(Object.keys(changes).length, 2);
+
+ ok(changes[startAddresses[0].guid].profile.deleted);
+ equal(changes[startAddresses[0].guid].counter, 2);
+
+ ok(!changes[startAddresses[1].guid].profile.deleted);
+ equal(changes[startAddresses[1].guid].counter, 1);
+
+ ok(
+ !changes[tombstoneGUID],
+ "Missing because it's a tombstone from sourceSync"
+ );
+ ok(!changes[addedDirectGUID], "Missing because it was added with sourceSync");
+
+ for (let address of addresses) {
+ let change = changes[address.guid];
+ if (!change) {
+ continue;
+ }
+ equal(change.profile.guid, address.guid);
+ let changeCounter = getSyncChangeCounter(
+ profileStorage.addresses,
+ change.profile.guid
+ );
+ equal(change.counter, changeCounter);
+ ok(!change.synced);
+ }
+});
+
+add_task(async function test_pullPushChanges() {
+ // round-trip changes between pull and push
+ let profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME, []);
+ let psa = profileStorage.addresses;
+
+ let guid1 = await psa.add(TEST_ADDRESS_1);
+ let guid2 = await psa.add(TEST_ADDRESS_2);
+ let guid3 = await psa.add(TEST_ADDRESS_3);
+
+ let changes = psa.pullSyncChanges();
+
+ equal(getSyncChangeCounter(psa, guid1), 1);
+ equal(getSyncChangeCounter(psa, guid2), 1);
+ equal(getSyncChangeCounter(psa, guid3), 1);
+
+ // between the pull and the push we change the second.
+ await psa.update(guid2, Object.assign({}, TEST_ADDRESS_2, { country: "AU" }));
+ equal(getSyncChangeCounter(psa, guid2), 2);
+ // and update the changeset to indicated we did update the first 2, but failed
+ // to update the 3rd for some reason.
+ changes[guid1].synced = true;
+ changes[guid2].synced = true;
+
+ psa.pushSyncChanges(changes);
+
+ // first was synced correctly.
+ equal(getSyncChangeCounter(psa, guid1), 0);
+ // second was synced correctly, but it had a change while syncing.
+ equal(getSyncChangeCounter(psa, guid2), 1);
+ // 3rd wasn't marked as having synced.
+ equal(getSyncChangeCounter(psa, guid3), 1);
+});
+
+add_task(async function test_changeGUID() {
+ let profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME, []);
+
+ let newguid = () => profileStorage.addresses._generateGUID();
+
+ let guid_synced = await profileStorage.addresses.add(TEST_ADDRESS_1);
+
+ // pullSyncChanges so guid_synced is flagged as syncing.
+ profileStorage.addresses.pullSyncChanges();
+
+ // and 2 items that haven't been synced.
+ let guid_u1 = await profileStorage.addresses.add(TEST_ADDRESS_2);
+ let guid_u2 = await profileStorage.addresses.add(TEST_ADDRESS_3);
+
+ // Change a non-existing guid
+ Assert.throws(
+ () => profileStorage.addresses.changeGUID(newguid(), newguid()),
+ /changeGUID: no source record/
+ );
+ // Change to a guid that already exists.
+ Assert.throws(
+ () => profileStorage.addresses.changeGUID(guid_u1, guid_u2),
+ /changeGUID: record with destination id exists already/
+ );
+ // Try and change a guid that's already been synced.
+ Assert.throws(
+ () => profileStorage.addresses.changeGUID(guid_synced, newguid()),
+ /changeGUID: existing record has already been synced/
+ );
+
+ // Change an item to itself makes no sense.
+ Assert.throws(
+ () => profileStorage.addresses.changeGUID(guid_u1, guid_u1),
+ /changeGUID: old and new IDs are the same/
+ );
+
+ // and one that works.
+ equal(
+ (await profileStorage.addresses.getAll({ includeDeleted: true })).length,
+ 3
+ );
+ let targetguid = newguid();
+ profileStorage.addresses.changeGUID(guid_u1, targetguid);
+ equal(
+ (await profileStorage.addresses.getAll({ includeDeleted: true })).length,
+ 3
+ );
+
+ ok(
+ await profileStorage.addresses.get(guid_synced),
+ "synced item still exists."
+ );
+ ok(
+ await profileStorage.addresses.get(guid_u2),
+ "guid we didn't touch still exists."
+ );
+ ok(await profileStorage.addresses.get(targetguid), "target guid exists.");
+ ok(
+ !(await profileStorage.addresses.get(guid_u1)),
+ "old guid no longer exists."
+ );
+});
+
+add_task(async function test_findDuplicateGUID() {
+ let profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME, [
+ TEST_ADDRESS_1,
+ ]);
+
+ let [record] = await profileStorage.addresses.getAll({ rawData: true });
+ await Assert.rejects(
+ profileStorage.addresses.findDuplicateGUID(record),
+ /Record \w+ already exists/,
+ "Should throw if the GUID already exists"
+ );
+
+ // Add a malformed record, passing `sourceSync` to work around the record
+ // normalization logic that would prevent this.
+ let timeLastModified = Date.now();
+ let timeCreated = timeLastModified - 60 * 1000;
+
+ await profileStorage.addresses.add(
+ {
+ guid: profileStorage.addresses._generateGUID(),
+ version: 1,
+ timeCreated,
+ timeLastModified,
+ },
+ { sourceSync: true }
+ );
+
+ strictEqual(
+ await profileStorage.addresses.findDuplicateGUID({
+ guid: profileStorage.addresses._generateGUID(),
+ version: 1,
+ timeCreated,
+ timeLastModified,
+ }),
+ null,
+ "Should ignore internal fields and malformed records"
+ );
+});
+
+add_task(async function test_reset() {
+ let profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME, [
+ TEST_ADDRESS_1,
+ TEST_ADDRESS_2,
+ ]);
+
+ let addresses = await profileStorage.addresses.getAll();
+ // All should start without sync metadata
+ for (let { guid } of addresses) {
+ let changeCounter = getSyncChangeCounter(profileStorage.addresses, guid);
+ equal(changeCounter, -1);
+ }
+ // pullSyncChanges should create the metadata.
+ profileStorage.addresses.pullSyncChanges();
+ addresses = await profileStorage.addresses.getAll();
+ for (let { guid } of addresses) {
+ let changeCounter = getSyncChangeCounter(profileStorage.addresses, guid);
+ equal(changeCounter, 1);
+ }
+ // and resetSync should wipe it.
+ profileStorage.addresses.resetSync();
+ addresses = await profileStorage.addresses.getAll();
+ for (let { guid } of addresses) {
+ let changeCounter = getSyncChangeCounter(profileStorage.addresses, guid);
+ equal(changeCounter, -1);
+ }
+});
diff --git a/browser/extensions/formautofill/test/unit/test_storage_tombstones.js b/browser/extensions/formautofill/test/unit/test_storage_tombstones.js
new file mode 100644
index 0000000000..584dac8043
--- /dev/null
+++ b/browser/extensions/formautofill/test/unit/test_storage_tombstones.js
@@ -0,0 +1,190 @@
+/**
+ * Tests tombstones in address/creditcard records.
+ */
+
+"use strict";
+
+let FormAutofillStorage;
+add_setup(async () => {
+ ({ FormAutofillStorage } = ChromeUtils.importESModule(
+ "resource://autofill/FormAutofillStorage.sys.mjs"
+ ));
+});
+
+const TEST_STORE_FILE_NAME = "test-tombstones.json";
+
+const TEST_ADDRESS_1 = {
+ "given-name": "Timothy",
+ "additional-name": "John",
+ "family-name": "Berners-Lee",
+ organization: "World Wide Web Consortium",
+ "street-address": "32 Vassar Street\nMIT Room 32-G524",
+ "address-level2": "Cambridge",
+ "address-level1": "MA",
+ "postal-code": "02139",
+ country: "US",
+ tel: "+1 617 253 5702",
+ email: "timbl@w3.org",
+};
+
+const TEST_CC_1 = {
+ "cc-name": "John Doe",
+ "cc-number": "4111111111111111",
+ "cc-exp-month": 4,
+ "cc-exp-year": 2017,
+};
+
+let do_check_tombstone_record = profile => {
+ Assert.ok(profile.deleted);
+ Assert.deepEqual(
+ Object.keys(profile).sort(),
+ ["guid", "timeLastModified", "deleted"].sort()
+ );
+};
+
+// Like add_task, but actually adds 2 - one for addresses and one for cards.
+function add_storage_task(test_function) {
+ add_task(async function () {
+ let path = getTempFile(TEST_STORE_FILE_NAME).path;
+ let profileStorage = new FormAutofillStorage(path);
+ let testCC1 = Object.assign({}, TEST_CC_1);
+ await profileStorage.initialize();
+
+ for (let [storage, record] of [
+ [profileStorage.addresses, TEST_ADDRESS_1],
+ [profileStorage.creditCards, testCC1],
+ ]) {
+ await test_function(storage, record);
+ }
+ });
+}
+
+add_storage_task(async function test_simple_tombstone(storage, record) {
+ info("check simple tombstone semantics");
+
+ let guid = await storage.add(record);
+ Assert.equal((await storage.getAll()).length, 1);
+
+ storage.remove(guid);
+
+ // should be unable to get it normally.
+ Assert.equal(await storage.get(guid), null);
+ // and getAll should also not return it.
+ Assert.equal((await storage.getAll()).length, 0);
+
+ // but getAll allows us to access deleted items - but we didn't create
+ // a tombstone here, so even that will not get it.
+ let all = await storage.getAll({ includeDeleted: true });
+ Assert.equal(all.length, 0);
+});
+
+add_storage_task(async function test_simple_synctombstone(storage, record) {
+ info("check simple tombstone semantics for synced records");
+
+ let guid = await storage.add(record);
+ Assert.equal((await storage.getAll()).length, 1);
+
+ storage.pullSyncChanges(); // force sync metadata, which triggers tombstone behaviour.
+
+ storage.remove(guid);
+
+ // should be unable to get it normally.
+ Assert.equal(await storage.get(guid), null);
+ // and getAll should also not return it.
+ Assert.equal((await storage.getAll()).length, 0);
+
+ // but getAll allows us to access deleted items.
+ let all = await storage.getAll({ includeDeleted: true });
+ Assert.equal(all.length, 1);
+
+ do_check_tombstone_record(all[0]);
+
+ // a tombstone got from API should look exactly the same as it got from the
+ // disk (besides "_sync").
+ let tombstoneInDisk = Object.assign(
+ {},
+ storage._store.data[storage._collectionName][0]
+ );
+ delete tombstoneInDisk._sync;
+ do_check_tombstone_record(tombstoneInDisk);
+});
+
+add_storage_task(async function test_add_tombstone(storage, record) {
+ info("Should be able to add a new tombstone");
+ let guid = await storage.add({ guid: "test-guid-1", deleted: true });
+
+ // should be unable to get it normally.
+ Assert.equal(await storage.get(guid), null);
+ // and getAll should also not return it.
+ Assert.equal((await storage.getAll()).length, 0);
+
+ // but getAll allows us to access deleted items.
+ let all = await storage.getAll({ rawData: true, includeDeleted: true });
+ Assert.equal(all.length, 1);
+
+ do_check_tombstone_record(all[0]);
+
+ // a tombstone got from API should look exactly the same as it got from the
+ // disk (besides "_sync").
+ let tombstoneInDisk = Object.assign(
+ {},
+ storage._store.data[storage._collectionName][0]
+ );
+ delete tombstoneInDisk._sync;
+ do_check_tombstone_record(tombstoneInDisk);
+});
+
+add_storage_task(async function test_add_tombstone_without_guid(
+ storage,
+ record
+) {
+ info("Should not be able to add a new tombstone without specifying the guid");
+ await Assert.rejects(storage.add({ deleted: true }), /Record missing GUID/);
+ Assert.equal((await storage.getAll({ includeDeleted: true })).length, 0);
+});
+
+add_storage_task(async function test_add_tombstone_existing_guid(
+ storage,
+ record
+) {
+ info(
+ "Should not be able to add a new tombstone when a record with that ID exists"
+ );
+ let guid = await storage.add(record);
+ await Assert.rejects(
+ storage.add({ guid, deleted: true }),
+ /a record with this GUID already exists/
+ );
+
+ // same if the existing item is already a tombstone.
+ await storage.add({ guid: "test-guid-1", deleted: true });
+ await Assert.rejects(
+ storage.add({ guid: "test-guid-1", deleted: true }),
+ /a record with this GUID already exists/
+ );
+});
+
+add_storage_task(async function test_update_tombstone(storage, record) {
+ info("Updating a tombstone should fail");
+ let guid = await storage.add({ guid: "test-guid-1", deleted: true });
+ await Assert.rejects(storage.update(guid, {}), /No matching record./);
+});
+
+add_storage_task(async function test_remove_existing_tombstone(
+ storage,
+ record
+) {
+ info("Removing a record that's already a tombstone should be a no-op");
+ let guid = await storage.add({
+ guid: "test-guid-1",
+ deleted: true,
+ timeLastModified: 1234,
+ });
+
+ storage.remove(guid);
+ let all = await storage.getAll({ rawData: true, includeDeleted: true });
+ Assert.equal(all.length, 1);
+
+ do_check_tombstone_record(all[0]);
+ equal(all[0].timeLastModified, 1234); // should not be updated to now().
+});
diff --git a/browser/extensions/formautofill/test/unit/test_sync.js b/browser/extensions/formautofill/test/unit/test_sync.js
new file mode 100644
index 0000000000..bc9467d7c9
--- /dev/null
+++ b/browser/extensions/formautofill/test/unit/test_sync.js
@@ -0,0 +1,1017 @@
+/**
+ * Tests sync functionality.
+ */
+
+/* import-globals-from ../../../../../services/sync/tests/unit/head_appinfo.js */
+/* import-globals-from ../../../../../services/common/tests/unit/head_helpers.js */
+/* import-globals-from ../../../../../services/sync/tests/unit/head_helpers.js */
+/* import-globals-from ../../../../../services/sync/tests/unit/head_http_server.js */
+
+"use strict";
+
+const { Service } = ChromeUtils.importESModule(
+ "resource://services-sync/service.sys.mjs"
+);
+const { SCORE_INCREMENT_XLARGE } = ChromeUtils.importESModule(
+ "resource://services-sync/constants.sys.mjs"
+);
+
+const { sanitizeStorageObject, AutofillRecord, AddressesEngine } =
+ ChromeUtils.importESModule("resource://autofill/FormAutofillSync.sys.mjs");
+
+Services.prefs.setCharPref("extensions.formautofill.loglevel", "Trace");
+initTestLogging("Trace");
+
+const TEST_STORE_FILE_NAME = "test-profile.json";
+
+const TEST_PROFILE_1 = {
+ "given-name": "Timothy",
+ "additional-name": "John",
+ "family-name": "Berners-Lee",
+ organization: "World Wide Web Consortium",
+ "street-address": "32 Vassar Street\nMIT Room 32-G524",
+ "address-level2": "Cambridge",
+ "address-level1": "MA",
+ "postal-code": "02139",
+ country: "US",
+ tel: "+16172535702",
+ email: "timbl@w3.org",
+ // A field this client doesn't "understand" from another client
+ "unknown-1": "some unknown data from another client",
+};
+
+const TEST_PROFILE_2 = {
+ "street-address": "Some Address",
+ country: "US",
+};
+
+async function expectLocalProfiles(profileStorage, expected) {
+ let profiles = await profileStorage.addresses.getAll({
+ rawData: true,
+ includeDeleted: true,
+ });
+ expected.sort((a, b) => a.guid.localeCompare(b.guid));
+ profiles.sort((a, b) => a.guid.localeCompare(b.guid));
+ try {
+ deepEqual(
+ profiles.map(p => p.guid),
+ expected.map(p => p.guid)
+ );
+ for (let i = 0; i < expected.length; i++) {
+ let thisExpected = expected[i];
+ let thisGot = profiles[i];
+ // always check "deleted".
+ equal(thisExpected.deleted, thisGot.deleted);
+ ok(objectMatches(thisGot, thisExpected));
+ }
+ } catch (ex) {
+ info("Comparing expected profiles:");
+ info(JSON.stringify(expected, undefined, 2));
+ info("against actual profiles:");
+ info(JSON.stringify(profiles, undefined, 2));
+ throw ex;
+ }
+}
+
+async function setup() {
+ let profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME);
+ // should always start with no profiles.
+ Assert.equal(
+ (await profileStorage.addresses.getAll({ includeDeleted: true })).length,
+ 0
+ );
+
+ Services.prefs.setCharPref(
+ "services.sync.log.logger.engine.addresses",
+ "Trace"
+ );
+ let engine = new AddressesEngine(Service);
+ await engine.initialize();
+ // Avoid accidental automatic sync due to our own changes
+ Service.scheduler.syncThreshold = 10000000;
+ let syncID = await engine.resetLocalSyncID();
+ let server = serverForUsers(
+ { foo: "password" },
+ {
+ meta: {
+ global: { engines: { addresses: { version: engine.version, syncID } } },
+ },
+ addresses: {},
+ }
+ );
+
+ Service.engineManager._engines.addresses = engine;
+ engine.enabled = true;
+ engine._store._storage = profileStorage.addresses;
+
+ generateNewKeys(Service.collectionKeys);
+
+ await SyncTestingInfrastructure(server);
+
+ let collection = server.user("foo").collection("addresses");
+
+ return { profileStorage, server, collection, engine };
+}
+
+async function cleanup(server) {
+ let promiseStartOver = promiseOneObserver("weave:service:start-over:finish");
+ await Service.startOver();
+ await promiseStartOver;
+ await promiseStopServer(server);
+}
+
+add_task(async function test_log_sanitization() {
+ let sanitized = sanitizeStorageObject(TEST_PROFILE_1);
+ // all strings have been mangled.
+ for (let key of Object.keys(TEST_PROFILE_1)) {
+ let val = TEST_PROFILE_1[key];
+ if (typeof val == "string") {
+ notEqual(sanitized[key], val);
+ }
+ }
+ // And check that stringifying a sync record is sanitized.
+ let record = new AutofillRecord("collection", "some-id");
+ record.entry = TEST_PROFILE_1;
+ let serialized = record.toString();
+ // None of the string values should appear in the output.
+ for (let key of Object.keys(TEST_PROFILE_1)) {
+ let val = TEST_PROFILE_1[key];
+ if (typeof val == "string") {
+ ok(!serialized.includes(val), `"${val}" shouldn't be in: ${serialized}`);
+ }
+ }
+});
+
+add_task(async function test_outgoing() {
+ let { profileStorage, server, collection, engine } = await setup();
+ try {
+ equal(engine._tracker.score, 0);
+ let existingGUID = await profileStorage.addresses.add(TEST_PROFILE_1);
+ // And a deleted item.
+ let deletedGUID = profileStorage.addresses._generateGUID();
+ await profileStorage.addresses.add({ guid: deletedGUID, deleted: true });
+
+ await expectLocalProfiles(profileStorage, [
+ {
+ guid: existingGUID,
+ },
+ {
+ guid: deletedGUID,
+ deleted: true,
+ },
+ ]);
+
+ await engine._tracker.asyncObserver.promiseObserversComplete();
+ // The tracker should have a score recorded for the 2 additions we had.
+ equal(engine._tracker.score, SCORE_INCREMENT_XLARGE * 2);
+
+ await engine.setLastSync(0);
+ await engine.sync();
+
+ Assert.equal(collection.count(), 2);
+ Assert.ok(collection.wbo(existingGUID));
+ Assert.ok(collection.wbo(deletedGUID));
+
+ await expectLocalProfiles(profileStorage, [
+ {
+ guid: existingGUID,
+ },
+ {
+ guid: deletedGUID,
+ deleted: true,
+ },
+ ]);
+
+ strictEqual(
+ getSyncChangeCounter(profileStorage.addresses, existingGUID),
+ 0
+ );
+ strictEqual(getSyncChangeCounter(profileStorage.addresses, deletedGUID), 0);
+ } finally {
+ await cleanup(server);
+ }
+});
+
+add_task(async function test_incoming_new() {
+ let { profileStorage, server, engine } = await setup();
+ try {
+ let profileID = Utils.makeGUID();
+ let deletedID = Utils.makeGUID();
+
+ server.insertWBO(
+ "foo",
+ "addresses",
+ new ServerWBO(
+ profileID,
+ encryptPayload({
+ id: profileID,
+ entry: Object.assign(
+ {
+ version: 1,
+ },
+ TEST_PROFILE_1
+ ),
+ }),
+ getDateForSync()
+ )
+ );
+ server.insertWBO(
+ "foo",
+ "addresses",
+ new ServerWBO(
+ deletedID,
+ encryptPayload({
+ id: deletedID,
+ deleted: true,
+ }),
+ getDateForSync()
+ )
+ );
+
+ // The tracker should start with no score.
+ equal(engine._tracker.score, 0);
+
+ await engine.setLastSync(0);
+ await engine.sync();
+
+ await expectLocalProfiles(profileStorage, [
+ {
+ guid: profileID,
+ },
+ {
+ guid: deletedID,
+ deleted: true,
+ },
+ ]);
+
+ strictEqual(getSyncChangeCounter(profileStorage.addresses, profileID), 0);
+ strictEqual(getSyncChangeCounter(profileStorage.addresses, deletedID), 0);
+
+ // Validate incoming records with unknown fields get stored
+ let localRecord = await profileStorage.addresses.get(profileID);
+ equal(localRecord["unknown-1"], TEST_PROFILE_1["unknown-1"]);
+
+ // The sync applied new records - ensure our tracker knew it came from
+ // sync and didn't bump the score.
+ equal(engine._tracker.score, 0);
+ } finally {
+ await cleanup(server);
+ }
+});
+
+add_task(async function test_incoming_existing() {
+ let { profileStorage, server, engine } = await setup();
+ try {
+ let guid1 = await profileStorage.addresses.add(TEST_PROFILE_1);
+ let guid2 = await profileStorage.addresses.add(TEST_PROFILE_2);
+
+ // an initial sync so we don't think they are locally modified.
+ await engine.setLastSync(0);
+ await engine.sync();
+
+ // now server records that modify the existing items.
+ let modifiedEntry1 = Object.assign({}, TEST_PROFILE_1, {
+ version: 1,
+ "given-name": "NewName",
+ });
+
+ let lastSync = await engine.getLastSync();
+ server.insertWBO(
+ "foo",
+ "addresses",
+ new ServerWBO(
+ guid1,
+ encryptPayload({
+ id: guid1,
+ entry: modifiedEntry1,
+ }),
+ lastSync + 10
+ )
+ );
+ server.insertWBO(
+ "foo",
+ "addresses",
+ new ServerWBO(
+ guid2,
+ encryptPayload({
+ id: guid2,
+ deleted: true,
+ }),
+ lastSync + 10
+ )
+ );
+
+ await engine.sync();
+
+ await expectLocalProfiles(profileStorage, [
+ Object.assign({}, modifiedEntry1, { guid: guid1 }),
+ { guid: guid2, deleted: true },
+ ]);
+ } finally {
+ await cleanup(server);
+ }
+});
+
+add_task(async function test_tombstones() {
+ let { profileStorage, server, collection, engine } = await setup();
+ try {
+ let existingGUID = await profileStorage.addresses.add(TEST_PROFILE_1);
+
+ await engine.setLastSync(0);
+ await engine.sync();
+
+ Assert.equal(collection.count(), 1);
+ let payload = collection.payloads()[0];
+ equal(payload.id, existingGUID);
+ equal(payload.deleted, undefined);
+
+ profileStorage.addresses.remove(existingGUID);
+ await engine.sync();
+
+ // should still exist, but now be a tombstone.
+ Assert.equal(collection.count(), 1);
+ payload = collection.payloads()[0];
+ equal(payload.id, existingGUID);
+ equal(payload.deleted, true);
+ } finally {
+ await cleanup(server);
+ }
+});
+
+add_task(async function test_applyIncoming_both_deleted() {
+ let { profileStorage, server, engine } = await setup();
+ try {
+ let guid = await profileStorage.addresses.add(TEST_PROFILE_1);
+
+ await engine.setLastSync(0);
+ await engine.sync();
+
+ // Delete synced record locally.
+ profileStorage.addresses.remove(guid);
+
+ // Delete same record remotely.
+ let lastSync = await engine.getLastSync();
+ let collection = server.user("foo").collection("addresses");
+ collection.insert(
+ guid,
+ encryptPayload({
+ id: guid,
+ deleted: true,
+ }),
+ lastSync + 10
+ );
+
+ await engine.sync();
+
+ ok(
+ !(await await profileStorage.addresses.get(guid)),
+ "Should not return record for locally deleted item"
+ );
+
+ let localRecords = await profileStorage.addresses.getAll({
+ includeDeleted: true,
+ });
+ equal(localRecords.length, 1, "Only tombstone should exist locally");
+
+ equal(collection.count(), 1, "Only tombstone should exist on server");
+ } finally {
+ await cleanup(server);
+ }
+});
+
+add_task(async function test_applyIncoming_nonexistent_tombstone() {
+ let { profileStorage, server, engine } = await setup();
+ try {
+ let guid = profileStorage.addresses._generateGUID();
+ let collection = server.user("foo").collection("addresses");
+ collection.insert(
+ guid,
+ encryptPayload({
+ id: guid,
+ deleted: true,
+ }),
+ getDateForSync()
+ );
+
+ await engine.setLastSync(0);
+ await engine.sync();
+
+ ok(
+ !(await profileStorage.addresses.get(guid)),
+ "Should not return record for uknown deleted item"
+ );
+ let localTombstone = (
+ await profileStorage.addresses.getAll({
+ includeDeleted: true,
+ })
+ ).find(record => record.guid == guid);
+ ok(localTombstone, "Should store tombstone for unknown item");
+ } finally {
+ await cleanup(server);
+ }
+});
+
+add_task(async function test_applyIncoming_incoming_deleted() {
+ let { profileStorage, server, engine } = await setup();
+ try {
+ let guid = await profileStorage.addresses.add(TEST_PROFILE_1);
+
+ await engine.setLastSync(0);
+ await engine.sync();
+
+ // Delete the record remotely.
+ let lastSync = await engine.getLastSync();
+ let collection = server.user("foo").collection("addresses");
+ collection.insert(
+ guid,
+ encryptPayload({
+ id: guid,
+ deleted: true,
+ }),
+ lastSync + 10
+ );
+
+ await engine.sync();
+
+ ok(
+ !(await profileStorage.addresses.get(guid)),
+ "Should delete unmodified item locally"
+ );
+
+ let localTombstone = (
+ await profileStorage.addresses.getAll({
+ includeDeleted: true,
+ })
+ ).find(record => record.guid == guid);
+ ok(localTombstone, "Should keep local tombstone for remotely deleted item");
+ strictEqual(
+ getSyncChangeCounter(profileStorage.addresses, guid),
+ 0,
+ "Local tombstone should be marked as syncing"
+ );
+ } finally {
+ await cleanup(server);
+ }
+});
+
+add_task(async function test_applyIncoming_incoming_restored() {
+ let { profileStorage, server, engine } = await setup();
+ try {
+ let guid = await profileStorage.addresses.add(TEST_PROFILE_1);
+
+ // Upload the record to the server.
+ await engine.setLastSync(0);
+ await engine.sync();
+
+ // Removing a synced record should write a tombstone.
+ profileStorage.addresses.remove(guid);
+
+ // Modify the deleted record remotely.
+ let collection = server.user("foo").collection("addresses");
+ let serverPayload = JSON.parse(
+ JSON.parse(collection.payload(guid)).ciphertext
+ );
+ serverPayload.entry["street-address"] = "I moved!";
+ let lastSync = await engine.getLastSync();
+ collection.insert(guid, encryptPayload(serverPayload), lastSync + 10);
+
+ // Sync again.
+ await engine.sync();
+
+ // We should replace our tombstone with the server's version.
+ let localRecord = await profileStorage.addresses.get(guid);
+ ok(
+ objectMatches(localRecord, {
+ "given-name": "Timothy",
+ "family-name": "Berners-Lee",
+ "street-address": "I moved!",
+ })
+ );
+
+ let maybeNewServerPayload = JSON.parse(
+ JSON.parse(collection.payload(guid)).ciphertext
+ );
+ deepEqual(
+ maybeNewServerPayload,
+ serverPayload,
+ "Should not change record on server"
+ );
+ } finally {
+ await cleanup(server);
+ }
+});
+
+add_task(async function test_applyIncoming_outgoing_restored() {
+ let { profileStorage, server, engine } = await setup();
+ try {
+ let guid = await profileStorage.addresses.add(TEST_PROFILE_1);
+
+ // Upload the record to the server.
+ await engine.setLastSync(0);
+ await engine.sync();
+
+ // Modify the local record.
+ let localCopy = Object.assign({}, TEST_PROFILE_1);
+ localCopy["street-address"] = "I moved!";
+ await profileStorage.addresses.update(guid, localCopy);
+
+ // Replace the record with a tombstone on the server.
+ let lastSync = await engine.getLastSync();
+ let collection = server.user("foo").collection("addresses");
+ collection.insert(
+ guid,
+ encryptPayload({
+ id: guid,
+ deleted: true,
+ }),
+ lastSync + 10
+ );
+
+ // Sync again.
+ await engine.sync();
+
+ // We should resurrect the record on the server.
+ let serverPayload = JSON.parse(
+ JSON.parse(collection.payload(guid)).ciphertext
+ );
+ ok(!serverPayload.deleted, "Should resurrect record on server");
+ ok(
+ objectMatches(serverPayload.entry, {
+ "given-name": "Timothy",
+ "family-name": "Berners-Lee",
+ "street-address": "I moved!",
+ // resurrection also beings back any unknown fields we had
+ "unknown-1": "some unknown data from another client",
+ })
+ );
+
+ let localRecord = await profileStorage.addresses.get(guid);
+ ok(localRecord, "Modified record should not be deleted locally");
+ } finally {
+ await cleanup(server);
+ }
+});
+
+// Unlike most sync engines, we want "both modified" to inspect the records,
+// and if materially different, create a duplicate.
+add_task(async function test_reconcile_both_modified_identical() {
+ let { profileStorage, server, engine } = await setup();
+ try {
+ // create a record locally.
+ let guid = await profileStorage.addresses.add(TEST_PROFILE_1);
+
+ // and an identical record on the server.
+ server.insertWBO(
+ "foo",
+ "addresses",
+ new ServerWBO(
+ guid,
+ encryptPayload({
+ id: guid,
+ entry: TEST_PROFILE_1,
+ }),
+ getDateForSync()
+ )
+ );
+
+ await engine.setLastSync(0);
+ await engine.sync();
+
+ await expectLocalProfiles(profileStorage, [{ guid }]);
+ } finally {
+ await cleanup(server);
+ }
+});
+
+add_task(async function test_incoming_dupes() {
+ let { profileStorage, server, engine } = await setup();
+ try {
+ // Create a profile locally, then sync to upload the new profile to the
+ // server.
+ let guid1 = await profileStorage.addresses.add(TEST_PROFILE_1);
+
+ await engine.setLastSync(0);
+ await engine.sync();
+
+ // Create another profile locally, but don't sync it yet.
+ await profileStorage.addresses.add(TEST_PROFILE_2);
+
+ // Now create two records on the server with the same contents as our local
+ // profiles, but different GUIDs.
+ let lastSync = await engine.getLastSync();
+ let guid1_dupe = Utils.makeGUID();
+ server.insertWBO(
+ "foo",
+ "addresses",
+ new ServerWBO(
+ guid1_dupe,
+ encryptPayload({
+ id: guid1_dupe,
+ entry: Object.assign(
+ {
+ version: 1,
+ },
+ TEST_PROFILE_1
+ ),
+ }),
+ lastSync + 10
+ )
+ );
+ let guid2_dupe = Utils.makeGUID();
+ server.insertWBO(
+ "foo",
+ "addresses",
+ new ServerWBO(
+ guid2_dupe,
+ encryptPayload({
+ id: guid2_dupe,
+ entry: Object.assign(
+ {
+ version: 1,
+ },
+ TEST_PROFILE_2
+ ),
+ }),
+ lastSync + 10
+ )
+ );
+
+ // Sync again. We should download `guid1_dupe` and `guid2_dupe`, then
+ // reconcile changes.
+ await engine.sync();
+
+ await expectLocalProfiles(profileStorage, [
+ // We uploaded `guid1` during the first sync. Even though its contents
+ // are the same as `guid1_dupe`, we keep both.
+ Object.assign({}, TEST_PROFILE_1, { guid: guid1 }),
+ Object.assign({}, TEST_PROFILE_1, { guid: guid1_dupe }),
+ // However, we didn't upload `guid2` before downloading `guid2_dupe`, so
+ // we *should* dedupe `guid2` to `guid2_dupe`.
+ Object.assign({}, TEST_PROFILE_2, { guid: guid2_dupe }),
+ ]);
+ } finally {
+ await cleanup(server);
+ }
+});
+
+add_task(async function test_dedupe_identical_unsynced() {
+ let { profileStorage, server, engine } = await setup();
+ try {
+ // create a record locally.
+ let localGuid = await profileStorage.addresses.add(TEST_PROFILE_1);
+
+ // and an identical record on the server but different GUID.
+ let remoteGuid = Utils.makeGUID();
+ notEqual(localGuid, remoteGuid);
+ server.insertWBO(
+ "foo",
+ "addresses",
+ new ServerWBO(
+ remoteGuid,
+ encryptPayload({
+ id: remoteGuid,
+ entry: Object.assign(
+ {
+ version: 1,
+ },
+ TEST_PROFILE_1
+ ),
+ }),
+ getDateForSync()
+ )
+ );
+
+ await engine.setLastSync(0);
+ await engine.sync();
+
+ // Should have 1 item locally with GUID changed to the remote one.
+ // There's no tombstone as the original was unsynced.
+ await expectLocalProfiles(profileStorage, [
+ {
+ guid: remoteGuid,
+ },
+ ]);
+ } finally {
+ await cleanup(server);
+ }
+});
+
+add_task(async function test_dedupe_identical_synced() {
+ let { profileStorage, server, engine } = await setup();
+ try {
+ // create a record locally.
+ let localGuid = await profileStorage.addresses.add(TEST_PROFILE_1);
+
+ // sync it - it will no longer be a candidate for de-duping.
+ await engine.setLastSync(0);
+ await engine.sync();
+
+ // and an identical record on the server but different GUID.
+ let lastSync = await engine.getLastSync();
+ let remoteGuid = Utils.makeGUID();
+ server.insertWBO(
+ "foo",
+ "addresses",
+ new ServerWBO(
+ remoteGuid,
+ encryptPayload({
+ id: remoteGuid,
+ entry: Object.assign(
+ {
+ version: 1,
+ },
+ TEST_PROFILE_1
+ ),
+ }),
+ lastSync + 10
+ )
+ );
+
+ await engine.sync();
+
+ // Should have 2 items locally, since the first was synced.
+ await expectLocalProfiles(profileStorage, [
+ { guid: localGuid },
+ { guid: remoteGuid },
+ ]);
+ } finally {
+ await cleanup(server);
+ }
+});
+
+add_task(async function test_dedupe_multiple_candidates() {
+ let { profileStorage, server, engine } = await setup();
+ try {
+ // It's possible to have duplicate local profiles, with the same fields but
+ // different GUIDs. After a node reassignment, or after disconnecting and
+ // reconnecting to Sync, we might dedupe a local record A to a remote record
+ // B, if we see B before we download and apply A. Since A and B are dupes,
+ // that's OK. We'll write a tombstone for A when we dedupe A to B, and
+ // overwrite that tombstone when we see A.
+
+ let localRecord = {
+ "given-name": "Mark",
+ "family-name": "Hammond",
+ organization: "Mozilla",
+ country: "AU",
+ tel: "+12345678910",
+ };
+ let serverRecord = Object.assign(
+ {
+ version: 1,
+ },
+ localRecord
+ );
+
+ // We don't pass `sourceSync` so that the records are marked as NEW.
+ let aGuid = await profileStorage.addresses.add(localRecord);
+ let bGuid = await profileStorage.addresses.add(localRecord);
+
+ // Insert B before A.
+ server.insertWBO(
+ "foo",
+ "addresses",
+ new ServerWBO(
+ bGuid,
+ encryptPayload({
+ id: bGuid,
+ entry: serverRecord,
+ }),
+ getDateForSync()
+ )
+ );
+ server.insertWBO(
+ "foo",
+ "addresses",
+ new ServerWBO(
+ aGuid,
+ encryptPayload({
+ id: aGuid,
+ entry: serverRecord,
+ }),
+ getDateForSync()
+ )
+ );
+
+ await engine.setLastSync(0);
+ await engine.sync();
+
+ await expectLocalProfiles(profileStorage, [
+ {
+ guid: aGuid,
+ "given-name": "Mark",
+ "family-name": "Hammond",
+ organization: "Mozilla",
+ country: "AU",
+ tel: "+12345678910",
+ },
+ {
+ guid: bGuid,
+ "given-name": "Mark",
+ "family-name": "Hammond",
+ organization: "Mozilla",
+ country: "AU",
+ tel: "+12345678910",
+ },
+ ]);
+ // Make sure these are both syncing.
+ strictEqual(
+ getSyncChangeCounter(profileStorage.addresses, aGuid),
+ 0,
+ "A should be marked as syncing"
+ );
+ strictEqual(
+ getSyncChangeCounter(profileStorage.addresses, bGuid),
+ 0,
+ "B should be marked as syncing"
+ );
+ } finally {
+ await cleanup(server);
+ }
+});
+
+// Unlike most sync engines, we want "both modified" to inspect the records,
+// and if materially different, create a duplicate.
+add_task(async function test_reconcile_both_modified_conflict() {
+ let { profileStorage, server, engine } = await setup();
+ try {
+ // create a record locally.
+ let guid = await profileStorage.addresses.add(TEST_PROFILE_1);
+
+ // Upload the record to the server.
+ await engine.setLastSync(0);
+ await engine.sync();
+
+ strictEqual(
+ getSyncChangeCounter(profileStorage.addresses, guid),
+ 0,
+ "Original record should be marked as syncing"
+ );
+
+ // Change the same field locally and on the server.
+ let localCopy = Object.assign({}, TEST_PROFILE_1);
+ localCopy["street-address"] = "I moved!";
+ await profileStorage.addresses.update(guid, localCopy);
+
+ let lastSync = await engine.getLastSync();
+ let collection = server.user("foo").collection("addresses");
+ let serverPayload = JSON.parse(
+ JSON.parse(collection.payload(guid)).ciphertext
+ );
+ serverPayload.entry["street-address"] = "I moved, too!";
+ collection.insert(guid, encryptPayload(serverPayload), lastSync + 10);
+
+ // Sync again.
+ await engine.sync();
+
+ // Since we wait to pull changes until we're ready to upload, both records
+ // should now exist on the server; we don't need a follow-up sync.
+ let serverPayloads = collection.payloads();
+ equal(serverPayloads.length, 2, "Both records should exist on server");
+
+ let forkedPayload = serverPayloads.find(payload => payload.id != guid);
+ ok(forkedPayload, "Forked record should exist on server");
+
+ await expectLocalProfiles(profileStorage, [
+ {
+ guid,
+ "given-name": "Timothy",
+ "family-name": "Berners-Lee",
+ "street-address": "I moved, too!",
+ },
+ {
+ guid: forkedPayload.id,
+ "given-name": "Timothy",
+ "family-name": "Berners-Lee",
+ "street-address": "I moved!",
+ },
+ ]);
+
+ let changeCounter = getSyncChangeCounter(
+ profileStorage.addresses,
+ forkedPayload.id
+ );
+ strictEqual(changeCounter, 0, "Forked record should be marked as syncing");
+ } finally {
+ await cleanup(server);
+ }
+});
+
+add_task(async function test_wipe() {
+ let { profileStorage, server, engine } = await setup();
+ try {
+ let guid = await profileStorage.addresses.add(TEST_PROFILE_1);
+
+ await expectLocalProfiles(profileStorage, [{ guid }]);
+
+ let promiseObserved = promiseOneObserver("formautofill-storage-changed");
+
+ await engine._wipeClient();
+
+ let { subject, data } = await promiseObserved;
+ Assert.equal(
+ subject.wrappedJSObject.sourceSync,
+ true,
+ "it should be noted this came from sync"
+ );
+ Assert.equal(
+ subject.wrappedJSObject.collectionName,
+ "addresses",
+ "got the correct collection"
+ );
+ Assert.equal(data, "removeAll", "a removeAll should be noted");
+
+ await expectLocalProfiles(profileStorage, []);
+ } finally {
+ await cleanup(server);
+ }
+});
+
+// Other clients might have data that we aren't able to process/understand yet
+// We should keep that data and ensure when we sync we don't lose that data
+add_task(async function test_full_roundtrip_unknown_data() {
+ let { profileStorage, server, engine } = await setup();
+ try {
+ let profileID = Utils.makeGUID();
+
+ info("Incoming records with unknown fields are properly stored");
+ // Insert a record onto the server
+ server.insertWBO(
+ "foo",
+ "addresses",
+ new ServerWBO(
+ profileID,
+ encryptPayload({
+ id: profileID,
+ entry: Object.assign(
+ {
+ version: 1,
+ },
+ TEST_PROFILE_1
+ ),
+ }),
+ getDateForSync()
+ )
+ );
+
+ // The tracker should start with no score.
+ equal(engine._tracker.score, 0);
+
+ await engine.setLastSync(0);
+ await engine.sync();
+
+ await expectLocalProfiles(profileStorage, [
+ {
+ guid: profileID,
+ },
+ ]);
+
+ strictEqual(getSyncChangeCounter(profileStorage.addresses, profileID), 0);
+
+ // The sync applied new records - ensure our tracker knew it came from
+ // sync and didn't bump the score.
+ equal(engine._tracker.score, 0);
+
+ // Validate incoming records with unknown fields are correctly stored
+ let localRecord = await profileStorage.addresses.get(profileID);
+ equal(localRecord["unknown-1"], TEST_PROFILE_1["unknown-1"]);
+
+ let onChanged = TestUtils.topicObserved(
+ "formautofill-storage-changed",
+ (subject, data) => data == "update"
+ );
+
+ // Validate we can update the records locally and not drop any unknown fields
+ info("Unknown fields are sent back up to the server");
+
+ // Modify the local copy
+ let localCopy = Object.assign({}, TEST_PROFILE_1);
+ localCopy["street-address"] = "I moved!";
+ await profileStorage.addresses.update(profileID, localCopy);
+ await onChanged;
+ await profileStorage._saveImmediately();
+
+ let updatedCopy = await profileStorage.addresses.get(profileID);
+ equal(updatedCopy["street-address"], "I moved!");
+
+ // Sync our changes to the server
+ await engine.setLastSync(0);
+ await engine.sync();
+
+ let collection = server.user("foo").collection("addresses");
+
+ Assert.ok(collection.wbo(profileID));
+ let serverPayload = JSON.parse(
+ JSON.parse(collection.payload(profileID)).ciphertext
+ );
+
+ // The server has the updated field as well as any unknown fields
+ equal(
+ serverPayload.entry["unknown-1"],
+ "some unknown data from another client"
+ );
+ equal(serverPayload.entry["street-address"], "I moved!");
+ } finally {
+ await cleanup(server);
+ }
+});
diff --git a/browser/extensions/formautofill/test/unit/test_sync_deprecate_credit_card_v4.js b/browser/extensions/formautofill/test/unit/test_sync_deprecate_credit_card_v4.js
new file mode 100644
index 0000000000..2362796b0c
--- /dev/null
+++ b/browser/extensions/formautofill/test/unit/test_sync_deprecate_credit_card_v4.js
@@ -0,0 +1,248 @@
+/**
+ * Tests sync functionality.
+ */
+
+/* import-globals-from ../../../../../services/sync/tests/unit/head_appinfo.js */
+/* import-globals-from ../../../../../services/common/tests/unit/head_helpers.js */
+/* import-globals-from ../../../../../services/sync/tests/unit/head_helpers.js */
+/* import-globals-from ../../../../../services/sync/tests/unit/head_http_server.js */
+
+"use strict";
+
+const { Service } = ChromeUtils.importESModule(
+ "resource://services-sync/service.sys.mjs"
+);
+
+const { CreditCardsEngine } = ChromeUtils.importESModule(
+ "resource://autofill/FormAutofillSync.sys.mjs"
+);
+
+Services.prefs.setCharPref("extensions.formautofill.loglevel", "Trace");
+initTestLogging("Trace");
+
+const TEST_STORE_FILE_NAME = "test-profile.json";
+
+const TEST_CREDIT_CARD_1 = {
+ guid: "86d961c7717a",
+ "cc-name": "John Doe",
+ "cc-number": "4111111111111111",
+ "cc-exp-month": 4,
+ "cc-exp-year": new Date().getFullYear(),
+};
+
+const TEST_CREDIT_CARD_2 = {
+ guid: "cf57a7ac3539",
+ "cc-name": "Timothy Berners-Lee",
+ "cc-number": "4929001587121045",
+ "cc-exp-month": 12,
+ "cc-exp-year": new Date().getFullYear() + 10,
+};
+
+function expectProfiles(profiles, expected) {
+ expected.sort((a, b) => a.guid.localeCompare(b.guid));
+ profiles.sort((a, b) => a.guid.localeCompare(b.guid));
+ try {
+ deepEqual(
+ profiles.map(p => p.guid),
+ expected.map(p => p.guid)
+ );
+ for (let i = 0; i < expected.length; i++) {
+ let thisExpected = expected[i];
+ let thisGot = profiles[i];
+ // always check "deleted".
+ equal(thisExpected.deleted, thisGot.deleted);
+ ok(objectMatches(thisGot, thisExpected));
+ }
+ } catch (ex) {
+ info("Comparing expected profiles:");
+ info(JSON.stringify(expected, undefined, 2));
+ info("against actual profiles:");
+ info(JSON.stringify(profiles, undefined, 2));
+ throw ex;
+ }
+}
+
+async function expectServerProfiles(collection, expected) {
+ const profiles = collection
+ .payloads()
+ .map(payload => Object.assign({ guid: payload.id }, payload.entry));
+ expectProfiles(profiles, expected);
+}
+
+async function expectLocalProfiles(profileStorage, expected) {
+ const profiles = await profileStorage.creditCards.getAll({
+ rawData: true,
+ includeDeleted: true,
+ });
+ expectProfiles(profiles, expected);
+}
+
+async function setup() {
+ let profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME);
+ // should always start with no profiles.
+ Assert.equal(
+ (await profileStorage.creditCards.getAll({ includeDeleted: true })).length,
+ 0
+ );
+
+ Services.prefs.setCharPref(
+ "services.sync.log.logger.engine.CreditCards",
+ "Trace"
+ );
+ let engine = new CreditCardsEngine(Service);
+ await engine.initialize();
+ // Avoid accidental automatic sync due to our own changes
+ Service.scheduler.syncThreshold = 10000000;
+ let syncID = await engine.resetLocalSyncID();
+ let server = serverForUsers(
+ { foo: "password" },
+ {
+ meta: {
+ global: {
+ engines: { creditcards: { version: engine.version, syncID } },
+ },
+ },
+ creditcards: {},
+ }
+ );
+
+ Service.engineManager._engines.creditcards = engine;
+ engine.enabled = true;
+ engine._store._storage = profileStorage.creditCards;
+
+ generateNewKeys(Service.collectionKeys);
+
+ await SyncTestingInfrastructure(server);
+
+ let collection = server.user("foo").collection("creditcards");
+
+ return { profileStorage, server, collection, engine };
+}
+
+async function cleanup(server) {
+ let promiseStartOver = promiseOneObserver("weave:service:start-over:finish");
+ await Service.startOver();
+ await promiseStartOver;
+ await promiseStopServer(server);
+}
+
+function getTestRecords(profileStorage, version) {
+ return [
+ Object.assign({ version }, TEST_CREDIT_CARD_1),
+ Object.assign({ version }, TEST_CREDIT_CARD_2),
+ ];
+}
+
+function setupServerRecords(server, records) {
+ for (const record of records) {
+ server.insertWBO(
+ "foo",
+ "creditcards",
+ new ServerWBO(
+ record.guid,
+ encryptPayload({
+ id: record.guid,
+ entry: Object.assign({}, record),
+ }),
+ getDateForSync()
+ )
+ );
+ }
+}
+
+/**
+ * We want to setup old records and run init() to migrate records.
+ * However, We don't have an easy way to setup an older version record with
+ * init() function now.
+ * So as a workaround, we simulate the behavior by directly setting data and then
+ * run migration.
+ */
+async function setupLocalProfilesAndRunMigration(profileStorage, records) {
+ for (const record of records) {
+ profileStorage._store.data.creditCards.push(Object.assign({}, record));
+ }
+ await Promise.all(
+ profileStorage.creditCards._data.map(async (record, index) =>
+ profileStorage.creditCards._migrateRecord(record, index)
+ )
+ );
+}
+
+// local v3, server v4
+add_task(async function test_local_v3_server_v4() {
+ let { collection, profileStorage, server, engine } = await setup();
+
+ const V3_RECORDS = getTestRecords(profileStorage, 3);
+ const V4_RECORDS = getTestRecords(profileStorage, 4);
+
+ await setupLocalProfilesAndRunMigration(profileStorage, V3_RECORDS);
+ setupServerRecords(server, V4_RECORDS);
+
+ await engine.setLastSync(0);
+ await engine.sync();
+
+ await expectServerProfiles(collection, V3_RECORDS);
+ await expectLocalProfiles(profileStorage, V3_RECORDS);
+
+ await cleanup(server);
+});
+
+// local v4, server empty
+add_task(async function test_local_v4_server_empty() {
+ let { collection, profileStorage, server, engine } = await setup();
+ const V3_RECORDS = getTestRecords(profileStorage, 3);
+ const V4_RECORDS = getTestRecords(profileStorage, 4);
+
+ await setupLocalProfilesAndRunMigration(profileStorage, V4_RECORDS);
+
+ await engine.setLastSync(0);
+ await engine.sync();
+
+ await expectServerProfiles(collection, V3_RECORDS);
+ await expectLocalProfiles(profileStorage, V3_RECORDS);
+
+ await cleanup(server);
+});
+
+// local v4, server v3
+add_task(async function test_local_v4_server_v3() {
+ let { collection, profileStorage, server, engine } = await setup();
+ const V3_RECORDS = getTestRecords(profileStorage, 3);
+ const V4_RECORDS = getTestRecords(profileStorage, 4);
+
+ await setupLocalProfilesAndRunMigration(profileStorage, V4_RECORDS);
+ setupServerRecords(server, V3_RECORDS);
+
+ // local should be v3 before syncing.
+ await expectLocalProfiles(profileStorage, V3_RECORDS);
+
+ await engine.setLastSync(0);
+ await engine.sync();
+
+ await expectServerProfiles(collection, V3_RECORDS);
+ await expectLocalProfiles(profileStorage, V3_RECORDS);
+
+ await cleanup(server);
+});
+
+// local v4, server v4
+add_task(async function test_local_v4_server_v4() {
+ let { collection, profileStorage, server, engine } = await setup();
+ const V3_RECORDS = getTestRecords(profileStorage, 3);
+ const V4_RECORDS = getTestRecords(profileStorage, 4);
+
+ await setupLocalProfilesAndRunMigration(profileStorage, V4_RECORDS);
+ setupServerRecords(server, V4_RECORDS);
+
+ // local should be v3 before syncing and then we ignore
+ // incoming v4 from server
+ await expectLocalProfiles(profileStorage, V3_RECORDS);
+
+ await engine.setLastSync(0);
+ await engine.sync();
+
+ await expectServerProfiles(collection, V3_RECORDS);
+ await expectLocalProfiles(profileStorage, V3_RECORDS);
+
+ await cleanup(server);
+});
diff --git a/browser/extensions/formautofill/test/unit/test_toOneLineAddress.js b/browser/extensions/formautofill/test/unit/test_toOneLineAddress.js
new file mode 100644
index 0000000000..ee04c8d1d5
--- /dev/null
+++ b/browser/extensions/formautofill/test/unit/test_toOneLineAddress.js
@@ -0,0 +1,64 @@
+"use strict";
+
+var FormAutofillUtils;
+add_setup(async () => {
+ ({ FormAutofillUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/shared/FormAutofillUtils.sys.mjs"
+ ));
+});
+
+add_task(async function test_getCategoriesFromFieldNames() {
+ const TEST_CASES = [
+ {
+ strings: ["A", "B", "C", "D"],
+ expectedValue: "A B C D",
+ },
+ {
+ strings: ["A", "B", "", "D"],
+ expectedValue: "A B D",
+ },
+ {
+ strings: ["", "B", "", "D"],
+ expectedValue: "B D",
+ },
+ {
+ strings: [null, "B", " ", "D"],
+ expectedValue: "B D",
+ },
+ {
+ strings: "A B C",
+ expectedValue: "A B C",
+ },
+ {
+ strings: "A\nB\n\n\nC",
+ expectedValue: "A B C",
+ },
+ {
+ strings: "A B \nC",
+ expectedValue: "A B C",
+ },
+ {
+ strings: "A-B-C",
+ expectedValue: "A B C",
+ delimiter: "-",
+ },
+ {
+ strings: "A B\n \nC",
+ expectedValue: "A B C",
+ },
+ {
+ strings: null,
+ expectedValue: "",
+ },
+ ];
+
+ for (let tc of TEST_CASES) {
+ let result;
+ if (tc.delimiter) {
+ result = FormAutofillUtils.toOneLineAddress(tc.strings, tc.delimiter);
+ } else {
+ result = FormAutofillUtils.toOneLineAddress(tc.strings);
+ }
+ Assert.equal(result, tc.expectedValue);
+ }
+});
diff --git a/browser/extensions/formautofill/test/unit/test_transformFields.js b/browser/extensions/formautofill/test/unit/test_transformFields.js
new file mode 100644
index 0000000000..47ba396e06
--- /dev/null
+++ b/browser/extensions/formautofill/test/unit/test_transformFields.js
@@ -0,0 +1,972 @@
+/**
+ * Tests the transform algorithm in profileStorage.
+ */
+
+"use strict";
+
+let FormAutofillStorage;
+add_setup(async () => {
+ ({ FormAutofillStorage } = ChromeUtils.importESModule(
+ "resource://autofill/FormAutofillStorage.sys.mjs"
+ ));
+});
+
+const TEST_STORE_FILE_NAME = "test-profile.json";
+
+const ADDRESS_COMPUTE_TESTCASES = [
+ // Name
+ {
+ description: "Has split names",
+ address: {
+ "given-name": "Timothy",
+ "additional-name": "John",
+ "family-name": "Berners-Lee",
+ },
+ expectedResult: {
+ "given-name": "Timothy",
+ "additional-name": "John",
+ "family-name": "Berners-Lee",
+ name: "Timothy John Berners-Lee",
+ },
+ },
+ {
+ description: "Has split CJK names",
+ address: {
+ "given-name": "德明",
+ "family-name": "孫",
+ },
+ expectedResult: {
+ "given-name": "德明",
+ "family-name": "孫",
+ name: "孫德明",
+ },
+ },
+
+ // Address
+ {
+ description: '"street-address" with single line',
+ address: {
+ "street-address": "single line",
+ },
+ expectedResult: {
+ "street-address": "single line",
+ "address-line1": "single line",
+ },
+ },
+ {
+ description: '"street-address" with multiple lines',
+ address: {
+ "street-address": "line1\nline2\nline3",
+ },
+ expectedResult: {
+ "street-address": "line1\nline2\nline3",
+ "address-line1": "line1",
+ "address-line2": "line2",
+ "address-line3": "line3",
+ },
+ },
+ {
+ description: '"street-address" with multiple lines but line2 is omitted',
+ address: {
+ "street-address": "line1\n\nline3",
+ },
+ expectedResult: {
+ "street-address": "line1\n\nline3",
+ "address-line1": "line1",
+ "address-line2": undefined,
+ "address-line3": "line3",
+ },
+ },
+ {
+ description: '"street-address" with 4 lines',
+ address: {
+ "street-address": "line1\nline2\nline3\nline4",
+ },
+ expectedResult: {
+ "street-address": "line1\nline2\nline3\nline4",
+ "address-line1": "line1",
+ "address-line2": "line2",
+ "address-line3": "line3 line4",
+ },
+ },
+ {
+ description: '"street-address" with blank lines',
+ address: {
+ "street-address": "line1\n \nline3\n \nline5",
+ },
+ expectedResult: {
+ "street-address": "line1\n \nline3\n \nline5",
+ "address-line1": "line1",
+ "address-line2": undefined,
+ "address-line3": "line3 line5",
+ },
+ },
+
+ // Country
+ {
+ description: 'Has "country"',
+ address: {
+ country: "US",
+ },
+ expectedResult: {
+ country: "US",
+ "country-name": "United States",
+ },
+ },
+
+ // Tel
+ {
+ description: '"tel" with US country code',
+ address: {
+ tel: "+16172535702",
+ },
+ expectedResult: {
+ tel: "+16172535702",
+ "tel-country-code": "+1",
+ "tel-national": "6172535702",
+ "tel-area-code": "617",
+ "tel-local": "2535702",
+ "tel-local-prefix": "253",
+ "tel-local-suffix": "5702",
+ },
+ },
+ {
+ description: '"tel" with TW country code (the components won\'t be parsed)',
+ address: {
+ tel: "+886212345678",
+ },
+ expectedResult: {
+ tel: "+886212345678",
+ "tel-country-code": "+886",
+ "tel-national": "0212345678",
+ "tel-area-code": undefined,
+ "tel-local": undefined,
+ "tel-local-prefix": undefined,
+ "tel-local-suffix": undefined,
+ },
+ },
+ {
+ description: '"tel" without country code so use "US" as default resion',
+ address: {
+ tel: "6172535702",
+ },
+ expectedResult: {
+ tel: "+16172535702",
+ "tel-country-code": "+1",
+ "tel-national": "6172535702",
+ "tel-area-code": "617",
+ "tel-local": "2535702",
+ "tel-local-prefix": "253",
+ "tel-local-suffix": "5702",
+ },
+ },
+ {
+ description: '"tel" without country code but "country" is "TW"',
+ address: {
+ tel: "0212345678",
+ country: "TW",
+ },
+ expectedResult: {
+ tel: "+886212345678",
+ "tel-country-code": "+886",
+ "tel-national": "0212345678",
+ "tel-area-code": undefined,
+ "tel-local": undefined,
+ "tel-local-prefix": undefined,
+ "tel-local-suffix": undefined,
+ },
+ },
+ {
+ description: '"tel" can\'t be parsed so leave it as-is',
+ address: {
+ tel: "12345",
+ },
+ expectedResult: {
+ tel: "12345",
+ "tel-country-code": undefined,
+ "tel-national": "12345",
+ "tel-area-code": undefined,
+ "tel-local": undefined,
+ "tel-local-prefix": undefined,
+ "tel-local-suffix": undefined,
+ },
+ },
+];
+
+const ADDRESS_NORMALIZE_TESTCASES = [
+ // Name
+ {
+ description: 'Has "name", and the split names are omitted',
+ address: {
+ name: "Timothy John Berners-Lee",
+ },
+ expectedResult: {
+ "given-name": "Timothy",
+ "additional-name": "John",
+ "family-name": "Berners-Lee",
+ },
+ },
+ {
+ description: 'Has both "name" and split names',
+ address: {
+ name: "John Doe",
+ "given-name": "Timothy",
+ "additional-name": "John",
+ "family-name": "Berners-Lee",
+ },
+ expectedResult: {
+ "given-name": "Timothy",
+ "additional-name": "John",
+ "family-name": "Berners-Lee",
+ },
+ },
+ {
+ description: 'Has "name", and some of split names are omitted',
+ address: {
+ name: "John Doe",
+ "given-name": "Timothy",
+ },
+ expectedResult: {
+ "given-name": "Timothy",
+ "family-name": "Doe",
+ },
+ },
+
+ // Address
+ {
+ description: 'Has "address-line1~3" and "street-address" is omitted',
+ address: {
+ "address-line1": "line1",
+ "address-line2": "line2",
+ "address-line3": "line3",
+ },
+ expectedResult: {
+ "street-address": "line1\nline2\nline3",
+ },
+ },
+ {
+ description: 'Has both "address-line1~3" and "street-address"',
+ address: {
+ "street-address": "street address",
+ "address-line1": "line1",
+ "address-line2": "line2",
+ "address-line3": "line3",
+ },
+ expectedResult: {
+ "street-address": "street address",
+ },
+ },
+ {
+ description: 'Has "address-line2~3" and single-line "street-address"',
+ address: {
+ "street-address": "street address",
+ "address-line2": "line2",
+ "address-line3": "line3",
+ },
+ expectedResult: {
+ "street-address": "street address\nline2\nline3",
+ },
+ },
+ {
+ description: 'Has "address-line2~3" and multiple-line "street-address"',
+ address: {
+ "street-address": "street address\nstreet address line 2",
+ "address-line2": "line2",
+ "address-line3": "line3",
+ },
+ expectedResult: {
+ "street-address": "street address\nstreet address line 2",
+ },
+ },
+ {
+ description: 'Has only "address-line1~2"',
+ address: {
+ "address-line1": "line1",
+ "address-line2": "line2",
+ },
+ expectedResult: {
+ "street-address": "line1\nline2",
+ },
+ },
+ {
+ description: 'Has only "address-line1"',
+ address: {
+ "address-line1": "line1",
+ },
+ expectedResult: {
+ "street-address": "line1",
+ },
+ },
+ {
+ description: 'Has only "address-line2~3"',
+ address: {
+ "address-line2": "line2",
+ "address-line3": "line3",
+ },
+ expectedResult: {
+ "street-address": "\nline2\nline3",
+ },
+ },
+ {
+ description: 'Has only "address-line2"',
+ address: {
+ "address-line2": "line2",
+ },
+ expectedResult: {
+ "street-address": "\nline2",
+ },
+ },
+
+ // Country
+ {
+ description: 'Has "country" in lowercase',
+ address: {
+ country: "us",
+ },
+ expectedResult: {
+ country: "US",
+ },
+ },
+ {
+ description: 'Has unknown "country"',
+ address: {
+ "given-name": "John", // Make sure it won't be an empty record.
+ country: "AA",
+ },
+ expectedResult: {
+ country: undefined,
+ },
+ },
+ {
+ description: 'Has "country-name"',
+ address: {
+ "country-name": "united states",
+ },
+ expectedResult: {
+ country: "US",
+ "country-name": "United States",
+ },
+ },
+ {
+ description: 'Has alternative "country-name"',
+ address: {
+ "country-name": "america",
+ },
+ expectedResult: {
+ country: "US",
+ "country-name": "United States",
+ },
+ },
+ {
+ description: 'Has "country-name" as a substring',
+ address: {
+ "country-name": "test america test",
+ },
+ expectedResult: {
+ country: "US",
+ "country-name": "United States",
+ },
+ },
+ {
+ description: 'Has "country-name" as part of a word',
+ address: {
+ "given-name": "John", // Make sure it won't be an empty record.
+ "country-name": "TRUST",
+ },
+ expectedResult: {
+ country: undefined,
+ "country-name": undefined,
+ },
+ },
+ {
+ description: 'Has unknown "country-name"',
+ address: {
+ "given-name": "John", // Make sure it won't be an empty record.
+ "country-name": "unknown country name",
+ },
+ expectedResult: {
+ country: undefined,
+ "country-name": undefined,
+ },
+ },
+ {
+ description: 'Has "country" and unknown "country-name"',
+ address: {
+ country: "us",
+ "country-name": "unknown country name",
+ },
+ expectedResult: {
+ country: "US",
+ "country-name": "United States",
+ },
+ },
+ {
+ description: 'Has "country-name" and unknown "country"',
+ address: {
+ "given-name": "John", // Make sure it won't be an empty record.
+ country: "AA",
+ "country-name": "united states",
+ },
+ expectedResult: {
+ country: undefined,
+ "country-name": undefined,
+ },
+ },
+ {
+ description: 'Has unsupported "country"',
+ address: {
+ "given-name": "John", // Make sure it won't be an empty record.
+ country: "XX",
+ },
+ expectedResult: {
+ country: undefined,
+ "country-name": undefined,
+ },
+ },
+
+ // Tel
+ {
+ description: 'Has "tel" with country code',
+ address: {
+ tel: "+16172535702",
+ },
+ expectedResult: {
+ tel: "+16172535702",
+ },
+ },
+ {
+ description: 'Has "tel" without country code but "country" is set',
+ address: {
+ tel: "0212345678",
+ country: "TW",
+ },
+ expectedResult: {
+ tel: "+886212345678",
+ },
+ },
+ {
+ description:
+ 'Has "tel" without country code and "country" so use "US" as default region',
+ address: {
+ tel: "6172535702",
+ },
+ expectedResult: {
+ tel: "+16172535702",
+ },
+ },
+ {
+ description: '"tel" can\'t be parsed so leave it as-is',
+ address: {
+ tel: "12345",
+ },
+ expectedResult: {
+ tel: "12345",
+ },
+ },
+ {
+ description: 'Has a valid tel-local format "tel"',
+ address: {
+ tel: "1234567",
+ },
+ expectedResult: {
+ tel: "1234567",
+ },
+ },
+ {
+ description: 'Has "tel-national" and "tel-country-code"',
+ address: {
+ "tel-national": "0212345678",
+ "tel-country-code": "+886",
+ },
+ expectedResult: {
+ tel: "+886212345678",
+ },
+ },
+ {
+ description: 'Has "tel-national" and "country"',
+ address: {
+ "tel-national": "0212345678",
+ country: "TW",
+ },
+ expectedResult: {
+ tel: "+886212345678",
+ },
+ },
+ {
+ description: 'Has "tel-national", "tel-country-code" and "country"',
+ address: {
+ "tel-national": "0212345678",
+ "tel-country-code": "+886",
+ country: "US",
+ },
+ expectedResult: {
+ tel: "+886212345678",
+ },
+ },
+ {
+ description: 'Has "tel-area-code" and "tel-local"',
+ address: {
+ "tel-area-code": "617",
+ "tel-local": "2535702",
+ },
+ expectedResult: {
+ tel: "+16172535702",
+ },
+ },
+ {
+ description:
+ 'Has "tel-area-code", "tel-local-prefix" and "tel-local-suffix"',
+ address: {
+ "tel-area-code": "617",
+ "tel-local-prefix": "253",
+ "tel-local-suffix": "5702",
+ },
+ expectedResult: {
+ tel: "+16172535702",
+ },
+ },
+];
+
+const CREDIT_CARD_COMPUTE_TESTCASES = [
+ // Name
+ {
+ description: 'Has "cc-name"',
+ creditCard: {
+ "cc-name": "Timothy John Berners-Lee",
+ "cc-number": "4929001587121045",
+ },
+ expectedResult: {
+ "cc-name": "Timothy John Berners-Lee",
+ "cc-number": "************1045",
+ "cc-given-name": "Timothy",
+ "cc-additional-name": "John",
+ "cc-family-name": "Berners-Lee",
+ },
+ },
+
+ // Card Number
+ {
+ description: "Number should be encrypted and masked",
+ creditCard: {
+ "cc-number": "4929001587121045",
+ },
+ expectedResult: {
+ "cc-number": "************1045",
+ },
+ },
+
+ // Expiration Date
+ {
+ description: 'Has "cc-exp-year" and "cc-exp-month"',
+ creditCard: {
+ "cc-exp-month": 12,
+ "cc-exp-year": 2022,
+ "cc-number": "4929001587121045",
+ },
+ expectedResult: {
+ "cc-exp-month": 12,
+ "cc-exp-year": 2022,
+ "cc-exp": "2022-12",
+ "cc-number": "************1045",
+ },
+ },
+ {
+ description: 'Has only "cc-exp-month"',
+ creditCard: {
+ "cc-exp-month": 12,
+ "cc-number": "4929001587121045",
+ },
+ expectedResult: {
+ "cc-exp-month": 12,
+ "cc-exp": undefined,
+ "cc-number": "************1045",
+ },
+ },
+ {
+ description: 'Has only "cc-exp-year"',
+ creditCard: {
+ "cc-exp-year": 2022,
+ "cc-number": "4929001587121045",
+ },
+ expectedResult: {
+ "cc-exp-year": 2022,
+ "cc-exp": undefined,
+ "cc-number": "************1045",
+ },
+ },
+];
+
+const CREDIT_CARD_NORMALIZE_TESTCASES = [
+ // Name
+ {
+ description: 'Has both "cc-name" and the split name fields',
+ creditCard: {
+ "cc-name": "Timothy John Berners-Lee",
+ "cc-given-name": "John",
+ "cc-family-name": "Doe",
+ "cc-number": "4929001587121045",
+ },
+ expectedResult: {
+ "cc-name": "Timothy John Berners-Lee",
+ "cc-number": "4929001587121045",
+ },
+ },
+ {
+ description: "Has only the split name fields",
+ creditCard: {
+ "cc-given-name": "John",
+ "cc-family-name": "Doe",
+ "cc-number": "4929001587121045",
+ },
+ expectedResult: {
+ "cc-name": "John Doe",
+ "cc-number": "4929001587121045",
+ },
+ },
+
+ // Card Number
+ {
+ description: "Regular number",
+ creditCard: {
+ "cc-number": "4929001587121045",
+ },
+ expectedResult: {
+ "cc-number": "4929001587121045",
+ },
+ },
+ {
+ description: "Number with spaces",
+ creditCard: {
+ "cc-number": "4111 1111 1111 1111",
+ },
+ expectedResult: {
+ "cc-number": "4111111111111111",
+ },
+ },
+ {
+ description: "Number with hyphens",
+ creditCard: {
+ "cc-number": "4111-1111-1111-1111",
+ },
+ expectedResult: {
+ "cc-number": "4111111111111111",
+ },
+ },
+
+ // Expiration Date
+ {
+ description: 'Has "cc-exp" formatted "yyyy-mm"',
+ creditCard: {
+ "cc-number": "4929001587121045",
+ "cc-exp": "2022-12",
+ },
+ expectedResult: {
+ "cc-exp-month": 12,
+ "cc-exp-year": 2022,
+ "cc-number": "4929001587121045",
+ },
+ },
+ {
+ description: 'Has "cc-exp" formatted "yyyy/mm"',
+ creditCard: {
+ "cc-number": "4929001587121045",
+ "cc-exp": "2022/12",
+ },
+ expectedResult: {
+ "cc-exp-month": 12,
+ "cc-exp-year": 2022,
+ "cc-number": "4929001587121045",
+ },
+ },
+ {
+ description: 'Has "cc-exp" formatted "yyyy-m"',
+ creditCard: {
+ "cc-number": "4929001587121045",
+ "cc-exp": "2022-3",
+ },
+ expectedResult: {
+ "cc-exp-month": 3,
+ "cc-exp-year": 2022,
+ "cc-number": "4929001587121045",
+ },
+ },
+ {
+ description: 'Has "cc-exp" formatted "yyyy/m"',
+ creditCard: {
+ "cc-number": "4929001587121045",
+ "cc-exp": "2022/3",
+ },
+ expectedResult: {
+ "cc-exp-month": 3,
+ "cc-exp-year": 2022,
+ "cc-number": "4929001587121045",
+ },
+ },
+ {
+ description: 'Has "cc-exp" formatted "mm-yyyy"',
+ creditCard: {
+ "cc-number": "4929001587121045",
+ "cc-exp": "12-2022",
+ },
+ expectedResult: {
+ "cc-exp-month": 12,
+ "cc-exp-year": 2022,
+ "cc-number": "4929001587121045",
+ },
+ },
+ {
+ description: 'Has "cc-exp" formatted "mm/yyyy"',
+ creditCard: {
+ "cc-number": "4929001587121045",
+ "cc-exp": "12/2022",
+ },
+ expectedResult: {
+ "cc-exp-month": 12,
+ "cc-exp-year": 2022,
+ "cc-number": "4929001587121045",
+ },
+ },
+ {
+ description: 'Has "cc-exp" formatted "m-yyyy"',
+ creditCard: {
+ "cc-number": "4929001587121045",
+ "cc-exp": "3-2022",
+ },
+ expectedResult: {
+ "cc-exp-month": 3,
+ "cc-exp-year": 2022,
+ "cc-number": "4929001587121045",
+ },
+ },
+ {
+ description: 'Has "cc-exp" formatted "m/yyyy"',
+ creditCard: {
+ "cc-number": "4929001587121045",
+ "cc-exp": "3/2022",
+ },
+ expectedResult: {
+ "cc-exp-month": 3,
+ "cc-exp-year": 2022,
+ "cc-number": "4929001587121045",
+ },
+ },
+ {
+ description: 'Has "cc-exp" formatted "mm-yy"',
+ creditCard: {
+ "cc-number": "4929001587121045",
+ "cc-exp": "12-22",
+ },
+ expectedResult: {
+ "cc-exp-month": 12,
+ "cc-exp-year": 2022,
+ "cc-number": "4929001587121045",
+ },
+ },
+ {
+ description: 'Has "cc-exp" formatted "mm/yy"',
+ creditCard: {
+ "cc-number": "4929001587121045",
+ "cc-exp": "12/22",
+ },
+ expectedResult: {
+ "cc-exp-month": 12,
+ "cc-exp-year": 2022,
+ "cc-number": "4929001587121045",
+ },
+ },
+ {
+ description: 'Has "cc-exp" formatted "yy-mm"',
+ creditCard: {
+ "cc-number": "4929001587121045",
+ "cc-exp": "22-12",
+ },
+ expectedResult: {
+ "cc-exp-month": 12,
+ "cc-exp-year": 2022,
+ "cc-number": "4929001587121045",
+ },
+ },
+ {
+ description: 'Has "cc-exp" formatted "yy/mm"',
+ creditCard: {
+ "cc-exp": "22/12",
+ "cc-number": "4929001587121045",
+ },
+ expectedResult: {
+ "cc-exp-month": 12,
+ "cc-exp-year": 2022,
+ "cc-number": "4929001587121045",
+ },
+ },
+ {
+ description: 'Has "cc-exp" formatted "mmyy"',
+ creditCard: {
+ "cc-exp": "1222",
+ "cc-number": "4929001587121045",
+ },
+ expectedResult: {
+ "cc-exp-month": 12,
+ "cc-exp-year": 2022,
+ "cc-number": "4929001587121045",
+ },
+ },
+ {
+ description: 'Has "cc-exp" formatted "yymm"',
+ creditCard: {
+ "cc-exp": "2212",
+ "cc-number": "4929001587121045",
+ },
+ expectedResult: {
+ "cc-exp-month": 12,
+ "cc-exp-year": 2022,
+ "cc-number": "4929001587121045",
+ },
+ },
+ {
+ description: 'Has "cc-exp" with spaces',
+ creditCard: {
+ "cc-exp": " 2033-11 ",
+ "cc-number": "4929001587121045",
+ },
+ expectedResult: {
+ "cc-exp-month": 11,
+ "cc-exp-year": 2033,
+ "cc-number": "4929001587121045",
+ },
+ },
+ {
+ description: 'Has invalid "cc-exp"',
+ creditCard: {
+ "cc-number": "4111111111111111", // Make sure it won't be an empty record.
+ "cc-exp": "99-9999",
+ },
+ expectedResult: {
+ "cc-exp-month": undefined,
+ "cc-exp-year": undefined,
+ },
+ },
+ {
+ description: 'Has both "cc-exp-*" and "cc-exp"',
+ creditCard: {
+ "cc-exp": "2022-12",
+ "cc-exp-month": 3,
+ "cc-exp-year": 2030,
+ "cc-number": "4929001587121045",
+ },
+ expectedResult: {
+ "cc-exp-month": 3,
+ "cc-exp-year": 2030,
+ "cc-number": "4929001587121045",
+ },
+ },
+ {
+ description: 'Has only "cc-exp-year" and "cc-exp"',
+ creditCard: {
+ "cc-exp": "2022-12",
+ "cc-exp-year": 2030,
+ "cc-number": "4929001587121045",
+ },
+ expectedResult: {
+ "cc-exp-month": 12,
+ "cc-exp-year": 2022,
+ "cc-number": "4929001587121045",
+ },
+ },
+ {
+ description: 'Has only "cc-exp-month" and "cc-exp"',
+ creditCard: {
+ "cc-exp": "2022-12",
+ "cc-exp-month": 3,
+ "cc-number": "4929001587121045",
+ },
+ expectedResult: {
+ "cc-exp-month": 12,
+ "cc-exp-year": 2022,
+ "cc-number": "4929001587121045",
+ },
+ },
+];
+
+let do_check_record_matches = (expectedRecord, record) => {
+ for (let key in expectedRecord) {
+ Assert.equal(expectedRecord[key], record[key]);
+ }
+};
+
+add_task(async function test_computeAddressFields() {
+ let path = getTempFile(TEST_STORE_FILE_NAME).path;
+
+ let profileStorage = new FormAutofillStorage(path);
+ await profileStorage.initialize();
+
+ for (let testcase of ADDRESS_COMPUTE_TESTCASES) {
+ info("Verify testcase: " + testcase.description);
+
+ let guid = await profileStorage.addresses.add(testcase.address);
+ let address = await profileStorage.addresses.get(guid);
+ do_check_record_matches(testcase.expectedResult, address);
+
+ profileStorage.addresses.remove(guid);
+ }
+
+ await profileStorage._finalize();
+});
+
+add_task(async function test_normalizeAddressFields() {
+ let path = getTempFile(TEST_STORE_FILE_NAME).path;
+
+ let profileStorage = new FormAutofillStorage(path);
+ await profileStorage.initialize();
+
+ for (let testcase of ADDRESS_NORMALIZE_TESTCASES) {
+ info("Verify testcase: " + testcase.description);
+
+ let guid = await profileStorage.addresses.add(testcase.address);
+ let address = await profileStorage.addresses.get(guid);
+ do_check_record_matches(testcase.expectedResult, address);
+
+ profileStorage.addresses.remove(guid);
+ }
+
+ await profileStorage._finalize();
+});
+
+add_task(async function test_computeCreditCardFields() {
+ let path = getTempFile(TEST_STORE_FILE_NAME).path;
+
+ let profileStorage = new FormAutofillStorage(path);
+ await profileStorage.initialize();
+
+ for (let testcase of CREDIT_CARD_COMPUTE_TESTCASES) {
+ info("Verify testcase: " + testcase.description);
+
+ let guid = await profileStorage.creditCards.add(testcase.creditCard);
+ let creditCard = await profileStorage.creditCards.get(guid);
+ do_check_record_matches(testcase.expectedResult, creditCard);
+
+ profileStorage.creditCards.remove(guid);
+ }
+
+ await profileStorage._finalize();
+});
+
+add_task(async function test_normalizeCreditCardFields() {
+ let path = getTempFile(TEST_STORE_FILE_NAME).path;
+
+ let profileStorage = new FormAutofillStorage(path);
+ await profileStorage.initialize();
+
+ for (let testcase of CREDIT_CARD_NORMALIZE_TESTCASES) {
+ info("Verify testcase: " + testcase.description);
+
+ let guid = await profileStorage.creditCards.add(testcase.creditCard);
+ let creditCard = await profileStorage.creditCards.get(guid, {
+ rawData: true,
+ });
+ do_check_record_matches(testcase.expectedResult, creditCard);
+
+ profileStorage.creditCards.remove(guid);
+ }
+
+ await profileStorage._finalize();
+});
diff --git a/browser/extensions/formautofill/test/unit/xpcshell.ini b/browser/extensions/formautofill/test/unit/xpcshell.ini
new file mode 100644
index 0000000000..bb0a71729c
--- /dev/null
+++ b/browser/extensions/formautofill/test/unit/xpcshell.ini
@@ -0,0 +1,100 @@
+[DEFAULT]
+skip-if =
+ (os == "linux") && ccov # bug 1821945
+ toolkit == 'android' # bug 1730213
+firefox-appdir = browser
+head = head.js
+support-files =
+ ../fixtures/**
+prefs =
+ extensions.formautofill.heuristics.visibilityCheckThreshold=0
+
+[test_activeStatus.js]
+[test_addressComponent_city.js]
+head = head_addressComponent.js
+[test_addressComponent_country.js]
+head = head_addressComponent.js
+[test_addressComponent_email.js]
+head = head_addressComponent.js
+[test_addressComponent_name.js]
+head = head_addressComponent.js
+[test_addressComponent_organization.js]
+head = head_addressComponent.js
+[test_addressComponent_postal_code.js]
+head = head_addressComponent.js
+[test_addressComponent_state.js]
+head = head_addressComponent.js
+[test_addressComponent_street_address.js]
+head = head_addressComponent.js
+[test_addressComponent_tel.js]
+head = head_addressComponent.js
+[test_addressDataLoader.js]
+[test_addressRecords.js]
+skip-if =
+ apple_silicon # bug 1729554
+[test_autofillFormFields.js]
+skip-if =
+ tsan # Times out, bug 1612707
+ apple_silicon # bug 1729554
+[test_clearPopulatedForm.js]
+[test_collectFormFields.js]
+[test_createRecords.js]
+[test_creditCardRecords.js]
+skip-if =
+ tsan # Times out, bug 1612707
+ apple_silicon # bug 1729554
+[test_extractLabelStrings.js]
+[test_findLabelElements.js]
+[test_getAdaptedProfiles.js]
+[test_getAdaptedProfiles_locales.js]
+[test_getCategoriesFromFieldNames.js]
+[test_getCreditCardLogo.js]
+[test_getFormInputDetails.js]
+[test_getInfo.js]
+[test_getRecords.js]
+skip-if =
+ tsan # Times out, bug 1612707
+ apple_silicon # bug 1729554
+[test_isAddressAutofillAvailable.js]
+[test_isCJKName.js]
+[test_isCreditCardAutofillAvailable.js]
+[test_isCreditCardOrAddressFieldType.js]
+[test_known_strings.js]
+[test_markAsAutofillField.js]
+[test_migrateRecords.js]
+skip-if = tsan # Times out, bug 1612707
+[test_nameUtils.js]
+[test_onFormSubmitted.js]
+skip-if = tsan # Times out, bug 1612707
+[test_parseStreetAddress.js]
+[test_parseAddressFormat.js]
+[test_previewFormFields.js]
+[test_profileAutocompleteResult.js]
+[test_phoneNumber.js]
+[test_reconcile.js]
+skip-if =
+ tsan # Times out, bug 1612707
+ apple_silicon # bug 1729554
+[test_savedFieldNames.js]
+[test_toOneLineAddress.js]
+[test_storage_tombstones.js]
+skip-if =
+ tsan # Times out, bug 1612707
+ apple_silicon # bug 1729554
+[test_storage_remove.js]
+skip-if =
+ tsan # Times out, bug 1612707
+ apple_silicon # bug 1729554
+[test_storage_syncfields.js]
+[test_transformFields.js]
+skip-if =
+ tsan # Times out, bug 1612707
+ apple_silicon # bug 1729554
+[test_sync.js]
+head = head.js ../../../../../services/sync/tests/unit/head_appinfo.js ../../../../../services/common/tests/unit/head_helpers.js ../../../../../services/sync/tests/unit/head_helpers.js ../../../../../services/sync/tests/unit/head_http_server.js
+skip-if = tsan # Times out, bug 1612707
+[test_sync_deprecate_credit_card_v4.js]
+head = head.js ../../../../../services/sync/tests/unit/head_appinfo.js ../../../../../services/common/tests/unit/head_helpers.js ../../../../../services/sync/tests/unit/head_helpers.js ../../../../../services/sync/tests/unit/head_http_server.js
+skip-if =
+ tsan # Times out, bug 1612707
+ apple_silicon # bug 1729554