--- a/browser/components/payments/res/containers/basic-card-form.js
+++ b/browser/components/payments/res/containers/basic-card-form.js
@@ -17,16 +17,18 @@ import paymentRequest from "../paymentRe
* as it will be much easier to share the logic once we switch to Fluent.
*/
export default class BasicCardForm extends PaymentStateSubscriberMixin(PaymentRequestPage) {
constructor() {
super();
this.genericErrorText = document.createElement("div");
+ this.genericErrorText.setAttribute("aria-live", "polite");
+ this.genericErrorText.classList.add("page-error");
this.addressAddLink = document.createElement("a");
this.addressAddLink.className = "add-link";
this.addressAddLink.href = "javascript:void(0)";
this.addressAddLink.addEventListener("click", this);
this.addressEditLink = document.createElement("a");
this.addressEditLink.className = "edit-link";
this.addressEditLink.href = "javascript:void(0)";
@@ -49,16 +51,18 @@ export default class BasicCardForm exten
this.saveButton.addEventListener("click", this);
this.footer.append(this.cancelButton, this.backButton, this.saveButton);
// The markup is shared with form autofill preferences.
let url = "formautofill/editCreditCard.xhtml";
this.promiseReady = this._fetchMarkup(url).then(doc => {
this.form = doc.getElementById("form");
+ this.form.addEventListener("input", this);
+ this.form.addEventListener("invalid", this);
return this.form;
});
}
_fetchMarkup(url) {
return new Promise((resolve, reject) => {
let xhr = new XMLHttpRequest();
xhr.responseType = "document";
@@ -162,24 +166,34 @@ export default class BasicCardForm exten
billingAddressSelect.value = basicCardPage.billingAddressGUID;
} else if (!editing) {
if (paymentRequest.getAddresses(state)[selectedShippingAddress]) {
billingAddressSelect.value = selectedShippingAddress;
} else {
billingAddressSelect.value = Object.keys(addresses)[0];
}
}
+
+ this.updateSaveButtonState();
}
handleEvent(event) {
switch (event.type) {
case "click": {
this.onClick(event);
break;
}
+ case "input": {
+ this.onInput(event);
+ break;
+ }
+ case "invalid": {
+ this.onInvalid(event);
+ break;
+ }
}
}
onClick(evt) {
switch (evt.target) {
case this.cancelButton: {
paymentRequest.cancel();
break;
@@ -246,25 +260,39 @@ export default class BasicCardForm exten
"basic-card-page": basicCardPageState,
});
}
this.requestStore.setState(nextState);
break;
}
case this.saveButton: {
- this.saveRecord();
+ if (this.form.checkValidity()) {
+ this.saveRecord();
+ }
break;
}
default: {
throw new Error("Unexpected click target");
}
}
}
+ onInput(event) {
+ this.updateSaveButtonState();
+ }
+
+ onInvalid(event) {
+ this.saveButton.disabled = true;
+ }
+
+ updateSaveButtonState() {
+ this.saveButton.disabled = !this.form.checkValidity();
+ }
+
saveRecord() {
let record = this.formHandler.buildFormObject();
let currentState = this.requestStore.getState();
let {
page,
tempBasicCards,
"basic-card-page": basicCardPage,
} = currentState;
--- a/browser/components/payments/res/paymentRequest.css
+++ b/browser/components/payments/res/paymentRequest.css
@@ -73,16 +73,20 @@ payment-dialog > header {
/* The area above the footer should scroll, if necessary. */
overflow: auto;
}
.page > .page-body > h2:empty {
display: none;
}
+.page-error {
+ color: #D70022;
+}
+
.page > footer {
align-items: center;
background-color: #eaeaee;
display: flex;
/* from visual spec: */
padding-top: 20px;
padding-bottom: 18px;
}
--- a/browser/components/payments/res/unprivileged-fallbacks.js
+++ b/browser/components/payments/res/unprivileged-fallbacks.js
@@ -21,17 +21,17 @@ var log = {
debug: console.debug.bind(console, "paymentRequest.xhtml:"),
};
var PaymentDialogUtils = {
getAddressLabel(address) {
return `${address.name} (${address.guid})`;
},
isCCNumber(str) {
- return str.length > 0;
+ return !!str.replace(/[-\s]/g, "").match(/^\d{9,}$/);
},
DEFAULT_REGION: "US",
supportedCountries: ["US", "CA"],
getFormFormat(country) {
return {
"addressLevel1Label": country == "US" ? "state" : "province",
"postalCodeLabel": country == "US" ? "zip" : "postalCode",
"fieldsOrder": [
--- a/browser/components/payments/test/PaymentTestUtils.jsm
+++ b/browser/components/payments/test/PaymentTestUtils.jsm
@@ -167,16 +167,17 @@ var PaymentTestUtils = {
* Don't await on this method from a ContentTask when expecting the dialog to close
*
* @returns {undefined}
*/
clickPrimaryButton: () => {
let {requestStore} = Cu.waiveXrays(content.document.querySelector("payment-dialog"));
let {page} = requestStore.getState();
let button = content.document.querySelector(`#${page.id} button.primary`);
+ ok(!button.disabled, "Primary button should not be disabled when clicking it");
button.click();
},
/**
* Click the cancel button
*
* Don't await on this task since the cancel can close the dialog before
* ContentTask can resolve the promise.
--- a/browser/components/payments/test/browser/browser_card_edit.js
+++ b/browser/components/payments/test/browser/browser_card_edit.js
@@ -108,23 +108,23 @@ async function add_link(aOptions = {}) {
}, aOptions);
await navigateToAddAddressPage(frame, addressOptions);
await fillInAddressForm(frame, PTU.Addresses.TimBL2, addressOptions);
await verifyPersistCheckbox(frame, addressOptions);
+ await spawnPaymentDialogTask(frame, PTU.DialogContentTasks.clickPrimaryButton);
+
await spawnPaymentDialogTask(frame, async (testArgs = {}) => {
let {
PaymentTestUtils: PTU,
} = ChromeUtils.import("resource://testing-common/PaymentTestUtils.jsm", {});
- content.document.querySelector("address-form button:last-of-type").click();
-
let state = await PTU.DialogContentUtils.waitForState(content, (state) => {
return state.page.id == "basic-card-page" && !state["basic-card-page"].guid;
}, "Check address was added and we're back on basic-card page (add)");
let addressCount = Object.keys(state.savedAddresses).length +
Object.keys(state.tempAddresses).length;
is(addressCount, 2, "Check address was added");
@@ -147,28 +147,29 @@ async function add_link(aOptions = {}) {
is(selectedAddressGuid, lastAddress.guid, "The select should have the new address selected");
}, aOptions);
await fillInCardForm(frame, PTU.BasicCards.JaneMasterCard, {
isTemporary: aOptions.isPrivate,
checkboxSelector: "basic-card-form .persist-checkbox",
});
+ await spawnPaymentDialogTask(frame, PTU.DialogContentTasks.clickPrimaryButton);
+
await spawnPaymentDialogTask(frame, async (testArgs = {}) => {
let {
PaymentTestUtils: PTU,
} = ChromeUtils.import("resource://testing-common/PaymentTestUtils.jsm", {});
- content.document.querySelector("basic-card-form button:last-of-type").click();
-
await PTU.DialogContentUtils.waitForState(content, (state) => {
return state.page.id == "payment-summary";
- }, "Check we are back on the sumamry page");
+ }, "Check we are back on the summary page");
});
+
await spawnPaymentDialogTask(frame, PTU.DialogContentTasks.setSecurityCode, {
securityCode: "123",
});
await spawnPaymentDialogTask(frame, async (testArgs = {}) => {
let {
PaymentTestUtils: PTU,
} = ChromeUtils.import("resource://testing-common/PaymentTestUtils.jsm", {});
@@ -365,26 +366,26 @@ add_task(async function test_edit_link()
let field = content.document.getElementById(key);
if (!field) {
ok(false, `${key} field not found`);
}
field.value = val.slice(0, -1) + "7";
ok(!field.disabled, `Field #${key} shouldn't be disabled`);
}
- content.document.querySelector("address-form button:last-of-type").click();
+ content.document.querySelector("address-form button.save-button").click();
state = await PTU.DialogContentUtils.waitForState(content, (state) => {
return state.page.id == "basic-card-page" && state["basic-card-page"].guid &&
Object.keys(state.savedAddresses).length == 1;
}, "Check still only one address and we're back on basic-card page");
is(Object.values(state.savedAddresses)[0].tel, PTU.Addresses.TimBL.tel.slice(0, -1) + "7",
"Check that address was edited and saved");
- content.document.querySelector("basic-card-form button:last-of-type").click();
+ content.document.querySelector("basic-card-form button.save-button").click();
state = await PTU.DialogContentUtils.waitForState(content, (state) => {
let cards = Object.entries(state.savedBasicCards);
return cards.length == 1 &&
cards[0][1]["cc-name"] == card["cc-name"];
}, "Check card was edited");
let cardGUIDs = Object.keys(state.savedBasicCards);
@@ -398,70 +399,78 @@ add_task(async function test_edit_link()
state = await PTU.DialogContentUtils.waitForState(content, (state) => {
return state.page.id == "payment-summary";
}, "Switched back to payment-summary");
}, args);
});
add_task(async function test_private_card_adding() {
await setup([PTU.Addresses.TimBL], [PTU.BasicCards.JohnDoe]);
- const args = {
- methodData: [PTU.MethodData.basicCard],
- details: PTU.Details.total60USD,
- };
let privateWin = await BrowserTestUtils.openNewBrowserWindow({private: true});
- await spawnInDialogForMerchantTask(PTU.ContentTasks.createAndShowRequest, async function check() {
- let {
- PaymentTestUtils: PTU,
- } = ChromeUtils.import("resource://testing-common/PaymentTestUtils.jsm", {});
+
+ await BrowserTestUtils.withNewTab({
+ gBrowser: privateWin.gBrowser,
+ url: BLANK_PAGE_URL,
+ }, async browser => {
+ let {win, frame} = await setupPaymentDialog(browser, {
+ methodData: [PTU.MethodData.basicCard],
+ details: PTU.Details.total60USD,
+ merchantTaskFn: PTU.ContentTasks.createAndShowRequest,
+ });
- let addLink = content.document.querySelector("payment-method-picker .add-link");
- is(addLink.textContent, "Add", "Add link text");
+ await spawnPaymentDialogTask(frame, async function check() {
+ let {
+ PaymentTestUtils: PTU,
+ } = ChromeUtils.import("resource://testing-common/PaymentTestUtils.jsm", {});
- addLink.click();
+ let addLink = content.document.querySelector("payment-method-picker .add-link");
+ is(addLink.textContent, "Add", "Add link text");
- let state = await PTU.DialogContentUtils.waitForState(content, (state) => {
- return state.page.id == "basic-card-page" && !state["basic-card-page"].guid;
- },
- "Check add page state");
+ addLink.click();
- let savedCardCount = Object.keys(state.savedBasicCards).length;
- let tempCardCount = Object.keys(state.tempBasicCards).length;
+ await PTU.DialogContentUtils.waitForState(content, (state) => {
+ return state.page.id == "basic-card-page" && !state["basic-card-page"].guid;
+ },
+ "Check card page state");
+ });
- let card = Object.assign({}, PTU.BasicCards.JohnDoe);
+ await fillInCardForm(frame, PTU.BasicCards.JohnDoe);
- info("filling fields");
- for (let [key, val] of Object.entries(card)) {
- let field = content.document.getElementById(key);
- field.value = val;
- ok(!field.disabled, `Field #${key} shouldn't be disabled`);
- }
+ await spawnPaymentDialogTask(frame, async function() {
+ let {
+ PaymentTestUtils: PTU,
+ } = ChromeUtils.import("resource://testing-common/PaymentTestUtils.jsm", {});
- content.document.querySelector("basic-card-form button:last-of-type").click();
+ let card = Object.assign({}, PTU.BasicCards.JohnDoe);
+ let state = await PTU.DialogContentUtils.getCurrentState(content);
+ let savedCardCount = Object.keys(state.savedBasicCards).length;
+ let tempCardCount = Object.keys(state.tempBasicCards).length;
+ content.document.querySelector("basic-card-form button.save-button").click();
- state = await PTU.DialogContentUtils.waitForState(content, (state) => {
- return Object.keys(state.tempBasicCards).length > tempCardCount;
- },
- "Check card was added to temp collection");
+ state = await PTU.DialogContentUtils.waitForState(content, (state) => {
+ return Object.keys(state.tempBasicCards).length > tempCardCount;
+ },
+ "Check card was added to temp collection");
- is(savedCardCount, Object.keys(state.savedBasicCards).length, "No card was saved in state");
- is(Object.keys(state.tempBasicCards).length, 1, "Card was added temporarily");
+ is(savedCardCount, Object.keys(state.savedBasicCards).length, "No card was saved in state");
+ is(Object.keys(state.tempBasicCards).length, 1, "Card was added temporarily");
- let cardGUIDs = Object.keys(state.tempBasicCards);
- is(cardGUIDs.length, 1, "Check there is one card");
+ let cardGUIDs = Object.keys(state.tempBasicCards);
+ is(cardGUIDs.length, 1, "Check there is one card");
- let tempCard = state.tempBasicCards[cardGUIDs[0]];
- // Card number should be masked, so skip cc-number in the compare loop below
- delete card["cc-number"];
- for (let [key, val] of Object.entries(card)) {
- is(tempCard[key], val, "Check " + key + ` ${tempCard[key]} matches ${val}`);
- }
- // check computed fields
- is(tempCard["cc-number"], "************1111", "cc-number is masked");
- is(tempCard["cc-given-name"], "John", "cc-given-name was computed");
- is(tempCard["cc-family-name"], "Doe", "cc-family-name was computed");
- ok(tempCard["cc-exp"], "cc-exp was computed");
- ok(tempCard["cc-number-encrypted"], "cc-number-encrypted was computed");
- }, args, {
- browser: privateWin.gBrowser,
+ let tempCard = state.tempBasicCards[cardGUIDs[0]];
+ // Card number should be masked, so skip cc-number in the compare loop below
+ delete card["cc-number"];
+ for (let [key, val] of Object.entries(card)) {
+ is(tempCard[key], val, "Check " + key + ` ${tempCard[key]} matches ${val}`);
+ }
+ // check computed fields
+ is(tempCard["cc-number"], "************1111", "cc-number is masked");
+ is(tempCard["cc-given-name"], "John", "cc-given-name was computed");
+ is(tempCard["cc-family-name"], "Doe", "cc-family-name was computed");
+ ok(tempCard["cc-exp"], "cc-exp was computed");
+ ok(tempCard["cc-number-encrypted"], "cc-number-encrypted was computed");
+ });
+ spawnPaymentDialogTask(frame, PTU.DialogContentTasks.manuallyClickCancel);
+ await BrowserTestUtils.waitForCondition(() => win.closed, "dialog should be closed");
});
await BrowserTestUtils.closeWindow(privateWin);
});
--- a/browser/components/payments/test/browser/browser_payments_onboarding_wizard.js
+++ b/browser/components/payments/test/browser/browser_payments_onboarding_wizard.js
@@ -81,31 +81,33 @@ add_task(async function test_onboarding_
ok(content.isVisible(basicCardTitle), "Basic card page title is visible");
is(basicCardTitle.textContent, "Add Credit Card", "Basic card page title is correctly shown");
info("Check if the correct billing address is selected in the basic card page");
PTU.DialogContentUtils.waitForState(content, (state) => {
let billingAddressSelect = content.document.querySelector("#billingAddressGUID");
return state.selectedShippingAddress == billingAddressSelect.value;
}, "Shipping address is selected as the billing address");
+ });
- for (let [key, val] of Object.entries(PTU.BasicCards.JohnDoe)) {
- let field = content.document.getElementById(key);
- field.value = val;
- ok(!field.disabled, `Field #${key} shouldn't be disabled`);
- }
- content.document.querySelector("basic-card-form .save-button").click();
+ await fillInCardForm(frame, PTU.BasicCards.JohnDoe);
+
+ await spawnPaymentDialogTask(frame, PTU.DialogContentTasks.clickPrimaryButton);
+
+ await spawnPaymentDialogTask(frame, async function() {
+ let {
+ PaymentTestUtils: PTU,
+ } = ChromeUtils.import("resource://testing-common/PaymentTestUtils.jsm", {});
await PTU.DialogContentUtils.waitForState(content, (state) => {
return state.page.id == "payment-summary";
}, "Payment summary page is shown after the basic card page during on boarding");
let cancelButton = content.document.querySelector("#cancel");
- ok(content.isVisible(cancelButton),
- "Payment summary page is rendered");
+ ok(content.isVisible(cancelButton), "Payment summary page is rendered");
});
info("Closing the payment dialog");
spawnPaymentDialogTask(frame, PTU.DialogContentTasks.manuallyClickCancel);
await BrowserTestUtils.waitForCondition(() => win.closed, "dialog should be closed");
});
});
@@ -351,23 +353,26 @@ add_task(async function test_onboarding_
let cardSaveButton = content.document.querySelector("basic-card-form .save-button");
ok(content.isVisible(cardSaveButton), "Basic card page is rendered");
info("Check if the correct billing address is selected in the basic card page");
PTU.DialogContentUtils.waitForState(content, (state) => {
let billingAddressSelect = content.document.querySelector("#billingAddressGUID");
return state["basic-card-page"].billingAddressGUID == billingAddressSelect.value;
}, "Billing Address is correctly shown");
+ });
- for (let [key, val] of Object.entries(PTU.BasicCards.JohnDoe)) {
- let field = content.document.getElementById(key);
- field.value = val;
- ok(!field.disabled, `Field #${key} shouldn't be disabled`);
- }
- content.document.querySelector("basic-card-form .save-button").click();
+ await fillInCardForm(frame, PTU.BasicCards.JohnDoe);
+
+ await spawnPaymentDialogTask(frame, PTU.DialogContentTasks.clickPrimaryButton);
+
+ await spawnPaymentDialogTask(frame, async function() {
+ let {
+ PaymentTestUtils: PTU,
+ } = ChromeUtils.import("resource://testing-common/PaymentTestUtils.jsm", {});
await PTU.DialogContentUtils.waitForState(content, (state) => {
return state.page.id == "payment-summary";
}, "payment-summary is shown after the basic card page during on boarding");
let cancelButton = content.document.querySelector("#cancel");
ok(content.isVisible(cancelButton), "Payment summary page is rendered");
});
--- a/browser/components/payments/test/browser/head.js
+++ b/browser/components/payments/test/browser/head.js
@@ -321,16 +321,18 @@ add_task(async function setup_head() {
if (msg.category == "CSP_CSPViolationWithURI" && msg.errorMessage.includes("at inline")) {
// Ignore unknown CSP error.
return;
}
if (msg.errorMessage.match(/docShell is null.*BrowserUtils.jsm/)) {
// Bug 1478142 - Console spam from the Find Toolbar.
return;
}
+ info("message: " + msg.message);
+ info("errorMessage: " + msg.errorMessage);
ok(false, msg.message || msg.errorMessage);
});
await setupFormAutofillStorage();
registerCleanupFunction(function cleanup() {
paymentSrv.cleanup();
cleanupFormAutofillStorage();
Services.prefs.clearUserPref(RESPONSE_TIMEOUT_PREF);
SpecialPowers.postConsoleSentinel();
@@ -493,18 +495,27 @@ async function fillInCardForm(frame, aCa
// fill the form
info("fillInCardForm: fill the form with card: " + JSON.stringify(card));
for (let [key, val] of Object.entries(card)) {
let field = content.document.getElementById(key);
if (!field) {
ok(false, `${key} field not found`);
}
- field.value = val;
+ ok(!field.disabled, `Field #${key} shouldn't be disabled`);
+ field.value = "";
+ field.focus();
+ // cc-exp-* fields are numbers so convert to strings and pad left with 0
+ let fillValue = val.toString().padStart(2, "0");
+ EventUtils.synthesizeKey(fillValue, {}, content.window);
+ ok(field.value, fillValue, `${key} value is correct after synthesizeKey`);
}
+
+ info([...content.document.getElementById("cc-exp-year").options].map(op => op.label).join(","));
+
let persistCheckbox = content.document.querySelector(options.checkboxSelector);
// only touch the checked state if explicitly told to in the options
if (options.hasOwnProperty("isTemporary")) {
Cu.waiveXrays(persistCheckbox).checked = !options.isTemporary;
}
}, {card: aCard, options: aOptions});
}
--- a/browser/components/payments/test/mochitest/test_basic_card_form.html
+++ b/browser/components/payments/test/mochitest/test_basic_card_form.html
@@ -91,25 +91,31 @@ add_task(async function test_backButton(
add_task(async function test_saveButton() {
let form = new BasicCardForm();
form.dataset.saveButtonLabel = "Save";
form.dataset.errorGenericSave = "Generic error";
await form.promiseReady;
display.appendChild(form);
await asyncElementRendered();
+ ok(form.saveButton.disabled, "Save button should initially be disabled");
form.form.querySelector("#cc-number").focus();
- sendString("4111111111111111");
+ sendString("4111 1111-1111 1111");
form.form.querySelector("#cc-name").focus();
+ // Check .disabled after .focus() so that it's after both "input" and "change" events.
+ ok(form.saveButton.disabled, "Save button should still be disabled without a name");
sendString("J. Smith");
form.form.querySelector("#cc-exp-month").focus();
sendString("11");
form.form.querySelector("#cc-exp-year").focus();
let year = (new Date()).getFullYear().toString();
sendString(year);
+ form.saveButton.focus();
+ ok(!form.saveButton.disabled,
+ "Save button should be enabled since the required fields are filled");
let messagePromise = promiseContentToChromeMessage("updateAutofillRecord");
is(form.saveButton.textContent, "Save", "Check label");
synthesizeMouseAtCenter(form.saveButton, {});
let details = await messagePromise;
is(details.collectionName, "creditCards", "Check collectionName");
isDeeply(details, {
@@ -122,17 +128,17 @@ add_task(async function test_saveButton(
},
guid: undefined,
messageType: "updateAutofillRecord",
preserveOldProperties: true,
record: {
"cc-exp-month": "11",
"cc-exp-year": year,
"cc-name": "J. Smith",
- "cc-number": "4111111111111111",
+ "cc-number": "4111 1111-1111 1111",
"billingAddressGUID": "",
},
selectedStateKey: ["selectedPaymentCard"],
successStateChange: {
page: {
id: "payment-summary",
},
},
--- a/browser/extensions/formautofill/FormAutofillStorage.jsm
+++ b/browser/extensions/formautofill/FormAutofillStorage.jsm
@@ -322,16 +322,20 @@ class AutofillRecords {
if (existing.deleted) {
this._data.splice(index, 1);
} else {
throw new Error(`Record ${recordToSave.guid} already exists`);
}
}
} else if (!recordToSave.deleted) {
this._normalizeRecord(recordToSave);
+ // _normalizeRecord shouldn't do any validation (throw) because in the
+ // `update` case it is called with partial records whereas
+ // `_validateFields` is called with a complete one.
+ this._validateFields(recordToSave);
recordToSave.guid = this._generateGUID();
recordToSave.version = this.version;
// Metadata
let now = Date.now();
recordToSave.timeCreated = now;
recordToSave.timeLastModified = now;
@@ -433,16 +437,23 @@ class AutofillRecords {
this._maybeStoreLastSyncedField(recordFound, field, oldValue);
}
if (!hasValidField) {
throw new Error("Record contains no valid field.");
}
+ // _normalizeRecord above is called with the `record` argument provided to
+ // `update` which may not contain all resulting fields when
+ // `preserveOldProperties` is used. This means we need to validate for
+ // missing fields after we compose the record (`recordFound`) with the stored
+ // record like we do in the loop above.
+ this._validateFields(recordFound);
+
recordFound.timeLastModified = Date.now();
let syncMetadata = this._getSyncMetaData(recordFound);
if (syncMetadata) {
syncMetadata.changeCounter += 1;
}
this.computeFields(recordFound);
this._data[recordFoundIndex] = recordFound;
@@ -1215,18 +1226,38 @@ class AutofillRecords {
}
// An interface to be inherited.
_recordReadProcessor(record) {}
// An interface to be inherited.
computeFields(record) {}
- // An interface to be inherited.
- _normalizeFields(record) {}
+ /**
+ * An interface to be inherited to mutate the argument to normalize it.
+ *
+ * @param {object} partialRecord containing the record passed by the consumer of
+ * storage and in the case of `update` with
+ * `preserveOldProperties` will only include the
+ * properties that the user is changing so the
+ * lack of a field doesn't mean that the record
+ * won't have that field.
+ */
+ _normalizeFields(partialRecord) {}
+
+ /**
+ * An interface to be inherited to validate that the complete record is
+ * consistent and isn't missing required fields. Overrides should throw for
+ * invalid records.
+ *
+ * @param {object} record containing the complete record that would be stored
+ * if this doesn't throw due to an error.
+ * @throws
+ */
+ _validateFields(record) {}
// An interface to be inherited.
mergeIfPossible(guid, record, strict) {}
}
class Addresses extends AutofillRecords {
constructor(store) {
super(store, "addresses", VALID_ADDRESS_FIELDS, VALID_ADDRESS_COMPUTED_FIELDS, ADDRESS_SCHEMA_VERSION);
@@ -1574,17 +1605,17 @@ class CreditCards extends AutofillRecord
delete creditCard["cc-additional-name"];
delete creditCard["cc-family-name"];
}
_normalizeCCNumber(creditCard) {
if (creditCard["cc-number"]) {
let card = new CreditCard({number: creditCard["cc-number"]});
creditCard["cc-number"] = card.number;
- if (!creditCard["cc-number"]) {
+ if (!card.isValidNumber()) {
delete creditCard["cc-number"];
}
}
}
_normalizeCCExpirationDate(creditCard) {
let card = new CreditCard({
expirationMonth: creditCard["cc-exp-month"],
@@ -1599,16 +1630,22 @@ class CreditCards extends AutofillRecord
if (card.expirationYear) {
creditCard["cc-exp-year"] = card.expirationYear;
} else {
delete creditCard["cc-exp-year"];
}
delete creditCard["cc-exp"];
}
+ _validateFields(creditCard) {
+ if (!creditCard["cc-number"]) {
+ throw new Error("Missing/invalid cc-number");
+ }
+ }
+
/**
* Normalize the given record and return the first matched guid if storage has the same record.
* @param {Object} targetCreditCard
* The credit card for duplication checking.
* @returns {string|null}
* Return the first guid if storage has the same credit card and null otherwise.
*/
getDuplicateGuid(targetCreditCard) {
--- a/browser/extensions/formautofill/FormAutofillUtils.jsm
+++ b/browser/extensions/formautofill/FormAutofillUtils.jsm
@@ -216,17 +216,17 @@ this.FormAutofillUtils = {
},
isCreditCardField(fieldName) {
return this._fieldNameInfo[fieldName] == "creditCard";
},
isCCNumber(ccNumber) {
let card = new CreditCard({number: ccNumber});
- return !!card.number;
+ return card.isValidNumber();
},
getCategoryFromFieldName(fieldName) {
return this._fieldNameInfo[fieldName];
},
getCategoriesFromFieldNames(fieldNames) {
let categories = new Set();
--- a/browser/extensions/formautofill/content/autofillEditForms.js
+++ b/browser/extensions/formautofill/content/autofillEditForms.js
@@ -203,16 +203,17 @@ class EditCreditCard extends EditAutofil
*/
constructor(elements, record, addresses, config) {
super(elements);
this._addresses = addresses;
Object.assign(this, config);
Object.assign(this._elements, {
ccNumber: this._elements.form.querySelector("#cc-number"),
+ invalidCardNumberStringElement: this._elements.form.querySelector("#invalidCardNumberString"),
year: this._elements.form.querySelector("#cc-exp-year"),
billingAddress: this._elements.form.querySelector("#billingAddressGUID"),
billingAddressRow: this._elements.form.querySelector(".billingAddressRow"),
});
this.loadRecord(record, addresses);
this.attachEventListeners();
}
@@ -232,16 +233,19 @@ class EditCreditCard extends EditAutofil
generateYears() {
const count = 11;
const currentYear = new Date().getFullYear();
const ccExpYear = this._record && this._record["cc-exp-year"];
// Clear the list
this._elements.year.textContent = "";
+ // Provide an empty year option
+ this._elements.year.appendChild(new Option());
+
if (ccExpYear && ccExpYear < currentYear) {
this._elements.year.appendChild(new Option(ccExpYear));
}
for (let i = 0; i < count; i++) {
let year = currentYear + i;
let option = new Option(year);
this._elements.year.appendChild(option);
@@ -281,17 +285,18 @@ class EditCreditCard extends EditAutofil
if (event.target != this._elements.ccNumber) {
return;
}
let ccNumberField = this._elements.ccNumber;
// Mark the cc-number field as invalid if the number is empty or invalid.
if (!this.isCCNumber(ccNumberField.value)) {
- ccNumberField.setCustomValidity(true);
+ let invalidCardNumberString = this._elements.invalidCardNumberStringElement.textContent;
+ ccNumberField.setCustomValidity(invalidCardNumberString || " ");
}
}
handleInput(event) {
// Clear the error message if cc-number is valid
if (event.target == this._elements.ccNumber &&
this.isCCNumber(this._elements.ccNumber.value)) {
this._elements.ccNumber.setCustomValidity("");
--- a/browser/extensions/formautofill/content/editCreditCard.xhtml
+++ b/browser/extensions/formautofill/content/editCreditCard.xhtml
@@ -15,21 +15,22 @@
<script src="chrome://formautofill/content/l10n.js"></script>
<script src="chrome://formautofill/content/editDialog.js"></script>
<script src="chrome://formautofill/content/autofillEditForms.js"></script>
</head>
<body dir="&locale.dir;">
<form id="form" autocomplete="off">
<label>
<span data-localization="cardNumber"/>
- <input id="cc-number" type="text"/>
+ <span id="invalidCardNumberString" hidden="hidden" data-localization="invalidCardNumber"></span>
+ <input id="cc-number" type="text" required="required" minlength="9" pattern="[- 0-9]+"/>
</label>
<label>
<span data-localization="nameOnCard"/>
- <input id="cc-name" type="text"/>
+ <input id="cc-name" type="text" required="required"/>
</label>
<div>
<span data-localization="cardExpires"/>
<select id="cc-exp-month">
<option/>
<option value="1">01</option>
<option value="2">02</option>
<option value="3">03</option>
--- a/browser/extensions/formautofill/content/editDialog.js
+++ b/browser/extensions/formautofill/content/editDialog.js
@@ -168,20 +168,24 @@ class EditCreditCardDialog extends Autof
localizeDocument() {
if (this._record) {
this._elements.title.dataset.localization = "editCreditCardTitle";
}
}
async handleSubmit() {
let creditCard = this._elements.fieldContainer.buildFormObject();
- if (!this._elements.fieldContainer._elements.form.checkValidity()) {
+ if (!this._elements.fieldContainer._elements.form.reportValidity()) {
return;
}
// TODO: "MasterPassword.ensureLoggedIn" can be removed after the storage
// APIs are refactored to be async functions (bug 1399367).
if (await MasterPassword.ensureLoggedIn()) {
- await this.saveRecord(creditCard, this._record ? this._record.guid : null);
+ try {
+ await this.saveRecord(creditCard, this._record ? this._record.guid : null);
+ window.close();
+ } catch (ex) {
+ Cu.reportError(ex);
+ }
}
- window.close();
}
}
--- a/browser/extensions/formautofill/locales/en-US/formautofill.properties
+++ b/browser/extensions/formautofill/locales/en-US/formautofill.properties
@@ -131,11 +131,12 @@ cancelBtnLabel = Cancel
saveBtnLabel = Save
countryWarningMessage2 = Form Autofill is currently available only for certain countries.
# LOCALIZATION NOTE (addNewCreditCardTitle, editCreditCardTitle): The dialog title for creating or editing
# credit cards in browser preferences.
addNewCreditCardTitle = Add New Credit Card
editCreditCardTitle = Edit Credit Card
cardNumber = Card Number
+invalidCardNumber = Please enter a valid card number
nameOnCard = Name on Card
cardExpires = Expires
billingAddress = Billing Address
--- a/browser/extensions/formautofill/test/browser/browser_editCreditCardDialog.js
+++ b/browser/extensions/formautofill/test/browser/browser_editCreditCardDialog.js
@@ -174,21 +174,26 @@ add_task(async function test_editCreditC
add_task(async function test_addInvalidCreditCard() {
await testDialog(EDIT_CREDIT_CARD_DIALOG_URL, (win) => {
const unloadHandler = () => ok(false, "Edit credit card dialog shouldn't be closed");
win.addEventListener("unload", unloadHandler);
EventUtils.synthesizeKey("VK_TAB", {}, win);
EventUtils.synthesizeKey("test", {}, win);
+ EventUtils.synthesizeKey("VK_TAB", {}, win);
+ EventUtils.synthesizeKey("test name", {}, win);
+ EventUtils.synthesizeKey("VK_TAB", {}, win);
EventUtils.synthesizeMouseAtCenter(win.document.querySelector("#save"), {}, win);
is(win.document.querySelector("form").checkValidity(), false, "cc-number is invalid");
SimpleTest.requestFlakyTimeout("Ensure the window remains open after save attempt");
setTimeout(() => {
win.removeEventListener("unload", unloadHandler);
+ info("closing");
win.close();
}, 500);
});
+ info("closed");
let creditCards = await getCreditCards();
is(creditCards.length, 0, "Credit card storage is empty");
});
--- a/browser/extensions/formautofill/test/browser/head.js
+++ b/browser/extensions/formautofill/test/browser/head.js
@@ -8,16 +8,17 @@
sleep, expectPopupOpen, openPopupOn, expectPopupClose, closePopup, clickDoorhangerButton,
getAddresses, saveAddress, removeAddresses, saveCreditCard,
getDisplayedPopupItems, getDoorhangerCheckbox, waitForMasterPasswordDialog,
getNotification, getDoorhangerButton, removeAllRecords, testDialog */
"use strict";
ChromeUtils.import("resource://testing-common/LoginTestUtils.jsm", this);
+ChromeUtils.import("resource://formautofill/MasterPassword.jsm", this);
const MANAGE_ADDRESSES_DIALOG_URL = "chrome://formautofill/content/manageAddresses.xhtml";
const MANAGE_CREDIT_CARDS_DIALOG_URL = "chrome://formautofill/content/manageCreditCards.xhtml";
const EDIT_ADDRESS_DIALOG_URL = "chrome://formautofill/content/editAddress.xhtml";
const EDIT_CREDIT_CARD_DIALOG_URL = "chrome://formautofill/content/editCreditCard.xhtml";
const BASE_URL = "http://mochi.test:8888/browser/browser/extensions/formautofill/test/browser/";
const FORM_URL = "http://mochi.test:8888/browser/browser/extensions/formautofill/test/browser/autocomplete_basic.html";
const CREDITCARD_FORM_URL =
@@ -339,16 +340,21 @@ async function removeAllRecords() {
async function waitForFocusAndFormReady(win) {
return Promise.all([
new Promise(resolve => waitForFocus(resolve, win)),
BrowserTestUtils.waitForEvent(win, "FormReady"),
]);
}
async function testDialog(url, testFn, arg = undefined) {
+ if (url == EDIT_CREDIT_CARD_DIALOG_URL && arg) {
+ arg = Object.assign({}, arg, {
+ "cc-number": await MasterPassword.decrypt(arg["cc-number-encrypted"]),
+ });
+ }
let win = window.openDialog(url, null, "width=600,height=600", arg);
await waitForFocusAndFormReady(win);
let unloadPromise = BrowserTestUtils.waitForEvent(win, "unload");
await testFn(win);
return unloadPromise;
}
registerCleanupFunction(removeAllRecords);
--- a/browser/extensions/formautofill/test/unit/test_autofillFormFields.js
+++ b/browser/extensions/formautofill/test/unit/test_autofillFormFields.js
@@ -241,28 +241,28 @@ const TESTCASES = [
<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": "1234000056780000",
+ "cc-number": "4111111111111111",
"cc-name": "test name",
"cc-exp-month": "06",
"cc-exp-year": "25",
},
expectedResult: {
"street-addr": "",
"city": "",
"country": "",
"email": "",
"tel": "",
- "cc-number": "1234000056780000",
+ "cc-number": "4111111111111111",
"cc-name": "test name",
"cc-exp-month": "06",
"cc-exp-year": "25",
},
},
];
--- a/browser/extensions/formautofill/test/unit/test_createRecords.js
+++ b/browser/extensions/formautofill/test/unit/test_createRecords.js
@@ -230,41 +230,41 @@ const TESTCASES = [
{
description: "A credit card form with the value of cc-number, cc-exp, and cc-name.",
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": "4444000022220000",
+ "cc-number": "5105105105105100",
"cc-name": "Foo Bar",
"cc-exp": "2022-06",
},
expectedRecord: {
address: [],
creditCard: [{
- "cc-number": "4444000022220000",
+ "cc-number": "5105105105105100",
"cc-name": "Foo Bar",
"cc-exp": "2022-06",
}],
},
},
{
description: "A credit card form with cc-number value only.",
document: `<form>
<input id="cc-number" autocomplete="cc-number">
</form>`,
formValue: {
- "cc-number": "4444000022220000",
+ "cc-number": "4111111111111111",
},
expectedRecord: {
address: [],
creditCard: [{
- "cc-number": "4444000022220000",
+ "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">
@@ -327,20 +327,20 @@ const TESTCASES = [
"family-name-shipping": "Doe",
"organization-shipping": "Mozilla",
"country-shipping": "US",
"given-name-billing": "Foo",
"organization-billing": "Bar",
"country-billing": "US",
- "cc-number-section-one": "4444000022220000",
+ "cc-number-section-one": "4111111111111111",
"cc-name-section-one": "John",
- "cc-number-section-two": "4444000022221111",
+ "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",
@@ -350,20 +350,20 @@ const TESTCASES = [
"organization": "Mozilla",
"country": "US",
}, {
"given-name": "Foo",
"organization": "Bar",
"country": "US",
}],
creditCard: [{
- "cc-number": "4444000022220000",
+ "cc-number": "4111111111111111",
"cc-name": "John",
}, {
- "cc-number": "4444000022221111",
+ "cc-number": "5105105105105100",
"cc-name": "Foo Bar",
"cc-exp": "2026-26",
}],
},
},
];
--- a/browser/extensions/formautofill/test/unit/test_onFormSubmitted.js
+++ b/browser/extensions/formautofill/test/unit/test_onFormSubmitted.js
@@ -77,45 +77,45 @@ const TESTCASES = [
creditCard: [],
},
},
},
{
description: "Trigger credit card saving",
formValue: {
"cc-name": "John Doe",
- "cc-number": "1234567812345678",
+ "cc-number": "5105105105105100",
"cc-exp-month": 12,
"cc-exp-year": 2000,
},
expectedResult: {
formSubmission: true,
records: {
address: [],
creditCard: [{
guid: null,
record: {
"cc-name": "John Doe",
- "cc-number": "1234567812345678",
+ "cc-number": "5105105105105100",
"cc-exp-month": 12,
"cc-exp-year": 2000,
},
untouchedFields: [],
}],
},
},
},
{
description: "Trigger address and credit card saving",
formValue: {
"street-addr": "331 E. Evelyn Avenue",
"country": "USA",
"tel": "1-650-903-0800",
"cc-name": "John Doe",
- "cc-number": "1234567812345678",
+ "cc-number": "5105105105105100",
"cc-exp-month": 12,
"cc-exp-year": 2000,
},
expectedResult: {
formSubmission: true,
records: {
address: [{
guid: null,
@@ -128,17 +128,17 @@ const TESTCASES = [
"tel": "1-650-903-0800",
},
untouchedFields: [],
}],
creditCard: [{
guid: null,
record: {
"cc-name": "John Doe",
- "cc-number": "1234567812345678",
+ "cc-number": "5105105105105100",
"cc-exp-month": 12,
"cc-exp-year": 2000,
},
untouchedFields: [],
}],
},
},
},
--- a/browser/extensions/formautofill/test/unit/test_transformFields.js
+++ b/browser/extensions/formautofill/test/unit/test_transformFields.js
@@ -521,19 +521,21 @@ const ADDRESS_NORMALIZE_TESTCASES = [
];
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
{
@@ -547,66 +549,76 @@ const CREDIT_CARD_COMPUTE_TESTCASES = [
},
// 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",
@@ -633,161 +645,191 @@ const CREDIT_CARD_NORMALIZE_TESTCASES =
"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",
},
@@ -797,42 +839,48 @@ const CREDIT_CARD_NORMALIZE_TESTCASES =
},
},
{
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]);
}