Bug 1428415 Add a checkbox for persisting new cards to the Add Payment Card screen. r=MattN
authorSam Foster <sfoster@mozilla.com>
Mon, 09 Apr 2018 16:15:15 -0700
changeset 471688 c6711b0df64123f07eae00340c9d5a1844358a34
parent 471687 c40209504a45529f23a2722a16a012424eb01ed6
child 471689 0b8f0217bda4c2368d7c02016b3d6389629ba7af
push id1728
push userjlund@mozilla.com
push dateMon, 18 Jun 2018 21:12:27 +0000
treeherdermozilla-release@c296fde26f5f [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersMattN
bugs1428415, 1427939
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 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"