Bug 1428415 Add a checkbox for persisting new cards to the Add Payment Card screen. r?MattN draft
authorSam Foster <sfoster@mozilla.com>
Mon, 09 Apr 2018 16:15:15 -0700
changeset 788099 0fde8d351b2e035592ec6b22104d3e8fb9f86b4f
parent 787860 7f6a582f00bfb5d0acb8d8bf7f8c79ca37c99b65
push id107903
push userbmo:sfoster@mozilla.com
push dateWed, 25 Apr 2018 22:44:54 +0000
reviewersMattN
bugs1428415, 1427939
milestone61.0a1
Bug 1428415 Add a checkbox for persisting new cards to the Add Payment Card screen. r?MattN * Add a new labelled-checkbox component, and use it for the persist checkbox in basic card add/edit form * Pass an isPrivate flag from the parent to UI in the state * Re-work save logic for the basic card form to set correct defaults when payment is initiated from a private window * Add a tempBasicCards object on the state, and a paymentRequest.getBasicCards(state) helper to get the union of both saved and temporary cards * Set a newly added temporary card as the selectedPaymentCard * Tests for basic-card-form.js in private windows, and correctly persisting or not new card info basic on the state of the 'Save to Firefox' checkbox * Add paymentRequest.js to mochitests, pending landing of bug 1427939 MozReview-Commit-ID: 9oQ1gbHPojf
toolkit/components/payments/content/paymentDialogWrapper.js
toolkit/components/payments/res/components/labelled-checkbox.js
toolkit/components/payments/res/containers/basic-card-form.js
toolkit/components/payments/res/containers/payment-dialog.js
toolkit/components/payments/res/containers/payment-method-picker.js
toolkit/components/payments/res/mixins/PaymentStateSubscriberMixin.js
toolkit/components/payments/res/paymentRequest.js
toolkit/components/payments/res/paymentRequest.xhtml
toolkit/components/payments/test/browser/browser_card_edit.js
toolkit/components/payments/test/browser/head.js
toolkit/components/payments/test/mochitest/mochitest.ini
toolkit/components/payments/test/mochitest/test_labelled_checkbox.html
toolkit/components/payments/test/mochitest/test_payment_method_picker.html
--- a/toolkit/components/payments/content/paymentDialogWrapper.js
+++ b/toolkit/components/payments/content/paymentDialogWrapper.js
@@ -12,16 +12,18 @@
 const paymentSrv = Cc["@mozilla.org/dom/payments/payment-request-service;1"]
                      .getService(Ci.nsIPaymentRequestService);
 
 ChromeUtils.import("resource://gre/modules/Services.jsm");
 ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm");
 
 ChromeUtils.defineModuleGetter(this, "MasterPassword",
                                "resource://formautofill/MasterPassword.jsm");
+ChromeUtils.defineModuleGetter(this, "PrivateBrowsingUtils",
+                               "resource://gre/modules/PrivateBrowsingUtils.jsm");
 
 XPCOMUtils.defineLazyGetter(this, "formAutofillStorage", () => {
   let formAutofillStorage;
   try {
     formAutofillStorage = ChromeUtils.import("resource://formautofill/FormAutofillStorage.jsm", {})
                                 .formAutofillStorage;
     formAutofillStorage.initialize();
   } catch (ex) {
@@ -378,20 +380,24 @@ var paymentDialogWrapper = {
         obj[key] = result;
       }
     }
     return obj;
   },
 
   initializeFrame() {
     let requestSerialized = this._serializeRequest(this.request);
+    let chromeWindow = Services.wm.getMostRecentWindow("navigator:browser");
+    let isPrivate = PrivateBrowsingUtils.isWindowPrivate(chromeWindow);
+
     this.sendMessageToContent("showPaymentRequest", {
       request: requestSerialized,
       savedAddresses: this.fetchSavedAddresses(),
       savedBasicCards: this.fetchSavedPaymentCards(),
+      isPrivate,
     });
 
     Services.obs.addObserver(this, "formautofill-storage-changed", true);
   },
 
   debugFrame() {
     // To avoid self-XSS-type attacks, ensure that Browser Chrome debugging is enabled.
     if (!Services.prefs.getBoolPref("devtools.chrome.enabled", false)) {
new file mode 100644
--- /dev/null
+++ b/toolkit/components/payments/res/components/labelled-checkbox.js
@@ -0,0 +1,47 @@
+/* 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 ObservedPropertiesMixin from "../mixins/ObservedPropertiesMixin.js";
+
+/**
+ *  <labelled-checkbox label="Some label" value="The value"></labelled-checkbox>
+ */
+
+export default class LabelledCheckbox extends ObservedPropertiesMixin(HTMLElement) {
+  static get observedAttributes() {
+    return [
+      "label",
+      "value",
+    ];
+  }
+  constructor() {
+    super();
+
+    this._label = document.createElement("label");
+    this._labelSpan = document.createElement("span");
+    this._checkbox = document.createElement("input");
+    this._checkbox.type = "checkbox";
+  }
+
+  connectedCallback() {
+    this.appendChild(this._label);
+    this._label.appendChild(this._checkbox);
+    this._label.appendChild(this._labelSpan);
+    this.render();
+  }
+
+  render() {
+    this._labelSpan.textContent = this.label;
+  }
+
+  get checked() {
+    return this._checkbox.checked;
+  }
+
+  set checked(value) {
+    return this._checkbox.checked = value;
+  }
+}
+
+customElements.define("labelled-checkbox", LabelledCheckbox);
--- a/toolkit/components/payments/res/containers/basic-card-form.js
+++ b/toolkit/components/payments/res/containers/basic-card-form.js
@@ -1,15 +1,17 @@
 /* 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 LabelledCheckbox from "../components/labelled-checkbox.js";
 import PaymentStateSubscriberMixin from "../mixins/PaymentStateSubscriberMixin.js";
 import paymentRequest from "../paymentRequest.js";
+
 /* import-globals-from ../unprivileged-fallbacks.js */
 
 /**
  * <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.
  */
@@ -21,16 +23,18 @@ export default class BasicCardForm exten
     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);
 
+    this.persistCheckbox = new LabelledCheckbox();
+
     // 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;
     });
   }
 
@@ -55,50 +59,59 @@ export default class BasicCardForm exten
       let addresses = [];
       this.formHandler = new EditCreditCard({
         form,
       }, record, addresses, {
         isCCNumber: PaymentDialogUtils.isCCNumber,
         getAddressLabel: PaymentDialogUtils.getAddressLabel,
       });
 
+      this.appendChild(this.persistCheckbox);
       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;
+    this.persistCheckbox.label = this.dataset.persistCheckboxLabel;
 
     let record = {};
     let {
       page,
       savedAddresses,
-      savedBasicCards,
       selectedShippingAddress,
     } = state;
+    let basicCards = paymentRequest.getBasicCards(state);
 
     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[page.guid];
+      record = basicCards[page.guid];
       if (!record) {
         throw new Error("Trying to edit a non-existing card: " + page.guid);
       }
-    } else if (selectedShippingAddress) {
-      record.billingAddressGUID = selectedShippingAddress;
+      // When editing an existing record, prevent changes to persistence
+      this.persistCheckbox.hidden = true;
+    } else {
+      if (selectedShippingAddress) {
+        record.billingAddressGUID = selectedShippingAddress;
+      }
+      // Adding a new record: default persistence to checked when in a not-private session
+      this.persistCheckbox.hidden = false;
+      this.persistCheckbox.checked = !state.isPrivate;
     }
 
     this.formHandler.loadRecord(record, savedAddresses);
   }
 
   handleEvent(event) {
     switch (event.type) {
       case "click": {
@@ -127,39 +140,61 @@ export default class BasicCardForm exten
       }
     }
   }
 
   saveRecord() {
     let record = this.formHandler.buildFormObject();
     let {
       page,
+      tempBasicCards,
     } = this.requestStore.getState();
+    let editing = !!page.guid;
+    let tempRecord = editing && tempBasicCards[page.guid];
 
     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) {
+    if (!editing) {
       record["cc-number"] = record["cc-number"] || "";
     }
 
-    paymentRequest.updateAutofillRecord("creditCards", record, page.guid, {
-      errorStateChange: {
-        page: {
-          id: "basic-card-page",
-          error: this.dataset.errorGenericSave,
+    if (!tempRecord && this.persistCheckbox.checked) {
+      log.debug(`BasicCardForm: persisting creditCard record: ${page.guid || "(new)"}`);
+      paymentRequest.updateAutofillRecord("creditCards", record, page.guid, {
+        errorStateChange: {
+          page: {
+            id: "basic-card-page",
+            error: this.dataset.errorGenericSave,
+          },
         },
-      },
-      preserveOldProperties: true,
-      selectedStateKey: "selectedPaymentCard",
-      successStateChange: {
+        preserveOldProperties: true,
+        selectedStateKey: "selectedPaymentCard",
+        successStateChange: {
+          page: {
+            id: "payment-summary",
+          },
+        },
+      });
+    } else {
+      // This record will never get inserted into the store
+      // so we generate a faux-guid for a new record
+      record.guid = page.guid || "temp-" + Math.abs(Math.random() * 0xffffffff|0);
+
+      log.debug(`BasicCardForm: saving temporary record: ${record.guid}`);
+      this.requestStore.setState({
         page: {
           id: "payment-summary",
         },
-      },
-    });
+        selectedPaymentCard: record.guid,
+        tempBasicCards: Object.assign({}, tempBasicCards, {
+        // Mix-in any previous values - equivalent to the store's preserveOldProperties: true,
+          [record.guid]: Object.assign({}, tempRecord, record),
+        }),
+      });
+    }
   }
 }
 
 customElements.define("basic-card-form", BasicCardForm);
--- a/toolkit/components/payments/res/containers/payment-dialog.js
+++ b/toolkit/components/payments/res/containers/payment-dialog.js
@@ -119,17 +119,16 @@ export default class PaymentDialog exten
     let oldSavedAddresses = this.requestStore.getState().savedAddresses;
     this.requestStore.setState(state);
 
     // Check if any foreign-key constraints were invalidated.
     state = this.requestStore.getState();
     let {
       request: {paymentOptions: {requestShipping: requestShipping}},
       savedAddresses,
-      savedBasicCards,
       selectedPayerAddress,
       selectedPaymentCard,
       selectedShippingAddress,
       selectedShippingOption,
     } = state;
     let shippingOptions = state.request.paymentDetails.shippingOptions;
     let shippingAddress = selectedShippingAddress && savedAddresses[selectedShippingAddress];
     let oldShippingAddress = selectedShippingAddress &&
@@ -155,19 +154,21 @@ export default class PaymentDialog exten
       }
       this.requestStore.setState({
         selectedShippingAddress: defaultShippingAddress || null,
       });
     }
 
     // Ensure `selectedPaymentCard` never refers to a deleted payment card and refers
     // to a payment card if one exists.
-    if (!savedBasicCards[selectedPaymentCard]) {
+    let basicCards = paymentRequest.getBasicCards(state);
+    if (!basicCards[selectedPaymentCard]) {
+      // Determining the initial selection is tracked in bug 1455789
       this.requestStore.setState({
-        selectedPaymentCard: Object.keys(savedBasicCards)[0] || null,
+        selectedPaymentCard: Object.keys(basicCards)[0] || null,
         selectedPaymentCardSecurityCode: null,
       });
     }
 
     // Ensure `selectedShippingOption` never refers to a deleted shipping option and
     // refers to a shipping option if one exists.
     if (shippingOptions && (!selectedShippingOption ||
                             !shippingOptions.find(option => option.id == selectedShippingOption))) {
--- a/toolkit/components/payments/res/containers/payment-method-picker.js
+++ b/toolkit/components/payments/res/containers/payment-method-picker.js
@@ -1,15 +1,16 @@
 /* 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 BasicCardOption from "../components/basic-card-option.js";
 import PaymentStateSubscriberMixin from "../mixins/PaymentStateSubscriberMixin.js";
 import RichSelect from "../components/rich-select.js";
+import paymentRequest from "../paymentRequest.js";
 
 /**
  * <payment-method-picker></payment-method-picker>
  * Container around add/edit links and <rich-select> with
  * <basic-card-option> listening to savedBasicCards.
  */
 
 export default class PaymentMethodPicker extends PaymentStateSubscriberMixin(HTMLElement) {
@@ -38,19 +39,19 @@ export default class PaymentMethodPicker
     this.appendChild(this.securityCodeInput);
     this.appendChild(this.addLink);
     this.append(" ");
     this.appendChild(this.editLink);
     super.connectedCallback();
   }
 
   render(state) {
-    let {savedBasicCards} = state;
+    let basicCards = paymentRequest.getBasicCards(state);
     let desiredOptions = [];
-    for (let [guid, basicCard] of Object.entries(savedBasicCards)) {
+    for (let [guid, basicCard] of Object.entries(basicCards)) {
       let optionEl = this.dropdown.getOptionByValue(guid);
       if (!optionEl) {
         optionEl = new BasicCardOption();
         optionEl.value = guid;
       }
       for (let key of BasicCardOption.recordAttributes) {
         let val = basicCard[key];
         if (val) {
--- a/toolkit/components/payments/res/mixins/PaymentStateSubscriberMixin.js
+++ b/toolkit/components/payments/res/mixins/PaymentStateSubscriberMixin.js
@@ -42,16 +42,17 @@ export let requestStore = new PaymentsSt
   },
   selectedPayerAddress: null,
   selectedPaymentCard: null,
   selectedPaymentCardSecurityCode: null,
   selectedShippingAddress: null,
   selectedShippingOption: null,
   savedAddresses: {},
   savedBasicCards: {},
+  tempBasicCards: {},
 });
 
 
 /**
  * A mixin to render UI based upon the requestStore and get updated when that store changes.
  *
  * Attaches `requestStore` to the element to give access to the store.
  * @param {class} superClass The class to extend
--- a/toolkit/components/payments/res/paymentRequest.js
+++ b/toolkit/components/payments/res/paymentRequest.js
@@ -108,20 +108,22 @@ var paymentRequest = {
   },
 
   async onShowPaymentRequest(detail) {
     // Handle getting called before the DOM is ready.
     log.debug("onShowPaymentRequest:", detail);
     await this.domReadyPromise;
 
     log.debug("onShowPaymentRequest: domReadyPromise resolved");
+    log.debug("onShowPaymentRequest, isPrivate?", detail.isPrivate);
     document.querySelector("payment-dialog").setStateFromParent({
       request: detail.request,
       savedAddresses: detail.savedAddresses,
       savedBasicCards: detail.savedBasicCards,
+      isPrivate: detail.isPrivate,
     });
   },
 
   cancel() {
     this.sendMessageToChrome("paymentCancel");
   },
 
   pay(data) {
@@ -198,13 +200,18 @@ var paymentRequest = {
     }
     return state.request.paymentDetails.totalItem;
   },
 
   onPaymentRequestUnload() {
     // remove listeners that may be used multiple times here
     window.removeEventListener("paymentChromeToContent", this);
   },
+
+  getBasicCards(state) {
+    let cards = Object.assign({}, state.savedBasicCards, state.tempBasicCards);
+    return cards;
+  },
 };
 
 paymentRequest.init();
 
 export default paymentRequest;
--- a/toolkit/components/payments/res/paymentRequest.xhtml
+++ b/toolkit/components/payments/res/paymentRequest.xhtml
@@ -22,16 +22,17 @@
   <!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">
+  <!ENTITY basicCardPage.persistCheckbox.label     "Save credit card to Firefox (Security code will not be saved)">
 ]>
 <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:"/>
 
@@ -106,16 +107,17 @@
         <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;"
+                       data-persist-checkbox-label="&basicCardPage.persistCheckbox.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/browser/browser_card_edit.js
+++ b/toolkit/components/payments/test/browser/browser_card_edit.js
@@ -15,16 +15,21 @@ add_task(async function test_add_link() 
 
     addLink.click();
 
     let state = await PTU.DialogContentUtils.waitForState(content, (state) => {
       return state.page.id == "basic-card-page" && !state.page.guid;
     },
                                                           "Check add page state");
 
+    ok(!state.isPrivate,
+       "isPrivate flag is not set when paymentrequest is shown from a non-private session");
+    let persistInput = content.document.querySelector("basic-card-form labelled-checkbox");
+    ok(Cu.waiveXrays(persistInput).checked, "persist checkbox should be checked by default");
+
     let year = (new Date()).getFullYear();
     let card = {
       "cc-number": "4111111111111111",
       "cc-name": "J. Smith",
       "cc-exp-month": 11,
       "cc-exp-year": year,
     };
 
@@ -110,8 +115,107 @@ 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_persist_defaults() {
+  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");
+
+    ok(!state.isPrivate,
+       "isPrivate flag is not set when paymentrequest is shown from a non-private session");
+    let persistInput = content.document.querySelector("basic-card-form labelled-checkbox");
+    ok(Cu.waiveXrays(persistInput).checked,
+       "checkbox is checked by default from a non-private session");
+  }, args);
+
+  await spawnInDialogForPrivateMerchantTask(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");
+
+    ok(state.isPrivate,
+       "isPrivate flag is set when paymentrequest is shown from a private session");
+    let persistInput = content.document.querySelector("labelled-checkbox");
+    ok(!Cu.waiveXrays(persistInput).checked,
+       "checkbox is not checked by default from a private session");
+  }, args);
+});
+
+add_task(async function test_private_card_adding() {
+  const args = {
+    methodData: [PTU.MethodData.basicCard],
+    details: PTU.Details.total60USD,
+  };
+  await spawnInDialogForPrivateMerchantTask(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 savedCardCount = Object.keys(state.savedBasicCards).length;
+    let tempCardCount = Object.keys(state.tempBasicCards).length;
+
+    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.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");
+  }, args);
+});
--- a/toolkit/components/payments/test/browser/head.js
+++ b/toolkit/components/payments/test/browser/head.js
@@ -234,16 +234,39 @@ async function spawnInDialogForMerchantT
     is(requests.length, 1, "Should have one payment request");
     let request = requests[0];
     ok(!!request.requestId, "Got a payment request with an ID");
 
     await spawnTaskInNewDialog(request.requestId, dialogTaskFn, taskArgs);
   });
 }
 
+async function spawnInDialogForPrivateMerchantTask(merchantTaskFn, dialogTaskFn, taskArgs, {
+  origin = "https://example.com",
+} = {
+  origin: "https://example.com",
+}) {
+  let privateWin = await BrowserTestUtils.openNewBrowserWindow({private: true});
+
+  await withMerchantTab({
+    url: origin + BLANK_PAGE_PATH,
+    browser: privateWin.gBrowser,
+  }, async merchBrowser => {
+    await ContentTask.spawn(merchBrowser, taskArgs, merchantTaskFn);
+
+    const requests = getPaymentRequests();
+    is(requests.length, 1, "Should have one payment request");
+    let request = requests[0];
+    ok(!!request.requestId, "Got a payment request with an ID");
+
+    await spawnTaskInNewDialog(request.requestId, dialogTaskFn, taskArgs);
+  });
+  await BrowserTestUtils.closeWindow(privateWin);
+}
+
 async function setupFormAutofillStorage() {
   await formAutofillStorage.initialize();
 }
 
 function cleanupFormAutofillStorage() {
   formAutofillStorage.addresses._nukeAllRecords();
   formAutofillStorage.creditCards._nukeAllRecords();
 }
--- a/toolkit/components/payments/test/mochitest/mochitest.ini
+++ b/toolkit/components/payments/test/mochitest/mochitest.ini
@@ -7,16 +7,17 @@ support-files =
    ../../../../../testing/modules/sinon-2.3.2.js
    ../../res/**
    payments_common.js
 skip-if = !e10s
 
 [test_address_picker.html]
 [test_basic_card_form.html]
 [test_currency_amount.html]
+[test_labelled_checkbox.html]
 [test_order_details.html]
 [test_payer_address_picker.html]
 [test_payment_dialog.html]
 [test_payment_details_item.html]
 [test_payment_method_picker.html]
 [test_rich_select.html]
 [test_shipping_option_picker.html]
 [test_ObservedPropertiesMixin.html]
new file mode 100644
--- /dev/null
+++ b/toolkit/components/payments/test/mochitest/test_labelled_checkbox.html
@@ -0,0 +1,74 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+Test the labelled-checkbox component
+-->
+<head>
+  <meta charset="utf-8">
+  <title>Test the labelled-checkbox component</title>
+  <script type="application/javascript" src="/tests/SimpleTest/SimpleTest.js"></script>
+  <script type="application/javascript" src="/tests/SimpleTest/AddTask.js"></script>
+  <script src="payments_common.js"></script>
+  <script src="../../res/vendor/custom-elements.min.js"></script>
+
+  <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+  <p id="display">
+    <labelled-checkbox id="box0"></labelled-checkbox>
+    <labelled-checkbox id="box1" label="the label" value="the value"></labelled-checkbox>
+  </p>
+<div id="content" style="display: none">
+
+</div>
+<pre id="test">
+</pre>
+<script type="module">
+/** Test the labelled-checkbox component **/
+
+/* import-globals-from payments_common.js */
+import "../../res/components/labelled-checkbox.js";
+
+let box0 = document.getElementById("box0");
+let box1 = document.getElementById("box1");
+
+add_task(async function test_no_values() {
+  ok(box0, "box0 exists");
+  is(box0.label, null, "Initially un-labelled");
+  is(box0.value, null, "Check .value");
+  ok(!box0.checked, "Initially is not checked");
+  ok(!box0.querySelector("input:checked"), "has no checked inner input");
+
+  box0.checked = true;
+  box0.value = "New value";
+  box0.label = "New label";
+
+  await asyncElementRendered();
+
+  ok(box0.checked, "Becomes checked");
+  ok(box0.querySelector("input:checked"), "has a checked inner input");
+  is(box0.getAttribute("label"), "New label", "Assigned label");
+  is(box0.getAttribute("value"), "New value", "Assigned value");
+});
+
+add_task(async function test_initial_values() {
+  is(box1.label, "the label", "Initial label");
+  is(box1.value, "the value", "Initial value");
+  ok(!box1.checked, "Initially unchecked");
+  ok(!box1.querySelector("input:checked"), "has no checked inner input");
+
+  box1.checked = false;
+  box1.value = "New value";
+  box1.label = "New label";
+
+  await asyncElementRendered();
+
+  ok(!box1.checked, "Checked property remains falsey");
+  is(box1.getAttribute("value"), "New value", "Assigned value");
+  is(box1.getAttribute("label"), "New label", "Assigned label");
+});
+
+</script>
+
+</body>
+</html>
--- a/toolkit/components/payments/test/mochitest/test_payment_method_picker.html
+++ b/toolkit/components/payments/test/mochitest/test_payment_method_picker.html
@@ -6,16 +6,17 @@ Test the payment-method-picker component
 <head>
   <meta charset="utf-8">
   <title>Test the payment-method-picker component</title>
   <script type="application/javascript" src="/tests/SimpleTest/SimpleTest.js"></script>
   <script type="application/javascript" src="/tests/SimpleTest/AddTask.js"></script>
   <script type="application/javascript" src="/tests/SimpleTest/EventUtils.js"></script>
   <script src="payments_common.js"></script>
   <script src="../../res/vendor/custom-elements.min.js"></script>
+  <script src="../../res/unprivileged-fallbacks.js"></script>
 
   <link rel="stylesheet" type="text/css" href="../../res/components/rich-select.css"/>
   <link rel="stylesheet" type="text/css" href="../../res/components/basic-card-option.css"/>
   <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
 </head>
 <body>
   <p id="display">
     <payment-method-picker id="picker1"