Bug 1428414 - Support saving credit card changes in the Payment Request dialog. r=jaws draft
authorMatthew Noorenberghe <mozilla@noorenberghe.ca>
Tue, 03 Apr 2018 14:06:21 -0400
changeset 776796 8d7ffa9290b5baec4afa38f416111b342c4c6352
parent 776795 58d4cad0fb2d2670a90ba0935056d3ffbd4cf96f
child 779370 018aba147321710e68db11b77083bab06c15bf85
child 779999 633e9113d897ca55081ce75c3fd8d14e15e341ff
push id104995
push usermozilla@noorenberghe.ca
push dateTue, 03 Apr 2018 18:27:34 +0000
reviewersjaws
bugs1428414
milestone61.0a1
Bug 1428414 - Support saving credit card changes in the Payment Request dialog. r=jaws MozReview-Commit-ID: J2rRUy1lHiZ
browser/extensions/formautofill/content/autofillEditForms.js
npm-shrinkwrap.json
toolkit/components/payments/content/paymentDialogWrapper.js
toolkit/components/payments/res/containers/basic-card-form.js
toolkit/components/payments/res/containers/payment-method-picker.js
toolkit/components/payments/res/paymentRequest.js
toolkit/components/payments/res/paymentRequest.xhtml
toolkit/components/payments/test/PaymentTestUtils.jsm
toolkit/components/payments/test/browser/browser.ini
toolkit/components/payments/test/browser/browser_card_edit.js
toolkit/components/payments/test/mochitest/mochitest.ini
toolkit/components/payments/test/mochitest/payments_common.js
toolkit/components/payments/test/mochitest/test_basic_card_form.html
--- a/browser/extensions/formautofill/content/autofillEditForms.js
+++ b/browser/extensions/formautofill/content/autofillEditForms.js
@@ -24,17 +24,17 @@ class EditAutofillForm {
   }
 
   /**
    * Get inputs from the form.
    * @returns {object}
    */
   buildFormObject() {
     return Array.from(this._elements.form.elements).reduce((obj, input) => {
-      if (input.value) {
+      if (input.value && !input.disabled) {
         obj[input.id] = input.value;
       }
       return obj;
     }, {});
   }
 
   /**
    * Handle events
--- a/npm-shrinkwrap.json
+++ b/npm-shrinkwrap.json
@@ -983,33 +983,33 @@
         "is-fullwidth-code-point": "2.0.0"
       }
     },
     "sprintf-js": {
       "version": "1.0.3",
       "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
       "integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw="
     },
-    "string_decoder": {
-      "version": "1.0.3",
-      "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.0.3.tgz",
-      "integrity": "sha512-4AH6Z5fzNNBcH+6XDMfA/BTt87skxqJlO0lAh3Dker5zThcAxG6mKz+iGu308UKoPPQ8Dcqx/4JhujzltRa+hQ==",
-      "requires": {
-        "safe-buffer": "5.1.1"
-      }
-    },
     "string-width": {
       "version": "2.1.1",
       "resolved": "https://registry.npmjs.org/string-width/-/string-width-2.1.1.tgz",
       "integrity": "sha512-nOqH59deCq9SRHlxq1Aw85Jnt4w6KvLKqWVik6oA9ZklXLNIOlqg4F2yrT1MVaTjAqvVwdfeZ7w7aCvJD7ugkw==",
       "requires": {
         "is-fullwidth-code-point": "2.0.0",
         "strip-ansi": "4.0.0"
       }
     },
+    "string_decoder": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.0.3.tgz",
+      "integrity": "sha512-4AH6Z5fzNNBcH+6XDMfA/BTt87skxqJlO0lAh3Dker5zThcAxG6mKz+iGu308UKoPPQ8Dcqx/4JhujzltRa+hQ==",
+      "requires": {
+        "safe-buffer": "5.1.1"
+      }
+    },
     "strip-ansi": {
       "version": "4.0.0",
       "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-4.0.0.tgz",
       "integrity": "sha1-qEeQIusaw2iocTibY1JixQXuNo8=",
       "requires": {
         "ansi-regex": "3.0.0"
       },
       "dependencies": {
--- a/toolkit/components/payments/content/paymentDialogWrapper.js
+++ b/toolkit/components/payments/content/paymentDialogWrapper.js
@@ -458,16 +458,53 @@ var paymentDialogWrapper = {
   onChangeShippingOption({optionID}) {
     // Note, failing here on browser_host_name.js because the test closes
     // the dialog before the onChangeShippingOption is called, thus
     // deleting the request and making the requestId invalid. Unclear
     // why we aren't seeing the same issue with onChangeShippingAddress.
     paymentSrv.changeShippingOption(this.request.requestId, optionID);
   },
 
+  async onUpdateAutofillRecord(collectionName, record, guid, {
+    errorStateChange,
+    preserveOldProperties,
+    selectedStateKey,
+    successStateChange,
+  }) {
+    if (collectionName == "creditCards" && !guid) {
+      // We need to be logged in so we can encrypt the credit card number and
+      // that's only supported when we're adding a new record.
+      // TODO: "MasterPassword.ensureLoggedIn" can be removed after the storage
+      // APIs are refactored to be async functions (bug 1399367).
+      if (!await MasterPassword.ensureLoggedIn()) {
+        Cu.reportError("User canceled master password entry");
+        return;
+      }
+    }
+
+    try {
+      if (guid) {
+        await formAutofillStorage[collectionName].update(guid, record, preserveOldProperties);
+      } else {
+        guid = await formAutofillStorage[collectionName].add(record);
+      }
+
+      // Select the new record
+      if (selectedStateKey) {
+        Object.assign(successStateChange, {
+          [selectedStateKey]: guid,
+        });
+      }
+
+      this.sendMessageToContent("updateState", successStateChange);
+    } catch (ex) {
+      this.sendMessageToContent("updateState", errorStateChange);
+    }
+  },
+
   /**
    * @implements {nsIObserver}
    * @param {nsISupports} subject
    * @param {string} topic
    * @param {string} data
    */
   observe(subject, topic, data) {
     switch (topic) {
@@ -504,16 +541,25 @@ var paymentDialogWrapper = {
       case "paymentCancel": {
         this.onPaymentCancel();
         break;
       }
       case "pay": {
         this.onPay(data);
         break;
       }
+      case "updateAutofillRecord": {
+        this.onUpdateAutofillRecord(data.collectionName, data.record, data.guid, {
+          errorStateChange: data.errorStateChange,
+          preserveOldProperties: data.preserveOldProperties,
+          selectedStateKey: data.selectedStateKey,
+          successStateChange: data.successStateChange,
+        });
+        break;
+      }
     }
   },
 };
 
 if ("document" in this) {
   // Running in a browser, not a unit test
   let frame = document.getElementById("paymentRequestFrame");
   let requestId = (new URLSearchParams(window.location.search)).get("requestId");
--- a/toolkit/components/payments/res/containers/basic-card-form.js
+++ b/toolkit/components/payments/res/containers/basic-card-form.js
@@ -1,32 +1,38 @@
 /* This Source Code Form is subject to the terms of the Mozilla Public
  * License, v. 2.0. If a copy of the MPL was not distributed with this
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 /* import-globals-from ../../../../../browser/extensions/formautofill/content/autofillEditForms.js*/
 /* import-globals-from ../mixins/PaymentStateSubscriberMixin.js */
 /* import-globals-from ../unprivileged-fallbacks.js */
+/* import-globals-from ../paymentRequest.js */
 
 "use strict";
 
 /**
  * <basic-card-form></basic-card-form>
  *
  * XXX: Bug 1446164 - This form isn't localized when used via this custom element
  * as it will be much easier to share the logic once we switch to Fluent.
  */
 
 class BasicCardForm extends PaymentStateSubscriberMixin(HTMLElement) {
   constructor() {
     super();
 
+    this.genericErrorText = document.createElement("div");
+
     this.backButton = document.createElement("button");
     this.backButton.addEventListener("click", this);
 
+    this.saveButton = document.createElement("button");
+    this.saveButton.addEventListener("click", this);
+
     // 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");
       return this.form;
     });
   }
 
@@ -49,57 +55,107 @@ class BasicCardForm extends PaymentState
 
       let record = {};
       this.formHandler = new EditCreditCard({
         form,
       }, record, {
         isCCNumber: PaymentDialogUtils.isCCNumber,
       });
 
+      this.appendChild(this.genericErrorText);
       this.appendChild(this.backButton);
+      this.appendChild(this.saveButton);
       // Only call the connected super callback(s) once our markup is fully
       // connected, including the shared form fetched asynchronously.
       super.connectedCallback();
     });
   }
 
   render(state) {
     this.backButton.textContent = this.dataset.backButtonLabel;
+    this.saveButton.textContent = this.dataset.saveButtonLabel;
 
     let record = {};
     let {
-      selectedPaymentCard,
+      page,
       savedBasicCards,
     } = state;
 
-    let editing = !!state.selectedPaymentCard;
+    this.genericErrorText.textContent = page.error;
+
+    let editing = !!page.guid;
     this.form.querySelector("#cc-number").disabled = editing;
 
     // If a card is selected we want to edit it.
     if (editing) {
-      record = savedBasicCards[selectedPaymentCard];
+      record = savedBasicCards[page.guid];
       if (!record) {
-        throw new Error("Trying to edit a non-existing card: " + selectedPaymentCard);
+        throw new Error("Trying to edit a non-existing card: " + page.guid);
       }
     }
 
     this.formHandler.loadRecord(record);
   }
 
   handleEvent(event) {
     switch (event.type) {
       case "click": {
         this.onClick(event);
         break;
       }
     }
   }
 
   onClick(evt) {
-    this.requestStore.setState({
-      page: {
-        id: "payment-summary",
+    switch (evt.target) {
+      case this.backButton: {
+        this.requestStore.setState({
+          page: {
+            id: "payment-summary",
+          },
+        });
+        break;
+      }
+      case this.saveButton: {
+        this.saveRecord();
+        break;
+      }
+      default: {
+        throw new Error("Unexpected click target");
+      }
+    }
+  }
+
+  saveRecord() {
+    let record = this.formHandler.buildFormObject();
+    let {
+      page,
+    } = this.requestStore.getState();
+
+    for (let editableFieldName of ["cc-name", "cc-exp-month", "cc-exp-year"]) {
+      record[editableFieldName] = record[editableFieldName] || "";
+    }
+
+    // Only save the card number if we're saving a new record, otherwise we'd
+    // overwrite the unmasked card number with the masked one.
+    if (!page.guid) {
+      record["cc-number"] = record["cc-number"] || "";
+    }
+
+    paymentRequest.updateAutofillRecord("creditCards", record, page.guid, {
+      errorStateChange: {
+        page: {
+          id: "basic-card-page",
+          error: this.dataset.errorGenericSave,
+        },
+      },
+      preserveOldProperties: true,
+      selectedStateKey: "selectedPaymentCard",
+      successStateChange: {
+        page: {
+          id: "payment-summary",
+        },
       },
     });
   }
 }
 
 customElements.define("basic-card-form", BasicCardForm);
--- a/toolkit/components/payments/res/containers/payment-method-picker.js
+++ b/toolkit/components/payments/res/containers/payment-method-picker.js
@@ -130,20 +130,23 @@ class PaymentMethodPicker extends Paymen
     let nextState = {
       page: {
         id: "basic-card-page",
       },
     };
 
     switch (target) {
       case this.addLink: {
-        nextState.selectedPaymentCard = null;
+        nextState.page.guid = null;
         break;
       }
       case this.editLink: {
+        let state = this.requestStore.getState();
+        let selectedPaymentCardGUID = state[this.selectedStateKey];
+        nextState.page.guid = selectedPaymentCardGUID;
         break;
       }
       default: {
         throw new Error("Unexpected onClick");
       }
     }
 
     this.requestStore.setState(nextState);
--- a/toolkit/components/payments/res/paymentRequest.js
+++ b/toolkit/components/payments/res/paymentRequest.js
@@ -126,15 +126,40 @@ var paymentRequest = {
   changeShippingAddress(data) {
     this.sendMessageToChrome("changeShippingAddress", data);
   },
 
   changeShippingOption(data) {
     this.sendMessageToChrome("changeShippingOption", data);
   },
 
+  /**
+   * Add/update an autofill storage record.
+   *
+   * If the the `guid` argument is provided update the record; otherwise, add it.
+   * @param {string} collectionName The autofill collection that record belongs to.
+   * @param {object} record The autofill record to add/update
+   * @param {string} [guid] The guid of the autofill record to update
+   */
+  updateAutofillRecord(collectionName, record, guid, {
+    errorStateChange,
+    preserveOldProperties,
+    selectedStateKey,
+    successStateChange,
+  }) {
+    this.sendMessageToChrome("updateAutofillRecord", {
+      collectionName,
+      guid,
+      record,
+      errorStateChange,
+      preserveOldProperties,
+      selectedStateKey,
+      successStateChange,
+    });
+  },
+
   onPaymentRequestUnload() {
     // remove listeners that may be used multiple times here
     window.removeEventListener("paymentChromeToContent", this);
   },
 };
 
 paymentRequest.init();
--- a/toolkit/components/payments/res/paymentRequest.xhtml
+++ b/toolkit/components/payments/res/paymentRequest.xhtml
@@ -19,17 +19,19 @@
   <!ENTITY cancelPaymentButton.label   "Cancel">
   <!ENTITY approvePaymentButton.label  "Pay">
   <!ENTITY processingPaymentButton.label "Processing">
   <!ENTITY successPaymentButton.label    "Done">
   <!ENTITY failPaymentButton.label       "Fail">
   <!ENTITY unknownPaymentButton.label    "Unknown">
   <!ENTITY orderDetailsLabel          "Order Details">
   <!ENTITY orderTotalLabel            "Total">
+  <!ENTITY basicCardPage.error.genericSave    "There was an error saving the payment card.">
   <!ENTITY basicCardPage.backButton.label     "Back">
+  <!ENTITY basicCardPage.saveButton.label     "Save">
 ]>
 <html xmlns="http://www.w3.org/1999/xhtml">
 <head>
   <title>&paymentSummaryTitle;</title>
 
   <!-- chrome: is needed for global.dtd -->
   <meta http-equiv="Content-Security-Policy" content="default-src 'self' chrome:"/>
 
@@ -121,17 +123,19 @@
       </section>
       <section id="order-details-overlay" hidden="hidden">
         <h1>&orderDetailsLabel;</h1>
         <order-details></order-details>
       </section>
 
       <basic-card-form id="basic-card-page"
                        class="page"
+                       data-error-generic-save="&basicCardPage.error.genericSave;"
                        data-back-button-label="&basicCardPage.backButton.label;"
+                       data-save-button-label="&basicCardPage.saveButton.label;"
                        hidden="hidden"></basic-card-form>
     </div>
 
     <div id="disabled-overlay" hidden="hidden">
       <!-- overlay to prevent changes while waiting for a response from the merchant -->
     </div>
   </template>
 
--- a/toolkit/components/payments/test/PaymentTestUtils.jsm
+++ b/toolkit/components/payments/test/PaymentTestUtils.jsm
@@ -168,16 +168,27 @@ var PaymentTestUtils = {
       // Waive the xray to access the untrusted `securityCodeInput` property
       let picker = Cu.waiveXrays(content.document.querySelector("payment-method-picker"));
       // Unwaive to access the ChromeOnly `setUserInput` API.
       // setUserInput dispatches changes events.
       Cu.unwaiveXrays(picker.securityCodeInput).setUserInput(securityCode);
     },
   },
 
+  DialogContentUtils: {
+    waitForState: async (content, stateCheckFn, msg) => {
+      const {
+        ContentTaskUtils,
+      } = ChromeUtils.import("resource://testing-common/ContentTaskUtils.jsm", {});
+      let {requestStore} = Cu.waiveXrays(content.document.querySelector("payment-dialog"));
+      await ContentTaskUtils.waitForCondition(() => stateCheckFn(requestStore.getState()), msg);
+      return requestStore.getState();
+    },
+  },
+
   /**
    * Common PaymentMethodData for testing
    */
   MethodData: {
     basicCard: {
       supportedMethods: "basic-card",
     },
     bobPay: {
--- a/toolkit/components/payments/test/browser/browser.ini
+++ b/toolkit/components/payments/test/browser/browser.ini
@@ -1,16 +1,17 @@
 [DEFAULT]
 head = head.js
 prefs =
   dom.payments.request.enabled=true
 skip-if = !e10s # Bug 1365964 - Payment Request isn't implemented for non-e10s
 support-files =
   blank_page.html
 
+[browser_card_edit.js]
 [browser_change_shipping.js]
 [browser_host_name.js]
 [browser_profile_storage.js]
 [browser_request_serialization.js]
 [browser_request_shipping.js]
 [browser_request_summary.js]
 uses-unsafe-cpows = true
 [browser_shippingaddresschange_error.js]
new file mode 100644
--- /dev/null
+++ b/toolkit/components/payments/test/browser/browser_card_edit.js
@@ -0,0 +1,117 @@
+"use strict";
+
+add_task(async function test_add_link() {
+  const args = {
+    methodData: [PTU.MethodData.basicCard],
+    details: PTU.Details.total60USD,
+  };
+  await spawnInDialogForMerchantTask(PTU.ContentTasks.createRequest, async function check() {
+    let {
+      PaymentTestUtils: PTU,
+    } = ChromeUtils.import("resource://testing-common/PaymentTestUtils.jsm", {});
+
+    let addLink = content.document.querySelector("payment-method-picker a");
+    is(addLink.textContent, "Add", "Add link text");
+
+    addLink.click();
+
+    let state = await PTU.DialogContentUtils.waitForState(content, (state) => {
+      return state.page.id == "basic-card-page" && !state.page.guid;
+    },
+                                                          "Check add page state");
+
+    let year = (new Date()).getFullYear();
+    let card = {
+      "cc-number": "4111111111111111",
+      "cc-name": "J. Smith",
+      "cc-exp-month": 11,
+      "cc-exp-year": year,
+    };
+
+    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`);
+    }
+
+    content.document.querySelector("basic-card-form button:last-of-type").click();
+
+    state = await PTU.DialogContentUtils.waitForState(content, (state) => {
+      return Object.keys(state.savedBasicCards).length == 1;
+    },
+                                                      "Check card was added");
+
+    let cardGUIDs = Object.keys(state.savedBasicCards);
+    is(cardGUIDs.length, 1, "Check there is one card");
+    let savedCard = state.savedBasicCards[cardGUIDs[0]];
+    card["cc-number"] = "************1111"; // Card should be masked
+    for (let [key, val] of Object.entries(card)) {
+      is(savedCard[key], val, "Check " + key);
+    }
+
+    state = await PTU.DialogContentUtils.waitForState(content, (state) => {
+      return state.page.id == "payment-summary";
+    },
+                                                      "Switched back to payment-summary");
+  }, args);
+});
+
+add_task(async function test_edit_link() {
+  const args = {
+    methodData: [PTU.MethodData.basicCard],
+    details: PTU.Details.total60USD,
+  };
+  await spawnInDialogForMerchantTask(PTU.ContentTasks.createRequest, async function check() {
+    let {
+      PaymentTestUtils: PTU,
+    } = ChromeUtils.import("resource://testing-common/PaymentTestUtils.jsm", {});
+
+    let editLink = content.document.querySelector("payment-method-picker a:nth-of-type(2)");
+    is(editLink.textContent, "Edit", "Edit link text");
+
+    editLink.click();
+
+    let state = await PTU.DialogContentUtils.waitForState(content, (state) => {
+      return state.page.id == "basic-card-page" && !!state.page.guid;
+    },
+                                                          "Check edit page state");
+
+    let nextYear = (new Date()).getFullYear() + 1;
+    let card = {
+      // cc-number cannot be modified
+      "cc-name": "A. Nonymous",
+      "cc-exp-month": 3,
+      "cc-exp-year": nextYear,
+    };
+
+    info("overwriting field values");
+    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`);
+    }
+    ok(content.document.getElementById("cc-number").disabled, "cc-number field should be disabled");
+
+    content.document.querySelector("basic-card-form button:last-of-type").click();
+
+    state = await PTU.DialogContentUtils.waitForState(content, (state) => {
+      return Object.keys(state.savedBasicCards).length == 1;
+    },
+                                                      "Check card was added");
+
+    let cardGUIDs = Object.keys(state.savedBasicCards);
+    is(cardGUIDs.length, 1, "Check there is still one card");
+    let savedCard = state.savedBasicCards[cardGUIDs[0]];
+    is(savedCard["cc-number"], "************1111", "Card number should be masked and unmodified.");
+    for (let [key, val] of Object.entries(card)) {
+      is(savedCard[key], val, "Check updated " + key);
+    }
+
+    state = await PTU.DialogContentUtils.waitForState(content, (state) => {
+      return state.page.id == "payment-summary";
+    },
+                                                      "Switched back to payment-summary");
+  }, args);
+});
+
--- a/toolkit/components/payments/test/mochitest/mochitest.ini
+++ b/toolkit/components/payments/test/mochitest/mochitest.ini
@@ -1,15 +1,16 @@
 [DEFAULT]
 prefs =
    dom.webcomponents.customelements.enabled=false
 support-files =
    ../../../../../browser/extensions/formautofill/content/autofillEditForms.js
    ../../../../../testing/modules/sinon-2.3.2.js
    ../../res/paymentRequest.css
+   ../../res/paymentRequest.js
    ../../res/paymentRequest.xhtml
    ../../res/PaymentsStore.js
    ../../res/unprivileged-fallbacks.js
    ../../res/components/currency-amount.js
    ../../res/components/address-option.js
    ../../res/components/address-option.css
    ../../res/components/basic-card-option.js
    ../../res/components/basic-card-option.css
--- a/toolkit/components/payments/test/mochitest/payments_common.js
+++ b/toolkit/components/payments/test/mochitest/payments_common.js
@@ -1,11 +1,12 @@
 "use strict";
 
-/* exported asyncElementRendered, promiseStateChange, deepClone, PTU */
+/* exported asyncElementRendered, promiseStateChange, promiseContentToChromeMessage, deepClone,
+   PTU */
 
 const PTU = SpecialPowers.Cu.import("resource://testing-common/PaymentTestUtils.jsm", {})
                             .PaymentTestUtils;
 
 /**
  * A helper to await on while waiting for an asynchronous rendering of a Custom
  * Element.
  * @returns {Promise}
@@ -20,11 +21,28 @@ function promiseStateChange(store) {
       stateChangeCallback(state) {
         store.unsubscribe(this);
         resolve(state);
       },
     });
   });
 }
 
+/**
+ * Wait for a message of `messageType` from content to chrome and resolve with the event details.
+ * @param {string} messageType of the expected message
+ * @returns {Promise} when the message is dispatched
+ */
+function promiseContentToChromeMessage(messageType) {
+  return new Promise(resolve => {
+    document.addEventListener("paymentContentToChrome", function onCToC(event) {
+      if (event.detail.messageType != messageType) {
+        return;
+      }
+      document.removeEventListener("paymentContentToChrome", onCToC);
+      resolve(event.detail);
+    });
+  });
+}
+
 function deepClone(obj) {
   return JSON.parse(JSON.stringify(obj));
 }
--- a/toolkit/components/payments/test/mochitest/test_basic_card_form.html
+++ b/toolkit/components/payments/test/mochitest/test_basic_card_form.html
@@ -12,16 +12,17 @@ Test the basic-card-form element
   <script src="sinon-2.3.2.js"></script>
   <script src="payments_common.js"></script>
   <script src="custom-elements.min.js"></script>
   <script src="unprivileged-fallbacks.js"></script>
   <script src="PaymentsStore.js"></script>
   <script src="PaymentStateSubscriberMixin.js"></script>
   <script src="autofillEditForms.js"></script>
   <script src="basic-card-form.js"></script>
+  <script src="paymentRequest.js"></script>
   <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
   <link rel="stylesheet" type="text/css" href="paymentRequest.css"/>
 </head>
 <body>
   <p id="display">
   </p>
 <div id="content" style="display: none">
 
@@ -80,29 +81,100 @@ add_task(async function test_backButton(
   synthesizeMouseAtCenter(form.backButton, {});
 
   let {page} = await stateChangePromise;
   is(page.id, "payment-summary", "Check initial page after appending");
 
   form.remove();
 });
 
+add_task(async function test_saveButton() {
+  let form = document.createElement("basic-card-form");
+  form.dataset.saveButtonLabel = "Save";
+  form.dataset.errorGenericSave = "Generic error";
+  await form.promiseReady;
+  display.appendChild(form);
+  await asyncElementRendered();
+
+  form.form.querySelector("#cc-number").focus();
+  sendString("4111111111111111");
+  form.form.querySelector("#cc-name").focus();
+  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);
+
+  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, {
+    collectionName: "creditCards",
+    errorStateChange: {
+      page: {
+        id: "basic-card-page",
+        error: "Generic error",
+      },
+    },
+    guid: undefined,
+    messageType: "updateAutofillRecord",
+    preserveOldProperties: true,
+    record: {
+      "cc-exp-month": "11",
+      "cc-exp-year": year,
+      "cc-name": "J. Smith",
+      "cc-number": "4111111111111111",
+    },
+    selectedStateKey: "selectedPaymentCard",
+    successStateChange: {
+      page: {
+        id: "payment-summary",
+      },
+    },
+  }, "Check event details for the message to chrome");
+  form.remove();
+});
+
+add_task(async function test_genericError() {
+  let form = document.createElement("basic-card-form");
+  await form.requestStore.setState({
+    page: {
+      id: "test-page",
+      error: "Generic Error",
+    },
+  });
+  await form.promiseReady;
+  display.appendChild(form);
+  await asyncElementRendered();
+
+  ok(!isHidden(form.genericErrorText), "Error message should be visible");
+  is(form.genericErrorText.textContent, "Generic Error", "Check error message");
+  form.remove();
+});
+
 add_task(async function test_record() {
   let form = document.createElement("basic-card-form");
   await form.promiseReady;
   display.appendChild(form);
   await asyncElementRendered();
 
   info("test year before current");
   let card1 = deepClone(PTU.BasicCards.JohnDoe);
   card1.guid = "9864798564";
   card1["cc-exp-year"] = 2011;
 
   await form.requestStore.setState({
-    selectedPaymentCard: card1.guid,
+    page: {
+      id: "basic-card-page",
+      guid: card1.guid,
+    },
     savedBasicCards: {
       [card1.guid]: deepClone(card1),
     },
   });
   await asyncElementRendered();
   checkCCForm(form, card1);
 
   info("test future year");
@@ -118,27 +190,32 @@ add_task(async function test_record() {
 
   info("test change to minimal record");
   let minimalCard = {
     // no expiration date or name
     "cc-number": "1234567690123",
     guid: "9gnjdhen46",
   };
   await form.requestStore.setState({
-    selectedPaymentCard: minimalCard.guid,
+    page: {
+      id: "basic-card-page",
+      guid: minimalCard.guid,
+    },
     savedBasicCards: {
       [minimalCard.guid]: deepClone(minimalCard),
     },
   });
   await asyncElementRendered();
   checkCCForm(form, minimalCard);
 
   info("change to no selected card");
   await form.requestStore.setState({
-    selectedPaymentCard: null,
+    page: {
+      id: "basic-card-page",
+    },
   });
   await asyncElementRendered();
   checkCCForm(form, {});
 
   form.remove();
 });
 </script>