Bug 1428414 - Support saving credit card changes in the Payment Request dialog. r=jaws
authorMatthew Noorenberghe <mozilla@noorenberghe.ca>
Tue, 03 Apr 2018 14:06:21 -0400
changeset 411562 70eb77d5bd24e93c06d4547b028cdf9d12c5cce7
parent 411561 6045035ad2edad1ed5a8c336883db01aa73bd463
child 411563 69964a79c66ac99adb528e1c8365b6cece01387c
push id101686
push useraciure@mozilla.com
push dateTue, 03 Apr 2018 21:59:31 +0000
treeherdermozilla-inbound@8d846598d35d [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersjaws
bugs1428414
milestone61.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
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>