Bug 1359978 - (Part 2) Add credit card support to ProfileStorage.jsm. r=MattN, r=steveck
MozReview-Commit-ID: JzojiHGcomp
--- a/browser/extensions/formautofill/ProfileStorage.jsm
+++ b/browser/extensions/formautofill/ProfileStorage.jsm
@@ -36,16 +36,41 @@
* address-line3,
*
* // metadata
* timeCreated, // in ms
* timeLastUsed, // in ms
* timeLastModified, // in ms
* timesUsed
* }
+ * ],
+ * creditCards: [
+ * {
+ * guid, // 12 characters
+ *
+ * // credit card fields
+ * cc-name,
+ * cc-number-encrypted,
+ * cc-number-masked, // e.g. ************1234
+ * cc-exp-month,
+ * cc-exp-year, // 2-digit year will be converted to 4 digits
+ * // upon saving
+ *
+ * // computed fields (These fields are not stored in the file as they are
+ * // generated at runtime.)
+ * cc-given-name,
+ * cc-additional-name,
+ * cc-family-name,
+ *
+ * // metadata
+ * timeCreated, // in ms
+ * timeLastUsed, // in ms
+ * timeLastModified, // in ms
+ * timesUsed
+ * }
* ]
* }
*/
"use strict";
// We expose a singleton from this module. Some tests may import the
// constructor via a backstage pass.
@@ -81,30 +106,38 @@ const VALID_PROFILE_FIELDS = [
"address-level2",
"address-level1",
"postal-code",
"country",
"tel",
"email",
];
+const VALID_CREDIT_CARD_FIELDS = [
+ "cc-name",
+ "cc-number-encrypted",
+ "cc-number-masked",
+ "cc-exp-month",
+ "cc-exp-year",
+];
+
const INTERNAL_FIELDS = [
"guid",
"timeCreated",
"timeLastUsed",
"timeLastModified",
"timesUsed",
];
/**
* Class that manipulates records in a specified collection.
*
* Note that it is responsible for converting incoming data to a consistent
* format in the storage. For example, computed fields will be transformed to
- * the original fields.
+ * the original fields and 2-digit years will be calculated into 4 digits.
*/
class AutofillRecords {
/**
* Creates an AutofillRecords.
*
* @param {JSONFile} store
* An instance of JSONFile.
* @param {string} collectionName
@@ -406,31 +439,126 @@ class Addresses extends AutofillRecords
// Concatenate "address-line*" if "street-address" is omitted.
if (!profile["street-address"]) {
profile["street-address"] = addressLines.join("\n");
}
}
}
}
+class CreditCards extends AutofillRecords {
+ constructor(store) {
+ super(store, "creditCards", VALID_CREDIT_CARD_FIELDS);
+ }
+
+ _recordReadProcessor(creditCard, {noComputedFields} = {}) {
+ if (noComputedFields) {
+ return;
+ }
+
+ // Compute split names
+ if (creditCard["cc-name"]) {
+ let nameParts = FormAutofillNameUtils.splitName(creditCard["cc-name"]);
+ if (nameParts.given) {
+ creditCard["cc-given-name"] = nameParts.given;
+ }
+ if (nameParts.middle) {
+ creditCard["cc-additional-name"] = nameParts.middle;
+ }
+ if (nameParts.family) {
+ creditCard["cc-family-name"] = nameParts.family;
+ }
+ }
+ }
+
+ _recordWriteProcessor(creditCard) {
+ // Fields that should not be set by content.
+ delete creditCard["cc-number-encrypted"];
+ delete creditCard["cc-number-masked"];
+
+ // Validate and encrypt credit card numbers, and calculate the masked numbers
+ if (creditCard["cc-number"]) {
+ let ccNumber = creditCard["cc-number"].replace(/\s/g, "");
+ delete creditCard["cc-number"];
+
+ if (!/^\d+$/.test(ccNumber)) {
+ throw new Error("Credit card number contains invalid characters.");
+ }
+
+ // TODO: Encrypt cc-number here (bug 1337314).
+ // e.g. creditCard["cc-number-encrypted"] = Encrypt(creditCard["cc-number"]);
+
+ if (ccNumber.length > 4) {
+ creditCard["cc-number-masked"] = "*".repeat(ccNumber.length - 4) + ccNumber.substr(-4);
+ } else {
+ creditCard["cc-number-masked"] = ccNumber;
+ }
+ }
+
+ // Normalize name
+ if (creditCard["cc-given-name"] || creditCard["cc-additional-name"] || creditCard["cc-family-name"]) {
+ if (!creditCard["cc-name"]) {
+ creditCard["cc-name"] = FormAutofillNameUtils.joinNameParts({
+ given: creditCard["cc-given-name"],
+ middle: creditCard["cc-additional-name"],
+ family: creditCard["cc-family-name"],
+ });
+ }
+
+ delete creditCard["cc-given-name"];
+ delete creditCard["cc-additional-name"];
+ delete creditCard["cc-family-name"];
+ }
+
+ // Validate expiry date
+ if (creditCard["cc-exp-month"]) {
+ let expMonth = parseInt(creditCard["cc-exp-month"], 10);
+ if (isNaN(expMonth) || expMonth < 1 || expMonth > 12) {
+ delete creditCard["cc-exp-month"];
+ } else {
+ creditCard["cc-exp-month"] = expMonth;
+ }
+ }
+ if (creditCard["cc-exp-year"]) {
+ let expYear = parseInt(creditCard["cc-exp-year"], 10);
+ if (isNaN(expYear) || expYear < 0) {
+ delete creditCard["cc-exp-year"];
+ } else if (expYear < 100) {
+ // Enforce 4 digits years.
+ creditCard["cc-exp-year"] = expYear + 2000;
+ } else {
+ creditCard["cc-exp-year"] = expYear;
+ }
+ }
+ }
+}
+
function ProfileStorage(path) {
this._path = path;
this._initializePromise = null;
this.INTERNAL_FIELDS = INTERNAL_FIELDS;
}
ProfileStorage.prototype = {
get addresses() {
if (!this._addresses) {
this._store.ensureDataReady();
this._addresses = new Addresses(this._store);
}
return this._addresses;
},
+ get creditCards() {
+ if (!this._creditCards) {
+ this._store.ensureDataReady();
+ this._creditCards = new CreditCards(this._store);
+ }
+ return this._creditCards;
+ },
+
/**
* Loads the profile data from file to memory.
*
* @returns {Promise}
* @resolves When the operation finished successfully.
* @rejects JavaScript exception.
*/
initialize() {
@@ -444,16 +572,19 @@ ProfileStorage.prototype = {
return this._initializePromise;
},
_dataPostProcessor(data) {
data.version = SCHEMA_VERSION;
if (!data.addresses) {
data.addresses = [];
}
+ if (!data.creditCards) {
+ data.creditCards = [];
+ }
return data;
},
// For test only.
_saveImmediately() {
return this._store._save();
},
};
new file mode 100644
--- /dev/null
+++ b/browser/extensions/formautofill/test/unit/test_creditCardRecords.js
@@ -0,0 +1,325 @@
+/**
+ * Tests ProfileStorage object with creditCards records.
+ */
+
+"use strict";
+
+const {ProfileStorage} = Cu.import("resource://formautofill/ProfileStorage.jsm", {});
+
+const TEST_STORE_FILE_NAME = "test-credit-card.json";
+
+const TEST_CREDIT_CARD_1 = {
+ "cc-name": "John Doe",
+ "cc-number": "1234567812345678",
+ "cc-exp-month": 4,
+ "cc-exp-year": 2017,
+};
+
+const TEST_CREDIT_CARD_2 = {
+ "cc-name": "Timothy Berners-Lee",
+ "cc-number": "1111222233334444",
+ "cc-exp-month": 12,
+ "cc-exp-year": 2022,
+};
+
+const TEST_CREDIT_CARD_3 = {
+ "cc-number": "9999888877776666",
+ "cc-exp-month": 1,
+ "cc-exp-year": 2000,
+};
+
+const TEST_CREDIT_CARD_WITH_2_DIGITS_YEAR = {
+ "cc-number": "1234123412341234",
+ "cc-exp-month": 1,
+ "cc-exp-year": 12,
+};
+
+const TEST_CREDIT_CARD_WITH_INVALID_FIELD = {
+ "cc-name": "John Doe",
+ invalidField: "INVALID",
+};
+
+const TEST_CREDIT_CARD_WITH_INVALID_EXPIRY_DATE = {
+ "cc-name": "John Doe",
+ "cc-number": "1111222233334444",
+ "cc-exp-month": 13,
+ "cc-exp-year": -3,
+};
+
+const TEST_CREDIT_CARD_WITH_SPACES_BETWEEN_DIGITS = {
+ "cc-name": "John Doe",
+ "cc-number": "1111 2222 3333 4444",
+};
+
+const TEST_CREDIT_CARD_WITH_INVALID_NUMBERS = {
+ "cc-name": "John Doe",
+ "cc-number": "abcdefg",
+};
+
+let prepareTestCreditCards = async function(path) {
+ let profileStorage = new ProfileStorage(path);
+ await profileStorage.initialize();
+
+ let onChanged = TestUtils.topicObserved("formautofill-storage-changed",
+ (subject, data) => data == "add");
+ profileStorage.creditCards.add(TEST_CREDIT_CARD_1);
+ await onChanged;
+ profileStorage.creditCards.add(TEST_CREDIT_CARD_2);
+ await profileStorage._saveImmediately();
+};
+
+let reCCNumber = /^(\*+)(.{4})$/;
+
+let do_check_credit_card_matches = (creditCardWithMeta, creditCard) => {
+ for (let key in creditCard) {
+ if (key == "cc-number") {
+ do_check_eq(creditCardWithMeta["cc-number"], undefined);
+
+ // check "cc-number-encrypted" after encryption lands (bug 1337314).
+
+ let matches = reCCNumber.exec(creditCardWithMeta["cc-number-masked"]);
+ do_check_neq(matches, null);
+ do_check_eq(creditCardWithMeta["cc-number-masked"].length, creditCard["cc-number"].length);
+ do_check_eq(creditCard["cc-number"].endsWith(matches[2]), true);
+ } else {
+ do_check_eq(creditCardWithMeta[key], creditCard[key]);
+ }
+ }
+};
+
+add_task(async function test_initialize() {
+ let path = getTempFile(TEST_STORE_FILE_NAME).path;
+ let profileStorage = new ProfileStorage(path);
+ await profileStorage.initialize();
+
+ do_check_eq(profileStorage._store.data.version, 1);
+ do_check_eq(profileStorage._store.data.creditCards.length, 0);
+
+ let data = profileStorage._store.data;
+ Assert.deepEqual(data.creditCards, []);
+
+ await profileStorage._saveImmediately();
+
+ profileStorage = new ProfileStorage(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 ProfileStorage(path);
+ await profileStorage.initialize();
+
+ let creditCards = profileStorage.creditCards.getAll();
+
+ do_check_eq(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.
+ do_check_eq(creditCards[0]["cc-given-name"], "John");
+ do_check_eq(creditCards[0]["cc-family-name"], "Doe");
+
+ // Test with noComputedFields set.
+ creditCards = profileStorage.creditCards.getAll({noComputedFields: true});
+ do_check_eq(creditCards[0]["cc-given-name"], undefined);
+ do_check_eq(creditCards[0]["cc-family-name"], undefined);
+
+ // Modifying output shouldn't affect the storage.
+ creditCards[0]["cc-name"] = "test";
+ do_check_credit_card_matches(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 ProfileStorage(path);
+ await profileStorage.initialize();
+
+ let creditCards = profileStorage.creditCards.getAll();
+ let guid = creditCards[0].guid;
+
+ let creditCard = 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(profileStorage.creditCards.get(guid), TEST_CREDIT_CARD_1);
+
+ Assert.throws(() => profileStorage.creditCards.get("INVALID_GUID"),
+ /No matching record\./);
+});
+
+add_task(async function test_getByFilter() {
+ let path = getTempFile(TEST_STORE_FILE_NAME).path;
+ await prepareTestCreditCards(path);
+
+ let profileStorage = new ProfileStorage(path);
+ await profileStorage.initialize();
+
+ let filter = {info: {fieldName: "cc-name"}, searchString: "Tim"};
+ let creditCards = profileStorage.creditCards.getByFilter(filter);
+ do_check_eq(creditCards.length, 1);
+ do_check_credit_card_matches(creditCards[0], TEST_CREDIT_CARD_2);
+
+ // TODO: Uncomment this after decryption lands (bug 1337314).
+ // filter = {info: {fieldName: "cc-number"}, searchString: "11"};
+ // creditCards = profileStorage.creditCards.getByFilter(filter);
+ // do_check_eq(creditCards.length, 1);
+ // do_check_credit_card_matches(creditCards[0], TEST_CREDIT_CARD_2);
+});
+
+add_task(async function test_add() {
+ let path = getTempFile(TEST_STORE_FILE_NAME).path;
+ await prepareTestCreditCards(path);
+
+ let profileStorage = new ProfileStorage(path);
+ await profileStorage.initialize();
+
+ let creditCards = profileStorage.creditCards.getAll();
+
+ do_check_eq(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);
+
+ do_check_neq(creditCards[0].guid, undefined);
+ do_check_neq(creditCards[0].timeCreated, undefined);
+ do_check_eq(creditCards[0].timeLastModified, creditCards[0].timeCreated);
+ do_check_eq(creditCards[0].timeLastUsed, 0);
+ do_check_eq(creditCards[0].timesUsed, 0);
+
+ Assert.throws(() => profileStorage.creditCards.add(TEST_CREDIT_CARD_WITH_INVALID_FIELD),
+ /"invalidField" is not a valid field\./);
+});
+
+add_task(async function test_update() {
+ let path = getTempFile(TEST_STORE_FILE_NAME).path;
+ await prepareTestCreditCards(path);
+
+ let profileStorage = new ProfileStorage(path);
+ await profileStorage.initialize();
+
+ let creditCards = profileStorage.creditCards.getAll();
+ let guid = creditCards[1].guid;
+ let timeLastModified = creditCards[1].timeLastModified;
+
+ let onChanged = TestUtils.topicObserved("formautofill-storage-changed",
+ (subject, data) => data == "update");
+
+ do_check_neq(creditCards[1]["cc-name"], undefined);
+
+ profileStorage.creditCards.update(guid, TEST_CREDIT_CARD_3);
+ await onChanged;
+ await profileStorage._saveImmediately();
+
+ profileStorage = new ProfileStorage(path);
+ await profileStorage.initialize();
+
+ let creditCard = profileStorage.creditCards.get(guid);
+
+ do_check_eq(creditCard["cc-name"], undefined);
+ do_check_neq(creditCard.timeLastModified, timeLastModified);
+ do_check_credit_card_matches(creditCard, TEST_CREDIT_CARD_3);
+
+ Assert.throws(
+ () => profileStorage.creditCards.update("INVALID_GUID", TEST_CREDIT_CARD_3),
+ /No matching record\./
+ );
+
+ Assert.throws(
+ () => profileStorage.creditCards.update(guid, TEST_CREDIT_CARD_WITH_INVALID_FIELD),
+ /"invalidField" is not a valid field\./
+ );
+});
+
+add_task(async function test_validate() {
+ let path = getTempFile(TEST_STORE_FILE_NAME).path;
+
+ let profileStorage = new ProfileStorage(path);
+ await profileStorage.initialize();
+
+ profileStorage.creditCards.add(TEST_CREDIT_CARD_WITH_INVALID_EXPIRY_DATE);
+ profileStorage.creditCards.add(TEST_CREDIT_CARD_WITH_2_DIGITS_YEAR);
+ profileStorage.creditCards.add(TEST_CREDIT_CARD_WITH_SPACES_BETWEEN_DIGITS);
+
+ let creditCards = profileStorage.creditCards.getAll();
+
+ do_check_eq(creditCards[0]["cc-exp-month"], undefined);
+ do_check_eq(creditCards[0]["cc-exp-year"], undefined);
+
+ do_check_eq(creditCards[1]["cc-exp-month"], TEST_CREDIT_CARD_WITH_2_DIGITS_YEAR["cc-exp-month"]);
+ do_check_eq(creditCards[1]["cc-exp-year"],
+ parseInt(TEST_CREDIT_CARD_WITH_2_DIGITS_YEAR["cc-exp-year"], 10) + 2000);
+
+ do_check_eq(creditCards[2]["cc-number-masked"].length, 16);
+ // TODO: Check the decrypted numbers should not contain spaces after
+ // decryption lands (bug 1337314).
+
+ Assert.throws(() => profileStorage.creditCards.add(TEST_CREDIT_CARD_WITH_INVALID_NUMBERS),
+ /Credit card number contains invalid characters\./);
+});
+
+add_task(async function test_notifyUsed() {
+ let path = getTempFile(TEST_STORE_FILE_NAME).path;
+ await prepareTestCreditCards(path);
+
+ let profileStorage = new ProfileStorage(path);
+ await profileStorage.initialize();
+
+ let creditCards = 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");
+
+ profileStorage.creditCards.notifyUsed(guid);
+ await onChanged;
+ await profileStorage._saveImmediately();
+
+ profileStorage = new ProfileStorage(path);
+ await profileStorage.initialize();
+
+ let creditCard = profileStorage.creditCards.get(guid);
+
+ do_check_eq(creditCard.timesUsed, timesUsed + 1);
+ do_check_neq(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 ProfileStorage(path);
+ await profileStorage.initialize();
+
+ let creditCards = profileStorage.creditCards.getAll();
+ let guid = creditCards[1].guid;
+
+ let onChanged = TestUtils.topicObserved("formautofill-storage-changed",
+ (subject, data) => data == "remove");
+
+ do_check_eq(creditCards.length, 2);
+
+ profileStorage.creditCards.remove(guid);
+ await onChanged;
+ await profileStorage._saveImmediately();
+
+ profileStorage = new ProfileStorage(path);
+ await profileStorage.initialize();
+
+ creditCards = profileStorage.creditCards.getAll();
+
+ do_check_eq(creditCards.length, 1);
+
+ Assert.throws(() => profileStorage.creditCards.get(guid), /No matching record\./);
+});
--- a/browser/extensions/formautofill/test/unit/test_transformFields.js
+++ b/browser/extensions/formautofill/test/unit/test_transformFields.js
@@ -175,16 +175,75 @@ const ADDRESS_NORMALIZE_TESTCASES = [
"address-line3": "line3",
},
expectedResult: {
"street-address": "street address\nstreet address line 2",
},
},
];
+const CREDIT_CARD_COMPUTE_TESTCASES = [
+ // Empty
+ {
+ description: "Empty credit card",
+ creditCard: {
+ },
+ expectedResult: {
+ },
+ },
+
+ // Name
+ {
+ description: "Has \"cc-name\"",
+ creditCard: {
+ "cc-name": "Timothy John Berners-Lee",
+ },
+ expectedResult: {
+ "cc-name": "Timothy John Berners-Lee",
+ "cc-given-name": "Timothy",
+ "cc-additional-name": "John",
+ "cc-family-name": "Berners-Lee",
+ },
+ },
+];
+
+const CREDIT_CARD_NORMALIZE_TESTCASES = [
+ // Empty
+ {
+ description: "Empty credit card",
+ creditCard: {
+ },
+ expectedResult: {
+ },
+ },
+
+ // 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",
+ },
+ expectedResult: {
+ "cc-name": "Timothy John Berners-Lee",
+ },
+ },
+ {
+ description: "Has only the split name fields",
+ creditCard: {
+ "cc-given-name": "John",
+ "cc-family-name": "Doe",
+ },
+ expectedResult: {
+ "cc-name": "John Doe",
+ },
+ },
+];
+
let do_check_record_matches = (expectedRecord, record) => {
for (let key in expectedRecord) {
do_check_eq(expectedRecord[key], record[key] || "");
}
};
add_task(async function test_computeAddressFields() {
let path = getTempFile(TEST_STORE_FILE_NAME).path;
@@ -220,8 +279,48 @@ add_task(async function test_normalizeAd
let addresses = profileStorage.addresses.getAll();
for (let i in addresses) {
do_print("Verify testcase: " + ADDRESS_NORMALIZE_TESTCASES[i].description);
do_check_record_matches(ADDRESS_NORMALIZE_TESTCASES[i].expectedResult, addresses[i]);
}
});
+
+add_task(async function test_computeCreditCardFields() {
+ let path = getTempFile(TEST_STORE_FILE_NAME).path;
+
+ let profileStorage = new ProfileStorage(path);
+ await profileStorage.initialize();
+
+ CREDIT_CARD_COMPUTE_TESTCASES.forEach(testcase => profileStorage.creditCards.add(testcase.creditCard));
+ await profileStorage._saveImmediately();
+
+ profileStorage = new ProfileStorage(path);
+ await profileStorage.initialize();
+
+ let creditCards = profileStorage.creditCards.getAll();
+
+ for (let i in creditCards) {
+ do_print("Verify testcase: " + CREDIT_CARD_COMPUTE_TESTCASES[i].description);
+ do_check_record_matches(CREDIT_CARD_COMPUTE_TESTCASES[i].expectedResult, creditCards[i]);
+ }
+});
+
+add_task(async function test_normalizeCreditCardFields() {
+ let path = getTempFile(TEST_STORE_FILE_NAME).path;
+
+ let profileStorage = new ProfileStorage(path);
+ await profileStorage.initialize();
+
+ CREDIT_CARD_NORMALIZE_TESTCASES.forEach(testcase => profileStorage.creditCards.add(testcase.creditCard));
+ await profileStorage._saveImmediately();
+
+ profileStorage = new ProfileStorage(path);
+ await profileStorage.initialize();
+
+ let creditCards = profileStorage.creditCards.getAll();
+
+ for (let i in creditCards) {
+ do_print("Verify testcase: " + CREDIT_CARD_NORMALIZE_TESTCASES[i].description);
+ do_check_record_matches(CREDIT_CARD_NORMALIZE_TESTCASES[i].expectedResult, creditCards[i]);
+ }
+});
--- a/browser/extensions/formautofill/test/unit/xpcshell.ini
+++ b/browser/extensions/formautofill/test/unit/xpcshell.ini
@@ -14,16 +14,17 @@ support-files =
[heuristics/third_party/test_OfficeDepot.js]
[heuristics/third_party/test_QVC.js]
[heuristics/third_party/test_Sears.js]
[heuristics/third_party/test_Staples.js]
[heuristics/third_party/test_Walmart.js]
[test_addressRecords.js]
[test_autofillFormFields.js]
[test_collectFormFields.js]
+[test_creditCardRecords.js]
[test_enabledStatus.js]
[test_findLabelElements.js]
[test_getFormInputDetails.js]
[test_isCJKName.js]
[test_markAsAutofillField.js]
[test_nameUtils.js]
[test_onFormSubmitted.js]
[test_profileAutocompleteResult.js]